From 0cf9be2c62065fa4e1486092d2b593ca5f04ddb8 Mon Sep 17 00:00:00 2001 From: John Pals <7024725+DigitalPals@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:45:59 +0200 Subject: [PATCH 01/11] Replace Fedora provisioning with native NixOS lifecycle Use shared NixOS modules for fresh ISO installation, machine configuration, applications and desktop policy. Add authenticated exact-source installation, independent input locks, staged updates, trial boot, confirmation and recovery while preserving personal configuration and state. Remove the Fedora/Ansible provisioning and RPM update paths. Add native build, parity, installation, recovery and signed-update coverage, release tooling and documentation. Source29 passed source/build/image parity and all five isolated VM qualification roles. Physical hardware, Secure Boot, MAC and public-release blockers remain documented. --- .ansible-lint | 5 - .github/ISSUE_TEMPLATE/config.yml | 6 +- .github/actionlint.yaml | 3 + .../fedora-source-environment/action.yml | 121 -- .../qualified_packages.py | 53 - .github/pull_request_template.md | 44 +- .github/workflows/desktop-release.yml | 213 -- .github/workflows/fedora-preview.yml | 73 - .github/workflows/fedora-vm-convergence.yml | 28 - .github/workflows/live-image.yml | 41 - .github/workflows/nixos.yml | 91 + .github/workflows/promote.yml | 75 + .github/workflows/release.yml | 351 +-- .github/workflows/tests.yml | 55 - .yamllint | 15 - AGENTS.md | 62 +- CONTRIBUTING.md | 183 +- README.md | 420 +--- SECURITY.md | 122 +- VERSION | 2 +- agent-skills/cybexos/SKILL.md | 105 +- agent-skills/cybexos/references/commands.md | 153 +- .../references/managed-configuration.md | 121 +- .../cybexos/references/quickshell-settings.md | 18 +- .../cybexos/references/user-widgets.md | 20 +- ansible.cfg | 28 - assets/distribution-review.json | 13 +- .../scripts/brightness-control | 2 +- assets/scripts/cybexos-agent | 4 +- assets/scripts/cybexos-backup | 5 +- assets/scripts/cybexos-firmware-update | 50 +- assets/scripts/cybexos-migrate-layering | 253 --- assets/scripts/cybexos-release-update | 521 ----- assets/scripts/cybexos-report | 101 +- assets/scripts/cybexos-runtime | 41 +- assets/scripts/cybexos-update-run | 1760 --------------- assets/scripts/cybexos-webapp | 21 +- .../scripts/external-monitor-toggle | 0 .../scripts/fastfetch-link-speed | 0 assets/scripts/screen-record | 9 +- assets/scripts/screenshot | 2 +- bootstrap | 4 +- desktop/bin/cybexos-session | 9 + .../bin}/cybexos-session-action | 0 desktop/bin/cybexos-session-start | 5 + .../defaults}/fastfetch.jsonc | 4 +- .../defaults}/fish-config.fish | 15 +- .../files => desktop/defaults}/gitconfig | 4 +- .../files => desktop/defaults}/kitty.conf | 2 +- .../files => desktop/defaults}/ssh.conf | 0 .../files => desktop/defaults}/voxtype.toml | 0 desktop/fonts.json | 213 ++ .../cybex_hermes/__init__.py | 0 .../cybex_hermes/auth.py | 0 .../cybex_hermes/gateway.py | 0 .../cybex_hermes/protocol.py | 0 .../cybex_hermes/registry.py | 0 .../hermes-menubar-bridge/hermes_bridge.py | 39 +- desktop/hypr/autostart.lua | 3 + .../files => desktop/hypr}/bindings.lua | 66 +- .../files => desktop/hypr}/displays.lua | 0 desktop/hypr/features.lua | 6 + .../hypr/hypridle.conf | 6 +- .../files => desktop/hypr}/hyprland.lua | 6 +- .../hypr/hyprlock.conf | 0 .../input.lua.j2 => desktop/hypr/input.lua | 6 +- .../hypr}/input_preferences.lua | 0 .../hypr/looknfeel.lua | 6 +- desktop/hypr/monitors.lua | 15 + .../hypr}/titlebars-native.patch | 12 +- .../hypr}/workspace_layouts.lua | 0 .../hypr}/workspace_model.lua | 0 .../hypr}/workspace_state.lua | 0 .../files => desktop/hypr}/workspaces.lua | 25 +- .../notification-images/CMakeLists.txt | 0 .../notification-images/LICENSE | 0 .../notification-images/build.py | 0 .../notification-images/imagefingerprints.cpp | 0 .../notification-images/imagefingerprints.h | 0 .../notification-images/plugin.cpp | 0 .../notification-images/plugins.qmltypes | 0 .../notification-images/qmldir | 0 .../notification-images/tests.cpp | 0 .../files => desktop/plymouth}/bullet.png | Bin .../files => desktop/plymouth}/cybex.plymouth | 0 .../files => desktop/plymouth}/cybex.script | 0 .../boot/files => desktop/plymouth}/entry.png | Bin .../boot/files => desktop/plymouth}/logo.png | Bin .../plymouth}/progress_bar.png | Bin .../plymouth}/progress_box.png | Bin .../files => desktop}/quickshell/.qmllint.ini | 0 .../files => desktop}/quickshell/Bar/Bar.qml | 2 +- .../quickshell/Bar/BarBrandIcon.qml | 0 .../quickshell/Bar/BarChip.qml | 0 .../quickshell/Bar/BarHover.qml | 0 .../quickshell/Bar/BarIcon.qml | 0 .../quickshell/Bar/BarPopoutWindow.qml | 0 .../quickshell/Bar/BarTooltip.qml | 0 .../quickshell/Bar/Cluster.qml | 0 .../quickshell/Bar/Divider.qml | 0 .../quickshell/Bar/HermesChip.qml | 0 .../quickshell/Bar/ModuleSlot.qml | 0 .../quickshell/Bar/Modules/BarModule.qml | 0 .../quickshell/Bar/Modules/Battery.qml | 0 .../quickshell/Bar/Modules/Bluetooth.qml | 0 .../quickshell/Bar/Modules/Clock.qml | 0 .../quickshell/Bar/Modules/Control.qml | 2 +- .../quickshell/Bar/Modules/Fusebox.qml | 0 .../quickshell/Bar/Modules/GitHub.qml | 0 .../quickshell/Bar/Modules/Hermes.qml | 0 .../quickshell/Bar/Modules/Indicators.qml | 2 +- .../quickshell/Bar/Modules/Media.qml | 0 .../quickshell/Bar/Modules/ModelUsage.qml | 0 .../quickshell/Bar/Modules/Notes.qml | 0 .../quickshell/Bar/Modules/Notifications.qml | 0 .../quickshell/Bar/Modules/Remote.qml | 0 .../quickshell/Bar/Modules/T3.qml | 0 .../quickshell/Bar/Modules/Tray.qml | 0 .../quickshell/Bar/Modules/Updates.qml | 0 .../quickshell/Bar/Modules/Volume.qml | 0 .../quickshell/Bar/Modules/Weather.qml | 0 .../quickshell/Bar/Modules/Wifi.qml | 0 .../quickshell/Bar/Modules/Workspaces.qml | 0 .../quickshell/Bar/Modules/qmldir | 0 .../quickshell/Bar/PointerCheck.qml | 0 .../quickshell/Bar/PopoutHost.qml | 0 .../quickshell/Bar/T3Chip.qml | 0 .../quickshell/Bar/UserWidgets.qml | 0 .../quickshell/Bar/Workspaces.qml | 0 .../files => desktop}/quickshell/Bar/qmldir | 0 .../quickshell/Common/Activity.qml | 0 .../quickshell/Common/AnimatedText.qml | 0 .../quickshell/Common/Audio.qml | 0 .../quickshell/Common/AudioHelpers.js | 0 .../quickshell/Common/Backup.qml | 0 .../quickshell/Common/BackupHelpers.js | 2 +- .../quickshell/Common/BarGeometry.js | 0 .../quickshell/Common/BarOverflow.qml | 0 .../quickshell/Common/Battery.qml | 0 .../quickshell/Common/BatteryHealth.qml | 0 .../quickshell/Common/BatteryViewHelpers.js | 0 .../quickshell/Common/BluetoothHelpers.js | 0 .../quickshell/Common/BluetoothState.qml | 0 .../quickshell/Common/BrandIcon.qml | 0 .../quickshell/Common/BrandIcons.qml | 8 +- .../quickshell/Common/Calendar.qml | 0 .../quickshell/Common/CalendarHelpers.js | 0 .../quickshell/Common/CaptureHelpers.js | 0 .../quickshell/Common/Claim.qml | 0 .../quickshell/Common/CommandRequest.qml | 0 .../quickshell/Common/CountdownHelpers.js | 0 .../quickshell/Common/DesktopWorkspaces.qml | 0 .../quickshell/Common/Dictation.qml | 0 .../quickshell/Common/DisplayHelpers.js | 0 .../quickshell/Common/DisplaySettings.qml | 0 .../quickshell/Common/Dock.qml | 2 +- .../quickshell/Common/DockHelpers.js | 0 .../quickshell/Common/EthernetHelpers.js | 0 .../quickshell/Common/EthernetState.qml | 0 .../quickshell/Common/ExternalUrl.js | 0 .../quickshell/Common/Format.js | 0 .../quickshell/Common/Fusebox.qml | 0 .../quickshell/Common/FuseboxHelpers.js | 0 .../quickshell/Common/GameMode.qml | 4 +- .../quickshell/Common/GameModeState.js | 0 .../quickshell/Common/GitHub.qml | 0 .../quickshell/Common/GitHubHelpers.js | 0 .../quickshell/Common/GitHubQueue.js | 0 .../quickshell/Common/HSlider.qml | 0 .../quickshell/Common/Hermes.qml | 2 +- .../quickshell/Common/HermesConnection.qml | 0 .../quickshell/Common/HermesConversations.qml | 0 .../quickshell/Common/HermesHelpers.js | 0 .../quickshell/Common/HermesRpc.qml | 0 .../quickshell/Common/HermesSocket.qml | 0 .../quickshell/Common/HermesTheme.qml | 0 .../quickshell/Common/HugCorner.qml | 0 .../quickshell/Common/I18n.qml | 0 .../quickshell/Common/I18nHelpers.js | 0 .../quickshell/Common/KeybindHelpers.js | 0 .../quickshell/Common/Launcher.qml | 0 .../quickshell/Common/LauncherNavigation.js | 0 .../quickshell/Common/LauncherProviders.js | 54 +- .../quickshell/Common/LauncherProviders.qml | 8 +- .../quickshell/Common/LayoutHelpers.js | 0 .../Common/Location/LocationSearch.qml | 0 .../quickshell/Common/Location/qmldir | 0 .../quickshell/Common/LocationHelpers.js | 0 .../quickshell/Common/Media.qml | 0 .../quickshell/Common/ModelUsageHub.qml | 0 .../quickshell/Common/NetworkDetails.qml | 0 .../Common/NetworkDetailsHelpers.js | 0 .../quickshell/Common/NetworkHelpers.js | 0 .../quickshell/Common/NetworkOverlayState.qml | 0 .../quickshell/Common/NetworkStatus.qml | 0 .../quickshell/Common/NetworkStatusHelpers.js | 0 .../quickshell/Common/Notes.qml | 0 .../quickshell/Common/NotesHelpers.js | 0 .../quickshell/Common/NotifActions.qml | 0 .../quickshell/Common/NotifButton.qml | 0 .../quickshell/Common/NotifCard.qml | 0 .../quickshell/Common/NotifHelpers.js | 0 .../quickshell/Common/NotifHolds.js | 0 .../quickshell/Common/NotifIcon.qml | 0 .../Common/NotifImageFingerprint.qml | 0 .../quickshell/Common/NotifPolicy.js | 0 .../quickshell/Common/NotifSmart.js | 0 .../quickshell/Common/NotifSnoozes.qml | 0 .../quickshell/Common/Notifs.qml | 0 .../quickshell/Common/OmarchyApplications.qml | 0 .../quickshell/Common/OmarchyBarApi.qml | 0 .../quickshell/Common/OmarchyEntryHost.qml | 0 .../quickshell/Common/OmarchyPluginApi.qml | 0 .../quickshell/Common/OmarchyPlugins.qml | 5 +- .../quickshell/Common/OmarchyServiceApi.qml | 0 .../quickshell/Common/OmarchyTheme.js | 0 .../quickshell/Common/OnlineWallpapers.qml | 0 .../quickshell/Common/Osd.qml | 0 .../quickshell/Common/Overview.qml | 0 .../quickshell/Common/OverviewHelpers.js | 0 .../quickshell/Common/Palette.qml | 0 .../quickshell/Common/PaletteHelpers.js | 0 .../quickshell/Common/PanelRegistryData.js | 0 .../Common/Persistence/SettingsDocument.qml | 0 .../quickshell/Common/Persistence/qmldir | 0 .../quickshell/Common/PluginSchema.js | 0 .../quickshell/Common/PolkitHelpers.js | 0 .../quickshell/Common/Popouts.qml | 0 .../quickshell/Common/ProcHelpers.js | 0 .../quickshell/Common/ProcessList.qml | 0 .../quickshell/Common/ProcessListHelpers.js | 0 .../quickshell/Common/Recorder.qml | 8 +- desktop/quickshell/Common/Recovery.qml | 6 + .../quickshell/Common/Reminders.qml | 2 +- .../quickshell/Common/RemoteServer.qml | 0 .../quickshell/Common/RemoteServerHelpers.js | 0 .../quickshell/Common/Revealer.qml | 0 .../quickshell/Common/Screens.qml | 0 .../quickshell/Common/ScrollChrome.qml | 0 .../quickshell/Common/SecurityHelpers.js | 6 +- .../quickshell/Common/Session.qml | 2 +- .../quickshell/Common/Settings.qml | 0 .../quickshell/Common/SettingsHelpers.js | 6 +- .../quickshell/Common/SettingsSearchData.js | 4 +- .../quickshell/Common/ShellHealth.qml | 2 +- .../quickshell/Common/ShellMetrics.js | 0 .../quickshell/Common/SocketContract.qml | 0 .../quickshell/Common/Startup.qml | 0 .../quickshell/Common/StateLayer.qml | 0 .../quickshell/Common/StatusHelpers.js | 0 .../quickshell/Common/StatusPlaceholder.qml | 0 .../quickshell/Common/Sym.qml | 0 .../quickshell/Common/SysInfo.qml | 2 +- .../quickshell/Common/SysInfoHelpers.js | 0 .../quickshell/Common/SystemSettings.qml | 0 .../Common/SystemSettingsBackend.qml | 0 .../quickshell/Common/SystemTheme.qml | 0 .../quickshell/Common/T3Actions.qml | 0 .../quickshell/Common/T3Code.qml | 0 .../quickshell/Common/T3CodeHelpers.js | 0 .../quickshell/Common/T3Connection.qml | 0 .../quickshell/Common/T3Detail.qml | 0 .../quickshell/Common/T3Drafts.qml | 0 .../quickshell/Common/T3Favorites.qml | 0 .../quickshell/Common/T3Git.qml | 0 .../quickshell/Common/T3Rpc.qml | 0 .../quickshell/Common/T3Socket.qml | 2 +- .../quickshell/Common/T3Theme.qml | 0 .../quickshell/Common/T3Threads.qml | 0 .../quickshell/Common/TablerGlyphs.js | 0 .../quickshell/Common/TablerIcons.qml | 0 .../quickshell/Common/Tailscale.qml | 0 .../quickshell/Common/TailscaleHelpers.js | 4 +- .../quickshell/Common/Theme.qml | 11 +- .../quickshell/Common/ThemeHelpers.js | 0 .../quickshell/Common/Themes.qml | 0 .../quickshell/Common/Toggle.qml | 0 .../quickshell/Common/Typography.js | 0 desktop/quickshell/Common/Updates.qml | 70 + desktop/quickshell/Common/UpdatesHelpers.js | 7 + .../quickshell/Common/UserPlugins.qml | 1 + .../quickshell/Common/UserWidgetHost.qml | 0 .../quickshell/Common/WallhavenHelpers.js | 0 .../quickshell/Common/Wallpaper.qml | 0 .../quickshell/Common/Weather.qml | 0 .../quickshell/Common/WebIcons.qml | 0 .../quickshell/Common/WidgetCatalog.js | 2 +- .../quickshell/Common/WidgetEditor.js | 0 .../quickshell/Common/WifiState.qml | 7 +- .../quickshell/Common/WindowCommands.js | 0 .../quickshell/Common/WorkspaceHelpers.js | 0 .../quickshell/Common/WorkspaceMotion.js | 0 .../quickshell/Common/i18n/nl.js | 43 +- .../quickshell/Common/qmldir | 1 - .../quickshell/Commons/Border.qml | 0 .../quickshell/Commons/BorderGeometry.js | 0 .../quickshell/Commons/Color.qml | 0 .../quickshell/Commons/Style.qml | 0 .../quickshell/Commons/Util.qml | 0 .../quickshell/Commons/qmldir | 0 .../quickshell/DockWindow.qml | 0 .../quickshell/LauncherView.qml | 0 .../quickshell/LauncherWindow.qml | 0 .../quickshell/ModelUsage/BlockMeter.qml | 0 .../quickshell/ModelUsage/CostBackend.qml | 0 .../quickshell/ModelUsage/CostPriceEditor.qml | 0 .../quickshell/ModelUsage/CostSettings.qml | 0 .../quickshell/ModelUsage/LICENSE | 0 .../quickshell/ModelUsage/Panel.qml | 0 .../quickshell/ModelUsage/README.md | 2 +- .../quickshell/ModelUsage/ResetBackend.qml | 0 .../ModelUsage/ResetConfirmation.qml | 0 .../quickshell/ModelUsage/SettingsActions.qml | 0 .../ModelUsage/SettingsCredential.qml | 0 .../quickshell/ModelUsage/SettingsField.qml | 0 .../quickshell/ModelUsage/SettingsHeader.qml | 0 .../quickshell/ModelUsage/SettingsSection.qml | 0 .../ModelUsage/THIRD_PARTY_NOTICES.md | 6 +- .../ModelUsage/UsageActivityBackend.qml | 0 .../quickshell/ModelUsage/UsageBackend.qml | 0 .../quickshell/ModelUsage/UsageCosts.qml | 0 .../quickshell/ModelUsage/UsageDisclosure.qml | 0 .../quickshell/ModelUsage/UsageHistory.qml | 0 .../ModelUsage/UsageLiveConnection.qml | 0 .../quickshell/ModelUsage/UsageLogic.js | 0 .../quickshell/ModelUsage/UsageNavigation.qml | 0 .../quickshell/ModelUsage/UsageSelect.qml | 0 .../quickshell/ModelUsage/UsageSettings.qml | 0 .../ModelUsage/UsageSharedActivityBackend.qml | 0 .../ModelUsage/UsageSharedBackend.qml | 0 .../quickshell/ModelUsage/UsageTabs.qml | 0 .../ModelUsage/assets/claude-bar.svg | 0 .../quickshell/ModelUsage/assets/claude.svg | 0 .../ModelUsage/assets/codex-bar.svg | 0 .../ModelUsage/assets/codex-light.svg | 0 .../quickshell/ModelUsage/assets/codex.svg | 0 .../quickshell/ModelUsage/assets/kimi-bar.svg | 0 .../quickshell/ModelUsage/assets/kimi.svg | 0 .../ModelUsage/scripts/cost-fetch.py | 0 .../ModelUsage/scripts/keeper_client.py | 0 .../ModelUsage/scripts/management-key.py | 0 .../ModelUsage/scripts/model_usage_common.py | 0 .../ModelUsage/scripts/proxy-activity.py | 0 .../ModelUsage/scripts/proxy-live.py | 0 .../ModelUsage/scripts/reset-credit.py | 0 .../quickshell/ModelUsage/scripts/t3_costs.py | 0 .../ModelUsage/scripts/usage-fetch.py | 0 .../quickshell/NetworkOverlayWindow.qml | 0 .../quickshell/NotificationToasts.qml | 0 .../quickshell/OsdWindow.qml | 0 .../quickshell/OverviewView.qml | 0 .../quickshell/OverviewWindow.qml | 0 .../quickshell/PolkitPrompt.qml | 0 .../quickshell/PolkitWindow.qml | 0 .../quickshell/Popovers/ActionButton.qml | 0 .../quickshell/Popovers/AudioPopover.qml | 0 .../quickshell/Popovers/BatteryPopover.qml | 0 .../quickshell/Popovers/BlockMeter.qml | 0 .../quickshell/Popovers/BluetoothPopover.qml | 0 .../quickshell/Popovers/CalendarPopover.qml | 0 .../Popovers/ControlCenterPopover.qml | 4 +- .../quickshell/Popovers/DaySheetPopover.qml | 0 .../Popovers/Drawer/DrawerBluetooth.qml | 0 .../Popovers/Drawer/DrawerFooter.qml | 0 .../Popovers/Drawer/DrawerIconButton.qml | 0 .../Popovers/Drawer/DrawerNetwork.qml | 0 .../Popovers/Drawer/DrawerNotifications.qml | 0 .../Popovers/Drawer/DrawerOverview.qml | 6 +- .../Popovers/Drawer/DrawerPopover.qml | 0 .../Popovers/Drawer/DrawerPower.qml | 0 .../Popovers/Drawer/DrawerProcesses.qml | 0 .../Popovers/Drawer/DrawerSliderRow.qml | 0 .../Popovers/Drawer/DrawerSound.qml | 0 .../Popovers/Drawer/DrawerSystemHero.qml | 12 +- .../quickshell/Popovers/Drawer/DrawerTabs.qml | 0 .../quickshell/Popovers/Drawer/DrawerTile.qml | 0 .../quickshell/Popovers/Drawer/qmldir | 0 .../quickshell/Popovers/FillSlider.qml | 0 .../quickshell/Popovers/FuseboxPopover.qml | 0 .../quickshell/Popovers/GitHubPopover.qml | 0 .../quickshell/Popovers/HDivider.qml | 0 .../quickshell/Popovers/HermesAuthPanel.qml | 0 .../quickshell/Popovers/HermesBarControl.qml | 0 .../quickshell/Popovers/HermesComposer.qml | 0 .../quickshell/Popovers/HermesInboxPage.qml | 0 .../Popovers/HermesInlineSelect.qml | 0 .../quickshell/Popovers/HermesModelPicker.qml | 0 .../quickshell/Popovers/HermesPopover.qml | 0 .../quickshell/Popovers/HermesRequestCard.qml | 0 .../Popovers/HermesSessionStrip.qml | 0 .../quickshell/Popovers/HermesToolCard.qml | 0 .../quickshell/Popovers/HermesTranscript.qml | 0 .../quickshell/Popovers/IconButton.qml | 0 .../quickshell/Popovers/LinkText.qml | 0 .../quickshell/Popovers/MediaPopover.qml | 0 .../quickshell/Popovers/NotesPopover.qml | 0 .../quickshell/Popovers/NotifsPopover.qml | 0 .../quickshell/Popovers/OverflowPopover.qml | 0 .../quickshell/Popovers/PopoutPanel.qml | 0 .../quickshell/Popovers/ReminderPopover.qml | 0 .../Popovers/RemoteServerPopover.qml | 0 .../quickshell/Popovers/SectionLabel.qml | 0 .../quickshell/Popovers/Surface.qml | 0 .../quickshell/Popovers/T3BarControl.qml | 0 .../quickshell/Popovers/T3CodePopover.qml | 0 .../quickshell/Popovers/T3Composer.qml | 0 .../quickshell/Popovers/T3InboxPage.qml | 0 .../quickshell/Popovers/T3InlineSelect.qml | 0 .../quickshell/Popovers/T3ModelPicker.qml | 0 .../quickshell/Popovers/T3NewThreadPage.qml | 0 .../quickshell/Popovers/T3Picker.qml | 0 .../quickshell/Popovers/T3RequestCard.qml | 0 .../quickshell/Popovers/T3ThreadPage.qml | 0 .../quickshell/Popovers/TailscalePopover.qml | 0 .../quickshell/Popovers/UpdatesPopover.qml | 100 + .../quickshell/Popovers/WeatherPopover.qml | 0 .../quickshell/Popovers/WifiPopover.qml | 0 .../quickshell/Popovers/qmldir | 0 .../quickshell/Settings/AboutPage.qml | 0 .../quickshell/Settings/AccountsPage.qml | 0 .../quickshell/Settings/ActionRow.qml | 0 .../quickshell/Settings/AppearancePage.qml | 0 .../quickshell/Settings/ApplyBar.qml | 0 .../quickshell/Settings/BackupPage.qml | 1 - .../Settings/BarBackgroundGroup.qml | 0 .../quickshell/Settings/BarLayoutGroups.qml | 0 .../quickshell/Settings/BarPreview.qml | 0 .../quickshell/Settings/ChipToggleRow.qml | 0 .../quickshell/Settings/ChoiceRow.qml | 0 .../quickshell/Settings/CornerPickerRow.qml | 0 .../Settings/DisplayArrangement.qml | 0 .../quickshell/Settings/DisplaysPage.qml | 0 .../quickshell/Settings/DraftFieldRow.qml | 0 .../quickshell/Settings/DrawerPage.qml | 2 +- .../quickshell/Settings/FieldRow.qml | 0 .../quickshell/Settings/FolderDialog.qml | 0 .../quickshell/Settings/GitHubWatchList.qml | 0 .../quickshell/Settings/HotspotGroup.qml | 0 .../quickshell/Settings/IdleTimeline.qml | 0 .../quickshell/Settings/IdleTimelineModel.js | 0 .../quickshell/Settings/InputDraft.js | 0 .../quickshell/Settings/InputDraftState.qml | 0 .../quickshell/Settings/InputDrafts.qml | 0 .../quickshell/Settings/IpSettings.qml | 0 .../quickshell/Settings/KeyboardPage.qml | 0 .../quickshell/Settings/LocalTime.js | 0 .../quickshell/Settings/ModuleDetailView.qml | 36 +- .../quickshell/Settings/ModulesPage.qml | 0 .../quickshell/Settings/MuteButton.qml | 0 .../quickshell/Settings/NetworkPage.qml | 0 .../quickshell/Settings/NightLightGroup.qml | 0 .../quickshell/Settings/NotificationsPage.qml | 0 .../quickshell/Settings/PickerRow.qml | 0 .../quickshell/Settings/PillRow.qml | 0 .../quickshell/Settings/PluginAppearance.qml | 0 .../quickshell/Settings/PluginRow.qml | 0 .../quickshell/Settings/PluginSource.js | 0 .../Settings/PluginWidgetSettings.qml | 0 .../quickshell/Settings/PluginsPage.qml | 0 .../quickshell/Settings/PowerPage.qml | 0 desktop/quickshell/Settings/RecoveryGroup.qml | 15 + .../quickshell/Settings/RegionPage.qml | 2 +- .../Settings/ResponsiveActionRow.qml | 0 .../quickshell/Settings/RowCluster.qml | 0 .../quickshell/Settings/SectionHeader.qml | 0 .../quickshell/Settings/SecurityPage.qml | 20 +- .../quickshell/Settings/SelectRow.qml | 0 .../quickshell/Settings/SettingsAction.qml | 0 .../Settings/SettingsDisclosure.qml | 0 .../quickshell/Settings/SettingsField.qml | 0 .../quickshell/Settings/SettingsGroup.qml | 0 .../quickshell/Settings/SettingsHint.qml | 0 .../quickshell/Settings/SettingsPage.qml | 0 .../quickshell/Settings/SettingsRow.qml | 0 .../quickshell/Settings/SettingsSelect.qml | 0 .../Settings/SettingsSubsection.qml | 0 .../quickshell/Settings/SettingsTextRow.qml | 0 .../quickshell/Settings/SettingsTooltip.qml | 0 .../quickshell/Settings/SettingsView.qml | 0 .../quickshell/Settings/SliderRow.qml | 0 .../quickshell/Settings/SoundPage.qml | 0 .../quickshell/Settings/StreamRow.qml | 0 .../quickshell/Settings/SwitchRow.qml | 0 .../quickshell/Settings/SystemChoice.qml | 0 .../Settings/SystemServiceStatus.qml | 0 .../quickshell/Settings/TextSizePreview.qml | 0 .../quickshell/Settings/TimeRow.qml | 0 .../quickshell/Settings/TouchpadPage.qml | 0 .../quickshell/Settings/UndoChip.qml | 0 .../quickshell/Settings/ValueRow.qml | 0 .../quickshell/Settings/VolumeRow.qml | 0 .../quickshell/Settings/VpnGroup.qml | 0 .../Settings/WallpaperOnlineView.qml | 0 .../quickshell/Settings/WallpaperPage.qml | 0 .../Settings/WeatherLocationPicker.qml | 0 .../quickshell/Settings/WidgetEditorRow.qml | 0 .../quickshell/Settings/WidgetPill.qml | 0 .../quickshell/Settings/WorkspacesPage.qml | 0 .../quickshell/Settings/qmldir | 1 - .../quickshell/SettingsWindow.qml | 0 .../quickshell/ShortcutsOverlay.qml | 0 .../quickshell/Ui/BackgroundMedia.qml | 0 .../quickshell/Ui/BackgroundVideo.qml | 0 .../quickshell/Ui/BarIconButton.qml | 0 .../quickshell/Ui/BarIndicator.qml | 0 .../quickshell/Ui/BarWidget.qml | 0 .../quickshell/Ui/BorderOverlay.qml | 0 .../quickshell/Ui/BorderSurface.qml | 0 .../quickshell/Ui/Button.qml | 0 .../quickshell/Ui/ButtonGroup.qml | 0 .../quickshell/Ui/ConfirmDialog.qml | 0 .../quickshell/Ui/CursorSurface.qml | 0 .../quickshell/Ui/Dropdown.qml | 0 .../quickshell/Ui/KeyboardPanel.qml | 0 .../quickshell/Ui/MultiSelect.qml | 0 .../quickshell/Ui/NumberField.qml | 0 .../quickshell/Ui/OpticalGlyph.qml | 0 .../files => desktop}/quickshell/Ui/Panel.qml | 0 .../quickshell/Ui/PanelActionButton.qml | 0 .../quickshell/Ui/PanelController.qml | 0 .../quickshell/Ui/PanelHero.qml | 0 .../quickshell/Ui/PanelKeyCatcher.qml | 0 .../quickshell/Ui/PanelSectionHeader.qml | 0 .../quickshell/Ui/PanelSeparator.qml | 0 .../quickshell/Ui/PanelSlider.qml | 0 .../quickshell/Ui/PanelToolTip.qml | 0 .../quickshell/Ui/PluginBarApi.qml | 0 .../quickshell/Ui/PointerMoveGate.qml | 0 .../quickshell/Ui/PopupCard.qml | 0 .../quickshell/Ui/ScreenMoveRemap.qml | 0 .../quickshell/Ui/SearchableDropdown.qml | 0 .../quickshell/Ui/SpeedTestOverlay.qml | 0 .../quickshell/Ui/TelemetryChart.qml | 0 .../quickshell/Ui/TextField.qml | 0 .../quickshell/Ui/Toggle.qml | 0 .../quickshell/Ui/ToggleSwitch.qml | 0 .../quickshell/Ui/WidgetButton.qml | 0 .../files => desktop}/quickshell/Ui/qmldir | 0 .../quickshell/WebcamOverlay.qml | 0 .../quickshell/assets/README.md | 5 +- .../quickshell/assets/THIRD_PARTY_LICENSES.md | 19 +- .../quickshell/assets/claude-white.svg | 0 .../quickshell/assets/claude.svg | 0 desktop/quickshell/assets/cybexos-white.svg | 1 + desktop/quickshell/assets/cybexos.svg | 1 + .../quickshell/assets/fusebox-white.svg | 0 .../quickshell/assets/fusebox.svg | 0 .../quickshell/assets/gemini-white.svg | 0 .../quickshell/assets/gemini.svg | 0 .../quickshell/assets/github-white.svg | 0 .../quickshell/assets/github.svg | 0 .../quickshell/assets/grok-white.svg | 0 .../quickshell/assets/grok.svg | 0 .../quickshell/assets/kimi-white.svg | 0 .../quickshell/assets/kimi.svg | 0 .../quickshell/assets/openai-white.svg | 0 .../quickshell/assets/openai.svg | 0 .../quickshell/assets/slack-white.svg | 0 .../quickshell/assets/slack.svg | 0 .../quickshell/assets/t3.svg | 0 .../quickshell/assets/tabler/LICENSE | 0 .../quickshell/assets/tabler/README.md | 0 .../quickshell/assets/tabler/aliases.json | 0 .../quickshell/assets/tabler/outline.ttf | Bin .../quickshell/assets/tailscale-white.svg | 0 .../quickshell/assets/tailscale.svg | 0 .../quickshell/assets/whatsapp-white.svg | 0 .../quickshell/assets/whatsapp.svg | 0 .../quickshell/assets/youtube-white.svg | 0 .../quickshell/assets/youtube.svg | 0 .../quickshell/compat/omarchy/LICENSE | 0 .../quickshell/compat/omarchy/README.md | 0 .../omarchy/bin/omarchy-notification-send | 2 +- .../compat/omarchy/bin/omarchy-shell | 2 +- .../quickshell/launcher-actions.json | 0 .../files => desktop}/quickshell/qmldir | 0 .../quickshell/safe-mode/shell.qml | 0 .../quickshell/scripts/audio-route | 0 .../quickshell/scripts/audio-source-route | 0 .../quickshell/scripts/battery-health | 0 .../quickshell/scripts/bluetooth-tool.py | 0 .../quickshell/scripts/calendar-events.py | 0 .../quickshell/scripts/clipboard-tool.py | 0 .../quickshell/scripts/display-settings.py | 0 .../quickshell/scripts/fusebox.py | 0 .../quickshell/scripts/hypridle-config.py | 0 .../quickshell/scripts/network-speedtest.py | 0 .../quickshell/scripts/network-tool.py | 0 .../quickshell/scripts/network_vpn_agent.py | 0 .../quickshell/scripts/note-title.py | 0 .../scripts/notification-history.py | 0 .../quickshell/scripts/notification-icon.py | 0 .../quickshell/scripts/plugin_packages.py | 0 .../quickshell/scripts/processes | 0 .../quickshell/scripts/remote-server.py | 0 .../quickshell/scripts/remote_server_probe.py | 0 .../quickshell/scripts/settings-store | 0 .../quickshell/scripts/shell-health.py | 0 .../quickshell/scripts/shell-recovery.py | 2 +- .../quickshell/scripts/system-settings.py | 0 .../scripts/system_settings_accounts.py | 0 .../scripts/system_settings_audio.py | 0 .../scripts/system_settings_input.py | 0 .../scripts/system_settings_network.py | 0 .../scripts/system_settings_region.py | 23 +- .../scripts/system_settings_security.py | 45 +- .../quickshell/scripts/t3-cloud.mjs | 0 .../quickshell/scripts/theme-apply.py | 0 .../quickshell/scripts/theme-packs.py | 0 .../quickshell/scripts/theme_gtk.py | 2 +- .../quickshell/scripts/theme_hyprland.py | 0 .../quickshell/scripts/theme_hyprlock.py | 0 .../quickshell/scripts/theme_kitty.py | 4 +- .../quickshell/scripts/theme_packs.py | 0 .../quickshell/scripts/theme_tokens.py | 0 .../quickshell/scripts/user-plugins.py | 4 +- .../quickshell/scripts/wallpaper-download.py | 0 .../quickshell/scripts/wallpaper-thumbnail.py | 0 .../files => desktop}/quickshell/shell.qml | 0 .../themes/catppuccin-latte/theme.json | 0 .../themes/catppuccin-mocha/theme.json | 0 .../quickshell/themes/cybexos/theme.json | 0 .../quickshell/themes/everforest/theme.json | 0 .../quickshell/themes/gruvbox/theme.json | 0 .../quickshell/themes/kanagawa/theme.json | 0 .../quickshell/themes/nord/theme.json | 0 .../quickshell/themes/rose-pine/theme.json | 0 .../quickshell/themes/tokyo-night/theme.json | 0 desktop/runtime-compatibility.json | 6 + docs/architecture/ownership.md | 184 +- docs/architecture/user-widgets.md | 14 +- docs/audit-implementation-2026-10-21.md | 431 ---- docs/authentication-dialog.md | 82 - docs/cliproxy-rust-model-usage-research.md | 394 ---- docs/customization.md | 66 + docs/dependency-policy.md | 239 --- docs/desktop-improvements-ledger.md | 182 -- docs/desktop-notifications.md | 374 ---- docs/desktop-startup.md | 268 --- docs/desktop-validation.md | 304 --- docs/desktop-workspace-architecture.md | 404 ---- docs/desktop-workspaces.md | 225 -- docs/efficiency-review-2026-10-15.md | 215 -- docs/fedora-major-upgrade.md | 368 ---- docs/fusebox-widget.md | 33 +- docs/gdm-replacement-research.md | 241 --- docs/hardware-security.md | 110 + docs/installation-parity.md | 217 -- docs/installation.md | 60 + docs/integration-boundaries.md | 27 +- docs/iso-releases.md | 282 --- docs/keyboard-shortcuts.md | 4 +- docs/launcher-ibus-focus-investigation.md | 160 -- docs/licensing-audit.md | 439 ---- docs/licensing.md | 94 - docs/measurements/efficiency-2026-10-15.json | 1383 ------------ docs/native-system-settings-research.md | 194 -- docs/native-system-settings.md | 376 ---- docs/native-titlebars.md | 190 -- docs/nvidia.md | 119 -- docs/omarchy-plugin-compatibility.md | 69 +- docs/operations.md | 1045 --------- docs/overview-and-dock.md | 4 +- docs/quickshell-notes.md | 938 -------- docs/recovery.md | 81 + docs/releasing.md | 354 ++-- docs/remote-server-widget.md | 10 +- docs/security.md | 207 -- docs/shell-appearance.md | 196 -- docs/shell-recovery.md | 13 +- docs/shell-settings-manual-verification.md | 633 ------ docs/system-theme.md | 271 --- docs/t3-composer-manual-verification.md | 65 - docs/t3-git-actions-manual-verification.md | 94 - docs/updates.md | 82 + docs/validation.md | 107 + docs/xps-2026-hardware.md | 346 --- flake.lock | 175 ++ flake.nix | 135 ++ .../xps}/camera/71-xps-ipu7-hide-isys.rules | 0 .../xps}/camera/90-xps-ipu7-psys.rules | 0 .../0003-vision-ptl-cvs-owns-rgb-power.patch | 0 .../patches/0004-ipu7-psys-register-bus.patch | 0 .../0005-ipu7-psys-stock-core-layout.patch | 31 +- .../0006-camera-hal-native-cvs-bridge.patch | 2 +- .../xps}/camera/xps-ipu7-abi-check | 109 +- .../xps}/camera/xps-ipu7-camera-frame-check | 8 +- hardware/xps/camera/xps-ipu7-camera-init | 38 + hardware/xps/xps-fingerprint-check | 5 + .../xps}/xps-fingerprint-detect | 0 .../xps}/xps-haptic-touchpad | 4 +- .../xps}/xps-speaker-filter-chain.conf | 0 .../xps}/xps-speaker-host.conf | 0 .../files => hardware/xps}/xps-speaker-tuning | 11 +- image/AUDIT-2026-09-23.md | 175 -- image/Containerfile.tests | 15 - image/IMPLEMENTATION-2026-09-23.md | 91 - image/INSTALL-AUDIT-2026-09-25.md | 336 --- image/INSTALLER.md | 644 ------ image/QUALIFICATION-2026-09-26-LIFECYCLE.md | 222 -- image/README.md | 532 ----- image/VALIDATION.md | 195 -- image/application_payload.py | 30 - image/applications | 187 -- image/applications.yml | 84 - image/benchmark-compression | 87 - image/boot-theme/theme.txt | 42 - image/boot_branding.py | 96 - image/browser-smoke.cjs | 435 ---- image/browser_qualification.py | 146 -- image/build | 219 -- image/build-in-guest | 88 - image/build.repo | 36 - image/build_support.py | 286 --- image/channels/CYBEXOS-desktop.asc | 16 - image/channels/stable.json | 5 - image/check-source | 31 - image/compose | 228 -- image/cybexos-desktop.spec | 167 -- image/desktop_payload.py | 193 -- image/distribution_review.py | 247 --- image/download_cache.py | 164 -- image/dracut-live.conf | 8 - image/fedora_target.py | 36 - image/github_release.py | 356 ---- image/install-live-rootfs | 24 - image/installation_policy.py | 89 - image/installed_outcomes.py | 256 --- image/installer-tests/browser-driver.test.cjs | 66 - image/installer-tests/model.test.mjs | 103 - image/library/cybexos_docker_access.py | 65 - image/library/cybexos_managed_file.py | 143 -- image/library/cybexos_user_include.py | 129 -- image/library/cybexos_versionlock.py | 347 --- .../etc/anaconda/conf.d/20-cybexos.conf | 12 - .../usr/bin/cybexos-installer-browser | 48 - .../lib/systemd/system/cybexos-live.service | 13 - .../user/cybexos-install-session.target | 10 - .../user/cybexos-install-welcome.service | 7 - .../usr/libexec/cybexos-installer-backend | 1099 ---------- .../usr/libexec/cybexos-installer-seed | 435 ---- .../usr/libexec/cybexos-installer-target | 276 --- .../usr/libexec/cybexos-live-setup | 42 - .../share/anaconda/interactive-defaults.ks | 4 - .../share/anaconda/post-scripts/90-cybexos.ks | 95 - .../cybexos-installer/cybex-wordmark.svg | 10 - .../cockpit/cybexos-installer/index.html | 129 -- .../cockpit/cybexos-installer/installer.css | 194 -- .../cockpit/cybexos-installer/installer.js | 516 ----- .../cockpit/cybexos-installer/manifest.json | 5 - .../share/cockpit/cybexos-installer/model.mjs | 109 - image/live.ks | 113 - image/login_qualification.py | 333 --- image/notification_images.py | 73 - image/offline-payload.json | 4 - image/offline_payload.py | 90 - image/offline_upgrade_inputs.py | 487 ----- image/package | 253 --- image/parity-exceptions.json | 13 - image/parity_qualification.py | 59 - image/post_install_check.py | 114 - image/prepare-github-release | 5 - image/provision.cfg | 3 - image/provision.yml | 276 --- image/provision_payload.py | 31 - image/public_update_qualification.py | 1064 ---------- image/publish-pxe | 5 - image/pxe_publish.py | 226 -- image/qmp_control.py | 91 - .../2026-09-26/build.json | 84 - .../2026-09-26/encrypted-nl.json | 38 - .../2026-09-26/encrypted-us.json | 38 - .../2026-09-26/plain-nl.json | 25 - .../2026-09-26/plain-us.json | 25 - .../2026-09-26/upgrade-recovery.json | 28 - image/qualification.py | 488 ----- image/qualification_contract.py | 75 - image/qualify | 5 - image/qualify-checkout | 271 --- image/real_browser_qualification.cjs | 173 -- image/reconstruct-iso | 80 - image/release-gate | 185 -- image/release-repository | 6 - image/release-runner | 6 - image/release_metadata.py | 189 -- image/release_repository.py | 253 --- image/release_runner.py | 354 ---- image/repair-installed | 195 -- image/rootfs/usr/bin/cybex | 57 - image/rootfs/usr/bin/cybexos-welcome | 630 ------ image/rootfs/usr/bin/hyprland-quickshell | 65 - .../systemd/system/cybexos-first-boot.service | 20 - .../system/cybexos-hardware-setup.service | 13 - .../system/cybexos-hardware-setup.timer | 11 - .../systemd/system/cybexos-new-owner.service | 32 - .../systemd/system/cybexos-reconcile.service | 12 - .../systemd/system/cybexos-reconcile.timer | 12 - .../system/cybexos-tailscale-enroll.service | 12 - .../lib/systemd/user/cybexos-app-seed.service | 11 - .../systemd/user/cybexos-session-lock.service | 8 - .../lib/systemd/user/cybexos-welcome.service | 8 - .../user/external-monitor-toggle.service | 17 - .../user/hypridle.service.d/50-cybexos.conf | 7 - .../lib/systemd/user/hyprland-session.target | 9 - .../voxtype.service.d/50-cybexos-seed.conf | 4 - image/rootfs/usr/libexec/cybexos-config | 370 ---- .../usr/libexec/cybexos-configure-installed | 456 ---- image/rootfs/usr/libexec/cybexos-doctor | 191 -- .../rootfs/usr/libexec/cybexos-factory-reset | 339 --- image/rootfs/usr/libexec/cybexos-first-boot | 131 -- image/rootfs/usr/libexec/cybexos-new-owner | 992 --------- .../rootfs/usr/libexec/cybexos-new-owner-gui | 413 ---- image/rootfs/usr/libexec/cybexos-reconcile | 202 -- .../usr/libexec/cybexos-seed-installed-users | 31 - .../rootfs/usr/libexec/cybexos-update-channel | 215 -- image/rootfs/usr/libexec/cybexos-user-init | 249 --- .../usr/share/applications/cybex.desktop | 9 - .../usr/share/cybexos/new-owner/Main.qml | 962 --------- .../rootfs/usr/share/cybexos/welcome/Main.qml | 771 ------- .../share/cybexos/welcome/cybex-wordmark.svg | 10 - .../hyprland-quickshell.desktop | 7 - image/sbom.py | 865 -------- image/source_build_provenance.py | 41 - image/source_snapshot.py | 64 - image/system_packages.py | 67 - image/test-live | 71 - image/test_build_tools.py | 512 ----- image/test_console_bootstrap.py | 157 -- image/test_desktop_payload.py | 755 ------- image/test_display_policy.py | 128 -- image/test_doctor.py | 108 - image/test_fedora_release.py | 227 -- image/test_github_release.py | 346 --- image/test_installation_parity.py | 714 ------- image/test_installed_outcomes.py | 278 --- image/test_installed_policy.py | 486 ----- image/test_installer.py | 1282 ----------- image/test_installer_seed.py | 486 ----- image/test_login_qualification.py | 429 ---- image/test_new_owner.py | 1188 ----------- image/test_new_owner_window.py | 351 --- image/test_nvidia.py | 350 --- image/test_offline_payload.py | 76 - image/test_offline_upgrade_inputs.py | 793 ------- image/test_post_install_check.py | 119 -- image/test_provision_containment.py | 348 --- image/test_public_update_qualification.py | 1222 ----------- image/test_qualification.py | 347 --- image/test_reconfigure.py | 728 ------- image/test_release.py | 168 -- image/test_release_gate.py | 398 ---- image/test_release_runner.py | 251 --- image/test_release_signing.py | 122 -- image/test_sbom.py | 604 ------ image/test_session_start.py | 12 - image/test_source_build_provenance.py | 65 - image/test_system_packages.py | 101 - image/test_update_channel.py | 131 -- image/test_update_recovery.py | 259 --- image/test_upgrade_lifecycle.py | 357 ---- image/test_user_parity.py | 572 ----- image/test_versionlock.py | 346 --- image/test_welcome_setup.py | 163 -- image/tests | 591 ------ image/titlebars | 42 - image/titlebars_payload.py | 55 - image/upgrade_qualification.py | 257 --- image/vm-control | 5 - image/vm_testing.py | 626 ------ install | 405 +--- installer/cybexos-install | 458 ++++ inventory/group_vars/all.yml | 399 ---- inventory/hosts.yml | 7 - lifecycle/bin/cybex | 3 + lifecycle/bin/cybexos-admin | 3 + lifecycle/bin/cybexos-boot-trial | 7 + lifecycle/bin/cybexos-desktop-check | 42 + lifecycle/bin/cybexos-health | 45 + lifecycle/bin/cybexos-personal-init | 53 + lifecycle/bin/cybexos-request | 3 + lifecycle/bin/cybexos-verify-encryption | 62 + lifecycle/cybexos/__init__.py | 1 + lifecycle/cybexos/boot.py | 55 + lifecycle/cybexos/cli.py | 173 ++ lifecycle/cybexos/common.py | 142 ++ lifecycle/cybexos/configuration.py | 29 + lifecycle/cybexos/desktop_check.py | 55 + lifecycle/cybexos/engine.py | 572 +++++ lifecycle/cybexos/health.py | 115 + lifecycle/cybexos/inputs.py | 77 + lifecycle/cybexos/trust.py | 187 ++ nix/desktop-compatibility.nix | 76 + nix/modules/applications.nix | 97 + nix/modules/default.nix | 87 + nix/modules/desktop.nix | 126 ++ nix/modules/hardware.nix | 233 ++ nix/modules/installer.nix | 34 + nix/modules/lifecycle.nix | 75 + nix/modules/security.nix | 54 + nix/packages/brave-origin.nix | 37 + nix/packages/chatgpt.nix | 76 + nix/packages/desktop.nix | 83 + nix/packages/fonts.nix | 12 + nix/packages/hermes-bridge.nix | 16 + nix/packages/intel-lpmd.nix | 28 + nix/packages/ipu7.nix | 83 + nix/packages/mdview-Cargo.lock | 1210 +++++++++++ nix/packages/mdview.nix | 39 + nix/packages/networkmanager-libreswan.nix | 15 + nix/packages/omawrite.nix | 17 + nix/packages/t3-code.nix | 38 + nix/packages/titlebars.nix | 23 + nix/packages/updater.nix | 33 + nix/patches/aquamarine-explicit-bool.patch | 17 + nix/tests/desktop-runtime.py | 75 + nix/tests/lifecycle.nix | 55 + nix/tests/machine.nix | 7 + nix/tests/parity.nix | 175 ++ plugins/callback/cybexos.py | 231 -- release-manifest.json | 7 - release/keys/trust.json | 4 + release/manage.py | 136 ++ release/notes.md | 7 + release/require-immutable-releases | 16 + release/verify-assets.py | 57 + roles/apps/defaults/main.yml | 152 -- roles/apps/files/1password.repo | 7 - roles/apps/files/49-cybexos-defaults.conf | 109 - roles/apps/files/brave-browser.repo | 6 - roles/apps/files/chatgpt-repository-key.asc | 28 - roles/apps/files/chatgpt.repo | 8 - roles/apps/files/cybexos-common.sh | 96 - roles/apps/files/cybexos-repository-policy | 93 - roles/apps/files/github-release-install | 413 ---- roles/apps/files/omawrite-install | 66 - roles/apps/files/omawrite.desktop | 12 - .../apps/files/rpmfusion-free-fedora-2020.asc | 29 - .../files/rpmfusion-nonfree-fedora-2020.asc | 30 - roles/apps/files/source-app-build | 211 -- roles/apps/files/tailscale.repo | 8 - roles/apps/handlers/main.yml | 46 - roles/apps/tasks/android.yml | 33 - roles/apps/tasks/devtools.yml | 150 -- roles/apps/tasks/flatpaks.yml | 42 - roles/apps/tasks/fonts.yml | 53 - roles/apps/tasks/main.yml | 96 - roles/apps/tasks/mpv.yml | 19 - roles/apps/tasks/omawrite.yml | 60 - roles/apps/tasks/packages.yml | 234 -- roles/apps/tasks/repos.yml | 198 -- roles/apps/tasks/rpmfusion.yml | 49 - roles/apps/tasks/upstream.yml | 505 ----- roles/apps/tasks/voxtype-backend.yml | 57 - roles/apps/templates/android-sdk-update.j2 | 246 --- roles/apps/templates/update-user-tools.j2 | 287 --- roles/base/defaults/main.yml | 156 -- roles/base/files/60-cybexos-hardening.conf | 11 - roles/base/files/cybexos-auth-policy | 271 --- 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 | 1062 ---------- roles/base/files/cybexos-update-bootstrap | 282 --- roles/base/files/cybexos-update-download | 115 - .../files/cybexos-update-download.service | 21 - .../base/files/cybexos-update-download.timer | 14 - 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 | 84 - roles/base/files/recovery/42_cybexos_recovery | 11 - .../files/recovery/90-cybexos-recovery.conf | 3 - .../cybexos-recovery-overlay.sh | 71 - .../90cybexos-recovery/module-setup.sh | 31 - .../recovery/95-cybexos-recovery.install | 16 - .../recovery/cybexos-recovery-refresh.service | 15 - roles/base/handlers/main.yml | 16 - roles/base/tasks/accounts.yml | 79 - roles/base/tasks/authentication.yml | 25 - roles/base/tasks/btrfs-scrub.yml | 81 - roles/base/tasks/docker-access.yml | 32 - roles/base/tasks/docker-activation.yml | 21 - roles/base/tasks/image.yml | 72 - roles/base/tasks/main.yml | 544 ----- roles/base/tasks/mdns.yml | 46 - roles/base/tasks/packages.yml | 15 - roles/base/tasks/reconcile-scrub.yml | 57 - roles/base/tasks/reconcile.yml | 81 - roles/base/tasks/recovery.yml | 146 -- roles/base/tasks/services.yml | 31 - roles/base/tasks/system-packages.yml | 17 - roles/base/tasks/versionlock.yml | 9 - roles/base/templates/cybexos-zone.xml.j2 | 12 - roles/base/templates/locale.conf.j2 | 10 - roles/boot/defaults/main.yml | 16 - roles/boot/handlers/main.yml | 4 - roles/boot/tasks/main.yml | 278 --- .../files/90-cybexos-ibus.gschema.override | 4 - .../files/90-cybexos-smb.gschema.override | 4 - roles/desktop/files/autostart.lua | 15 - roles/desktop/files/cybexos-backup.service | 13 - roles/desktop/files/cybexos-backup.timer | 17 - .../files/cybexos-input-method.service | 16 - roles/desktop/files/hyprland-session-start | 49 - roles/desktop/files/login/50-cybexos.conf | 5 - .../desktop/files/login/cybexos-login-prepare | 228 -- roles/desktop/files/login/sddm-autologin | 23 - .../files/quickshell/Common/Recovery.qml | 138 -- .../quickshell/Common/RecoveryHelpers.js | 163 -- .../quickshell/Common/UpdateLogReader.qml | 28 - .../files/quickshell/Common/Updates.qml | 1886 ----------------- .../files/quickshell/Common/UpdatesHelpers.js | 1217 ----------- .../quickshell/Popovers/UpdatesPopover.qml | 1409 ------------ .../quickshell/Settings/FactoryResetRow.qml | 45 - .../quickshell/Settings/RecoveryGroup.qml | 145 -- .../files/quickshell/assets/fedora-white.svg | 1 - .../files/quickshell/assets/fedora.svg | 1 - .../files/quickshell/scripts/update-client | 61 - roles/desktop/files/titlebars-build.path | 14 - roles/desktop/files/titlebars-build.service | 18 - roles/desktop/files/titlebars-install | 190 -- roles/desktop/handlers/main.yml | 58 - roles/desktop/tasks/hermes-menubar.yml | 174 -- roles/desktop/tasks/main.yml | 1446 ------------- roles/desktop/tasks/notification-images.yml | 21 - roles/desktop/tasks/portals.yml | 21 - roles/desktop/tasks/titlebars.yml | 81 - .../templates/cybexos-session-lock.service.j2 | 10 - roles/desktop/templates/features.lua.j2 | 10 - .../hermes-menubar-bridge.service.j2 | 22 - roles/desktop/templates/hypridle.service.j2 | 12 - roles/desktop/templates/hyprland-copr.repo.j2 | 10 - .../desktop/templates/hyprland-quickshell.j2 | 58 - roles/desktop/templates/monitors.lua.j2 | 133 -- roles/desktop/templates/quickshell.service.j2 | 19 - roles/desktop/templates/voxtype.service.j2 | 17 - roles/dotfiles/defaults/main.yml | 4 - roles/dotfiles/files/brave-origin.desktop | 8 - roles/dotfiles/files/chatgpt.desktop | 11 - .../files/com.onepassword.OnePassword.desktop | 9 - roles/dotfiles/files/dev-distrobox-init | 20 - roles/dotfiles/files/manage-firefox-policy | 83 - roles/dotfiles/files/nvim-kitty.desktop | 9 - roles/dotfiles/handlers/main.yml | 22 - roles/dotfiles/tasks/agent-skills.yml | 38 - roles/dotfiles/tasks/environment.yml | 19 - roles/dotfiles/tasks/main.yml | 625 ------ roles/dotfiles/tasks/personal.yml | 250 --- roles/dotfiles/tasks/shell-defaults.yml | 18 - roles/dotfiles/tasks/wallpapers.yml | 31 - roles/dotfiles/templates/cybex.j2 | 98 - roles/dotfiles/templates/dev-arch.desktop.j2 | 7 - .../dotfiles/templates/dev-debian.desktop.j2 | 7 - .../dotfiles/templates/dev-fedora.desktop.j2 | 7 - roles/dotfiles/templates/distrobox-shell.j2 | 9 - roles/dotfiles/templates/environment.conf.j2 | 16 - .../localsend-share-clipboard.desktop.j2 | 7 - .../templates/localsend-share-file.desktop.j2 | 7 - .../localsend-share-folder.desktop.j2 | 7 - roles/dotfiles/templates/mimeapps.list.j2 | 30 - roles/dotfiles/templates/portal-launcher.j2 | 9 - roles/dotfiles/templates/spotify.j2 | 5 - roles/dotfiles/templates/t3code-desktop.j2 | 29 - .../templates/t3code-nightly.desktop.j2 | 10 - roles/dotfiles/templates/t3code-update.j2 | 115 - roles/finalize/tasks/main.yml | 222 -- roles/legacy-names/tasks/main.yml | 81 - roles/nvidia/defaults/main.yml | 35 - roles/nvidia/files/cybexos-nvidia | 438 ---- roles/nvidia/tasks/detect.yml | 22 - roles/nvidia/tasks/main.yml | 9 - roles/nvidia/tasks/setup.yml | 146 -- roles/private-hooks/files/build-asdcontrol | 48 - roles/private-hooks/handlers/main.yml | 8 - roles/private-hooks/tasks/main.yml | 183 -- roles/private-hooks/templates/sccache.j2 | 44 - roles/uninstall/tasks/main.yml | 759 ------- roles/xps-2026/defaults/main.yml | 49 - .../files/camera/v4l2-relayd-ipu7.conf | 6 - .../files/camera/v4l2-relayd-override.conf | 21 - .../files/camera/xps-ipu7-camera-check | 65 - .../files/camera/xps-ipu7-camera-init | 100 - .../files/camera/xps-ipu7-camera-sleep | 16 - .../files/camera/xps-ipu7-camera-stack.spec | 49 - .../camera/xps-ipu7-camera.modprobe.conf | 8 - .../files/camera/xps-ipu7-userspace-check | 166 -- roles/xps-2026/files/tuned-ppd.conf | 12 - .../xps-2026/files/tuned-xps-performance.conf | 13 - roles/xps-2026/files/xps-fingerprint-check | 80 - roles/xps-2026/handlers/main.yml | 76 - roles/xps-2026/tasks/backlight.yml | 63 - roles/xps-2026/tasks/camera-disabled.yml | 102 - roles/xps-2026/tasks/camera.yml | 1325 ------------ roles/xps-2026/tasks/detect.yml | 100 - roles/xps-2026/tasks/display.yml | 74 - roles/xps-2026/tasks/fingerprint.yml | 60 - roles/xps-2026/tasks/main.yml | 54 - roles/xps-2026/tasks/packages.yml | 37 - roles/xps-2026/tasks/power.yml | 75 - roles/xps-2026/tasks/speaker.yml | 114 - roles/xps-2026/tasks/touchpad.yml | 75 - .../templates/90-xps-ipu7-v4l2.conf.j2 | 21 - .../templates/xps-haptic-touchpad.conf.j2 | 2 - .../templates/xps-haptic-touchpad.service.j2 | 22 - roles/xps-2026/templates/xps-ipu7-build.j2 | 782 ------- .../templates/xps-ipu7-camera-init.service.j2 | 14 - .../xps-ipu7-camera-resume.service.j2 | 11 - .../templates/xps-ipu7-camera.service.j2 | 15 - .../templates/xps-ipu7-refresh-wireplumber.j2 | 4 - .../templates/xps-speaker-tuning.service.j2 | 18 - scripts/check-ci-capacity | 45 + scripts/check-image-parity | 196 ++ scripts/i18n-catalog | 2 +- scripts/installer-defaults | 56 - scripts/manage-agent-skills | 365 ---- scripts/migrate-config | 117 - scripts/migrate-legacy-names | 238 --- scripts/qualify-installations | 472 +++++ scripts/startup-evidence.py | 4 +- scripts/sync-model-usage | 4 +- scripts/ui.sh | 2 +- scripts/update-tabler-icons | 2 +- site.yml | 138 -- templates/machine/desktop-settings.json | 1 + templates/machine/flake.nix | 16 + templates/machine/local/default.nix | 11 + templates/machine/settings.nix | 6 + tests/agent-skill.py | 110 - tests/agent-skills | 254 --- tests/application-defaults.py | 368 ---- tests/brightness-routing | 186 -- tests/callback-smoke.py | 68 - tests/camera-transaction.py | 287 --- tests/cybex-deployment.py | 213 -- tests/cybexos-update-run | 1623 -------------- tests/desktop-smoke/shell.qml | 25 + tests/display-settings.py | 2 +- tests/distribution-review.py | 252 --- tests/fedora-release.py | 12 - tests/fedora-vm-convergence | 289 --- tests/firefox-policy.py | 73 - tests/fixtures/hyprland-workspace-modes.lua | 29 +- tests/hyprland-displays | 34 +- tests/hyprland-features | 407 ---- tests/hyprland-input | 165 -- tests/hyprland-workspace-modes | 18 - tests/hyprland-workspaces | 251 --- tests/installation-parity.py | 14 - tests/installer-convergence.py | 1143 ---------- tests/launcher-input-live.py | 258 --- tests/legacy-names | 153 -- tests/lib/quickshell-live | 89 +- tests/login-policy.py | 219 -- tests/major-upgrade.py | 370 ---- tests/model-usage-popup.py | 2 +- tests/model-usage.py | 4 +- tests/nixos-native | 380 ++++ tests/{ => nixos}/camera-frame.py | 6 +- tests/{ => nixos}/default-agent.py | 14 +- tests/{ => nixos}/hermes-bridge.py | 24 +- tests/{ => nixos}/hermes-remote-runtime.py | 136 +- tests/{ => nixos}/hermes-webui-auth.py | 4 +- tests/{backup.py => nixos/test_backup.py} | 34 +- .../test_bluetooth_tool.py} | 6 +- tests/nixos/test_boot.py | 64 + .../test_clipboard_tool.py} | 13 +- tests/nixos/test_desktop_health.py | 388 ++++ tests/nixos/test_encryption.py | 70 + .../test_firmware_update.py} | 68 +- tests/{fusebox.py => nixos/test_fusebox.py} | 6 +- .../{game-mode.py => nixos/test_game_mode.py} | 25 +- tests/nixos/test_installer.py | 595 ++++++ tests/nixos/test_lifecycle.py | 1096 ++++++++++ .../test_native_input_region.py} | 86 +- .../test_network_connections.py} | 50 +- tests/nixos/test_personal_init.py | 76 + tests/nixos/test_release.py | 230 ++ .../test_remote_server.py} | 7 +- .../test_report.py} | 177 +- tests/nixos/test_runtime_ipc.py | 107 + .../test_screenshot_stitch.py} | 9 +- .../test_security_settings.py} | 238 +-- .../test_shell_recovery.py} | 12 +- .../test_wallpaper_download.py} | 4 +- .../test_webapps.py} | 32 +- tests/notification-focus-live.py | 172 -- tests/nvidia-hardware.py | 388 ---- tests/omarchy-plugins/shell.qml | 6 +- tests/omawrite.py | 124 -- tests/ownership-layering.py | 326 --- tests/plugin-packages.py | 2 +- tests/qml-lint | 23 +- tests/qml/tst_helpers.qml | 8 +- tests/qml/tst_input_draft.qml | 2 +- tests/qml/tst_location_search.qml | 2 +- tests/qml/tst_notificationholds.qml | 4 +- tests/qml/tst_settings_document.qml | 4 +- tests/qml/tst_settings_system.qml | 4 +- tests/quickshell-deploy-health | 170 -- tests/quickshell-deploy-health-fixture | 133 -- tests/quickshell-deployment.py | 394 ---- .../accessibility-contracts.test.cjs | 3 +- tests/quickshell/background-churn.test.cjs | 70 +- tests/quickshell/backup.test.cjs | 13 +- tests/quickshell/bar-geometry.test.cjs | 2 +- tests/quickshell/brand-icons.test.cjs | 49 +- tests/quickshell/brightness.test.cjs | 6 +- tests/quickshell/calendar.test.cjs | 34 +- tests/quickshell/capture-scripts.test.cjs | 29 +- tests/quickshell/control-panel.test.cjs | 21 +- tests/quickshell/converge-efficiency.test.cjs | 248 --- .../deployment-convergence.test.cjs | 295 --- tests/quickshell/desktop-navigation.test.cjs | 14 +- tests/quickshell/display-settings.test.cjs | 21 - tests/quickshell/dock-helpers.test.cjs | 10 +- tests/quickshell/drawer-overview.test.cjs | 4 +- tests/quickshell/expanded-workspaces.test.cjs | 2 +- .../external-monitor-toggle.test.cjs | 206 -- tests/quickshell/firmware-access.test.cjs | 28 + tests/quickshell/fish-config.test.cjs | 6 +- tests/quickshell/fusebox.test.cjs | 8 +- tests/quickshell/game-mode.test.cjs | 4 +- tests/quickshell/glass-color.test.cjs | 6 +- tests/quickshell/hermes-ui.test.cjs | 39 +- tests/quickshell/i18n-keys.cjs | 2 +- tests/quickshell/idle-timeouts.test.cjs | 8 +- tests/quickshell/indicators.test.cjs | 48 +- tests/quickshell/input-method.test.cjs | 62 - tests/quickshell/keybind-helpers.test.cjs | 2 +- tests/quickshell/launcher-providers.test.cjs | 44 +- tests/quickshell/launcher.test.cjs | 21 +- tests/quickshell/lock-screen.test.cjs | 2 +- tests/quickshell/network-panel.test.cjs | 6 - tests/quickshell/network-status.test.cjs | 10 +- tests/quickshell/note-title-helper.test.cjs | 12 +- tests/quickshell/polish-primitives.test.cjs | 6 +- tests/quickshell/power-saver.test.cjs | 6 +- tests/quickshell/recovery.test.cjs | 124 -- tests/quickshell/remote-server.test.cjs | 4 +- tests/quickshell/security-lifecycle.test.cjs | 139 -- tests/quickshell/security-settings.test.cjs | 17 +- tests/quickshell/session-action.test.cjs | 2 +- tests/quickshell/settings-layout.test.cjs | 4 +- .../quickshell/settings-system-pages.test.cjs | 8 +- tests/quickshell/settings-window.test.cjs | 24 - tests/quickshell/settings.test.cjs | 57 +- tests/quickshell/shell-health.test.cjs | 4 +- .../quickshell/shell-lifecycle-fixes.test.cjs | 2 +- tests/quickshell/shell.cjs | 8 +- tests/quickshell/status-helpers.test.cjs | 6 +- tests/quickshell/sysinfo-helpers.test.cjs | 12 +- tests/quickshell/system-theme-gtk.test.cjs | 208 +- .../quickshell/system-theme-hyprland.test.cjs | 2 +- .../quickshell/system-theme-hyprlock.test.cjs | 28 +- tests/quickshell/system-theme-kitty.test.cjs | 12 +- tests/quickshell/system-theme.cjs | 2 +- tests/quickshell/t3-cloud-login.test.cjs | 31 +- tests/quickshell/t3code-desktop.test.cjs | 81 - tests/quickshell/tailscale-signin.test.cjs | 2 +- tests/quickshell/theme-helpers.test.cjs | 2 +- tests/quickshell/update-client.test.cjs | 108 - tests/quickshell/update-recovery.test.cjs | 25 + tests/quickshell/updates-preview.test.cjs | 382 ---- tests/quickshell/updates-schedule.test.cjs | 417 ---- tests/quickshell/updates.test.cjs | 594 ------ .../user-plugins-lifecycle.test.cjs | 21 + tests/quickshell/verify-cli.test.cjs | 43 - tests/quickshell/view-churn.test.cjs | 7 +- tests/quickshell/wallhaven-helpers.test.cjs | 23 - tests/quickshell/window-rules.test.cjs | 16 +- tests/quickshell/xps-backlight.test.cjs | 40 - tests/quickshell/xps-haptic-touchpad.test.cjs | 7 +- tests/release-update | 572 ----- tests/repository-policy.py | 490 ----- tests/run | 1324 +----------- tests/screenshot-workflow | 252 --- tests/session-launcher.py | 209 -- tests/settings-ownership.py | 91 +- tests/shell-health.py | 83 - tests/startup-evidence.py | 72 - tests/static-analysis | 72 - tests/system-settings-live | 51 - tests/system-settings-network-integration.py | 78 - tests/system-settings-network-namespace | 87 - tests/system-settings.py | 2 +- tests/system-snapshot | 8 - tests/system-snapshot.py | 568 ----- tests/t3-contract-snapshot | 145 -- tests/theme-packs.py | 2 +- tests/titlebars.py | 203 -- tests/update-bootstrap.py | 208 -- tests/update-download.py | 190 -- tests/update-transaction.py | 478 ----- tests/user-adoption.py | 197 -- tests/user-plugins.py | 46 +- tests/user-widgets/shell.qml | 2 +- tests/verify-system | 860 -------- tests/vm/assert-converged | 159 -- tests/vm/assert-uninstalled | 122 -- tests/vm/convergence-vars.yml | 31 - uninstall | 74 - uninstall.yml | 27 - update | 7 +- verify | 124 +- web/ASSETS.md | 11 +- web/README.md | 22 +- web/app/globals.css | 6 +- web/app/layout.tsx | 2 +- web/components/ControlDrawer.tsx | 4 +- web/components/Desktop.tsx | 4 +- web/components/Fastfetch.tsx | 10 +- web/components/SettingsApp.tsx | 2 +- web/components/apps.css | 16 +- web/lib/terminal.ts | 16 +- web/package.json | 4 +- web/public/cybexos-white.svg | 1 + web/public/cybexos.svg | 1 + web/public/fedora.svg | 1 - web/public/licenses/cybexos-mark.txt | 1 + web/public/licenses/fedora-mark.txt | 16 - web/tests/apps.test.tsx | 10 +- web/tests/rendered-html.test.mjs | 2 +- web/tests/terminal.test.ts | 10 +- 1324 files changed, 12538 insertions(+), 100221 deletions(-) delete mode 100644 .ansible-lint create mode 100644 .github/actionlint.yaml delete mode 100644 .github/actions/fedora-source-environment/action.yml delete mode 100644 .github/actions/fedora-source-environment/qualified_packages.py delete mode 100644 .github/workflows/desktop-release.yml delete mode 100644 .github/workflows/fedora-preview.yml delete mode 100644 .github/workflows/fedora-vm-convergence.yml delete mode 100644 .github/workflows/live-image.yml create mode 100644 .github/workflows/nixos.yml create mode 100644 .github/workflows/promote.yml delete mode 100644 .github/workflows/tests.yml delete mode 100644 .yamllint delete mode 100644 ansible.cfg rename roles/dotfiles/templates/brightness-control.j2 => assets/scripts/brightness-control (98%) delete mode 100755 assets/scripts/cybexos-migrate-layering delete mode 100755 assets/scripts/cybexos-release-update delete mode 100755 assets/scripts/cybexos-update-run rename roles/dotfiles/templates/external-monitor-toggle.j2 => assets/scripts/external-monitor-toggle (100%) mode change 100644 => 100755 rename roles/dotfiles/templates/fastfetch-link-speed.j2 => assets/scripts/fastfetch-link-speed (100%) mode change 100644 => 100755 create mode 100755 desktop/bin/cybexos-session rename {roles/desktop/files => desktop/bin}/cybexos-session-action (100%) mode change 100644 => 100755 create mode 100755 desktop/bin/cybexos-session-start rename {roles/dotfiles/files => desktop/defaults}/fastfetch.jsonc (91%) rename {roles/dotfiles/files => desktop/defaults}/fish-config.fish (87%) rename {roles/dotfiles/files => desktop/defaults}/gitconfig (63%) rename {roles/dotfiles/files => desktop/defaults}/kitty.conf (96%) rename {roles/dotfiles/files => desktop/defaults}/ssh.conf (100%) rename {roles/dotfiles/files => desktop/defaults}/voxtype.toml (100%) create mode 100644 desktop/fonts.json rename {roles/desktop/files => desktop}/hermes-menubar-bridge/cybex_hermes/__init__.py (100%) rename {roles/desktop/files => desktop}/hermes-menubar-bridge/cybex_hermes/auth.py (100%) rename {roles/desktop/files => desktop}/hermes-menubar-bridge/cybex_hermes/gateway.py (100%) rename {roles/desktop/files => desktop}/hermes-menubar-bridge/cybex_hermes/protocol.py (100%) rename {roles/desktop/files => desktop}/hermes-menubar-bridge/cybex_hermes/registry.py (100%) rename {roles/desktop/files => desktop}/hermes-menubar-bridge/hermes_bridge.py (99%) create mode 100644 desktop/hypr/autostart.lua rename {roles/desktop/files => desktop/hypr}/bindings.lua (82%) rename {roles/desktop/files => desktop/hypr}/displays.lua (100%) create mode 100644 desktop/hypr/features.lua rename roles/desktop/templates/hypridle.conf.j2 => desktop/hypr/hypridle.conf (81%) rename {roles/desktop/files => desktop/hypr}/hyprland.lua (95%) rename roles/desktop/templates/hyprlock.conf.j2 => desktop/hypr/hyprlock.conf (100%) rename roles/desktop/templates/input.lua.j2 => desktop/hypr/input.lua (96%) rename {roles/desktop/files => desktop/hypr}/input_preferences.lua (100%) rename roles/desktop/templates/looknfeel.lua.j2 => desktop/hypr/looknfeel.lua (98%) create mode 100644 desktop/hypr/monitors.lua rename {roles/desktop/files => desktop/hypr}/titlebars-native.patch (90%) rename {roles/desktop/files => desktop/hypr}/workspace_layouts.lua (100%) rename {roles/desktop/files => desktop/hypr}/workspace_model.lua (100%) rename {roles/desktop/files => desktop/hypr}/workspace_state.lua (100%) rename {roles/desktop/files => desktop/hypr}/workspaces.lua (97%) rename {roles/desktop/files => desktop}/notification-images/CMakeLists.txt (100%) rename {roles/desktop/files => desktop}/notification-images/LICENSE (100%) rename {roles/desktop/files => desktop}/notification-images/build.py (100%) rename {roles/desktop/files => desktop}/notification-images/imagefingerprints.cpp (100%) rename {roles/desktop/files => desktop}/notification-images/imagefingerprints.h (100%) rename {roles/desktop/files => desktop}/notification-images/plugin.cpp (100%) rename {roles/desktop/files => desktop}/notification-images/plugins.qmltypes (100%) rename {roles/desktop/files => desktop}/notification-images/qmldir (100%) rename {roles/desktop/files => desktop}/notification-images/tests.cpp (100%) rename {roles/boot/files => desktop/plymouth}/bullet.png (100%) rename {roles/boot/files => desktop/plymouth}/cybex.plymouth (100%) rename {roles/boot/files => desktop/plymouth}/cybex.script (100%) rename {roles/boot/files => desktop/plymouth}/entry.png (100%) rename {roles/boot/files => desktop/plymouth}/logo.png (100%) rename {roles/boot/files => desktop/plymouth}/progress_bar.png (100%) rename {roles/boot/files => desktop/plymouth}/progress_box.png (100%) rename {roles/desktop/files => desktop}/quickshell/.qmllint.ini (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Bar.qml (99%) rename {roles/desktop/files => desktop}/quickshell/Bar/BarBrandIcon.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/BarChip.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/BarHover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/BarIcon.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/BarPopoutWindow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/BarTooltip.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Cluster.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Divider.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/HermesChip.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/ModuleSlot.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/BarModule.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Battery.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Bluetooth.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Clock.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Control.qml (96%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Fusebox.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/GitHub.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Hermes.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Indicators.qml (99%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Media.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/ModelUsage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Notes.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Notifications.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Remote.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/T3.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Tray.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Updates.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Volume.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Weather.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Wifi.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/Workspaces.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Modules/qmldir (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/PointerCheck.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/PopoutHost.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/T3Chip.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/UserWidgets.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/Workspaces.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Bar/qmldir (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Activity.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/AnimatedText.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Audio.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/AudioHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Backup.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/BackupHelpers.js (99%) rename {roles/desktop/files => desktop}/quickshell/Common/BarGeometry.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/BarOverflow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Battery.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/BatteryHealth.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/BatteryViewHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/BluetoothHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/BluetoothState.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/BrandIcon.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/BrandIcons.qml (93%) rename {roles/desktop/files => desktop}/quickshell/Common/Calendar.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/CalendarHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/CaptureHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Claim.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/CommandRequest.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/CountdownHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/DesktopWorkspaces.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Dictation.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/DisplayHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/DisplaySettings.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Dock.qml (98%) rename {roles/desktop/files => desktop}/quickshell/Common/DockHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/EthernetHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/EthernetState.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/ExternalUrl.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Format.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Fusebox.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/FuseboxHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/GameMode.qml (92%) rename {roles/desktop/files => desktop}/quickshell/Common/GameModeState.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/GitHub.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/GitHubHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/GitHubQueue.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/HSlider.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Hermes.qml (99%) rename {roles/desktop/files => desktop}/quickshell/Common/HermesConnection.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/HermesConversations.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/HermesHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/HermesRpc.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/HermesSocket.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/HermesTheme.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/HugCorner.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/I18n.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/I18nHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/KeybindHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Launcher.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/LauncherNavigation.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/LauncherProviders.js (96%) rename {roles/desktop/files => desktop}/quickshell/Common/LauncherProviders.qml (99%) rename {roles/desktop/files => desktop}/quickshell/Common/LayoutHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Location/LocationSearch.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Location/qmldir (100%) rename {roles/desktop/files => desktop}/quickshell/Common/LocationHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Media.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/ModelUsageHub.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NetworkDetails.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NetworkDetailsHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NetworkHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NetworkOverlayState.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NetworkStatus.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NetworkStatusHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Notes.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NotesHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NotifActions.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NotifButton.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NotifCard.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NotifHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NotifHolds.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NotifIcon.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NotifImageFingerprint.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NotifPolicy.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NotifSmart.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/NotifSnoozes.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Notifs.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/OmarchyApplications.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/OmarchyBarApi.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/OmarchyEntryHost.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/OmarchyPluginApi.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/OmarchyPlugins.qml (99%) rename {roles/desktop/files => desktop}/quickshell/Common/OmarchyServiceApi.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/OmarchyTheme.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/OnlineWallpapers.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Osd.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Overview.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/OverviewHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Palette.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/PaletteHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/PanelRegistryData.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Persistence/SettingsDocument.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Persistence/qmldir (100%) rename {roles/desktop/files => desktop}/quickshell/Common/PluginSchema.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/PolkitHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Popouts.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/ProcHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/ProcessList.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/ProcessListHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Recorder.qml (97%) create mode 100644 desktop/quickshell/Common/Recovery.qml rename {roles/desktop/files => desktop}/quickshell/Common/Reminders.qml (98%) rename {roles/desktop/files => desktop}/quickshell/Common/RemoteServer.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/RemoteServerHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Revealer.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Screens.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/ScrollChrome.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/SecurityHelpers.js (94%) rename {roles/desktop/files => desktop}/quickshell/Common/Session.qml (98%) rename {roles/desktop/files => desktop}/quickshell/Common/Settings.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/SettingsHelpers.js (99%) rename {roles/desktop/files => desktop}/quickshell/Common/SettingsSearchData.js (99%) rename {roles/desktop/files => desktop}/quickshell/Common/ShellHealth.qml (99%) rename {roles/desktop/files => desktop}/quickshell/Common/ShellMetrics.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/SocketContract.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Startup.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/StateLayer.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/StatusHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/StatusPlaceholder.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Sym.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/SysInfo.qml (99%) rename {roles/desktop/files => desktop}/quickshell/Common/SysInfoHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/SystemSettings.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/SystemSettingsBackend.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/SystemTheme.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/T3Actions.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/T3Code.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/T3CodeHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/T3Connection.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/T3Detail.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/T3Drafts.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/T3Favorites.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/T3Git.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/T3Rpc.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/T3Socket.qml (95%) rename {roles/desktop/files => desktop}/quickshell/Common/T3Theme.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/T3Threads.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/TablerGlyphs.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/TablerIcons.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Tailscale.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/TailscaleHelpers.js (95%) rename {roles/desktop/files => desktop}/quickshell/Common/Theme.qml (98%) rename {roles/desktop/files => desktop}/quickshell/Common/ThemeHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Themes.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Toggle.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Typography.js (100%) create mode 100644 desktop/quickshell/Common/Updates.qml create mode 100644 desktop/quickshell/Common/UpdatesHelpers.js rename {roles/desktop/files => desktop}/quickshell/Common/UserPlugins.qml (98%) rename {roles/desktop/files => desktop}/quickshell/Common/UserWidgetHost.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/WallhavenHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Wallpaper.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/Weather.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/WebIcons.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Common/WidgetCatalog.js (97%) rename {roles/desktop/files => desktop}/quickshell/Common/WidgetEditor.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/WifiState.qml (90%) rename {roles/desktop/files => desktop}/quickshell/Common/WindowCommands.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/WorkspaceHelpers.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/WorkspaceMotion.js (100%) rename {roles/desktop/files => desktop}/quickshell/Common/i18n/nl.js (96%) rename {roles/desktop/files => desktop}/quickshell/Common/qmldir (99%) rename {roles/desktop/files => desktop}/quickshell/Commons/Border.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Commons/BorderGeometry.js (100%) rename {roles/desktop/files => desktop}/quickshell/Commons/Color.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Commons/Style.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Commons/Util.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Commons/qmldir (100%) rename {roles/desktop/files => desktop}/quickshell/DockWindow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/LauncherView.qml (100%) rename {roles/desktop/files => desktop}/quickshell/LauncherWindow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/BlockMeter.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/CostBackend.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/CostPriceEditor.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/CostSettings.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/LICENSE (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/Panel.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/README.md (96%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/ResetBackend.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/ResetConfirmation.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/SettingsActions.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/SettingsCredential.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/SettingsField.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/SettingsHeader.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/SettingsSection.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md (88%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageActivityBackend.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageBackend.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageCosts.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageDisclosure.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageHistory.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageLiveConnection.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageLogic.js (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageNavigation.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageSelect.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageSettings.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageSharedActivityBackend.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageSharedBackend.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/UsageTabs.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/assets/claude-bar.svg (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/assets/claude.svg (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/assets/codex-bar.svg (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/assets/codex-light.svg (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/assets/codex.svg (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/assets/kimi-bar.svg (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/assets/kimi.svg (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/scripts/cost-fetch.py (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/scripts/keeper_client.py (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/scripts/management-key.py (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/scripts/model_usage_common.py (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/scripts/proxy-activity.py (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/scripts/proxy-live.py (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/scripts/reset-credit.py (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/scripts/t3_costs.py (100%) rename {roles/desktop/files => desktop}/quickshell/ModelUsage/scripts/usage-fetch.py (100%) rename {roles/desktop/files => desktop}/quickshell/NetworkOverlayWindow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/NotificationToasts.qml (100%) rename {roles/desktop/files => desktop}/quickshell/OsdWindow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/OverviewView.qml (100%) rename {roles/desktop/files => desktop}/quickshell/OverviewWindow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/PolkitPrompt.qml (100%) rename {roles/desktop/files => desktop}/quickshell/PolkitWindow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/ActionButton.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/AudioPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/BatteryPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/BlockMeter.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/BluetoothPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/CalendarPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/ControlCenterPopover.qml (99%) rename {roles/desktop/files => desktop}/quickshell/Popovers/DaySheetPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerBluetooth.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerFooter.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerIconButton.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerNetwork.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerNotifications.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerOverview.qml (98%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerPower.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerProcesses.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerSliderRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerSound.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerSystemHero.qml (97%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerTabs.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/DrawerTile.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Drawer/qmldir (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/FillSlider.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/FuseboxPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/GitHubPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HDivider.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HermesAuthPanel.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HermesBarControl.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HermesComposer.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HermesInboxPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HermesInlineSelect.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HermesModelPicker.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HermesPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HermesRequestCard.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HermesSessionStrip.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HermesToolCard.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/HermesTranscript.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/IconButton.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/LinkText.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/MediaPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/NotesPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/NotifsPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/OverflowPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/PopoutPanel.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/ReminderPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/RemoteServerPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/SectionLabel.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/Surface.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/T3BarControl.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/T3CodePopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/T3Composer.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/T3InboxPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/T3InlineSelect.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/T3ModelPicker.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/T3NewThreadPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/T3Picker.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/T3RequestCard.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/T3ThreadPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/TailscalePopover.qml (100%) create mode 100644 desktop/quickshell/Popovers/UpdatesPopover.qml rename {roles/desktop/files => desktop}/quickshell/Popovers/WeatherPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/WifiPopover.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Popovers/qmldir (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/AboutPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/AccountsPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/ActionRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/AppearancePage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/ApplyBar.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/BackupPage.qml (99%) rename {roles/desktop/files => desktop}/quickshell/Settings/BarBackgroundGroup.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/BarLayoutGroups.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/BarPreview.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/ChipToggleRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/ChoiceRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/CornerPickerRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/DisplayArrangement.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/DisplaysPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/DraftFieldRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/DrawerPage.qml (99%) rename {roles/desktop/files => desktop}/quickshell/Settings/FieldRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/FolderDialog.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/GitHubWatchList.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/HotspotGroup.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/IdleTimeline.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/IdleTimelineModel.js (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/InputDraft.js (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/InputDraftState.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/InputDrafts.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/IpSettings.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/KeyboardPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/LocalTime.js (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/ModuleDetailView.qml (97%) rename {roles/desktop/files => desktop}/quickshell/Settings/ModulesPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/MuteButton.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/NetworkPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/NightLightGroup.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/NotificationsPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/PickerRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/PillRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/PluginAppearance.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/PluginRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/PluginSource.js (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/PluginWidgetSettings.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/PluginsPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/PowerPage.qml (100%) create mode 100644 desktop/quickshell/Settings/RecoveryGroup.qml rename {roles/desktop/files => desktop}/quickshell/Settings/RegionPage.qml (99%) rename {roles/desktop/files => desktop}/quickshell/Settings/ResponsiveActionRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/RowCluster.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SectionHeader.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SecurityPage.qml (95%) rename {roles/desktop/files => desktop}/quickshell/Settings/SelectRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsAction.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsDisclosure.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsField.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsGroup.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsHint.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsSelect.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsSubsection.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsTextRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsTooltip.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SettingsView.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SliderRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SoundPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/StreamRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SwitchRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SystemChoice.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/SystemServiceStatus.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/TextSizePreview.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/TimeRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/TouchpadPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/UndoChip.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/ValueRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/VolumeRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/VpnGroup.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/WallpaperOnlineView.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/WallpaperPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/WeatherLocationPicker.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/WidgetEditorRow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/WidgetPill.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/WorkspacesPage.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Settings/qmldir (98%) rename {roles/desktop/files => desktop}/quickshell/SettingsWindow.qml (100%) rename {roles/desktop/files => desktop}/quickshell/ShortcutsOverlay.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/BackgroundMedia.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/BackgroundVideo.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/BarIconButton.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/BarIndicator.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/BarWidget.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/BorderOverlay.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/BorderSurface.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/Button.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/ButtonGroup.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/ConfirmDialog.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/CursorSurface.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/Dropdown.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/KeyboardPanel.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/MultiSelect.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/NumberField.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/OpticalGlyph.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/Panel.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/PanelActionButton.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/PanelController.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/PanelHero.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/PanelKeyCatcher.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/PanelSectionHeader.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/PanelSeparator.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/PanelSlider.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/PanelToolTip.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/PluginBarApi.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/PointerMoveGate.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/PopupCard.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/ScreenMoveRemap.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/SearchableDropdown.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/SpeedTestOverlay.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/TelemetryChart.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/TextField.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/Toggle.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/ToggleSwitch.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/WidgetButton.qml (100%) rename {roles/desktop/files => desktop}/quickshell/Ui/qmldir (100%) rename {roles/desktop/files => desktop}/quickshell/WebcamOverlay.qml (100%) rename {roles/desktop/files => desktop}/quickshell/assets/README.md (87%) rename {roles/desktop/files => desktop}/quickshell/assets/THIRD_PARTY_LICENSES.md (82%) rename {roles/desktop/files => desktop}/quickshell/assets/claude-white.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/claude.svg (100%) create mode 100644 desktop/quickshell/assets/cybexos-white.svg create mode 100644 desktop/quickshell/assets/cybexos.svg rename {roles/desktop/files => desktop}/quickshell/assets/fusebox-white.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/fusebox.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/gemini-white.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/gemini.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/github-white.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/github.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/grok-white.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/grok.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/kimi-white.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/kimi.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/openai-white.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/openai.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/slack-white.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/slack.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/t3.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/tabler/LICENSE (100%) rename {roles/desktop/files => desktop}/quickshell/assets/tabler/README.md (100%) rename {roles/desktop/files => desktop}/quickshell/assets/tabler/aliases.json (100%) rename {roles/desktop/files => desktop}/quickshell/assets/tabler/outline.ttf (100%) rename {roles/desktop/files => desktop}/quickshell/assets/tailscale-white.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/tailscale.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/whatsapp-white.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/whatsapp.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/youtube-white.svg (100%) rename {roles/desktop/files => desktop}/quickshell/assets/youtube.svg (100%) rename {roles/desktop/files => desktop}/quickshell/compat/omarchy/LICENSE (100%) rename {roles/desktop/files => desktop}/quickshell/compat/omarchy/README.md (100%) rename {roles/desktop/files => desktop}/quickshell/compat/omarchy/bin/omarchy-notification-send (99%) rename {roles/desktop/files => desktop}/quickshell/compat/omarchy/bin/omarchy-shell (99%) rename {roles/desktop/files => desktop}/quickshell/launcher-actions.json (100%) rename {roles/desktop/files => desktop}/quickshell/qmldir (100%) rename {roles/desktop/files => desktop}/quickshell/safe-mode/shell.qml (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/audio-route (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/audio-source-route (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/battery-health (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/bluetooth-tool.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/calendar-events.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/clipboard-tool.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/display-settings.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/fusebox.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/hypridle-config.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/network-speedtest.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/network-tool.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/network_vpn_agent.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/note-title.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/notification-history.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/notification-icon.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/plugin_packages.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/processes (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/remote-server.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/remote_server_probe.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/settings-store (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/shell-health.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/shell-recovery.py (98%) rename {roles/desktop/files => desktop}/quickshell/scripts/system-settings.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/system_settings_accounts.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/system_settings_audio.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/system_settings_input.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/system_settings_network.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/system_settings_region.py (78%) rename {roles/desktop/files => desktop}/quickshell/scripts/system_settings_security.py (89%) rename {roles/desktop/files => desktop}/quickshell/scripts/t3-cloud.mjs (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/theme-apply.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/theme-packs.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/theme_gtk.py (98%) rename {roles/desktop/files => desktop}/quickshell/scripts/theme_hyprland.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/theme_hyprlock.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/theme_kitty.py (96%) rename {roles/desktop/files => desktop}/quickshell/scripts/theme_packs.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/theme_tokens.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/user-plugins.py (99%) rename {roles/desktop/files => desktop}/quickshell/scripts/wallpaper-download.py (100%) rename {roles/desktop/files => desktop}/quickshell/scripts/wallpaper-thumbnail.py (100%) rename {roles/desktop/files => desktop}/quickshell/shell.qml (100%) rename {roles/desktop/files => desktop}/quickshell/themes/catppuccin-latte/theme.json (100%) rename {roles/desktop/files => desktop}/quickshell/themes/catppuccin-mocha/theme.json (100%) rename {roles/desktop/files => desktop}/quickshell/themes/cybexos/theme.json (100%) rename {roles/desktop/files => desktop}/quickshell/themes/everforest/theme.json (100%) rename {roles/desktop/files => desktop}/quickshell/themes/gruvbox/theme.json (100%) rename {roles/desktop/files => desktop}/quickshell/themes/kanagawa/theme.json (100%) rename {roles/desktop/files => desktop}/quickshell/themes/nord/theme.json (100%) rename {roles/desktop/files => desktop}/quickshell/themes/rose-pine/theme.json (100%) rename {roles/desktop/files => desktop}/quickshell/themes/tokyo-night/theme.json (100%) create mode 100644 desktop/runtime-compatibility.json delete mode 100644 docs/audit-implementation-2026-10-21.md delete mode 100644 docs/authentication-dialog.md delete mode 100644 docs/cliproxy-rust-model-usage-research.md create mode 100644 docs/customization.md delete mode 100644 docs/dependency-policy.md delete mode 100644 docs/desktop-improvements-ledger.md delete mode 100644 docs/desktop-notifications.md delete mode 100644 docs/desktop-startup.md delete mode 100644 docs/desktop-validation.md delete mode 100644 docs/desktop-workspace-architecture.md delete mode 100644 docs/desktop-workspaces.md delete mode 100644 docs/efficiency-review-2026-10-15.md delete mode 100644 docs/fedora-major-upgrade.md delete mode 100644 docs/gdm-replacement-research.md create mode 100644 docs/hardware-security.md delete mode 100644 docs/installation-parity.md create mode 100644 docs/installation.md delete mode 100644 docs/iso-releases.md delete mode 100644 docs/launcher-ibus-focus-investigation.md delete mode 100644 docs/licensing-audit.md delete mode 100644 docs/licensing.md delete mode 100644 docs/measurements/efficiency-2026-10-15.json delete mode 100644 docs/native-system-settings-research.md delete mode 100644 docs/native-system-settings.md delete mode 100644 docs/native-titlebars.md delete mode 100644 docs/nvidia.md delete mode 100644 docs/operations.md delete mode 100644 docs/quickshell-notes.md create mode 100644 docs/recovery.md delete mode 100644 docs/security.md delete mode 100644 docs/shell-appearance.md delete mode 100644 docs/shell-settings-manual-verification.md delete mode 100644 docs/system-theme.md delete mode 100644 docs/t3-composer-manual-verification.md delete mode 100644 docs/t3-git-actions-manual-verification.md create mode 100644 docs/updates.md create mode 100644 docs/validation.md delete mode 100644 docs/xps-2026-hardware.md create mode 100644 flake.lock create mode 100644 flake.nix rename {roles/xps-2026/files => hardware/xps}/camera/71-xps-ipu7-hide-isys.rules (100%) rename {roles/xps-2026/files => hardware/xps}/camera/90-xps-ipu7-psys.rules (100%) rename {roles/xps-2026/files => hardware/xps}/camera/patches/0003-vision-ptl-cvs-owns-rgb-power.patch (100%) rename {roles/xps-2026/files => hardware/xps}/camera/patches/0004-ipu7-psys-register-bus.patch (100%) rename {roles/xps-2026/files => hardware/xps}/camera/patches/0005-ipu7-psys-stock-core-layout.patch (67%) rename {roles/xps-2026/files => hardware/xps}/camera/patches/0006-camera-hal-native-cvs-bridge.patch (97%) rename {roles/xps-2026/files => hardware/xps}/camera/xps-ipu7-abi-check (69%) rename {roles/xps-2026/files => hardware/xps}/camera/xps-ipu7-camera-frame-check (82%) create mode 100644 hardware/xps/camera/xps-ipu7-camera-init create mode 100755 hardware/xps/xps-fingerprint-check rename {roles/xps-2026/files => hardware/xps}/xps-fingerprint-detect (100%) rename {roles/xps-2026/files => hardware/xps}/xps-haptic-touchpad (98%) rename {roles/xps-2026/files => hardware/xps}/xps-speaker-filter-chain.conf (100%) rename {roles/xps-2026/files => hardware/xps}/xps-speaker-host.conf (100%) rename {roles/xps-2026/files => hardware/xps}/xps-speaker-tuning (94%) delete mode 100644 image/AUDIT-2026-09-23.md delete mode 100644 image/Containerfile.tests delete mode 100644 image/IMPLEMENTATION-2026-09-23.md delete mode 100644 image/INSTALL-AUDIT-2026-09-25.md delete mode 100644 image/INSTALLER.md delete mode 100644 image/QUALIFICATION-2026-09-26-LIFECYCLE.md delete mode 100644 image/README.md delete mode 100644 image/VALIDATION.md delete mode 100644 image/application_payload.py delete mode 100755 image/applications delete mode 100644 image/applications.yml delete mode 100755 image/benchmark-compression delete mode 100644 image/boot-theme/theme.txt delete mode 100644 image/boot_branding.py delete mode 100644 image/browser-smoke.cjs delete mode 100644 image/browser_qualification.py delete mode 100755 image/build delete mode 100755 image/build-in-guest delete mode 100644 image/build.repo delete mode 100644 image/build_support.py delete mode 100644 image/channels/CYBEXOS-desktop.asc delete mode 100644 image/channels/stable.json delete mode 100755 image/check-source delete mode 100755 image/compose delete mode 100644 image/cybexos-desktop.spec delete mode 100644 image/desktop_payload.py delete mode 100644 image/distribution_review.py delete mode 100644 image/download_cache.py delete mode 100644 image/dracut-live.conf delete mode 100644 image/fedora_target.py delete mode 100644 image/github_release.py delete mode 100644 image/install-live-rootfs delete mode 100644 image/installation_policy.py delete mode 100644 image/installed_outcomes.py delete mode 100644 image/installer-tests/browser-driver.test.cjs delete mode 100644 image/installer-tests/model.test.mjs delete mode 100644 image/library/cybexos_docker_access.py delete mode 100644 image/library/cybexos_managed_file.py delete mode 100644 image/library/cybexos_user_include.py delete mode 100644 image/library/cybexos_versionlock.py delete mode 100644 image/live-rootfs/etc/anaconda/conf.d/20-cybexos.conf delete mode 100755 image/live-rootfs/usr/bin/cybexos-installer-browser delete mode 100644 image/live-rootfs/usr/lib/systemd/system/cybexos-live.service delete mode 100644 image/live-rootfs/usr/lib/systemd/user/cybexos-install-session.target delete mode 100644 image/live-rootfs/usr/lib/systemd/user/cybexos-install-welcome.service delete mode 100755 image/live-rootfs/usr/libexec/cybexos-installer-backend delete mode 100755 image/live-rootfs/usr/libexec/cybexos-installer-seed delete mode 100755 image/live-rootfs/usr/libexec/cybexos-installer-target delete mode 100755 image/live-rootfs/usr/libexec/cybexos-live-setup delete mode 100644 image/live-rootfs/usr/share/anaconda/interactive-defaults.ks delete mode 100644 image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks delete mode 100644 image/live-rootfs/usr/share/cockpit/cybexos-installer/cybex-wordmark.svg delete mode 100644 image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html delete mode 100644 image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.css delete mode 100644 image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.js delete mode 100644 image/live-rootfs/usr/share/cockpit/cybexos-installer/manifest.json delete mode 100644 image/live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs delete mode 100644 image/live.ks delete mode 100644 image/login_qualification.py delete mode 100644 image/notification_images.py delete mode 100644 image/offline-payload.json delete mode 100644 image/offline_payload.py delete mode 100644 image/offline_upgrade_inputs.py delete mode 100755 image/package delete mode 100644 image/parity-exceptions.json delete mode 100644 image/parity_qualification.py delete mode 100644 image/post_install_check.py delete mode 100755 image/prepare-github-release delete mode 100644 image/provision.cfg delete mode 100644 image/provision.yml delete mode 100644 image/provision_payload.py delete mode 100644 image/public_update_qualification.py delete mode 100755 image/publish-pxe delete mode 100644 image/pxe_publish.py delete mode 100644 image/qmp_control.py delete mode 100644 image/qualification-results/2026-09-26/build.json delete mode 100644 image/qualification-results/2026-09-26/encrypted-nl.json delete mode 100644 image/qualification-results/2026-09-26/encrypted-us.json delete mode 100644 image/qualification-results/2026-09-26/plain-nl.json delete mode 100644 image/qualification-results/2026-09-26/plain-us.json delete mode 100644 image/qualification-results/2026-09-26/upgrade-recovery.json delete mode 100644 image/qualification.py delete mode 100644 image/qualification_contract.py delete mode 100755 image/qualify delete mode 100755 image/qualify-checkout delete mode 100644 image/real_browser_qualification.cjs delete mode 100755 image/reconstruct-iso delete mode 100755 image/release-gate delete mode 100755 image/release-repository delete mode 100755 image/release-runner delete mode 100644 image/release_metadata.py delete mode 100644 image/release_repository.py delete mode 100644 image/release_runner.py delete mode 100755 image/repair-installed delete mode 100755 image/rootfs/usr/bin/cybex delete mode 100755 image/rootfs/usr/bin/cybexos-welcome delete mode 100755 image/rootfs/usr/bin/hyprland-quickshell delete mode 100644 image/rootfs/usr/lib/systemd/system/cybexos-first-boot.service delete mode 100644 image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.service delete mode 100644 image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.timer delete mode 100644 image/rootfs/usr/lib/systemd/system/cybexos-new-owner.service delete mode 100644 image/rootfs/usr/lib/systemd/system/cybexos-reconcile.service delete mode 100644 image/rootfs/usr/lib/systemd/system/cybexos-reconcile.timer delete mode 100644 image/rootfs/usr/lib/systemd/system/cybexos-tailscale-enroll.service delete mode 100644 image/rootfs/usr/lib/systemd/user/cybexos-app-seed.service delete mode 100644 image/rootfs/usr/lib/systemd/user/cybexos-session-lock.service delete mode 100644 image/rootfs/usr/lib/systemd/user/cybexos-welcome.service delete mode 100644 image/rootfs/usr/lib/systemd/user/external-monitor-toggle.service delete mode 100644 image/rootfs/usr/lib/systemd/user/hypridle.service.d/50-cybexos.conf delete mode 100644 image/rootfs/usr/lib/systemd/user/hyprland-session.target delete mode 100644 image/rootfs/usr/lib/systemd/user/voxtype.service.d/50-cybexos-seed.conf delete mode 100755 image/rootfs/usr/libexec/cybexos-config delete mode 100755 image/rootfs/usr/libexec/cybexos-configure-installed delete mode 100755 image/rootfs/usr/libexec/cybexos-doctor delete mode 100755 image/rootfs/usr/libexec/cybexos-factory-reset delete mode 100755 image/rootfs/usr/libexec/cybexos-first-boot delete mode 100755 image/rootfs/usr/libexec/cybexos-new-owner delete mode 100755 image/rootfs/usr/libexec/cybexos-new-owner-gui delete mode 100755 image/rootfs/usr/libexec/cybexos-reconcile delete mode 100755 image/rootfs/usr/libexec/cybexos-seed-installed-users delete mode 100755 image/rootfs/usr/libexec/cybexos-update-channel delete mode 100755 image/rootfs/usr/libexec/cybexos-user-init delete mode 100644 image/rootfs/usr/share/applications/cybex.desktop delete mode 100644 image/rootfs/usr/share/cybexos/new-owner/Main.qml delete mode 100644 image/rootfs/usr/share/cybexos/welcome/Main.qml delete mode 100644 image/rootfs/usr/share/cybexos/welcome/cybex-wordmark.svg delete mode 100644 image/rootfs/usr/share/wayland-sessions/hyprland-quickshell.desktop delete mode 100644 image/sbom.py delete mode 100644 image/source_build_provenance.py delete mode 100644 image/source_snapshot.py delete mode 100644 image/system_packages.py delete mode 100755 image/test-live delete mode 100644 image/test_build_tools.py delete mode 100644 image/test_console_bootstrap.py delete mode 100644 image/test_desktop_payload.py delete mode 100644 image/test_display_policy.py delete mode 100644 image/test_doctor.py delete mode 100644 image/test_fedora_release.py delete mode 100644 image/test_github_release.py delete mode 100644 image/test_installation_parity.py delete mode 100644 image/test_installed_outcomes.py delete mode 100644 image/test_installed_policy.py delete mode 100644 image/test_installer.py delete mode 100644 image/test_installer_seed.py delete mode 100644 image/test_login_qualification.py delete mode 100644 image/test_new_owner.py delete mode 100644 image/test_new_owner_window.py delete mode 100644 image/test_nvidia.py delete mode 100644 image/test_offline_payload.py delete mode 100644 image/test_offline_upgrade_inputs.py delete mode 100644 image/test_post_install_check.py delete mode 100644 image/test_provision_containment.py delete mode 100644 image/test_public_update_qualification.py delete mode 100644 image/test_qualification.py delete mode 100644 image/test_reconfigure.py delete mode 100644 image/test_release.py delete mode 100644 image/test_release_gate.py delete mode 100644 image/test_release_runner.py delete mode 100644 image/test_release_signing.py delete mode 100644 image/test_sbom.py delete mode 100644 image/test_session_start.py delete mode 100644 image/test_source_build_provenance.py delete mode 100644 image/test_system_packages.py delete mode 100644 image/test_update_channel.py delete mode 100644 image/test_update_recovery.py delete mode 100644 image/test_upgrade_lifecycle.py delete mode 100644 image/test_user_parity.py delete mode 100644 image/test_versionlock.py delete mode 100644 image/test_welcome_setup.py delete mode 100755 image/tests delete mode 100644 image/titlebars delete mode 100644 image/titlebars_payload.py delete mode 100644 image/upgrade_qualification.py delete mode 100755 image/vm-control delete mode 100644 image/vm_testing.py create mode 100755 installer/cybexos-install delete mode 100644 inventory/group_vars/all.yml delete mode 100644 inventory/hosts.yml create mode 100755 lifecycle/bin/cybex create mode 100755 lifecycle/bin/cybexos-admin create mode 100755 lifecycle/bin/cybexos-boot-trial create mode 100755 lifecycle/bin/cybexos-desktop-check create mode 100755 lifecycle/bin/cybexos-health create mode 100755 lifecycle/bin/cybexos-personal-init create mode 100755 lifecycle/bin/cybexos-request create mode 100755 lifecycle/bin/cybexos-verify-encryption create mode 100644 lifecycle/cybexos/__init__.py create mode 100644 lifecycle/cybexos/boot.py create mode 100644 lifecycle/cybexos/cli.py create mode 100644 lifecycle/cybexos/common.py create mode 100644 lifecycle/cybexos/configuration.py create mode 100644 lifecycle/cybexos/desktop_check.py create mode 100644 lifecycle/cybexos/engine.py create mode 100644 lifecycle/cybexos/health.py create mode 100644 lifecycle/cybexos/inputs.py create mode 100644 lifecycle/cybexos/trust.py create mode 100644 nix/desktop-compatibility.nix create mode 100644 nix/modules/applications.nix create mode 100644 nix/modules/default.nix create mode 100644 nix/modules/desktop.nix create mode 100644 nix/modules/hardware.nix create mode 100644 nix/modules/installer.nix create mode 100644 nix/modules/lifecycle.nix create mode 100644 nix/modules/security.nix create mode 100644 nix/packages/brave-origin.nix create mode 100644 nix/packages/chatgpt.nix create mode 100644 nix/packages/desktop.nix create mode 100644 nix/packages/fonts.nix create mode 100644 nix/packages/hermes-bridge.nix create mode 100644 nix/packages/intel-lpmd.nix create mode 100644 nix/packages/ipu7.nix create mode 100644 nix/packages/mdview-Cargo.lock create mode 100644 nix/packages/mdview.nix create mode 100644 nix/packages/networkmanager-libreswan.nix create mode 100644 nix/packages/omawrite.nix create mode 100644 nix/packages/t3-code.nix create mode 100644 nix/packages/titlebars.nix create mode 100644 nix/packages/updater.nix create mode 100644 nix/patches/aquamarine-explicit-bool.patch create mode 100644 nix/tests/desktop-runtime.py create mode 100644 nix/tests/lifecycle.nix create mode 100644 nix/tests/machine.nix create mode 100644 nix/tests/parity.nix delete mode 100644 plugins/callback/cybexos.py delete mode 100644 release-manifest.json create mode 100644 release/keys/trust.json create mode 100644 release/manage.py create mode 100644 release/notes.md create mode 100755 release/require-immutable-releases create mode 100644 release/verify-assets.py delete mode 100644 roles/apps/defaults/main.yml delete mode 100644 roles/apps/files/1password.repo delete mode 100644 roles/apps/files/49-cybexos-defaults.conf delete mode 100644 roles/apps/files/brave-browser.repo delete mode 100644 roles/apps/files/chatgpt-repository-key.asc delete mode 100644 roles/apps/files/chatgpt.repo delete mode 100644 roles/apps/files/cybexos-common.sh delete mode 100755 roles/apps/files/cybexos-repository-policy delete mode 100755 roles/apps/files/github-release-install delete mode 100755 roles/apps/files/omawrite-install delete mode 100644 roles/apps/files/omawrite.desktop delete mode 100644 roles/apps/files/rpmfusion-free-fedora-2020.asc delete mode 100644 roles/apps/files/rpmfusion-nonfree-fedora-2020.asc delete mode 100755 roles/apps/files/source-app-build delete mode 100644 roles/apps/files/tailscale.repo delete mode 100644 roles/apps/handlers/main.yml delete mode 100644 roles/apps/tasks/android.yml delete mode 100644 roles/apps/tasks/devtools.yml delete mode 100644 roles/apps/tasks/flatpaks.yml delete mode 100644 roles/apps/tasks/fonts.yml delete mode 100644 roles/apps/tasks/main.yml delete mode 100644 roles/apps/tasks/mpv.yml delete mode 100644 roles/apps/tasks/omawrite.yml delete mode 100644 roles/apps/tasks/packages.yml delete mode 100644 roles/apps/tasks/repos.yml delete mode 100644 roles/apps/tasks/rpmfusion.yml delete mode 100644 roles/apps/tasks/upstream.yml delete mode 100644 roles/apps/tasks/voxtype-backend.yml delete mode 100644 roles/apps/templates/android-sdk-update.j2 delete mode 100644 roles/apps/templates/update-user-tools.j2 delete mode 100644 roles/base/defaults/main.yml delete mode 100644 roles/base/files/60-cybexos-hardening.conf delete mode 100755 roles/base/files/cybexos-auth-policy delete mode 100755 roles/base/files/cybexos-major-upgrade delete mode 100644 roles/base/files/cybexos-major-upgrade-validate.service delete mode 100644 roles/base/files/cybexos-major-upgrade-validate.timer delete mode 100755 roles/base/files/cybexos-system-snapshot delete mode 100755 roles/base/files/cybexos-update-bootstrap delete mode 100755 roles/base/files/cybexos-update-download delete mode 100644 roles/base/files/cybexos-update-download.service delete mode 100644 roles/base/files/cybexos-update-download.timer delete mode 100755 roles/base/files/cybexos-update-recover delete mode 100644 roles/base/files/cybexos-update-recover-login.conf delete mode 100644 roles/base/files/cybexos-update-recover.service delete mode 100755 roles/base/files/cybexos-update-transaction delete mode 100644 roles/base/files/cybexos-vendor-paths.json delete mode 100755 roles/base/files/recovery/42_cybexos_recovery delete mode 100644 roles/base/files/recovery/90-cybexos-recovery.conf delete mode 100755 roles/base/files/recovery/90cybexos-recovery/cybexos-recovery-overlay.sh delete mode 100755 roles/base/files/recovery/90cybexos-recovery/module-setup.sh delete mode 100755 roles/base/files/recovery/95-cybexos-recovery.install delete mode 100644 roles/base/files/recovery/cybexos-recovery-refresh.service delete mode 100644 roles/base/handlers/main.yml delete mode 100644 roles/base/tasks/accounts.yml delete mode 100644 roles/base/tasks/authentication.yml delete mode 100644 roles/base/tasks/btrfs-scrub.yml delete mode 100644 roles/base/tasks/docker-access.yml delete mode 100644 roles/base/tasks/docker-activation.yml delete mode 100644 roles/base/tasks/image.yml delete mode 100644 roles/base/tasks/main.yml delete mode 100644 roles/base/tasks/mdns.yml delete mode 100644 roles/base/tasks/packages.yml delete mode 100644 roles/base/tasks/reconcile-scrub.yml delete mode 100644 roles/base/tasks/reconcile.yml delete mode 100644 roles/base/tasks/recovery.yml delete mode 100644 roles/base/tasks/services.yml delete mode 100644 roles/base/tasks/system-packages.yml delete mode 100644 roles/base/tasks/versionlock.yml delete mode 100644 roles/base/templates/cybexos-zone.xml.j2 delete mode 100644 roles/base/templates/locale.conf.j2 delete mode 100644 roles/boot/defaults/main.yml delete mode 100644 roles/boot/handlers/main.yml delete mode 100644 roles/boot/tasks/main.yml delete mode 100644 roles/desktop/files/90-cybexos-ibus.gschema.override delete mode 100644 roles/desktop/files/90-cybexos-smb.gschema.override delete mode 100644 roles/desktop/files/autostart.lua delete mode 100644 roles/desktop/files/cybexos-backup.service delete mode 100644 roles/desktop/files/cybexos-backup.timer delete mode 100644 roles/desktop/files/cybexos-input-method.service delete mode 100644 roles/desktop/files/hyprland-session-start delete mode 100644 roles/desktop/files/login/50-cybexos.conf delete mode 100755 roles/desktop/files/login/cybexos-login-prepare delete mode 100644 roles/desktop/files/login/sddm-autologin delete mode 100644 roles/desktop/files/quickshell/Common/Recovery.qml delete mode 100644 roles/desktop/files/quickshell/Common/RecoveryHelpers.js delete mode 100644 roles/desktop/files/quickshell/Common/UpdateLogReader.qml delete mode 100644 roles/desktop/files/quickshell/Common/Updates.qml delete mode 100644 roles/desktop/files/quickshell/Common/UpdatesHelpers.js delete mode 100644 roles/desktop/files/quickshell/Popovers/UpdatesPopover.qml delete mode 100644 roles/desktop/files/quickshell/Settings/FactoryResetRow.qml delete mode 100644 roles/desktop/files/quickshell/Settings/RecoveryGroup.qml delete mode 100644 roles/desktop/files/quickshell/assets/fedora-white.svg delete mode 100644 roles/desktop/files/quickshell/assets/fedora.svg delete mode 100644 roles/desktop/files/quickshell/scripts/update-client delete mode 100644 roles/desktop/files/titlebars-build.path delete mode 100644 roles/desktop/files/titlebars-build.service delete mode 100755 roles/desktop/files/titlebars-install delete mode 100644 roles/desktop/handlers/main.yml delete mode 100644 roles/desktop/tasks/hermes-menubar.yml delete mode 100644 roles/desktop/tasks/main.yml delete mode 100644 roles/desktop/tasks/notification-images.yml delete mode 100644 roles/desktop/tasks/portals.yml delete mode 100644 roles/desktop/tasks/titlebars.yml delete mode 100644 roles/desktop/templates/cybexos-session-lock.service.j2 delete mode 100644 roles/desktop/templates/features.lua.j2 delete mode 100644 roles/desktop/templates/hermes-menubar-bridge.service.j2 delete mode 100644 roles/desktop/templates/hypridle.service.j2 delete mode 100644 roles/desktop/templates/hyprland-copr.repo.j2 delete mode 100644 roles/desktop/templates/hyprland-quickshell.j2 delete mode 100644 roles/desktop/templates/monitors.lua.j2 delete mode 100644 roles/desktop/templates/quickshell.service.j2 delete mode 100644 roles/desktop/templates/voxtype.service.j2 delete mode 100644 roles/dotfiles/defaults/main.yml delete mode 100644 roles/dotfiles/files/brave-origin.desktop delete mode 100644 roles/dotfiles/files/chatgpt.desktop delete mode 100644 roles/dotfiles/files/com.onepassword.OnePassword.desktop delete mode 100755 roles/dotfiles/files/dev-distrobox-init delete mode 100644 roles/dotfiles/files/manage-firefox-policy delete mode 100644 roles/dotfiles/files/nvim-kitty.desktop delete mode 100644 roles/dotfiles/handlers/main.yml delete mode 100644 roles/dotfiles/tasks/agent-skills.yml delete mode 100644 roles/dotfiles/tasks/environment.yml delete mode 100644 roles/dotfiles/tasks/main.yml delete mode 100644 roles/dotfiles/tasks/personal.yml delete mode 100644 roles/dotfiles/tasks/shell-defaults.yml delete mode 100644 roles/dotfiles/tasks/wallpapers.yml delete mode 100644 roles/dotfiles/templates/cybex.j2 delete mode 100644 roles/dotfiles/templates/dev-arch.desktop.j2 delete mode 100644 roles/dotfiles/templates/dev-debian.desktop.j2 delete mode 100644 roles/dotfiles/templates/dev-fedora.desktop.j2 delete mode 100644 roles/dotfiles/templates/distrobox-shell.j2 delete mode 100644 roles/dotfiles/templates/environment.conf.j2 delete mode 100644 roles/dotfiles/templates/localsend-share-clipboard.desktop.j2 delete mode 100644 roles/dotfiles/templates/localsend-share-file.desktop.j2 delete mode 100644 roles/dotfiles/templates/localsend-share-folder.desktop.j2 delete mode 100644 roles/dotfiles/templates/mimeapps.list.j2 delete mode 100644 roles/dotfiles/templates/portal-launcher.j2 delete mode 100644 roles/dotfiles/templates/spotify.j2 delete mode 100644 roles/dotfiles/templates/t3code-desktop.j2 delete mode 100644 roles/dotfiles/templates/t3code-nightly.desktop.j2 delete mode 100644 roles/dotfiles/templates/t3code-update.j2 delete mode 100644 roles/finalize/tasks/main.yml delete mode 100644 roles/legacy-names/tasks/main.yml delete mode 100644 roles/nvidia/defaults/main.yml delete mode 100755 roles/nvidia/files/cybexos-nvidia delete mode 100644 roles/nvidia/tasks/detect.yml delete mode 100644 roles/nvidia/tasks/main.yml delete mode 100644 roles/nvidia/tasks/setup.yml delete mode 100755 roles/private-hooks/files/build-asdcontrol delete mode 100644 roles/private-hooks/handlers/main.yml delete mode 100644 roles/private-hooks/tasks/main.yml delete mode 100644 roles/private-hooks/templates/sccache.j2 delete mode 100644 roles/uninstall/tasks/main.yml delete mode 100644 roles/xps-2026/defaults/main.yml delete mode 100644 roles/xps-2026/files/camera/v4l2-relayd-ipu7.conf delete mode 100644 roles/xps-2026/files/camera/v4l2-relayd-override.conf delete mode 100644 roles/xps-2026/files/camera/xps-ipu7-camera-check delete mode 100644 roles/xps-2026/files/camera/xps-ipu7-camera-init delete mode 100644 roles/xps-2026/files/camera/xps-ipu7-camera-sleep delete mode 100644 roles/xps-2026/files/camera/xps-ipu7-camera-stack.spec delete mode 100644 roles/xps-2026/files/camera/xps-ipu7-camera.modprobe.conf delete mode 100755 roles/xps-2026/files/camera/xps-ipu7-userspace-check delete mode 100644 roles/xps-2026/files/tuned-ppd.conf delete mode 100644 roles/xps-2026/files/tuned-xps-performance.conf delete mode 100755 roles/xps-2026/files/xps-fingerprint-check delete mode 100644 roles/xps-2026/handlers/main.yml delete mode 100644 roles/xps-2026/tasks/backlight.yml delete mode 100644 roles/xps-2026/tasks/camera-disabled.yml delete mode 100644 roles/xps-2026/tasks/camera.yml delete mode 100644 roles/xps-2026/tasks/detect.yml delete mode 100644 roles/xps-2026/tasks/display.yml delete mode 100644 roles/xps-2026/tasks/fingerprint.yml delete mode 100644 roles/xps-2026/tasks/main.yml delete mode 100644 roles/xps-2026/tasks/packages.yml delete mode 100644 roles/xps-2026/tasks/power.yml delete mode 100644 roles/xps-2026/tasks/speaker.yml delete mode 100644 roles/xps-2026/tasks/touchpad.yml delete mode 100644 roles/xps-2026/templates/90-xps-ipu7-v4l2.conf.j2 delete mode 100644 roles/xps-2026/templates/xps-haptic-touchpad.conf.j2 delete mode 100644 roles/xps-2026/templates/xps-haptic-touchpad.service.j2 delete mode 100644 roles/xps-2026/templates/xps-ipu7-build.j2 delete mode 100644 roles/xps-2026/templates/xps-ipu7-camera-init.service.j2 delete mode 100644 roles/xps-2026/templates/xps-ipu7-camera-resume.service.j2 delete mode 100644 roles/xps-2026/templates/xps-ipu7-camera.service.j2 delete mode 100644 roles/xps-2026/templates/xps-ipu7-refresh-wireplumber.j2 delete mode 100644 roles/xps-2026/templates/xps-speaker-tuning.service.j2 create mode 100755 scripts/check-ci-capacity create mode 100755 scripts/check-image-parity delete mode 100755 scripts/installer-defaults delete mode 100755 scripts/manage-agent-skills delete mode 100755 scripts/migrate-config delete mode 100755 scripts/migrate-legacy-names create mode 100755 scripts/qualify-installations delete mode 100644 site.yml create mode 100644 templates/machine/desktop-settings.json create mode 100644 templates/machine/flake.nix create mode 100644 templates/machine/local/default.nix create mode 100644 templates/machine/settings.nix delete mode 100755 tests/agent-skill.py delete mode 100755 tests/agent-skills delete mode 100644 tests/application-defaults.py delete mode 100755 tests/brightness-routing delete mode 100644 tests/callback-smoke.py delete mode 100644 tests/camera-transaction.py delete mode 100644 tests/cybex-deployment.py delete mode 100755 tests/cybexos-update-run create mode 100644 tests/desktop-smoke/shell.qml delete mode 100644 tests/distribution-review.py delete mode 100644 tests/fedora-release.py delete mode 100755 tests/fedora-vm-convergence delete mode 100644 tests/firefox-policy.py delete mode 100755 tests/hyprland-features delete mode 100755 tests/hyprland-input delete mode 100755 tests/hyprland-workspace-modes delete mode 100755 tests/hyprland-workspaces delete mode 100644 tests/installation-parity.py delete mode 100644 tests/installer-convergence.py delete mode 100644 tests/launcher-input-live.py delete mode 100755 tests/legacy-names delete mode 100644 tests/login-policy.py delete mode 100644 tests/major-upgrade.py create mode 100644 tests/nixos-native rename tests/{ => nixos}/camera-frame.py (86%) rename tests/{ => nixos}/default-agent.py (93%) rename tests/{ => nixos}/hermes-bridge.py (97%) mode change 100644 => 100755 rename tests/{ => nixos}/hermes-remote-runtime.py (91%) mode change 100644 => 100755 rename tests/{ => nixos}/hermes-webui-auth.py (99%) mode change 100644 => 100755 rename tests/{backup.py => nixos/test_backup.py} (97%) rename tests/{bluetooth-tool.py => nixos/test_bluetooth_tool.py} (97%) create mode 100644 tests/nixos/test_boot.py rename tests/{clipboard-tool.py => nixos/test_clipboard_tool.py} (95%) create mode 100644 tests/nixos/test_desktop_health.py create mode 100644 tests/nixos/test_encryption.py rename tests/{firmware-update.py => nixos/test_firmware_update.py} (86%) rename tests/{fusebox.py => nixos/test_fusebox.py} (99%) rename tests/{game-mode.py => nixos/test_game_mode.py} (91%) create mode 100644 tests/nixos/test_installer.py create mode 100644 tests/nixos/test_lifecycle.py rename tests/{native-input-region.py => nixos/test_native_input_region.py} (80%) mode change 100755 => 100644 rename tests/{network-connections.py => nixos/test_network_connections.py} (95%) create mode 100644 tests/nixos/test_personal_init.py create mode 100644 tests/nixos/test_release.py rename tests/{remote-server.py => nixos/test_remote_server.py} (96%) rename tests/{cybexos-report.py => nixos/test_report.py} (65%) mode change 100755 => 100644 create mode 100644 tests/nixos/test_runtime_ipc.py rename tests/{screenshot-stitch.py => nixos/test_screenshot_stitch.py} (97%) mode change 100755 => 100644 rename tests/{security-settings.py => nixos/test_security_settings.py} (63%) mode change 100644 => 100755 rename tests/{shell-recovery.py => nixos/test_shell_recovery.py} (91%) rename tests/{wallpaper-download.py => nixos/test_wallpaper_download.py} (98%) rename tests/{cybexos-webapp.py => nixos/test_webapps.py} (91%) delete mode 100644 tests/notification-focus-live.py delete mode 100755 tests/nvidia-hardware.py delete mode 100755 tests/omawrite.py delete mode 100755 tests/ownership-layering.py delete mode 100755 tests/quickshell-deploy-health delete mode 100755 tests/quickshell-deploy-health-fixture delete mode 100644 tests/quickshell-deployment.py delete mode 100644 tests/quickshell/converge-efficiency.test.cjs delete mode 100644 tests/quickshell/deployment-convergence.test.cjs delete mode 100644 tests/quickshell/external-monitor-toggle.test.cjs create mode 100644 tests/quickshell/firmware-access.test.cjs delete mode 100644 tests/quickshell/input-method.test.cjs delete mode 100644 tests/quickshell/recovery.test.cjs delete mode 100644 tests/quickshell/t3code-desktop.test.cjs delete mode 100644 tests/quickshell/update-client.test.cjs create mode 100644 tests/quickshell/update-recovery.test.cjs delete mode 100644 tests/quickshell/updates-preview.test.cjs delete mode 100644 tests/quickshell/updates-schedule.test.cjs delete mode 100644 tests/quickshell/updates.test.cjs delete mode 100644 tests/quickshell/verify-cli.test.cjs delete mode 100644 tests/quickshell/xps-backlight.test.cjs delete mode 100755 tests/release-update delete mode 100644 tests/repository-policy.py delete mode 100755 tests/screenshot-workflow delete mode 100644 tests/session-launcher.py delete mode 100644 tests/shell-health.py delete mode 100755 tests/startup-evidence.py delete mode 100755 tests/static-analysis delete mode 100755 tests/system-settings-live delete mode 100644 tests/system-settings-network-integration.py delete mode 100755 tests/system-settings-network-namespace delete mode 100755 tests/system-snapshot delete mode 100755 tests/system-snapshot.py delete mode 100755 tests/t3-contract-snapshot delete mode 100644 tests/titlebars.py delete mode 100644 tests/update-bootstrap.py delete mode 100755 tests/update-download.py delete mode 100644 tests/update-transaction.py delete mode 100644 tests/user-adoption.py delete mode 100755 tests/verify-system delete mode 100755 tests/vm/assert-converged delete mode 100755 tests/vm/assert-uninstalled delete mode 100644 tests/vm/convergence-vars.yml delete mode 100755 uninstall delete mode 100644 uninstall.yml create mode 100644 web/public/cybexos-white.svg create mode 100644 web/public/cybexos.svg delete mode 100644 web/public/fedora.svg create mode 100644 web/public/licenses/cybexos-mark.txt delete mode 100644 web/public/licenses/fedora-mark.txt diff --git a/.ansible-lint b/.ansible-lint deleted file mode 100644 index a2f46dc9..00000000 --- a/.ansible-lint +++ /dev/null @@ -1,5 +0,0 @@ -profile: min -offline: true -exclude_paths: - - .cache/ - - assets/ diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3c244e92..03682c2a 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -6,6 +6,6 @@ contact_links: - name: Documentation url: https://github.com/DigitalPals/CybexOS#readme about: Installation, updates, recovery, and links to the guides in docs/. - - name: Operations and recovery guide - url: https://github.com/DigitalPals/CybexOS/blob/main/docs/operations.md - about: Commands, update logs, recovery points, and troubleshooting. + - name: Recovery guide + url: https://github.com/DigitalPals/CybexOS/blob/main/docs/recovery.md + about: Deployment recovery, rollback, persistent state, and troubleshooting. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..cae12c72 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - cybexos-isolated diff --git a/.github/actions/fedora-source-environment/action.yml b/.github/actions/fedora-source-environment/action.yml deleted file mode 100644 index 6fe22cf8..00000000 --- a/.github/actions/fedora-source-environment/action.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: Fedora source-test environment -description: >- - Install the source-test dependencies, configure the fingerprint-verified - Hyprland COPR and assert the container's Fedora release. The required source - contract (tests.yml) and the non-blocking next-release preview - (fedora-preview.yml) share this definition, so their environments cannot - drift apart. Run it after actions/checkout. - -inputs: - fedora-release: - description: >- - Fedora release the container must report. Empty means the release the - installation contract targets: inventory/group_vars/all.yml - fedora_release, which must also be in release-manifest.json. - required: false - default: "" - best-effort: - description: >- - "true" only for a preview of an unqualified release: packages and COPR - builds that release does not provide yet are reported and skipped, so - every check that can still run does. The required job never sets it. - required: false - default: "false" - -outputs: - unavailable: - description: >- - What a best-effort run could not install, space-separated; empty when - the release provides everything. - value: ${{ steps.packages.outputs.unavailable }}${{ steps.copr.outputs.unavailable }} - -runs: - using: composite - steps: - - name: Install source-test dependencies - id: packages - shell: bash - env: - BEST_EFFORT: ${{ inputs.best-effort }} - run: | - skip=() unavailable='' - if [ "$BEST_EFFORT" = true ]; then skip=(--skip-unavailable --skip-broken); fi - install_packages() { - dnf install -y "${skip[@]}" "$@" - if [ "$BEST_EFFORT" = true ]; then - unavailable+=$(rpm -q --whatprovides "$@" \ - | sed -n 's/^no package provides //p' | tr '\n' ' ') || true - fi - } - install_packages \ - ansible-core \ - python3-ansible-lint \ - python3-ruff \ - ShellCheck \ - yamllint \ - curl \ - dbus-daemon \ - desktop-file-utils \ - dnf5-plugins \ - findutils \ - git \ - glib2 \ - gnupg2 \ - jq \ - luajit \ - nodejs24 \ - patch \ - procps-ng \ - python3 \ - python3-gobject-base \ - python3-websockets \ - qt6-qtdeclarative-devel \ - qt6-qtwebsockets-devel \ - qt6-qtmultimedia \ - shared-mime-info \ - util-linux \ - xdg-utils \ - ripgrep - install_packages sway - echo "unavailable=$unavailable" >>"$GITHUB_OUTPUT" - - - name: Configure the fingerprint-verified Hyprland COPR - id: copr - shell: bash - env: - BEST_EFFORT: ${{ inputs.best-effort }} - run: | - curl --fail --location --retry 3 \ - --output /tmp/hyprland-copr.gpg \ - https://download.copr.fedorainfracloud.org/results/sdegler/hyprland/pubkey.gpg - echo '581bd602d10705eb89003680335ffc3684b922c587073b6e52c371f949843fd7 /tmp/hyprland-copr.gpg' \ - | sha256sum --check - test "$(gpg --show-keys --with-colons /tmp/hyprland-copr.gpg \ - | awk -F: '$1 == "fpr" { print $10; exit }')" \ - = 64BBBF013D1CA5E4BE5B0552C043104207862204 - install -m 0644 /tmp/hyprland-copr.gpg \ - /etc/pki/rpm-gpg/CYBEXOS-sdegler-hyprland-copr.gpg - install -m 0644 roles/desktop/templates/hyprland-copr.repo.j2 \ - '/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:sdegler:hyprland.repo' - python3 .github/actions/fedora-source-environment/qualified_packages.py prepare - # The key is verified either way. Only a preview tolerates a COPR - # without a build for its release, and it says so on the run. - if ! dnf install -y quickshell || \ - ! python3 .github/actions/fedora-source-environment/qualified_packages.py verify quickshell; then - test "$BEST_EFFORT" = true - . /etc/os-release - echo "::warning title=Hyprland COPR::sdegler/hyprland has no installable quickshell for fedora-$VERSION_ID-x86_64; checks that need qs will fail." - rm -f '/etc/yum.repos.d/_copr:copr.fedorainfracloud.org:sdegler:hyprland.repo' - echo "unavailable=quickshell " >>"$GITHUB_OUTPUT" - fi - - - name: Assert the requested Fedora release - shell: bash - env: - REQUESTED_FEDORA: ${{ inputs.fedora-release }} - run: | - targeted=$(python3 image/fedora_target.py) - . /etc/os-release - test "$ID" = fedora - test "$VERSION_ID" = "${REQUESTED_FEDORA:-$targeted}" - echo "Fedora $VERSION_ID container; the installation contract targets Fedora $targeted." diff --git a/.github/actions/fedora-source-environment/qualified_packages.py b/.github/actions/fedora-source-environment/qualified_packages.py deleted file mode 100644 index ee798792..00000000 --- a/.github/actions/fedora-source-environment/qualified_packages.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -"""Use the installed product's package-series contract in source CI.""" -import argparse -import importlib.util -from pathlib import Path - -import yaml - -ROOT = Path(__file__).resolve().parents[3] -SPEC = importlib.util.spec_from_file_location("cybexos_versionlock", ROOT / "image/library/cybexos_versionlock.py") -policy = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(policy) - - -def prepare(series, path): - _, preserved = policy.manage(path, series) - if preserved: - raise ValueError(f"Cannot enforce qualified packages: {preserved}") - - -def verify(series, names, installed=policy.installed_evr): - policy.validate_series(series) - selected = [entry for entry in series if entry["name"] in names] - unknown = set(names) - {entry["name"] for entry in selected} - if unknown: - raise ValueError("No qualified series for " + ", ".join(sorted(unknown))) - versions = {entry["name"]: installed(entry["name"]) for entry in selected} - problems = policy.outside_series(selected, versions.get, require=True) - if problems: - raise ValueError("; ".join(problems)) - return versions - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("prepare", "verify")) - parser.add_argument("packages", nargs="*", default=["quickshell"]) - parser.add_argument("--lock-file", type=Path, default=Path(policy.DEFAULT_PATH)) - args = parser.parse_args() - try: - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - series = inventory["qualified_package_series"] - if args.command == "prepare": - prepare(series, args.lock_file) - else: - for name, version in verify(series, args.packages or ["quickshell"]).items(): - print(f"Qualified runtime: {name} {version}") - except (OSError, ValueError, KeyError) as error: - parser.exit(1, f"qualified packages: {error}\n") - - -if __name__ == "__main__": - main() diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2299cf1f..08483e72 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,36 +1,14 @@ -## Summary +Describe the concrete behavior changed and why. - +Validation: -## Installation paths +- Source/module tests and candidate desktop checks: +- Real ISO/installer/updater scenarios exercised: +- Exact tested source revision and NAR identity: +- Personal configuration/state and public plugin API coverage: +- Hardware/security checks, limitations and release blockers: +- Task-created artifacts retained (path, size, reason) and cleanup: - - -- [ ] Not an installed-product change (documentation, CI, tests, or tooling only) -- [ ] ISO path covered (image packaging, `cybexos-desktop` RPM, provisioning, rootfs, repair, or reconcile as applicable) -- [ ] Source-checkout path covered (Ansible roles applied by `./install`) -- [ ] Parity regression coverage added or extended (for example `image/test_installation_parity.py`, `image/test_desktop_payload.py`, `image/test_installed_policy.py`, `image/test_user_parity.py`) -- [ ] Saved user choices and user-owned data are preserved - -## Documentation - -- [ ] User-facing documentation updated (README, `docs/`, `image/README.md`, `agent-skills/cybexos/references/`), or none is affected - -## Checks - - - -- `./tests/run`: -- `python3 -B image/check-source`: -- Manual verification (hardware, live shell, VM), or why it was not needed: - -## Dependencies and licensing - -- [ ] New downloads are pinned by version or commit and checksum ([dependency policy](https://github.com/DigitalPals/CybexOS/blob/main/docs/dependency-policy.md)), or none were added -- [ ] New third-party code, artwork, fonts, or images have recorded provenance and a redistributable license ([licensing](https://github.com/DigitalPals/CybexOS/blob/main/docs/licensing.md)), or none were added - -## Privacy - -- [ ] No secrets, tokens, personal data, or unredacted logs in the diff, screenshots, or this description +A fixture suite or Nix build is not end-to-end qualification. Product policy +belongs in shared NixOS modules; never silently discard personal configuration +to make an update pass. diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml deleted file mode 100644 index e6b1ffd4..00000000 --- a/.github/workflows/desktop-release.yml +++ /dev/null @@ -1,213 +0,0 @@ -name: ISO lifecycle qualification - -on: - workflow_dispatch: - inputs: - tag: - description: Reviewed release tag matching VERSION - required: true - type: string - baseline_iso: - description: Older supported ISO on the PXE host under /data/pxe/iso - required: true - type: string - workflow_call: - inputs: - tag: - required: true - type: string - baseline_iso: - required: true - type: string - secrets: - CYBEXOS_RPM_SIGNING_KEY: - required: false - -permissions: - contents: read - -concurrency: - group: cybexos-iso-release - cancel-in-progress: false - -jobs: - qualify: - # Debian 13 PXE host: only reviewed main/tag code, with no signing secret - # or desktop-release environment attached to this machine. - if: github.repository == 'DigitalPals/CybexOS' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) - runs-on: cybexos-iso-${{ github.run_id }} - timeout-minutes: 360 - env: - RELEASE_TAG: ${{ inputs.tag }} - BASELINE_ISO: ${{ inputs.baseline_iso }} - PYTHONDONTWRITEBYTECODE: '1' - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Prepare isolated release workspace - run: | - set -euo pipefail - work=$(mktemp -d "$RUNNER_TEMP/cybexos-release.XXXXXXXX") - printf 'RELEASE_WORK=%s\n' "$work" >> "$GITHUB_ENV" - test -r /data/pxe/README.md - test -n "$BASELINE_ISO" - systemctl is-active --quiet iventoy.service - image/build --preflight - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: '24' - - name: Install isolated browser test driver - run: | - npm install --prefix "$RELEASE_WORK/browser" --no-audit --no-fund --ignore-scripts playwright-core@1.63.0 - printf 'NODE_PATH=%s\n' "$RELEASE_WORK/browser/node_modules" >> "$GITHUB_ENV" - - name: Build and qualify graphical installs, upgrade and recovery - run: | - image/release-gate --execute --output "$RELEASE_WORK/qualification" \ - --tag "$RELEASE_TAG" --baseline-iso "$BASELINE_ISO" - - name: Transfer qualified artifacts and reports to the hosted signing job - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: cybexos-qualified-desktop - # The common root is qualification/. Do not upload VM disks, console - # logs, browser state, the builder cache, or the source checkout. - path: | - ${{ env.RELEASE_WORK }}/qualification/build/artifacts/ - ${{ env.RELEASE_WORK }}/qualification/*/qualification.json - ${{ env.RELEASE_WORK }}/qualification/release-gate.json - if-no-files-found: error - compression-level: 0 - retention-days: 2 - - name: Retain bounded qualification evidence - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: cybexos-qualification-reports - path: | - ${{ env.RELEASE_WORK }}/qualification/release-gate.json - ${{ env.RELEASE_WORK }}/qualification/*/qualification.json - retention-days: 14 - if-no-files-found: ignore - - name: Remove task staging - if: always() - run: | - if [[ -n ${RELEASE_WORK:-} && $RELEASE_WORK == "$RUNNER_TEMP"/cybexos-release.* ]]; then - rm -rf -- "$RELEASE_WORK" - fi - - sign: - name: Sign qualified desktop artifacts on a hosted Fedora container - needs: qualify - runs-on: ubuntu-latest - environment: desktop-release - timeout-minutes: 120 - env: - RELEASE_TAG: ${{ inputs.tag }} - PYTHONDONTWRITEBYTECODE: '1' - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - - name: Reserve space for raw and prepared desktop artifacts - run: | - set -euo pipefail - # This job owns its disposable GitHub-hosted VM. Remove only unused - # preinstalled SDKs; runner tools, Docker and our checkout stay intact. - sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup - available=$(df --output=avail -B1 "$RUNNER_TEMP" | tail -n 1) - if (( available < 24 * 1024 * 1024 * 1024 )); then - echo 'Signing requires at least 24 GiB free for raw and prepared artifacts.' >&2 - exit 1 - fi - work=$(mktemp -d "$RUNNER_TEMP/cybexos-signing.XXXXXXXX") - printf 'RELEASE_WORK=%s\n' "$work" >> "$GITHUB_ENV" - - name: Prepare Fedora 44 RPM signing tools without private key access - run: | - docker build --tag cybexos-release-signing:local - <<'DOCKERFILE' - FROM fedora:44 - RUN dnf -y --setopt=install_weak_deps=False install python3 rpm rpm-sign createrepo_c gnupg2 tar gzip && dnf clean all - DOCKERFILE - - name: Download the exact qualified build and reports - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: cybexos-qualified-desktop - path: ${{ env.RELEASE_WORK }}/qualified - - name: Sign RPM and prepare verified release assets - env: - RELEASE_SIGNING_KEY: ${{ secrets.CYBEXOS_RPM_SIGNING_KEY }} - run: | - set -euo pipefail - test -n "$RELEASE_SIGNING_KEY" - # The key enters only this ephemeral hosted container. Its value is - # passed through the environment, never arguments or shell tracing. - docker run --rm --init --interactive \ - --name "cybexos-signing-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ - --env RELEASE_SIGNING_KEY --env RELEASE_TAG --env GITHUB_REPOSITORY \ - --env PYTHONDONTWRITEBYTECODE=1 \ - --volume "$GITHUB_WORKSPACE:/source:ro" \ - --volume "$RELEASE_WORK:/release-work" \ - --workdir /source cybexos-release-signing:local bash -s <<'SIGN' - set -euo pipefail - keyhome=/release-work/signing - install -d -m 0700 "$keyhome" - trap 'gpgconf --homedir "$keyhome" --kill all; rm -rf -- "$keyhome"' EXIT - printf '%s' "$RELEASE_SIGNING_KEY" | gpg --homedir "$keyhome" --batch --import - unset RELEASE_SIGNING_KEY - fingerprint=$(python3 -c 'import json; print(json.load(open("image/channels/stable.json"))["fingerprint"])') - baseurl=$(python3 -c 'import json; print(json.load(open("image/channels/stable.json"))["baseurl"])') - image/release-repository /release-work/qualified/build/artifacts/cybexos-desktop-*.rpm \ - /release-work/qualified/build/artifacts/cybexos-offline-apps-*.rpm \ - --output /release-work/signed --public-key image/channels/CYBEXOS-desktop.asc \ - --key "$fingerprint" --gnupghome "$keyhome" --baseurl "$baseurl" \ - --previous-repository "$baseurl" \ - --packages-baseurl "https://github.com/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG" - reports=() - for scenario in encrypted-us plain-us encrypted-nl plain-nl upgrade; do - reports+=(--qualification "/release-work/qualified/$scenario/qualification.json") - done - image/prepare-github-release --signed-repository /release-work/signed \ - --artifacts /release-work/qualified/build/artifacts \ - --output /release-work/publication --repository "$GITHUB_REPOSITORY" \ - --tag "$RELEASE_TAG" --fingerprint "$fingerprint" "${reports[@]}" - # Query RPM headers with Fedora's matching RPM tools; Ubuntu's older - # reader need not understand Fedora 44's package format. The publish - # job rechecks each artifact digest before using this evidence. - for channel in desktop-rpm offline-rpm; do - package=cybexos-desktop - [[ $channel != offline-rpm ]] || package=cybexos-offline-apps - python3 -B image/distribution_review.py capture --channel "$channel" \ - --artifact /release-work/publication/assets/"$package"-*.rpm \ - --output "/release-work/publication/$channel-distribution-inventory.json" - done - # Keep the actual payload inventories and compiler evidence accessible - # for channel-specific PUBLIC review. Internal qualification needs no - # distribution approval, and these records do not grant any rights. - for evidence in rpm-inventory.tsv flatpak-inventory.tsv package-manifest.json \ - source-build-provenance.json build-provenance.json; do - cp "/release-work/qualified/build/artifacts/$evidence" "/release-work/publication/assets/$evidence" - done - ( - cd /release-work/publication/assets - sha256sum rpm-inventory.tsv flatpak-inventory.tsv package-manifest.json \ - source-build-provenance.json build-provenance.json >>desktop-SHA256SUMS - ) - # The container is root; artifacts must be readable by the hosted - # runner's upload action. Secret material is outside this directory. - chmod -R a+rX /release-work/publication - SIGN - - name: Retain qualified assets for the publishing job - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: cybexos-desktop-release - path: ${{ env.RELEASE_WORK }}/publication/ - if-no-files-found: error - compression-level: 0 - retention-days: 2 - - name: Remove signing material and task artifacts - if: always() - run: | - docker rm --force "cybexos-signing-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >/dev/null 2>&1 || true - docker image rm cybexos-release-signing:local >/dev/null 2>&1 || true - if [[ -n ${RELEASE_WORK:-} && $RELEASE_WORK == "$RUNNER_TEMP"/cybexos-signing.* ]]; then - sudo rm -rf -- "$RELEASE_WORK" - fi diff --git a/.github/workflows/fedora-preview.yml b/.github/workflows/fedora-preview.yml deleted file mode 100644 index f8931198..00000000 --- a/.github/workflows/fedora-preview.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Fedora preview - -# The source contract on the next, not yet qualified Fedora release. It is -# informational: the job may fail without failing the run, it is not a -# required check, and release.yml never calls this workflow, so a preview can -# neither block nor slow a release. Packages or Hyprland COPR builds the -# release does not provide yet are skipped, so every check that can still run -# does, and are then reported as a failure of the job. "Fedora 45 -# qualification" in docs/fedora-major-upgrade.md lists what a passing preview -# does not establish. -on: - pull_request: - push: - branches: [main] - schedule: - # Weekly, to notice when Fedora or the COPR catch up between pushes. - - cron: "41 4 * * 1" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: fedora-preview-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - source-contract: - name: Fedora ${{ matrix.fedora }} source contract preview - runs-on: ubuntu-latest - timeout-minutes: 30 - continue-on-error: true - strategy: - fail-fast: false - matrix: - # Unqualified releases only; tests/fedora-release.py rejects one that - # inventory or release-manifest.json already supports. - fedora: ["45"] - container: - image: fedora:${{ matrix.fedora }} - # The same headless environment as the required tests.yml job. - env: - PYTHONDONTWRITEBYTECODE: "1" - QT_QPA_PLATFORM: offscreen - QT_QUICK_BACKEND: software - - steps: - - name: Install Git for the checkout - run: dnf install -y git-core - - # actions/checkout v4.4.0, the commit tests.yml pins. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - - name: Prepare the Fedora source-test environment - id: environment - uses: ./.github/actions/fedora-source-environment - with: - fedora-release: ${{ matrix.fedora }} - best-effort: "true" - - - name: Run every source check - run: ./tests/run - - # Whatever tests/run reported, the release is not ready while anything - # the source contract installs was unavailable. - - name: Report what the release does not provide yet - if: ${{ !cancelled() && steps.environment.outputs.unavailable != '' }} - env: - FEDORA: ${{ matrix.fedora }} - UNAVAILABLE: ${{ steps.environment.outputs.unavailable }} - run: | - echo "::error title=Fedora $FEDORA preview::Unavailable: $UNAVAILABLE" - exit 1 diff --git a/.github/workflows/fedora-vm-convergence.yml b/.github/workflows/fedora-vm-convergence.yml deleted file mode 100644 index af3c1375..00000000 --- a/.github/workflows/fedora-vm-convergence.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Fedora VM convergence - -on: - workflow_dispatch: - schedule: - - cron: "17 3 * * 1" - -permissions: - contents: read - -jobs: - fedora-44-vm: - name: Fedora 44 desktop roles, two passes - runs-on: ubuntu-latest - timeout-minutes: 120 - - steps: - # actions/checkout v4.4.0, pinned for reviewable supply-chain changes. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - - name: Install VM test dependencies - run: | - sudo apt-get update - sudo apt-get install -y cloud-image-utils qemu-system-x86 qemu-utils - if test -e /dev/kvm; then sudo chmod a+rw /dev/kvm; fi - - - name: Converge the Fedora desktop twice - run: ./tests/fedora-vm-convergence diff --git a/.github/workflows/live-image.yml b/.github/workflows/live-image.yml deleted file mode 100644 index c90b4b35..00000000 --- a/.github/workflows/live-image.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Live image integration - -on: - pull_request: - push: - branches: [main] - workflow_dispatch: - workflow_call: - -permissions: - contents: read - -jobs: - welcome-and-accounts: - name: Image tooling, installer fixtures and account isolation - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - # The image tooling follows the release the installation contract targets. - - name: Read the targeted Fedora release - run: | - release=$(python3 image/fedora_target.py) - echo "FEDORA_RELEASE=$release" >>"$GITHUB_ENV" - - name: Build Fedora test environment - run: | - docker build --build-arg FEDORA_RELEASE="$FEDORA_RELEASE" \ - -f image/Containerfile.tests -t "cybexos-image-tests:$FEDORA_RELEASE" image - - name: Exercise source fixtures without building or booting an ISO - run: docker run --rm -v "$PWD:/source:ro" "cybexos-image-tests:$FEDORA_RELEASE" - - name: Validate installer configuration and launchers - run: | - docker run --rm -e FEDORA_RELEASE -v "$PWD:/source:ro" \ - -v "$PWD/image/live-rootfs/usr/share/anaconda:/usr/share/anaconda:ro" \ - "cybexos-image-tests:$FEDORA_RELEASE" bash -euc ' - ksvalidator --version "F$FEDORA_RELEASE" image/live.ks - ksvalidator -i --version "F$FEDORA_RELEASE" /usr/share/anaconda/interactive-defaults.ks - shellcheck image/build-in-guest image/rootfs/usr/bin/hyprland-quickshell \ - image/rootfs/usr/bin/cybex image/live-rootfs/usr/libexec/cybexos-live-setup - desktop-file-validate image/rootfs/usr/share/applications/*.desktop - ' diff --git a/.github/workflows/nixos.yml b/.github/workflows/nixos.yml new file mode 100644 index 00000000..a49a6d68 --- /dev/null +++ b/.github/workflows/nixos.yml @@ -0,0 +1,91 @@ +name: NixOS qualification +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + workflow_call: + inputs: + source_ref: + type: string + required: false + default: '' +permissions: + contents: read +jobs: + source: + runs-on: [self-hosted, linux, x64, cybexos-isolated] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.source_ref || github.ref }} + - uses: cachix/install-nix-action@3c6342b75005b9cc7c7d4069c3e9179247c89012 + with: + extra_nix_config: | + max-jobs = 2 + cores = 4 + - run: python3 -B scripts/check-ci-capacity + - run: nix flake check --no-build + - run: nix build .#checks.x86_64-linux.lifecycle -L + - run: nix build .#checks.x86_64-linux.parity -L + - run: nix develop -c tests/run + - run: nix develop -c tests/qml-lint + - run: nix develop -c python3 -B tests/user-plugins.py + - run: nix develop -c python3 -B tests/nixos-native + product: + runs-on: [self-hosted, linux, x64, cybexos-isolated] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.source_ref || github.ref }} + - uses: cachix/install-nix-action@3c6342b75005b9cc7c7d4069c3e9179247c89012 + with: + extra_nix_config: | + max-jobs = 2 + cores = 4 + - run: python3 -B scripts/check-ci-capacity + - run: nix build .#checks.x86_64-linux.product -L + - name: Build matched XPS stack and evaluate NVIDIA independently + run: nix build .#checks.x86_64-linux.xps-system .#checks.x86_64-linux.nvidia-evaluation -L + vm: + runs-on: [self-hosted, linux, x64, cybexos-isolated] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.source_ref || github.ref }} + - uses: cachix/install-nix-action@3c6342b75005b9cc7c7d4069c3e9179247c89012 + with: + extra_nix_config: | + max-jobs = 2 + cores = 4 + - run: python3 -B scripts/check-ci-capacity --vm + - run: nix build .#checks.x86_64-linux.vm -L + image: + runs-on: [self-hosted, linux, x64, cybexos-isolated] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.source_ref || github.ref }} + - uses: cachix/install-nix-action@3c6342b75005b9cc7c7d4069c3e9179247c89012 + with: + extra_nix_config: | + max-jobs = 2 + cores = 4 + - run: python3 -B scripts/check-ci-capacity --vm + - name: Build and qualify the exact source image in isolated VMs + run: | + set -euo pipefail + staging=$(mktemp -d "$RUNNER_TEMP/cybexos-image-XXXXXX") + revision=$(git rev-parse HEAD) + image="/data/pxe/iso/cybexos-$revision-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT.iso" + trap 'rm -rf "$staging"; sudo rm -f "$image" "$image.sha256"' EXIT + nix build .#iso -L --out-link "$staging/result" + sudo install -d -m 0755 /data/pxe/iso + sudo install -m 0644 "$staging"/result/iso/*.iso "$image" + (cd /data/pxe/iso; sha256sum "$(basename "$image")") > "$staging/checksum" + sudo install -m 0644 "$staging/checksum" "$image.sha256" + (cd /data/pxe/iso; sha256sum -c "$image.sha256") + # The disposable runner has no PXE service. Local PXE delivery + # separately requires the refresh/service/image-list checks in AGENTS.md. + nix develop -c scripts/check-image-parity "$image" . --output "$staging/image-parity.json" + nix develop -c scripts/qualify-installations "$image" --output "$staging/installations.json" diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 00000000..3630b53a --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,75 @@ +name: Promote authenticated channel +on: + workflow_dispatch: + inputs: + tag: + required: true + description: Existing qualified immutable release tag + channel: + required: true + type: choice + options: [beta, stable] + sequence: + required: true + description: Strictly increasing channel sequence +permissions: + contents: read +concurrency: + group: cybexos-channel-promotion + cancel-in-progress: false +jobs: + promote: + environment: cybexos-channel + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + id-token: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + - uses: cachix/install-nix-action@3c6342b75005b9cc7c7d4069c3e9179247c89012 + - name: Authenticate complete immutable release before promotion + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + CHANNEL: ${{ inputs.channel }} + SEQUENCE: ${{ inputs.sequence }} + SIGNING_KEY: ${{ secrets.CHANNEL_ED25519_PRIVATE_KEY }} + KEY_ID: ${{ vars.CHANNEL_KEY_ID }} + run: | + set -euo pipefail + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]] + [[ "$SEQUENCE" =~ ^[1-9][0-9]*$ ]] + test "$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" --jq .immutable)" = true + revision=$(git rev-parse HEAD) + test "$revision" = "$(git rev-parse "$TAG^{commit}")" + test "${TAG#v}" = "$(cat VERSION)" + nix flake metadata --no-update-lock-file --json > source-metadata.json + mkdir downloaded + gh release download "$TAG" --pattern 'manifest.json*' --dir downloaded + python3 release/verify-assets.py downloaded/manifest.json release/keys/trust.json --expected-version "${TAG#v}" --expected-revision "$revision" --expected-nar-hash "$(jq -r .locked.narHash source-metadata.json)" + gh auth setup-git + git clone --single-branch --branch gh-pages "https://github.com/$GITHUB_REPOSITORY.git" site + mkdir -p site/channels + previous=() + if test -f "site/channels/$CHANNEL.json"; then + previous=(--previous "site/channels/$CHANNEL.json") + fi + umask 077 + trap 'rm -f channel-key' EXIT + printf '%s\n' "$SIGNING_KEY" > channel-key + python3 release/manage.py channel downloaded/manifest.json "$CHANNEL" "$SEQUENCE" "site/channels/$CHANNEL.json" --trust release/keys/trust.json "${previous[@]}" + python3 release/manage.py sign "site/channels/$CHANNEL.json" channel-key "$KEY_ID" --trust release/keys/trust.json --purpose channel + git -C site config user.name cybexos-release + git -C site config user.email release@cybexos.invalid + git -C site add channels + git -C site commit -m "Promote $CHANNEL to $TAG" + git -C site push origin gh-pages + rm -rf site/.git + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa + with: + path: site + - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ee14759..8df80ff0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,297 +1,112 @@ name: Qualified immutable release - on: - push: - tags: - # The build step applies the repository's strict SemVer validator. - - "v*" - + workflow_dispatch: + inputs: + tag: + description: Exact existing semantic tag (v1.2.3 or v1.2.3-beta.1) + required: true + qualification_url: + description: HTTPS URL of the same-revision complete physical and lifecycle qualification report + required: true permissions: contents: read - -# Never cancel a release in flight; a second run for the same tag waits. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - jobs: - prerequisites: - name: Release prerequisites + resolve: runs-on: ubuntu-latest + outputs: + revision: ${{ steps.source.outputs.revision }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - name: Require version and older ISO + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + - id: source env: - RELEASE_TAG: ${{ github.ref_name }} - BASELINE_ISO: ${{ vars.CYBEXOS_BASELINE_ISO }} + TAG: ${{ inputs.tag }} run: | set -euo pipefail - test -n "$BASELINE_ISO" - version=${RELEASE_TAG#v} - scripts/semver validate "$version" - test "$(cat VERSION)" = "$version" - - source: - name: Source contract - # The exact job that gates main: same packages, same verified COPR key. - uses: ./.github/workflows/tests.yml - - image-source: - name: Image source contract - uses: ./.github/workflows/live-image.yml - - vm: - name: Generic Fedora VM install - runs-on: ubuntu-latest - timeout-minutes: 120 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - run: | - sudo apt-get update - sudo apt-get install -y cloud-image-utils qemu-system-x86 qemu-utils - if test -e /dev/kvm; then sudo chmod a+rw /dev/kvm; fi - - run: ./tests/fedora-vm-convergence - - iso: - name: ISO install, upgrade and recovery - needs: [prerequisites, source, image-source, vm] - uses: ./.github/workflows/desktop-release.yml + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]] + test "${TAG#v}" = "$(cat VERSION)" + revision=$(git rev-parse HEAD) + test "$revision" = "$(git rev-parse "$TAG^{commit}")" + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + qualify: + needs: resolve + uses: ./.github/workflows/nixos.yml with: - tag: ${{ github.ref_name }} - baseline_iso: ${{ vars.CYBEXOS_BASELINE_ISO }} - secrets: inherit - + source_ref: ${{ needs.resolve.outputs.revision }} publish: - name: Review, attest, and publish release assets - environment: public-distribution - needs: [source, vm, iso] - runs-on: ubuntu-latest - timeout-minutes: 60 + needs: [resolve, qualify] + runs-on: [self-hosted, linux, x64, cybexos-isolated] + environment: cybexos-release permissions: contents: write - id-token: write - attestations: write steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - name: Download qualified desktop assets - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 with: - name: cybexos-desktop-release - path: dist/desktop-release - - name: Install release build dependencies - run: | - sudo apt-get update - sudo apt-get install -y ansible-core jq zstd - - name: Build the versioned source asset - env: - RELEASE_TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - version=${RELEASE_TAG#v} - [[ $RELEASE_TAG == v$version ]] - scripts/semver validate "$version" - root="dist/cybexos-$version" - mkdir -p "$root" - git archive HEAD | tar -x -C "$root" - printf '%s\n' "$version" >"$root/VERSION" - jq --arg version "$version" '. + {version:$version}' \ - "$root/release-manifest.json" >"$root/release-manifest.json.new" - mv "$root/release-manifest.json.new" "$root/release-manifest.json" - source_epoch=$(git show -s --format=%ct HEAD) - tar --create --zstd --sort=name --mtime="@$source_epoch" \ - --owner=0 --group=0 --numeric-owner --file "dist/cybexos-$version.tar.zst" \ - --directory dist "cybexos-$version" - ( - cd dist - sha256sum "cybexos-$version.tar.zst" >SHA256SUMS - ) - mkdir dist/verify - tar --extract --zstd --file "dist/cybexos-$version.tar.zst" \ - --directory dist/verify --strip-components=1 - test "$(tr -d '[:space:]' >"$GITHUB_OUTPUT" - - name: Capture the actual distribution inventories - env: - RELEASE_TAG: ${{ github.ref_name }} - SBOM_PATH: ${{ steps.sbom.outputs.path }} - run: | - set -euo pipefail - version=${RELEASE_TAG#v} - mkdir -p dist/distribution-inventories - python3 -B image/distribution_review.py capture --channel source \ - --root dist/verify --artifact "dist/cybexos-$version.tar.zst" \ - --output dist/distribution-inventories/source-distribution-inventory.json - python3 -B - <<'PYTHON' - import json - from pathlib import Path - import shutil - import sys - sys.path.insert(0, 'image') - from distribution_review import revalidate_artifacts - for channel in ('desktop-rpm', 'offline-rpm'): - path = Path('dist/desktop-release') / (channel + '-distribution-inventory.json') - record = json.loads(path.read_text()) - if record.get('channel') != channel: - raise ValueError('Wrong captured RPM channel') - revalidate_artifacts(record, Path('dist/desktop-release/assets')) - shutil.copyfile(path, Path('dist/distribution-inventories') / path.name) - PYTHON - python3 -B image/distribution_review.py capture --channel iso \ - --root dist/desktop-release/assets --sbom "$SBOM_PATH" \ - --output dist/distribution-inventories/iso-distribution-inventory.json - - name: Retain exact inventories for owner and legal review - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + ref: ${{ needs.resolve.outputs.revision }} + fetch-depth: 0 + - uses: cachix/install-nix-action@3c6342b75005b9cc7c7d4069c3e9179247c89012 with: - name: public-distribution-inventories - path: dist/distribution-inventories/ - if-no-files-found: error - retention-days: 30 - - name: Require reviewed PUBLIC distribution for every channel + extra_nix_config: | + max-jobs = 2 + cores = 4 + - run: python3 -B scripts/check-ci-capacity --vm + - name: Verify tag and exact qualification identity env: - # Owner-supplied after inspecting this exact run's artifacts. Keeping - # approvals outside the archive avoids a self-referential digest. - PUBLIC_REVIEW: ${{ vars.CYBEXOS_PUBLIC_DISTRIBUTION_REVIEW }} + TAG: ${{ inputs.tag }} + REPORT_URL: ${{ inputs.qualification_url }} run: | set -euo pipefail - python3 -B - <<'PYTHON' - import json - import os - from pathlib import Path - import sys - sys.path.insert(0, 'image') - from distribution_review import verify - review = json.loads(os.environ.get('PUBLIC_REVIEW') or Path('assets/distribution-review.json').read_text()) - for channel in ('source', 'desktop-rpm', 'offline-rpm', 'iso'): - path = Path('dist/distribution-inventories') / (channel + '-distribution-inventory.json') - verify(json.loads(path.read_text()), review) - Path('dist/distribution-review.json').write_text(json.dumps(review, indent=2) + '\n') - PYTHON - ( - cd dist - sha256sum distribution-review.json >>SHA256SUMS - cd distribution-inventories - sha256sum *.json >>../SHA256SUMS - ) - - name: Attest the exact release archive and ISO SBOM - id: attest - # actions/attest v4.2.2 - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 - with: - subject-path: | - dist/cybexos-*.tar.zst - ${{ steps.sbom.outputs.path }} - dist/distribution-inventories/*.json - dist/distribution-review.json - - name: Attest the SPDX SBOM of the exact ISO - id: attest-sbom - # actions/attest v4.2.2: sbom-path makes an SPDX predicate attestation - # whose subject is the ISO digest from its published checksum file. - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 - with: - subject-checksums: ${{ steps.sbom.outputs.iso-checksum }} - sbom-path: ${{ steps.sbom.outputs.path }} - # Installed systems verify updates offline from this bundle, so checking - # for and applying a release needs no GitHub login or API token. - - name: Package the provenance bundles for offline verification + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]] + test "${TAG#v}" = "$(cat VERSION)" + test "$(git rev-parse HEAD)" = "$(git rev-parse "$TAG^{commit}")" + test -z "$(git status --porcelain)" + mkdir -p release-output + curl --proto '=https' --fail --location "$REPORT_URL" -o release-output/qualification.json + nix flake metadata --json > source-metadata.json + python3 release/manage.py gate release-output/qualification.json "$(git rev-parse HEAD)" "$(jq -r .locked.narHash source-metadata.json)" + nix flake check -L + staging=$(mktemp -d "$RUNNER_TEMP/cybexos-release-image-XXXXXX") + revision=$(git rev-parse HEAD) + image="/data/pxe/iso/cybexos-$revision-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT.iso" + trap 'rm -rf "$staging"; sudo rm -f "$image" "$image.sha256"' EXIT + nix build .#iso -L --out-link "$staging/result" + sudo install -d -m 0755 /data/pxe/iso + sudo install -m 0644 "$staging"/result/iso/*.iso "$image" + (cd /data/pxe/iso; sha256sum "$(basename "$image")") > "$staging/checksum" + sudo install -m 0644 "$staging/checksum" "$image.sha256" + (cd /data/pxe/iso; sha256sum -c "$image.sha256") + nix develop -c scripts/check-image-parity "$image" . --output release-output/image-parity.json + nix develop -c scripts/qualify-installations "$image" --output release-output/installation-evidence.json + cp "$image" "release-output/cybexos-${TAG#v}-x86_64-linux.iso" + - name: Prepare immutable assets and manifest env: - BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }} - SBOM_BUNDLE_PATH: ${{ steps.attest-sbom.outputs.bundle-path }} - SBOM_PATH: ${{ steps.sbom.outputs.path }} - ISO_CHECKSUM: ${{ steps.sbom.outputs.iso-checksum }} - RELEASE_TAG: ${{ github.ref_name }} + TAG: ${{ inputs.tag }} + SIGNING_KEY: ${{ secrets.RELEASE_ED25519_PRIVATE_KEY }} + KEY_ID: ${{ vars.RELEASE_KEY_ID }} run: | set -euo pipefail - version=${RELEASE_TAG#v} - bundle="dist/cybexos-$version.tar.zst.sigstore.jsonl" - jq -c . "$BUNDLE_PATH" >"$bundle" - test -s "$bundle" - # The same pinned, offline check the updater runs, without a token. - # The archive's provenance also covers the published SBOM file. - for subject in "dist/cybexos-$version.tar.zst" "$SBOM_PATH"; do - env -u GH_TOKEN -u GITHUB_TOKEN gh attestation verify \ - "$subject" --bundle "$bundle" \ - --repo "$GITHUB_REPOSITORY" \ - --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" \ - --source-ref "refs/tags/$RELEASE_TAG" --deny-self-hosted-runners - done - # The SBOM attestation's subject is the ISO, published only as parts, - # so check its predicate type and subject digest structurally here. - iso_name=$(basename "$ISO_CHECKSUM" .sha256) - sbom_bundle="$iso_name.sbom.sigstore.jsonl" - jq -c . "$SBOM_BUNDLE_PATH" >"dist/$sbom_bundle" - read -r iso_digest _ <"$ISO_CHECKSUM" - jq -e --arg digest "$iso_digest" '.dsseEnvelope.payload | @base64d | fromjson - | .predicateType == "https://spdx.dev/Document/v2.3" - and any(.subject[]; .digest.sha256 == $digest)' "dist/$sbom_bundle" >/dev/null - ( - cd dist - sha256sum "cybexos-$version.tar.zst.sigstore.jsonl" "$sbom_bundle" >>SHA256SUMS - ) - - name: Publish the completed release + trap 'rm -f signing-key' EXIT + umask 077 + printf '%s\n' "$SIGNING_KEY" > signing-key + git archive --format=tar.gz --prefix=cybexos/ HEAD > release-output/cybexos-source.tar.gz + cp release/notes.md release-output/release-notes.md + (cd release-output; sha256sum ./*.iso cybexos-source.tar.gz > SHA256SUMS) + python3 release/manage.py manifest release-output "${TAG#v}" "$(git rev-parse HEAD)" "$(jq -r .locked.narHash source-metadata.json)" release/notes.md + python3 release/manage.py sign release-output/manifest.json signing-key "$KEY_ID" --trust release/keys/trust.json --purpose release + python3 release/verify-assets.py release-output/manifest.json release/keys/trust.json --local + - name: Publish all assets together and verify public delivery env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} + IMMUTABILITY_CHECK_TOKEN: ${{ secrets.IMMUTABILITY_CHECK_TOKEN }} + TAG: ${{ inputs.tag }} run: | set -euo pipefail - version=${RELEASE_TAG#v} - prerelease=() - [[ $version != *-* ]] || prerelease+=(--prerelease) - gh release create "$RELEASE_TAG" --verify-tag --draft \ - --generate-notes --title "CybexOS $version" "${prerelease[@]}" - gh release upload "$RELEASE_TAG" \ - "dist/cybexos-$version.tar.zst" \ - "dist/cybexos-$version.tar.zst.sigstore.jsonl" dist/SHA256SUMS \ - dist/*.iso.sbom.sigstore.jsonl dist/desktop-release/assets/* \ - dist/distribution-inventories/*.json dist/distribution-review.json - gh release edit "$RELEASE_TAG" --draft=false - for _attempt in $(seq 1 12); do - if test "$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq .immutable)" = true; then - exit 0 - fi - sleep 5 - done - echo "GitHub did not make $RELEASE_TAG immutable after publication" >&2 - exit 1 - - pages: - name: Publish signed desktop repository metadata - needs: publish - # Prerelease assets are downloadable but must never advance stable clients. - if: ${{ !contains(github.ref_name, '-') }} - runs-on: ubuntu-latest - permissions: - contents: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - name: cybexos-desktop-release - path: desktop-release - - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b - - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa - with: - path: desktop-release/pages - - name: Deploy verified metadata after RPM assets exist - id: deploy - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e + release/require-immutable-releases + gh release create "$TAG" --draft --verify-tag --title "CybexOS ${TAG#v}" --notes-file release/notes.md + gh release upload "$TAG" release-output/* + release/require-immutable-releases + gh release edit "$TAG" --draft=false + test "$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" --jq .immutable)" = true + python3 release/verify-assets.py release-output/manifest.json release/keys/trust.json diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index ff032501..00000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Repository checks - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - # release.yml gates a release on this same job. - workflow_call: - -permissions: - contents: read - -# A newer push to the same branch or pull request supersedes a running check; -# a release's run is never cancelled. The prefix keeps this group apart from -# the caller's own when release.yml calls this workflow, where github.workflow -# names the caller. -concurrency: - group: source-checks-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} - -jobs: - # Branch protection requires this check by name and release.yml gates on it. - # Its container and name follow inventory fedora_release only when the - # targeted release changes (tests/fedora-release.py checks they agree); - # the next release is previewed, non-blocking, in fedora-preview.yml. - fedora-44: - name: Fedora 44 source contract - runs-on: ubuntu-latest - timeout-minutes: 20 - container: - image: fedora:44 - env: - PYTHONDONTWRITEBYTECODE: "1" - QT_QPA_PLATFORM: offscreen - QT_QUICK_BACKEND: software - - steps: - # actions/checkout needs Git in the container for a real work tree; the - # shared environment step installs everything else. - - name: Install Git for the checkout - run: dnf install -y git-core - - # actions/checkout v4.4.0; pin the reviewed action code, not its moving - # major tag. Dependabot/manual review can advance this commit explicitly. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - - # Dependencies, the fingerprint-verified Hyprland COPR, and an assertion - # that this container is the release inventory/group_vars/all.yml - # targets. Shared with the non-blocking fedora-preview.yml job. - - name: Prepare the Fedora source-test environment - uses: ./.github/actions/fedora-source-environment - - - name: Run every required source check - run: ./tests/run diff --git a/.yamllint b/.yamllint deleted file mode 100644 index be9be376..00000000 --- a/.yamllint +++ /dev/null @@ -1,15 +0,0 @@ -extends: relaxed - -rules: - document-start: disable - line-length: disable - truthy: - allowed-values: ["true", "false"] - check-keys: false - trailing-spaces: enable - key-duplicates: enable - comments: - min-spaces-from-content: 1 - octal-values: - forbid-implicit-octal: true - forbid-explicit-octal: true diff --git a/AGENTS.md b/AGENTS.md index 811d9a75..45f20968 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,32 +1,40 @@ # Repository agent instructions -## Installation parity: ISO is authoritative +## NixOS installation and lifecycle contract -- The current ISO installation defines the CybexOS product. Keep `./install`, - `./bootstrap`, and ISO installations equivalent for the same release, - hardware, and explicit user choices. Resolve differences by bringing the - checkout path into line with the ISO, not by changing the ISO to match an - older checkout default. -- Treat applications, desktop and personal defaults, authentication, enabled - services, firewall policy, hardware support, and recovery as one shared - installation contract. Reuse the shared policy, task files, templates, and - package selections; do not add independent installer defaults. -- Preserve explicit saved choices and user-owned data during installs, - reconfiguration, and updates. Matching a fresh installation never authorizes - repartitioning an existing Fedora system or resetting personal settings. - Automatic login must retain the ISO's verified-encryption requirement. -- Every change to the installed product must cover both installation paths in - the same change, with regression coverage that compares their outcomes. - Run the source and image parity checks before handoff. A passing fixture - suite must not be reported as a completed end-to-end installation test. -- Release comparisons must use artifacts from the same source revision. - When preparing a release, rebuild and qualify the ISO/RPM if its installed - payload changed; an older ISO or an older qualification report does not - validate the new release. +- NixOS is the sole supported operating system. Target fresh installations. + Do not add installation migration, compatibility shims, or alternate + provisioning/update paths. +- `nix/modules` defines the shared installation contract for the ISO, installed + system, configuration tools and lifecycle. `install` and `bootstrap` invoke + the same fresh installer, available only in the live environment. +- Vendor files are immutable. Personal modules and machine-owned locks belong + in `/etc/nixos`; secrets/state stay outside the Nix store. Never overwrite + personal packages, services, VPNs, widgets, preferences, data or skills to make + an update pass. Never implicitly refresh unrelated personal inputs. +- Nixpkgs follows the supported stable branch independently; CybexOS follows + the machine Nixpkgs and is pinned to an authenticated exact release revision + and NAR identity. Major NixOS upgrades are deliberate; do not change + `system.stateVersion` during routine updates. +- Preserve password fallback, password-only first login, encryption-gated + automatic login, firewall protections and supported hardware. Hardware stack + failure blocks qualification; it must not silently remove support or disable + Secure Boot. +- Validate changes through the actual staged updater. Source/fixture tests and + a Nix build are not end-to-end installation, runtime or physical hardware + qualification. Test ISO/source parity and use the exact same source identity + for release artifacts and qualification. Record what was actually exercised. +- Do not modify the running workstation, partition existing systems, publish + releases, or reboot the host without explicit authorization. Use isolated + build/test environments and preserve unrelated work. ## ISO testing location - Always place completed ISOs for testing in `/data/pxe/iso`. +- The PXE host is **thebeast (`10.10.0.7`)**, reached with + `ssh john@10.10.0.7`. Its `/data/pxe/README.md` documents the service. + A development machine's directory with the same path is not the served tree; + copy the verified final ISO/checksum to the Beast before refreshing it. - Build in a unique task-specific staging directory, then copy the completed ISO and its SHA-256 checksum into `/data/pxe/iso`. Preserve existing images unless their replacement or removal is explicitly requested. @@ -35,7 +43,8 @@ incomplete ISOs outside the served ISO tree. Use ASCII filenames without spaces and permissions that allow iVentoy to read the files. - Prefer **ISO Management → Refresh** in the PXE host's iVentoy web UI - (port 26000; the host is documented in `/data/pxe/README.md`). + (`http://10.10.0.7:26000`; deployment details are in the Beast's + `/data/pxe/README.md`). On the PXE host, the equivalent requests below were verified with iVentoy 1.0.41. They use the installed UI's internal API, so recheck `http://127.0.0.1:26000/vtoy_image.html` after an iVentoy upgrade: @@ -106,3 +115,10 @@ - A live test is not complete until `quickshell.service` is active, its `MainPID` is the sole remaining `qs` PID, and the current invocation's journal has been checked for QML errors. +- Scope process comparisons to the authenticated user's UID; another user's + independent desktop session is not an unmanaged menubar. Updater health + reports extra developer processes without terminating them. The live-test + guard may terminate only the confirmed task/developer instances above. +- Count Nixpkgs' Qt-wrapped Quickshell (`.quickshell-wrapped`, with Linux + `comm` truncated to `.quickshell-wra`) in these comparisons as well as + `pgrep -x qs`; the wrapper is the same upstream executable, not a second shell. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea6523c0..47dbb444 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,159 +1,24 @@ -# Contributing to CybexOS - -CybexOS is an opinionated Fedora 44 desktop built on Hyprland and Quickshell. -It has two installation paths: the bootable ISO (the `cybexos-desktop` RPM and -the image tooling under `image/`) and the source checkout (`./install`, which -applies the Ansible roles in `site.yml`). The ISO defines the product. - -Participation is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). -Contributions are accepted under the repository's [MIT License](LICENSE). -Report security problems privately as described in [SECURITY.md](SECURITY.md). - -## Before you start - -- Search existing issues. For a new feature or a larger change, open a feature - request first so the approach can be agreed before you write it. -- CybexOS prefers a strong automatic default over a new setting, and an - existing surface (a keybinding, the launcher, a row in an existing Settings - page) over a new panel or menubar module. -- Do not add third-party code, artwork, fonts, wallpapers, or logos unless - their license permits redistribution and you record their provenance. See - [licensing and asset provenance](docs/licensing.md) and the - [redistribution audit](docs/licensing-audit.md). - -## Development setup - -Develop on Fedora 44 x86_64: - -```bash -sudo dnf install -y git -git clone https://github.com/DigitalPals/CybexOS.git -cd CybexOS -``` - -The CI workflow, [`.github/workflows/tests.yml`](.github/workflows/tests.yml), -prepares the Fedora packages the source gate needs (Ansible, ansible-lint, -ShellCheck, yamllint, Ruff, Node.js 24, Qt 6 declarative tools, and others) -and configures the fingerprint-verified Hyprland COPR. The image fixtures' -dependencies are listed in [image/README.md](image/README.md#source-checks-no-iso-or-vm). - -On a CybexOS machine, `cybex dev enable "$PWD"` runs live Quickshell and static -Hyprland modules from your validated checkout without modifying it; -`cybex dev disable` returns to the verified runtime. Keep exactly one live -menubar: test through `quickshell.service` and never leave a source-tree `qs` -process running beside it. See [Quickshell development notes](docs/quickshell-notes.md). - -## Source gates - -Run both gates before you open a pull request. Neither builds an ISO or boots -a VM. - -```bash -./tests/run # strict source gate -python3 -B image/check-source # image tooling and installer fixtures -``` - -`./tests/run --list` shows the stages and `--only STAGE` runs one; see -[the strict source gate](docs/operations.md#the-strict-source-gate). If you do -not develop on Fedora 44, run the image fixtures in the Fedora 44 test -container: - -```bash -docker build -f image/Containerfile.tests -t cybexos-image-tests:44 image -docker run --rm -v "$PWD:/source:ro" cybexos-image-tests:44 -``` - -Pull requests must pass the required checks `Fedora 44 source contract` and -`Image tooling, installer fixtures and account isolation`. Fixture suites check -the installation contract; they are not evidence that a real ISO or checkout -installation was performed, so do not describe them that way. Slower gates, -such as `./tests/fedora-vm-convergence` and ISO qualification, are described in -[the release process](docs/releasing.md). - -## Installation parity - -The rules are summarized from [AGENTS.md](AGENTS.md) and -[installation parity](docs/installation-parity.md): - -- The current ISO installation is authoritative. `./install`, `./bootstrap`, - and the ISO must produce the same installed product for the same release, - hardware, and explicit choices. Resolve a difference by bringing the - checkout path in line with the ISO. -- Every change to the installed product (applications, desktop and personal - defaults, authentication, services, firewall policy, hardware support, - recovery) covers both paths in the same change, reusing the shared policy, - task files, templates, and package selections. Do not add installer-specific - defaults. -- Add or extend regression coverage that compares both paths' outcomes, such - as `image/test_installation_parity.py`, `image/test_desktop_payload.py`, - `image/test_installed_policy.py`, and `image/test_user_parity.py`. -- Preserve explicit saved choices and user-owned data during installs, - reconfiguration, and updates. Parity never justifies repartitioning an - existing system or resetting personal settings. Automatic login keeps the - verified-encryption requirement. -- Release comparisons use artifacts built from the same source revision. - -## Dependencies and pinning - -Follow the [dependency and pinning policy](docs/dependency-policy.md). Pins live -in `inventory/group_vars/all.yml`: GitHub release applications and fonts need an -exact tag and SHA-256, source builds a full commit, container images a manifest -digest, and vendor repository keys a checksum plus full fingerprint. Changing a -pin is a review decision; explain the upstream reason in the pull request. -A new moving input must document why currency matters more than reproducibility. - -## Code style - -- Match the surrounding code. Comments explain why, not what. -- Quickshell: theme values come from `Common/Theme.qml`; register new QML types - in their directory's `qmldir`; do not run `qmlformat`. Pure logic belongs in a - Qt-free `.js` helper with a Node test in `tests/quickshell/*.test.cjs`. -- Shell scripts pass ShellCheck. Python passes Ruff (`pyproject.toml`) and new - helpers get tests registered in `tests/run`. YAML passes yamllint - (`.yamllint`) and Ansible passes ansible-lint. -- New user helper scripts go in `assets/scripts/` and are listed in - `assets/desktop-contract.json`, which installs them on both paths. New `cybex` - subcommands go in both dispatchers: `roles/dotfiles/templates/cybex.j2` and - `image/rootfs/usr/bin/cybex`. -- Privileged actions use the existing Polkit-authorized systemd units or - `pkexec`. Never add passwordless sudo or Polkit rules. Treat external input - (URLs, files, Git repositories, themes) as data, never as code. -- User-facing copy is short, in sentence case, without exclamation marks. - -## Commits and pull requests - -- Branch from `main` and keep each pull request focused on one change. -- Write commit subjects in the imperative, describing the outcome, for example - "Keep mDNS open so network printers and scanners are found". Use the body to - explain why. -- Update the documentation that describes the behavior you changed: the - README, `docs/`, `image/README.md`, and - `agent-skills/cybexos/references/` for commands the agent skill should know. -- Fill in the pull request template, including both gate results. -- Never commit secrets, tokens, personal data, build artifacts, or unredacted - logs. Remove disposable ISOs, VM disks, and logs you created while testing. - -## Reporting bugs - -Run `cybex report` on the affected machine. It writes a redacted diagnostic -bundle, `cybexos-report-.tar.gz`, locally and opens a prefilled -GitHub bug report. Review the bundle before attaching it; the redaction is a -safeguard, not a guarantee. Never attach unredacted logs, and never report a -security problem in a public issue: follow [SECURITY.md](SECURITY.md). - -If you cannot run `cybex report`, use the **Bug report** form and include the -output of `cybex version`, the installation path (ISO or source checkout), and -steps to reproduce. - -## Where the documentation lives - -- [README.md](README.md): overview, installation, updates, and common commands -- [image/README.md](image/README.md): the ISO, its installer, first boot, and repair -- [docs/operations.md](docs/operations.md): commands, Ansible tags, updates, and recovery -- [docs/installation-parity.md](docs/installation-parity.md): the shared installation contract -- [docs/releasing.md](docs/releasing.md) and [docs/iso-releases.md](docs/iso-releases.md): release process -- [docs/architecture/ownership.md](docs/architecture/ownership.md): runtime and user-owned paths -- [docs/quickshell-notes.md](docs/quickshell-notes.md): working on the shell -- [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md): keybinding groups -- [docs/licensing.md](docs/licensing.md): licensing and asset provenance -- [AGENTS.md](AGENTS.md): rules for coding agents, including ISO testing and artifact cleanup +# Contributing + +Read [AGENTS.md](AGENTS.md). NixOS is the only operating-system implementation. +Develop vendor behavior in `nix/modules`, `lifecycle`, `installer`, `desktop`, +and `hardware`. Keep personal declarations out of vendor code. + +Use `nix develop` and `tests/run`, then `nix flake check --no-build` and relevant +Nix builds/VM tests. Source fixtures must be reported separately from actual +installation and update tests. `scripts/qualify-installations` owns disposable +VMs/disks and exercises the actual installer and updater, with evidence tied to +source content. Hardware claims require representative physical machines. + +Every product change covers the shared module and both installation entry points. +Keep plugin API 1 and the supported Omarchy adapter functional. Runtime preferences +must preserve unknown fields and explicit choices. Never fix an update by removing +personal modules, resetting settings or silently disabling an extension. + +Use isolated environments. Do not deploy a development shell onto the running +workstation without authorization. Any live Quickshell test must use +`tests/lib/quickshell-live`, including its final PID/service/journal checks. + +Release workflows are protected operations and require complete same-revision +qualification, operator-owned signing keys and immutable GitHub Releases. See +[release operations](docs/releasing.md). diff --git a/README.md b/README.md index 8f322c60..5f0efc35 100644 --- a/README.md +++ b/README.md @@ -1,390 +1,40 @@ # CybexOS -CybexOS stands for **Cybex Opinionated System**. It is an opinionated Fedora -Linux desktop built around Hyprland and Quickshell. The primary installation -path is the bootable CybexOS ISO; a source-checkout installer remains available -for development and existing checkout deployments. The core configuration is -hardware-neutral. A separate, precisely gated role preserves extra support -for the 2026 Dell XPS 14 and 16. - -The current release target is Fedora 44 on x86_64. Fedora remains responsible -for the kernel, drivers, SELinux, and base operating system. - -The application command is `cybex`; paths, services, and packages use the -`cybexos` name. The project was previously called `fedora-config`. Running -`./install` from a CybexOS checkout moves such an installation to the new names; -see [the operations guide](docs/operations.md#migrating-from-fedora-config). - -## Try the browser desktop - -The [browser experience](web/README.md) recreates the CybexOS menubar, -workspaces, launcher, windows, terminal, files, notes, and appearance settings. -It runs as a standalone web app, with Docker Compose serving it on port 5174. -Notes and preferences stay in the visitor's browser; the demo does not run -Linux or execute commands on the host. - -## What it installs - -- Hyprland, a custom Quickshell menubar, SDDM, portals, notifications, and - desktop services -- a portable Fedora package, Flatpak, shell, font, firewall, and recovery - baseline -- encrypted personal-file backups with restic to an external drive or SSH - server, set up in Settings → Backup & recovery or with `cybex backup` -- Omawrite for text, NFO, and Markdown files, with default file associations - when personal dotfiles integration is enabled -- developer/Android tools, Steam, Docker, Podman/Distrobox, Tailscale, - connected-service widgets, proprietary applications, and source-built tools - enabled by default -- automatically detected XPS 2026 speaker, camera, haptic, fingerprint, - backlight, firmware, and power support -- persistent installer configuration and lifecycle tools for source-checkout - deployments, plus a verified source release updater -- one release-scoped CybexOS skill discoverable by compatible coding - agents for safe installed-system diagnosis and customization -- a user-selectable default AI coding agent with terminal, launcher, and - keyboard entry points -- a `Super+Space` command palette for applications, actions, clipboard - history and emoji, and installable web apps - -Source-checkout installs have no desktop-preset selection: every installation -gets the same core Hyprland/Quickshell desktop. That installer asks about the -target machine, security decisions, personal dotfiles, and application opt-outs. - -On a source-checkout installation, apply only Omawrite and the managed file -associations with the saved configuration: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags omawrite,mime-defaults -``` - -## Install from the ISO - -For a normal installation, boot the CybexOS ISO and follow the on-screen -installer. It installs the desktop RPM and offline application set, then -configures the target system without requiring a Git checkout or a network -connection for the desktop payload. The image's [installation guide](image/README.md) -describes disk requirements, encryption, first boot and recovery. Use the -release instructions in [docs/releasing.md](docs/releasing.md) for current -artifact availability and checksums. - -The installed desktop is delivered by `cybexos-desktop`; its packaged files -live under `/usr/share/cybexos`. The first-login account is configured by the -image installer, and subsequent package upgrades use the system update path. - -## Install from a source checkout - -This supported path is intended for development and existing checkout-based -deployments. For a regular new installation, use the ISO above. - -The commands below install from this repository with Ansible; they do not -install or update the ISO's `cybexos-desktop` RPM. Repository-based options and -the saved installer configuration described here apply to this checkout path. - -Start with Fedora 44 and a user that can run `sudo`: - -```bash -sudo dnf install -y git -git clone https://github.com/DigitalPals/CybexOS.git -cd CybexOS -./install +CybexOS is a NixOS desktop built around Hyprland, a custom Quickshell interface, +keyboard-driven applications, and personal extensions that stay yours. +NixOS 26.05 on x86_64 is the current target. This is an **unreleased replacement**; +[qualification and release blockers](docs/validation.md) describe its actual status. + +The same NixOS modules define the live ISO and installed product. Vendor desktop +code lives in immutable store paths. Your machine owns its configuration and lock +file; personal modules and persistent data are separate from vendor releases. + +- [Fresh installation](docs/installation.md) +- [Architecture and ownership](docs/architecture/ownership.md) +- [Independent updates](docs/updates.md) +- [Personal configuration and plugin development](docs/customization.md) +- [Release publishing and signing keys](docs/releasing.md) +- [Recovery, persistence and garbage collection](docs/recovery.md) +- [Hardware and security](docs/hardware-security.md) +- [Validation status](docs/validation.md) + +Upstream packages and CybexOS are independently selectable: + +```console +cybex update check +cybex update stage upstream # or cybexos, both, local +cybex update status # review the built changes and returned ID/token +cybex update apply ID TOKEN +# Reboot when you choose, sign in, then: +cybex update confirm ``` -No inventory or configuration file needs to be edited first. The installer -detects the current desktop user, home directory, hostname, timezone, locale, -and keyboard settings and offers them as defaults. The ISO defines the shared -fresh-install policy: all application groups and personal dotfiles are enabled, -sudo and local Polkit require authentication, and Docker requires sudo. -Automatic login defaults on only when the complete root filesystem is verified -as encrypted. These defaults also apply in non-interactive installs; interactive -setup allows explicit opt-outs. Existing saved choices remain authoritative. -Fastfetch is a required baseline package. Passwordless sudo, local Polkit and -Docker access remain explicit opt-ins; the `docker` group grants root-equivalent -control of the machine. See [installation parity](docs/installation-parity.md) -for the shared contract and release checks. - -On an encrypted single-user installation, SDDM can open the desktop after the -LUKS unlock and unlock GNOME Keyring with the briefly cached boot password. -Automatic login is attempted once per boot; logout, a desktop crash, or a -login-manager restart requires password login. An unavailable or different -boot password leaves the keyring protected and allows manual unlocking. -Unencrypted systems use password login. See the [login design and validation -status](docs/gdm-replacement-research.md). Existing installations change login -managers on the next boot, preserving their previous configuration for uninstall. - -Answers are saved in `/etc/cybexos/config.yml`, outside versioned release -trees, and are reused by later installs and updates. Existing explicit -application opt-outs are preserved; missing choices receive the full defaults. Run -`cybex configure` to ask the questions again. `./bootstrap` remains a -compatibility alias for `./install`. The first successful install snapshots -the runtime source under `~/.local/share/cybexos/releases/`, so the -cloned checkout can then be moved or removed. - -That active release also owns the canonical `cybexos` agent skill. -Installation always links it at `~/.agents/skills/cybexos`, -`~/.claude/skills/cybexos`, and `~/.codex/skills/cybexos`, whether -or not a corresponding agent is currently installed. Invoke it explicitly as -`$cybexos` in Codex or `/cybexos` in Claude Code; its focused -description also supports automatic selection. - -Before any role adopts configuration, the installer creates a one-time backup -under `~/.local/state/cybexos/backups/initial/`. Existing Hyprland and -Quickshell trees are always preserved; personal application files are added -when the optional dotfiles integration is selected. Uninstall restores those -pre-existing files. Managed Fish, Kitty, Git, and SSH settings use -fragments/includes where those applications support them. Bundled wallpapers -are installed into `~/Pictures/Wallpapers`, and a mountain wallpaper is selected when no wallpaper is configured. Existing selections and -custom folders are preserved. Change the image in CybexOS Settings → Wallpaper. -No avatar is imposed. - -To install or refresh only the wallpapers using the saved configuration: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags wallpapers -``` - -A lone tiled window on an external monitor is centered at 70% of the display's -width, adapting to resolution, scaling, and rotation. Laptop panels and -workspaces with multiple tiled windows use the normal small edge gaps. - -`Super+K` shows every keyboard shortcut, read live from Hyprland, including -described bindings added in `~/.config/cybexos/hypr/user.lua`. See -[keyboard shortcuts](docs/keyboard-shortcuts.md). - -Around the windows themselves: - -- **Windows:** `Super+Tab` opens an overview with live thumbnails of every - workspace; type to filter, or drag a window to another workspace. A - scratchpad (`Super+-`), window groups (`Super+G`) and screen zoom - (`Super+Z`) are built in, and a three-finger swipe changes workspace. -- **Capture:** region or window screenshots, scrolling screenshots, screen - recording with an optional webcam bubble, text and QR code copying, and a - colour picker. -- **Appearance:** Settings → Appearance starts with **Theme** (CybexOS, Nord, - Gruvbox, Tokyo Night, Catppuccin, Rosé Pine, Everforest, Kanagawa) and can - save the current look as a theme; `cybex theme` installs, exports and - applies themes as plain data. The terminal, GTK applications and the lock - screen follow the shell's colours and text size. See - [themes](docs/shell-appearance.md#themes). -- **Dock and extras:** floating workspaces show a dock automatically - (Settings → Bar → Dock can show it in all layouts), alongside the - busiest processes in the Control Center's Overview, and a game mode - (`Super+Shift+G`) that turns effects, gaps and the idle lock off. See - [window overview, dock and top processes](docs/overview-and-dock.md). -- **Language:** the shell follows the system language set in Settings → - Region & formats and ships English and Dutch. See - [shell translations](docs/shell-translations.md). - -Hyprland and Quickshell stay on the series each release was qualified on: -patch updates arrive normally, and the next minor or major release waits for a -CybexOS release that has reviewed it. See the -[dependency policy](docs/dependency-policy.md). - -Desktop runtime and user customization have a strict boundary. Verified -releases reconcile `~/.local/share/cybexos/runtime`, while shell -settings, Hyprland overrides, themes, and plugins live in user-owned roots -that updates never prune. An upgrade from the legacy layout emits a migration -report and preserves customized QML/Lua without trying to translate it. See -[the ownership architecture](docs/architecture/ownership.md) for the complete -path and migration contract. - -Personal bar widgets use a versioned API and live outside the distro runtime. -Codex/Claude can create a package in -`~/.local/share/cybexos/plugins//` and enable it with -`cybex plugin enable `. Its preferences and data survive updates; -no edits to built-in shell modules are needed. See the -[widget contract and commands](docs/architecture/user-widgets.md). The -ownership guide also identifies remaining application-configuration gaps. -The Omarchy compatibility adapter supports widgets, shared services, panels, -overlays, menus, and replacement bars, with representative unchanged plugins tested. See [installation and limits](docs/omarchy-plugin-compatibility.md). -The built-in Model Usage widget is a vendored copy of the -[Model Usage](https://github.com/DigitalPals/omarchy-modelusage) Omarchy -plugin, hosted through the same adapter; see -[its README](roles/desktop/files/quickshell/ModelUsage/README.md). - -Each pre-existing `cybexos` skill slot is backed up independently before -first adoption. Updates retarget all three paths through the atomic active -release link, and uninstall restores the exact original file, directory, or -symlink without changing neighboring skills. - -## Default AI agent - -Developer tooling installs pinned Claude Code, OpenCode, and Codex CLI -versions. CybexOS does not silently prefer one provider: the first -interactive invocation asks which installed agent to use and stores that -per-user choice at `~/.config/cybexos/defaults/agent`. - -```bash -cybex agent # launch the default in this directory -cybex agent --pick # choose, remember, and launch another -cybex agent set opencode # change the default without launching -cybex agent list # show supported and installed agents -cybex agent prompt "review this change" -``` - -`Super+Ctrl+Shift+A` opens the default agent in a Kitty window rooted at -`~/Code`. The Quickshell Actions tab can launch or choose it as well. Managed -Fish configuration provides the short alias `a`. - -The dispatcher passes no automatic-approval or permission-bypass flags and -never stores credentials. Authentication, model selection, permissions, and -provider-specific settings remain owned by each agent. The preference survives -updates and uninstall because it is user data rather than Ansible policy. - -## Launcher and web apps - -`Super+Space` opens the launcher. Typing searches applications and also lists -up to three matching actions, such as **Take a scrolling screenshot** or -**Toggle game mode**. The Actions tab lists every action, grouped as Install, -Remove, Set up, Toggle, Update, Capture and System. `Super+Ctrl+V` opens the -clipboard history: `Enter` pastes the selected entry, `Shift+Enter` copies it, -and `Ctrl+P` pins it so it survives when the history is pruned or cleared. - -Web apps are websites in their own window with a launcher entry. Brave Origin -opens them in app mode when it is installed; otherwise Firefox opens them. -Every account starts with Slack, WhatsApp, YouTube, Google Photos and X on -their usual shortcuts; a removed one is not added back by later updates. - -```bash -cybex webapp install --url https://app.example.com/ # fetches the site icon -cybex webapp list -cybex webapp remove example -cybex webapp launch slack -``` - -The launcher's **Install web app…** and **Remove web app…** actions do the -same. Only http and https addresses without a user name or password are -accepted. See [keyboard shortcuts](docs/keyboard-shortcuts.md) and the -[launcher notes](docs/quickshell-notes.md#launcher-providers-and-actions). - -## Update - -On ISO installations, `cybex update` updates Fedora packages, Flatpaks, and -the desktop RPM when a signed update channel has been enrolled. The default -ISO configuration does not enable a desktop RPM channel. Inspect it with -`cybex update-channel status --json`; enabling one requires its reviewed public -configuration and full signing-key fingerprint. See the -[release guide](docs/releasing.md) for channel setup. - -On source-checkout installations, use the release updater described below. -These releases are separate from the ISO desktop RPM channel. - -After the first install, use: - -```bash -cybex update -``` - -The updater follows the saved `stable` channel by default (`--channel beta` -selects and saves prereleases). It checks this repository's GitHub releases -anonymously, so no GitHub account or `gh auth login` is needed. While a channel -has no published release, the CybexOS step is skipped and packages still -update. For a newer immutable release it checks the API SHA-256 digest, -verifies the release workflow's provenance attestation offline from the -bundle published beside the archive, validates Fedora/architecture/config-schema -compatibility, and extracts into a versioned staging directory. One durable system worker owns the -saved-answer migration, candidate application, rollback, and atomic `current` -symlink change, so detaching the terminal cannot split the transaction. A -failed apply restores the previous configuration. The active release and two -recent fallbacks are kept. - -The same durable worker updates Fedora packages and system Flatpaks. From the -Quickshell panel, systemd requests authorization through the desktop's native -Polkit agent and progress remains in the Updates view; the worker then runs in -a transient system unit, so a Quickshell restart does not interrupt package -work. Terminal invocations retain their sudo-compatible path. On Btrfs, -package work first creates a recovery point: a read-only root snapshot and a -`/boot` archive. The boot menu's **CybexOS recovery points** submenu starts one -with a temporary overlay (hold Shift or press Esc during boot to show the -menu), and Settings → Backup & recovery → Recovery points or `sudo cybexos-system-snapshot -restore ID` makes it the system again; the replaced root is kept until you -discard it. - -Before you update, the Updates view's **What will change** lists each package -and app with its installed and incoming version, Fedora's advisories (security -fixes first), and CybexOS's own changes: the release notes on a source -checkout, the desktop RPM's changelog on an ISO installation. After an update -it names the recovery point that update took, with a link to the Settings -section that restores it. **Download updates automatically** (Settings → Bar → -Updates, off by default) downloads Fedora packages and system Flatpaks in the -background on AC power and unmetered networks; installing still waits for you. - -Recovery points never include your home folder. Back it up to an external -drive or an SSH server with **Settings → Backup & recovery → Set up…** (or -`cybex backup setup`). Backups are encrypted with a password kept in the GNOME -Keyring and a recovery key you save once; they run daily while the destination -is available, and a restore always goes into a new folder under `~/Restored`. - -Useful commands: - -| Command | Purpose | -| --- | --- | -| `cybex update --check` (source checkout) | Check the configured GitHub channel | -| `cybex update --system-only` (source checkout) | Update Fedora and Flatpak only | -| `cybex agent` | Launch or choose the per-user default AI coding agent | -| `cybex dev status` | Show whether the verified or a development runtime is active | -| `cybex plugin list` | Inspect personal widgets and API compatibility | -| `cybex backup status` | Show the last and next personal-file backup (`now`, `restore`, `setup` act) | -| `cybex verify` | Check the installed system (`--source` opts into developer checks) | -| `cybex doctor` | Alias for `verify` | -| `cybex report` | Write a redacted diagnostic bundle and optionally open a prefilled bug report | -| `cybex configure` (source checkout) | Re-run the installer questions | -| `cybex uninstall` (source checkout) | Remove project-managed configuration; retain applications | -| `cybex factory-reset` (ISO) | Erase all accounts and home folders and prepare the computer for a new owner | - -Detailed updater status, logs, cancellation, Btrfs recovery, and advanced -Ansible tags are documented in [the operations guide](docs/operations.md). - -## Hardware support - -Generic systems skip every XPS workaround. The `xps-2026` role activates only -for Dell vendor/product identifiers, supported SKUs `0DB9` or `0DBA`, and the -expected Panther Lake CPU family; individual devices are gated again before -their configuration is installed. See -[Dell XPS 2026 hardware support](docs/xps-2026-hardware.md). - -Machines with an NVIDIA GPU get RPM Fusion's driver automatically on both -installation paths, with Secure Boot key enrollment and the open-source driver -as a fallback; hybrid laptops keep rendering on the integrated GPU. See -[NVIDIA graphics](docs/nvidia.md). - -Any fingerprint reader that Fedora's fprintd supports, and any FIDO2/U2F -security key, can unlock the screen and approve sudo and system prompts -alongside the password; manage them in Settings > Security. Signing in after a -restart always asks for the password, which unlocks the login keyring. See -[Fingerprints and security keys](docs/security.md). - -## Development and releases - -```bash -./verify --source -./tests/fedora-vm-convergence -cybex dev enable "$PWD" -cybex dev disable -``` - -Development mode reads live Quickshell and static Hyprland modules from the -validated checkout. It never fetches, resets, merges, or writes to that -checkout, and normal internet updates remain enabled in parallel. - -CI runs the complete source contract in Fedora 44 and converges all roles on a -generic Fedora Cloud VM twice to enforce idempotence. A `vX.Y.Z` tag publishes only -after both gates pass. Repository release immutability must be enabled before -the first public tag. - -Key documentation: - -- [Operations and recovery](docs/operations.md) -- [Runtime and user ownership](docs/architecture/ownership.md) -- [Release process](docs/releasing.md) -- [Dependency and pinning policy](docs/dependency-policy.md) -- [Fedora major upgrades](docs/fedora-major-upgrade.md) -- [Licensing and asset provenance](docs/licensing.md) -- [Quickshell development notes](docs/quickshell-notes.md) -- [Contributing](CONTRIBUTING.md) and [reporting security issues](SECURITY.md) +Both the desktop Update button and CLI use the supervised, serialized lifecycle. +A failed combined preview can be retried explicitly with `stage upstream` while +retaining installed CybexOS. Personal inputs are never refreshed implicitly. -> [!IMPORTANT] -> The repository's original code and configuration are MIT-licensed. This -> license does not cover third-party packages, artwork, fonts, trademarks, or -> images. Audit those separate redistribution terms before publishing bundled -> release media; see [licensing and asset provenance](docs/licensing.md). +For source work, use `nix develop` and `tests/run`. Build the ISO with +`nix build .#iso`; use task-specific staging and the repository's ISO/PXE rules. +A successful build or fixture suite does not qualify installation, runtime, +physical hardware, or a different source revision. Nothing in these commands +publishes a release or changes the running workstation. diff --git a/SECURITY.md b/SECURITY.md index c14daaf0..1c12882b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,97 +1,25 @@ -# Security policy - -CybexOS configures privileged parts of a Fedora system: sudo and Polkit -policy, systemd units, the firewall, login and disk-encryption handling, signed -update channels, and pinned downloads. We take reports about these seriously -and handle them privately until a fix is available. - -## Supported versions - -| Version | Supported | -| --- | --- | -| Latest CybexOS release on Fedora 44, x86_64 (ISO and source-checkout installations) | Yes | -| Older CybexOS releases | No; update with `cybex update` first | -| Other Fedora releases or architectures | No | - -Until the first public release is published, reports against the current -`main` branch are accepted. Fedora packages, the kernel, and vendor -applications receive security updates through their own channels; `cybex -update` applies them. - -## Reporting a vulnerability - -Do not report security problems in public issues, discussions, or pull -requests. - -Report them privately through GitHub's private vulnerability reporting: open -the repository's **Security** tab, choose **Advisories**, then **Report a -vulnerability**, or go directly to -. - -If that button is not available, open a public issue that asks for a private -security contact and contains no technical details. A maintainer will arrange -a private channel. - -### What to include - -- the CybexOS version (`cybex version`) and installation path (ISO or source - checkout); -- the affected component, file, or command; -- the impact: what an attacker gains, and from which starting position (local - user, another account, the local network, a malicious download or update); -- reproduction steps or a proof of concept, and any non-default settings - involved, such as passwordless sudo or sudoless Docker; -- whether the problem is already public or known to others. - -`cybex report` writes a redacted diagnostic bundle, -`cybexos-report-.tar.gz`, on your machine. Review its contents before -sharing it, and attach it only to the private advisory, never to a public -issue. Never attach unredacted logs, credentials, tokens, SSH or GPG keys, -recovery keys, or browser profiles. - -## What to expect - -Response times depend on maintainer availability. These are goals, not -guarantees: - -- acknowledge a report within 7 days; -- share an initial assessment within 14 days; -- agree on a fix and disclosure timeline with the reporter, prioritising by - severity. - -Fixes ship as new releases. Published releases are immutable, so a -correction is always a new version rather than an edited release. - -## Scope - -In scope is CybexOS's own code and configuration: - -- the source-checkout installer, Ansible roles, and saved configuration - handling; -- the ISO image, its installer, the `cybexos-desktop` RPM, provisioning, - repair, and reconciliation; -- the `cybex` command, helper scripts, Quickshell shell, and agent skill; -- update, release, signing, and provenance verification, including pinned - download checks; -- privilege boundaries: sudo, Polkit, systemd units, firewall policy, login, - and automatic-login encryption checks; -- insecure defaults, or CybexOS configuration that makes an upstream weakness - exploitable. - -Report problems in the upstream software itself to its maintainers: Fedora -(kernel and packages), RPM Fusion, Hyprland, Quickshell, Flatpak applications, -and vendor applications such as Brave, 1Password, ChatGPT, Tailscale, Steam, -Spotify, Claude Code, Codex, OpenCode, and T3 Code. If you are unsure, report -privately here and we will help route it. - -Documented, explicit opt-ins, such as passwordless sudo, passwordless local -Polkit, sudoless Docker, or additional local-network firewall ports, are -accepted trade-offs rather than vulnerabilities. A default that enables one -of them without the documented choice is in scope. - -## Disclosure - -We follow coordinated disclosure. Please keep details private until a fixed -release is available and a GitHub Security Advisory has been published, or -until 90 days after your report, whichever comes first, unless we agree on -another timeline. Advisories credit reporters who want to be credited. +# Security + +CybexOS configures a NixOS workstation's login, authorization, firewall and +privileged update lifecycle. The NixOS port is unreleased and is not yet +production-qualified. Report security issues privately through the repository's +GitHub security advisories; do not include credentials or personal archives. + +Nixpkgs supplies upstream packages. CybexOS releases and stable/beta channel +manifests use explicit Ed25519 publisher trust. Source revision and NAR identity +must match authenticated metadata. Invalid signatures, expiry, replay, API +incompatibility and normal-update downgrades are rejected. No production trust +key has been supplied; the checked-in trust set is empty and fails closed. + +Secrets never belong in Nix expressions or source archives. Use root-owned +runtime paths and service credentials. The Nix store is publicly readable. +Personal plugins execute trusted local code and are not sandboxed. + +Password-only first login unlocks the keyring. Fingerprint/FIDO authorization +retains password fallback; automatic login requires a declaration and runtime +verification of encryption. Secure Boot must never be silently disabled. +AppArmor and explicit kernel hardening replace distribution-specific policy; +policy equivalence and physical authentication still require qualification. + +[Hardware/security](docs/hardware-security.md), [update trust](docs/releasing.md) +and [recovery limits](docs/recovery.md) document the concrete boundaries. diff --git a/VERSION b/VERSION index cb676de1..ffbc9939 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.0-dev +1.0.0-beta.1 diff --git a/agent-skills/cybexos/SKILL.md b/agent-skills/cybexos/SKILL.md index 7afa64ab..909842cd 100644 --- a/agent-skills/cybexos/SKILL.md +++ b/agent-skills/cybexos/SKILL.md @@ -1,64 +1,67 @@ --- name: cybexos -description: Operate and customize an installed CybexOS Hyprland/Quickshell workstation. Use for CybexOS diagnostics and commands, personal widgets, shell or bar settings, managed desktop changes, screenshots, recording, OCR, reminders, or LocalSend; not for unrelated Fedora systems. +description: Customize or maintain a CybexOS NixOS Hyprland/Quickshell desktop, including personal packages, services, VPNs, widgets, settings, staged updates and recovery. Use on supported CybexOS installations; not for unrelated NixOS hosts. --- # CybexOS -Use this skill for an installed [CybexOS](https://github.com/DigitalPals/CybexOS) -desktop (Cybex Opinionated System). Codex can invoke it as `$cybexos`; Claude -Code exposes the same skill as `/cybexos`. Its description also supports -automatic selection. +Read `/etc/os-release` and `/etc/cybexos/release.json` before making changes. +The supported machine configuration is `/etc/nixos/flake.nix` and its +`flake.lock`; `/etc/nixos` is the machine-owned working-configuration symlink. +Read its modules and applicable repository instructions. A checkout is vendor +source, not the personal configuration of the running desktop. -## Identify the installation +Choose the owner before editing: -Check `rpm -q cybexos-desktop` first. On ISO installations, packaged vendor -files are under `/usr/share/cybexos`; the user's -`~/.local/share/cybexos/runtime` is a compatibility symlink to the packaged -runtime. These systems do not use `~/.local/share/cybexos/current`. +- Vendor: `/nix/store`, `/etc/cybexos/runtime` and + `/run/current-system/sw/share/cybexos`. Inspect read-only. Personal changes + never require editing these files or forking CybexOS. +- Machine/personal declarations: `/etc/nixos/local/*.nix`, imported by + `local/default.nix`. Add packages, services and VPN definitions here. + Normal Nix definitions override vendor `mkDefault` values. Preserve unrelated + declarations and inputs; do not replace entire files with a small example. +- Desktop preferences: `~/.config/cybexos/shell.json`, `displays.json` and + `hypr/user.lua`. Preferences apply without rebuilding the operating system. + Read [Quickshell settings](references/quickshell-settings.md) before edits. +- Plugins: personal code, preferences, placement and data are separate from + the vendor runtime. Read [User widgets](references/user-widgets.md), use the + API 1 contract and `cybex plugin` for native or Omarchy plugins. Personal QML + must not import private shell types. Plugins are trusted code, not sandboxed. +- Secrets/state: `/var/lib/cybexos/secrets` (root `0700`, files `0600`), + persistent service directories, and the person's home. Use `privateKeyFile`, + `LoadCredential` or the service's documented runtime secret API. Never put + secret values or `builtins.readFile` of a secret in a flake, Nix string, + source archive, command argument, log, or public repository. -If the desktop RPM is absent, check whether this is a source-checkout -installation. Its active release is selected by -`~/.local/share/cybexos/current`; inspect it read-only for diagnostics and -schemas. Do not assume a missing RPM means an incomplete installation. +For packages, services or a VPN, read +[Personal declarations](references/managed-configuration.md). NetworkManager +profiles and credentials may instead use its persistent system D-Bus APIs; +do not copy a credential into a Nix declaration. -## Choose the ownership layer +Preview a requested system change through `cybex update stage local`. This +builds the complete configuration and checks the candidate desktop. Inspect +`cybex update status`, including actual package changes and errors. Apply only +the reviewed `transaction` and `reviewToken` using +`cybex update apply TRANSACTION REVIEW_TOKEN`. An authorized request to make +and apply a customization covers this workflow; a diagnostic request does not. +Never substitute a direct `nixos-rebuild switch` or manual lock refresh for the +supported lifecycle. Explain required administrator authentication. -- User shell preferences belong in `~/.config/cybexos/shell.json`. Read - [Quickshell settings](references/quickshell-settings.md) before editing it. -- Personal bar widgets belong in user-owned plugin packages. Read - [User widgets](references/user-widgets.md); use the versioned plugin API and - `cybex plugin` commands. -- Personal Hyprland changes belong in `~/.config/cybexos/hypr/user.lua`. -- Changes to distro defaults, built-in Quickshell code, services, packages, - and other vendor behavior belong in a writable source checkout. Read - [Managed configuration](references/managed-configuration.md). +For updates, select `upstream`, `cybexos` or `both` explicitly. Personal inputs +are updated only on a separate explicit request. A combined failure offers an +explicit upstream-only retry retaining installed CybexOS; never remove modules +or widgets to make a build pass. Keep `system.stateVersion` unchanged. Read +[Commands and recovery](references/commands.md) when applying or recovering. -For supported operator commands and desktop actions, read -[Commands and desktop helpers](references/commands.md). +All live Quickshell tests must source the installed +`/run/current-system/sw/share/cybexos/lib/quickshell-live` and run `qs_live_begin` +and `qs_live_end`. In a checkout use `tests/lib/quickshell-live`. The managed +service's MainPID must be the sole `qs` PID and its current journal must be +clean. Never use `pkill qs` or launch a second menubar. Preview personal plugin +code in an isolated test environment, or use the managed service under these +guards when the user has authorized a live test. -## Boundaries - -- Never edit packaged files under `/usr/share/cybexos` or files below the - source installation's `~/.local/share/cybexos/current` or - `~/.local/share/cybexos/runtime` directly. -- Do not treat `cybex repair` as a way to deploy checkout changes. On ISO - installations it reapplies the policy bundled with the installed RPM; make - vendor changes in source, rebuild/update the desktop RPM, and then use the - supported package update path. -- Never add personal plugin IDs or settings to `shell.json`'s built-in `mods` - or `modOpts`. Preserve plugin packages, preferences, and state across - updates and rollbacks. -- Preserve unrelated checkout changes. Read every applicable `AGENTS.md` - before modifying or testing a checkout. -- Require explicit user intent before reconfiguration, updates, uninstall, - cancellation, reboot, shutdown, reset, package removal, or another - destructive operation. A diagnostic request authorizes inspection, not a - repair or upgrade. -- Never use `pkill qs`. Every live Quickshell check must use the checkout's - `tests/lib/quickshell-live` start and end functions, including their PID, - service, and current-invocation journal checks. - -Prefer the least invasive diagnostic that answers the request. Report what was -observed, what was changed, the verification performed, and anything that -still needs manual confirmation. +Report what changed, the preview/build and health results, persistent data +impact, and any required user-selected restart. Never reboot without an +explicit preference or instruction. Generation rollback restores system code +and configuration; it does not roll back credentials or application databases. diff --git a/agent-skills/cybexos/references/commands.md b/agent-skills/cybexos/references/commands.md index ad345d8b..a2a3ce93 100644 --- a/agent-skills/cybexos/references/commands.md +++ b/agent-skills/cybexos/references/commands.md @@ -1,121 +1,32 @@ -# Commands and desktop helpers - -Prefer installed commands over reconstructed shell pipelines. On RPM systems, -packaged command sources live under `/usr/share/cybexos`; on source-checkout -systems, inspect the active release under `~/.local/share/cybexos/current` or -run help before using an unfamiliar option. - -## CybexOS - -- `cybex version` reports the active desktop release; `cybex doctor --json` - runs read-only installed-system diagnostics (on checkouts, `cybex verify` - too; add `--source` only for repository/developer checks). -- `cybex update-channel status --json` reports the RPM update channel. On - checkouts, `cybex update --check` checks the release channel instead; ISO - installations do not support it. Neither starts an update. -- `sudo /usr/libexec/cybexos-reconcile --status` inspects pending account and - machine policy reconciliation after RPM upgrades; `--retry` requests a retry. -- `cybexos-update-run status --json`, `log-dir`, `read-log` and - `history --json` (finished updates with the recovery point, `snapshotId`, - each took) inspect the durable updater without starting one. -- `cybexos-runtime ipc updates check` refreshes the desktop's update check; - `cybexos-runtime ipc updates open` opens the Updates view. Neither installs. - `systemctl is-enabled cybexos-update-download.timer` shows whether - background downloads are on (off by default; changing it is a Settings - choice that asks for authorization). -- `cybex report --json --no-browser` writes a redacted diagnostic bundle - (`cybexos-report-*.tar.gz`; `--output` picks the path) and prints its - summary. It uploads nothing; the user reviews and attaches the file. -- `/usr/local/libexec/cybexos-nvidia status` prints the NVIDIA driver state - (`ready`, `reboot`, `enroll`, `failed`). Never print - `/var/lib/cybexos/nvidia-mok.json`: it holds the one-time Secure Boot - enrollment password. -- `cybex factory-reset` (ISO installations only) erases every account and home - folder for a new owner. It is destructive and interactive (typed - confirmation, sudo): only on an explicit request, after suggesting - `cybex backup now`. On a checkout it only explains it is unavailable. - -Enable a public RPM channel only after verifying its public configuration and -key fingerprint (`image/channels/stable.json` in the source tree; add -`--check` to validate without enabling). Never use a private signing key on -the workstation: - -```bash -sudo cybex update-channel enroll /path/to/public.json \ - --fingerprint FULL_OPENPGP_PRIMARY_FINGERPRINT -``` - -An actual `cybex update`, `configure`, `install`, or `uninstall` needs explicit -user intent. So do update cancellation, reboot, shutdown, and recovery/reset -operations. Do not infer authorization from a request to diagnose or check -status. - -## Backups - -`cybex backup` drives encrypted personal-file backups (restic), the same -helper as Settings → Backup & recovery. `status --json` (read-only, no -restic), `snapshots --json` (may ask to unlock the keyring) and `excludes` -(extra patterns go in `~/.config/cybexos/backup-exclude`) only inspect. -Steam userdata, Proton prefixes and rootless container volumes are included; -downloaded games, container images and writable layers are excluded. Live -database files are not a consistent export: use the application's dump/backup -tool into a backed-up home folder, or stop it for the whole backup. System -stores and separately mounted filesystems are outside this home backup. -`setup`, `now`, `restore`, `automatic`, `recovery-key` and `confirm-key` need -explicit user intent. `restore` writes only into a new folder under -`~/Restored`. Never ask for, print, store, or copy a recovery key or the -keyring password; `setup` and `recovery-key` show a new key once, to the user. - -## Desktop actions - -These commands act in the user's graphical session. A direct request for the -corresponding action supplies intent; otherwise explain the command rather -than launching an interactive selector or sending data. - -- `screenshot` selects a region (or a window, with a click), saves it under - `~/Pictures/Screenshots` and copies it; `screenshot fullscreen` captures the - focused monitor. `screenshot scroll` captures a region while the user - scrolls and saves one stitched image when they press Enter, Escape or the - shortcut again (`screenshot scroll-stop` finishes, never starts, one). -- `screen-record` toggles a region recording, saved under - `~/Videos/Screen Recordings`; `--webcam` adds a camera preview to the - recorded area, and `--encoder` prints the encoder a recording would use. -- `screen-ocr` copies a QR code or barcode's content in a selected region, - or else its English text. It never opens a decoded link. -- `color-picker` lets the user click a pixel and copies its hex colour. -- `quickshell-reminder add MINUTES [MESSAGE]` schedules a persistent reminder; - `list --json`, `cancel ID` and `clear` manage them. -- `cybex webapp list --json` lists web apps. `install --url URL [--name NAME] - [--id ID]` adds one (http/https without credentials; `--offline` uses a - generated icon), `remove ID` removes one for good, `launch ID` opens one. - The defaults are `slack`, `whatsapp`, `youtube`, `google-photos` and `x`. -- `cybex theme list --json` lists appearance themes; `apply ID` sets mode, - accent, bar colour and font (and a theme's wallpaper) at once; `export - ID|CURRENT [DIR] [--name NAME]` writes a shareable folder or `.tar.gz`; - `install SOURCE` accepts a folder, archive or `https://` Git URL holding - only data; `remove ID` deletes a user theme. Make a theme in - `~/.local/share/cybexos/themes//theme.json` (schema in - docs/shell-appearance.md#themes), not by editing shell.json theme keys. -- `game-mode status [--json]` reports game mode; `on`, `off` and `toggle` - turn effects, rounding, gaps and idle locking off until it is turned off or - the session ends. It notifies on every change. -- `cybexos-runtime ipc launcher open PROVIDER` opens the launcher on a tab - such as `clipboard` or `actions`; `cybexos-runtime ipc overview toggle` - (or `open`, `close`) shows the window overview, as `Super+Tab` does. -- `localsend` launches the LocalSend Flatpak. `localsend-share clipboard`, - `localsend-share file [PATH...]` and `localsend-share folder [PATH...]` send - through its headless interface; omitted paths open an interactive chooser. - -Screen capture, recording, OCR, colour picking, reminders, web app changes and -LocalSend are user-visible or externally consequential. Report cancellation or -command failure accurately; do not retry a send, capture, or reminder creation -unless the first attempt is known not to have completed. - -## Sign-in factors - -Fingerprints and FIDO2/U2F security keys are managed in Settings → Security -(`cybexos-runtime ipc settings open security`). Enrolment needs the person at -the reader or key, so open the page rather than running `fprintd-enroll` or -`pamu2fcfg` for them. The sudo, Polkit and lock-screen PAM steps belong to -`cybexos-auth-policy` (docs/security.md); do not hand-edit `/etc/pam.d` or -enable authselect's `with-pam-u2f`, which would also reach the login screen. +# Commands and recovery + +- `cybex update check`: independent upstream and authenticated CybexOS status. +- `cybex update stage upstream|cybexos|both|local`: build and validate a staged + complete configuration, preserving the working configuration. +- `cybex update status`: durable progress, actual closure changes, errors, + restart needs, transaction ID and review token. +- `cybex update apply ID TOKEN`: supervised activation of the reviewed build. +- `cybex update confirm`: after an explicit reboot and authenticated desktop + login, check boot/services/Hyprland/Quickshell and retain the known-good system. +- `cybex update history`: retained deployment identities and recovery state. +- `cybex update rollback [ID]`: restore the pending or selected deployment's + previous system and configuration. No automatic reboot or application-data + rollback is implied. +- `cybex plugin ...`, `cybex theme ...`, `cybex shell ...`: persistent desktop + extensions, themes and diagnostics. + +Read the error and transaction status before retrying. Interrupted staging +retains the working system. Interrupted activation is recovered at boot. A trial +boot leaves the previous generation as the persistent boot default until fresh +boot and login health pass. If a trial fails before userspace, reboot and choose +the previous generation; automatic reboots are disabled. Boot the installation +ISO for offline recovery if the firmware cannot find a retained generation. + +Persistent credentials, NetworkManager profiles, databases and widget data +remain in place through rollback. Back up and test explicit data migrations +before a future service/schema change; rolling system code back does not undo +data writes. Do not downgrade a database or rewrite an unknown newer schema. + +Use the user's normal desktop commands for screenshots (`screenshot region`), +recording (`screen-record`), OCR (`screen-ocr`) and file sharing (`localsend-share`). +Do not capture or transmit private material without an authorized task. diff --git a/agent-skills/cybexos/references/managed-configuration.md b/agent-skills/cybexos/references/managed-configuration.md index 41cd6c1e..f0344a05 100644 --- a/agent-skills/cybexos/references/managed-configuration.md +++ b/agent-skills/cybexos/references/managed-configuration.md @@ -1,94 +1,35 @@ -# Managed Hyprland and Quickshell changes - -Use this guide for persistent behavior owned by CybexOS: Hyprland, -Quickshell source, services, packages, launchers, or provisioning policy. -Personal widgets use [the user widget API](user-widgets.md), and personal -Hyprland overrides use `~/.config/cybexos/hypr/user.lua`. Those changes do -not need a distro fork. Never edit deployed vendor copies. - -ISO installations receive vendor files in the `cybexos-desktop` RPM under -`/usr/share/cybexos`; `~/.local/share/cybexos/runtime` points to its runtime. -There is no `~/.local/share/cybexos/current` release link on this installation -type. Persistent vendor changes for an ISO installation must be made in a -source checkout, included in a rebuilt desktop RPM, and delivered through the -RPM update channel. `cybex repair` reapplies policy bundled in the installed -RPM; it does not deploy files from a checkout. - -Hyprland and Quickshell are held to the series the release qualified by -entries marked `CybexOS qualified series` in `/etc/dnf/versionlock.toml` -(`dnf versionlock list`); a converge replaces edits to them. Moving to another -series is a release change to `qualified_package_series` in -`inventory/group_vars/all.yml`. With explicit user intent, a separately -reviewed build can be admitted by an entry of the administrator's own, such as -`sudo dnf versionlock add hyprland-VERSION-RELEASE`, which CybexOS keeps. - -## Find a writable checkout - -1. Inspect the current working tree and `~/Code/CybexOS` first. A usable - checkout must be writable, have this repository's `site.yml`, and identify - `DigitalPals/CybexOS` (formerly `DigitalPals/fedora-config`) as an expected - Git remote. Do not mistake - `~/.local/share/cybexos/current` or a versioned release for a checkout. -2. If needed, search a small set of user source roots such as `~/Code` without - traversing the whole home directory. Inspect `git status` before choosing a - tree, and preserve all existing modifications. -3. If no suitable checkout exists, ask before cloning - `https://github.com/DigitalPals/CybexOS.git` into - `~/Code/CybexOS`. Cloning is not implied by a customization or - diagnostic request. -4. Read the repository root `AGENTS.md` and any nearer `AGENTS.md` files before - acting. - -Use the packaged source under `/usr/share/cybexos` read-only to inspect an ISO -installation. On a source-checkout installation, use its active release -read-only when no source change is needed. These are different deployment -paths; do not run checkout Ansible against an ISO installation as a way to -apply RPM-managed files. - -## Change and deploy - -Keep the edit in the smallest managed source file. Check the worktree before -and after, and do not reformat, delete, stage, or restore unrelated changes. - -Run the repository gate before a source-checkout deployment: - -```bash -./tests/run -``` - -For source-checkout installations, preview the machine change with the saved -installer contract when practical: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --check --diff +# Personal declarations + +Inspect `/etc/nixos/flake.nix`, `machine.nix`, `local/default.nix`, and existing +modules first. Add a narrowly scoped module and import it from +`local/default.nix`; preserve other imports. Example: + +```nix +{ pkgs, ... }: { + environment.systemPackages = [ pkgs.wireshark ]; + services.syncthing.enable = true; + networking.wg-quick.interfaces.work = { + address = [ "10.20.0.2/32" ]; + privateKeyFile = "/var/lib/cybexos/secrets/work-vpn.key"; + peers = [{ + publicKey = "PUBLIC_KEY"; + allowedIPs = [ "10.20.0.0/16" ]; + endpoint = "vpn.example.org:51820"; + }]; + }; +} ``` -Deploy to a source-checkout installation through Ansible, choosing only a -documented narrow tag when its prerequisites are already present. Hyprland and -Quickshell are normally in the `desktop` role: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags desktop -``` - -Do not copy source files directly into `~/.config`. Do not run reconfiguration, -an update, uninstall, reboot, package removal, or reset unless the user -explicitly requested that operation. - -## Live Quickshell safety +Service `StateDirectory` and `LoadCredential` retain state and load runtime +secrets without exposing plaintext to the Nix store. For VPN credentials, ask +for an authorized local secret file or use a private secret-entry mechanism; +never ask the person to paste a key into chat. Do not print it to validate it. -Every live shell inspection or smoke test must source this checkout's -`tests/lib/quickshell-live` and call `qs_live_begin` before the check and -`qs_live_end` afterward. Ensure `qs_live_end` also runs on failure and signals. -Those functions reconcile `quickshell.service`'s `MainPID` against every -`pgrep -x qs` result, inspect extra processes before terminating only confirmed -developer instances, restore service health, and inspect the current -invocation journal. Never substitute a handwritten shortcut and never run -`pkill qs`. +Run `cybex update stage local`, review `cybex update status`, and apply the +returned transaction/token. Failed evaluation/build leaves the active system +available. Correct the personal module; do not disable unrelated modules. -Prefer deployment followed by testing through `quickshell.service`. If a -source-tree `qs -d` or `qs -p` session is genuinely necessary, stop -`quickshell.service` first and install cleanup that always terminates only that -known developer PID and restores the service. The test is incomplete until -`qs_live_end` confirms the managed service is active and is the sole clean -`qs` process. +Nixpkgs must remain the single stable root input. New personal dependencies +follow the root `nixpkgs`. Vendor input updates must leave personal input +identities alone. Major NixOS upgrades require explicit intent and compatibility +qualification; `system.stateVersion` records the installation state baseline. diff --git a/agent-skills/cybexos/references/quickshell-settings.md b/agent-skills/cybexos/references/quickshell-settings.md index 021686da..b685ac9a 100644 --- a/agent-skills/cybexos/references/quickshell-settings.md +++ b/agent-skills/cybexos/references/quickshell-settings.md @@ -9,18 +9,18 @@ separate preferences use [the user widget API](user-widgets.md). Do not assume a schema version or copy defaults from another release. Read: ```text -~/.local/share/cybexos/current/roles/desktop/files/quickshell/Common/SettingsHelpers.js -~/.local/share/cybexos/current/roles/desktop/files/quickshell/Common/Settings.qml +/etc/cybexos/runtime/quickshell/Common/SettingsHelpers.js +/etc/cybexos/runtime/quickshell/Common/Settings.qml ``` `SettingsHelpers.js` is authoritative for `VERSION`, `defaults()`, accepted enums and ranges, module IDs, composite normalization, and migrations. -`Settings.qml` identifies the persisted properties and the live watcher. It can -be loaded with Node because it exports its pure helpers: +`Settings.qml` identifies the persisted properties and the live watcher. +`SettingsHelpers.js` can be loaded with Node because it exports its pure helpers: ```bash node -e 'const s=require(process.argv[1]); console.log(JSON.stringify({version:s.VERSION,defaults:s.defaults(),modules:s.MODULE_IDS},null,2))' \ - "$HOME/.local/share/cybexos/current/roles/desktop/files/quickshell/Common/SettingsHelpers.js" + /etc/cybexos/runtime/quickshell/Common/SettingsHelpers.js ``` The stable top-level setting families are: @@ -46,10 +46,10 @@ not partial patches. Do not set or lower the `v` field manually. ## Merge safely -1. If a live shell will be checked, first locate the applicable checkout (the - active release is sufficient for diagnostics), source - `tests/lib/quickshell-live`, and call `qs_live_begin`. Arrange cleanup so - `qs_live_end` runs even after failure or interruption. +1. If a live shell will be checked, source the installed + `/run/current-system/sw/share/cybexos/lib/quickshell-live` and call + `qs_live_begin`. In a checkout use `tests/lib/quickshell-live`. Arrange cleanup + so `qs_live_end` runs even after failure or interruption. 2. Read the current file. If it exists, require a regular, valid JSON object; stop on a symlink, non-regular file, malformed JSON, or read failure. 3. Put only the requested keys in a JSON object patch. Preserve arrays in full. diff --git a/agent-skills/cybexos/references/user-widgets.md b/agent-skills/cybexos/references/user-widgets.md index d3bd98af..0cb4847c 100644 --- a/agent-skills/cybexos/references/user-widgets.md +++ b/agent-skills/cybexos/references/user-widgets.md @@ -1,8 +1,10 @@ # Personal bar widgets Create a widget for the installed user without changing the distro. The active -release's [widget contract](../../../docs/architecture/user-widgets.md) is the -authoritative API reference; read it before implementing behavior. +release's authoritative API reference is installed at +`/run/current-system/sw/share/cybexos/source/docs/architecture/user-widgets.md`; +read it before implementing behavior. In a checkout, read +[the widget contract](../../../docs/architecture/user-widgets.md). ## Ownership and workflow @@ -26,15 +28,19 @@ authoritative API reference; read it before implementing behavior. error tooltip. Use `cybex plugin disable ` to recover without deleting code, preferences, or data. -V1 places widgets before the right-hand built-in modules. Width is 24–320 -pixels; lower order comes first, with IDs breaking ties. Widgets that exceed +API 1 supports left, center and right plugin sections. Native widgets default +to the right section; move them with `cybex plugin move-widget left|center|right ` +or drag them in the bar. Width is 24–320 pixels; lower order comes first within +a section, with IDs breaking ties. Widgets that exceed the available space are hidden with a count and tooltip. Each output gets its own widget instance; settings and state are shared. Run shared background services separately rather than starting one daemon per output. -For a live check, follow the repository's `tests/lib/quickshell-live` begin/end -workflow described in [managed configuration](managed-configuration.md). Test -through `quickshell.service`; never leave a second source-tree shell running. +For a live check, source the installed +`/run/current-system/sw/share/cybexos/lib/quickshell-live` and run +`qs_live_begin` and `qs_live_end`. In a checkout use `tests/lib/quickshell-live`. +Test through `quickshell.service`; never leave a second source-tree shell +running. ## Minimal package diff --git a/ansible.cfg b/ansible.cfg deleted file mode 100644 index e26deb33..00000000 --- a/ansible.cfg +++ /dev/null @@ -1,28 +0,0 @@ -[defaults] -inventory = inventory/hosts.yml -roles_path = roles -library = image/library -callback_plugins = plugins/callback -interpreter_python = auto_silent -retry_files_enabled = False -stdout_callback = cybexos -force_color = True -# Every invocation validates the machine it is running on from fresh setup -# facts. A persistent cache is unsafe for a local workstation inventory alias: -# it can outlive a kernel update or even follow the checkout to another host. -gathering = explicit -fact_caching = memory -# A notified handler is otherwise lost when a later task fails: the retry sees -# its triggering task as unchanged, so an initramfs rebuild, daemon-reload, or -# service restart would never happen. Run already-notified handlers first. -force_handlers = True - -[privilege_escalation] -become = True - -[connection] -# Feed modules to the interpreter over stdin instead of writing, chmodding, -# and deleting a temporary file per task. Fedora's sudo has no requiretty. -# Async tasks and file transfers still use temporary files, which the base -# role's acl package makes readable to an unprivileged become_user. -pipelining = True diff --git a/assets/distribution-review.json b/assets/distribution-review.json index 23b4b99e..b4f62e8b 100644 --- a/assets/distribution-review.json +++ b/assets/distribution-review.json @@ -1,12 +1,9 @@ { "schema": 1, - "publication": "PUBLIC", - "reviews": [], - "unresolved": { - "source": "Owner review of third-party artwork, code notices, Plymouth, wordmarks and trademark use is required for the actual archive.", - "desktop-rpm": "Owner review of the actual RPM payload, artwork, third-party notices and marks is required.", - "offline-rpm": "Vendor redistribution grants, SDK acceptance, font obligations and corresponding source are unresolved; inspect the actual payload.", - "iso": "Actual RPM/Flatpak inventories, firmware, proprietary payloads, codec patents, OpenH264, corresponding source and Fedora/vendor trademarks require owner/legal decisions.", - "web": "Review actual compiled web payload, dependency/font notices, wordmarks and provider/Fedora trademark use. Original wallpaper provenance does not resolve these decisions." + "status": "owner-review-required", + "scopes": { + "iso": "Review actual Nix closure, firmware, codecs, proprietary payloads, fonts, artwork, third-party notices and corresponding-source obligations.", + "source": "Review source/license notices and bundled extension redistribution.", + "web": "Review compiled web dependencies, fonts, marks and artwork." } } diff --git a/roles/dotfiles/templates/brightness-control.j2 b/assets/scripts/brightness-control similarity index 98% rename from roles/dotfiles/templates/brightness-control.j2 rename to assets/scripts/brightness-control index b9824e54..c02e096a 100755 --- a/roles/dotfiles/templates/brightness-control.j2 +++ b/assets/scripts/brightness-control @@ -27,7 +27,7 @@ verb=${1:-} [[ -n $verb ]] || usage # Apple's HID brightness range, from the device table asdcontrol is built -# with — see roles/private-hooks/files/build-asdcontrol, where every supported +# with — see nix/packages/asdcontrol.nix, where every supported # display maps 0%..100% onto these bounds. asd_min=400 asd_max=60000 diff --git a/assets/scripts/cybexos-agent b/assets/scripts/cybexos-agent index 1c90eeeb..cb284bf9 100644 --- a/assets/scripts/cybexos-agent +++ b/assets/scripts/cybexos-agent @@ -93,7 +93,7 @@ store_default() { supported_agent "$selected" \ || fail 2 "unsupported agent '$selected' (choose opencode, claude, or codex)" agent_available "$selected" \ - || fail 127 "$(agent_label "$selected") is not installed; run 'cybex configure'" + || fail 127 "$(agent_label "$selected") is not installed; enable cybexos.features.developer in /etc/nixos/local and preview with 'cybex update stage local'" mkdir -p -- "$defaults_dir" \ || fail 1 "could not create $defaults_dir" temporary=$(mktemp "$defaults_dir/.agent.XXXXXX") \ @@ -118,7 +118,7 @@ pick_agent() { fi done ((${#choices[@]} > 0)) \ - || fail 127 "no supported AI agent is installed; run 'cybex configure'" + || fail 127 "no supported AI agent is installed; enable cybexos.features.developer in /etc/nixos/local and preview with 'cybex update stage local'" # stdout is intentionally captured by the caller so only the selected ID # flows into the adapter; stdin remains the reliable interactive boundary. [[ -t 0 ]] \ diff --git a/assets/scripts/cybexos-backup b/assets/scripts/cybexos-backup index 2a9b3d1a..6eebc328 100755 --- a/assets/scripts/cybexos-backup +++ b/assets/scripts/cybexos-backup @@ -114,9 +114,6 @@ HOME_EXCLUDES = ( ".local/share/docker/buildkit", ".local/share/docker/containerd", ".local/share/flatpak", - ".local/share/cybexos/runtime", - ".local/share/cybexos/releases", - ".local/share/cybexos/current", ".local/share/nvim/lazy", ".local/share/nvim/mason", ".local/share/voxtype/models", @@ -794,7 +791,7 @@ class Progress: def runtime_command(context): - installed = context.home / ".local/bin/cybexos-runtime" + installed = Path(shutil.which("cybexos-runtime") or "/run/current-system/sw/bin/cybexos-runtime") return str(installed) if installed.exists() else shutil.which("cybexos-runtime") diff --git a/assets/scripts/cybexos-firmware-update b/assets/scripts/cybexos-firmware-update index 247bcc68..b9ad2031 100755 --- a/assets/scripts/cybexos-firmware-update +++ b/assets/scripts/cybexos-firmware-update @@ -1,11 +1,13 @@ #!/usr/bin/python3 -I -"""Install fwupd device firmware for the durable CybexOS updater. +"""Inspect and install fwupd device firmware independently of Nix generations. -cybexos-update-run runs `install` as root after the package phase. It picks -the same devices and releases as `fwupdmgr update`, but reports every step as -structured events that Quickshell renders, instead of prompting in a terminal: +The desktop opens `interactive` in a terminal. It refreshes firmware metadata, +shows the exact device/release plan and requires confirmation before installing. +fwupd handles authorization and verifies publisher signatures. Firmware changes +are not reverted by a Nix generation rollback. No command reboots the machine. cybexos-firmware-update plan + cybexos-firmware-update interactive cybexos-firmware-update install [--events PATH] `plan` is read-only and prints {"devices": [...]}. `install` appends one @@ -15,9 +17,8 @@ stream with exactly one `summary`. Exit status: 0 when nothing failed (including nothing to do), 1 when any device failed, 2 when fwupd could not be used at all. -The interpreter is the system Python in isolated mode: PyGObject comes from -python3-gobject, and a root process must not import packages from the update -owner's user site directory (the worker keeps that user's HOME). +The Nix package wraps Python and PyGObject with their native fwupd typelibs; +the isolated interpreter never imports packages from the user's site directory. """ from __future__ import annotations @@ -25,6 +26,7 @@ from __future__ import annotations import argparse import json import re +import subprocess import sys EXIT_OK = 0 @@ -611,7 +613,7 @@ def command_plan(out): return EXIT_OK -def command_install(events_path, out): +def command_install(events_path, out, interactive=False): try: events = EventLog(events_path) except OSError as error: @@ -632,9 +634,38 @@ def command_install(events_path, out): events.emit("failed", id="", reason=reason, message=message) events.emit("summary", installed=0, failed=1, needsReboot=False) return EXIT_SETUP + if interactive and plan: + out.line("Available firmware updates:") + for planned in plan: + out.line(" " + planned.label) + out.line("Firmware cannot be rolled back with NixOS generations. Keep external power connected.") + try: + approved = input("Install these firmware updates? [y/N] ").strip().lower() in ("y", "yes") + except (EOFError, KeyboardInterrupt): + approved = False + if not approved: + out.line("Firmware installation cancelled; no firmware was installed.") + return EXIT_OK return Installer(api, client, events, out).run(plan) +def command_interactive(out): + if not sys.stdin.isatty(): + out.line("Open firmware updates in a terminal to inspect and confirm the device plan.") + return EXIT_SETUP + out.line("Refreshing signed firmware metadata…") + try: + result = subprocess.run(["fwupdmgr", "refresh"], check=False) + except OSError as error: + out.line(f"Cannot refresh firmware metadata: {error}") + return EXIT_SETUP + # fwupdmgr returns 2 for an operation with nothing to do. + if result.returncode not in (0, 2): + out.line("Firmware metadata could not be refreshed. Check the network and fwupd service, then retry.") + return EXIT_SETUP + return command_install(None, out, interactive=True) + + def warn(text): print(f"cybexos-firmware-update: {text}", file=sys.stderr, flush=True) @@ -645,6 +676,7 @@ def main(argv=None): description="Install fwupd firmware updates with structured progress.") commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("plan", help="print the updates `install` would apply (read-only)") + commands.add_parser("interactive", help="refresh metadata, review and confirm firmware installation in a terminal") install = commands.add_parser("install", help="install every available firmware update") install.add_argument("--events", metavar="PATH", help="append JSON-lines progress events to PATH") @@ -655,6 +687,8 @@ def main(argv=None): out = HumanOutput(sys.stdout) if args.command == "plan": return command_plan(out) + if args.command == "interactive": + return command_interactive(out) return command_install(args.events, out) diff --git a/assets/scripts/cybexos-migrate-layering b/assets/scripts/cybexos-migrate-layering deleted file mode 100755 index 50f6d88f..00000000 --- a/assets/scripts/cybexos-migrate-layering +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -"""Classify legacy managed desktop trees without mutating user-owned paths.""" - -from __future__ import annotations - -import argparse -from datetime import datetime, timezone -import json -import os -from pathlib import Path, PurePosixPath -import shutil -import stat -import sys -from typing import Any - - -LEGACY_HYPR_FILES = ( - "features.lua", - "monitors.lua", - "input.lua", - "looknfeel.lua", - "bindings.lua", - "autostart.lua", - "hyprland.lua", - "hypridle.conf", - "hyprlock.conf", -) -STATIC_HYPR_FILES = {"bindings.lua", "autostart.lua", "hyprland.lua"} - - -def within(path: Path, parent: Path) -> bool: - try: - path.relative_to(parent) - except ValueError: - return False - return True - - -def safe_relative(value: str) -> Path | None: - pure = PurePosixPath(value) - if not value or pure.is_absolute() or ".." in pure.parts or "." in pure.parts: - return None - return Path(*pure.parts) - - -def same_entry(current: Path, baseline: Path) -> bool: - try: - current_stat = current.lstat() - baseline_stat = baseline.lstat() - except OSError: - return False - if stat.S_ISREG(current_stat.st_mode) and stat.S_ISREG(baseline_stat.st_mode): - return current.read_bytes() == baseline.read_bytes() - if stat.S_ISLNK(current_stat.st_mode) and stat.S_ISLNK(baseline_stat.st_mode): - return os.readlink(current) == os.readlink(baseline) - return False - - -def copy_entry(source: Path, destination: Path) -> None: - destination.parent.mkdir(parents=True, exist_ok=True) - source_stat = source.lstat() - if stat.S_ISLNK(source_stat.st_mode): - destination.symlink_to(os.readlink(source)) - elif stat.S_ISREG(source_stat.st_mode): - shutil.copy2(source, destination, follow_symlinks=False) - - -def copy_missing(source: Path, destination: Path) -> bool: - """Copy legacy user state only when the new namespaced path is absent.""" - if destination.exists() or destination.is_symlink(): - return False - try: - source_stat = source.lstat() - except OSError: - return False - destination.parent.mkdir(parents=True, exist_ok=True) - if stat.S_ISDIR(source_stat.st_mode) and not stat.S_ISLNK(source_stat.st_mode): - shutil.copytree(source, destination, symlinks=True) - else: - copy_entry(source, destination) - return True - - -def walk_leaf_entries(root: Path) -> set[Path]: - found: set[Path] = set() - if not root.is_dir() or root.is_symlink(): - return found - for directory, directories, files in os.walk(root, followlinks=False): - current = Path(directory) - linked_directories = [name for name in directories if (current / name).is_symlink()] - directories[:] = [name for name in directories if name not in linked_directories] - for name in [*linked_directories, *files]: - found.add((current / name).relative_to(root)) - return found - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--home", required=True, type=Path) - parser.add_argument("--previous-source", required=True, type=Path) - parser.add_argument("--runtime-root", required=True, type=Path) - args = parser.parse_args() - - home = args.home.resolve(strict=True) - if not home.is_dir() or home.stat().st_uid != os.getuid(): - raise SystemExit("layering migration: home is not owned by the current user") - - state_root = home / ".local/state/cybexos" - marker = state_root / "migrations/ownership-v1.complete" - legacy_manifest = state_root / "quickshell-manifest.txt" - for guarded in (state_root, state_root / "migrations"): - if guarded.is_symlink(): - raise SystemExit(f"layering migration: refusing linked state path: {guarded}") - if marker.is_file() and not marker.is_symlink(): - print("Layering migration already completed.") - return 0 - - state_root.mkdir(parents=True, exist_ok=True, mode=0o700) - marker.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - os.chmod(state_root, 0o700) - os.chmod(marker.parent, 0o700) - - # This manifest was written only by the legacy deployment. Its absence on - # a fresh install means ~/.config/{quickshell,hypr} belongs wholly to the - # user and must not even be interpreted as an earlier CybexOS tree. - if not legacy_manifest.is_file() or legacy_manifest.is_symlink(): - marker.write_text("no-legacy-deployment\n", encoding="utf-8") - os.chmod(marker, 0o600) - print("Layering migration found no legacy CybexOS deployment.") - return 0 - - stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - migration_root = marker.parent / f"ownership-v1-{stamp}" - suffix = 0 - while migration_root.exists(): - suffix += 1 - migration_root = marker.parent / f"ownership-v1-{stamp}-{suffix}" - backup_root = migration_root / "preserved" - migration_root.mkdir(mode=0o700) - - previous_source = args.previous_source.resolve(strict=False) - runtime_root = args.runtime_root.resolve(strict=False) - entries: list[dict[str, Any]] = [] - backed_up: set[str] = set() - - legacy_state_moves = ( - (home / ".local/state/quickshell/shell-settings.json", home / ".config/cybexos/shell.json"), - (home / ".local/state/quickshell/notes.json", state_root / "shell/notes.json"), - (home / ".local/state/quickshell/t3-model-favorites.json", state_root / "shell/t3-model-favorites.json"), - (home / ".local/state/quickshell/wallpaper-palette.json", state_root / "shell/wallpaper-palette.json"), - (home / ".local/state/quickshell/reminders", state_root / "shell/reminders"), - (home / ".local/state/quickshell-github.json", state_root / "shell/github.json"), - ) - for old_path, new_path in legacy_state_moves: - if copy_missing(old_path, new_path): - entries.append({ - "path": str(new_path.relative_to(home)), - "classification": "legacy-state-copied", - "baseline": str(old_path), - }) - - def classify(relative_home: str, baseline: Path | None, *, managed: bool) -> None: - current = home / relative_home - try: - current.lstat() - except OSError: - return - if baseline is not None and same_entry(current, baseline): - classification = "vendor-unchanged" - elif managed and baseline is not None and baseline.exists(): - classification = "user-modified" - elif managed: - classification = "baseline-unavailable" - else: - classification = "user-added" - if classification != "vendor-unchanged": - copy_entry(current, backup_root / relative_home) - backed_up.add(relative_home) - entries.append({ - "path": relative_home, - "classification": classification, - "baseline": str(baseline) if baseline is not None else "", - }) - - quickshell_root = home / ".config/quickshell" - if quickshell_root.is_symlink() or (quickshell_root.exists() and not quickshell_root.is_dir()): - classify(".config/quickshell", None, managed=False) - else: - quickshell_entries = walk_leaf_entries(quickshell_root) - linked_directories = { - relative - for relative in quickshell_entries - if (quickshell_root / relative).is_symlink() - } - managed_quickshell: set[Path] = set() - for line in legacy_manifest.read_text(encoding="utf-8").splitlines(): - relative = safe_relative(line.strip()) - if relative is None: - continue - managed_quickshell.add(relative) - # Never follow a user-inserted directory link while inspecting a - # formerly managed descendant. The link itself is preserved below. - if any(parent in linked_directories for parent in relative.parents): - continue - classify( - str(Path(".config/quickshell") / relative), - previous_source / "roles/desktop/files/quickshell" / relative, - managed=True, - ) - for relative in sorted(quickshell_entries - managed_quickshell): - classify(str(Path(".config/quickshell") / relative), None, managed=False) - - hypr_root = home / ".config/hypr" - if hypr_root.is_symlink() or (hypr_root.exists() and not hypr_root.is_dir()): - classify(".config/hypr", None, managed=False) - else: - for name in LEGACY_HYPR_FILES: - if name in STATIC_HYPR_FILES: - baseline = previous_source / "roles/desktop/files" / name - else: - baseline = runtime_root / "hypr" / name - classify(str(Path(".config/hypr") / name), baseline, managed=True) - - counts: dict[str, int] = {} - for entry in entries: - key = str(entry["classification"]) - counts[key] = counts.get(key, 0) + 1 - report = { - "schemaVersion": 1, - "createdAt": datetime.now(timezone.utc).isoformat(), - "policy": "classify-and-preserve; legacy paths were not modified", - "previousSource": str(previous_source), - "runtimeRoot": str(runtime_root), - "preservedRoot": str(backup_root) if backed_up else "", - "counts": counts, - "entries": entries, - } - report_path = migration_root / "report.json" - report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") - os.chmod(report_path, 0o600) - marker.write_text(str(report_path) + "\n", encoding="utf-8") - os.chmod(marker, 0o600) - - if not within(report_path, home): - raise SystemExit("layering migration: report escaped the user home") - print(f"CHANGED: legacy desktop layering classified; report: {report_path}") - if backed_up: - print(f"Preserved {len(backed_up)} customized entries under {backup_root}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/assets/scripts/cybexos-release-update b/assets/scripts/cybexos-release-update deleted file mode 100755 index d4e24921..00000000 --- a/assets/scripts/cybexos-release-update +++ /dev/null @@ -1,521 +0,0 @@ -#!/usr/bin/env bash -# Resolve, verify, stage, and activate immutable CybexOS GitHub releases. -set -euo pipefail -umask 077 - -readonly updater_version=0.1.0 -github_repo=${CYBEXOS_GITHUB_REPO:-DigitalPals/CybexOS} -channel=${CYBEXOS_CHANNEL:-stable} -channel_explicit=false -[[ -n ${CYBEXOS_CHANNEL:-} ]] && channel_explicit=true -source_dir=${CYBEXOS_SOURCE_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)} -semver_helper=${CYBEXOS_SEMVER_HELPER:-$source_dir/scripts/semver} -config_file=${CYBEXOS_CONFIG_FILE:-/etc/cybexos/config.yml} -data_root=${XDG_DATA_HOME:-$HOME/.local/share}/cybexos -releases_root=$data_root/releases -backend=${CYBEXOS_UPDATE_BACKEND:-$HOME/.local/bin/cybexos-update-run} -command_name=run -with_packages=true -with_flatpak=true -with_firmware=false -json=false -start_only=false -backend_args=() - -usage() { - cat <<'EOF' -Usage: cybex update [--check] [--channel stable|beta] [--system-only] - [--no-packages] [--no-flatpak] [--firmware] - [--start] [--json] - -By default, check GitHub for a verified CybexOS release, stage and apply -it when newer, and then update Fedora packages and system Flatpaks. No GitHub -account or login is needed. When no release has been published on the channel -yet, the project step is skipped and the command succeeds. When a release -cannot be checked, verified, or staged (offline, a GitHub error or rate limit), -packages are still updated and the command exits 69 after a successful package -run; --start reports the reason as projectError. - - --check Only report whether a compatible project release is available - --channel Save and use the stable or beta release channel - --system-only Skip the project release check and update system packages only - --no-packages Apply only a project release; do not update Fedora/Flatpak - --no-flatpak Skip system Flatpak updates - --firmware Also install available device firmware through fwupd after - the package phase - --start Start the durable worker and return without attaching - --json Emit machine-readable output for --check/--start -EOF -} - -while (($#)); do - case $1 in - --check) command_name=check ;; - --channel) - shift - [[ $# -gt 0 ]] || { echo 'update: --channel needs stable or beta' >&2; exit 2; } - channel=$1 - channel_explicit=true - ;; - --system-only) command_name=system-only ;; - --no-packages) with_packages=false ;; - --no-flatpak) with_flatpak=false ;; - --firmware) with_firmware=true ;; - --start) start_only=true; json=true ;; - --json) json=true ;; - -h|--help) usage; exit 0 ;; - --) shift; backend_args+=("$@"); break ;; - *) backend_args+=("$1") ;; - esac - shift -done - -if [[ $command_name == check && $start_only == true ]]; then - echo 'update: --check and --start cannot be combined' >&2 - exit 2 -fi - -if [[ $command_name == system-only ]]; then - if [[ $start_only == true ]]; then - args=(start --json --system-unit) - else - args=(run) - fi - [[ $with_flatpak == true ]] || args+=(--no-flatpak) - [[ $with_firmware != true ]] || args+=(--firmware) - exec "$backend" "${args[@]}" "${backend_args[@]}" -fi - -# The project release needs GitHub and gh (verification only, never a login); -# Fedora and Flatpak updates must not. Only tools every path needs are hard -# requirements here. -for required_command in curl jq python3 sha256sum tar; do - command -v "$required_command" >/dev/null 2>&1 || { - printf 'update: required command is missing: %s\n' "$required_command" >&2 - exit 2 - } -done -[[ -x $semver_helper ]] || { - printf 'update: Semantic Version helper is missing: %s\n' "$semver_helper" >&2 - exit 2 -} -[[ -r $config_file ]] || { - printf 'update: saved installer configuration is missing: %s\n' "$config_file" >&2 - [[ ! -d /etc/fedora-config ]] || - printf 'update: run ./install from a CybexOS checkout to migrate this fedora-config installation\n' >&2 - exit 2 -} - -mkdir -p -- "$releases_root" "$data_root" -if [[ $channel_explicit == false && -r $data_root/channel ]]; then - channel=$(tr -d '[:space:]' <"$data_root/channel") -fi -[[ $channel == stable || $channel == beta ]] || { - printf 'update: unsupported channel %s\n' "$channel" >&2 - exit 2 -} -if [[ $channel_explicit == true || ! -e $data_root/channel ]]; then - printf '%s\n' "$channel" >"$data_root/channel" -fi - -current_version=0.0.0 -[[ -r $source_dir/VERSION ]] && current_version=$(tr -d '[:space:]' <"$source_dir/VERSION") -"$semver_helper" validate "$current_version" || { - printf 'update: installed VERSION is not strict SemVer: %s\n' "$current_version" >&2 - exit 2 -} - -release_json=$(mktemp) -archive=$(mktemp --suffix=.tar.zst) -# gh recognizes a bundle file by its extension. -bundle_file=$(mktemp --suffix=.jsonl) -stage= -cleanup() { - rm -f -- "$release_json" "$archive" "$bundle_file" - [[ -z $stage ]] || rm -rf -- "$stage" -} -trap cleanup EXIT - -# Bound the connection and abort a stalled transfer. The installed release -# tree does not ship the system helper library, so the limits are local here. -release_curl() { - curl --connect-timeout 15 --speed-limit 1024 --speed-time 60 \ - --retry 3 --retry-connrefused --fail --silent --show-error --location "$@" -} - -# Every step that depends on GitHub or on the downloaded candidate records why -# it could not continue instead of exiting, so that being offline, an API -# error, or a rate limit still leaves package updates working. These -# functions run in conditional context, where `set -e` does not apply; each -# step checks its own status. -project_error= -project_unavailable() { - project_error=$1 - return 1 -} - -# Anonymous GitHub API request: no login and no token. The response body goes -# to the output file and the final HTTP status to http_status ("000" without -# a response). A transport failure returns curl's status and leaves its own -# message on stderr, which is what an offline check shows. -http_status=000 -github_api() { - local url=$1 output=$2 status rc=0 - status=$(curl --connect-timeout 15 --speed-limit 1024 --speed-time 60 \ - --max-time 60 --retry 3 --retry-connrefused --retry-max-time 60 \ - --silent --show-error --location \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2022-11-28' \ - -w '%{http_code}' -o "$output" "$url") || rc=$? - http_status=${status:-000} - return "$rc" -} - -# Turn a GitHub HTTP error into the reason the panel and terminal show. -github_http_error() { - local what=$1 message - message=$(jq -r '.message // empty' "$release_json" 2>/dev/null || true) - case $http_status in - 403|429) - if [[ $http_status == 429 || ${message,,} == *"rate limit"* ]]; then - project_unavailable "GitHub's API rate limit for anonymous requests was reached; try again later" - else - project_unavailable "GitHub refused the $what request (HTTP $http_status)" - fi - ;; - *) project_unavailable "GitHub returned HTTP $http_status for the $what request" ;; - esac -} - -# "no-release" when the channel has nothing published yet. That is not an -# error: the project step is skipped and package updates run normally. -project_status= - -fetch_release() { - local api="https://api.github.com/repos/$github_repo" rc=0 selected - if [[ $channel == stable ]]; then - github_api "$api/releases/latest" "$release_json" || rc=$? - else - github_api "$api/releases?per_page=30" "$release_json" || rc=$? - fi - if ((rc != 0)); then - project_unavailable "could not fetch the $channel release from GitHub" - return "$rc" - fi - if [[ $channel == stable && $http_status == 404 ]]; then - # GitHub answers 404 both when no release is published and when the - # repository does not exist; only the repository itself tells them apart. - rc=0 - github_api "$api" "$release_json" || rc=$? - if ((rc != 0)); then - project_unavailable "could not fetch the $channel release from GitHub" - return "$rc" - fi - case $http_status in - 200) project_status=no-release; return 0 ;; - 404) project_unavailable "the GitHub repository $github_repo was not found"; return ;; - *) github_http_error 'repository'; return ;; - esac - fi - if [[ $http_status == 404 ]]; then - project_unavailable "the GitHub repository $github_repo was not found" - return - fi - [[ $http_status == 200 ]] || { github_http_error "$channel release"; return; } - if [[ $channel == beta ]]; then - selected=$(jq -c '[.[] | select(.draft == false and .prerelease == true)] | first // empty' \ - "$release_json") || { project_unavailable 'GitHub returned invalid release data'; return; } - if [[ -z $selected ]]; then - project_status=no-release - return 0 - fi - printf '%s\n' "$selected" >"$release_json" \ - || { project_unavailable 'could not record the beta release'; return; } - fi - - tag=$(jq -er '.tag_name | strings | select(startswith("v"))' "$release_json") \ - || { project_unavailable 'GitHub returned no usable release tag'; return; } - release_version=${tag#v} - "$semver_helper" validate "$release_version" \ - || { project_unavailable "release tag is not strict SemVer: $tag"; return; } - asset_name="cybexos-$release_version.tar.zst" - asset_url=$(jq -er --arg name "$asset_name" \ - '.assets[] | select(.name == $name) | .browser_download_url' "$release_json") \ - || { project_unavailable "release $tag has no $asset_name asset"; return; } - asset_digest=$(jq -er --arg name "$asset_name" \ - '.assets[] | select(.name == $name) | .digest | select(test("^sha256:[0-9a-f]{64}$"))' "$release_json") \ - || { project_unavailable "release $tag publishes no SHA-256 digest for $asset_name"; return; } - # The provenance bundle lets gh verify the archive offline, without the - # GitHub API and therefore without a login. - bundle_url=$(jq -er --arg name "$asset_name.sigstore.jsonl" \ - '.assets[] | select(.name == $name) | .browser_download_url' "$release_json") \ - || { project_unavailable "release $tag has no $asset_name.sigstore.jsonl provenance bundle"; return; } - immutable=$(jq -r '.immutable // false' "$release_json") - [[ $immutable == true ]] \ - || { project_unavailable "refusing mutable GitHub release $tag"; return; } -} - -# gh verifies the release workflow's provenance attestation from the bundle -# published beside the archive. That is offline verification against the -# Sigstore trust root: it needs neither the GitHub API nor a login. -attestation_ready() { - local help - command -v gh >/dev/null 2>&1 || { - project_unavailable 'the GitHub CLI (gh) is not installed; it verifies CybexOS releases' - return - } - help=$(gh attestation verify --help 2>/dev/null) || help= - [[ $help == *--bundle* && $help == *--signer-workflow* && $help == *--source-ref* ]] || { - project_unavailable 'this GitHub CLI is too old to verify CybexOS releases; upgrade gh' - return - } -} - -version_newer() { - "$semver_helper" newer "$1" "$2" -} - -compatible_manifest() { - local tree=$1 expected_version=$2 fedora architecture schema minimum_updater - fedora=$(source /etc/os-release; printf '%s' "$VERSION_ID") - architecture=$(uname -m) - schema=$(python3 -c 'import sys,yaml; print(yaml.safe_load(open(sys.argv[1])).get("config_schema_version", 0))' "$config_file") \ - || return 1 - jq -e --arg version "$expected_version" --arg fedora "$fedora" \ - --arg architecture "$architecture" --argjson schema "$schema" \ - '.product == "cybexos" - and .version == $version - and (.configSchema | type == "number") and .configSchema >= $schema - and (.supportedFedora | index($fedora)) != null - and (.architectures | index($architecture)) != null' \ - "$tree/release-manifest.json" >/dev/null || return 1 - minimum_updater=$(jq -er '.minimumUpdater | strings' \ - "$tree/release-manifest.json") || return 1 - "$semver_helper" validate "$minimum_updater" || return 1 - [[ $minimum_updater == "$updater_version" ]] \ - || version_newer "$updater_version" "$minimum_updater" -} - -# Download, verify, and extract the candidate into a hidden stage directory. -stage_release() { - local actual_digest - attestation_ready || return - if [[ $start_only == true ]]; then - printf 'Downloading CybexOS %s...\n' "$release_version" >&2 - else - printf 'Downloading CybexOS %s...\n' "$release_version" - fi - release_curl "$asset_url" -o "$archive" \ - || { project_unavailable "could not download CybexOS $release_version"; return; } - actual_digest=sha256:$(sha256sum "$archive" | awk '{print $1}') - [[ $actual_digest == "$asset_digest" ]] || { - project_unavailable "the downloaded CybexOS $release_version archive does not match GitHub's digest" - return - } - release_curl "$bundle_url" -o "$bundle_file" \ - || { project_unavailable "could not download the CybexOS $release_version provenance bundle"; return; } - # Only this repository's release workflow, building this tag on a - # GitHub-hosted runner, can have produced an accepted archive. - timeout 120 env -u GH_HOST -u GH_ENTERPRISE_TOKEN -u GITHUB_ENTERPRISE_TOKEN \ - GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 \ - gh attestation verify "$archive" --bundle "$bundle_file" --repo "$github_repo" \ - --signer-workflow "$github_repo/.github/workflows/release.yml" \ - --source-ref "refs/tags/$tag" --deny-self-hosted-runners >/dev/null \ - || { - project_unavailable "the CybexOS $release_version provenance attestation could not be verified" - return - } - - stage=$(mktemp -d "$releases_root/.stage.$release_version.XXXXXX") \ - || { project_unavailable 'could not create a release staging directory'; return; } - tar --extract --zstd --file "$archive" --directory "$stage" --strip-components=1 \ - || { project_unavailable "could not extract CybexOS $release_version"; return; } - [[ -x $stage/install && -x $stage/verify \ - && -x $stage/scripts/manage-agent-skills \ - && -r $stage/agent-skills/cybexos/SKILL.md \ - && -r $stage/release-manifest.json ]] || { - project_unavailable "the CybexOS $release_version archive is incomplete" - return - } - [[ $(tr -d '[:space:]' <"$stage/VERSION") == "$release_version" ]] || { - project_unavailable "the CybexOS $release_version archive does not match its tag" - return - } - compatible_manifest "$stage" "$release_version" || { - project_unavailable "CybexOS $release_version is incompatible with this system or saved configuration" - return - } - [[ $stage == "$releases_root/.stage.$release_version."* ]] \ - && "$semver_helper" validate "$release_version" || { - project_unavailable 'unsafe release staging path' - return - } -} - -available=false -fetch_rc=0 -release_version= -fetch_release || fetch_rc=$? -if ((fetch_rc == 0)) && [[ $project_status != no-release ]]; then - version_newer "$release_version" "$current_version" && available=true -fi -no_release="No CybexOS release has been published on the $channel channel yet" - -if [[ $command_name == check ]]; then - # A failed check keeps its reason as the last stderr line for the panel: - # curl's own error when GitHub could not be reached, otherwise ours. - if ((fetch_rc != 0)); then - [[ $http_status == 000 || -z $project_error ]] \ - || printf 'update: %s\n' "$project_error" >&2 - exit "$fetch_rc" - fi - # A release that this machine cannot verify is still reported, with the - # reason, so the panel can say what is needed before it can be applied. - # Its notes travel with it for the Updates view, which shows them as - # plain text; they are bounded and never interpreted here. - release_notes= - if [[ $available == true ]]; then - attestation_ready || true - release_notes=$(jq -r '.body | strings | .[0:8000]' "$release_json" 2>/dev/null) \ - || release_notes= - fi - status=current - [[ $available != true ]] || status=available - [[ $project_status != no-release ]] || status=no-release - if [[ $json == true ]]; then - jq -cn --arg current "$current_version" --arg availableVersion "$release_version" \ - --arg channel "$channel" --argjson available "$available" \ - --arg projectError "$project_error" --arg status "$status" \ - --arg releaseNotes "$release_notes" \ - '{currentVersion:$current,availableVersion:$availableVersion,channel:$channel, - available:$available,status:$status,projectError:$projectError, - releaseNotes:$releaseNotes}' - elif [[ $status == no-release ]]; then - printf '%s (installed: %s).\n' "$no_release" "$current_version" - elif [[ $available == true ]]; then - printf 'CybexOS %s is available (installed: %s, channel: %s).\n' \ - "$release_version" "$current_version" "$channel" - [[ -z $project_error ]] || printf 'To apply it: %s.\n' "$project_error" - else - printf 'CybexOS %s is current on the %s channel.\n' "$current_version" "$channel" - fi - exit 0 -fi - -candidate= -if [[ $available == true ]] && stage_release; then - candidate=$stage - # Transfer cleanup ownership before asking the backend to launch. The worker - # promotes this unique verified tree to its canonical version path while it - # owns the global update lock; no competing client can replace a tree already - # being applied. An interruption during launch may leave a harmless hidden - # stage for explicit maintenance, but can never remove a worker-owned tree. - stage= -fi -if [[ -n $stage ]]; then - rm -rf -- "$stage" - stage= -fi - -if [[ -n $project_error ]]; then - printf 'update: CybexOS release skipped: %s\n' "$project_error" >&2 -elif [[ $project_status == no-release && $with_packages == true ]]; then - # Informational, not a skipped release: --start keeps stdout for its JSON. - if [[ $start_only == true ]]; then - printf '%s; updating Fedora packages and system Flatpaks.\n' "$no_release" >&2 - else - printf '%s; updating Fedora packages and system Flatpaks.\n' "$no_release" - fi -fi - -if [[ -n $candidate ]]; then - update_args=(start --json --system-unit --full --skip-tests --repo "$candidate" - --release-version "$release_version" --release-data-root "$data_root" - --release-config "$config_file") - [[ $with_packages == true ]] || update_args+=(--no-packages) - [[ $with_flatpak == true ]] || update_args+=(--no-flatpak) - [[ $with_firmware != true ]] || update_args+=(--firmware) - update_args+=(-- -e "@$config_file") -elif [[ $with_packages == true ]]; then - [[ -z $project_error ]] \ - || printf 'update: updating Fedora packages and system Flatpaks only\n' >&2 - if [[ $start_only == true ]]; then - update_args=(start --json --system-unit) - else - update_args=(run) - fi - [[ $with_flatpak == true ]] || update_args+=(--no-flatpak) - [[ $with_firmware != true ]] || update_args+=(--firmware) -else - [[ -z $project_error ]] || exit 1 - if [[ $start_only == true ]]; then - echo 'update: no project or package work was requested' >&2 - exit 4 - fi - if [[ $project_status == no-release ]]; then - printf '%s; nothing to apply.\n' "$no_release" - else - echo 'CybexOS is already current.' - fi - exit 0 -fi - -if [[ -z $candidate ]]; then - if [[ $start_only == true ]]; then - set +e - backend_output=$("$backend" "${update_args[@]}" "${backend_args[@]}") - backend_rc=$? - set -e - # The panel reads one JSON record. Carry the skipped release's reason in - # it, so a started package-only run can say why CybexOS was not updated. - if [[ -n $project_error ]] \ - && merged=$(jq -c --arg projectError "$project_error" \ - '. + {projectError: $projectError}' <<<"$backend_output" 2>/dev/null); then - backend_output=$merged - fi - printf '%s\n' "$backend_output" - exit "$backend_rc" - fi - # Stream the attached package run directly to the terminal. - set +e - "$backend" "${update_args[@]}" "${backend_args[@]}" - backend_rc=$? - set -e - if [[ -n $project_error ]] && ((backend_rc == 0)); then - printf 'update: system packages were updated, but CybexOS was not: %s\n' \ - "$project_error" >&2 - exit 69 - fi - exit "$backend_rc" -fi - -set +e -backend_output=$("$backend" "${update_args[@]}" "${backend_args[@]}") -backend_rc=$? -set -e -if ((backend_rc == 3)); then - active_id=$(jq -r '.id // empty' <<<"$backend_output" 2>/dev/null || true) - printf 'update: another update is active%s; retry after it finishes\n' \ - "${active_id:+ ($active_id)}" >&2 - [[ $candidate == "$releases_root/.stage.$release_version."* ]] && rm -rf -- "$candidate" - exit 75 -fi -if ((backend_rc != 0)); then - [[ -z $backend_output ]] || printf '%s\n' "$backend_output" >&2 - [[ $candidate == "$releases_root/.stage.$release_version."* ]] && rm -rf -- "$candidate" - exit "$backend_rc" -fi -run_id=$(jq -er 'select(.existing == false) | .id' <<<"$backend_output") || { - echo 'update: backend did not return a new durable transaction' >&2 - exit 1 -} -if [[ $start_only == true ]]; then - printf '%s\n' "$backend_output" - exit 0 -fi -printf 'Started durable release update %s. Ctrl+C detaches without cancelling it.\n' "$run_id" -set +e -"$backend" attach "$run_id" -backend_rc=$? -set -e -exit "$backend_rc" diff --git a/assets/scripts/cybexos-report b/assets/scripts/cybexos-report index 0736bb13..7724832c 100755 --- a/assets/scripts/cybexos-report +++ b/assets/scripts/cybexos-report @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Collect a redacted CybexOS diagnostic bundle for a bug report. -cybex report gathers the doctor result, versions, a hardware summary, recent +cybex report gathers read-only health checks, versions, a hardware summary, recent journal excerpts and shell health into one local archive, removes personal data, shows what it collected, and can open a prefilled GitHub issue. It never uploads anything: the user reviews the archive and attaches it. @@ -29,13 +29,9 @@ ISSUE_URL = 'https://github.com/DigitalPals/CybexOS/issues/new' # archive is a gzip tarball the user can attach directly. DEFAULT_SUFFIX = '.tar.gz' COMMAND_TIMEOUT = 20 -DOCTOR_TIMEOUT = 240 JOURNAL_LINES = 300 -# Hostnames that identify nothing; redacting them would erase "Fedora". -GENERIC_HOSTS = {'localhost', 'localhost.localdomain', 'fedora', 'cybexos', 'linux', 'host'} -KEY_PACKAGES = ('cybexos-desktop', 'kernel', 'hyprland', 'quickshell', 'aquamarine', 'mesa-dri-drivers', - 'xorg-x11-drv-nvidia', 'akmod-nvidia', 'systemd', 'sddm', 'pipewire', 'wireplumber', - 'NetworkManager', 'flatpak', 'xdg-desktop-portal-hyprland', 'linux-firmware') +# Hostnames that identify nothing; redacting them would erase "NixOS". +GENERIC_HOSTS = {'localhost', 'localhost.localdomain', 'nixos', 'cybexos', 'linux', 'host'} # ---- redaction --------------------------------------------------------- @@ -157,19 +153,15 @@ def read(path, limit=None): def tool(name): - """The CybexOS helper for either installation path.""" - for candidate in (shutil.which(name), Path.home() / '.local/bin' / name, Path('/usr/share/cybexos/bin') / name): + """Resolve a packaged CybexOS helper in the current user's environment.""" + for candidate in (shutil.which(name), Path.home() / '.local/bin' / name, Path('/run/current-system/sw/bin') / name): if candidate and os.access(candidate, os.X_OK): return str(candidate) return name def installation(): - if Path('/usr/share/cybexos/build.json').is_file(): - return 'iso' - if (Path.home() / '.local/share/cybexos/current').exists(): - return 'checkout' - return 'unknown' + return 'nixos' if Path('/etc/cybexos/release.json').is_file() else 'unknown' def os_release(): @@ -240,7 +232,7 @@ def hardware_summary(): if line.startswith('MemTotal')), '') lines.append(f'memory: {memory}') sections = ['\n'.join(lines)] - for title, argv in (('Secure Boot', ['mokutil', '--sb-state']), + for title, argv in (('UEFI and Secure Boot', ['bootctl', 'status', '--no-pager']), ('PCI devices', ['lspci', '-nnk']), ('USB devices', ['lsusb']), ('Block devices', ['lsblk', '-o', 'NAME,SIZE,TYPE,FSTYPE,MOUNTPOINTS']), @@ -277,20 +269,59 @@ def journal(arguments, user=False): return output if rc == 0 else f'unavailable ({output})' -def collect(doctor_timeout=DOCTOR_TIMEOUT): +def diagnostics(): + """Read-only observations, without changing or accepting deployment health.""" + checks = [] + + def record(name, state, detail): + checks.append({'name': name, 'state': state, 'detail': detail}) + + code, output = run(['systemctl', 'is-system-running']) + record('boot', 'ok' if code == 0 and output == 'running' else 'warning', output) + declaration = json_or_text(read('/etc/cybexos/health-services.json')) + if (not isinstance(declaration, dict) or declaration.get('schema') != 1 + or not isinstance(declaration.get('units'), list) or not declaration['units'] + or not isinstance(declaration.get('userUnits'), list)): + record('services', 'warning', 'Health service declaration is missing or invalid') + else: + for scope, units in (('system', declaration['units']), ('user', declaration['userUnits'])): + for unit in units: + if not isinstance(unit, str) or not re.fullmatch(r'[A-Za-z0-9_.@:-]+\.(service|target)', unit): + record('services', 'warning', 'Invalid health service declaration') + continue + code, output = run(['systemctl', *(['--user'] if scope == 'user' else []), 'is-active', unit]) + record(unit, 'ok' if code == 0 and output == 'active' else 'warning', output) + code, output = run([tool('cybex'), 'update', 'status']) + deployment = json_or_text(output) + state = deployment.get('state') if isinstance(deployment, dict) else None + record('deployment', 'ok' if code == 0 and state in ('idle', 'done', 'ready') else 'warning', + output[-4000:]) + code, output = run(['hyprctl', '-j', 'configerrors']) + errors = json_or_text(output) + healthy = code == 0 and isinstance(errors, list) and all(isinstance(error, str) and not error.strip() for error in errors) + record('hyprland', 'ok' if healthy else 'warning', output[-4000:]) + code, output = run([tool('cybex'), 'shell', 'status']) + shell = json_or_text(output) + healthy = code == 0 and isinstance(shell, dict) and shell.get('safe') is False + record('shell', 'ok' if healthy else 'warning', output[-4000:]) + code, output = run(['systemctl', '--user', 'is-active', 'quickshell.service']) + record('quickshell.service', 'ok' if code == 0 and output == 'active' else 'warning', output) + return {'version': 1, 'overall': 'warning' if any(check['state'] != 'ok' for check in checks) else 'ok', + 'checks': checks, 'scope': 'Read-only diagnostics; these do not confirm a trial deployment or qualify hardware'} + + +def collect(): """Unredacted sections, by archive member name. Redaction happens once.""" cybex = tool('cybex') sections = {} - rc, doctor = run([cybex, 'doctor', '--json'], timeout=doctor_timeout) - sections['doctor.json'] = doctor if json_or_text(doctor) is not None else json.dumps( - {'available': False, 'exit_code': rc, 'output': doctor[-4000:]}) + sections['health.json'] = json.dumps(diagnostics()) release = os_release() version_lines = [f'installation: {installation()}', f"os: {release.get('PRETTY_NAME', 'unknown')}", f'kernel: {os.uname().release}', f'architecture: {os.uname().machine}', f"session: {os.environ.get('XDG_CURRENT_DESKTOP', '')} {os.environ.get('XDG_SESSION_TYPE', '')}".strip()] rc, version = run([cybex, 'version']) version_lines.insert(0, f"cybexos: {version if rc == 0 else 'unknown'}") - rc, packages = run(['rpm', '-q', *KEY_PACKAGES]) + rc, packages = run(['nix', 'path-info', '/run/current-system']) version_lines += ['', '## Packages', packages] for title, argv in (('Hyprland', ['hyprctl', 'version']), ('Quickshell', ['qs', '--version'])): rc, output = run(argv) @@ -312,20 +343,22 @@ def collect(doctor_timeout=DOCTOR_TIMEOUT): f'{read(session_log, JOURNAL_LINES)}\n') sections['journal/quickshell.txt'] = journal(['-u', 'quickshell.service'], user=True) + '\n' sections['journal/sddm.txt'] = journal(['-u', 'sddm.service']) + '\n' - rc, update = run([tool('cybexos-update-run'), 'status', '--json']) - sections['journal/update-worker.txt'] = (f'## cybexos-update-run status\n{update}\n\n## user units\n' - f"{journal(['-u', 'cybexos-update-*'], user=True)}\n\n" - f"## system units\n{journal(['-u', 'cybexos-update-*'])}\n") + rc, update = run([cybex, 'update', 'status']) + update_units = ('cybexos-stage@*.service', 'cybexos-apply@*.service', + 'cybexos-rollback.service', 'cybexos-confirm.service', + 'cybexos-recover.service', 'cybexos-region.service') + sections['journal/update-worker.txt'] = (f'## cybex update status\n{update}\n\n## system units\n' + f"{journal([arg for unit in update_units for arg in ('-u', unit)])}\n") sections['journal/hardware-setup.txt'] = '\n\n'.join( f'## {unit}\n{journal(["-u", unit])}' for unit in - ('cybexos-hardware-setup.service', 'akmods.service', 'nvidia-fallback.service')) + '\n' + ('xps-haptic-touchpad.service', 'xps-ipu7-abi.service', 'v4l2-relayd-ipu7.service', 'nvidia-persistenced.service')) + '\n' return sections def summary(sections, redactions, name): release = os_release() - doctor = json_or_text(sections.get('doctor.json', '')) or {} - failing = [check.get('name', '') for check in doctor.get('checks', []) + health = json_or_text(sections.get('health.json', '')) or {} + failing = [check.get('name', '') for check in health.get('checks', []) if isinstance(check, dict) and check.get('state') in ('fail', 'warning', 'pending')] version = next((line.split(': ', 1)[1] for line in sections['versions.txt'].splitlines() if line.startswith('cybexos: ')), 'unknown') @@ -334,10 +367,10 @@ def summary(sections, redactions, name): 'archive': name, 'cybexos': version, 'installation': installation(), - 'fedora': release.get('VERSION_ID', 'unknown'), + 'nixos': release.get('VERSION_ID', 'unknown'), 'kernel': os.uname().release, 'gpus': gpu_summary(), - 'doctor': doctor.get('overall', 'unavailable') if isinstance(doctor, dict) else 'unavailable', + 'health': health.get('overall', 'unavailable') if isinstance(health, dict) else 'unavailable', 'attention': failing[:12], 'members': sorted(sections), 'redactions': dict(sorted(redactions.items())), @@ -347,10 +380,10 @@ def summary(sections, redactions, name): def issue_url(info, redact): """A short, redacted environment block; the logs stay in the archive.""" lines = [f"- CybexOS: {info['cybexos']} ({info['installation']} installation)", - f"- Fedora {info['fedora']}, kernel {info['kernel']}"] + f"- NixOS {info['nixos']}, kernel {info['kernel']}"] lines += [f'- GPU: {gpu}' for gpu in info['gpus'][:3]] attention = ', '.join(info['attention']) if info['attention'] else 'none' - lines += [f"- cybex doctor: {info['doctor']} (needs attention: {attention})", + lines += [f"- System diagnostics: {info['health']} (needs attention: {attention})", f"- Diagnostic bundle: {info['archive']} (attach it to this issue)"] query = {'template': 'bug_report.yml', 'labels': 'bug', 'environment': redact('\n'.join(lines))} return ISSUE_URL + '?' + urllib.parse.urlencode(query, quote_via=urllib.parse.quote) @@ -424,7 +457,7 @@ def main(argv=None): stamp = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) path = output_path(args.output, stamp.strftime('%Y%m%d-%H%M%S')) if not args.json: - print('Collecting diagnostics; cybex doctor can take a minute…', file=sys.stderr, flush=True) + print('Collecting read-only system and desktop diagnostics…', file=sys.stderr, flush=True) found = identities() redact = Redactor(**found) members = {name: redact(text) for name, text in collect().items()} @@ -438,10 +471,10 @@ def main(argv=None): 'summary': info}, sort_keys=True)) return 0 print(f'Diagnostic bundle: {path} ({size} bytes)') - print(f"CybexOS {info['cybexos']} ({info['installation']}), Fedora {info['fedora']}, kernel {info['kernel']}") + print(f"CybexOS {info['cybexos']} ({info['installation']}), NixOS {info['nixos']}, kernel {info['kernel']}") for gpu in info['gpus']: print(f'GPU: {gpu}') - print(f"cybex doctor: {info['doctor']}" + (f" (needs attention: {', '.join(info['attention'])})" + print(f"System diagnostics: {info['health']}" + (f" (needs attention: {', '.join(info['attention'])})" if info['attention'] else '')) print('Files: ' + ', '.join(info['members'])) removed = ', '.join(f'{count} {kind}' for kind, count in info['redactions'].items()) or 'nothing found' diff --git a/assets/scripts/cybexos-runtime b/assets/scripts/cybexos-runtime index a03d19d8..983f0fc8 100755 --- a/assets/scripts/cybexos-runtime +++ b/assets/scripts/cybexos-runtime @@ -5,7 +5,7 @@ umask 077 config_root=${XDG_CONFIG_HOME:-$HOME/.config}/cybexos data_root=${XDG_DATA_HOME:-$HOME/.local/share}/cybexos -runtime_root=$data_root/runtime +runtime_root=/etc/cybexos/runtime dev_source_file=$config_root/dev-source usage() { @@ -43,10 +43,10 @@ validate_checkout() { top=$(git -C "$canonical" rev-parse --show-toplevel 2>/dev/null) || return 1 top=$(readlink -f -- "$top") || return 1 [[ $top == "$canonical" ]] || return 1 - [[ -f $canonical/roles/desktop/files/quickshell/shell.qml - && -f $canonical/roles/desktop/files/hyprland.lua - && -f $canonical/roles/desktop/files/bindings.lua - && -f $canonical/roles/desktop/files/autostart.lua ]] || return 1 + [[ -f $canonical/desktop/quickshell/shell.qml + && -f $canonical/desktop/hypr/hyprland.lua + && -f $canonical/desktop/hypr/bindings.lua + && -f $canonical/desktop/hypr/autostart.lua ]] || return 1 printf '%s\n' "$canonical" } @@ -75,18 +75,21 @@ active_source() { } runtime_path() { - local kind=$1 source + local kind=$1 source selected case $kind in quickshell) if source=$(active_source); then - printf '%s/roles/desktop/files/quickshell\n' "$source" + selected=$source/desktop/quickshell else - printf '%s/quickshell\n' "$runtime_root" + selected=$runtime_root/quickshell fi + # Quickshell hashes the supplied path for IPC without canonicalizing + # aliases. Launchers, recovery and bundled helpers must use one identity. + readlink -f -- "$selected" ;; hypr-source) if source=$(active_source); then - printf '%s/roles/desktop/files\n' "$source" + printf '%s/desktop/hypr\n' "$source" else printf '%s/hypr\n' "$runtime_root" fi @@ -178,9 +181,7 @@ dev_command() { render_hypridle_config() { local vendor=$1 script output_dir output temporary session_action script=$(runtime_path quickshell)/scripts/hypridle-config.py - session_action=/usr/local/libexec/cybexos-session-action - [[ -x $session_action || ! -x /usr/libexec/cybexos-session-action ]] || - session_action=/usr/libexec/cybexos-session-action + session_action=$(command -v cybexos-session-action) output_dir=${XDG_RUNTIME_DIR:-}/cybexos output=$output_dir/hypridle.conf if [[ -z ${XDG_RUNTIME_DIR:-} || ! -f $script ]] || @@ -227,16 +228,16 @@ registered_security_key() { return 1 } -# Adds the sign-in factors to a CybexOS lock screen (docs/security.md): the +# Adds the sign-in factors to a CybexOS lock screen (docs/hardware-security.md): the # cybexos-lock PAM service, which has no pam_fprintd step and tries a # registered security key before the password, and hyprlock's own fprintd -# check beside it while authselect offers fingerprints. Prints the rendered +# check beside it while the NixOS module enables fingerprints. Prints the rendered # file, or the input file when that PAM service is absent: a lock naming a # missing service would refuse every password, and hyprlock's default service # already asks fprintd itself. render_hyprlock_config() { local selected=$1 lock_pam=/etc/pam.d/cybexos-lock - local authselect_conf=/etc/authselect/authselect.conf + local fingerprint_policy=/etc/cybexos/fingerprint-enabled local output_dir output temporary fingerprint=false keys=false output_dir=${XDG_RUNTIME_DIR:-}/cybexos output=$output_dir/hyprlock.conf @@ -246,7 +247,7 @@ render_hyprlock_config() { printf '%s\n' "$selected" return 0 fi - grep -qx 'with-fingerprint' "$authselect_conf" 2>/dev/null && fingerprint=true + test -e "$fingerprint_policy" && fingerprint=true registered_security_key && keys=true if { if [[ $keys == true ]]; then @@ -262,7 +263,7 @@ render_hyprlock_config() { fi } >"$temporary" && cat >>"$temporary" </dev/null || true - -usage() { - cat <<'EOF' -Usage: - cybexos-update-run run [OPTIONS] [ANSIBLE_ARGS...] - cybexos-update-run start [--json] [OPTIONS] [ANSIBLE_ARGS...] - cybexos-update-run status [--json] [RUN_ID] - cybexos-update-run attach [RUN_ID] - cybexos-update-run cancel [RUN_ID] - cybexos-update-run dismiss [RUN_ID] - cybexos-update-run log-dir [RUN_ID] - cybexos-update-run history [--json] [--limit N] - -Update Fedora packages and system Flatpaks. The worker runs as a transient -service, so closing a terminal or reloading Quickshell does not abandon the -transaction. Only one CLI or panel update can run at a time. `cancel` never -interrupts a running package transaction, firmware update, or Ansible play: -the worker stops at the next safe step boundary. `history` lists finished -runs, newest first, with the recovery point each one created. - -Options for run/start: - -f, --full Also run site.yml with --skip-tags boot - --skip-tests Skip repository checks before a full configuration run - --no-packages Skip Fedora and Flatpak updates (requires --full or - --firmware) - --no-flatpak Skip the system Flatpak update - --firmware Also install available device firmware through fwupd - after the package phase - --release-version VERSION - --release-data-root PATH - --release-config PATH - Finalize a staged release in the durable worker (internal) - -h, --help Show this help message and exit - -ANSIBLE_ARGS require --full and are passed verbatim to ansible-playbook. - -Examples: - ./update - ./update --full - ./update --full --check --diff - cybexos-update-run status - cybexos-update-run attach - cybexos-update-run cancel -EOF -} - -valid_id() { [[ ${1:-} =~ ^[0-9]{8}-[0-9]{6}-[0-9]+-[0-9]+$ ]]; } - -current_id() { - local id="" - [[ -r $current_file ]] && IFS= read -r id < "$current_file" - valid_id "$id" && printf '%s\n' "$id" -} - -resolve_id() { - local id=${1:-} - [[ -n $id ]] || id=$(current_id) - valid_id "$id" || { - printf 'cybexos-update-run: no update run is available\n' >&2 - return 1 - } - printf '%s\n' "$id" -} - -status_path() { printf '%s/%s/status.json\n' "$log_shelf" "$1"; } - -current_boot_id() { - local id="" - [[ -r $boot_id_file ]] && IFS= read -r id < "$boot_id_file" - [[ -n $id ]] || return 1 - printf '%s\n' "$id" -} - -valid_reboot_recommendation() { - case ${1:-} in - pending|checking|recommended|not-needed|unavailable) return 0 ;; - *) return 1 ;; - esac -} - -# Recommendations belong to one boot. Missing fields are legacy records, and -# a record from an earlier boot is no longer evidence that this boot should be -# restarted. Normalize both cases at the status boundary without rewriting a -# status file that an active worker may be replacing concurrently. -status_reboot_recommendation() { - local file=$1 expected_boot=$2 recorded_boot recommendation - recommendation="unavailable" - if [[ -n $expected_boot && -r $file ]]; then - recorded_boot=$(jq -r '.bootId // empty' "$file" 2>/dev/null || true) - if [[ $recorded_boot == "$expected_boot" ]]; then - recommendation=$(jq -r '.rebootRecommendation // "unavailable"' \ - "$file" 2>/dev/null || printf unavailable) - valid_reboot_recommendation "$recommendation" \ - || recommendation="unavailable" - fi - fi - printf '%s\n' "$recommendation" -} - -# A capsule staged by fwupd waits for the next boot, and dnf's -# needs-restarting knows nothing about it. Carry that fact to every later run -# of the same boot so a package-only run cannot downgrade the advice to -# not-needed; a record from an earlier boot no longer applies. -status_firmware_reboot() { - local file=$1 expected_boot=$2 recorded_boot staged=false - if [[ -n $expected_boot && -r $file ]]; then - recorded_boot=$(jq -r '.bootId // empty' "$file" 2>/dev/null || true) - if [[ $recorded_boot == "$expected_boot" ]] \ - && jq -e '.firmwareReboot == true' "$file" >/dev/null 2>&1; then - staged=true - fi - fi - printf '%s\n' "$staged" -} - -status_state() { - local file - file=$(status_path "$1") - [[ -r $file ]] || return 1 - jq -er '.state | strings' "$file" 2>/dev/null -} - -is_active() { [[ ${1:-} == queued || ${1:-} == running ]]; } - -unit_active_state() { - local unit=$1 system_unit=${2:-false} - if [[ $system_unit == true ]]; then - systemctl show "$unit" --property=ActiveState --value 2>/dev/null - else - systemctl --user show "$unit" --property=ActiveState --value 2>/dev/null - fi -} - -system_manager_command() { - local command_name=$1 command_path - shift - if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 ]]; then - command_path=$(command -v -- "$command_name") || { - printf 'cybexos-update-run: required command is missing: %s\n' \ - "$command_name" >&2 - return 127 - } - else - command_path="/usr/bin/$command_name" - fi - - # A terminal invocation may already have a sudo timestamp, in which case - # preserve its non-interactive path. Quickshell deliberately requests a - # system unit without priming sudo: systemd then authorizes its own - # manage-units action through the graphical session's Polkit agent. - if ((EUID != 0)) && sudo -n true >/dev/null 2>&1; then - sudo -n "$command_path" "$@" - else - "$command_path" "$@" - fi -} - -report_existing_run() { - local id=$1 state=$2 json_mode=$3 - if [[ $json_mode == true ]]; then - json_status "$id" | jq '. + {existing: true}' - else - printf 'An update is already %s.\n%s\n' "$state" "$id" - fi -} - -repair_stale_status() ( - local id=$1 caller_holds_lock=${2:-false} - local file state unit active_state started now temporary system_unit - - # The worker holds this lock until after publishing its terminal status. - # Take the same lock before synthesizing an abandoned result so a fast - # failure cannot be mistaken for a vanished worker between its last - # `running` write and its final write. start_run already owns the lock in - # its stale-record path and tells us not to acquire it a second time. - if [[ $caller_holds_lock != true ]]; then - exec 8>"$lock_file" - flock -n 8 || return 0 - fi - - file=$(status_path "$id") - [[ -r $file ]] || return 0 - state=$(jq -r '.state // "unknown"' "$file" 2>/dev/null || printf unknown) - is_active "$state" || return 0 - unit=$(jq -r '.unit // empty' "$file" 2>/dev/null || true) - system_unit=$(jq -r '.systemUnit // false' "$file" 2>/dev/null || printf false) - active_state="" - if [[ -n $unit ]] && ! active_state=$(unit_active_state "$unit" "$system_unit"); then - printf 'cybexos-update-run: cannot inspect %s; preserving %s status\n' \ - "$unit" "$state" >&2 - return 0 - fi - case $active_state in - active|activating|reloading|deactivating) return 0 ;; - esac - # Allow the small interval between publishing "queued" and systemd-run - # registering the transient unit. A stale record from a crash/reboot is - # older than this and must not block every subsequent update forever. - started=$(jq -r '.startedAt // 0' "$file" 2>/dev/null || printf 0) - now=$(date +%s) - if [[ $state == queued && $started =~ ^[0-9]+$ ]] && (( now - started < 10 )); then - return 0 - fi - temporary="$file.$$.abandoned" - jq --argjson now "$now" \ - '.state = "failed" | .phase = "abandoned" | - .message = "The update worker disappeared before recording completion" | - .finishedAt = $now | .exitCode = 125' "$file" > "$temporary" - mv -f -- "$temporary" "$file" -) - -prune_logs() { - local keep_id=${1:-} dir id - local -a ids=() - while IFS= read -r id; do - valid_id "$id" && ids+=("$id") - done < <(find "$log_shelf" -mindepth 1 -maxdepth 1 -type d \ - -printf '%f\n' 2>/dev/null | sort -r) - # Keep the twenty newest runs. Old malformed directories and the current - # run are never inferred as deletion targets. - for (( index=20; index<${#ids[@]}; index++ )); do - id=${ids[index]} - [[ $id == "$keep_id" ]] && continue - dir="$log_shelf/$id" - [[ -d $dir && $dir != "$log_shelf" ]] && rm -rf -- "$dir" - done -} - -atomic_text() { - local target=$1 value=$2 temporary - temporary="${target}.$$.tmp" - printf '%s\n' "$value" > "$temporary" - mv -f -- "$temporary" "$target" -} - -json_status() { - local id=${1:-} file dnf_bytes=0 flatpak_bytes=0 firmware_event_bytes=0 boot_id - local recommendation firmware_reboot - boot_id=$(current_boot_id || true) - [[ -n $id ]] || id=$(current_id || true) - if ! valid_id "$id"; then - jq -cn --arg bootId "$boot_id" \ - '{state: "idle", id: "", bootId: $bootId, - rebootRecommendation: "unavailable"}' - return - fi - repair_stale_status "$id" - file=$(status_path "$id") - if [[ ! -r $file ]]; then - jq -n --arg id "$id" --arg bootId "$boot_id" \ - '{id: $id, state: "unknown", bootId: $bootId, - rebootRecommendation: "unavailable"}' - return 1 - fi - [[ ! -r $log_shelf/$id/dnf.log ]] || dnf_bytes=$(wc -c < "$log_shelf/$id/dnf.log") - [[ ! -r $log_shelf/$id/flatpak.log ]] || flatpak_bytes=$(wc -c < "$log_shelf/$id/flatpak.log") - [[ ! -r $log_shelf/$id/firmware-events.log ]] \ - || firmware_event_bytes=$(wc -c < "$log_shelf/$id/firmware-events.log") - recommendation=$(status_reboot_recommendation "$file" "$boot_id") - # Like the recommendation, a staged capsule belongs to the boot it waits - # for: once that boot has ended, the firmware has been applied. - firmware_reboot=$(status_firmware_reboot "$file" "$boot_id") - # A deferred cancellation is visible while the worker finishes its step. - local cancel_pending=false - [[ ! -e $log_shelf/$id/cancel.pending ]] || cancel_pending=true - jq --argjson dnfBytes "$dnf_bytes" --argjson flatpakBytes "$flatpak_bytes" \ - --argjson firmwareEventBytes "$firmware_event_bytes" \ - --arg bootId "$boot_id" --arg rebootRecommendation "$recommendation" \ - --argjson firmwareReboot "$firmware_reboot" \ - --argjson cancelPending "$cancel_pending" \ - '. + {dnfBytes: $dnfBytes, flatpakBytes: $flatpakBytes, - firmwareEventBytes: $firmwareEventBytes, firmwareReboot: $firmwareReboot, - bootId: $bootId, rebootRecommendation: $rebootRecommendation, - cancelRequested: ($cancelPending - and (.state == "queued" or .state == "running"))}' "$file" -} - -human_status() { - json_status "${1:-}" | jq -r 'if .state == "idle" then "No update run is recorded." - else "\(.id): \(.state)\(if .phase then " (" + .phase + ")" else "" end)\nlogs: \(.logDir)" end' -} - -write_status() { - local state=$1 phase=$2 message=${3:-} exit_code=${4:-0} finished=0 - local temporary="$run_dir/.status.$$.tmp" - local status_boot_id=${boot_id:-} status_reboot=${reboot_recommendation:-unavailable} - valid_reboot_recommendation "$status_reboot" || status_reboot="unavailable" - [[ $state == queued || $state == running ]] || finished=$(date +%s) - jq -n --arg id "$run_id" --arg state "$state" --arg phase "$phase" \ - --arg message "$message" --arg logDir "$run_dir" --arg unit "$unit_name" \ - --arg releaseVersion "${release_version:-}" \ - --arg bootId "$status_boot_id" --arg rebootRecommendation "$status_reboot" \ - --argjson startedAt "$started_at" --argjson finishedAt "$finished" \ - --argjson systemUnit "${system_unit:-false}" \ - --argjson exitCode "$exit_code" --argjson full "$full" \ - --argjson flatpak "$with_flatpak" --argjson dnfDone "$dnf_done" \ - --argjson flatpakDone "$flatpak_done" --argjson dnfRc "$dnf_rc" \ - --argjson flatpakRc "$flatpak_rc" --argjson testsRc "$tests_rc" \ - --argjson ansibleRc "$ansible_rc" --arg snapshotId "${snapshot_id:-}" \ - --argjson mixedState "${mixed_state:-false}" \ - --arg transactionProtection "${transaction_protection:-unavailable}" \ - --arg rollbackState "${rollback_state:-none}" \ - --argjson firmware "${with_firmware:-false}" \ - --argjson firmwareDone "${firmware_done:-false}" \ - --argjson firmwareRc "${firmware_rc:-0}" \ - --argjson firmwareReboot "${firmware_reboot:-false}" \ - '{id: $id, state: $state, phase: $phase, message: $message, - logDir: $logDir, unit: $unit, systemUnit: $systemUnit, startedAt: $startedAt, - finishedAt: $finishedAt, exitCode: $exitCode, full: $full, - flatpak: $flatpak, dnfDone: $dnfDone, flatpakDone: $flatpakDone, - dnfRc: $dnfRc, flatpakRc: $flatpakRc, testsRc: $testsRc, - ansibleRc: $ansibleRc, firmware: $firmware, firmwareDone: $firmwareDone, - firmwareRc: $firmwareRc, firmwareReboot: $firmwareReboot, - snapshotId: $snapshotId, bootId: $bootId, - releaseVersion: $releaseVersion, mixedState: $mixedState, - transactionProtection: $transactionProtection, rollbackState: $rollbackState, - deferredCancel: true, - rebootRecommendation: $rebootRecommendation}' > "$temporary" - mv -f -- "$temporary" "$run_dir/status.json" - if [[ -n ${CYBEXOS_UPDATE_OWNER_UID:-} ]]; then - chown "${CYBEXOS_UPDATE_OWNER_UID}:${CYBEXOS_UPDATE_OWNER_GID}" \ - "$run_dir/status.json" 2>/dev/null || true - fi -} - -parse_options() { - full=false; skip_tests=false; with_packages=true; with_flatpak=true; json=false; repo="" - with_firmware=false - release_version=""; release_data_root=""; release_config=""; release_target="" - release_transaction=false - system_unit=${CYBEXOS_UPDATE_SYSTEM_UNIT:-false} - ansible_args=() - while (( $# )); do - case $1 in - -f|--full) full=true ;; - --skip-tests) skip_tests=true ;; - --no-packages) with_packages=false; with_flatpak=false ;; - --no-flatpak) with_flatpak=false ;; - --firmware) with_firmware=true ;; - --json) json=true ;; - --system-unit) system_unit=true ;; - --release-version|--release-data-root|--release-config) - option=$1 - shift - [[ $# -gt 0 ]] || { - printf 'cybexos-update-run: %s needs a value\n' "$option" >&2 - return 2 - } - case $option in - --release-version) release_version=$1 ;; - --release-data-root) release_data_root=$1 ;; - --release-config) release_config=$1 ;; - esac - ;; - --repo) - shift - [[ $# -gt 0 ]] || { printf 'cybexos-update-run: --repo needs a path\n' >&2; return 2; } - repo=$1 - ;; - -h|--help) usage; return 10 ;; - --) shift; ansible_args+=("$@"); break ;; - *) ansible_args+=("$1") ;; - esac - shift - done - if (( ${#ansible_args[@]} > 0 )) && [[ $full != true ]]; then - printf 'cybexos-update-run: Ansible arguments require --full\n\n' >&2 - usage >&2 - return 2 - fi - # A run needs some work: skipping packages leaves the configuration run or - # a firmware-only update. - if [[ $with_packages == false && $full != true && $with_firmware != true ]]; then - printf 'cybexos-update-run: --no-packages requires --full or --firmware\n' >&2 - return 2 - fi - if [[ $full == true ]]; then - [[ -n $repo ]] || repo=$PWD - repo=$(readlink -f -- "$repo") - if [[ ! -f $repo/site.yml || ! -x $repo/tests/run ]]; then - printf 'cybexos-update-run: --full requires the configuration repository path\n' >&2 - return 2 - fi - fi - if [[ -n $release_version || -n $release_data_root || -n $release_config ]]; then - if [[ $full != true || -z $release_version || -z $release_data_root \ - || -z $release_config ]]; then - printf 'cybexos-update-run: a release transaction requires --full and all release options\n' >&2 - return 2 - fi - release_data_root=$(readlink -f -- "$release_data_root") || return 2 - release_config=$(readlink -f -- "$release_config") || return 2 - release_target="$release_data_root/releases/$release_version" - release_parent=$(readlink -f -- "$(dirname -- "$repo")") || return 2 - release_name=${repo##*/} - if [[ ! -d $release_data_root/releases || ! -f $release_config \ - || ! -r $repo/agent-skills/cybexos/SKILL.md \ - || ! -x $repo/scripts/manage-agent-skills \ - || ! -x $repo/scripts/migrate-config || ! -x $repo/scripts/semver \ - || ! -r $repo/VERSION ]] \ - || ! "$repo/scripts/semver" validate "$release_version" \ - || [[ $(tr -d '[:space:]' <"$repo/VERSION") != "$release_version" ]] \ - || { [[ $repo != "$release_target" ]] \ - && [[ $release_parent != "$release_data_root/releases" \ - || $release_name != ".stage.$release_version."* ]]; }; then - printf 'cybexos-update-run: invalid staged release transaction\n' >&2 - return 2 - fi - release_transaction=true - fi -} - -# A transient unit has no terminal. sudo's default timestamp_type=tty keys a -# cached authorization to the terminal, so a ticket this client holds does not -# reach a user-unit worker; only sudo that works without any terminal (NOPASSWD -# or a global timestamp) does. Probe exactly that condition. -detached_sudo_available() { - setsid -w sudo -n true /dev/null 2>&1 -} - -start_run() { - parse_options "$@" - local parse_rc=$? - [[ $parse_rc -eq 0 ]] || { [[ $parse_rc -eq 10 ]] && return 0; return "$parse_rc"; } - if [[ ${CYBEXOS_UPDATE_ASSUME_USER_UNIT:-0} != 1 \ - && $system_unit != true ]] && ! detached_sudo_available; then - if ! sudo -n true >/dev/null 2>&1; then - if [[ $json == true || ! -t 0 ]]; then - printf 'cybexos-update-run: authentication is required; run `cybex update` in a terminal\n' >&2 - return 77 - fi - printf 'Authenticate once to start the durable system update worker.\n' - sudo -v || return - fi - # The terminal's ticket authorizes launching a root system unit now; the - # worker itself then needs no further authentication. - system_unit=true - fi - exec 9>"$lock_file" - local active_id active_state="" - - # The worker owns this lock for its complete package/test/Ansible lifetime. - # A client must never wait for that transaction and then launch another one; - # resolve the already-published run immediately and let callers attach. - if ! flock -n 9; then - active_id=$(current_id || true) - if [[ -n $active_id ]]; then - repair_stale_status "$active_id" - active_state=$(status_state "$active_id" || true) - fi - if is_active "$active_state"; then - report_existing_run "$active_id" "$active_state" "$json" - return 3 - fi - # install and uninstall hold the same lock for their whole playbook. - printf 'cybexos-update-run: the update lock is held by another CybexOS operation (install, uninstall, or a launching update); retry after it finishes\n' >&2 - return 4 - fi - - active_id=$(current_id || true) - if [[ -n $active_id ]]; then - repair_stale_status "$active_id" true - active_state=$(status_state "$active_id" || true) - fi - if is_active "$active_state"; then - report_existing_run "$active_id" "$active_state" "$json" - return 3 - fi - - local run_id unit_name run_dir started_at dnf_done=false flatpak_done=false - local dnf_rc=0 flatpak_rc=0 tests_rc=0 ansible_rc=0 - local firmware_done=false firmware_rc=0 firmware_reboot=false - local boot_id reboot_recommendation snapshot_id="" - boot_id=$(current_boot_id || true) - reboot_recommendation="pending" - if [[ -n $active_id ]]; then - reboot_recommendation=$(status_reboot_recommendation \ - "$(status_path "$active_id")" "$boot_id") - [[ $reboot_recommendation == recommended ]] \ - || reboot_recommendation="pending" - firmware_reboot=$(status_firmware_reboot \ - "$(status_path "$active_id")" "$boot_id") - fi - run_id="$(date +%Y%m%d-%H%M%S)-$$-$RANDOM" - unit_name="cybexos-update-$run_id.service" - run_dir="$log_shelf/$run_id" - mkdir -p -- "$run_dir" - : > "$run_dir/dnf.log"; : > "$run_dir/flatpak.log"; : > "$run_dir/run.log" - # Root appends to the firmware logs; creating them here keeps them owned by - # this user, so the unprivileged panel can still read them with read-log. - : > "$run_dir/firmware.log"; : > "$run_dir/firmware-events.log" - started_at=$(date +%s) - write_status queued queued "Waiting for the update worker" 0 - atomic_text "$current_file" "$run_id" - prune_logs "$run_id" - - local worker_args=("$script_path" _worker "$run_id") - # SELinux keeps the system manager (init_t) from executing a release copy - # under ~/.local/share (data_home_t), so the system unit starts bash, which - # may read the script, rather than executing the script itself. - [[ $system_unit == true ]] && worker_args=(/usr/bin/bash "${worker_args[@]}" --system-unit) - [[ $full == true ]] && worker_args+=(--full --repo "$repo") - if [[ $release_transaction == true ]]; then - worker_args+=(--release-version "$release_version" - --release-data-root "$release_data_root" --release-config "$release_config") - fi - [[ $skip_tests == true ]] && worker_args+=(--skip-tests) - [[ $with_packages == true ]] || worker_args+=(--no-packages) - [[ $with_flatpak == true ]] || worker_args+=(--no-flatpak) - [[ $with_firmware != true ]] || worker_args+=(--firmware) - (( ${#ansible_args[@]} == 0 )) || worker_args+=(-- "${ansible_args[@]}") - # KillMode=mixed sends a stop's SIGTERM to the worker alone. It lets a - # running dnf transaction or Ansible play finish and records the - # cancellation at the next step boundary; everything left is killed only - # after TimeoutStopSec. The worker also yields CPU and I/O to the desktop. - local -a unit_properties=( - --property=Type=exec - --property=KillMode=mixed - --property=TimeoutStopSec=30min - --property=Nice=10 - --property=IOWeight=20 - --property=CPUWeight=20 - ) - local launch_rc=0 - if [[ $system_unit == true ]]; then - system_manager_command systemd-run --system --quiet --collect \ - --unit="$unit_name" \ - "${unit_properties[@]}" \ - --setenv="HOME=$HOME" \ - --setenv="XDG_STATE_HOME=${XDG_STATE_HOME:-$HOME/.local/state}" \ - --setenv="XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \ - --setenv="CYBEXOS_UPDATE_OWNER_UID=$(id -u)" \ - --setenv="CYBEXOS_UPDATE_OWNER_GID=$(id -g)" \ - -- "${worker_args[@]}" || launch_rc=$? - else - systemd-run --user --quiet --collect --unit="$unit_name" \ - "${unit_properties[@]}" -- "${worker_args[@]}" || launch_rc=$? - fi - if ((launch_rc != 0)); then - write_status failed launch "Could not start the update worker" 125 - [[ $json == true ]] && json_status "$run_id" | jq '. + {existing: false}' \ - || printf 'cybexos-update-run: could not start the update worker\n' >&2 - return 1 - fi - [[ $json == true ]] && json_status "$run_id" | jq '. + {existing: false}' \ - || printf '%s\n' "$run_id" -} - -tagged_command() { - local tag=$1 logfile=$2 rcfile=$3 - shift 3 - set +e - stdbuf -oL -eL "$@" 2>&1 | tee "$logfile" \ - | sed -u "s/^/[$tag] /" >> "$run_dir/run.log" - local -a result=("${PIPESTATUS[@]}") - local code=${result[0]} - if ((result[1] != 0 || result[2] != 0)); then code=125; fi - printf '%s\n' "$code" > "$rcfile" || return 125 - return 0 -} - -phase_result() { - local value='' - if [[ -r $1 ]] && IFS= read -r value <"$1" \ - && [[ $value =~ ^[0-9]{1,3}$ ]] && ((10#$value <= 255)); then - printf '%s\n' "$((10#$value))" - else - # An empty/missing result must never mean success (Bash arithmetic treats - # the empty string as zero), especially when log storage filled up. - printf '125\n' - fi -} - -worker_finalized=false -release_config_changed=false -release_activated=false -release_backup="" -cancel_requested=false -packages_ran=false -mixed_state=false -transaction_started=false -transaction_applying=false -transaction_helper="" -transaction_protection=unavailable -rollback_state=none -firmware_done=false -firmware_rc=0 -firmware_reboot=false -inhibitor_pid="" - -# SIGTERM from `cybexos-update-run cancel` (systemctl stop) only records the -# request. Interrupting dnf/rpm or an Ansible play midway can leave duplicate -# packages or a half-applied configuration, so the worker lets the running -# step finish and stops at the next boundary through honor_cancellation. -request_cancellation() { cancel_requested=true; } - -honor_cancellation() { - [[ $cancel_requested == true ]] || return 0 - worker_cancelled -} - -# wait_for_job PID -# A trapped signal makes `wait` return early while the job is still running; -# keep waiting until it has really exited. -wait_for_job() { - local pid=$1 - until wait "$pid"; do - kill -0 "$pid" 2>/dev/null || return 0 - done -} - -# Hold a logind block inhibitor for the worker's lifetime so shutdown and -# sleep requests are refused while packages or configuration change. The -# worker must stay the unit's main process (it alone receives a stop's -# SIGTERM), so a child holds the lock and exits together with the worker. A -# user unit may be refused by Polkit; the update then continues unprotected. -# logind's default LidSwitchIgnoreInhibited=yes still suspends on lid close. -acquire_inhibitor() { - local ready="$run_dir/.inhibit.$$.ready" error_file="$run_dir/inhibit.err" - if ! command -v systemd-inhibit >/dev/null 2>&1; then - printf '[inhibit] systemd-inhibit is unavailable; shutdown and sleep are not blocked\n' \ - >>"$run_dir/run.log" - return 0 - fi - rm -f -- "$ready" - systemd-inhibit --no-ask-password --what=shutdown:sleep --mode=block \ - --who=CybexOS --why='Applying updates' \ - sh -c ': >"$1" && exec tail --pid="$2" -f /dev/null' \ - cybexos-update-inhibit "$ready" "$$" \ - /dev/null 2>"$error_file" & - inhibitor_pid=$! - for _ in {1..100}; do - if [[ -e $ready ]]; then - rm -f -- "$ready" - printf '[inhibit] shutdown and sleep are blocked until the update finishes\n' \ - >>"$run_dir/run.log" - return 0 - fi - kill -0 "$inhibitor_pid" 2>/dev/null || break - sleep 0.05 - done - release_inhibitor - rm -f -- "$ready" - printf '[inhibit] could not block shutdown and sleep; continuing: %s\n' \ - "$(tr -s '\n' ' ' <"$error_file" 2>/dev/null | cut -c1-200)" >>"$run_dir/run.log" -} - -release_inhibitor() { - [[ -n $inhibitor_pid ]] || return 0 - kill "$inhibitor_pid" 2>/dev/null || true - wait "$inhibitor_pid" 2>/dev/null || true - inhibitor_pid="" -} - -restore_release_configuration() { - [[ ${release_transaction:-false} == true && $release_config_changed == true \ - && $release_activated != true ]] || return 0 - [[ -r $release_backup ]] || return 1 - if ((EUID == 0)); then - install -m 0644 "$release_backup" "$release_config" - else - sudo -n install -m 0644 "$release_backup" "$release_config" - fi - printf '[release] restored the pre-update installer configuration\n' \ - >>"$run_dir/run.log" - release_config_changed=false -} - -# Files on /home are outside the root recovery point. The shared helper owns -# a manifest of vendor-only integration paths and journals its checkpoint in -# the top-level Btrfs store. Settings, plugins and personal data never rewind. -drain_installed_provisioning() { - [[ -x /usr/libexec/cybexos-configure-installed ]] || return 0 - # Older baseline helpers predate containment and do not accept --drain. - # The newly installed helper advertises the interface before it is used. - local interface - interface=$(/usr/libexec/cybexos-configure-installed --help) || return 1 - [[ $interface == *--drain* ]] || return 0 - "${privileged_package[@]}" /usr/libexec/cybexos-configure-installed --drain \ - >>"$run_dir/run.log" 2>&1 -} - -finish_failed_transaction() { - # The installed helper uses its own systemd cgroup. A killed reconciler - # must not allow recovery to race that independently supervised workload. - # Early cancellation can run this finalizer before privilege setup. At that - # point no provisioning workload has been launched by this worker. - if [[ -n ${privileged_package[0]:-} ]]; then - drain_installed_provisioning || return 1 - fi - [[ $transaction_started == true ]] || return 0 - local action=abort - [[ $transaction_applying != true ]] || action=rollback - local journal journal_state - journal=$("${privileged_package[@]}" "$transaction_helper" status "$run_id" 2>/dev/null) || journal='' - journal_state=$(jq -r '.state // empty' <<<"$journal" 2>/dev/null) || journal_state='' - case $journal_state in - prepared) action=abort ;; - applying|validating|rolling-back|rollback-failed|awaiting-desktop) action=rollback ;; - committed|aborted) transaction_started=false; return 0 ;; - esac - if "${privileged_package[@]}" "$transaction_helper" "$action" "$run_id" \ - >>"$run_dir/run.log" 2>&1; then - transaction_started=false - if [[ $action == rollback ]]; then - rollback_state=restart-required - reboot_recommendation=recommended - # The currently mounted root remains the failed generation until boot. - mixed_state=true - fi - else - rollback_state=failed - return 1 - fi -} - -worker_failed() { - local phase=$1 message=$2 rc=$3 - # A capsule staged before a later step failed still waits for a restart. - apply_firmware_reboot_recommendation - if ! restore_release_configuration; then - phase=rollback - message="$message; restoring the previous installer configuration also failed" - rc=125 - fi - if ! finish_failed_transaction; then - phase=rollback - message="$message; automatic recovery failed; use the recovery boot menu" - rc=125 - elif [[ $rollback_state == restart-required ]]; then - message="$message; the previous system and vendor desktop were restored; restart to use them" - fi - write_status failed "$phase" "$message" "$rc" - worker_finalized=true - exit "$rc" -} - -prepare_release_configuration() { - [[ $release_transaction == true ]] || return 0 - local migrated="$run_dir/config.migrated.yml" - local migration_error="$run_dir/config-migration.err" - if ! "$repo/scripts/migrate-config" "$release_config" \ - >"$migrated" 2>"$migration_error"; then - return 1 - fi - if cmp -s -- "$release_config" "$migrated"; then - printf '[release] installer configuration already uses the target schema\n' \ - >>"$run_dir/run.log" - return 0 - fi - release_backup="$run_dir/config.before.yml" - cp --preserve=mode,timestamps -- "$release_config" "$release_backup" || return 1 - release_config_changed=true - "${privileged_package[@]}" install -m 0644 "$migrated" "$release_config" || return 1 - printf '[release] migrated installer configuration for %s\n' "$release_version" \ - >>"$run_dir/run.log" -} - -run_as_update_owner() { - local owner_uid=${CYBEXOS_UPDATE_OWNER_UID:-$EUID} - local owner_gid=${CYBEXOS_UPDATE_OWNER_GID:-$(id -g)} - [[ $owner_uid =~ ^[0-9]+$ && $owner_gid =~ ^[0-9]+$ ]] || { - printf 'cybexos-update-run: invalid update-owner identity\n' >&2 - return 1 - } - if [[ $owner_uid == "$EUID" && $owner_gid == "$(id -g)" ]]; then - "$@" - return - fi - if ((EUID != 0)) || [[ ! -x /usr/bin/sudo ]]; then - printf 'cybexos-update-run: cannot become update owner %s:%s\n' \ - "$owner_uid" "$owner_gid" >&2 - return 1 - fi - /usr/bin/sudo -n -u "#$owner_uid" -g "#$owner_gid" -- "$@" -} - -activate_release() { - [[ $release_transaction == true ]] || return 0 - local releases_root="$release_data_root/releases" expected - local current_link="$release_data_root/current" previous_target="" - local next_link="$release_data_root/.current.$run_id.new" - local restore_link="$release_data_root/.current.$run_id.restore" - local previous_present=false - expected=$(readlink -f -- "$releases_root/$release_version") || return 1 - [[ $expected == "$repo" ]] || return 1 - - if [[ -e $current_link || -L $current_link ]]; then - [[ -L $current_link ]] || return 1 - previous_target=$(readlink -- "$current_link") || return 1 - previous_present=true - fi - - rm -f -- "$next_link" "$restore_link" || return 1 - ln -s -- "releases/$release_version" "$next_link" || return 1 - mv -Tf -- "$next_link" "$current_link" || return 1 - - # The first release containing the skill cannot provision it during Ansible: - # current still names the prior release at that point. Adopt the discovery - # slots immediately after the atomic swap. The helper restores every slot to - # its pre-call state on failure; restore current here before failing the - # release transaction as well. - if ! run_as_update_owner \ - "$repo/scripts/manage-agent-skills" provision --home "$HOME" \ - --canonical "$current_link/agent-skills/cybexos" \ - >>"$run_dir/run.log" 2>&1; then - printf '[release] agent skill provisioning failed; restoring prior activation\n' \ - >>"$run_dir/run.log" - if [[ $previous_present == true ]]; then - ln -s -- "$previous_target" "$restore_link" || return 1 - mv -Tf -- "$restore_link" "$current_link" || return 1 - else - rm -f -- "$current_link" || return 1 - fi - return 1 - fi - - release_activated=true - printf '[release] activated CybexOS %s\n' "$release_version" \ - >>"$run_dir/run.log" - -} - -prune_releases() { - [[ $release_transaction == true ]] || return 0 - local releases_root="$release_data_root/releases" old_release old_path - local -a installed_releases=() - - mapfile -t installed_releases < <( - find "$releases_root" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' \ - | "$repo/scripts/semver" sort - ) - if ((${#installed_releases[@]} > 3)); then - for old_release in "${installed_releases[@]:3}"; do - old_path="$releases_root/$old_release" - [[ $old_path == "$releases_root/"* && $old_release != "$release_version" ]] \ - || continue - rm -rf -- "$old_path" \ - || printf '[release] warning: could not prune %s\n' "$old_release" \ - >>"$run_dir/run.log" - done - fi -} - -promote_release_candidate() { - [[ $release_transaction == true ]] || return 0 - [[ $repo != "$release_target" ]] || return 0 - local parent name - parent=$(readlink -f -- "$(dirname -- "$repo")") || return 1 - name=${repo##*/} - [[ $parent == "$release_data_root/releases" \ - && $name == ".stage.$release_version."* ]] || return 1 - [[ ! -e $release_target && ! -L $release_target ]] || rm -rf -- "$release_target" - mv -- "$repo" "$release_target" || return 1 - repo=$release_target -} - -worker_cancelled() { - local state=cancelled phase=cancelled message="Update cancelled" rc=130 - if [[ $packages_ran == true ]]; then - # The finished transaction may still need a reboot; keep that advice. - check_reboot_recommendation - message="Update cancelled after the package transaction finished" - fi - if [[ $firmware_done == true ]]; then - message="Update cancelled after the firmware update finished" - fi - apply_firmware_reboot_recommendation - if ! restore_release_configuration; then - state=failed - phase=rollback - message="Update cancelled, but restoring the previous installer configuration failed" - rc=125 - fi - if ! finish_failed_transaction; then - state=failed; phase=rollback; rc=125 - message="$message; automatic recovery failed" - elif [[ $rollback_state == restart-required ]]; then - message="$message; previous system selected; restart required" - fi - write_status "$state" "$phase" "$message" "$rc" - worker_finalized=true - exit "$rc" -} - -worker_exit_fallback() { - local rc=$? - release_inhibitor - [[ $worker_finalized == true ]] && return - apply_firmware_reboot_recommendation - # This catches interpreter/tool failures and SIGKILL's next status read - # repairs the one case in which no shell trap can run. - local message="Update worker exited unexpectedly (status $rc)" - if ! restore_release_configuration; then - message="$message; restoring the previous installer configuration also failed" - fi - finish_failed_transaction || message="$message; automatic recovery failed" - write_status failed internal "$message" \ - "$([[ $rc -eq 0 ]] && printf 125 || printf '%s' "$rc")" 2>/dev/null || true -} - -check_reboot_recommendation() { - local carried_recommendation=false output_file error_file rc parsed="" - [[ $reboot_recommendation == recommended ]] \ - && carried_recommendation=true - - if [[ -z $boot_id ]]; then - reboot_recommendation="unavailable" - return 0 - fi - - reboot_recommendation="checking" - write_status running reboot-check \ - "Checking whether Fedora recommends a reboot" 0 - output_file="$run_dir/reboot.json" - error_file="$run_dir/reboot.err" - timeout 30s env LC_ALL=C dnf --quiet --cacheonly needs-restarting --json \ - > "$output_file" 2> "$error_file" - rc=$? - if [[ $rc -eq 0 || $rc -eq 1 ]]; then - parsed=$(jq -r ' - if type != "array" then error("expected an array") else - [.[] | select(type == "object" and .type == "reboot" - and (.reboot_required | type) == "boolean") - | .reboot_required] - | if length == 1 then .[0] else error("expected one reboot result") end - end - ' "$output_file" 2>/dev/null) || parsed="" - fi - - if [[ $rc -eq 1 && $parsed == true ]]; then - reboot_recommendation="recommended" - elif [[ $rc -eq 0 && $parsed == false ]]; then - reboot_recommendation="not-needed" - elif [[ $carried_recommendation == true ]]; then - # A missing plugin, timeout, or malformed response cannot erase a - # recommendation already established during this boot. - reboot_recommendation="recommended" - else - reboot_recommendation="unavailable" - fi -} - -# dnf's needs-restarting knows nothing about a staged capsule, so a firmware -# update that waits for the next boot always recommends the restart. -apply_firmware_reboot_recommendation() { - [[ ${firmware_reboot:-false} != true ]] || reboot_recommendation="recommended" -} - -firmware_helper_path() { - if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 \ - && -n ${CYBEXOS_UPDATE_FIRMWARE_HELPER:-} ]]; then - printf '%s\n' "$CYBEXOS_UPDATE_FIRMWARE_HELPER" - else - printf '%s/cybexos-firmware-update\n' "$(dirname -- "$script_path")" - fi -} - -# start_run creates the run's logs as the update owner. A worker launched by -# an older client can find the firmware logs missing: create them and hand -# them to that owner, because root appends to them while the panel reads them -# unprivileged through read-log. -ensure_owner_log() { - local file=$1 - [[ ! -e $file ]] || return 0 - : >"$file" || return 0 - if [[ -n ${CYBEXOS_UPDATE_OWNER_UID:-} ]]; then - chown "${CYBEXOS_UPDATE_OWNER_UID}:${CYBEXOS_UPDATE_OWNER_GID}" \ - "$file" 2>/dev/null || true - fi -} - -# Firmware follows the package phase, so a failed or cancelled package -# transaction never leaves a capsule staged behind it. It never fails the run: -# each device's outcome (on battery, already scheduled, a download error) is -# in firmware-events.log for the panel, and a nonzero helper status is added -# to the final message like Flatpak's. The helper runs to completion even when -# a cancellation arrives, because fwupd owns a flash once it has started. -run_firmware_phase() { - local helper events="$run_dir/firmware-events.log" - helper=$(firmware_helper_path) - ensure_owner_log "$run_dir/firmware.log" - ensure_owner_log "$events" - write_status running firmware "Updating device firmware" 0 - if [[ -x $helper ]]; then - tagged_command firmware "$run_dir/firmware.log" "$run_dir/firmware.rc" \ - "${privileged_package[@]}" "$helper" install --events "$events" - firmware_rc=$(phase_result "$run_dir/firmware.rc") - else - firmware_rc=127 - printf '%s\n' "$firmware_rc" >"$run_dir/firmware.rc" - printf '[firmware] the firmware updater is not installed: %s\n' "$helper" \ - >>"$run_dir/run.log" - { - jq -cn '{event: "failed", id: "", reason: "unavailable", - message: "The firmware updater is not installed"}' - jq -cn '{event: "summary", installed: 0, failed: 1, needsReboot: false}' - } >>"$events" - fi - [[ $firmware_rc =~ ^[0-9]+$ ]] || firmware_rc=1 - firmware_done=true - # A malformed line (say, from a helper killed mid-write) is skipped rather - # than hiding the capsules staged before it. - if jq -Rne 'any(inputs | fromjson? | objects; - .event == "installed" and .needsReboot == true)' \ - "$events" >/dev/null 2>&1; then - firmware_reboot=true - fi - write_status running firmware "Device firmware update finished" 0 -} - -# Publish the recovery point as soon as it exists, so the run's record (and -# the history and Updates view built from it) names it even when the worker -# dies before its next step. -record_recovery_point() { - printf '[snapshot] recovery point %s\n' "$snapshot_id" >>"$run_dir/run.log" - write_status running snapshot "Created recovery point $snapshot_id" 0 -} - -# The background download (cybexos-update-download.service, off by default) -# shares dnf's package cache. Stop it before this run snapshots or changes -# anything; what it already fetched stays cached and is reused. Never fatal. -stop_background_download() { - local state - state=$(systemctl show cybexos-update-download.service --property=ActiveState \ - --value 2>/dev/null) || return 0 - case $state in - active|activating|reloading) ;; - *) return 0 ;; - esac - printf '[download] stopping the background update download\n' >>"$run_dir/run.log" - "${privileged_package[@]}" systemctl stop cybexos-update-download.service \ - >>"$run_dir/run.log" 2>&1 \ - || printf '[download] could not stop the background download; continuing\n' \ - >>"$run_dir/run.log" -} - -# Always address the local system manager, independently of the invoking -# session's bus/host redirects (including SYSTEMCTL_FORCE_BUS). With root and -# --system, systemctl uses /run/systemd/private rather than the public bus. -# daemon-reload ignores the usual bus timeout, so bound the process itself. -bounded_manager_command() { - local deadline=$1 command_name=$2 command_path - shift 2 - command_path="/usr/bin/$command_name" - if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 ]]; then - command_path=$(command -v -- "$command_name") || return 127 - fi - "${privileged_package[@]}" env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \ - timeout --kill-after=5s "$deadline" "$command_path" "$@" -} - -public_manager_ready() { - local reply rc - reply=$(bounded_manager_command 15s busctl --system --timeout=10s --json=short \ - get-property org.freedesktop.systemd1 /org/freedesktop/systemd1 \ - org.freedesktop.systemd1.Manager Version 2>>"$run_dir/run.log") - rc=$? - ((rc == 0)) || return "$rc" - printf '[manager] %s\n' "$reply" >>"$run_dir/run.log" - if ! jq -se 'length == 1 and (.[0] | type == "object" and .type == "s" - and (.data | type == "string" and test("^[0-9]")))' \ - <<<"$reply" >/dev/null 2>&1; then - printf '[manager] invalid public manager version reply\n' >>"$run_dir/run.log" - return 125 - fi -} - -worker() { - run_id=${1:-}; shift || true - valid_id "$run_id" || exit 2 - parse_options "$@" || exit $? - run_dir="$log_shelf/$run_id"; unit_name="cybexos-update-$run_id.service" - release_config_changed=false - release_activated=false - release_backup="$run_dir/config.before.yml" - started_at=$(jq -r '.startedAt // 0' "$run_dir/status.json" 2>/dev/null || printf 0) - [[ $started_at =~ ^[0-9]+$ ]] || started_at=$(date +%s) - dnf_done=false; flatpak_done=false; dnf_rc=0; flatpak_rc=0; tests_rc=0; ansible_rc=0 - firmware_done=false; firmware_rc=0 - snapshot_id="" - boot_id=$(current_boot_id || true) - reboot_recommendation=$(status_reboot_recommendation \ - "$run_dir/status.json" "$boot_id") - [[ $reboot_recommendation == recommended ]] \ - || reboot_recommendation="pending" - firmware_reboot=$(status_firmware_reboot "$run_dir/status.json" "$boot_id") - cancel_requested=false; packages_ran=false; mixed_state=false - trap request_cancellation INT TERM - trap worker_exit_fallback EXIT - exec 9>"$lock_file" - # start_run publishes the queued state while holding this same lock. Wait for - # that short critical section to end, then retain the lock for the complete - # package/test/Ansible lifecycle. - flock 9 - - # Cancellation can win before systemd-run has registered this transient - # unit. The durable intent closes the interval between an inactive unit - # query and registration; never begin package work once it is published. - if [[ -e $run_dir/cancel.requested ]]; then - worker_cancelled - fi - - # Also honor an already-published terminal state instead of resurrecting the - # run as `running`. - local published_state - published_state=$(status_state "$run_id" || true) - if [[ $published_state != queued ]]; then - worker_finalized=true - [[ $published_state == cancelled ]] && exit 130 - printf 'cybexos-update-run: refusing worker for run %s in state %s\n' \ - "$run_id" "${published_state:-unknown}" >&2 - exit 125 - fi - - honor_cancellation - if [[ $release_transaction == true ]] && ! promote_release_candidate; then - worker_failed staging "Could not promote the verified release candidate" 1 - fi - - acquire_inhibitor - write_status running snapshot "Creating a pre-update recovery point" 0 - printf 'CybexOS update %s started at %s\n' "$run_id" "$(date --iso-8601=seconds)" >> "$run_dir/run.log" - # Keep run state and logs private, but do not pass that 0077 umask through - # sudo. DNF5 rewrites its shared system-state TOML files using the process - # umask; making them root-only breaks every later unprivileged dnf query. - local -a privileged_package - if ((EUID == 0)); then - privileged_package=(sh -c 'umask 022; exec "$@"' cybexos-update-package) - else - privileged_package=(sudo -n sh -c \ - 'umask 022; exec "$@"' cybexos-update-package) - fi - if ! drain_installed_provisioning; then - worker_failed reconcile "Could not stop abandoned provisioning workers" 125 - fi - stop_background_download - local snapshot_helper_root=/usr/local/libexec - if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 \ - && -n ${CYBEXOS_UPDATE_TEST_LIBEXEC:-} ]]; then - snapshot_helper_root=$CYBEXOS_UPDATE_TEST_LIBEXEC - elif [[ ! -x $snapshot_helper_root/cybexos-system-snapshot \ - && ! -x $snapshot_helper_root/xps-system-snapshot ]]; then - # The RPM and checkout share a protocol, but own different libexec paths. - local alternate_helper_root - for alternate_helper_root in /usr/libexec /usr/local/libexec; do - if [[ -x $alternate_helper_root/cybexos-system-snapshot \ - || -x $alternate_helper_root/xps-system-snapshot ]]; then - snapshot_helper_root=$alternate_helper_root - break - fi - done - fi - local snapshot_helper="$snapshot_helper_root/cybexos-system-snapshot" - transaction_helper="$snapshot_helper_root/cybexos-update-transaction" - local legacy_snapshot_helper="$snapshot_helper_root/xps-system-snapshot" - local snapshot_rc=0 - # Existing installations can receive the renamed updater before Ansible has - # renamed the root-owned helper. The legacy helper has the same recovery - # contract and safely bridges that first full convergence. - if [[ ! -x $snapshot_helper && -x $legacy_snapshot_helper ]]; then - snapshot_helper=$legacy_snapshot_helper - fi - if [[ -x $snapshot_helper ]]; then - tagged_command snapshot "$run_dir/snapshot.log" "$run_dir/snapshot.rc" \ - "${privileged_package[@]}" "$snapshot_helper" create "update $run_id" - snapshot_rc=$(phase_result "$run_dir/snapshot.rc") - if ((snapshot_rc != 0)); then - worker_failed snapshot "Could not create the pre-update recovery point" "$snapshot_rc" - fi - snapshot_id=$(tail -n 1 "$run_dir/snapshot.log") || snapshot_id='' - if [[ $snapshot_id == skipped:* ]]; then - local skipped_filesystem - skipped_filesystem=$(findmnt --noheadings --output FSTYPE --target / 2>/dev/null | xargs) || skipped_filesystem='' - if [[ -z $skipped_filesystem || $skipped_filesystem == btrfs ]]; then - worker_failed snapshot "The recovery helper skipped a Btrfs or unknown filesystem" 125 - fi - snapshot_id='' - elif [[ ! $snapshot_id =~ ^[0-9]{8}T[0-9]{6}Z-[0-9]+$ ]]; then - worker_failed snapshot "The recovery helper did not return a valid recovery point" 125 - else - record_recovery_point - fi - else - local root_filesystem - root_filesystem=$(findmnt --noheadings --output FSTYPE --target / 2>/dev/null | xargs) || root_filesystem='' - if [[ -z $root_filesystem || $root_filesystem == btrfs ]]; then - worker_failed snapshot "Btrfs recovery helper is not installed" 127 - fi - printf '[snapshot] non-Btrfs root; no filesystem recovery point created\n' \ - >>"$run_dir/run.log" - fi - local bootstrap_helper='' bootstrap_source='' bootstrap_bundle='' - if [[ -n $snapshot_id ]]; then - # An explicitly selected full checkout/release has already passed the - # worker's source validation. A package-only source invocation may use - # its own adjacent checkout, never an arbitrary CWD or home directory. - local adjacent_source - adjacent_source=$(dirname -- "$(dirname -- "$(dirname -- "$script_path")")") - if [[ $full == true && -x $repo/roles/base/files/cybexos-update-bootstrap ]]; then - bootstrap_source=$repo/roles/base/files - elif [[ -x $snapshot_helper_root/cybexos-update-bootstrap ]]; then - bootstrap_source=$snapshot_helper_root - elif [[ ${CYBEXOS_UPDATE_TESTING:-0} != 1 \ - && $script_path == "$adjacent_source/assets/scripts/cybexos-update-run" \ - && -f $adjacent_source/site.yml && -x $adjacent_source/tests/run \ - && -x $adjacent_source/roles/base/files/cybexos-update-bootstrap ]]; then - bootstrap_source=$adjacent_source/roles/base/files - fi - bootstrap_helper=${bootstrap_source:+$bootstrap_source/cybexos-update-bootstrap} - if [[ -z $bootstrap_helper ]]; then - worker_failed snapshot "Recovery bootstrap is unavailable; update from a complete CybexOS release or checkout" 127 - fi - if ! "${privileged_package[@]}" /usr/bin/python3 -I "$bootstrap_helper" ready \ - --libexec "$snapshot_helper_root" >>"$run_dir/run.log" 2>&1; then - # The first point preserves the untouched old system. The second point - # includes boot recovery, which must survive exchanging the root before - # home restoration finishes. No packages or vendor home paths change - # until this hook is durable and the second checkpoint succeeds. - write_status running snapshot "Installing recovery for the first transactional update" 0 - tagged_command bootstrap "$run_dir/bootstrap.log" "$run_dir/bootstrap.rc" \ - "${privileged_package[@]}" /usr/bin/python3 -I "$bootstrap_helper" prepare \ - --source "$bootstrap_source" --checkpoint "$snapshot_id" --id "$run_id" - local bootstrap_rc protected_snapshot - bootstrap_rc=$(phase_result "$run_dir/bootstrap.rc") - ((bootstrap_rc == 0)) || worker_failed snapshot "Could not prepare durable boot recovery; no packages were changed" "$bootstrap_rc" - protected_snapshot=$(tail -n 1 "$run_dir/bootstrap.log" | jq -er '.snapshot | strings') || protected_snapshot='' - transaction_helper=$(tail -n 1 "$run_dir/bootstrap.log" | jq -er '.transactionHelper | strings') || transaction_helper='' - if [[ ! $protected_snapshot =~ ^[0-9]{8}T[0-9]{6}Z-[0-9]+$ \ - || $protected_snapshot == "$snapshot_id" || ! -x $transaction_helper ]]; then - worker_failed snapshot "Recovery bootstrap did not return a protected checkpoint" 125 - fi - snapshot_id=$protected_snapshot - record_recovery_point - bootstrap_bundle=$(dirname -- "$transaction_helper") - bootstrap_helper=$bootstrap_bundle/cybexos-update-bootstrap - fi - if ! "${privileged_package[@]}" "$transaction_helper" begin "$run_id" "$snapshot_id" \ - --uid "${CYBEXOS_UPDATE_OWNER_UID:-$UID}" >>"$run_dir/run.log" 2>&1; then - worker_failed snapshot "Could not checkpoint the vendor desktop; no packages were changed" 1 - fi - transaction_started=true - transaction_protection=btrfs - fi - honor_cancellation - if [[ $transaction_started == true && $with_packages == true ]]; then - write_status running download "Downloading packages before changing the installed system" 0 - tagged_command download "$run_dir/download.log" "$run_dir/download.rc" \ - "${privileged_package[@]}" dnf -y upgrade --refresh --no-best --downloadonly - local download_rc - download_rc=$(phase_result "$run_dir/download.rc") - ((download_rc == 0)) || worker_failed download "Package download failed; installed system is unchanged" "$download_rc" - honor_cancellation - fi - if [[ $transaction_started == true ]]; then - "${privileged_package[@]}" "$transaction_helper" applying "$run_id" \ - >>"$run_dir/run.log" 2>&1 \ - || worker_failed snapshot "Could not journal the update transaction" 1 - transaction_applying=true - fi - local dnf_pid="" flatpak_pid="" - if [[ $with_packages == true ]]; then - write_status running packages "Updating system packages and Flatpaks" 0 - # --no-best (dnf's default, stated so a local best=True cannot override - # it): versionlock holds Hyprland and Quickshell to their qualified series - # (docs/dependency-policy.md), so a library upgrade that only a newer, - # held-back compositor could use is skipped instead of failing the run. - tagged_command dnf "$run_dir/dnf.log" "$run_dir/dnf.rc" \ - "${privileged_package[@]}" dnf -y upgrade --refresh --no-best & - dnf_pid=$! - if [[ $with_flatpak == true ]]; then - tagged_command flatpak "$run_dir/flatpak.log" "$run_dir/flatpak.rc" \ - "${privileged_package[@]}" flatpak update --system --noninteractive & - flatpak_pid=$! - else - printf '0\n' > "$run_dir/flatpak.rc"; flatpak_done=true - fi - wait_for_job "$dnf_pid" - dnf_rc=$(phase_result "$run_dir/dnf.rc"); dnf_done=true - packages_ran=true - write_status running packages "System package update finished" 0 - if [[ -n $flatpak_pid ]]; then - wait_for_job "$flatpak_pid" - flatpak_rc=$(phase_result "$run_dir/flatpak.rc"); flatpak_done=true - write_status running packages "Package phase finished" 0 - fi - else - printf '0\n' > "$run_dir/dnf.rc" - printf '0\n' > "$run_dir/flatpak.rc" - dnf_done=true - flatpak_done=true - fi - if (( dnf_rc != 0 )); then - worker_failed packages "dnf exited with status $dnf_rc" "$dnf_rc" - fi - if [[ $transaction_started == true ]] && ((flatpak_rc != 0)); then - worker_failed packages "Flatpak exited with status $flatpak_rc" "$flatpak_rc" - fi - honor_cancellation - - if [[ $with_packages == true ]]; then - # RPM triggers can leave PID 1 disconnected from its API bus. One private - # reload synchronizes the successful package boundary before work using - # the public bus (including systemd-run --wait). It does not replay any - # failed service actions recorded by provisioning. - printf '[manager] synchronizing after the package phase\n' >>"$run_dir/run.log" - bounded_manager_command 30s systemctl --system --no-ask-password daemon-reload \ - >>"$run_dir/run.log" 2>&1 - local manager_rc=$? - ((manager_rc == 0)) \ - || worker_failed packages "Could not synchronize the updated system" "$manager_rc" - public_manager_ready - manager_rc=$? - ((manager_rc == 0)) \ - || worker_failed packages "The system manager is unavailable after the package update" "$manager_rc" - honor_cancellation - fi - - if [[ $full == true ]]; then - cd -- "$repo" || { - worker_failed tests "Could not enter repository directory" 1 - } - if [[ $skip_tests == true ]]; then - printf '[tests] repository checks skipped (--skip-tests)\n' >> "$run_dir/run.log" - else - write_status running tests "Running repository checks" 0 - # A wedged check must not hold the update lock indefinitely. - tagged_command tests "$run_dir/tests.log" "$run_dir/tests.rc" \ - timeout --kill-after=1m 30m ./tests/run - tests_rc=$(phase_result "$run_dir/tests.rc") - if (( tests_rc == 124 )); then - worker_failed tests "Repository checks did not finish within 30 minutes" "$tests_rc" - elif (( tests_rc != 0 )); then - worker_failed tests "Repository checks exited with status $tests_rc" "$tests_rc" - fi - honor_cancellation - fi - if [[ $release_transaction == true ]]; then - write_status running migration "Migrating the saved installer configuration" 0 - if ! prepare_release_configuration; then - worker_failed migration "Could not migrate the saved installer configuration" 1 - fi - fi - # Last boundary: once Ansible starts, the transaction runs to completion - # (or failure) so installed files and the active release stay coherent. - honor_cancellation - # From here until activation, installed files can be newer than the - # active release; a failure or abandonment leaves that recorded. - [[ $release_transaction != true ]] || mixed_state=true - write_status running ansible "Applying the managed configuration" 0 - # Keep the complete stream even when the configured callback is compact. - # Ansible's DNF modules inherit the process mask just like a direct dnf - # invocation. Keep shared package state readable without relaxing the - # updater's private log/state mask. - tagged_command ansible "$run_dir/ansible.log" "$run_dir/ansible.rc" \ - sh -c 'umask 022; exec "$@"' cybexos-update-ansible \ - ansible-playbook site.yml --skip-tags boot "${ansible_args[@]}" - ansible_rc=$(phase_result "$run_dir/ansible.rc") - if (( ansible_rc != 0 )); then - worker_failed ansible "Ansible exited with status $ansible_rc" "$ansible_rc" - fi - if [[ $release_transaction == true ]]; then - write_status running activation "Activating the applied CybexOS release" 0 - if ! activate_release; then - worker_failed activation "Could not activate the applied CybexOS release" 1 - fi - mixed_state=false - fi - fi - - # RPM posttrans only queues reconciliation. Finish it within the protected - # update before declaring the new installed policy healthy. - local reconcile_helper=/usr/libexec/cybexos-reconcile - if [[ ${CYBEXOS_UPDATE_TESTING:-0} == 1 \ - && -n ${CYBEXOS_UPDATE_TEST_LIBEXEC:-} ]]; then - reconcile_helper="$CYBEXOS_UPDATE_TEST_LIBEXEC/cybexos-reconcile" - fi - if [[ -x $reconcile_helper ]]; then - write_status running reconcile "Applying the updated installed-system policy" 0 - tagged_command reconcile "$run_dir/reconcile.log" "$run_dir/reconcile.rc" \ - "${privileged_package[@]}" "$reconcile_helper" --retry - local reconcile_rc reconcile_status - reconcile_rc=$(phase_result "$run_dir/reconcile.rc") - ((reconcile_rc == 0)) || worker_failed reconcile "Installed-system reconciliation failed" "$reconcile_rc" - # The helper deliberately exits successfully when another reconciler has - # its lock or RPM is busy. Success alone does not mean the new policy was - # applied; require its durable status for the current packaged version. - if ! reconcile_status=$("${privileged_package[@]}" "$reconcile_helper" --status \ - 2>>"$run_dir/reconcile.log") \ - || ! jq -e --argjson ownerUid "${CYBEXOS_UPDATE_OWNER_UID:-$UID}" ' - .desiredVersion as $desired | .accounts as $accounts | - .state == "ready" and .pending == false - and ($desired | type == "string" and length > 0) - and .version == $desired - and (.accounts | type == "object" and length > 0 - and all(.[]; .state == "ready" and .version == $desired)) - and ($ownerUid < 1000 or any($accounts[]; .uid == $ownerUid))' \ - <<<"$reconcile_status" >/dev/null 2>&1; then - worker_failed reconcile "Installed-system reconciliation has not reached the current ready state" 125 - fi - fi - - check_reboot_recommendation - apply_firmware_reboot_recommendation - - if [[ $transaction_started == true ]]; then - write_status running health "Validating the updated system and desktop" 0 - public_manager_ready - local health_manager_rc=$? - ((health_manager_rc == 0)) \ - || worker_failed health "The system manager is unavailable" "$health_manager_rc" - if ! "${privileged_package[@]}" "$transaction_helper" commit "$run_id" \ - >>"$run_dir/run.log" 2>&1; then - worker_failed health "The installed health check failed" 1 - fi - transaction_started=false - if [[ -n $bootstrap_bundle ]]; then - # A source role or RPM now owns the canonical recovery service. Retire - # the temporary implementation only after the journal is committed. - "${privileged_package[@]}" /usr/bin/python3 -I "$bootstrap_helper" finalize \ - --bundle "$bootstrap_bundle" >>"$run_dir/run.log" 2>&1 \ - || printf '[snapshot] retained recovery bootstrap for a later update\n' >>"$run_dir/run.log" - fi - fi - prune_releases - # Device firmware is not part of a filesystem snapshot. Apply it only after - # reversible system/configuration work has passed its health gate. - if [[ $with_firmware == true ]]; then - run_firmware_phase - honor_cancellation - apply_firmware_reboot_recommendation - fi - - local message="Update completed" - (( flatpak_rc == 0 )) || message="Update completed; Flatpak reported status $flatpak_rc" - (( firmware_rc == 0 )) || message="$message; firmware reported status $firmware_rc" - [[ $cancel_requested != true ]] \ - || message="$message; cancellation arrived after its last stopping point" - printf 'CybexOS update %s finished at %s\n' "$run_id" "$(date --iso-8601=seconds)" >> "$run_dir/run.log" - write_status done complete "$message" 0 - worker_finalized=true -} - -attach_run() { - local id file state exit_code tail_pid attach_interrupted=false - id=$(resolve_id "${1:-}") || return - file="$log_shelf/$id/run.log"; [[ -e $file ]] || : > "$file" - tail -n +1 -F -- "$file" & tail_pid=$! - detach_attach() { - attach_interrupted=true - kill "$tail_pid" 2>/dev/null || true - } - trap 'kill "$tail_pid" 2>/dev/null || true' EXIT - trap detach_attach INT TERM - while :; do - if [[ $attach_interrupted == true ]]; then - wait "$tail_pid" 2>/dev/null || true - trap - EXIT INT TERM - printf '\nDetached from update %s; the worker is still running.\n' "$id" - return 130 - fi - repair_stale_status "$id" - state=$(status_state "$id" || printf unknown) - is_active "$state" || break - sleep 1 - done - sleep 1 - kill "$tail_pid" 2>/dev/null || true; wait "$tail_pid" 2>/dev/null || true - trap - EXIT INT TERM - exit_code=$(jq -r '.exitCode // 1' "$(status_path "$id")" 2>/dev/null || printf 1) - printf '\nUpdate %s: %s (logs: %s)\n' "$id" "$state" "$log_shelf/$id" - return "$exit_code" -} - -load_status_globals() { - local id=$1 file - file=$(status_path "$id") - run_id=$id; run_dir="$log_shelf/$id"; unit_name=$(jq -r '.unit // empty' "$file") - started_at=$(jq -r '.startedAt // 0' "$file"); full=$(jq -r '.full // false' "$file") - with_flatpak=$(jq -r '.flatpak // true' "$file"); dnf_done=$(jq -r '.dnfDone // false' "$file") - flatpak_done=$(jq -r '.flatpakDone // false' "$file"); dnf_rc=$(jq -r '.dnfRc // 0' "$file") - flatpak_rc=$(jq -r '.flatpakRc // 0' "$file"); tests_rc=$(jq -r '.testsRc // 0' "$file") - ansible_rc=$(jq -r '.ansibleRc // 0' "$file") - # Records written before the firmware phase existed have none of these. - with_firmware=$(jq -r '.firmware // false' "$file") - [[ $with_firmware == true ]] || with_firmware=false - firmware_done=$(jq -r '.firmwareDone // false' "$file") - [[ $firmware_done == true ]] || firmware_done=false - firmware_rc=$(jq -r '.firmwareRc // 0' "$file") - [[ $firmware_rc =~ ^[0-9]+$ ]] || firmware_rc=0 - firmware_reboot=$(jq -r '.firmwareReboot // false' "$file") - [[ $firmware_reboot == true ]] || firmware_reboot=false - system_unit=$(jq -r '.systemUnit // false' "$file") - release_version=$(jq -r '.releaseVersion // empty' "$file") - [[ -z $release_version ]] && release_transaction=false || release_transaction=true - snapshot_id=$(jq -r '.snapshotId // empty' "$file") - mixed_state=$(jq -r '.mixedState // false' "$file") - [[ $mixed_state == true ]] || mixed_state=false - boot_id=$(current_boot_id || true) - reboot_recommendation=$(status_reboot_recommendation "$file" "$boot_id") -} - -cancel_run() { - local id state unit active_state next_active_state intent system_unit - local phase deferred - id=$(resolve_id "${1:-}") || return; state=$(status_state "$id" || true) - is_active "$state" || { printf 'cybexos-update-run: %s is not active (%s)\n' "$id" "${state:-unknown}" >&2; return 1; } - unit=$(jq -r '.unit // empty' "$(status_path "$id")") - system_unit=$(jq -r '.systemUnit // false' "$(status_path "$id")") - phase=$(jq -r '.phase // empty' "$(status_path "$id")") - deferred=$(jq -r '.deferredCancel // false' "$(status_path "$id")") - [[ -n $unit ]] || unit="cybexos-update-$id.service" - - # A worker launched before deferred cancellation existed is stopped with - # SIGTERM to its whole control group, which would interrupt dnf/rpm. - if [[ $phase == packages && $deferred != true ]]; then - printf 'cybexos-update-run: %s is applying a package transaction that cannot be interrupted safely; wait for it to finish\n' \ - "$id" >&2 - return 1 - fi - - # Publish intent before inspecting the unit. If start_run has not registered - # it yet, the eventual worker observes this marker under the transaction - # lock and exits before package work. If it is already registered, the - # active-unit path below still stops and verifies it synchronously. - intent="$log_shelf/$id/cancel.requested" - atomic_text "$intent" "$(date +%s)" - - if ! active_state=$(unit_active_state "$unit" "$system_unit"); then - printf 'cybexos-update-run: cancellation intent recorded, but %s could not be inspected; outcome is not yet verified\n' \ - "$unit" >&2 - return 1 - fi - case $active_state in - active|activating|reloading|deactivating) - # Queue the stop without waiting for it: with KillMode=mixed the worker - # alone receives SIGTERM and may first finish a package transaction or - # Ansible play, and a blocking stop would hold this client that long. - if [[ $system_unit == true ]]; then - system_manager_command systemctl stop --no-block "$unit" >/dev/null 2>&1 || { - printf 'cybexos-update-run: could not stop %s; update remains active\n' "$unit" >&2 - return 1 - } - elif ! systemctl --user stop --no-block "$unit" >/dev/null 2>&1; then - printf 'cybexos-update-run: could not stop %s; update remains active\n' "$unit" >&2 - return 1 - fi - # Verify the ownership boundary before publishing a terminal record. A - # worker proven stopped can no longer overwrite the synthetic status if - # its TERM trap did not get to run. One still finishing its step records - # the cancellation itself. - for _ in {1..20}; do - if ! next_active_state=$(unit_active_state "$unit" "$system_unit"); then - printf 'cybexos-update-run: could not verify %s stopped\n' "$unit" >&2 - return 1 - fi - case $next_active_state in - inactive|failed) break ;; - esac - sleep 0.05 - done - case $next_active_state in - inactive|failed) ;; - active|activating|reloading|deactivating) - atomic_text "$log_shelf/$id/cancel.pending" "$(date +%s)" - printf 'Cancellation requested for update %s. A running package transaction or configuration step finishes first; `cybexos-update-run attach %s` follows it.\n' \ - "$id" "$id" - return 0 - ;; - *) - printf 'cybexos-update-run: %s is still %s; update remains active\n' \ - "$unit" "${next_active_state:-unknown}" >&2 - return 1 - ;; - esac - ;; - inactive|failed) - if [[ $state != queued ]]; then - repair_stale_status "$id" - state=$(status_state "$id" || true) - printf 'cybexos-update-run: %s has no active worker (%s)\n' \ - "$id" "${state:-unknown}" >&2 - return 1 - fi - # start_run publishes queued before systemd-run registers the unit. Mark - # that narrow window terminal; a subsequently registered worker checks - # the status under the transaction lock and refuses package work. - ;; - *) - printf 'cybexos-update-run: unexpected unit state for %s: %s\n' \ - "$unit" "${active_state:-unknown}" >&2 - return 1 - ;; - esac - - state=$(status_state "$id" || true) - if is_active "$state"; then - load_status_globals "$id" - write_status cancelled cancelled "Update cancelled" 130 - elif [[ $state != cancelled ]]; then - printf 'cybexos-update-run: %s finished as %s before cancellation\n' \ - "$id" "${state:-unknown}" >&2 - return 1 - fi - printf 'Cancelled update %s.\n' "$id" -} - -dismiss_run() { - local id state file temporary - id=$(resolve_id "${1:-}") || return; file=$(status_path "$id") - state=$(status_state "$id" || true) - is_active "$state" && { printf 'cybexos-update-run: an active run cannot be dismissed\n' >&2; return 1; } - # Dismissing hides the result from the panel; the history keeps it. - temporary="$file.$$.tmp" - jq 'if .state == "dismissed" then . else .outcome = .state | .state = "dismissed" end' \ - "$file" > "$temporary" - mv -f -- "$temporary" "$file" -} - -# Finished runs, newest first: what happened and the recovery point each one -# created before it changed anything. Built from the retained run records, so -# it reaches as far back as their twenty-run retention. -history_runs() { - local json=false limit=20 id file current record - local -a records=() - while (($#)); do - case $1 in - --json) json=true ;; - --limit) - shift - [[ ${1:-} =~ ^[0-9]{1,3}$ ]] || { - printf 'cybexos-update-run: --limit needs a number\n' >&2 - return 2 - } - limit=$((10#$1)) - ;; - *) printf 'cybexos-update-run: unknown history option %s\n' "$1" >&2; return 2 ;; - esac - shift - done - # A worker that vanished is recorded as failed before it is listed. - current=$(current_id || true) - [[ -z $current ]] || repair_stale_status "$current" - while IFS= read -r id; do - valid_id "$id" || continue - file=$(status_path "$id") - [[ -r $file ]] || continue - # A dismissed record keeps its outcome; one dismissed by an older - # updater only has its exit code to say what it was. - record=$(jq -c ' - select(type == "object" and (.id | type) == "string") - | (.outcome // (if .state == "dismissed" then - (if .exitCode == 0 then "done" elif .exitCode == 130 then "cancelled" - else "failed" end) else .state end)) as $outcome - | select($outcome == "done" or $outcome == "failed" or $outcome == "cancelled") - | {id, outcome: $outcome, startedAt: (.startedAt // 0), - finishedAt: (.finishedAt // 0), phase: (.phase // ""), - snapshotId: (.snapshotId // ""), releaseVersion: (.releaseVersion // ""), - transactionProtection: (.transactionProtection // "unavailable"), - rollbackState: (.rollbackState // "none"), message: (.message // "")}' \ - "$file" 2>/dev/null) || continue - [[ -n $record ]] && records+=("$record") - done < <(find "$log_shelf" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2>/dev/null) - local listing - listing=$(printf '%s\n' "${records[@]}" | jq -cs --argjson limit "$limit" \ - 'map(select(. != null)) | sort_by(.startedAt, .id) | reverse | .[:$limit]') - if [[ $json == true ]]; then - printf '%s\n' "$listing" - elif [[ $listing == '[]' ]]; then - printf 'No update has finished yet.\n' - else - jq -r '.[] | "\((if .finishedAt > 0 then .finishedAt else .startedAt end) - | strflocaltime("%Y-%m-%d %H:%M")) \(.outcome)\(if .snapshotId != "" - then " recovery point " + .snapshotId else "" end) \(.id)"' <<<"$listing" - fi -} - -read_log() { - local id=${1:-} name=${2:-} offset=${3:-0} count=${4:-} file - valid_id "$id" || return 2 - case $name in - dnf|flatpak|run|tests|ansible|firmware|firmware-events) ;; - *) return 2 ;; - esac - [[ $offset =~ ^[0-9]+$ ]] || return 2 - [[ -z $count || $count =~ ^[0-9]+$ ]] || return 2 - file="$log_shelf/$id/$name.log"; [[ -r $file ]] || return 0 - if [[ -n $count ]]; then - dd if="$file" iflag=skip_bytes,count_bytes skip="$offset" count="$count" \ - status=none - else - tail -c "+$((offset + 1))" -- "$file" - fi -} - -run_client() { - local output rc id existing - local argument - for argument in "$@"; do - if [[ $argument == -h || $argument == --help ]]; then - usage - return 0 - fi - done - # start_run runs inside a command substitution and cannot prompt. Unless - # sudo already works for it, authenticate here in the terminal, so that it - # can launch the system unit instead of failing with "authentication is - # required" in the very terminal it asks for. - if [[ ${CYBEXOS_UPDATE_ASSUME_USER_UNIT:-0} != 1 && -t 0 ]] \ - && ! detached_sudo_available && ! sudo -n true >/dev/null 2>&1; then - printf 'Authenticate once to start the durable system update worker.\n' - sudo -v || return - fi - if output=$(start_run --json "$@"); then - rc=0 - else - rc=$? - fi - [[ -n $output ]] || return "$rc" - id=$(jq -er '.id' <<< "$output") || return 1 - existing=$(jq -r '.existing // false' <<< "$output") - [[ $existing == true ]] \ - && printf 'An update is already active; attaching to %s.\n' "$id" \ - || printf 'Started durable update %s. Ctrl+C detaches; `cybexos-update-run cancel` cancels.\n' "$id" - attach_run "$id" -} - -command=${1:-run}; shift || true -case $command in - run) run_client "$@" ;; - start) start_run "$@" ;; - status) - json=false; [[ ${1:-} == --json ]] && { json=true; shift; } - [[ $json == true ]] && json_status "${1:-}" || human_status "${1:-}" - ;; - attach) attach_run "${1:-}" ;; - cancel) cancel_run "${1:-}" ;; - dismiss) dismiss_run "${1:-}" ;; - log-dir) id=$(resolve_id "${1:-}") || exit; printf '%s/%s\n' "$log_shelf" "$id" ;; - history) history_runs "$@" ;; - read-log) read_log "${1:-}" "${2:-}" "${3:-0}" "${4:-}" ;; - _worker) worker "$@" ;; - -h|--help|help) usage ;; - *) printf 'cybexos-update-run: unknown command %s\n\n' "$command" >&2; usage >&2; exit 2 ;; -esac diff --git a/assets/scripts/cybexos-webapp b/assets/scripts/cybexos-webapp index cdaf8ebf..3d9142d3 100755 --- a/assets/scripts/cybexos-webapp +++ b/assets/scripts/cybexos-webapp @@ -23,8 +23,8 @@ generated monogram, and the real icon is fetched on a later launch. `seed` installs the CybexOS defaults (Slack, WhatsApp, YouTube, Google Photos and X) once per account, offline. Every default it has seeded is recorded, so -one the user removes is never added back. Both installation paths run it: -the checkout's dotfiles role and the ISO's per-account initialization. +one the user removes is never added back. The shared NixOS personal-init +service runs it for both the live desktop and installed accounts. The site address is data. It never reaches a shell, a desktop-entry Exec line, or a command-line option position. @@ -64,8 +64,8 @@ USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) CybexOS-webapp/1" # The defaults CybexOS has always offered on Super+M, Super+W, Super+Y, # Super+Shift+P and Super+Shift+X. Their bindings launch them by these ids. -# cybexos-user-init stops calling `seed` once defaults.json exists, so a -# release that adds a default must also give it a way to be seeded. +# The managed personal-init service calls `seed` at login. Recorded deletions +# remain absent; newly declared defaults are seeded once. DEFAULTS = ( ("slack", "Slack", "https://app.slack.com/"), ("whatsapp", "WhatsApp", "https://web.whatsapp.com/"), @@ -75,7 +75,7 @@ DEFAULTS = ( ) # Brave Origin exactly as bindings.lua starts it for Super+B, plus app mode. -BRAVE = "brave-origin-stable" +BRAVE = "brave" BRAVE_FLAGS = ( "--enable-features=TouchpadOverscrollHistoryNavigation,PipeWireCamera", "--enable-wayland-ime", @@ -126,9 +126,8 @@ def desktop_path(identifier: str) -> Path: def helper_path() -> Path: - # Both installation paths provide the helper here: a copy on a checkout, - # a link to /usr/share/cybexos/bin on the ISO. - return home() / ".local/bin/cybexos-webapp" + # The shared NixOS package provides this stable selected-system entry point. + return Path("/run/current-system/sw/bin/cybexos-webapp") def directory(path: Path) -> Path: @@ -645,8 +644,8 @@ def launch(identifier: str) -> None: record = read_record(identifier) if record is None: default = next((entry for entry in DEFAULTS if entry[0] == identifier), None) - hint = (f"cybex webapp install --name {default[1]} --url {default[2]}" - if default else "cybex webapp install --url ADDRESS") + hint = (f"cybexos-webapp install --name {default[1]} --url {default[2]}" + if default else "cybexos-webapp install --url ADDRESS") raise WebAppError( f"The {default[1] if default else identifier} web app was removed. " f"Reinstall it with Install web app… in the launcher, or {hint}") @@ -737,7 +736,7 @@ def main(argv: list[str] | None = None) -> int: except OSError as error: print(f"cybexos-webapp: {error}", file=sys.stderr) if announce or arguments.command == "launch": - notify("Web apps", "The web app could not be changed. Run cybex webapp in a " + notify("Web apps", "The web app could not be changed. Run cybexos-webapp in a " "terminal for details.") return 1 return 0 diff --git a/roles/dotfiles/templates/external-monitor-toggle.j2 b/assets/scripts/external-monitor-toggle old mode 100644 new mode 100755 similarity index 100% rename from roles/dotfiles/templates/external-monitor-toggle.j2 rename to assets/scripts/external-monitor-toggle diff --git a/roles/dotfiles/templates/fastfetch-link-speed.j2 b/assets/scripts/fastfetch-link-speed old mode 100644 new mode 100755 similarity index 100% rename from roles/dotfiles/templates/fastfetch-link-speed.j2 rename to assets/scripts/fastfetch-link-speed diff --git a/assets/scripts/screen-record b/assets/scripts/screen-record index edc45008..ca82deee 100644 --- a/assets/scripts/screen-record +++ b/assets/scripts/screen-record @@ -66,8 +66,8 @@ render_nodes() { ((${#nvidia_nodes[@]} == 0)) || printf '%s\n' "${nvidia_nodes[@]}" } -# Whether VA-API offers H.264 encoding on a render node. Mesa as Fedora -# builds it leaves H.264 out (RPM Fusion's freeworld drivers add it back), and +# Whether VA-API offers H.264 encoding on a render node. Mesa as NixOS +# builds it leaves H.264 out (codec support depends on the selected driver), and # nvidia-vaapi-driver only decodes, so the vendor alone proves nothing: the # driver's own list of entrypoints is the answer. vaapi_encodes_h264() { @@ -87,7 +87,7 @@ ffmpeg_has_nvenc() { } # One line: the recorder, then how it encodes. gpu-screen-recorder is not -# packaged for Fedora; when a user installs it, it is the better recorder on +# packaged for NixOS; when a user installs it, it is the better recorder on # NVIDIA, where it encodes with NVENC directly. select_encoder() { local entry node vendor driver nvidia=0 nodes=() @@ -318,7 +318,8 @@ OUTPUT_FILE="$OUTPUT_DIR/screen-recording-${TS}.mp4" # shell can open the camera while the encoder initialises. Without any video # device there is nothing to show; say so once and record anyway. if ((webcam)); then - if compgen -G "$V4L_ROOT/video*" >/dev/null; then + cameras=("$V4L_ROOT"/video*) + if [[ -e ${cameras[0]} ]]; then publish_state "$WEBCAM_STATE" "$SELECTION" else webcam=0 diff --git a/assets/scripts/screenshot b/assets/scripts/screenshot index 7a17351d..ae2fcef3 100644 --- a/assets/scripts/screenshot +++ b/assets/scripts/screenshot @@ -154,7 +154,7 @@ edit_screenshot() { --early-exit \ --actions-on-enter save-to-clipboard \ --save-after-copy \ - --copy-command "$HOME/.local/bin/clipboard-copy-image" + --copy-command "clipboard-copy-image" } # offer_edit FILE [SUMMARY] [BODY] diff --git a/bootstrap b/bootstrap index da0f9dff..26b7d8b3 100755 --- a/bootstrap +++ b/bootstrap @@ -1,5 +1,3 @@ #!/usr/bin/env bash -# Backward-compatible entry point. New installations use ./install directly. set -euo pipefail -repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -exec "$repo_dir/install" "$@" +exec cybex install "$@" diff --git a/desktop/bin/cybexos-session b/desktop/bin/cybexos-session new file mode 100755 index 00000000..b8db2c62 --- /dev/null +++ b/desktop/bin/cybexos-session @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +export XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=Hyprland XDG_SESSION_DESKTOP=Hyprland +export MOZ_ENABLE_WAYLAND=1 QT_QPA_PLATFORM=wayland XMODIFIERS=@im=ibus +export CYBEXOS_USER_CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/cybexos" +export CYBEXOS_PLUGIN_ROOT="${XDG_DATA_HOME:-$HOME/.local/share}/cybexos/plugins" +# SDDM must get control again after a compositor crash. +trap 'systemctl --user stop hyprland-session.target' EXIT +Hyprland --config /etc/cybexos/runtime/hypr/hyprland.lua "$@" diff --git a/roles/desktop/files/cybexos-session-action b/desktop/bin/cybexos-session-action old mode 100644 new mode 100755 similarity index 100% rename from roles/desktop/files/cybexos-session-action rename to desktop/bin/cybexos-session-action diff --git a/desktop/bin/cybexos-session-start b/desktop/bin/cybexos-session-start new file mode 100755 index 00000000..c64c89b3 --- /dev/null +++ b/desktop/bin/cybexos-session-start @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +systemctl --user import-environment WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE + dbus-update-activation-environment --systemd WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE +systemctl --user start hyprland-session.target diff --git a/roles/dotfiles/files/fastfetch.jsonc b/desktop/defaults/fastfetch.jsonc similarity index 91% rename from roles/dotfiles/files/fastfetch.jsonc rename to desktop/defaults/fastfetch.jsonc index ec57d13f..38840947 100644 --- a/roles/dotfiles/files/fastfetch.jsonc +++ b/desktop/defaults/fastfetch.jsonc @@ -1,6 +1,6 @@ { "$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json", - "logo": { "source": "Fedora", "padding": { "top": 2, "right": 6, "left": 2 } }, + "logo": { "source": "NixOS", "padding": { "top": 2, "right": 6, "left": 2 } }, "modules": [ "break", { "type": "custom", "format": "\u001b[90m┌──────────────────────Hardware──────────────────────┐" }, @@ -8,7 +8,7 @@ { "type": "cpu", "key": "│ ├", "showPeCoreCount": true, "keyColor": "green" }, { "type": "gpu", "key": "│ ├", "detectionMethod": "pci", "keyColor": "green" }, { "type": "display", "key": "│ ├󱄄", "keyColor": "green" }, - { "type": "command", "key": "│ ├󰈀", "keyColor": "green", "text": "$HOME/.local/bin/fastfetch-link-speed" }, + { "type": "command", "key": "│ ├󰈀", "keyColor": "green", "text": "fastfetch-link-speed" }, { "type": "disk", "key": "│ ├󰋊", "keyColor": "green" }, { "type": "memory", "key": "│ ├", "keyColor": "green" }, { "type": "swap", "key": "└ └󰓡 ", "keyColor": "green" }, diff --git a/roles/dotfiles/files/fish-config.fish b/desktop/defaults/fish-config.fish similarity index 87% rename from roles/dotfiles/files/fish-config.fish rename to desktop/defaults/fish-config.fish index 0818303d..c4c6f737 100644 --- a/roles/dotfiles/files/fish-config.fish +++ b/desktop/defaults/fish-config.fish @@ -17,7 +17,7 @@ set -gx SCCACHE_DIR "$HOME/.cache/sccache" # the PATH and variables above, but not the six processes behind a prompt, # hooks and aliases they never use. if status is-interactive - # Keep the prompt icon accurate on Fedora and inside each Distrobox. One + # Keep the prompt icon accurate on NixOS and inside each Distrobox. One # builtin read of os-release; the quotes are optional in that format. set -l posh_os_id set -l posh_os_like @@ -32,8 +32,8 @@ if status is-interactive end dict[str, Any]: {"session_id": conversation_id}, timeout=30, ) + self.remote_deleted[conversation_id] = self.remote_list_generation runtime = self.runtime_by_conversation.pop(conversation_id, "") if runtime: self.conversation_by_runtime.pop(runtime, None) @@ -1266,7 +1273,9 @@ async def delete_conversation(self, params: dict[str, Any]) -> dict[str, Any]: await self.stop_remote_stream(conversation_id) if self.remote_observed_conversation_id == conversation_id: await self.observe_remote_conversation("") - self.registry.conversations.pop(conversation_id) + # A list refresh during the awaits above may already have removed the + # row; removal is idempotent and still completes with one event. + self.registry.conversations.pop(conversation_id, None) if self.registry.selected_conversation_id == conversation_id: self.registry.selected_conversation_id = "" self.registry.save() @@ -2259,13 +2268,33 @@ async def refresh_remote_conversations(self) -> dict[str, Any]: raise RemoteLoginFault( "Remote Hermes authentication is required", self.remote_auth.status ) - result = await self.remote_request( - "GET", "/api/sessions?exclude_hidden=1", timeout=45 - ) + self.remote_list_generation += 1 + generation = self.remote_list_generation + self.remote_list_requests.add(generation) + try: + result = await self.remote_request( + "GET", "/api/sessions?exclude_hidden=1", timeout=45 + ) + finally: + self.remote_list_requests.discard(generation) rows = result.get("sessions") if not isinstance(rows, list): raise RpcFault(-32045, "Remote Hermes returned no conversation list") + # Only lists requested after a confirmed delete are authoritative for + # it; older responses must not restore the row or its stream. Forget a + # delete once no list request that predates it remains in flight. + deleted = { + session_id + for session_id, confirmed in self.remote_deleted.items() + if confirmed >= generation + } + oldest = min(self.remote_list_requests, default=self.remote_list_generation + 1) + self.remote_deleted = { + session_id: confirmed + for session_id, confirmed in self.remote_deleted.items() + if confirmed >= oldest + } previous = self.registry.conversations incoming: dict[str, dict[str, Any]] = {} for raw in rows: @@ -2279,6 +2308,8 @@ async def refresh_remote_conversations(self) -> dict[str, Any]: except RpcFault: LOG.warning("ignoring an invalid remote Hermes conversation row") continue + if record["id"] in deleted: + continue incoming[record["id"]] = record # A just-created first turn can be absent from the sidebar snapshot for diff --git a/desktop/hypr/autostart.lua b/desktop/hypr/autostart.lua new file mode 100644 index 00000000..ace71666 --- /dev/null +++ b/desktop/hypr/autostart.lua @@ -0,0 +1,3 @@ +hl.on("hyprland.start", function() + hl.exec_cmd("cybexos-session-start") +end) diff --git a/roles/desktop/files/bindings.lua b/desktop/hypr/bindings.lua similarity index 82% rename from roles/desktop/files/bindings.lua rename to desktop/hypr/bindings.lua index 9f415c30..78153044 100644 --- a/roles/desktop/files/bindings.lua +++ b/desktop/hypr/bindings.lua @@ -5,7 +5,7 @@ local features = require("features") local browser = "firefox" local privateBrowser = "firefox --private-window" if features.proprietary_apps then - browser = "brave-origin-stable --enable-features=TouchpadOverscrollHistoryNavigation,PipeWireCamera --enable-wayland-ime --restore-last-session --hide-crash-restore-bubble" + browser = "brave --enable-features=TouchpadOverscrollHistoryNavigation,PipeWireCamera --enable-wayland-ime --restore-last-session --hide-crash-restore-bubble" privateBrowser = browser .. " --incognito" end @@ -13,15 +13,15 @@ end -- the launcher). The helper picks the browser as Super+B does, and tells the -- user how to reinstall one that was removed. local function web_app(id) - return home .. "/.local/bin/cybexos-webapp launch " .. id + return "cybexos-webapp launch " .. id end -local previous = rawget(_G, "__fedora_hypr_binds") or {} +local previous = rawget(_G, "__cybexos_hypr_binds") or {} for _, keybind in ipairs(previous) do if keybind and keybind.remove then keybind:remove() elseif keybind and keybind.unbind then keybind:unbind() end end -_G.__fedora_hypr_binds = {} +_G.__cybexos_hypr_binds = {} -- Every binding the user should know about carries a description, "Group: -- Label". The Super+K cheatsheet is drawn from `hyprctl binds -j`, so these @@ -30,7 +30,7 @@ _G.__fedora_hypr_binds = {} local function bind(keys, dispatcher, opts) hl.unbind(keys) local keybind = hl.bind(keys, dispatcher, opts) - table.insert(_G.__fedora_hypr_binds, keybind) + table.insert(_G.__cybexos_hypr_binds, keybind) return keybind end @@ -41,26 +41,26 @@ end bind(mainMod .. " + Return", hl.dsp.exec_cmd(terminal), { description = "Apps: Terminal" }) if features.podman then - bind(mainMod .. " + SHIFT + F", hl.dsp.exec_cmd(terminal .. " -e " .. home .. "/.local/bin/dev-fedora-shell"), - { description = "Development: Fedora container shell" }) - bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd(terminal .. " -e " .. home .. "/.local/bin/dev-arch-shell"), + bind(mainMod .. " + SHIFT + F", hl.dsp.exec_cmd(terminal .. " -e " .. "dev-nix-shell"), + { description = "Development: Nix development shell" }) + bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd(terminal .. " -e " .. "dev-arch-shell"), { description = "Development: Arch container shell" }) - bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd(terminal .. " -e " .. home .. "/.local/bin/dev-debian-shell"), + bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd(terminal .. " -e " .. "dev-debian-shell"), { description = "Development: Debian container shell" }) end bind(mainMod .. " + SPACE", hl.dsp.global("quickshell:launcherToggle"), { description = "Shell: Launcher" }) -bind(mainMod .. " + CTRL + SHIFT + A", hl.dsp.exec_cmd(home .. "/.local/bin/cybex agent --window"), +bind(mainMod .. " + CTRL + SHIFT + A", hl.dsp.exec_cmd("cybex agent --window"), { description = "Apps: AI agent" }) -- The shell runs from the CybexOS runtime by path, where a bare -- `qs ipc call` finds no configuration; cybexos-runtime names the active one. -bind(mainMod .. " + comma", hl.dsp.exec_cmd(home .. "/.local/bin/cybexos-runtime ipc settings toggle"), +bind(mainMod .. " + comma", hl.dsp.exec_cmd("cybexos-runtime ipc settings toggle"), { description = "Shell: CybexOS Settings" }) -- Shell surfaces the menubar also opens by click. -bind(mainMod .. " + N", hl.dsp.exec_cmd(home .. "/.local/bin/cybexos-runtime ipc popouts toggle notifications"), +bind(mainMod .. " + N", hl.dsp.exec_cmd("cybexos-runtime ipc popouts toggle notifications"), { description = "Shell: Notifications" }) -bind(mainMod .. " + A", hl.dsp.exec_cmd(home .. "/.local/bin/cybexos-runtime ipc popouts toggle control"), +bind(mainMod .. " + A", hl.dsp.exec_cmd("cybexos-runtime ipc popouts toggle control"), { description = "Shell: Control Panel" }) -bind(mainMod .. " + K", hl.dsp.exec_cmd(home .. "/.local/bin/cybexos-runtime ipc session keys"), +bind(mainMod .. " + K", hl.dsp.exec_cmd("cybexos-runtime ipc session keys"), { description = "Shell: Keyboard shortcuts" }) bind(mainMod .. " + E", hl.dsp.exec_cmd("nautilus --new-window"), { description = "Apps: Files" }) bind(mainMod .. " + B", hl.dsp.exec_cmd(browser), { description = "Apps: Browser" }) @@ -76,7 +76,7 @@ end if features.developer_tools then -- Docker group membership is root-equivalent and opt-in, so without it the -- socket is reachable only through sudo. The binary is resolved before - -- sudo because sudo's secure_path need not include /usr/local/bin. + -- sudo because sudo's secure_path may differ from the user's package profile. local lazydocker = [[sh -c 'docker info >/dev/null 2>&1 && exec lazydocker; exec sudo "$(command -v lazydocker)"']] bind(mainMod .. " + D", hl.dsp.exec_cmd(terminal .. " -e " .. lazydocker), { description = "Development: Docker (lazydocker)" }) @@ -102,9 +102,9 @@ end bind(mainMod .. " + C", function() send_shortcut_once("CTRL", "Insert") end, { description = "Clipboard: Copy" }) bind(mainMod .. " + V", function() send_shortcut_once("SHIFT", "Insert") end, { description = "Clipboard: Paste" }) bind(mainMod .. " + X", function() send_shortcut_once("CTRL", "X") end, { description = "Clipboard: Cut" }) -bind(mainMod .. " + SHIFT + V", hl.dsp.exec_cmd(home .. "/.local/bin/clipboard-image-to-file"), +bind(mainMod .. " + SHIFT + V", hl.dsp.exec_cmd("clipboard-image-to-file"), { description = "Clipboard: Save copied image to a file" }) -bind(mainMod .. " + CTRL + V", hl.dsp.exec_cmd(home .. "/.local/bin/cybexos-runtime ipc launcher open clipboard"), +bind(mainMod .. " + CTRL + V", hl.dsp.exec_cmd("cybexos-runtime ipc launcher open clipboard"), { description = "Clipboard: Clipboard history" }) bind(mainMod .. " + Q", hl.dsp.window.close(), { description = "Windows: Close window" }) @@ -120,7 +120,7 @@ bind(mainMod .. " + L", hl.dsp.exec_cmd("systemctl --user start cybexos-session- { description = "Shell: Lock screen" }) -- Effects, gaps and idle lock off for games; the helper notifies and the -- menubar shows an indicator while it is on. -bind(mainMod .. " + SHIFT + G", hl.dsp.exec_cmd(home .. "/.local/bin/game-mode toggle"), +bind(mainMod .. " + SHIFT + G", hl.dsp.exec_cmd("game-mode toggle"), { description = "Shell: Game mode" }) for _, direction in ipairs({ "left", "right", "up", "down" }) do @@ -192,44 +192,44 @@ bind(mainMod .. " + CTRL + equal", function() step_zoom(1) end, bind(mainMod .. " + CTRL + minus", function() step_zoom(-1) end, { repeating = true, description = "Windows: Adjust zoom" }) -bind(mainMod .. " + grave", hl.dsp.exec_cmd(home .. "/.local/bin/screenshot region"), +bind(mainMod .. " + grave", hl.dsp.exec_cmd("screenshot region"), { description = "Capture: Screenshot a region or window" }) -bind(mainMod .. " + SHIFT + grave", hl.dsp.exec_cmd(home .. "/.local/bin/screen-record"), +bind(mainMod .. " + SHIFT + grave", hl.dsp.exec_cmd("screen-record"), { description = "Capture: Start or stop screen recording" }) -bind("Print", hl.dsp.exec_cmd(home .. "/.local/bin/screenshot region"), +bind("Print", hl.dsp.exec_cmd("screenshot region"), { description = "Capture: Screenshot a region or window" }) -bind("SHIFT + Print", hl.dsp.exec_cmd(home .. "/.local/bin/screenshot fullscreen"), +bind("SHIFT + Print", hl.dsp.exec_cmd("screenshot fullscreen"), { description = "Capture: Screenshot the whole screen" }) -bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd(home .. "/.local/bin/screen-ocr"), +bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("screen-ocr"), { description = "Capture: Copy text or QR code from a region" }) -- Pressing the scrolling screenshot's key again finishes it, as do Enter and -- Escape, which the script binds only while a capture runs. -bind("ALT + Print", hl.dsp.exec_cmd(home .. "/.local/bin/screenshot scroll"), +bind("ALT + Print", hl.dsp.exec_cmd("screenshot scroll"), { description = "Capture: Scrolling screenshot" }) -bind(mainMod .. " + ALT + grave", hl.dsp.exec_cmd(home .. "/.local/bin/screenshot scroll"), +bind(mainMod .. " + ALT + grave", hl.dsp.exec_cmd("screenshot scroll"), { description = "Capture: Scrolling screenshot" }) -bind(mainMod .. " + CTRL + SHIFT + grave", hl.dsp.exec_cmd(home .. "/.local/bin/screen-record --webcam"), +bind(mainMod .. " + CTRL + SHIFT + grave", hl.dsp.exec_cmd("screen-record --webcam"), { description = "Capture: Record with webcam" }) -bind(mainMod .. " + Print", hl.dsp.exec_cmd(home .. "/.local/bin/color-picker"), +bind(mainMod .. " + Print", hl.dsp.exec_cmd("color-picker"), { description = "Capture: Pick a colour" }) -bind(mainMod .. " + SHIFT + C", hl.dsp.exec_cmd(home .. "/.local/bin/color-picker"), +bind(mainMod .. " + SHIFT + C", hl.dsp.exec_cmd("color-picker"), { description = "Capture: Pick a colour" }) local volumeUp = "wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ 5%+" local volumeDown = "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-" local volumeMute = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle" if features.xps_2026 then - volumeUp = "/usr/local/libexec/xps-speaker-tuning control up || " .. volumeUp - volumeDown = "/usr/local/libexec/xps-speaker-tuning control down || " .. volumeDown - volumeMute = "/usr/local/libexec/xps-speaker-tuning control mute || " .. volumeMute + volumeUp = "xps-speaker-tuning control up || " .. volumeUp + volumeDown = "xps-speaker-tuning control down || " .. volumeDown + volumeMute = "xps-speaker-tuning control mute || " .. volumeMute end bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(volumeUp), { locked = true, repeating = true, description = "Hardware keys: Volume up" }) bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(volumeDown), { locked = true, repeating = true, description = "Hardware keys: Volume down" }) -bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(home .. "/.local/bin/brightness-control up 5"), +bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightness-control up 5"), { locked = true, repeating = true, description = "Hardware keys: Brightness up" }) -bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(home .. "/.local/bin/brightness-control down 5"), +bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightness-control down 5"), { locked = true, repeating = true, description = "Hardware keys: Brightness down" }) bind("XF86AudioMute", hl.dsp.exec_cmd(volumeMute), { locked = true, description = "Hardware keys: Mute audio" }) if features.developer_tools then diff --git a/roles/desktop/files/displays.lua b/desktop/hypr/displays.lua similarity index 100% rename from roles/desktop/files/displays.lua rename to desktop/hypr/displays.lua diff --git a/desktop/hypr/features.lua b/desktop/hypr/features.lua new file mode 100644 index 00000000..84824d85 --- /dev/null +++ b/desktop/hypr/features.lua @@ -0,0 +1,6 @@ +return { + podman = os.getenv("CYBEXOS_PODMAN") == "1", developer_tools = os.getenv("CYBEXOS_DEVELOPER_TOOLS") ~= "0", + connected_widgets = os.getenv("CYBEXOS_CONNECTED_WIDGETS") ~= "0", + private_portal = false, proprietary_apps = os.getenv("CYBEXOS_PROPRIETARY_APPS") == "1", + xps_2026 = os.getenv("CYBEXOS_XPS_2026") == "1", +} diff --git a/roles/desktop/templates/hypridle.conf.j2 b/desktop/hypr/hypridle.conf similarity index 81% rename from roles/desktop/templates/hypridle.conf.j2 rename to desktop/hypr/hypridle.conf index a75bb0d4..8314658a 100644 --- a/roles/desktop/templates/hypridle.conf.j2 +++ b/desktop/hypr/hypridle.conf @@ -1,6 +1,6 @@ general { lock_cmd = systemctl --user start cybexos-session-lock.service - before_sleep_cmd = /usr/local/libexec/cybexos-session-action lock + before_sleep_cmd = cybexos-session-action lock after_sleep_cmd = hyprctl eval 'hl.dispatch(hl.dsp.dpms({ action = "on" }))' inhibit_sleep = 3 } @@ -32,7 +32,7 @@ listener { listener { timeout = 1800 - condition_cmd = /usr/local/libexec/cybexos-session-action on-battery + condition_cmd = cybexos-session-action on-battery condition_retry = 60 - on-timeout = /usr/local/libexec/cybexos-session-action idle-suspend on-battery + on-timeout = cybexos-session-action idle-suspend on-battery } diff --git a/roles/desktop/files/hyprland.lua b/desktop/hypr/hyprland.lua similarity index 95% rename from roles/desktop/files/hyprland.lua rename to desktop/hypr/hyprland.lua index 605895f0..c1a320a4 100644 --- a/roles/desktop/files/hyprland.lua +++ b/desktop/hypr/hyprland.lua @@ -1,7 +1,7 @@ local home = assert(os.getenv("HOME"), "HOME is required") local config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config") local data_home = os.getenv("XDG_DATA_HOME") or (home .. "/.local/share") -local generated_dir = data_home .. "/cybexos/runtime/hypr" +local generated_dir = "/etc/cybexos/runtime/hypr" local source_dir = generated_dir local user_dir = config_home .. "/cybexos/hypr" @@ -14,10 +14,10 @@ if source_file then local trailing = source_file:read("*l") source_file:close() if checkout and checkout:sub(1, 1) == "/" and not trailing then - local required = io.open(checkout .. "/roles/desktop/files/bindings.lua", "r") + local required = io.open(checkout .. "/desktop/hypr/bindings.lua", "r") if required then required:close() - source_dir = checkout .. "/roles/desktop/files" + source_dir = checkout .. "/desktop/hypr" end end end diff --git a/roles/desktop/templates/hyprlock.conf.j2 b/desktop/hypr/hyprlock.conf similarity index 100% rename from roles/desktop/templates/hyprlock.conf.j2 rename to desktop/hypr/hyprlock.conf diff --git a/roles/desktop/templates/input.lua.j2 b/desktop/hypr/input.lua similarity index 96% rename from roles/desktop/templates/input.lua.j2 rename to desktop/hypr/input.lua index 86de6655..14690c7e 100644 --- a/roles/desktop/templates/input.lua.j2 +++ b/desktop/hypr/input.lua @@ -20,8 +20,8 @@ _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 }}, + kb_layout = "us", + kb_variant = "", -- Keep left Alt available to applications (for example Codex Alt+Up). kb_options = _G.__cybexos_vendor_keyboard_options, follow_mouse = 1, @@ -132,7 +132,7 @@ if type(add_gesture) == "function" then fingers = 3, direction = "up", action = function() - hl.exec_cmd(home .. "/.local/bin/cybexos-runtime ipc overview toggle") + hl.exec_cmd("cybexos-runtime ipc overview toggle") end, }) end diff --git a/roles/desktop/files/input_preferences.lua b/desktop/hypr/input_preferences.lua similarity index 100% rename from roles/desktop/files/input_preferences.lua rename to desktop/hypr/input_preferences.lua diff --git a/roles/desktop/templates/looknfeel.lua.j2 b/desktop/hypr/looknfeel.lua similarity index 98% rename from roles/desktop/templates/looknfeel.lua.j2 rename to desktop/hypr/looknfeel.lua index 91119db4..f1794746 100644 --- a/roles/desktop/templates/looknfeel.lua.j2 +++ b/desktop/hypr/looknfeel.lua @@ -55,7 +55,7 @@ hl.config({ misc = { force_default_wallpaper = 0, disable_hyprland_logo = true, disable_splash_rendering = true, focus_on_activate = false, disable_watchdog_warning = true }, xwayland = { - force_zero_scaling = {{ 'os.getenv("CYBEXOS_XPS_2026") == "1"' if cybexos_dynamic_hardware | default(false) else ((cybexos_xps_2026 | bool) | ternary('true', 'false')) }}, + force_zero_scaling = false, }, group = { col = { @@ -305,13 +305,13 @@ hl.window_rule({ match = { class = [[org\.gnome\.Nautilus]], title = [[Open.*]] hl.window_rule({ match = { class = [[org\.gnome\.Nautilus]], title = [[Save.*]] }, float = true }) -- A lone tiled window uses 70% of an external display's logical width. -- Laptop displays and workspaces with multiple tiled windows keep normal gaps. -local previous = rawget(_G, "__fedora_single_window_width") +local previous = rawget(_G, "__cybexos_single_window_width") if previous then previous.subscription:remove() for _, entry in pairs(previous.rules) do entry.rule:set_enabled(false) end end local policy = { rules = {}, suspended = false } -_G.__fedora_single_window_width = policy +_G.__cybexos_single_window_width = policy -- Game mode turns every gap off, these margins included. function policy.suspend(value) diff --git a/desktop/hypr/monitors.lua b/desktop/hypr/monitors.lua new file mode 100644 index 00000000..ccefac90 --- /dev/null +++ b/desktop/hypr/monitors.lua @@ -0,0 +1,15 @@ +-- Vendor monitor rules. They are recorded as well as applied so that +-- displays.lua (Settings -> Displays) can start a saved choice from the rule +-- it overrides and keep fields the page does not own, such as bitdepth. +local vendor_monitor_rules = {} +_G.__cybexos_vendor_monitor_rules = vendor_monitor_rules +local function monitor(rule) + table.insert(vendor_monitor_rules, rule) + hl.monitor(rule) +end + + +-- Portable default: use each connected output's preferred mode and let +-- Hyprland choose its position and scale. +monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" }) + diff --git a/roles/desktop/files/titlebars-native.patch b/desktop/hypr/titlebars-native.patch similarity index 90% rename from roles/desktop/files/titlebars-native.patch rename to desktop/hypr/titlebars-native.patch index 6cfa1ebd..f9e15e01 100644 --- a/roles/desktop/files/titlebars-native.patch +++ b/desktop/hypr/titlebars-native.patch @@ -27,10 +27,10 @@ +} + static PLUGIN_DESCRIPTION_INFO initializeOmarchyFloating(HANDLE handle) { -+ // A Fedora release rebuild may keep the source/API hash while changing ++ // A Nixpkgs rebuild may keep the source/API hash while changing + // the executable ABI. Never enter plugin state against another ELF build. + if (cybexosHyprlandBuildId() != CYBEXOS_HYPRLAND_BUILD_ID) -+ throw std::runtime_error("[hyprbars] Fedora Hyprland build ID mismatch; rebuild native titlebars"); ++ throw std::runtime_error("[hyprbars] Nixpkgs Hyprland build ID mismatch; rebuild native titlebars"); + OMARCHY_FLOATING_PHANDLE = handle; @@ -55,7 +55,7 @@ # visibility breaks the host API. Our own types/globals have unique OmarchyFloating names. target_compile_options(titlebars PRIVATE -fno-gnu-unique) +if(NOT CYBEXOS_HYPRLAND_BUILD_ID MATCHES "^[0-9a-f]+$") -+ message(FATAL_ERROR "CYBEXOS_HYPRLAND_BUILD_ID must identify the installed Fedora Hyprland executable") ++ message(FATAL_ERROR "CYBEXOS_HYPRLAND_BUILD_ID must identify the installed Nixpkgs Hyprland executable") +endif() +target_compile_definitions(titlebars PRIVATE CYBEXOS_HYPRLAND_BUILD_ID="${CYBEXOS_HYPRLAND_BUILD_ID}") @@ -64,14 +64,14 @@ --- /dev/null +++ b/cybexosBuildId.hpp @@ -0,0 +1,44 @@ -+// CybexOS native Fedora build guard. SPDX-License-Identifier: MIT ++// CybexOS native compositor build guard. SPDX-License-Identifier: MIT +#pragma once +#include +#include +#include + -+// Read the main executable's mapped ELF notes, not /usr/bin/Hyprland: an RPM -+// update can replace that path while an older compositor is still running. ++// Read the running executable's mapped ELF notes: a package upgrade can select ++// a new executable while the older compositor is still running. +static std::string cybexosHyprlandBuildId() { + std::string result; + dl_iterate_phdr([](dl_phdr_info *info, size_t, void *data) -> int { diff --git a/roles/desktop/files/workspace_layouts.lua b/desktop/hypr/workspace_layouts.lua similarity index 100% rename from roles/desktop/files/workspace_layouts.lua rename to desktop/hypr/workspace_layouts.lua diff --git a/roles/desktop/files/workspace_model.lua b/desktop/hypr/workspace_model.lua similarity index 100% rename from roles/desktop/files/workspace_model.lua rename to desktop/hypr/workspace_model.lua diff --git a/roles/desktop/files/workspace_state.lua b/desktop/hypr/workspace_state.lua similarity index 100% rename from roles/desktop/files/workspace_state.lua rename to desktop/hypr/workspace_state.lua diff --git a/roles/desktop/files/workspaces.lua b/desktop/hypr/workspaces.lua similarity index 97% rename from roles/desktop/files/workspaces.lua rename to desktop/hypr/workspaces.lua index c55d4d1e..88f2554a 100644 --- a/roles/desktop/files/workspaces.lua +++ b/desktop/hypr/workspaces.lua @@ -1,5 +1,5 @@ -- One compositor owner for workspace numbering, layout and minimized windows. --- Public/data contract and qualification: docs/desktop-workspace-architecture.md. +-- Personal configuration and runtime data contract: docs/customization.md. local data = require("workspace_state") local model = require("workspace_model") local floating = require("workspace_layouts") @@ -9,6 +9,21 @@ local config = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config") local state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state") local runtime = os.getenv("XDG_RUNTIME_DIR") local session = os.getenv("HYPRLAND_INSTANCE_SIGNATURE") or "" +-- Hyprland's documented --verify-config parses declarations without creating +-- the event loop, but still emits config.reloaded. Calling hl.timer from that +-- event crashes 0.56.2. Only timer scheduling is inapplicable in this mode; +-- module loading, configuration declarations and initial refresh still run. +local verify_config = false +do + local process = io.open("/proc/self/cmdline", "rb") + if process then + local arguments = process:read(65536) or "" + process:close() + for argument in arguments:gmatch("[^%z]+") do + if argument == "--verify-config" then verify_config = true; break end + end + end +end M.path = config .. "/cybexos/workspaces.json" M.assignments_path = state_home .. "/cybexos/workspace-assignments.json" M.snapshot_path = runtime and runtime .. "/cybexos/workspaces.json" or nil @@ -53,10 +68,10 @@ _G.__cybexos_workspaces = state -- installed optional plugin, even when currently disabled or already loaded. -- Runtime refreshes only toggle rendering; they never load/unload a plugin. do - local file = io.open("/usr/lib64/cybexos/titlebars.so", "rb") + local file = io.open("/etc/cybexos/titlebars.so", "rb") if file then file:close() - local ok, err = pcall(hl.plugin.load, "/usr/lib64/cybexos/titlebars.so") + local ok, err = pcall(hl.plugin.load, "/etc/cybexos/titlebars.so") if not ok then state.error = "titlebar plugin unavailable: " .. tostring(err) end end end @@ -349,7 +364,7 @@ local function write_snapshot() end local function sync_policy() _G.__cybexos_workspace_mode = preferences.mode - local shared = rawget(_G, "__fedora_workspace_policy") + local shared = rawget(_G, "__cybexos_workspace_policy") if shared and shared.apply then shared.apply() end -- Replace only the registered vendor swipe, preserving Touchpad opt-outs -- and any user.lua gesture that already retired it. Native workspace swipes @@ -476,7 +491,7 @@ function M.refresh_theme() refresh() end local function schedule() - if state.busy or state.retired or state.pending then return end + if verify_config or state.busy or state.retired or state.pending then return end state.pending = hl.timer(function() state.pending = nil if not state.retired then refresh() end diff --git a/roles/desktop/files/notification-images/CMakeLists.txt b/desktop/notification-images/CMakeLists.txt similarity index 100% rename from roles/desktop/files/notification-images/CMakeLists.txt rename to desktop/notification-images/CMakeLists.txt diff --git a/roles/desktop/files/notification-images/LICENSE b/desktop/notification-images/LICENSE similarity index 100% rename from roles/desktop/files/notification-images/LICENSE rename to desktop/notification-images/LICENSE diff --git a/roles/desktop/files/notification-images/build.py b/desktop/notification-images/build.py similarity index 100% rename from roles/desktop/files/notification-images/build.py rename to desktop/notification-images/build.py diff --git a/roles/desktop/files/notification-images/imagefingerprints.cpp b/desktop/notification-images/imagefingerprints.cpp similarity index 100% rename from roles/desktop/files/notification-images/imagefingerprints.cpp rename to desktop/notification-images/imagefingerprints.cpp diff --git a/roles/desktop/files/notification-images/imagefingerprints.h b/desktop/notification-images/imagefingerprints.h similarity index 100% rename from roles/desktop/files/notification-images/imagefingerprints.h rename to desktop/notification-images/imagefingerprints.h diff --git a/roles/desktop/files/notification-images/plugin.cpp b/desktop/notification-images/plugin.cpp similarity index 100% rename from roles/desktop/files/notification-images/plugin.cpp rename to desktop/notification-images/plugin.cpp diff --git a/roles/desktop/files/notification-images/plugins.qmltypes b/desktop/notification-images/plugins.qmltypes similarity index 100% rename from roles/desktop/files/notification-images/plugins.qmltypes rename to desktop/notification-images/plugins.qmltypes diff --git a/roles/desktop/files/notification-images/qmldir b/desktop/notification-images/qmldir similarity index 100% rename from roles/desktop/files/notification-images/qmldir rename to desktop/notification-images/qmldir diff --git a/roles/desktop/files/notification-images/tests.cpp b/desktop/notification-images/tests.cpp similarity index 100% rename from roles/desktop/files/notification-images/tests.cpp rename to desktop/notification-images/tests.cpp diff --git a/roles/boot/files/bullet.png b/desktop/plymouth/bullet.png similarity index 100% rename from roles/boot/files/bullet.png rename to desktop/plymouth/bullet.png diff --git a/roles/boot/files/cybex.plymouth b/desktop/plymouth/cybex.plymouth similarity index 100% rename from roles/boot/files/cybex.plymouth rename to desktop/plymouth/cybex.plymouth diff --git a/roles/boot/files/cybex.script b/desktop/plymouth/cybex.script similarity index 100% rename from roles/boot/files/cybex.script rename to desktop/plymouth/cybex.script diff --git a/roles/boot/files/entry.png b/desktop/plymouth/entry.png similarity index 100% rename from roles/boot/files/entry.png rename to desktop/plymouth/entry.png diff --git a/roles/boot/files/logo.png b/desktop/plymouth/logo.png similarity index 100% rename from roles/boot/files/logo.png rename to desktop/plymouth/logo.png diff --git a/roles/boot/files/progress_bar.png b/desktop/plymouth/progress_bar.png similarity index 100% rename from roles/boot/files/progress_bar.png rename to desktop/plymouth/progress_bar.png diff --git a/roles/boot/files/progress_box.png b/desktop/plymouth/progress_box.png similarity index 100% rename from roles/boot/files/progress_box.png rename to desktop/plymouth/progress_box.png diff --git a/roles/desktop/files/quickshell/.qmllint.ini b/desktop/quickshell/.qmllint.ini similarity index 100% rename from roles/desktop/files/quickshell/.qmllint.ini rename to desktop/quickshell/.qmllint.ini diff --git a/roles/desktop/files/quickshell/Bar/Bar.qml b/desktop/quickshell/Bar/Bar.qml similarity index 99% rename from roles/desktop/files/quickshell/Bar/Bar.qml rename to desktop/quickshell/Bar/Bar.qml index e425df0e..cfb7d023 100644 --- a/roles/desktop/files/quickshell/Bar/Bar.qml +++ b/desktop/quickshell/Bar/Bar.qml @@ -820,7 +820,7 @@ PanelWindow { // ---- the slab ------------------------------------------------------ // One continuous surface. In glass mode the compositor supplies the blur - // behind its tint (see roles/desktop/templates/looknfeel.lua.j2); solid mode uses + // behind its tint (see desktop/hypr/looknfeel.lua); solid mode uses // the same selected menubar color at full opacity. Item { id: slabLayer diff --git a/roles/desktop/files/quickshell/Bar/BarBrandIcon.qml b/desktop/quickshell/Bar/BarBrandIcon.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarBrandIcon.qml rename to desktop/quickshell/Bar/BarBrandIcon.qml diff --git a/roles/desktop/files/quickshell/Bar/BarChip.qml b/desktop/quickshell/Bar/BarChip.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarChip.qml rename to desktop/quickshell/Bar/BarChip.qml diff --git a/roles/desktop/files/quickshell/Bar/BarHover.qml b/desktop/quickshell/Bar/BarHover.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarHover.qml rename to desktop/quickshell/Bar/BarHover.qml diff --git a/roles/desktop/files/quickshell/Bar/BarIcon.qml b/desktop/quickshell/Bar/BarIcon.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarIcon.qml rename to desktop/quickshell/Bar/BarIcon.qml diff --git a/roles/desktop/files/quickshell/Bar/BarPopoutWindow.qml b/desktop/quickshell/Bar/BarPopoutWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarPopoutWindow.qml rename to desktop/quickshell/Bar/BarPopoutWindow.qml diff --git a/roles/desktop/files/quickshell/Bar/BarTooltip.qml b/desktop/quickshell/Bar/BarTooltip.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/BarTooltip.qml rename to desktop/quickshell/Bar/BarTooltip.qml diff --git a/roles/desktop/files/quickshell/Bar/Cluster.qml b/desktop/quickshell/Bar/Cluster.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Cluster.qml rename to desktop/quickshell/Bar/Cluster.qml diff --git a/roles/desktop/files/quickshell/Bar/Divider.qml b/desktop/quickshell/Bar/Divider.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Divider.qml rename to desktop/quickshell/Bar/Divider.qml diff --git a/roles/desktop/files/quickshell/Bar/HermesChip.qml b/desktop/quickshell/Bar/HermesChip.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/HermesChip.qml rename to desktop/quickshell/Bar/HermesChip.qml diff --git a/roles/desktop/files/quickshell/Bar/ModuleSlot.qml b/desktop/quickshell/Bar/ModuleSlot.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/ModuleSlot.qml rename to desktop/quickshell/Bar/ModuleSlot.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/BarModule.qml b/desktop/quickshell/Bar/Modules/BarModule.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/BarModule.qml rename to desktop/quickshell/Bar/Modules/BarModule.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Battery.qml b/desktop/quickshell/Bar/Modules/Battery.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Battery.qml rename to desktop/quickshell/Bar/Modules/Battery.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Bluetooth.qml b/desktop/quickshell/Bar/Modules/Bluetooth.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Bluetooth.qml rename to desktop/quickshell/Bar/Modules/Bluetooth.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Clock.qml b/desktop/quickshell/Bar/Modules/Clock.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Clock.qml rename to desktop/quickshell/Bar/Modules/Clock.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Control.qml b/desktop/quickshell/Bar/Modules/Control.qml similarity index 96% rename from roles/desktop/files/quickshell/Bar/Modules/Control.qml rename to desktop/quickshell/Bar/Modules/Control.qml index 0fd6b90e..608eb8b2 100644 --- a/roles/desktop/files/quickshell/Bar/Modules/Control.qml +++ b/desktop/quickshell/Bar/Modules/Control.qml @@ -21,7 +21,7 @@ BarModule { anchors.verticalCenter: parent.verticalCenter width: Theme.barIconSize height: Theme.barIconSize - name: "fedora" + name: "cybexos" highlighted: controlButton.held || controlButton.hovered } } diff --git a/roles/desktop/files/quickshell/Bar/Modules/Fusebox.qml b/desktop/quickshell/Bar/Modules/Fusebox.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Fusebox.qml rename to desktop/quickshell/Bar/Modules/Fusebox.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/GitHub.qml b/desktop/quickshell/Bar/Modules/GitHub.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/GitHub.qml rename to desktop/quickshell/Bar/Modules/GitHub.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Hermes.qml b/desktop/quickshell/Bar/Modules/Hermes.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Hermes.qml rename to desktop/quickshell/Bar/Modules/Hermes.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Indicators.qml b/desktop/quickshell/Bar/Modules/Indicators.qml similarity index 99% rename from roles/desktop/files/quickshell/Bar/Modules/Indicators.qml rename to desktop/quickshell/Bar/Modules/Indicators.qml index 972e4372..f6cc3a0b 100644 --- a/roles/desktop/files/quickshell/Bar/Modules/Indicators.qml +++ b/desktop/quickshell/Bar/Modules/Indicators.qml @@ -14,7 +14,7 @@ BarModule { moduleId: "indicators" readonly property string panelName: "reminders" readonly property string ocrScript: - Quickshell.env("HOME") + "/.local/bin/screen-ocr" + "screen-ocr" spacing: 2 property bool disclosureLatched: false property var actionButtons: [] diff --git a/roles/desktop/files/quickshell/Bar/Modules/Media.qml b/desktop/quickshell/Bar/Modules/Media.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Media.qml rename to desktop/quickshell/Bar/Modules/Media.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/ModelUsage.qml b/desktop/quickshell/Bar/Modules/ModelUsage.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/ModelUsage.qml rename to desktop/quickshell/Bar/Modules/ModelUsage.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Notes.qml b/desktop/quickshell/Bar/Modules/Notes.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Notes.qml rename to desktop/quickshell/Bar/Modules/Notes.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Notifications.qml b/desktop/quickshell/Bar/Modules/Notifications.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Notifications.qml rename to desktop/quickshell/Bar/Modules/Notifications.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Remote.qml b/desktop/quickshell/Bar/Modules/Remote.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Remote.qml rename to desktop/quickshell/Bar/Modules/Remote.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/T3.qml b/desktop/quickshell/Bar/Modules/T3.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/T3.qml rename to desktop/quickshell/Bar/Modules/T3.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Tray.qml b/desktop/quickshell/Bar/Modules/Tray.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Tray.qml rename to desktop/quickshell/Bar/Modules/Tray.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Updates.qml b/desktop/quickshell/Bar/Modules/Updates.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Updates.qml rename to desktop/quickshell/Bar/Modules/Updates.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Volume.qml b/desktop/quickshell/Bar/Modules/Volume.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Volume.qml rename to desktop/quickshell/Bar/Modules/Volume.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Weather.qml b/desktop/quickshell/Bar/Modules/Weather.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Weather.qml rename to desktop/quickshell/Bar/Modules/Weather.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Wifi.qml b/desktop/quickshell/Bar/Modules/Wifi.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Wifi.qml rename to desktop/quickshell/Bar/Modules/Wifi.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/Workspaces.qml b/desktop/quickshell/Bar/Modules/Workspaces.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/Workspaces.qml rename to desktop/quickshell/Bar/Modules/Workspaces.qml diff --git a/roles/desktop/files/quickshell/Bar/Modules/qmldir b/desktop/quickshell/Bar/Modules/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Bar/Modules/qmldir rename to desktop/quickshell/Bar/Modules/qmldir diff --git a/roles/desktop/files/quickshell/Bar/PointerCheck.qml b/desktop/quickshell/Bar/PointerCheck.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/PointerCheck.qml rename to desktop/quickshell/Bar/PointerCheck.qml diff --git a/roles/desktop/files/quickshell/Bar/PopoutHost.qml b/desktop/quickshell/Bar/PopoutHost.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/PopoutHost.qml rename to desktop/quickshell/Bar/PopoutHost.qml diff --git a/roles/desktop/files/quickshell/Bar/T3Chip.qml b/desktop/quickshell/Bar/T3Chip.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/T3Chip.qml rename to desktop/quickshell/Bar/T3Chip.qml diff --git a/roles/desktop/files/quickshell/Bar/UserWidgets.qml b/desktop/quickshell/Bar/UserWidgets.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/UserWidgets.qml rename to desktop/quickshell/Bar/UserWidgets.qml diff --git a/roles/desktop/files/quickshell/Bar/Workspaces.qml b/desktop/quickshell/Bar/Workspaces.qml similarity index 100% rename from roles/desktop/files/quickshell/Bar/Workspaces.qml rename to desktop/quickshell/Bar/Workspaces.qml diff --git a/roles/desktop/files/quickshell/Bar/qmldir b/desktop/quickshell/Bar/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Bar/qmldir rename to desktop/quickshell/Bar/qmldir diff --git a/roles/desktop/files/quickshell/Common/Activity.qml b/desktop/quickshell/Common/Activity.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Activity.qml rename to desktop/quickshell/Common/Activity.qml diff --git a/roles/desktop/files/quickshell/Common/AnimatedText.qml b/desktop/quickshell/Common/AnimatedText.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/AnimatedText.qml rename to desktop/quickshell/Common/AnimatedText.qml diff --git a/roles/desktop/files/quickshell/Common/Audio.qml b/desktop/quickshell/Common/Audio.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Audio.qml rename to desktop/quickshell/Common/Audio.qml diff --git a/roles/desktop/files/quickshell/Common/AudioHelpers.js b/desktop/quickshell/Common/AudioHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/AudioHelpers.js rename to desktop/quickshell/Common/AudioHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Backup.qml b/desktop/quickshell/Common/Backup.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Backup.qml rename to desktop/quickshell/Common/Backup.qml diff --git a/roles/desktop/files/quickshell/Common/BackupHelpers.js b/desktop/quickshell/Common/BackupHelpers.js similarity index 99% rename from roles/desktop/files/quickshell/Common/BackupHelpers.js rename to desktop/quickshell/Common/BackupHelpers.js index d16ed651..0f4b1b66 100644 --- a/roles/desktop/files/quickshell/Common/BackupHelpers.js +++ b/desktop/quickshell/Common/BackupHelpers.js @@ -195,7 +195,7 @@ function validRestorePath(path) { } function helperPath(home) { - return String(home || "") + "/.local/bin/" + HELPER_NAME; + return HELPER_NAME; } // `kind` is "local" (target: a folder) or "sftp" (target: an SSH alias or diff --git a/roles/desktop/files/quickshell/Common/BarGeometry.js b/desktop/quickshell/Common/BarGeometry.js similarity index 100% rename from roles/desktop/files/quickshell/Common/BarGeometry.js rename to desktop/quickshell/Common/BarGeometry.js diff --git a/roles/desktop/files/quickshell/Common/BarOverflow.qml b/desktop/quickshell/Common/BarOverflow.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/BarOverflow.qml rename to desktop/quickshell/Common/BarOverflow.qml diff --git a/roles/desktop/files/quickshell/Common/Battery.qml b/desktop/quickshell/Common/Battery.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Battery.qml rename to desktop/quickshell/Common/Battery.qml diff --git a/roles/desktop/files/quickshell/Common/BatteryHealth.qml b/desktop/quickshell/Common/BatteryHealth.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/BatteryHealth.qml rename to desktop/quickshell/Common/BatteryHealth.qml diff --git a/roles/desktop/files/quickshell/Common/BatteryViewHelpers.js b/desktop/quickshell/Common/BatteryViewHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/BatteryViewHelpers.js rename to desktop/quickshell/Common/BatteryViewHelpers.js diff --git a/roles/desktop/files/quickshell/Common/BluetoothHelpers.js b/desktop/quickshell/Common/BluetoothHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/BluetoothHelpers.js rename to desktop/quickshell/Common/BluetoothHelpers.js diff --git a/roles/desktop/files/quickshell/Common/BluetoothState.qml b/desktop/quickshell/Common/BluetoothState.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/BluetoothState.qml rename to desktop/quickshell/Common/BluetoothState.qml diff --git a/roles/desktop/files/quickshell/Common/BrandIcon.qml b/desktop/quickshell/Common/BrandIcon.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/BrandIcon.qml rename to desktop/quickshell/Common/BrandIcon.qml diff --git a/roles/desktop/files/quickshell/Common/BrandIcons.qml b/desktop/quickshell/Common/BrandIcons.qml similarity index 93% rename from roles/desktop/files/quickshell/Common/BrandIcons.qml rename to desktop/quickshell/Common/BrandIcons.qml index eb470bc0..836f652c 100644 --- a/roles/desktop/files/quickshell/Common/BrandIcons.qml +++ b/desktop/quickshell/Common/BrandIcons.qml @@ -12,12 +12,12 @@ Singleton { id: root readonly property var names: [ - "claude", "fedora", "fusebox", "gemini", "github", "grok", "kimi", "openai", + "claude", "cybexos", "fusebox", "gemini", "github", "grok", "kimi", "openai", "slack", "t3", "tailscale", "whatsapp", "youtube" ] readonly property var files: ({ claude: "claude.svg", - fedora: "fedora.svg", + cybexos: "cybexos.svg", fusebox: "fusebox.svg", gemini: "gemini.svg", github: "github.svg", @@ -35,7 +35,7 @@ Singleton { // keeps thin outlines (notably OpenAI's) at their original weight. readonly property var highlightFiles: ({ claude: "claude-white.svg", - fedora: "fedora-white.svg", + cybexos: "cybexos-white.svg", fusebox: "fusebox-white.svg", gemini: "gemini-white.svg", github: "github-white.svg", @@ -51,7 +51,7 @@ Singleton { }) readonly property var labels: ({ claude: "Claude", - fedora: "Fedora", + cybexos: "CybexOS", fusebox: "Fusebox", gemini: "Gemini", github: "GitHub", diff --git a/roles/desktop/files/quickshell/Common/Calendar.qml b/desktop/quickshell/Common/Calendar.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Calendar.qml rename to desktop/quickshell/Common/Calendar.qml diff --git a/roles/desktop/files/quickshell/Common/CalendarHelpers.js b/desktop/quickshell/Common/CalendarHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/CalendarHelpers.js rename to desktop/quickshell/Common/CalendarHelpers.js diff --git a/roles/desktop/files/quickshell/Common/CaptureHelpers.js b/desktop/quickshell/Common/CaptureHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/CaptureHelpers.js rename to desktop/quickshell/Common/CaptureHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Claim.qml b/desktop/quickshell/Common/Claim.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Claim.qml rename to desktop/quickshell/Common/Claim.qml diff --git a/roles/desktop/files/quickshell/Common/CommandRequest.qml b/desktop/quickshell/Common/CommandRequest.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/CommandRequest.qml rename to desktop/quickshell/Common/CommandRequest.qml diff --git a/roles/desktop/files/quickshell/Common/CountdownHelpers.js b/desktop/quickshell/Common/CountdownHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/CountdownHelpers.js rename to desktop/quickshell/Common/CountdownHelpers.js diff --git a/roles/desktop/files/quickshell/Common/DesktopWorkspaces.qml b/desktop/quickshell/Common/DesktopWorkspaces.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/DesktopWorkspaces.qml rename to desktop/quickshell/Common/DesktopWorkspaces.qml diff --git a/roles/desktop/files/quickshell/Common/Dictation.qml b/desktop/quickshell/Common/Dictation.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Dictation.qml rename to desktop/quickshell/Common/Dictation.qml diff --git a/roles/desktop/files/quickshell/Common/DisplayHelpers.js b/desktop/quickshell/Common/DisplayHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/DisplayHelpers.js rename to desktop/quickshell/Common/DisplayHelpers.js diff --git a/roles/desktop/files/quickshell/Common/DisplaySettings.qml b/desktop/quickshell/Common/DisplaySettings.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/DisplaySettings.qml rename to desktop/quickshell/Common/DisplaySettings.qml diff --git a/roles/desktop/files/quickshell/Common/Dock.qml b/desktop/quickshell/Common/Dock.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/Dock.qml rename to desktop/quickshell/Common/Dock.qml index c55122cb..9caa75e4 100644 --- a/roles/desktop/files/quickshell/Common/Dock.qml +++ b/desktop/quickshell/Common/Dock.qml @@ -202,7 +202,7 @@ Singleton { } Process { id: webAppList - command: [Quickshell.env("HOME") + "/.local/bin/cybexos-webapp", "list", "--json"] + command: ["cybexos-webapp", "list", "--json"] stdout: StdioCollector { onStreamFinished: root.webApps = ProviderHelpers.parseWebApps(text) } diff --git a/roles/desktop/files/quickshell/Common/DockHelpers.js b/desktop/quickshell/Common/DockHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/DockHelpers.js rename to desktop/quickshell/Common/DockHelpers.js diff --git a/roles/desktop/files/quickshell/Common/EthernetHelpers.js b/desktop/quickshell/Common/EthernetHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/EthernetHelpers.js rename to desktop/quickshell/Common/EthernetHelpers.js diff --git a/roles/desktop/files/quickshell/Common/EthernetState.qml b/desktop/quickshell/Common/EthernetState.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/EthernetState.qml rename to desktop/quickshell/Common/EthernetState.qml diff --git a/roles/desktop/files/quickshell/Common/ExternalUrl.js b/desktop/quickshell/Common/ExternalUrl.js similarity index 100% rename from roles/desktop/files/quickshell/Common/ExternalUrl.js rename to desktop/quickshell/Common/ExternalUrl.js diff --git a/roles/desktop/files/quickshell/Common/Format.js b/desktop/quickshell/Common/Format.js similarity index 100% rename from roles/desktop/files/quickshell/Common/Format.js rename to desktop/quickshell/Common/Format.js diff --git a/roles/desktop/files/quickshell/Common/Fusebox.qml b/desktop/quickshell/Common/Fusebox.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Fusebox.qml rename to desktop/quickshell/Common/Fusebox.qml diff --git a/roles/desktop/files/quickshell/Common/FuseboxHelpers.js b/desktop/quickshell/Common/FuseboxHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/FuseboxHelpers.js rename to desktop/quickshell/Common/FuseboxHelpers.js diff --git a/roles/desktop/files/quickshell/Common/GameMode.qml b/desktop/quickshell/Common/GameMode.qml similarity index 92% rename from roles/desktop/files/quickshell/Common/GameMode.qml rename to desktop/quickshell/Common/GameMode.qml index 390a4c78..22ed7b3b 100644 --- a/roles/desktop/files/quickshell/Common/GameMode.qml +++ b/desktop/quickshell/Common/GameMode.qml @@ -4,7 +4,7 @@ import Quickshell import Quickshell.Io import "GameModeState.js" as GameModeState -// Game mode, read from the state file ~/.local/bin/game-mode keeps in +// Game mode, read from the state file game-mode keeps in // $XDG_RUNTIME_DIR/cybexos. The helper owns the compositor effects and the // idle inhibitor and is bound to Super+Shift+G, so game mode can start without // the shell; watching its state keeps the indicator honest either way. @@ -14,7 +14,7 @@ Singleton { readonly property string stateDir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/cybexos" readonly property string helper: - Quickshell.env("HOME") + "/.local/bin/game-mode" + "game-mode" readonly property string instance: Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE") || "" diff --git a/roles/desktop/files/quickshell/Common/GameModeState.js b/desktop/quickshell/Common/GameModeState.js similarity index 100% rename from roles/desktop/files/quickshell/Common/GameModeState.js rename to desktop/quickshell/Common/GameModeState.js diff --git a/roles/desktop/files/quickshell/Common/GitHub.qml b/desktop/quickshell/Common/GitHub.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/GitHub.qml rename to desktop/quickshell/Common/GitHub.qml diff --git a/roles/desktop/files/quickshell/Common/GitHubHelpers.js b/desktop/quickshell/Common/GitHubHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/GitHubHelpers.js rename to desktop/quickshell/Common/GitHubHelpers.js diff --git a/roles/desktop/files/quickshell/Common/GitHubQueue.js b/desktop/quickshell/Common/GitHubQueue.js similarity index 100% rename from roles/desktop/files/quickshell/Common/GitHubQueue.js rename to desktop/quickshell/Common/GitHubQueue.js diff --git a/roles/desktop/files/quickshell/Common/HSlider.qml b/desktop/quickshell/Common/HSlider.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HSlider.qml rename to desktop/quickshell/Common/HSlider.qml diff --git a/roles/desktop/files/quickshell/Common/Hermes.qml b/desktop/quickshell/Common/Hermes.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/Hermes.qml rename to desktop/quickshell/Common/Hermes.qml index 91a62c71..9523b950 100644 --- a/roles/desktop/files/quickshell/Common/Hermes.qml +++ b/desktop/quickshell/Common/Hermes.qml @@ -147,7 +147,7 @@ Singleton { const home = Quickshell.env("HOME"); Quickshell.execDetached([ "kitty", "--title", "Hermes model setup", - home + "/.local/bin/hermes", "model" + "hermes", "model" ]); } diff --git a/roles/desktop/files/quickshell/Common/HermesConnection.qml b/desktop/quickshell/Common/HermesConnection.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesConnection.qml rename to desktop/quickshell/Common/HermesConnection.qml diff --git a/roles/desktop/files/quickshell/Common/HermesConversations.qml b/desktop/quickshell/Common/HermesConversations.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesConversations.qml rename to desktop/quickshell/Common/HermesConversations.qml diff --git a/roles/desktop/files/quickshell/Common/HermesHelpers.js b/desktop/quickshell/Common/HermesHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesHelpers.js rename to desktop/quickshell/Common/HermesHelpers.js diff --git a/roles/desktop/files/quickshell/Common/HermesRpc.qml b/desktop/quickshell/Common/HermesRpc.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesRpc.qml rename to desktop/quickshell/Common/HermesRpc.qml diff --git a/roles/desktop/files/quickshell/Common/HermesSocket.qml b/desktop/quickshell/Common/HermesSocket.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesSocket.qml rename to desktop/quickshell/Common/HermesSocket.qml diff --git a/roles/desktop/files/quickshell/Common/HermesTheme.qml b/desktop/quickshell/Common/HermesTheme.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HermesTheme.qml rename to desktop/quickshell/Common/HermesTheme.qml diff --git a/roles/desktop/files/quickshell/Common/HugCorner.qml b/desktop/quickshell/Common/HugCorner.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/HugCorner.qml rename to desktop/quickshell/Common/HugCorner.qml diff --git a/roles/desktop/files/quickshell/Common/I18n.qml b/desktop/quickshell/Common/I18n.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/I18n.qml rename to desktop/quickshell/Common/I18n.qml diff --git a/roles/desktop/files/quickshell/Common/I18nHelpers.js b/desktop/quickshell/Common/I18nHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/I18nHelpers.js rename to desktop/quickshell/Common/I18nHelpers.js diff --git a/roles/desktop/files/quickshell/Common/KeybindHelpers.js b/desktop/quickshell/Common/KeybindHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/KeybindHelpers.js rename to desktop/quickshell/Common/KeybindHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Launcher.qml b/desktop/quickshell/Common/Launcher.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Launcher.qml rename to desktop/quickshell/Common/Launcher.qml diff --git a/roles/desktop/files/quickshell/Common/LauncherNavigation.js b/desktop/quickshell/Common/LauncherNavigation.js similarity index 100% rename from roles/desktop/files/quickshell/Common/LauncherNavigation.js rename to desktop/quickshell/Common/LauncherNavigation.js diff --git a/roles/desktop/files/quickshell/Common/LauncherProviders.js b/desktop/quickshell/Common/LauncherProviders.js similarity index 96% rename from roles/desktop/files/quickshell/Common/LauncherProviders.js rename to desktop/quickshell/Common/LauncherProviders.js index deea1cf0..f6d97bba 100644 --- a/roles/desktop/files/quickshell/Common/LauncherProviders.js +++ b/desktop/quickshell/Common/LauncherProviders.js @@ -32,9 +32,8 @@ var VERBS = [ { id: "system", label: "System" } ]; -// Commands start from the user's ~/.local/bin, where both installation paths -// put the CybexOS helpers (the ISO links them from /usr/share/cybexos/bin); -// the shell service's PATH need not include it. "{argument}" is replaced by +// Commands resolve packaged helpers through the managed desktop's PATH. +// "{argument}" is replaced by // the whole typed argument as one argv element, never spliced into a word. // // An entry is one of: @@ -63,7 +62,7 @@ var BUILTIN_ACTIONS = [ hint: "Type a web address, then press Enter", submit: "Install web app from" }, - command: ["~/.local/bin/cybexos-webapp", "install", "--notify", + command: ["cybexos-webapp", "install", "--notify", "--url", "{argument}"] }, { @@ -80,7 +79,7 @@ var BUILTIN_ACTIONS = [ hint: "Type an https or SSH Git address, then press Enter", submit: "Install theme from" }, - command: ["~/.local/bin/cybex", "theme", "install", "{argument}"] + command: ["cybex", "theme", "install", "{argument}"] }, { id: "webapp-remove", @@ -90,7 +89,7 @@ var BUILTIN_ACTIONS = [ keywords: ["webapp", "uninstall", "delete", "pwa", "web"], glyph: "trash", choices: "webapps", - command: ["~/.local/bin/cybexos-webapp", "remove", "--notify", "{argument}"] + command: ["cybexos-webapp", "remove", "--notify", "{argument}"] }, { id: "security-fingerprint", @@ -127,7 +126,7 @@ var BUILTIN_ACTIONS = [ keywords: ["ai", "default", "provider", "model", "agent", "code"], glyph: "robot", requires: "developerTools", - command: ["~/.local/bin/cybex", "agent", "--window", "--pick"] + command: ["cybex", "agent", "--window", "--pick"] }, { id: "game-mode", @@ -136,7 +135,7 @@ var BUILTIN_ACTIONS = [ subtitle: "Turn off effects and notifications while playing", keywords: ["gaming", "games", "steam", "performance", "fullscreen"], glyph: "device-gamepad-2", - command: ["~/.local/bin/game-mode", "toggle"] + command: ["game-mode", "toggle"] }, { id: "dnd-toggle", @@ -150,10 +149,19 @@ var BUILTIN_ACTIONS = [ id: "updates-check", verb: "update", name: "Check for updates", - subtitle: "Look for CybexOS, package and firmware updates", - keywords: ["upgrade", "dnf", "flatpak", "firmware", "update"], + subtitle: "Look for CybexOS and NixOS package updates", + keywords: ["upgrade", "nix", "update"], glyph: "refresh", - command: ["~/.local/bin/cybexos-runtime", "ipc", "updates", "check"] + command: ["cybexos-runtime", "ipc", "updates", "check"] + }, + { + id: "firmware-update", + verb: "update", + name: "Review device firmware…", + subtitle: "Check signed device firmware and confirm installation separately", + keywords: ["firmware", "bios", "uefi", "device", "update"], + glyph: "refresh", + command: ["kitty", "--hold", "cybexos-firmware-update", "interactive"] }, { id: "updates-open", @@ -162,7 +170,7 @@ var BUILTIN_ACTIONS = [ subtitle: "Show pending updates and their progress", keywords: ["upgrade", "install", "update", "pending"], glyph: "circle-arrow-up", - command: ["~/.local/bin/cybexos-runtime", "ipc", "updates", "open"] + command: ["cybexos-runtime", "ipc", "updates", "open"] }, { id: "screenshot-region", @@ -172,7 +180,7 @@ var BUILTIN_ACTIONS = [ keywords: ["screenshot", "capture", "screen", "print"], glyph: "camera", settle: true, - command: ["~/.local/bin/screenshot", "region"] + command: ["screenshot", "region"] }, { id: "screenshot-fullscreen", @@ -182,7 +190,7 @@ var BUILTIN_ACTIONS = [ keywords: ["screenshot", "capture", "screen", "print", "monitor"], glyph: "device-desktop", settle: true, - command: ["~/.local/bin/screenshot", "fullscreen"] + command: ["screenshot", "fullscreen"] }, { id: "screenshot-scroll", @@ -192,7 +200,7 @@ var BUILTIN_ACTIONS = [ keywords: ["screenshot", "capture", "scroll", "long", "page"], glyph: "arrow-down", settle: true, - command: ["~/.local/bin/screenshot", "scroll"] + command: ["screenshot", "scroll"] }, { id: "screen-record", @@ -202,7 +210,7 @@ var BUILTIN_ACTIONS = [ keywords: ["record", "recording", "video", "screencast", "capture"], glyph: "video", settle: true, - command: ["~/.local/bin/screen-record"] + command: ["screen-record"] }, { id: "screen-record-webcam", @@ -212,7 +220,7 @@ var BUILTIN_ACTIONS = [ keywords: ["record", "recording", "video", "camera", "webcam", "capture"], glyph: "video", settle: true, - command: ["~/.local/bin/screen-record", "--webcam"] + command: ["screen-record", "--webcam"] }, { id: "color-picker", @@ -222,7 +230,7 @@ var BUILTIN_ACTIONS = [ keywords: ["color", "colour", "picker", "eyedropper", "hex"], glyph: "droplet-half-2", settle: true, - command: ["~/.local/bin/color-picker"] + command: ["color-picker"] }, { id: "screen-ocr", @@ -232,7 +240,7 @@ var BUILTIN_ACTIONS = [ keywords: ["ocr", "text", "qr", "scan", "capture"], glyph: "qrcode", settle: true, - command: ["~/.local/bin/screen-ocr"] + command: ["screen-ocr"] }, { id: "settings", @@ -258,7 +266,7 @@ var BUILTIN_ACTIONS = [ subtitle: "Start a backup with the saved settings", keywords: ["backup", "copy", "save"], glyph: "archive", - command: ["~/.local/bin/cybex", "backup", "now"] + command: ["cybex", "backup", "now"] }, { id: "overview", @@ -267,7 +275,7 @@ var BUILTIN_ACTIONS = [ subtitle: "See every window and workspace", keywords: ["windows", "workspaces", "expose", "mission"], glyph: "layout-grid", - command: ["~/.local/bin/cybexos-runtime", "ipc", "overview", "open"] + command: ["cybexos-runtime", "ipc", "overview", "open"] }, { id: "ai-agent-launch", @@ -277,7 +285,7 @@ var BUILTIN_ACTIONS = [ keywords: ["ai", "code", "assistant", "terminal", "agent"], glyph: "robot", requires: "developerTools", - command: ["~/.local/bin/cybex", "agent", "--window"] + command: ["cybex", "agent", "--window"] }, { id: "wallpaper-shuffle", @@ -310,7 +318,7 @@ var BUILTIN_ACTIONS = [ subtitle: "Collect diagnostics to share with CybexOS", keywords: ["bug", "issue", "feedback", "diagnostics", "support"], glyph: "message-circle-exclamation", - command: ["~/.local/bin/cybex", "report"] + command: ["cybex", "report"] }, { id: "recovery", diff --git a/roles/desktop/files/quickshell/Common/LauncherProviders.qml b/desktop/quickshell/Common/LauncherProviders.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/LauncherProviders.qml rename to desktop/quickshell/Common/LauncherProviders.qml index 5a458451..842c305c 100644 --- a/roles/desktop/files/quickshell/Common/LauncherProviders.qml +++ b/desktop/quickshell/Common/LauncherProviders.qml @@ -777,7 +777,7 @@ Singleton { id: webAppListProc property bool exitSeen: false property int lastExit: 0 - command: [root.home + "/.local/bin/cybexos-webapp", "list", "--json"] + command: ["cybexos-webapp", "list", "--json"] stdout: StdioCollector { onStreamFinished: root.webApps = ProviderHelpers.parseWebApps(text) } @@ -873,12 +873,12 @@ Singleton { ClipboardWatcher { watch: "wl-paste --type text --watch " - + Quickshell.env("HOME") + "/.local/bin/clipboard-history-store" + + "clipboard-history-store" } ClipboardWatcher { watch: "wl-paste --type image --watch " - + Quickshell.env("HOME") + "/.local/bin/clipboard-history-store" + + "clipboard-history-store" } Connections { @@ -897,7 +897,7 @@ Singleton { FileView { id: emojiFile - path: root.emojiRequested ? "/usr/share/unicode/emoji/emoji-test.txt" : "" + path: root.emojiRequested ? "/etc/cybexos/emoji-test.txt" : "" printErrors: false onLoaded: { root.emojiEntries = ProviderHelpers.parseEmojiData(text()); diff --git a/roles/desktop/files/quickshell/Common/LayoutHelpers.js b/desktop/quickshell/Common/LayoutHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/LayoutHelpers.js rename to desktop/quickshell/Common/LayoutHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Location/LocationSearch.qml b/desktop/quickshell/Common/Location/LocationSearch.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Location/LocationSearch.qml rename to desktop/quickshell/Common/Location/LocationSearch.qml diff --git a/roles/desktop/files/quickshell/Common/Location/qmldir b/desktop/quickshell/Common/Location/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Common/Location/qmldir rename to desktop/quickshell/Common/Location/qmldir diff --git a/roles/desktop/files/quickshell/Common/LocationHelpers.js b/desktop/quickshell/Common/LocationHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/LocationHelpers.js rename to desktop/quickshell/Common/LocationHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Media.qml b/desktop/quickshell/Common/Media.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Media.qml rename to desktop/quickshell/Common/Media.qml diff --git a/roles/desktop/files/quickshell/Common/ModelUsageHub.qml b/desktop/quickshell/Common/ModelUsageHub.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/ModelUsageHub.qml rename to desktop/quickshell/Common/ModelUsageHub.qml diff --git a/roles/desktop/files/quickshell/Common/NetworkDetails.qml b/desktop/quickshell/Common/NetworkDetails.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkDetails.qml rename to desktop/quickshell/Common/NetworkDetails.qml diff --git a/roles/desktop/files/quickshell/Common/NetworkDetailsHelpers.js b/desktop/quickshell/Common/NetworkDetailsHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkDetailsHelpers.js rename to desktop/quickshell/Common/NetworkDetailsHelpers.js diff --git a/roles/desktop/files/quickshell/Common/NetworkHelpers.js b/desktop/quickshell/Common/NetworkHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkHelpers.js rename to desktop/quickshell/Common/NetworkHelpers.js diff --git a/roles/desktop/files/quickshell/Common/NetworkOverlayState.qml b/desktop/quickshell/Common/NetworkOverlayState.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkOverlayState.qml rename to desktop/quickshell/Common/NetworkOverlayState.qml diff --git a/roles/desktop/files/quickshell/Common/NetworkStatus.qml b/desktop/quickshell/Common/NetworkStatus.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkStatus.qml rename to desktop/quickshell/Common/NetworkStatus.qml diff --git a/roles/desktop/files/quickshell/Common/NetworkStatusHelpers.js b/desktop/quickshell/Common/NetworkStatusHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NetworkStatusHelpers.js rename to desktop/quickshell/Common/NetworkStatusHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Notes.qml b/desktop/quickshell/Common/Notes.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Notes.qml rename to desktop/quickshell/Common/Notes.qml diff --git a/roles/desktop/files/quickshell/Common/NotesHelpers.js b/desktop/quickshell/Common/NotesHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NotesHelpers.js rename to desktop/quickshell/Common/NotesHelpers.js diff --git a/roles/desktop/files/quickshell/Common/NotifActions.qml b/desktop/quickshell/Common/NotifActions.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifActions.qml rename to desktop/quickshell/Common/NotifActions.qml diff --git a/roles/desktop/files/quickshell/Common/NotifButton.qml b/desktop/quickshell/Common/NotifButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifButton.qml rename to desktop/quickshell/Common/NotifButton.qml diff --git a/roles/desktop/files/quickshell/Common/NotifCard.qml b/desktop/quickshell/Common/NotifCard.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifCard.qml rename to desktop/quickshell/Common/NotifCard.qml diff --git a/roles/desktop/files/quickshell/Common/NotifHelpers.js b/desktop/quickshell/Common/NotifHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifHelpers.js rename to desktop/quickshell/Common/NotifHelpers.js diff --git a/roles/desktop/files/quickshell/Common/NotifHolds.js b/desktop/quickshell/Common/NotifHolds.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifHolds.js rename to desktop/quickshell/Common/NotifHolds.js diff --git a/roles/desktop/files/quickshell/Common/NotifIcon.qml b/desktop/quickshell/Common/NotifIcon.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifIcon.qml rename to desktop/quickshell/Common/NotifIcon.qml diff --git a/roles/desktop/files/quickshell/Common/NotifImageFingerprint.qml b/desktop/quickshell/Common/NotifImageFingerprint.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifImageFingerprint.qml rename to desktop/quickshell/Common/NotifImageFingerprint.qml diff --git a/roles/desktop/files/quickshell/Common/NotifPolicy.js b/desktop/quickshell/Common/NotifPolicy.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifPolicy.js rename to desktop/quickshell/Common/NotifPolicy.js diff --git a/roles/desktop/files/quickshell/Common/NotifSmart.js b/desktop/quickshell/Common/NotifSmart.js similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifSmart.js rename to desktop/quickshell/Common/NotifSmart.js diff --git a/roles/desktop/files/quickshell/Common/NotifSnoozes.qml b/desktop/quickshell/Common/NotifSnoozes.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/NotifSnoozes.qml rename to desktop/quickshell/Common/NotifSnoozes.qml diff --git a/roles/desktop/files/quickshell/Common/Notifs.qml b/desktop/quickshell/Common/Notifs.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Notifs.qml rename to desktop/quickshell/Common/Notifs.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyApplications.qml b/desktop/quickshell/Common/OmarchyApplications.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OmarchyApplications.qml rename to desktop/quickshell/Common/OmarchyApplications.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyBarApi.qml b/desktop/quickshell/Common/OmarchyBarApi.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OmarchyBarApi.qml rename to desktop/quickshell/Common/OmarchyBarApi.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyEntryHost.qml b/desktop/quickshell/Common/OmarchyEntryHost.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OmarchyEntryHost.qml rename to desktop/quickshell/Common/OmarchyEntryHost.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyPluginApi.qml b/desktop/quickshell/Common/OmarchyPluginApi.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OmarchyPluginApi.qml rename to desktop/quickshell/Common/OmarchyPluginApi.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyPlugins.qml b/desktop/quickshell/Common/OmarchyPlugins.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/OmarchyPlugins.qml rename to desktop/quickshell/Common/OmarchyPlugins.qml index 034e25b9..cc56b4f6 100644 --- a/roles/desktop/files/quickshell/Common/OmarchyPlugins.qml +++ b/desktop/quickshell/Common/OmarchyPlugins.qml @@ -507,7 +507,10 @@ Singleton { function hide(id: string): void { root.hide(id); } function toggle(id: string, payloadJson: string): void { root.toggle(id, payloadJson); } function call(id: string, method: string, arg: string): string { return root.call(id, method, arg); } - function listPlugins(): string { return JSON.stringify({ plugins: UserPlugins.plugins, errors: root.errors }); } + function listPlugins(): string { + return JSON.stringify({ plugins: UserPlugins.plugins, errors: root.errors, + discoveryError: UserPlugins.discoveryError, discovering: UserPlugins.discoveryBusy }); + } function rescanPlugins(): string { UserPlugins.refresh(); return "ok"; } function setPluginEnabled(id: string, enabled: bool): string { if (!UserPlugins.plugins.some(item => item.id === id)) return "unknown"; diff --git a/roles/desktop/files/quickshell/Common/OmarchyServiceApi.qml b/desktop/quickshell/Common/OmarchyServiceApi.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OmarchyServiceApi.qml rename to desktop/quickshell/Common/OmarchyServiceApi.qml diff --git a/roles/desktop/files/quickshell/Common/OmarchyTheme.js b/desktop/quickshell/Common/OmarchyTheme.js similarity index 100% rename from roles/desktop/files/quickshell/Common/OmarchyTheme.js rename to desktop/quickshell/Common/OmarchyTheme.js diff --git a/roles/desktop/files/quickshell/Common/OnlineWallpapers.qml b/desktop/quickshell/Common/OnlineWallpapers.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/OnlineWallpapers.qml rename to desktop/quickshell/Common/OnlineWallpapers.qml diff --git a/roles/desktop/files/quickshell/Common/Osd.qml b/desktop/quickshell/Common/Osd.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Osd.qml rename to desktop/quickshell/Common/Osd.qml diff --git a/roles/desktop/files/quickshell/Common/Overview.qml b/desktop/quickshell/Common/Overview.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Overview.qml rename to desktop/quickshell/Common/Overview.qml diff --git a/roles/desktop/files/quickshell/Common/OverviewHelpers.js b/desktop/quickshell/Common/OverviewHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/OverviewHelpers.js rename to desktop/quickshell/Common/OverviewHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Palette.qml b/desktop/quickshell/Common/Palette.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Palette.qml rename to desktop/quickshell/Common/Palette.qml diff --git a/roles/desktop/files/quickshell/Common/PaletteHelpers.js b/desktop/quickshell/Common/PaletteHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/PaletteHelpers.js rename to desktop/quickshell/Common/PaletteHelpers.js diff --git a/roles/desktop/files/quickshell/Common/PanelRegistryData.js b/desktop/quickshell/Common/PanelRegistryData.js similarity index 100% rename from roles/desktop/files/quickshell/Common/PanelRegistryData.js rename to desktop/quickshell/Common/PanelRegistryData.js diff --git a/roles/desktop/files/quickshell/Common/Persistence/SettingsDocument.qml b/desktop/quickshell/Common/Persistence/SettingsDocument.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Persistence/SettingsDocument.qml rename to desktop/quickshell/Common/Persistence/SettingsDocument.qml diff --git a/roles/desktop/files/quickshell/Common/Persistence/qmldir b/desktop/quickshell/Common/Persistence/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Common/Persistence/qmldir rename to desktop/quickshell/Common/Persistence/qmldir diff --git a/roles/desktop/files/quickshell/Common/PluginSchema.js b/desktop/quickshell/Common/PluginSchema.js similarity index 100% rename from roles/desktop/files/quickshell/Common/PluginSchema.js rename to desktop/quickshell/Common/PluginSchema.js diff --git a/roles/desktop/files/quickshell/Common/PolkitHelpers.js b/desktop/quickshell/Common/PolkitHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/PolkitHelpers.js rename to desktop/quickshell/Common/PolkitHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Popouts.qml b/desktop/quickshell/Common/Popouts.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Popouts.qml rename to desktop/quickshell/Common/Popouts.qml diff --git a/roles/desktop/files/quickshell/Common/ProcHelpers.js b/desktop/quickshell/Common/ProcHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/ProcHelpers.js rename to desktop/quickshell/Common/ProcHelpers.js diff --git a/roles/desktop/files/quickshell/Common/ProcessList.qml b/desktop/quickshell/Common/ProcessList.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/ProcessList.qml rename to desktop/quickshell/Common/ProcessList.qml diff --git a/roles/desktop/files/quickshell/Common/ProcessListHelpers.js b/desktop/quickshell/Common/ProcessListHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/ProcessListHelpers.js rename to desktop/quickshell/Common/ProcessListHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Recorder.qml b/desktop/quickshell/Common/Recorder.qml similarity index 97% rename from roles/desktop/files/quickshell/Common/Recorder.qml rename to desktop/quickshell/Common/Recorder.qml index fb60f078..4a01881d 100644 --- a/roles/desktop/files/quickshell/Common/Recorder.qml +++ b/desktop/quickshell/Common/Recorder.qml @@ -3,14 +3,14 @@ import QtQuick import Quickshell import Quickshell.Io -// Screen-recording state, read from the state files ~/.local/bin/screen-record +// Screen-recording state, read from the state files screen-record // keeps in $XDG_RUNTIME_DIR. // // The shell deliberately does not drive wf-recorder itself: the script already // owns region selection, the freeze overlay, the output path and the saved // notification, and it is bound to a compositor key, so a recording can start // without the shell involved at all. Watching its state is what keeps the bar -// chip honest either way. A scrolling screenshot (~/.local/bin/screenshot +// chip honest either way. A scrolling screenshot (screenshot // scroll) publishes its capture in the same directory and lights the same // chip, and a --webcam recording its area for WebcamOverlay. Singleton { @@ -19,9 +19,9 @@ Singleton { readonly property string stateDir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/screen-record" readonly property string script: - Quickshell.env("HOME") + "/.local/bin/screen-record" + "screen-record" readonly property string screenshotScript: - Quickshell.env("HOME") + "/.local/bin/screenshot" + "screenshot" property bool active: false property int recorderPid: 0 diff --git a/desktop/quickshell/Common/Recovery.qml b/desktop/quickshell/Common/Recovery.qml new file mode 100644 index 00000000..da4ef583 --- /dev/null +++ b/desktop/quickshell/Common/Recovery.qml @@ -0,0 +1,6 @@ +pragma Singleton +import QtQuick +import Quickshell +Singleton { + readonly property bool recoveryBoot: false +} diff --git a/roles/desktop/files/quickshell/Common/Reminders.qml b/desktop/quickshell/Common/Reminders.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/Reminders.qml rename to desktop/quickshell/Common/Reminders.qml index c59e99a1..92fcf4c0 100644 --- a/roles/desktop/files/quickshell/Common/Reminders.qml +++ b/desktop/quickshell/Common/Reminders.qml @@ -11,7 +11,7 @@ Singleton { id: root readonly property string helper: - Quickshell.env("HOME") + "/.local/bin/quickshell-reminder" + "quickshell-reminder" property var records: [] property bool loading: false property bool refreshPending: false diff --git a/roles/desktop/files/quickshell/Common/RemoteServer.qml b/desktop/quickshell/Common/RemoteServer.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/RemoteServer.qml rename to desktop/quickshell/Common/RemoteServer.qml diff --git a/roles/desktop/files/quickshell/Common/RemoteServerHelpers.js b/desktop/quickshell/Common/RemoteServerHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/RemoteServerHelpers.js rename to desktop/quickshell/Common/RemoteServerHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Revealer.qml b/desktop/quickshell/Common/Revealer.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Revealer.qml rename to desktop/quickshell/Common/Revealer.qml diff --git a/roles/desktop/files/quickshell/Common/Screens.qml b/desktop/quickshell/Common/Screens.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Screens.qml rename to desktop/quickshell/Common/Screens.qml diff --git a/roles/desktop/files/quickshell/Common/ScrollChrome.qml b/desktop/quickshell/Common/ScrollChrome.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/ScrollChrome.qml rename to desktop/quickshell/Common/ScrollChrome.qml diff --git a/roles/desktop/files/quickshell/Common/SecurityHelpers.js b/desktop/quickshell/Common/SecurityHelpers.js similarity index 94% rename from roles/desktop/files/quickshell/Common/SecurityHelpers.js rename to desktop/quickshell/Common/SecurityHelpers.js index 1ea7c39a..9fef7d6f 100644 --- a/roles/desktop/files/quickshell/Common/SecurityHelpers.js +++ b/desktop/quickshell/Common/SecurityHelpers.js @@ -1,4 +1,4 @@ -// Settings > Security copy and enrolment parsing (docs/security.md). Kept free +// Settings > Security copy and enrolment parsing (docs/hardware-security.md). Kept free // of Qt APIs so tests/quickshell/security-settings.test.cjs can run it under // Node. The helper scripts report fprintd's own result names; every word a // person reads is chosen here. @@ -35,10 +35,8 @@ function readerLine(state) { function keyLine(state) { if (state === "missing") return "Security keys need the pam-u2f package."; - if (state === "authselect") - return "Security keys are managed with authselect on this computer."; if (state === "inactive") - return "Security keys are not set up yet. Run cybex update, then try again."; + return "Security keys are not enabled in the NixOS authentication policy. Check your personal modules, then preview and apply the change."; return ""; } diff --git a/roles/desktop/files/quickshell/Common/Session.qml b/desktop/quickshell/Common/Session.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/Session.qml rename to desktop/quickshell/Common/Session.qml index d5e1ec04..103796d7 100644 --- a/roles/desktop/files/quickshell/Common/Session.qml +++ b/desktop/quickshell/Common/Session.qml @@ -61,7 +61,7 @@ Singleton { actionError = ""; actionOutput = ""; actionInFlight = action; - sessionAction.command = ["/usr/local/libexec/cybexos-session-action", action]; + sessionAction.command = ["cybexos-session-action", action]; sessionAction.running = true; return true; } diff --git a/roles/desktop/files/quickshell/Common/Settings.qml b/desktop/quickshell/Common/Settings.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Settings.qml rename to desktop/quickshell/Common/Settings.qml diff --git a/roles/desktop/files/quickshell/Common/SettingsHelpers.js b/desktop/quickshell/Common/SettingsHelpers.js similarity index 99% rename from roles/desktop/files/quickshell/Common/SettingsHelpers.js rename to desktop/quickshell/Common/SettingsHelpers.js index 8e3ec42a..de3963b7 100644 --- a/roles/desktop/files/quickshell/Common/SettingsHelpers.js +++ b/desktop/quickshell/Common/SettingsHelpers.js @@ -7,7 +7,7 @@ var BAR_STYLES = ["hug", "floating", "attached"]; var PALETTE_MODES = ["wallpaper", "fixed"]; // The edge drawer's tabs, in default strip order. Overview is the drawer's -// front door — the Fedora button and the session footer live there — so it +// front door — the CybexOS button and the session footer live there — so it // can be reordered but never disabled. var DRAWER_TAB_IDS = ["overview", "sound", "network", "bluetooth", "power", "notifications"]; @@ -34,7 +34,7 @@ var IDLE_SUSPEND_MINS = [0, 15, 30, 60, 120]; // old combined centre pill; schema 11 deliberately introduces the distinct // `notifications` id rather than reviving that historical key. `idle` became // a Control Panel toggle. Schema 24 restores `control` as a configurable -// widget, with the Fedora button's existing rightmost placement by default. +// widget, with the CybexOS button's existing rightmost placement by default. // Schema 25 retired `usage`, the first built-in model usage widget, and its // drawer tab. Schema 26 builds Model Usage in as `modelusage`, vendored from // the digitalpals.model-usage Omarchy plugin (see ModelUsage/README.md). @@ -1155,7 +1155,7 @@ function migrateMods(raw, sourceVersion, context) { var migrated = { left: [], center: [], right: [] }; ["left", "center", "right"].forEach(function(col) { var list = Array.isArray(raw[col]) ? raw[col] : []; - // Before schema 24 the old control entry was ignored and the Fedora + // Before schema 24 the old control entry was ignored and the CybexOS // button always lived on the right. Replace stale historical entries // with that effective placement; later user moves/removals persist. if (typeof sourceVersion !== "number" || sourceVersion < 24) diff --git a/roles/desktop/files/quickshell/Common/SettingsSearchData.js b/desktop/quickshell/Common/SettingsSearchData.js similarity index 99% rename from roles/desktop/files/quickshell/Common/SettingsSearchData.js rename to desktop/quickshell/Common/SettingsSearchData.js index ca6c29a1..ac850c95 100644 --- a/roles/desktop/files/quickshell/Common/SettingsSearchData.js +++ b/desktop/quickshell/Common/SettingsSearchData.js @@ -100,9 +100,9 @@ var ROWS = [ { page: "bar", pageLabel: "Bar", group: "Weather", label: "Weather location", key: "", widget: "weather", terms: "clock city place search country region coordinates latitude longitude forecast" }, { page: "bar", pageLabel: "Bar", group: "Fusebox", label: "Fusebox server", key: "", widget: "fusebox", terms: "ai proxy cliproxy subscriptions accounts management key url faults load requests sessions quota used remaining left meters pace breaker priority clients budgets connected addresses notifications privacy email" }, { page: "bar", pageLabel: "Bar", group: "Model Usage", label: "Model Usage", key: "", widget: "modelusage", terms: "ai claude codex kimi quota limits reset percentage providers cliproxy proxy accounts credentials cost tokens t3 refresh warning critical" }, - { page: "bar", pageLabel: "Bar", group: "Updates", label: "Download updates automatically", key: "", widget: "updates", terms: "background download updates automatic schedule timer dnf flatpak packages metered power check every interval notify" }, + { page: "bar", pageLabel: "Bar", group: "Updates", label: "Download updates automatically", key: "", widget: "updates", terms: "background download updates automatic schedule timer nix packages metered power check every interval notify" }, - { page: "bar", pageLabel: "Bar", group: "Control Center", label: "Control Center widget", key: "", widget: "control", terms: "fedora button move drag reorder show hide drawer" }, + { page: "bar", pageLabel: "Bar", group: "Control Center", label: "Control Center widget", key: "", widget: "control", terms: "cybexos button move drag reorder show hide drawer" }, // Control Center (also searchable by its former Drawer name) { page: "bar", pageLabel: "Bar", widget: "control", group: "Tabs", label: "Tab order", key: "", terms: "drawer reorder overview sound network bluetooth power notifications usage" }, diff --git a/roles/desktop/files/quickshell/Common/ShellHealth.qml b/desktop/quickshell/Common/ShellHealth.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/ShellHealth.qml rename to desktop/quickshell/Common/ShellHealth.qml index 807c433c..aa9e3b36 100644 --- a/roles/desktop/files/quickshell/Common/ShellHealth.qml +++ b/desktop/quickshell/Common/ShellHealth.qml @@ -138,7 +138,7 @@ Singleton { property string body: "" property bool exitSeen: false property int lastExit: -1 - command: ["/usr/bin/python3", root.helper] + command: ["python3", root.helper] stdout: StdioCollector { onStreamFinished: probe.body = text } stderr: StdioCollector {} onStarted: { diff --git a/roles/desktop/files/quickshell/Common/ShellMetrics.js b/desktop/quickshell/Common/ShellMetrics.js similarity index 100% rename from roles/desktop/files/quickshell/Common/ShellMetrics.js rename to desktop/quickshell/Common/ShellMetrics.js diff --git a/roles/desktop/files/quickshell/Common/SocketContract.qml b/desktop/quickshell/Common/SocketContract.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/SocketContract.qml rename to desktop/quickshell/Common/SocketContract.qml diff --git a/roles/desktop/files/quickshell/Common/Startup.qml b/desktop/quickshell/Common/Startup.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Startup.qml rename to desktop/quickshell/Common/Startup.qml diff --git a/roles/desktop/files/quickshell/Common/StateLayer.qml b/desktop/quickshell/Common/StateLayer.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/StateLayer.qml rename to desktop/quickshell/Common/StateLayer.qml diff --git a/roles/desktop/files/quickshell/Common/StatusHelpers.js b/desktop/quickshell/Common/StatusHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/StatusHelpers.js rename to desktop/quickshell/Common/StatusHelpers.js diff --git a/roles/desktop/files/quickshell/Common/StatusPlaceholder.qml b/desktop/quickshell/Common/StatusPlaceholder.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/StatusPlaceholder.qml rename to desktop/quickshell/Common/StatusPlaceholder.qml diff --git a/roles/desktop/files/quickshell/Common/Sym.qml b/desktop/quickshell/Common/Sym.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Sym.qml rename to desktop/quickshell/Common/Sym.qml diff --git a/roles/desktop/files/quickshell/Common/SysInfo.qml b/desktop/quickshell/Common/SysInfo.qml similarity index 99% rename from roles/desktop/files/quickshell/Common/SysInfo.qml rename to desktop/quickshell/Common/SysInfo.qml index 0b138479..c58492a2 100644 --- a/roles/desktop/files/quickshell/Common/SysInfo.qml +++ b/desktop/quickshell/Common/SysInfo.qml @@ -772,7 +772,7 @@ Singleton { // the Studio Display it reported the closed lid's backlight, and writing // brightnessctl moved that panel instead of the one being looked at. readonly property string brightnessTool: - Quickshell.env("HOME") + "/.local/bin/brightness-control" + "brightness-control" // The reading follows the pointer immediately so the slider stays live // under a drag; the process that writes it is coalesced onto the last diff --git a/roles/desktop/files/quickshell/Common/SysInfoHelpers.js b/desktop/quickshell/Common/SysInfoHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/SysInfoHelpers.js rename to desktop/quickshell/Common/SysInfoHelpers.js diff --git a/roles/desktop/files/quickshell/Common/SystemSettings.qml b/desktop/quickshell/Common/SystemSettings.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/SystemSettings.qml rename to desktop/quickshell/Common/SystemSettings.qml diff --git a/roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml b/desktop/quickshell/Common/SystemSettingsBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/SystemSettingsBackend.qml rename to desktop/quickshell/Common/SystemSettingsBackend.qml diff --git a/roles/desktop/files/quickshell/Common/SystemTheme.qml b/desktop/quickshell/Common/SystemTheme.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/SystemTheme.qml rename to desktop/quickshell/Common/SystemTheme.qml diff --git a/roles/desktop/files/quickshell/Common/T3Actions.qml b/desktop/quickshell/Common/T3Actions.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Actions.qml rename to desktop/quickshell/Common/T3Actions.qml diff --git a/roles/desktop/files/quickshell/Common/T3Code.qml b/desktop/quickshell/Common/T3Code.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Code.qml rename to desktop/quickshell/Common/T3Code.qml diff --git a/roles/desktop/files/quickshell/Common/T3CodeHelpers.js b/desktop/quickshell/Common/T3CodeHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/T3CodeHelpers.js rename to desktop/quickshell/Common/T3CodeHelpers.js diff --git a/roles/desktop/files/quickshell/Common/T3Connection.qml b/desktop/quickshell/Common/T3Connection.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Connection.qml rename to desktop/quickshell/Common/T3Connection.qml diff --git a/roles/desktop/files/quickshell/Common/T3Detail.qml b/desktop/quickshell/Common/T3Detail.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Detail.qml rename to desktop/quickshell/Common/T3Detail.qml diff --git a/roles/desktop/files/quickshell/Common/T3Drafts.qml b/desktop/quickshell/Common/T3Drafts.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Drafts.qml rename to desktop/quickshell/Common/T3Drafts.qml diff --git a/roles/desktop/files/quickshell/Common/T3Favorites.qml b/desktop/quickshell/Common/T3Favorites.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Favorites.qml rename to desktop/quickshell/Common/T3Favorites.qml diff --git a/roles/desktop/files/quickshell/Common/T3Git.qml b/desktop/quickshell/Common/T3Git.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Git.qml rename to desktop/quickshell/Common/T3Git.qml diff --git a/roles/desktop/files/quickshell/Common/T3Rpc.qml b/desktop/quickshell/Common/T3Rpc.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Rpc.qml rename to desktop/quickshell/Common/T3Rpc.qml diff --git a/roles/desktop/files/quickshell/Common/T3Socket.qml b/desktop/quickshell/Common/T3Socket.qml similarity index 95% rename from roles/desktop/files/quickshell/Common/T3Socket.qml rename to desktop/quickshell/Common/T3Socket.qml index 03d1e2b7..0ecb81ef 100644 --- a/roles/desktop/files/quickshell/Common/T3Socket.qml +++ b/desktop/quickshell/Common/T3Socket.qml @@ -2,7 +2,7 @@ import QtQuick import QtWebSockets // Thin WebSocket wrapper loaded dynamically by T3Code so a machine -// without the QtWebSockets QML module (qt6-qtwebsockets-devel) degrades +// without the QtWebSockets QML module degrades // to an offline chip instead of taking down the whole shell. Item { id: root diff --git a/roles/desktop/files/quickshell/Common/T3Theme.qml b/desktop/quickshell/Common/T3Theme.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Theme.qml rename to desktop/quickshell/Common/T3Theme.qml diff --git a/roles/desktop/files/quickshell/Common/T3Threads.qml b/desktop/quickshell/Common/T3Threads.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/T3Threads.qml rename to desktop/quickshell/Common/T3Threads.qml diff --git a/roles/desktop/files/quickshell/Common/TablerGlyphs.js b/desktop/quickshell/Common/TablerGlyphs.js similarity index 100% rename from roles/desktop/files/quickshell/Common/TablerGlyphs.js rename to desktop/quickshell/Common/TablerGlyphs.js diff --git a/roles/desktop/files/quickshell/Common/TablerIcons.qml b/desktop/quickshell/Common/TablerIcons.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/TablerIcons.qml rename to desktop/quickshell/Common/TablerIcons.qml diff --git a/roles/desktop/files/quickshell/Common/Tailscale.qml b/desktop/quickshell/Common/Tailscale.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Tailscale.qml rename to desktop/quickshell/Common/Tailscale.qml diff --git a/roles/desktop/files/quickshell/Common/TailscaleHelpers.js b/desktop/quickshell/Common/TailscaleHelpers.js similarity index 95% rename from roles/desktop/files/quickshell/Common/TailscaleHelpers.js rename to desktop/quickshell/Common/TailscaleHelpers.js index 09365231..8a814611 100644 --- a/roles/desktop/files/quickshell/Common/TailscaleHelpers.js +++ b/desktop/quickshell/Common/TailscaleHelpers.js @@ -49,14 +49,14 @@ function errorMessage(code, message, privileged) { function command(up, privileged, login) { // The timeout runs at the same privilege level as tailscale, so even a // shell restart cannot leave a root-owned login command waiting forever. - var args = ["/usr/bin/timeout", "--kill-after=2s", "20s", "/usr/bin/tailscale"]; + var args = ["timeout", "--kill-after=2s", "20s", "tailscale"]; // A bare up is Tailscale's special reconnect path: adding even --json // makes it reject existing non-default preferences. Only new sign-ins // use JSON; a reconnect that discovers expired credentials also emits // its HTTPS login URL on stderr, which the caller handles separately. args = args.concat(!up ? ["down"] : login === false ? ["up"] : ["up", "--json", "--timeout=15s"]); - return privileged ? ["/usr/bin/pkexec"].concat(args) : args; + return privileged ? ["pkexec"].concat(args) : args; } if (typeof module !== "undefined") diff --git a/roles/desktop/files/quickshell/Common/Theme.qml b/desktop/quickshell/Common/Theme.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/Theme.qml rename to desktop/quickshell/Common/Theme.qml index 1062a1a3..2573acf6 100644 --- a/roles/desktop/files/quickshell/Common/Theme.qml +++ b/desktop/quickshell/Common/Theme.qml @@ -399,14 +399,7 @@ Singleton { readonly property color okBgSoft: Qt.rgba(okSeed.r, okSeed.g, okSeed.b, dark ? 0.10 : 0.08) readonly property color okBorder: Qt.rgba(okSeed.r, okSeed.g, okSeed.b, 0.30) - // The update run's transaction feed. The tags keep the terminal update - // script's cyan/pink source identity, recalibrated for the panel; `well` - // is the one recessed console surface, the only fill that sits below the - // tile instead of above it. - readonly property color feedDnf: SettingsHelpers.ensureContrast( - dark ? "#6cc7ec" : "#1d7fae", copyReferenceBg.toString(), 4.5) - readonly property color feedFlatpak: SettingsHelpers.ensureContrast( - dark ? "#e98fd2" : "#a83d8a", copyReferenceBg.toString(), 4.5) + // The recessed console surface sits below the surrounding tiles. readonly property color well: dark ? Qt.rgba(16 / 255, 14 / 255, 28 / 255, 0.55) : Qt.rgba(24 / 255, 22 / 255, 44 / 255, 0.05) @@ -546,7 +539,7 @@ Singleton { readonly property int pickerRowHeight: scaled(40) // A panel takes the shared surface corner and everything inside it takes the bar's // chip corner. `surfaceRadius` stays where it is: it is the compositor's - // window rounding (roles/desktop/templates/looknfeel.lua.j2) and the Hug corners + // window rounding (desktop/hypr/looknfeel.lua) and the Hug corners // that have to match it, not a shell-internal design choice. readonly property int popRadius: panelRadius readonly property int cardRadius: chipRadius diff --git a/roles/desktop/files/quickshell/Common/ThemeHelpers.js b/desktop/quickshell/Common/ThemeHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/ThemeHelpers.js rename to desktop/quickshell/Common/ThemeHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Themes.qml b/desktop/quickshell/Common/Themes.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Themes.qml rename to desktop/quickshell/Common/Themes.qml diff --git a/roles/desktop/files/quickshell/Common/Toggle.qml b/desktop/quickshell/Common/Toggle.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Toggle.qml rename to desktop/quickshell/Common/Toggle.qml diff --git a/roles/desktop/files/quickshell/Common/Typography.js b/desktop/quickshell/Common/Typography.js similarity index 100% rename from roles/desktop/files/quickshell/Common/Typography.js rename to desktop/quickshell/Common/Typography.js diff --git a/desktop/quickshell/Common/Updates.qml b/desktop/quickshell/Common/Updates.qml new file mode 100644 index 00000000..12384dea --- /dev/null +++ b/desktop/quickshell/Common/Updates.qml @@ -0,0 +1,70 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +// A client of the durable NixOS lifecycle. Closing/reloading the shell never +// owns or interrupts the systemd worker. Every apply names the reviewed build. +Singleton { + id: root + property var sources: ({upstream:{}, cybexos:{}}) + property var deployment: ({state:"idle"}) + readonly property bool busy: checker.running + readonly property bool runActive: ["discovering", "building", "validating", "activating", "recovering"].includes(deployment.state) + readonly property string runState: runActive ? "running" : deployment.state === "done" ? "done" + : ["failed", "interrupted", "rolled-back", "recovery-failed"].includes(deployment.state) ? "failed" : "idle" + readonly property string error: [sources.upstream.error || "", sources.cybexos.error || "", deployment.error || ""].filter(x => x).join("\n") + readonly property string packageError: sources.upstream.error || "" + readonly property int upstreamCount: sources.upstream.state === "available" ? 1 : 0 + readonly property bool projectAvailable: sources.cybexos.state === "available" + readonly property string projectVersion: sources.cybexos.available || "" + readonly property string installedVersion: sources.cybexos.installed || deployment.installedVersion || "" + readonly property string projectNotes: deployment.notes || sources.cybexos.notes || "" + readonly property int total: upstreamCount + (projectAvailable ? 1 : 0) + readonly property string summary: busy ? "Checking update sources…" : runActive ? runPhaseLabel + : total > 0 ? "Updates available" : error ? "Some update sources could not be checked" : "No updates reported" + readonly property string checkedLabel: checkedAt ? Qt.formatTime(checkedAt, "HH:mm") : "Not checked" + property var checkedAt: null + readonly property int runPercent: -1 + readonly property int runElapsed: deployment.phaseElapsedSeconds || 0 + readonly property bool runCancelled: false + readonly property string runPhaseLabel: deployment.phase || "" + readonly property string failMessage: deployment.error || deployment.phase || "" + readonly property bool rebootRecommended: ["awaiting-boot", "awaiting-login"].includes(deployment.state) + readonly property string rebootRecommendation: rebootRecommended ? "Reboot when ready, then sign in to confirm health" : "" + readonly property bool kernelPending: rebootRecommended + readonly property bool ready: deployment.state === "ready" + property bool detailsOpen: false + + function parse(text) { try { return JSON.parse(text); } catch (_) { return null; } } + function check() { if (!checker.running) checker.running = true; } + function request(args) { if (!requester.running) { requester.command = ["cybex", "update"].concat(args); requester.running = true; } } + function stage(scope) { request(["stage", scope]); } + function run() { stage("both"); } + function apply() { if (ready) request(["apply", deployment.transaction, deployment.reviewToken]); } + function rollback() { request(["rollback"]); } + function confirm() { request(["confirm"]); } + function dismissRun() { check(); } + function openLog() { if (deployment.transaction) Quickshell.execDetached(["kitty", "--hold", "cybex", "update", "log", deployment.transaction]); } + function openRecoveryPoints() { Quickshell.execDetached(["kitty", "--hold", "cybex", "update", "history"]); } + function openFirmware() { Quickshell.execDetached(["kitty", "--hold", "cybexos-firmware-update", "interactive"]); } + + Process { + id: checker + command: ["cybex", "update", "check"] + stdout: StdioCollector { onStreamFinished: { const result = root.parse(text); if (result) { root.sources = result; root.checkedAt = new Date(); } } } + stderr: StdioCollector { onStreamFinished: { if (text.trim()) console.warn("Update check: " + text.trim()); } } + } + Process { + id: requester + stdout: StdioCollector { onStreamFinished: { poll.running = true; } } + stderr: StdioCollector { onStreamFinished: { if (text.trim()) root.deployment = Object.assign({}, root.deployment, {error:text.trim()}); } } + } + Process { + id: poll + command: ["cybex", "update", "status"] + stdout: StdioCollector { onStreamFinished: { const result = root.parse(text); if (result) root.deployment = result; } } + } + Timer { interval: 2000; repeat: true; running: true; triggeredOnStart: true; onTriggered: { if (!poll.running) poll.running = true; } } + Timer { interval: Math.max(10, Number((Settings.modOpts.updates || {}).pollMins) || 60) * 60000; repeat: true; running: true; triggeredOnStart: true; onTriggered: root.check() } +} diff --git a/desktop/quickshell/Common/UpdatesHelpers.js b/desktop/quickshell/Common/UpdatesHelpers.js new file mode 100644 index 00000000..1dbbb331 --- /dev/null +++ b/desktop/quickshell/Common/UpdatesHelpers.js @@ -0,0 +1,7 @@ +function rebootLabel(recommendation, kernelPending) { return recommendation || (kernelPending ? "Reboot when ready" : ""); } +function pendingSummary(upstream, apps, firmware, project, version) { + const parts = []; + if (upstream) parts.push("Upstream NixOS packages"); + if (project) parts.push("CybexOS " + version); + return parts.join(" · ") || "Check update sources"; +} diff --git a/roles/desktop/files/quickshell/Common/UserPlugins.qml b/desktop/quickshell/Common/UserPlugins.qml similarity index 98% rename from roles/desktop/files/quickshell/Common/UserPlugins.qml rename to desktop/quickshell/Common/UserPlugins.qml index b079aad5..1e4b09b8 100644 --- a/roles/desktop/files/quickshell/Common/UserPlugins.qml +++ b/desktop/quickshell/Common/UserPlugins.qml @@ -12,6 +12,7 @@ Singleton { property var widgets: [] property var barConfig: ({}) property string discoveryError: "" + readonly property bool discoveryBusy: scanner.running || refreshPending || registryChanged.running property string mutationError: "" readonly property string error: [mutationError, discoveryError].filter(message => message !== "").join("\n") property string lastResult: "" diff --git a/roles/desktop/files/quickshell/Common/UserWidgetHost.qml b/desktop/quickshell/Common/UserWidgetHost.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/UserWidgetHost.qml rename to desktop/quickshell/Common/UserWidgetHost.qml diff --git a/roles/desktop/files/quickshell/Common/WallhavenHelpers.js b/desktop/quickshell/Common/WallhavenHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/WallhavenHelpers.js rename to desktop/quickshell/Common/WallhavenHelpers.js diff --git a/roles/desktop/files/quickshell/Common/Wallpaper.qml b/desktop/quickshell/Common/Wallpaper.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Wallpaper.qml rename to desktop/quickshell/Common/Wallpaper.qml diff --git a/roles/desktop/files/quickshell/Common/Weather.qml b/desktop/quickshell/Common/Weather.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/Weather.qml rename to desktop/quickshell/Common/Weather.qml diff --git a/roles/desktop/files/quickshell/Common/WebIcons.qml b/desktop/quickshell/Common/WebIcons.qml similarity index 100% rename from roles/desktop/files/quickshell/Common/WebIcons.qml rename to desktop/quickshell/Common/WebIcons.qml diff --git a/roles/desktop/files/quickshell/Common/WidgetCatalog.js b/desktop/quickshell/Common/WidgetCatalog.js similarity index 97% rename from roles/desktop/files/quickshell/Common/WidgetCatalog.js rename to desktop/quickshell/Common/WidgetCatalog.js index 145b2c79..20fe1746 100644 --- a/roles/desktop/files/quickshell/Common/WidgetCatalog.js +++ b/desktop/quickshell/Common/WidgetCatalog.js @@ -35,7 +35,7 @@ var WIDGETS = { wifi: { name: "Network", short: "Network", glyph: "wifi", description: "Manage network connections." }, bt: { name: "Bluetooth", short: "Bluetooth", tag: "when connected", glyph: "bluetooth", description: "Manage connected Bluetooth devices." }, batt: { name: "Battery", short: "Battery", tag: "on laptops", detail: true, glyph: "battery_full", description: "Monitor battery charge and power status." }, - control: { name: "Control Center", short: "Control Center", glyph: "tune", description: "Open Control Center from the Fedora button. Configure its tabs, overview, and behavior." } + control: { name: "Control Center", short: "Control Center", glyph: "tune", description: "Open Control Center from the CybexOS button. Configure its tabs, overview, and behavior." } }; // Never null: a widget id that outlived its catalog entry still has to draw a diff --git a/roles/desktop/files/quickshell/Common/WidgetEditor.js b/desktop/quickshell/Common/WidgetEditor.js similarity index 100% rename from roles/desktop/files/quickshell/Common/WidgetEditor.js rename to desktop/quickshell/Common/WidgetEditor.js diff --git a/roles/desktop/files/quickshell/Common/WifiState.qml b/desktop/quickshell/Common/WifiState.qml similarity index 90% rename from roles/desktop/files/quickshell/Common/WifiState.qml rename to desktop/quickshell/Common/WifiState.qml index e6339c74..c2009f34 100644 --- a/roles/desktop/files/quickshell/Common/WifiState.qml +++ b/desktop/quickshell/Common/WifiState.qml @@ -5,9 +5,8 @@ import Quickshell.Networking import "StatusHelpers.js" as StatusHelpers // The Wi-Fi device and its active network. `Networking.devices` lists wired -// and wireless alike and only the wireless ones carry a `networks` list, so -// that is what identifies the radio — the same find() the bar, the control -// centre and the Wi-Fi popover each wrote separately. +// and wireless alike. Networks belong to the base NetworkDevice in Quickshell +// 0.3, so select the explicit device type before accessing Wi-Fi-only state. // // Named WifiState, not Network: Quickshell.Networking already exports a // `Network` type, and a consumer importing both would resolve the name by @@ -15,7 +14,7 @@ import "StatusHelpers.js" as StatusHelpers Singleton { id: root - readonly property var device: Networking.devices.values.find(d => d.networks !== undefined) ?? null + readonly property var device: Networking.devices.values.find(d => d.type === DeviceType.Wifi) ?? null readonly property var active: device ? (device.networks.values.find(n => n.connected) ?? null) : null readonly property bool connected: active !== null readonly property string name: active ? active.name : "" diff --git a/roles/desktop/files/quickshell/Common/WindowCommands.js b/desktop/quickshell/Common/WindowCommands.js similarity index 100% rename from roles/desktop/files/quickshell/Common/WindowCommands.js rename to desktop/quickshell/Common/WindowCommands.js diff --git a/roles/desktop/files/quickshell/Common/WorkspaceHelpers.js b/desktop/quickshell/Common/WorkspaceHelpers.js similarity index 100% rename from roles/desktop/files/quickshell/Common/WorkspaceHelpers.js rename to desktop/quickshell/Common/WorkspaceHelpers.js diff --git a/roles/desktop/files/quickshell/Common/WorkspaceMotion.js b/desktop/quickshell/Common/WorkspaceMotion.js similarity index 100% rename from roles/desktop/files/quickshell/Common/WorkspaceMotion.js rename to desktop/quickshell/Common/WorkspaceMotion.js diff --git a/roles/desktop/files/quickshell/Common/i18n/nl.js b/desktop/quickshell/Common/i18n/nl.js similarity index 96% rename from roles/desktop/files/quickshell/Common/i18n/nl.js rename to desktop/quickshell/Common/i18n/nl.js index 0c6c99cf..8d129c26 100644 --- a/roles/desktop/files/quickshell/Common/i18n/nl.js +++ b/desktop/quickshell/Common/i18n/nl.js @@ -16,7 +16,6 @@ var MESSAGES = { "7 days": "7 dagen", "A desktop notification when an account trips, except for rate limits.": "Een bureaubladmelding wanneer een account uitvalt, behalve bij snelheidslimieten.", "A display turned off here turns back on by itself when the displays it was turned off beside are disconnected.": "Een beeldscherm dat je hier uitschakelt, gaat vanzelf weer aan wanneer de beeldschermen waarnaast het is uitgeschakeld, worden losgekoppeld.", - "A restored recovery point starts at the next restart.": "Een hersteld herstelpunt wordt actief bij de volgende herstart.", "A thin bar counts down the time a toast has left": "Een dunne balk telt de resterende tijd van een pop-upmelding af", "ACCOUNT": "ACCOUNT", "ACTIONS": "ACTIES", @@ -87,11 +86,11 @@ var MESSAGES = { "Appearance, bar, widgets and system": "Weergave, balk, widgets en systeem", "Application audio": "Audio van toepassingen", "Applications": "Toepassingen", - "Applies to every account on this computer. The password always works too.": "Geldt voor elk account op deze computer. Het wachtwoord blijft ook altijd werken.", "Apply": "Toepassen", "Apply language": "Taal toepassen", "Apply preset": "Voorinstelling toepassen", "Apply reset": "Reset toepassen", + "Apply reviewed generation": "Gecontroleerde generatie toepassen", "Apply timezone": "Tijdzone toepassen", "Applying…": "Toepassen…", "Approval": "Goedkeuring", @@ -164,13 +163,13 @@ var MESSAGES = { "Browse plugins": "Plug-ins bekijken", "Browser": "Browser", "Building QR code…": "QR-code maken…", + "Built package changes": "Gebouwde pakketwijzigingen", "Burst limit": "Meldingslimiet", "CALC": "REKENEN", "CI reports": "CI-rapporten", "CLIPBOARD": "KLEMBORD", "CONNECTION": "VERBINDING", "CONNECTIVITY": "VERBINDINGEN", - "COULDN’T CHECK": "CONTROLE MISLUKT", "CPU": "CPU", "CPU usage": "CPU-gebruik", "Calculator": "Rekenmachine", @@ -190,6 +189,7 @@ var MESSAGES = { "Check every": "Controleren elke", "Check for updates": "Controleren op updates", "Check outcome": "Resultaat controleren", + "Check signed device firmware and confirm installation separately": "Ondertekende apparaatfirmware controleren en installatie afzonderlijk bevestigen", "Checking": "Controleren", "Checking Ethernet…": "Ethernet controleren…", "Checking…": "Controleren…", @@ -241,8 +241,8 @@ var MESSAGES = { "Configure local provider": "Lokale provider configureren", "Confirm": "Bevestigen", "Confirm clear all": "Alles wissen bevestigen", - "Confirm restore": "Herstel bevestigen", "Confirm retry": "Opnieuw proberen bevestigen", + "Confirm this boot and desktop": "Deze opstart en desktop bevestigen", "Connect": "Verbinden", "Connect Google": "Google koppelen", "Connect an account to show its calendars in CybexOS. Signing in opens a separate account window and your browser.": "Koppel een account om de agenda's ervan in CybexOS te tonen. Bij het inloggen worden een apart accountvenster en je browser geopend.", @@ -256,7 +256,6 @@ var MESSAGES = { "Connection setup": "Verbinding instellen", "Connections": "Verbindingen", "Content redacted": "Inhoud afgeschermd", - "Continue in terminal": "Doorgaan in terminal", "Control Center": "Bedieningspaneel", "Control Center widget": "Widget Bedieningspaneel", "Control Panel": "Bedieningspaneel", @@ -295,7 +294,6 @@ var MESSAGES = { "Custom DNS servers": "Aangepaste DNS-servers", "Custom height": "Aangepaste hoogte", "Cut": "Knippen", - "CybexOS": "CybexOS", "CybexOS Settings": "CybexOS-instellingen", "Cycle workspace layout": "Werkbladindeling wisselen", "Cycles": "Cycli", @@ -324,6 +322,7 @@ var MESSAGES = { "Detail": "Detail", "Details": "Details", "Development": "Ontwikkeling", + "Device firmware…": "Apparaatfirmware…", "Devices": "Apparaten", "Devices join with the hotspot's name and password, or by scanning its QR code.": "Apparaten maken verbinding met de naam en het wachtwoord van de hotspot, of door de QR-code te scannen.", "Dictate in Dutch": "Dicteren in het Nederlands", @@ -369,6 +368,7 @@ var MESSAGES = { "Elapsed time": "Verstreken tijd", "Empty. Drag a widget here, or add one from the widgets below.": "Leeg. Sleep hier een widget naartoe of voeg er een toe uit de widgets hieronder.", "Enable or disable notification toasts": "Pop-upmeldingen in- of uitschakelen", + "Enabled": "Ingeschakeld", "Encrypted copies of your home folder on an external drive or another computer.": "Versleutelde kopieën van je thuismap op een externe schijf of een andere computer.", "End process": "Proces beëindigen", "Enroll a fingerprint for unlocking and sign-in": "Een vingerafdruk registreren voor ontgrendelen en inloggen", @@ -391,10 +391,8 @@ var MESSAGES = { "Extra notifications collect in the center": "Extra meldingen worden verzameld in het meldingencentrum", "FILES": "BESTANDEN", "Factory reset": "Fabrieksinstellingen terugzetten", - "Factory reset…": "Fabrieksinstellingen terugzetten…", "Faults": "Storingen", "Favourites": "Favorieten", - "Fedora container shell": "Fedora-containershell", "File or folder": "Bestand of map", "Files": "Bestanden", "Filesystem": "Bestandssysteem", @@ -407,7 +405,6 @@ var MESSAGES = { "Finger": "Vinger", "Fingerprint": "Vingerafdruk", "Fingerprint, security keys and sign-in": "Vingerafdruk, beveiligingssleutels en inloggen", - "Firmware": "Firmware", "Fixed": "Vast", "Flipped": "Gespiegeld", "Floating": "Zwevend", @@ -474,7 +471,6 @@ var MESSAGES = { "In the bar": "In de balk", "Inbox": "Inbox", "Inbox is clear": "Inbox is leeg", - "Include Flatpak": "Flatpak meenemen", "Includes readable home files, Steam saves and rootless container volumes. Caches, downloaded games, container images and mounted filesystems are excluded.": "Bevat leesbare persoonlijke bestanden, Steam-opslagbestanden en rootless containervolumes. Caches, gedownloade spellen, containerimages en aangekoppelde bestandssystemen worden overgeslagen.", "Indicator settings": "Indicatorinstellingen", "Input": "Invoer", @@ -496,7 +492,6 @@ var MESSAGES = { "JSON value, for example true, 42, or \"text\"": "JSON-waarde, bijvoorbeeld true, 42 of \"tekst\"", "KNOWN NETWORKS": "BEKENDE NETWERKEN", "Keep it somewhere safe, such as a password manager or on paper. It opens your backups on any computer. If this computer's keyring and this key are both lost, the backups cannot be opened. It is not shown again.": "Bewaar hem op een veilige plek, bijvoorbeeld in een wachtwoordbeheerder of op papier. Hiermee open je je back-ups op elke computer. Als zowel de sleutelbos van deze computer als deze sleutel verloren gaan, kunnen de back-ups niet meer worden geopend. De sleutel wordt niet opnieuw getoond.", - "Keep this system": "Dit systeem behouden", "Keeps mode, colors, bar and font. The wallpaper is not included.": "Bewaart modus, kleuren, balk en lettertype. De achtergrond wordt niet meegenomen.", "Keyboard": "Toetsenbord", "Keyboard changes not applied yet": "Toetsenbordwijzigingen nog niet toegepast", @@ -544,16 +539,16 @@ var MESSAGES = { "Log out": "Afmelden", "Longitude": "Lengtegraad", "Look ahead": "Vooruitkijken", - "Look for CybexOS, package and firmware updates": "Zoeken naar updates voor CybexOS, pakketten en firmware", + "Look for CybexOS and NixOS package updates": "Zoeken naar updates voor CybexOS en NixOS-pakketten", "Lower is warmer": "Lager is warmer", "MACHINE": "COMPUTER", "MEMORY": "GEHEUGEN", "MICROPHONE": "MICROFOON", "MORE WIDGETS": "MEER WIDGETS", "MUTE": "GEDEMPT", - "Manage": "Beheren", "Manage Omarchy plugins…": "Omarchy-plug-ins beheren…", "Manage this plugin": "Deze plug-in beheren", + "Managed in /etc/nixos/local": "Beheerd in /etc/nixos/local", "Management key": "Beheersleutel", "Manual": "Handmatig", "Markdown note": "Markdown-notitie", @@ -636,6 +631,8 @@ var MESSAGES = { "Night light settings": "Instellingen voor nachtlicht", "Nightly": "Nightly", "Nights": "'s Nachts", + "Nix development shell": "Nix-ontwikkelomgeving", + "NixOS generations retain system code and configuration. Credentials, VPN profiles, documents, databases and plugin data stay in persistent storage. Generation rollback does not restore application data.": "NixOS-generaties bewaren systeemcode en configuratie. Inloggegevens, VPN-profielen, documenten, databases en plugingegevens blijven in permanente opslag. Terugkeren naar een generatie herstelt geen toepassingsgegevens.", "No": "Nee", "No Bluetooth adapter": "Geen Bluetooth-adapter", "No Ethernet ports": "Geen ethernetpoorten", @@ -651,6 +648,7 @@ var MESSAGES = { "No models found": "Geen modellen gevonden", "No notes yet": "Nog geen notities", "No notifications": "Geen meldingen", + "No package closure changes": "Geen wijzigingen in pakketten", "No paired devices": "Geen gekoppelde apparaten", "No plugins yet": "Nog geen plug-ins", "No repositories this account can see": "Geen repository's zichtbaar voor dit account", @@ -667,7 +665,6 @@ var MESSAGES = { "Notification history": "Meldingsgeschiedenis", "Notifications": "Meldingen", "Notify about faults": "Melden bij storingen", - "Notify when updates appear": "Melden wanneer er updates zijn", "Now playing": "Nu aan het afspelen", "Numbers": "Cijfers", "OCR": "OCR", @@ -678,7 +675,6 @@ var MESSAGES = { "Omarchy plugins": "Omarchy-plug-ins", "On": "Aan", "On-screen display": "Schermweergave", - "One is taken automatically before each update.": "Vóór elke update wordt er automatisch een gemaakt.", "Online": "Online", "Online accounts": "Online-accounts", "Only on battery": "Alleen op batterij", @@ -693,11 +689,11 @@ var MESSAGES = { "Open a recovery terminal": "Een herstelterminal openen", "Open conversation history": "Gespreksgeschiedenis openen", "Open full local setup": "Volledige lokale configuratie openen", - "Open full log": "Volledig logboek openen", "Open link": "Link openen", "Open list": "Lijst openen", "Open on GitHub": "Openen op GitHub", "Open on hover": "Openen bij aanwijzen", + "Open private build log": "Privébouwlog openen", "Open recovery points": "Herstelpunten openen", "Open system monitor": "Systeemmonitor openen", "Open the current wallpaper's Wallhaven page": "Wallhaven-pagina van de huidige achtergrond openen", @@ -782,9 +778,12 @@ var MESSAGES = { "Presets…": "Voorinstellingen…", "Press Enter to copy result": "Druk op Enter om het resultaat te kopiëren", "Press Enter to search, then Down to reach the results.": "Druk op Enter om te zoeken en daarna op pijl-omlaag om naar de resultaten te gaan.", + "Preview": "Voorbeeld", "Preview truncated at 100,000 characters / 2,000 lines": "Voorbeeld afgekapt op 100.000 tekens / 2.000 regels", "Preview update": "Update vooraf bekijken", + "Preview upstream packages, CybexOS, or both in Updates. Review the complete build before applying. Personal inputs and Flatpak applications are managed separately.": "Bekijk upstream-pakketten, CybexOS of beide in Updates. Controleer de volledige build voordat je deze toepast. Persoonlijke bronnen en Flatpak-applicaties worden afzonderlijk beheerd.", "Preview: a toast from WhatsApp, Sarah Jansen": "Voorbeeld: een pop-upmelding van WhatsApp, Sarah Jansen", + "Previous generations are available in the boot menu.": "Eerdere generaties zijn beschikbaar in het opstartmenu.", "Previous or next window in group": "Vorig of volgend venster in groep", "Previous track": "Vorig nummer", "Previous workspace": "Vorige werkruimte", @@ -883,6 +882,8 @@ var MESSAGES = { "Retry saving notes": "Notities opnieuw opslaan", "Retrying…": "Opnieuw proberen…", "Review": "Bekijken", + "Review device firmware…": "Apparaatfirmware bekijken…", + "Review firmware separately in a terminal. Firmware changes are not reverted by a system rollback.": "Bekijk firmware afzonderlijk in een terminal. Firmwarewijzigingen worden niet teruggedraaid door systeemherstel.", "Review reset": "Reset bekijken", "Roomy": "Ruim", "Rotate": "Afwisselen", @@ -950,6 +951,7 @@ var MESSAGES = { "Session sign-in required. An HTTP 302 from Hermes WebUI means its browser session has expired or requires sign-in; it is not a usable API response. Enter the password again to renew this client session.": "Opnieuw inloggen vereist voor deze sessie. Een HTTP 302 van Hermes WebUI betekent dat de browsersessie is verlopen of dat inloggen nodig is; het is geen bruikbaar API-antwoord. Voer het wachtwoord opnieuw in om deze clientsessie te vernieuwen.", "Set a location in Settings": "Stel een locatie in bij Instellingen", "Set a value the plugin documents but does not list here": "Stel een waarde in die de plug-in documenteert maar hier niet vermeldt", + "Set cybexos.authentication.fingerprint in a personal NixOS module, then preview with cybex update stage local and apply the reviewed change. Enrol and remove fingerprints here.": "Stel cybexos.authentication.fingerprint in een persoonlijke NixOS-module in, bekijk het voorstel met cybex update stage local en pas de gecontroleerde wijziging toe. Registreer en verwijder hier vingerafdrukken.", "Set up": "Instellen", "Set up Fusebox": "Fusebox instellen", "Set up backups": "Back-ups instellen", @@ -1053,6 +1055,7 @@ var MESSAGES = { "Switch layouts": "Wisselen van indeling", "Switch output to %1": "Uitvoer wijzigen naar %1", "System": "Systeem", + "System generations": "Systeemgeneraties", "System language": "Systeemtaal", "System monitor": "Systeemmonitor", "System timezone": "Systeemtijdzone", @@ -1079,13 +1082,10 @@ var MESSAGES = { "The configured count applies to recent account and org repositories. Every watched repository is additive to that list and, when enabled, the workflow-report scope. Repository refresh uses the interval above; the Inbox checks repository events and GitHub notifications every minute.": "Het ingestelde aantal geldt voor recente repository's van het account en organisaties. Elke gevolgde repository komt bij die lijst en, indien ingeschakeld, bij het bereik van de workflowrapporten. Repository's worden vernieuwd volgens het interval hierboven; de Inbox controleert elke minuut op repository-gebeurtenissen en GitHub-meldingen.", "The dock appears automatically on floating workspaces. Also show it in tiled and scrolling layouts.": "Het dock verschijnt automatisch op zwevende werkbladen. Toon het ook bij tegel- en schuifindelingen.", "The dock appears on floating workspaces. Minimized windows remain in the overview and launcher.": "Het dock verschijnt op zwevende werkbladen. Geminimaliseerde vensters blijven in het overzicht en de starter.", - "The five newest are kept; your home folder is never rolled back. To try one without changing anything, restart and hold Shift (or press Esc) until the boot menu appears, then open CybexOS recovery points.": "De vijf nieuwste worden bewaard; je thuismap wordt nooit teruggezet. Om er een te proberen zonder iets te wijzigen, start je opnieuw op en houd je Shift ingedrukt (of druk je op Esc) tot het opstartmenu verschijnt; open daar CybexOS recovery points.", "The open dashboard updates every 2 seconds.": "Het geopende dashboard wordt elke 2 seconden bijgewerkt.", "The size that Text size and Interface scale build on": "De grootte waarop Tekstgrootte en Interfaceschaal voortbouwen", "Theme": "Thema", "Theme name": "Themanaam", - "They need the standard Btrfs layout (%1).": "Hiervoor is de standaard Btrfs-indeling nodig (%1).", - "They need the standard Btrfs layout.": "Hiervoor is de standaard Btrfs-indeling nodig.", "This approval needs the full T3 Code client.": "Voor deze goedkeuring is de volledige T3 Code-client nodig.", "This credential does not grant orchestration read access.": "Deze inloggegevens geven geen leestoegang tot orchestratie.", "This destination already holds CybexOS backups. Enter their recovery key to keep using them from this computer.": "Deze bestemming bevat al CybexOS-back-ups. Voer de bijbehorende herstelsleutel in om ze vanaf deze computer te blijven gebruiken.", @@ -1163,6 +1163,7 @@ var MESSAGES = { "Variant": "Variant", "Verb only": "Alleen werkwoord", "Verification notification": "Verificatiemelding", + "View generations": "Generaties bekijken", "View pull request": "Pull request bekijken", "Visibility": "Zichtbaarheid", "Visiting workspace": "Bezocht werkblad", @@ -1171,10 +1172,7 @@ var MESSAGES = { "Volume up": "Volume omhoog", "WATCHED REPOS": "GEVOLGDE REPO'S", "WEB": "WEB", - "WHAT DNF SAID": "WAT DNF MELDDE", - "WHAT HAPPENED": "WAT ER GEBEURDE", "WINDOWS": "VENSTERS", - "Waiting for the transaction…": "Wachten op de transactie…", "Waiting…": "Wachten…", "Waits while plugged in; suspends once unplugged": "Wacht zolang de stekker erin zit; gaat in slaapstand zodra de stekker eruit is", "Wallpaper": "Achtergrond", @@ -1187,7 +1185,6 @@ var MESSAGES = { "Web apps": "Web-apps", "WebUI password": "WebUI-wachtwoord", "What a click on it starts, and whether it stays on after you sign in": "Wat een klik erop start, en of het aan blijft nadat je inlogt", - "What will change": "Wat er verandert", "WhatsApp": "WhatsApp", "Where the volume and brightness pop-up appears": "Waar de pop-up voor volume en helderheid verschijnt", "While open": "Zolang geopend", diff --git a/roles/desktop/files/quickshell/Common/qmldir b/desktop/quickshell/Common/qmldir similarity index 99% rename from roles/desktop/files/quickshell/Common/qmldir rename to desktop/quickshell/Common/qmldir index afbdd620..463722cb 100644 --- a/roles/desktop/files/quickshell/Common/qmldir +++ b/desktop/quickshell/Common/qmldir @@ -111,4 +111,3 @@ singleton RemoteServer RemoteServer.qml singleton Fusebox Fusebox.qml CommandRequest CommandRequest.qml -UpdateLogReader UpdateLogReader.qml diff --git a/roles/desktop/files/quickshell/Commons/Border.qml b/desktop/quickshell/Commons/Border.qml similarity index 100% rename from roles/desktop/files/quickshell/Commons/Border.qml rename to desktop/quickshell/Commons/Border.qml diff --git a/roles/desktop/files/quickshell/Commons/BorderGeometry.js b/desktop/quickshell/Commons/BorderGeometry.js similarity index 100% rename from roles/desktop/files/quickshell/Commons/BorderGeometry.js rename to desktop/quickshell/Commons/BorderGeometry.js diff --git a/roles/desktop/files/quickshell/Commons/Color.qml b/desktop/quickshell/Commons/Color.qml similarity index 100% rename from roles/desktop/files/quickshell/Commons/Color.qml rename to desktop/quickshell/Commons/Color.qml diff --git a/roles/desktop/files/quickshell/Commons/Style.qml b/desktop/quickshell/Commons/Style.qml similarity index 100% rename from roles/desktop/files/quickshell/Commons/Style.qml rename to desktop/quickshell/Commons/Style.qml diff --git a/roles/desktop/files/quickshell/Commons/Util.qml b/desktop/quickshell/Commons/Util.qml similarity index 100% rename from roles/desktop/files/quickshell/Commons/Util.qml rename to desktop/quickshell/Commons/Util.qml diff --git a/roles/desktop/files/quickshell/Commons/qmldir b/desktop/quickshell/Commons/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Commons/qmldir rename to desktop/quickshell/Commons/qmldir diff --git a/roles/desktop/files/quickshell/DockWindow.qml b/desktop/quickshell/DockWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/DockWindow.qml rename to desktop/quickshell/DockWindow.qml diff --git a/roles/desktop/files/quickshell/LauncherView.qml b/desktop/quickshell/LauncherView.qml similarity index 100% rename from roles/desktop/files/quickshell/LauncherView.qml rename to desktop/quickshell/LauncherView.qml diff --git a/roles/desktop/files/quickshell/LauncherWindow.qml b/desktop/quickshell/LauncherWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/LauncherWindow.qml rename to desktop/quickshell/LauncherWindow.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/BlockMeter.qml b/desktop/quickshell/ModelUsage/BlockMeter.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/BlockMeter.qml rename to desktop/quickshell/ModelUsage/BlockMeter.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/CostBackend.qml b/desktop/quickshell/ModelUsage/CostBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/CostBackend.qml rename to desktop/quickshell/ModelUsage/CostBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/CostPriceEditor.qml b/desktop/quickshell/ModelUsage/CostPriceEditor.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/CostPriceEditor.qml rename to desktop/quickshell/ModelUsage/CostPriceEditor.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/CostSettings.qml b/desktop/quickshell/ModelUsage/CostSettings.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/CostSettings.qml rename to desktop/quickshell/ModelUsage/CostSettings.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/LICENSE b/desktop/quickshell/ModelUsage/LICENSE similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/LICENSE rename to desktop/quickshell/ModelUsage/LICENSE diff --git a/roles/desktop/files/quickshell/ModelUsage/Panel.qml b/desktop/quickshell/ModelUsage/Panel.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/Panel.qml rename to desktop/quickshell/ModelUsage/Panel.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/README.md b/desktop/quickshell/ModelUsage/README.md similarity index 96% rename from roles/desktop/files/quickshell/ModelUsage/README.md rename to desktop/quickshell/ModelUsage/README.md index 118192cf..40d058e0 100644 --- a/roles/desktop/files/quickshell/ModelUsage/README.md +++ b/desktop/quickshell/ModelUsage/README.md @@ -6,7 +6,7 @@ Omarchy plugin, vendored as the shell's built-in Model Usage widget. Commit: `d4986628d0493aa303d02de0d9017237747704ec` (version 1.3.2) The upstream QML and JavaScript receive the reviewed -[`integration.patch`](../../../../../scripts/model-usage/integration.patch): +[`integration.patch`](../../../scripts/model-usage/integration.patch): shared quota and legacy activity workers, idle/offline polling control, and first-use popup construction. Assets, helper scripts and licenses are unchanged. The manifest, documentation, keeper deployment and CI are not copied. The unit diff --git a/roles/desktop/files/quickshell/ModelUsage/ResetBackend.qml b/desktop/quickshell/ModelUsage/ResetBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/ResetBackend.qml rename to desktop/quickshell/ModelUsage/ResetBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/ResetConfirmation.qml b/desktop/quickshell/ModelUsage/ResetConfirmation.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/ResetConfirmation.qml rename to desktop/quickshell/ModelUsage/ResetConfirmation.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/SettingsActions.qml b/desktop/quickshell/ModelUsage/SettingsActions.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/SettingsActions.qml rename to desktop/quickshell/ModelUsage/SettingsActions.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/SettingsCredential.qml b/desktop/quickshell/ModelUsage/SettingsCredential.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/SettingsCredential.qml rename to desktop/quickshell/ModelUsage/SettingsCredential.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/SettingsField.qml b/desktop/quickshell/ModelUsage/SettingsField.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/SettingsField.qml rename to desktop/quickshell/ModelUsage/SettingsField.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/SettingsHeader.qml b/desktop/quickshell/ModelUsage/SettingsHeader.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/SettingsHeader.qml rename to desktop/quickshell/ModelUsage/SettingsHeader.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/SettingsSection.qml b/desktop/quickshell/ModelUsage/SettingsSection.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/SettingsSection.qml rename to desktop/quickshell/ModelUsage/SettingsSection.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md b/desktop/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md similarity index 88% rename from roles/desktop/files/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md rename to desktop/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md index 944e218d..6a9f5148 100644 --- a/roles/desktop/files/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md +++ b/desktop/quickshell/ModelUsage/THIRD_PARTY_NOTICES.md @@ -24,11 +24,11 @@ The plugin's architecture and component usage follow Omarchy Quattro. The Claude > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE > SOFTWARE. -## DigitalPals/fedora-config +## DigitalPals/cybexos-config -The existing Fedora/Quickshell Model Usage module at commit `6aa074432f36548381ead91ed39d57c34529e327` was the functional and segmented-meter design reference. That checkout did not contain a repository-level license. No source file, theme implementation, or asset from that repository is redistributed here; the backend and Omarchy-native QML were independently implemented against current provider and Omarchy interfaces. +The existing CybexOS/Quickshell Model Usage module at commit `6aa074432f36548381ead91ed39d57c34529e327` was the functional and segmented-meter design reference. That checkout did not contain a repository-level license. No source file, theme implementation, or asset from that repository is redistributed here; the backend and Omarchy-native QML were independently implemented against current provider and Omarchy interfaces. -CLIProxyAPI source selection and account-pool behavior were also reviewed at Fedora commit `431def720d4b905c1563d600be33ca50eed621bb`. The management protocol was checked against [`router-for-me/CLIProxyAPI`](https://github.com/router-for-me/CLIProxyAPI) at commit `5b2785617d1e7de84a9f4dee599d275a4ccd8999`. This integration is independently implemented; no source or assets from either reference are redistributed. +CLIProxyAPI source selection and account-pool behavior were also reviewed at CybexOS commit `431def720d4b905c1563d600be33ca50eed621bb`. The management protocol was checked against [`router-for-me/CLIProxyAPI`](https://github.com/router-for-me/CLIProxyAPI) at commit `5b2785617d1e7de84a9f4dee599d275a4ccd8999`. This integration is independently implemented; no source or assets from either reference are redistributed. ## T3 Code diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageActivityBackend.qml b/desktop/quickshell/ModelUsage/UsageActivityBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageActivityBackend.qml rename to desktop/quickshell/ModelUsage/UsageActivityBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageBackend.qml b/desktop/quickshell/ModelUsage/UsageBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageBackend.qml rename to desktop/quickshell/ModelUsage/UsageBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageCosts.qml b/desktop/quickshell/ModelUsage/UsageCosts.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageCosts.qml rename to desktop/quickshell/ModelUsage/UsageCosts.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageDisclosure.qml b/desktop/quickshell/ModelUsage/UsageDisclosure.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageDisclosure.qml rename to desktop/quickshell/ModelUsage/UsageDisclosure.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageHistory.qml b/desktop/quickshell/ModelUsage/UsageHistory.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageHistory.qml rename to desktop/quickshell/ModelUsage/UsageHistory.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageLiveConnection.qml b/desktop/quickshell/ModelUsage/UsageLiveConnection.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageLiveConnection.qml rename to desktop/quickshell/ModelUsage/UsageLiveConnection.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageLogic.js b/desktop/quickshell/ModelUsage/UsageLogic.js similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageLogic.js rename to desktop/quickshell/ModelUsage/UsageLogic.js diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageNavigation.qml b/desktop/quickshell/ModelUsage/UsageNavigation.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageNavigation.qml rename to desktop/quickshell/ModelUsage/UsageNavigation.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageSelect.qml b/desktop/quickshell/ModelUsage/UsageSelect.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageSelect.qml rename to desktop/quickshell/ModelUsage/UsageSelect.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageSettings.qml b/desktop/quickshell/ModelUsage/UsageSettings.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageSettings.qml rename to desktop/quickshell/ModelUsage/UsageSettings.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageSharedActivityBackend.qml b/desktop/quickshell/ModelUsage/UsageSharedActivityBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageSharedActivityBackend.qml rename to desktop/quickshell/ModelUsage/UsageSharedActivityBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageSharedBackend.qml b/desktop/quickshell/ModelUsage/UsageSharedBackend.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageSharedBackend.qml rename to desktop/quickshell/ModelUsage/UsageSharedBackend.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageTabs.qml b/desktop/quickshell/ModelUsage/UsageTabs.qml similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/UsageTabs.qml rename to desktop/quickshell/ModelUsage/UsageTabs.qml diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/claude-bar.svg b/desktop/quickshell/ModelUsage/assets/claude-bar.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/claude-bar.svg rename to desktop/quickshell/ModelUsage/assets/claude-bar.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/claude.svg b/desktop/quickshell/ModelUsage/assets/claude.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/claude.svg rename to desktop/quickshell/ModelUsage/assets/claude.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/codex-bar.svg b/desktop/quickshell/ModelUsage/assets/codex-bar.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/codex-bar.svg rename to desktop/quickshell/ModelUsage/assets/codex-bar.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/codex-light.svg b/desktop/quickshell/ModelUsage/assets/codex-light.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/codex-light.svg rename to desktop/quickshell/ModelUsage/assets/codex-light.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/codex.svg b/desktop/quickshell/ModelUsage/assets/codex.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/codex.svg rename to desktop/quickshell/ModelUsage/assets/codex.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/kimi-bar.svg b/desktop/quickshell/ModelUsage/assets/kimi-bar.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/kimi-bar.svg rename to desktop/quickshell/ModelUsage/assets/kimi-bar.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/assets/kimi.svg b/desktop/quickshell/ModelUsage/assets/kimi.svg similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/assets/kimi.svg rename to desktop/quickshell/ModelUsage/assets/kimi.svg diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/cost-fetch.py b/desktop/quickshell/ModelUsage/scripts/cost-fetch.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/cost-fetch.py rename to desktop/quickshell/ModelUsage/scripts/cost-fetch.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/keeper_client.py b/desktop/quickshell/ModelUsage/scripts/keeper_client.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/keeper_client.py rename to desktop/quickshell/ModelUsage/scripts/keeper_client.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/management-key.py b/desktop/quickshell/ModelUsage/scripts/management-key.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/management-key.py rename to desktop/quickshell/ModelUsage/scripts/management-key.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/model_usage_common.py b/desktop/quickshell/ModelUsage/scripts/model_usage_common.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/model_usage_common.py rename to desktop/quickshell/ModelUsage/scripts/model_usage_common.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/proxy-activity.py b/desktop/quickshell/ModelUsage/scripts/proxy-activity.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/proxy-activity.py rename to desktop/quickshell/ModelUsage/scripts/proxy-activity.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/proxy-live.py b/desktop/quickshell/ModelUsage/scripts/proxy-live.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/proxy-live.py rename to desktop/quickshell/ModelUsage/scripts/proxy-live.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/reset-credit.py b/desktop/quickshell/ModelUsage/scripts/reset-credit.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/reset-credit.py rename to desktop/quickshell/ModelUsage/scripts/reset-credit.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/t3_costs.py b/desktop/quickshell/ModelUsage/scripts/t3_costs.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/t3_costs.py rename to desktop/quickshell/ModelUsage/scripts/t3_costs.py diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/usage-fetch.py b/desktop/quickshell/ModelUsage/scripts/usage-fetch.py similarity index 100% rename from roles/desktop/files/quickshell/ModelUsage/scripts/usage-fetch.py rename to desktop/quickshell/ModelUsage/scripts/usage-fetch.py diff --git a/roles/desktop/files/quickshell/NetworkOverlayWindow.qml b/desktop/quickshell/NetworkOverlayWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/NetworkOverlayWindow.qml rename to desktop/quickshell/NetworkOverlayWindow.qml diff --git a/roles/desktop/files/quickshell/NotificationToasts.qml b/desktop/quickshell/NotificationToasts.qml similarity index 100% rename from roles/desktop/files/quickshell/NotificationToasts.qml rename to desktop/quickshell/NotificationToasts.qml diff --git a/roles/desktop/files/quickshell/OsdWindow.qml b/desktop/quickshell/OsdWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/OsdWindow.qml rename to desktop/quickshell/OsdWindow.qml diff --git a/roles/desktop/files/quickshell/OverviewView.qml b/desktop/quickshell/OverviewView.qml similarity index 100% rename from roles/desktop/files/quickshell/OverviewView.qml rename to desktop/quickshell/OverviewView.qml diff --git a/roles/desktop/files/quickshell/OverviewWindow.qml b/desktop/quickshell/OverviewWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/OverviewWindow.qml rename to desktop/quickshell/OverviewWindow.qml diff --git a/roles/desktop/files/quickshell/PolkitPrompt.qml b/desktop/quickshell/PolkitPrompt.qml similarity index 100% rename from roles/desktop/files/quickshell/PolkitPrompt.qml rename to desktop/quickshell/PolkitPrompt.qml diff --git a/roles/desktop/files/quickshell/PolkitWindow.qml b/desktop/quickshell/PolkitWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/PolkitWindow.qml rename to desktop/quickshell/PolkitWindow.qml diff --git a/roles/desktop/files/quickshell/Popovers/ActionButton.qml b/desktop/quickshell/Popovers/ActionButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/ActionButton.qml rename to desktop/quickshell/Popovers/ActionButton.qml diff --git a/roles/desktop/files/quickshell/Popovers/AudioPopover.qml b/desktop/quickshell/Popovers/AudioPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/AudioPopover.qml rename to desktop/quickshell/Popovers/AudioPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/BatteryPopover.qml b/desktop/quickshell/Popovers/BatteryPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/BatteryPopover.qml rename to desktop/quickshell/Popovers/BatteryPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/BlockMeter.qml b/desktop/quickshell/Popovers/BlockMeter.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/BlockMeter.qml rename to desktop/quickshell/Popovers/BlockMeter.qml diff --git a/roles/desktop/files/quickshell/Popovers/BluetoothPopover.qml b/desktop/quickshell/Popovers/BluetoothPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/BluetoothPopover.qml rename to desktop/quickshell/Popovers/BluetoothPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/CalendarPopover.qml b/desktop/quickshell/Popovers/CalendarPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/CalendarPopover.qml rename to desktop/quickshell/Popovers/CalendarPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/ControlCenterPopover.qml b/desktop/quickshell/Popovers/ControlCenterPopover.qml similarity index 99% rename from roles/desktop/files/quickshell/Popovers/ControlCenterPopover.qml rename to desktop/quickshell/Popovers/ControlCenterPopover.qml index aa0551b0..5eeb70bd 100644 --- a/roles/desktop/files/quickshell/Popovers/ControlCenterPopover.qml +++ b/desktop/quickshell/Popovers/ControlCenterPopover.qml @@ -20,7 +20,7 @@ import "../Common" // a segmented track, separated by the SectionLabel the rest of the shell // already draws. // -// The Fedora button opens this dashboard; the four status widgets open their +// The CybexOS button opens this dashboard; the four status widgets open their // dedicated detail views directly. Surface { id: root @@ -62,7 +62,7 @@ Surface { clearSessionConfirmation(); } - readonly property string binDir: Quickshell.env("HOME") + "/.local/bin/" + readonly property string binDir: "" readonly property var btConnected: BluetoothState.devices.filter(d => d.connected) readonly property real contentWidth: Math.max(0, root.width - 2 * root.padding) diff --git a/roles/desktop/files/quickshell/Popovers/DaySheetPopover.qml b/desktop/quickshell/Popovers/DaySheetPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/DaySheetPopover.qml rename to desktop/quickshell/Popovers/DaySheetPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerBluetooth.qml b/desktop/quickshell/Popovers/Drawer/DrawerBluetooth.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerBluetooth.qml rename to desktop/quickshell/Popovers/Drawer/DrawerBluetooth.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerFooter.qml b/desktop/quickshell/Popovers/Drawer/DrawerFooter.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerFooter.qml rename to desktop/quickshell/Popovers/Drawer/DrawerFooter.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerIconButton.qml b/desktop/quickshell/Popovers/Drawer/DrawerIconButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerIconButton.qml rename to desktop/quickshell/Popovers/Drawer/DrawerIconButton.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerNetwork.qml b/desktop/quickshell/Popovers/Drawer/DrawerNetwork.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerNetwork.qml rename to desktop/quickshell/Popovers/Drawer/DrawerNetwork.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerNotifications.qml b/desktop/quickshell/Popovers/Drawer/DrawerNotifications.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerNotifications.qml rename to desktop/quickshell/Popovers/Drawer/DrawerNotifications.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml b/desktop/quickshell/Popovers/Drawer/DrawerOverview.qml similarity index 98% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml rename to desktop/quickshell/Popovers/Drawer/DrawerOverview.qml index aee10e97..a45aa1a0 100644 --- a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerOverview.qml +++ b/desktop/quickshell/Popovers/Drawer/DrawerOverview.qml @@ -313,8 +313,8 @@ Column { text: Updates.runActive ? Updates.runPhaseLabel : Updates.runState === "failed" ? Updates.failMessage : Updates.total > 0 - ? UpdatesHelpers.pendingSummary(Updates.dnfCount, - Updates.flatpakCount, Updates.firmwareCount, + ? UpdatesHelpers.pendingSummary(Updates.upstreamCount, + 0, 0, Updates.projectAvailable, Updates.projectVersion) : "" font.family: Theme.fontMenu @@ -414,7 +414,7 @@ Column { onClicked: { Popouts.close(); Quickshell.execDetached(["sh", "-c", - Quickshell.env("HOME") + "/.local/bin/screenshot region"]); + "screenshot region"]); } } diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerPopover.qml b/desktop/quickshell/Popovers/Drawer/DrawerPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerPopover.qml rename to desktop/quickshell/Popovers/Drawer/DrawerPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerPower.qml b/desktop/quickshell/Popovers/Drawer/DrawerPower.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerPower.qml rename to desktop/quickshell/Popovers/Drawer/DrawerPower.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerProcesses.qml b/desktop/quickshell/Popovers/Drawer/DrawerProcesses.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerProcesses.qml rename to desktop/quickshell/Popovers/Drawer/DrawerProcesses.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerSliderRow.qml b/desktop/quickshell/Popovers/Drawer/DrawerSliderRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerSliderRow.qml rename to desktop/quickshell/Popovers/Drawer/DrawerSliderRow.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerSound.qml b/desktop/quickshell/Popovers/Drawer/DrawerSound.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerSound.qml rename to desktop/quickshell/Popovers/Drawer/DrawerSound.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerSystemHero.qml b/desktop/quickshell/Popovers/Drawer/DrawerSystemHero.qml similarity index 97% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerSystemHero.qml rename to desktop/quickshell/Popovers/Drawer/DrawerSystemHero.qml index 63a45134..e77a2e96 100644 --- a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerSystemHero.qml +++ b/desktop/quickshell/Popovers/Drawer/DrawerSystemHero.qml @@ -5,7 +5,7 @@ import "../../Common/SysInfoHelpers.js" as SysInfoHelpers import ".." // The fixed identity and health summary at the top of Overview. It stays -// independent of drawer section settings: opening the Fedora button always +// independent of drawer section settings: opening the CybexOS button always // answers which machine this is and how its core resources are doing. Rectangle { id: root @@ -19,8 +19,8 @@ Rectangle { border.color: Theme.hairlineSoft readonly property int logoSize: Theme.scaled(56) - readonly property bool fedoraIdentity: - SysInfo.osId.toLowerCase() === "fedora" && SysInfo.osName !== "" + readonly property bool cybexosIdentity: + SysInfo.osId.toLowerCase() === "cybexos" && SysInfo.osName !== "" readonly property string osTitle: SysInfo.osName !== "" ? SysInfo.osName : "System information unavailable" readonly property string osReleaseLine: { @@ -210,13 +210,13 @@ Rectangle { BrandIcon { anchors.fill: parent - name: "fedora" - visible: root.fedoraIdentity + name: "cybexos" + visible: root.cybexosIdentity } Rectangle { anchors.fill: parent - visible: !root.fedoraIdentity + visible: !root.cybexosIdentity radius: Theme.rowRadius color: Theme.chipHover diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerTabs.qml b/desktop/quickshell/Popovers/Drawer/DrawerTabs.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerTabs.qml rename to desktop/quickshell/Popovers/Drawer/DrawerTabs.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerTile.qml b/desktop/quickshell/Popovers/Drawer/DrawerTile.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/DrawerTile.qml rename to desktop/quickshell/Popovers/Drawer/DrawerTile.qml diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/qmldir b/desktop/quickshell/Popovers/Drawer/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Drawer/qmldir rename to desktop/quickshell/Popovers/Drawer/qmldir diff --git a/roles/desktop/files/quickshell/Popovers/FillSlider.qml b/desktop/quickshell/Popovers/FillSlider.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/FillSlider.qml rename to desktop/quickshell/Popovers/FillSlider.qml diff --git a/roles/desktop/files/quickshell/Popovers/FuseboxPopover.qml b/desktop/quickshell/Popovers/FuseboxPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/FuseboxPopover.qml rename to desktop/quickshell/Popovers/FuseboxPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/GitHubPopover.qml b/desktop/quickshell/Popovers/GitHubPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/GitHubPopover.qml rename to desktop/quickshell/Popovers/GitHubPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/HDivider.qml b/desktop/quickshell/Popovers/HDivider.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HDivider.qml rename to desktop/quickshell/Popovers/HDivider.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesAuthPanel.qml b/desktop/quickshell/Popovers/HermesAuthPanel.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesAuthPanel.qml rename to desktop/quickshell/Popovers/HermesAuthPanel.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesBarControl.qml b/desktop/quickshell/Popovers/HermesBarControl.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesBarControl.qml rename to desktop/quickshell/Popovers/HermesBarControl.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesComposer.qml b/desktop/quickshell/Popovers/HermesComposer.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesComposer.qml rename to desktop/quickshell/Popovers/HermesComposer.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesInboxPage.qml b/desktop/quickshell/Popovers/HermesInboxPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesInboxPage.qml rename to desktop/quickshell/Popovers/HermesInboxPage.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesInlineSelect.qml b/desktop/quickshell/Popovers/HermesInlineSelect.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesInlineSelect.qml rename to desktop/quickshell/Popovers/HermesInlineSelect.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesModelPicker.qml b/desktop/quickshell/Popovers/HermesModelPicker.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesModelPicker.qml rename to desktop/quickshell/Popovers/HermesModelPicker.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesPopover.qml b/desktop/quickshell/Popovers/HermesPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesPopover.qml rename to desktop/quickshell/Popovers/HermesPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesRequestCard.qml b/desktop/quickshell/Popovers/HermesRequestCard.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesRequestCard.qml rename to desktop/quickshell/Popovers/HermesRequestCard.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesSessionStrip.qml b/desktop/quickshell/Popovers/HermesSessionStrip.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesSessionStrip.qml rename to desktop/quickshell/Popovers/HermesSessionStrip.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesToolCard.qml b/desktop/quickshell/Popovers/HermesToolCard.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesToolCard.qml rename to desktop/quickshell/Popovers/HermesToolCard.qml diff --git a/roles/desktop/files/quickshell/Popovers/HermesTranscript.qml b/desktop/quickshell/Popovers/HermesTranscript.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/HermesTranscript.qml rename to desktop/quickshell/Popovers/HermesTranscript.qml diff --git a/roles/desktop/files/quickshell/Popovers/IconButton.qml b/desktop/quickshell/Popovers/IconButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/IconButton.qml rename to desktop/quickshell/Popovers/IconButton.qml diff --git a/roles/desktop/files/quickshell/Popovers/LinkText.qml b/desktop/quickshell/Popovers/LinkText.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/LinkText.qml rename to desktop/quickshell/Popovers/LinkText.qml diff --git a/roles/desktop/files/quickshell/Popovers/MediaPopover.qml b/desktop/quickshell/Popovers/MediaPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/MediaPopover.qml rename to desktop/quickshell/Popovers/MediaPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/NotesPopover.qml b/desktop/quickshell/Popovers/NotesPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/NotesPopover.qml rename to desktop/quickshell/Popovers/NotesPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/NotifsPopover.qml b/desktop/quickshell/Popovers/NotifsPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/NotifsPopover.qml rename to desktop/quickshell/Popovers/NotifsPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/OverflowPopover.qml b/desktop/quickshell/Popovers/OverflowPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/OverflowPopover.qml rename to desktop/quickshell/Popovers/OverflowPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/PopoutPanel.qml b/desktop/quickshell/Popovers/PopoutPanel.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/PopoutPanel.qml rename to desktop/quickshell/Popovers/PopoutPanel.qml diff --git a/roles/desktop/files/quickshell/Popovers/ReminderPopover.qml b/desktop/quickshell/Popovers/ReminderPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/ReminderPopover.qml rename to desktop/quickshell/Popovers/ReminderPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml b/desktop/quickshell/Popovers/RemoteServerPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/RemoteServerPopover.qml rename to desktop/quickshell/Popovers/RemoteServerPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/SectionLabel.qml b/desktop/quickshell/Popovers/SectionLabel.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/SectionLabel.qml rename to desktop/quickshell/Popovers/SectionLabel.qml diff --git a/roles/desktop/files/quickshell/Popovers/Surface.qml b/desktop/quickshell/Popovers/Surface.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/Surface.qml rename to desktop/quickshell/Popovers/Surface.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3BarControl.qml b/desktop/quickshell/Popovers/T3BarControl.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3BarControl.qml rename to desktop/quickshell/Popovers/T3BarControl.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3CodePopover.qml b/desktop/quickshell/Popovers/T3CodePopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3CodePopover.qml rename to desktop/quickshell/Popovers/T3CodePopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3Composer.qml b/desktop/quickshell/Popovers/T3Composer.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3Composer.qml rename to desktop/quickshell/Popovers/T3Composer.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3InboxPage.qml b/desktop/quickshell/Popovers/T3InboxPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3InboxPage.qml rename to desktop/quickshell/Popovers/T3InboxPage.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3InlineSelect.qml b/desktop/quickshell/Popovers/T3InlineSelect.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3InlineSelect.qml rename to desktop/quickshell/Popovers/T3InlineSelect.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3ModelPicker.qml b/desktop/quickshell/Popovers/T3ModelPicker.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3ModelPicker.qml rename to desktop/quickshell/Popovers/T3ModelPicker.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3NewThreadPage.qml b/desktop/quickshell/Popovers/T3NewThreadPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3NewThreadPage.qml rename to desktop/quickshell/Popovers/T3NewThreadPage.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3Picker.qml b/desktop/quickshell/Popovers/T3Picker.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3Picker.qml rename to desktop/quickshell/Popovers/T3Picker.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3RequestCard.qml b/desktop/quickshell/Popovers/T3RequestCard.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3RequestCard.qml rename to desktop/quickshell/Popovers/T3RequestCard.qml diff --git a/roles/desktop/files/quickshell/Popovers/T3ThreadPage.qml b/desktop/quickshell/Popovers/T3ThreadPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/T3ThreadPage.qml rename to desktop/quickshell/Popovers/T3ThreadPage.qml diff --git a/roles/desktop/files/quickshell/Popovers/TailscalePopover.qml b/desktop/quickshell/Popovers/TailscalePopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/TailscalePopover.qml rename to desktop/quickshell/Popovers/TailscalePopover.qml diff --git a/desktop/quickshell/Popovers/UpdatesPopover.qml b/desktop/quickshell/Popovers/UpdatesPopover.qml new file mode 100644 index 00000000..7dff8eef --- /dev/null +++ b/desktop/quickshell/Popovers/UpdatesPopover.qml @@ -0,0 +1,100 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import "../Common" + +Surface { + id: root + implicitWidth: Theme.drawerWidth + spacing: 16 + Text { width: parent.width; text: I18n.tr("Updates"); color: Theme.textHi; font.pixelSize: Theme.typography.heading; font.bold: true } + Text { width: parent.width; text: Updates.summary; color: Theme.textHi; wrapMode: Text.Wrap } + Text { + width: parent.width + text: "NixOS packages: " + (Updates.sources.upstream.state || "Checking…") + + "\nCybexOS: " + Updates.installedVersion + (Updates.projectVersion ? " → " + Updates.projectVersion : "") + + " · " + (Updates.sources.cybexos.state || "Checking…") + color: Theme.textHi; wrapMode: Text.Wrap + } + Text { width: parent.width; visible: text !== ""; text: Updates.error; color: Theme.textHi; wrapMode: Text.Wrap; textFormat: Text.PlainText } + Row { + spacing: 8 + Repeater { + model: [{label:"Upstream", scope:"upstream"}, {label:"CybexOS", scope:"cybexos"}, {label:"Both", scope:"both"}] + ActionButton { + required property var modelData + width: 106; height: 38 + label: I18n.tr("Preview") + " " + modelData.label + tint: Theme.textHi + enabled: !Updates.runActive && !["awaiting-boot", "awaiting-login", "recovery-failed"].includes(Updates.deployment.state) + onTriggered: Updates.stage(modelData.scope) + } + } + } + Text { width: parent.width; text: Updates.runPhaseLabel; color: Theme.textHi; wrapMode: Text.Wrap } + ScrollView { + width: parent.width + height: Math.min(240, details.implicitHeight + 8) + visible: Updates.projectNotes !== "" || Updates.ready + clip: true + contentWidth: availableWidth + Column { + id: details + width: parent.width + spacing: 12 + Text { + width: parent.width; text: Updates.projectNotes + color: Theme.textHi; wrapMode: Text.Wrap; textFormat: Text.PlainText + } + Text { + width: parent.width; visible: Updates.ready + text: I18n.tr("Built package changes") + "\n" + (Updates.deployment.changes || I18n.tr("No package closure changes")) + color: Theme.textHi; font.family: "monospace"; font.pixelSize: Theme.typography.metadata + wrapMode: Text.Wrap; textFormat: Text.PlainText + } + } + } + ActionButton { + width: parent.width; height: 42; visible: Updates.ready + label: I18n.tr("Apply reviewed generation"); tint: Theme.textHi + onTriggered: Updates.apply() + } + Text { + width: parent.width; visible: text !== ""; text: Updates.deployment.recovery || "" + color: Theme.textHi; wrapMode: Text.Wrap + } + Row { + spacing: 8 + Repeater { + model: [{label:"Check sources", action:"check"}, {label:"Recovery", action:"openRecoveryPoints"}, {label:"Rollback", action:"rollback"}] + ActionButton { + required property var modelData + width: 106; height: 36 + label: I18n.tr(modelData.label); tint: Theme.textHi + enabled: !Updates.runActive + onTriggered: Updates[modelData.action]() + } + } + } + ActionButton { + width: parent.width; height: 40; visible: Updates.deployment.state === "awaiting-login" + label: I18n.tr("Confirm this boot and desktop"); tint: Theme.textHi + onTriggered: Updates.confirm() + } + ActionButton { + width: parent.width; visible: Boolean(Updates.deployment.transaction) + label: I18n.tr("Open private build log"); tint: Theme.textHi + onTriggered: Updates.openLog() + } + ActionButton { + width: parent.width; height: 40; enabled: !Updates.runActive + label: I18n.tr("Device firmware…"); tint: Theme.textHi + onTriggered: Updates.openFirmware() + } + Text { + width: parent.width; color: Theme.textHi; font.pixelSize: Theme.typography.secondary + text: I18n.tr("Review firmware separately in a terminal. Firmware changes are not reverted by a system rollback.") + wrapMode: Text.Wrap + } + Text { width: parent.width; text: "Updates preserve your personal modules, widgets, preferences and data. Reboots are always your choice."; color: Theme.textHi; font.pixelSize: Theme.typography.secondary; wrapMode: Text.Wrap } +} diff --git a/roles/desktop/files/quickshell/Popovers/WeatherPopover.qml b/desktop/quickshell/Popovers/WeatherPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/WeatherPopover.qml rename to desktop/quickshell/Popovers/WeatherPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/WifiPopover.qml b/desktop/quickshell/Popovers/WifiPopover.qml similarity index 100% rename from roles/desktop/files/quickshell/Popovers/WifiPopover.qml rename to desktop/quickshell/Popovers/WifiPopover.qml diff --git a/roles/desktop/files/quickshell/Popovers/qmldir b/desktop/quickshell/Popovers/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Popovers/qmldir rename to desktop/quickshell/Popovers/qmldir diff --git a/roles/desktop/files/quickshell/Settings/AboutPage.qml b/desktop/quickshell/Settings/AboutPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/AboutPage.qml rename to desktop/quickshell/Settings/AboutPage.qml diff --git a/roles/desktop/files/quickshell/Settings/AccountsPage.qml b/desktop/quickshell/Settings/AccountsPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/AccountsPage.qml rename to desktop/quickshell/Settings/AccountsPage.qml diff --git a/roles/desktop/files/quickshell/Settings/ActionRow.qml b/desktop/quickshell/Settings/ActionRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ActionRow.qml rename to desktop/quickshell/Settings/ActionRow.qml diff --git a/roles/desktop/files/quickshell/Settings/AppearancePage.qml b/desktop/quickshell/Settings/AppearancePage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/AppearancePage.qml rename to desktop/quickshell/Settings/AppearancePage.qml diff --git a/roles/desktop/files/quickshell/Settings/ApplyBar.qml b/desktop/quickshell/Settings/ApplyBar.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ApplyBar.qml rename to desktop/quickshell/Settings/ApplyBar.qml diff --git a/roles/desktop/files/quickshell/Settings/BackupPage.qml b/desktop/quickshell/Settings/BackupPage.qml similarity index 99% rename from roles/desktop/files/quickshell/Settings/BackupPage.qml rename to desktop/quickshell/Settings/BackupPage.qml index c0ba4d39..97aaf30b 100644 --- a/roles/desktop/files/quickshell/Settings/BackupPage.qml +++ b/desktop/quickshell/Settings/BackupPage.qml @@ -444,7 +444,6 @@ SettingsPage { RecoveryGroup { width: parent.width - nowMs: page.nowMs } } } diff --git a/roles/desktop/files/quickshell/Settings/BarBackgroundGroup.qml b/desktop/quickshell/Settings/BarBackgroundGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/BarBackgroundGroup.qml rename to desktop/quickshell/Settings/BarBackgroundGroup.qml diff --git a/roles/desktop/files/quickshell/Settings/BarLayoutGroups.qml b/desktop/quickshell/Settings/BarLayoutGroups.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/BarLayoutGroups.qml rename to desktop/quickshell/Settings/BarLayoutGroups.qml diff --git a/roles/desktop/files/quickshell/Settings/BarPreview.qml b/desktop/quickshell/Settings/BarPreview.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/BarPreview.qml rename to desktop/quickshell/Settings/BarPreview.qml diff --git a/roles/desktop/files/quickshell/Settings/ChipToggleRow.qml b/desktop/quickshell/Settings/ChipToggleRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ChipToggleRow.qml rename to desktop/quickshell/Settings/ChipToggleRow.qml diff --git a/roles/desktop/files/quickshell/Settings/ChoiceRow.qml b/desktop/quickshell/Settings/ChoiceRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ChoiceRow.qml rename to desktop/quickshell/Settings/ChoiceRow.qml diff --git a/roles/desktop/files/quickshell/Settings/CornerPickerRow.qml b/desktop/quickshell/Settings/CornerPickerRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/CornerPickerRow.qml rename to desktop/quickshell/Settings/CornerPickerRow.qml diff --git a/roles/desktop/files/quickshell/Settings/DisplayArrangement.qml b/desktop/quickshell/Settings/DisplayArrangement.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/DisplayArrangement.qml rename to desktop/quickshell/Settings/DisplayArrangement.qml diff --git a/roles/desktop/files/quickshell/Settings/DisplaysPage.qml b/desktop/quickshell/Settings/DisplaysPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/DisplaysPage.qml rename to desktop/quickshell/Settings/DisplaysPage.qml diff --git a/roles/desktop/files/quickshell/Settings/DraftFieldRow.qml b/desktop/quickshell/Settings/DraftFieldRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/DraftFieldRow.qml rename to desktop/quickshell/Settings/DraftFieldRow.qml diff --git a/roles/desktop/files/quickshell/Settings/DrawerPage.qml b/desktop/quickshell/Settings/DrawerPage.qml similarity index 99% rename from roles/desktop/files/quickshell/Settings/DrawerPage.qml rename to desktop/quickshell/Settings/DrawerPage.qml index 696847c6..fa314516 100644 --- a/roles/desktop/files/quickshell/Settings/DrawerPage.qml +++ b/desktop/quickshell/Settings/DrawerPage.qml @@ -10,7 +10,7 @@ SettingsPage { id: page readonly property var tabMeta: ({ - overview: { glyph: "dashboard", label: I18n.tr("Overview"), from: "the Fedora button" }, + overview: { glyph: "dashboard", label: I18n.tr("Overview"), from: "the CybexOS button" }, sound: { glyph: "volume_down", label: I18n.tr("Sound"), from: "the volume glyph" }, network: { glyph: "wifi", label: I18n.tr("Network"), from: "the network glyphs" }, bluetooth: { glyph: "bluetooth", label: I18n.tr("Bluetooth"), from: "the Bluetooth glyph" }, diff --git a/roles/desktop/files/quickshell/Settings/FieldRow.qml b/desktop/quickshell/Settings/FieldRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/FieldRow.qml rename to desktop/quickshell/Settings/FieldRow.qml diff --git a/roles/desktop/files/quickshell/Settings/FolderDialog.qml b/desktop/quickshell/Settings/FolderDialog.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/FolderDialog.qml rename to desktop/quickshell/Settings/FolderDialog.qml diff --git a/roles/desktop/files/quickshell/Settings/GitHubWatchList.qml b/desktop/quickshell/Settings/GitHubWatchList.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/GitHubWatchList.qml rename to desktop/quickshell/Settings/GitHubWatchList.qml diff --git a/roles/desktop/files/quickshell/Settings/HotspotGroup.qml b/desktop/quickshell/Settings/HotspotGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/HotspotGroup.qml rename to desktop/quickshell/Settings/HotspotGroup.qml diff --git a/roles/desktop/files/quickshell/Settings/IdleTimeline.qml b/desktop/quickshell/Settings/IdleTimeline.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/IdleTimeline.qml rename to desktop/quickshell/Settings/IdleTimeline.qml diff --git a/roles/desktop/files/quickshell/Settings/IdleTimelineModel.js b/desktop/quickshell/Settings/IdleTimelineModel.js similarity index 100% rename from roles/desktop/files/quickshell/Settings/IdleTimelineModel.js rename to desktop/quickshell/Settings/IdleTimelineModel.js diff --git a/roles/desktop/files/quickshell/Settings/InputDraft.js b/desktop/quickshell/Settings/InputDraft.js similarity index 100% rename from roles/desktop/files/quickshell/Settings/InputDraft.js rename to desktop/quickshell/Settings/InputDraft.js diff --git a/roles/desktop/files/quickshell/Settings/InputDraftState.qml b/desktop/quickshell/Settings/InputDraftState.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/InputDraftState.qml rename to desktop/quickshell/Settings/InputDraftState.qml diff --git a/roles/desktop/files/quickshell/Settings/InputDrafts.qml b/desktop/quickshell/Settings/InputDrafts.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/InputDrafts.qml rename to desktop/quickshell/Settings/InputDrafts.qml diff --git a/roles/desktop/files/quickshell/Settings/IpSettings.qml b/desktop/quickshell/Settings/IpSettings.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/IpSettings.qml rename to desktop/quickshell/Settings/IpSettings.qml diff --git a/roles/desktop/files/quickshell/Settings/KeyboardPage.qml b/desktop/quickshell/Settings/KeyboardPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/KeyboardPage.qml rename to desktop/quickshell/Settings/KeyboardPage.qml diff --git a/roles/desktop/files/quickshell/Settings/LocalTime.js b/desktop/quickshell/Settings/LocalTime.js similarity index 100% rename from roles/desktop/files/quickshell/Settings/LocalTime.js rename to desktop/quickshell/Settings/LocalTime.js diff --git a/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml b/desktop/quickshell/Settings/ModuleDetailView.qml similarity index 97% rename from roles/desktop/files/quickshell/Settings/ModuleDetailView.qml rename to desktop/quickshell/Settings/ModuleDetailView.qml index 79890a2b..2a58e621 100644 --- a/roles/desktop/files/quickshell/Settings/ModuleDetailView.qml +++ b/desktop/quickshell/Settings/ModuleDetailView.qml @@ -1584,43 +1584,11 @@ SettingsPage { onResetRequested: view.resetOpt("pollMins") } - SwitchRow { + SettingsHint { width: parent.width - label: I18n.tr("Include Flatpak") - checked: view.opts.flatpak - dirty: view.optDirty("flatpak") - onToggled: value => view.setOpt("flatpak", value) - onResetRequested: view.resetOpt("flatpak") + text: I18n.tr("Preview upstream packages, CybexOS, or both in Updates. Review the complete build before applying. Personal inputs and Flatpak applications are managed separately.") } - SwitchRow { - width: parent.width - label: I18n.tr("Notify when updates appear") - checked: view.opts.notify - dirty: view.optDirty("notify") - onToggled: value => view.setOpt("notify", value) - onResetRequested: view.resetOpt("notify") - } - - // A system timer, not a widget option: its enablement is the - // setting (see Updates.autoDownloadState), and changing it asks - // for authorization. Hidden where the timer is not installed. - SwitchRow { - width: parent.width - visible: Updates.autoDownloadState !== "unavailable" - label: I18n.tr("Download updates automatically") - description: Updates.autoDownloadError !== "" ? Updates.autoDownloadError - : "In the background, on AC power and unmetered networks. Installing still waits for you." - hintTone: Updates.autoDownloadError !== "" ? "error" : "info" - checked: Updates.autoDownloadState === "enabled" - // Nothing to reset here: systemd keeps the choice, not shell.json. - dirty: false - resetKeys: [] - disabledReason: Updates.autoDownloadBusy ? "Waiting for authorization…" - : Updates.autoDownloadState === "unknown" ? "Checking…" : "" - onToggled: value => Updates.setAutoDownload(value) - Component.onCompleted: Updates.refreshAutoDownload() - } } } diff --git a/roles/desktop/files/quickshell/Settings/ModulesPage.qml b/desktop/quickshell/Settings/ModulesPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ModulesPage.qml rename to desktop/quickshell/Settings/ModulesPage.qml diff --git a/roles/desktop/files/quickshell/Settings/MuteButton.qml b/desktop/quickshell/Settings/MuteButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/MuteButton.qml rename to desktop/quickshell/Settings/MuteButton.qml diff --git a/roles/desktop/files/quickshell/Settings/NetworkPage.qml b/desktop/quickshell/Settings/NetworkPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/NetworkPage.qml rename to desktop/quickshell/Settings/NetworkPage.qml diff --git a/roles/desktop/files/quickshell/Settings/NightLightGroup.qml b/desktop/quickshell/Settings/NightLightGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/NightLightGroup.qml rename to desktop/quickshell/Settings/NightLightGroup.qml diff --git a/roles/desktop/files/quickshell/Settings/NotificationsPage.qml b/desktop/quickshell/Settings/NotificationsPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/NotificationsPage.qml rename to desktop/quickshell/Settings/NotificationsPage.qml diff --git a/roles/desktop/files/quickshell/Settings/PickerRow.qml b/desktop/quickshell/Settings/PickerRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PickerRow.qml rename to desktop/quickshell/Settings/PickerRow.qml diff --git a/roles/desktop/files/quickshell/Settings/PillRow.qml b/desktop/quickshell/Settings/PillRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PillRow.qml rename to desktop/quickshell/Settings/PillRow.qml diff --git a/roles/desktop/files/quickshell/Settings/PluginAppearance.qml b/desktop/quickshell/Settings/PluginAppearance.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PluginAppearance.qml rename to desktop/quickshell/Settings/PluginAppearance.qml diff --git a/roles/desktop/files/quickshell/Settings/PluginRow.qml b/desktop/quickshell/Settings/PluginRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PluginRow.qml rename to desktop/quickshell/Settings/PluginRow.qml diff --git a/roles/desktop/files/quickshell/Settings/PluginSource.js b/desktop/quickshell/Settings/PluginSource.js similarity index 100% rename from roles/desktop/files/quickshell/Settings/PluginSource.js rename to desktop/quickshell/Settings/PluginSource.js diff --git a/roles/desktop/files/quickshell/Settings/PluginWidgetSettings.qml b/desktop/quickshell/Settings/PluginWidgetSettings.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PluginWidgetSettings.qml rename to desktop/quickshell/Settings/PluginWidgetSettings.qml diff --git a/roles/desktop/files/quickshell/Settings/PluginsPage.qml b/desktop/quickshell/Settings/PluginsPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PluginsPage.qml rename to desktop/quickshell/Settings/PluginsPage.qml diff --git a/roles/desktop/files/quickshell/Settings/PowerPage.qml b/desktop/quickshell/Settings/PowerPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/PowerPage.qml rename to desktop/quickshell/Settings/PowerPage.qml diff --git a/desktop/quickshell/Settings/RecoveryGroup.qml b/desktop/quickshell/Settings/RecoveryGroup.qml new file mode 100644 index 00000000..64eb030f --- /dev/null +++ b/desktop/quickshell/Settings/RecoveryGroup.qml @@ -0,0 +1,15 @@ +import QtQuick +import "../Common" +SettingsGroup { + width: parent.width + title: I18n.tr("Recovery points") + SettingsHint { + width: parent.width + text: I18n.tr("NixOS generations retain system code and configuration. Credentials, VPN profiles, documents, databases and plugin data stay in persistent storage. Generation rollback does not restore application data.") + } + ValueRow { + label: I18n.tr("System generations") + hint: Updates.deployment.recovery || I18n.tr("Previous generations are available in the boot menu.") + SettingsAction { text: I18n.tr("View generations"); onTriggered: Updates.openRecoveryPoints() } + } +} diff --git a/roles/desktop/files/quickshell/Settings/RegionPage.qml b/desktop/quickshell/Settings/RegionPage.qml similarity index 99% rename from roles/desktop/files/quickshell/Settings/RegionPage.qml rename to desktop/quickshell/Settings/RegionPage.qml index 6881a4a9..6c9fb585 100644 --- a/roles/desktop/files/quickshell/Settings/RegionPage.qml +++ b/desktop/quickshell/Settings/RegionPage.qml @@ -40,7 +40,7 @@ SettingsPage { function onSnapshotChanged() { page.load(); } function onLoadedChanged() { page.load(); } function onCompleted(result) { - if (!result.success) return; + if (!result.success || result.staged) return; if (page.service.request.action === "timezone") page.originalTimezone = page.timezone; if (page.service.request.action === "locale") page.originalLocale = page.locale; } diff --git a/roles/desktop/files/quickshell/Settings/ResponsiveActionRow.qml b/desktop/quickshell/Settings/ResponsiveActionRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ResponsiveActionRow.qml rename to desktop/quickshell/Settings/ResponsiveActionRow.qml diff --git a/roles/desktop/files/quickshell/Settings/RowCluster.qml b/desktop/quickshell/Settings/RowCluster.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/RowCluster.qml rename to desktop/quickshell/Settings/RowCluster.qml diff --git a/roles/desktop/files/quickshell/Settings/SectionHeader.qml b/desktop/quickshell/Settings/SectionHeader.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SectionHeader.qml rename to desktop/quickshell/Settings/SectionHeader.qml diff --git a/roles/desktop/files/quickshell/Settings/SecurityPage.qml b/desktop/quickshell/Settings/SecurityPage.qml similarity index 95% rename from roles/desktop/files/quickshell/Settings/SecurityPage.qml rename to desktop/quickshell/Settings/SecurityPage.qml index 06eea08b..f9ed59f9 100644 --- a/roles/desktop/files/quickshell/Settings/SecurityPage.qml +++ b/desktop/quickshell/Settings/SecurityPage.qml @@ -7,12 +7,13 @@ import "../Common/Format.js" as Format import "../Common/SecurityHelpers.js" as SecurityHelpers // Security: fingerprints and security keys as alternatives to the password -// for the lock screen, sudo and system prompts (docs/security.md). Signing in +// for sudo and system prompts (docs/hardware-security.md). Signing in // after a restart always asks for the password, which unlocks the keyring. // // Nothing here is privileged by itself. fprintd asks for authorization (the // shell's Polkit dialog) before it adds or removes a fingerprint, and the -// fingerprint switch changes the system's authselect profile through pkexec. +// authentication policy is declared in personal NixOS modules and reviewed +// through the staged update lifecycle. // A key registration is a public key in ~/.config/Yubico/u2f_keys. // // Every request runs off the UI thread: the shared settings backend for @@ -324,15 +325,18 @@ SettingsPage { } } - SwitchRow { + ValueRow { width: parent.width visible: page.readerReady && page.fingers.length > 0 label: I18n.tr("Use fingerprint to unlock and authorise") - description: I18n.tr("Applies to every account on this computer. The password always works too.") - checked: page.snapshot.fingerprintAuth === true - disabledReason: page.snapshot.fingerprintManaged === false ? "Managed outside CybexOS" - : page.service.busy ? "Updating…" : "" - onToggled: value => page.act({ action: "fingerprint-auth", enabled: value }) + value: page.snapshot.fingerprintAuth === true ? I18n.tr("Enabled") : I18n.tr("Disabled") + hint: I18n.tr("Managed in /etc/nixos/local") + } + SettingsHint { + width: parent.width + visible: page.readerReady && page.fingers.length > 0 + maximumLines: 6 + text: I18n.tr("Set cybexos.authentication.fingerprint in a personal NixOS module, then preview with cybex update stage local and apply the reviewed change. Enrol and remove fingerprints here.") } } diff --git a/roles/desktop/files/quickshell/Settings/SelectRow.qml b/desktop/quickshell/Settings/SelectRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SelectRow.qml rename to desktop/quickshell/Settings/SelectRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsAction.qml b/desktop/quickshell/Settings/SettingsAction.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsAction.qml rename to desktop/quickshell/Settings/SettingsAction.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsDisclosure.qml b/desktop/quickshell/Settings/SettingsDisclosure.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsDisclosure.qml rename to desktop/quickshell/Settings/SettingsDisclosure.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsField.qml b/desktop/quickshell/Settings/SettingsField.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsField.qml rename to desktop/quickshell/Settings/SettingsField.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsGroup.qml b/desktop/quickshell/Settings/SettingsGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsGroup.qml rename to desktop/quickshell/Settings/SettingsGroup.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsHint.qml b/desktop/quickshell/Settings/SettingsHint.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsHint.qml rename to desktop/quickshell/Settings/SettingsHint.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsPage.qml b/desktop/quickshell/Settings/SettingsPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsPage.qml rename to desktop/quickshell/Settings/SettingsPage.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsRow.qml b/desktop/quickshell/Settings/SettingsRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsRow.qml rename to desktop/quickshell/Settings/SettingsRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsSelect.qml b/desktop/quickshell/Settings/SettingsSelect.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsSelect.qml rename to desktop/quickshell/Settings/SettingsSelect.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsSubsection.qml b/desktop/quickshell/Settings/SettingsSubsection.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsSubsection.qml rename to desktop/quickshell/Settings/SettingsSubsection.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsTextRow.qml b/desktop/quickshell/Settings/SettingsTextRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsTextRow.qml rename to desktop/quickshell/Settings/SettingsTextRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsTooltip.qml b/desktop/quickshell/Settings/SettingsTooltip.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsTooltip.qml rename to desktop/quickshell/Settings/SettingsTooltip.qml diff --git a/roles/desktop/files/quickshell/Settings/SettingsView.qml b/desktop/quickshell/Settings/SettingsView.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SettingsView.qml rename to desktop/quickshell/Settings/SettingsView.qml diff --git a/roles/desktop/files/quickshell/Settings/SliderRow.qml b/desktop/quickshell/Settings/SliderRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SliderRow.qml rename to desktop/quickshell/Settings/SliderRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SoundPage.qml b/desktop/quickshell/Settings/SoundPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SoundPage.qml rename to desktop/quickshell/Settings/SoundPage.qml diff --git a/roles/desktop/files/quickshell/Settings/StreamRow.qml b/desktop/quickshell/Settings/StreamRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/StreamRow.qml rename to desktop/quickshell/Settings/StreamRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SwitchRow.qml b/desktop/quickshell/Settings/SwitchRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SwitchRow.qml rename to desktop/quickshell/Settings/SwitchRow.qml diff --git a/roles/desktop/files/quickshell/Settings/SystemChoice.qml b/desktop/quickshell/Settings/SystemChoice.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SystemChoice.qml rename to desktop/quickshell/Settings/SystemChoice.qml diff --git a/roles/desktop/files/quickshell/Settings/SystemServiceStatus.qml b/desktop/quickshell/Settings/SystemServiceStatus.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/SystemServiceStatus.qml rename to desktop/quickshell/Settings/SystemServiceStatus.qml diff --git a/roles/desktop/files/quickshell/Settings/TextSizePreview.qml b/desktop/quickshell/Settings/TextSizePreview.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/TextSizePreview.qml rename to desktop/quickshell/Settings/TextSizePreview.qml diff --git a/roles/desktop/files/quickshell/Settings/TimeRow.qml b/desktop/quickshell/Settings/TimeRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/TimeRow.qml rename to desktop/quickshell/Settings/TimeRow.qml diff --git a/roles/desktop/files/quickshell/Settings/TouchpadPage.qml b/desktop/quickshell/Settings/TouchpadPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/TouchpadPage.qml rename to desktop/quickshell/Settings/TouchpadPage.qml diff --git a/roles/desktop/files/quickshell/Settings/UndoChip.qml b/desktop/quickshell/Settings/UndoChip.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/UndoChip.qml rename to desktop/quickshell/Settings/UndoChip.qml diff --git a/roles/desktop/files/quickshell/Settings/ValueRow.qml b/desktop/quickshell/Settings/ValueRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/ValueRow.qml rename to desktop/quickshell/Settings/ValueRow.qml diff --git a/roles/desktop/files/quickshell/Settings/VolumeRow.qml b/desktop/quickshell/Settings/VolumeRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/VolumeRow.qml rename to desktop/quickshell/Settings/VolumeRow.qml diff --git a/roles/desktop/files/quickshell/Settings/VpnGroup.qml b/desktop/quickshell/Settings/VpnGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/VpnGroup.qml rename to desktop/quickshell/Settings/VpnGroup.qml diff --git a/roles/desktop/files/quickshell/Settings/WallpaperOnlineView.qml b/desktop/quickshell/Settings/WallpaperOnlineView.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WallpaperOnlineView.qml rename to desktop/quickshell/Settings/WallpaperOnlineView.qml diff --git a/roles/desktop/files/quickshell/Settings/WallpaperPage.qml b/desktop/quickshell/Settings/WallpaperPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WallpaperPage.qml rename to desktop/quickshell/Settings/WallpaperPage.qml diff --git a/roles/desktop/files/quickshell/Settings/WeatherLocationPicker.qml b/desktop/quickshell/Settings/WeatherLocationPicker.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WeatherLocationPicker.qml rename to desktop/quickshell/Settings/WeatherLocationPicker.qml diff --git a/roles/desktop/files/quickshell/Settings/WidgetEditorRow.qml b/desktop/quickshell/Settings/WidgetEditorRow.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WidgetEditorRow.qml rename to desktop/quickshell/Settings/WidgetEditorRow.qml diff --git a/roles/desktop/files/quickshell/Settings/WidgetPill.qml b/desktop/quickshell/Settings/WidgetPill.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WidgetPill.qml rename to desktop/quickshell/Settings/WidgetPill.qml diff --git a/roles/desktop/files/quickshell/Settings/WorkspacesPage.qml b/desktop/quickshell/Settings/WorkspacesPage.qml similarity index 100% rename from roles/desktop/files/quickshell/Settings/WorkspacesPage.qml rename to desktop/quickshell/Settings/WorkspacesPage.qml diff --git a/roles/desktop/files/quickshell/Settings/qmldir b/desktop/quickshell/Settings/qmldir similarity index 98% rename from roles/desktop/files/quickshell/Settings/qmldir rename to desktop/quickshell/Settings/qmldir index f385e99e..b438f122 100644 --- a/roles/desktop/files/quickshell/Settings/qmldir +++ b/desktop/quickshell/Settings/qmldir @@ -29,7 +29,6 @@ TouchpadPage TouchpadPage.qml KeyboardPage KeyboardPage.qml NightLightGroup NightLightGroup.qml RecoveryGroup RecoveryGroup.qml -FactoryResetRow FactoryResetRow.qml UndoChip UndoChip.qml WallpaperPage WallpaperPage.qml WallpaperOnlineView WallpaperOnlineView.qml diff --git a/roles/desktop/files/quickshell/SettingsWindow.qml b/desktop/quickshell/SettingsWindow.qml similarity index 100% rename from roles/desktop/files/quickshell/SettingsWindow.qml rename to desktop/quickshell/SettingsWindow.qml diff --git a/roles/desktop/files/quickshell/ShortcutsOverlay.qml b/desktop/quickshell/ShortcutsOverlay.qml similarity index 100% rename from roles/desktop/files/quickshell/ShortcutsOverlay.qml rename to desktop/quickshell/ShortcutsOverlay.qml diff --git a/roles/desktop/files/quickshell/Ui/BackgroundMedia.qml b/desktop/quickshell/Ui/BackgroundMedia.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BackgroundMedia.qml rename to desktop/quickshell/Ui/BackgroundMedia.qml diff --git a/roles/desktop/files/quickshell/Ui/BackgroundVideo.qml b/desktop/quickshell/Ui/BackgroundVideo.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BackgroundVideo.qml rename to desktop/quickshell/Ui/BackgroundVideo.qml diff --git a/roles/desktop/files/quickshell/Ui/BarIconButton.qml b/desktop/quickshell/Ui/BarIconButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BarIconButton.qml rename to desktop/quickshell/Ui/BarIconButton.qml diff --git a/roles/desktop/files/quickshell/Ui/BarIndicator.qml b/desktop/quickshell/Ui/BarIndicator.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BarIndicator.qml rename to desktop/quickshell/Ui/BarIndicator.qml diff --git a/roles/desktop/files/quickshell/Ui/BarWidget.qml b/desktop/quickshell/Ui/BarWidget.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BarWidget.qml rename to desktop/quickshell/Ui/BarWidget.qml diff --git a/roles/desktop/files/quickshell/Ui/BorderOverlay.qml b/desktop/quickshell/Ui/BorderOverlay.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BorderOverlay.qml rename to desktop/quickshell/Ui/BorderOverlay.qml diff --git a/roles/desktop/files/quickshell/Ui/BorderSurface.qml b/desktop/quickshell/Ui/BorderSurface.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/BorderSurface.qml rename to desktop/quickshell/Ui/BorderSurface.qml diff --git a/roles/desktop/files/quickshell/Ui/Button.qml b/desktop/quickshell/Ui/Button.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/Button.qml rename to desktop/quickshell/Ui/Button.qml diff --git a/roles/desktop/files/quickshell/Ui/ButtonGroup.qml b/desktop/quickshell/Ui/ButtonGroup.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/ButtonGroup.qml rename to desktop/quickshell/Ui/ButtonGroup.qml diff --git a/roles/desktop/files/quickshell/Ui/ConfirmDialog.qml b/desktop/quickshell/Ui/ConfirmDialog.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/ConfirmDialog.qml rename to desktop/quickshell/Ui/ConfirmDialog.qml diff --git a/roles/desktop/files/quickshell/Ui/CursorSurface.qml b/desktop/quickshell/Ui/CursorSurface.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/CursorSurface.qml rename to desktop/quickshell/Ui/CursorSurface.qml diff --git a/roles/desktop/files/quickshell/Ui/Dropdown.qml b/desktop/quickshell/Ui/Dropdown.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/Dropdown.qml rename to desktop/quickshell/Ui/Dropdown.qml diff --git a/roles/desktop/files/quickshell/Ui/KeyboardPanel.qml b/desktop/quickshell/Ui/KeyboardPanel.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/KeyboardPanel.qml rename to desktop/quickshell/Ui/KeyboardPanel.qml diff --git a/roles/desktop/files/quickshell/Ui/MultiSelect.qml b/desktop/quickshell/Ui/MultiSelect.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/MultiSelect.qml rename to desktop/quickshell/Ui/MultiSelect.qml diff --git a/roles/desktop/files/quickshell/Ui/NumberField.qml b/desktop/quickshell/Ui/NumberField.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/NumberField.qml rename to desktop/quickshell/Ui/NumberField.qml diff --git a/roles/desktop/files/quickshell/Ui/OpticalGlyph.qml b/desktop/quickshell/Ui/OpticalGlyph.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/OpticalGlyph.qml rename to desktop/quickshell/Ui/OpticalGlyph.qml diff --git a/roles/desktop/files/quickshell/Ui/Panel.qml b/desktop/quickshell/Ui/Panel.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/Panel.qml rename to desktop/quickshell/Ui/Panel.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelActionButton.qml b/desktop/quickshell/Ui/PanelActionButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelActionButton.qml rename to desktop/quickshell/Ui/PanelActionButton.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelController.qml b/desktop/quickshell/Ui/PanelController.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelController.qml rename to desktop/quickshell/Ui/PanelController.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelHero.qml b/desktop/quickshell/Ui/PanelHero.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelHero.qml rename to desktop/quickshell/Ui/PanelHero.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelKeyCatcher.qml b/desktop/quickshell/Ui/PanelKeyCatcher.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelKeyCatcher.qml rename to desktop/quickshell/Ui/PanelKeyCatcher.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelSectionHeader.qml b/desktop/quickshell/Ui/PanelSectionHeader.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelSectionHeader.qml rename to desktop/quickshell/Ui/PanelSectionHeader.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelSeparator.qml b/desktop/quickshell/Ui/PanelSeparator.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelSeparator.qml rename to desktop/quickshell/Ui/PanelSeparator.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelSlider.qml b/desktop/quickshell/Ui/PanelSlider.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelSlider.qml rename to desktop/quickshell/Ui/PanelSlider.qml diff --git a/roles/desktop/files/quickshell/Ui/PanelToolTip.qml b/desktop/quickshell/Ui/PanelToolTip.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PanelToolTip.qml rename to desktop/quickshell/Ui/PanelToolTip.qml diff --git a/roles/desktop/files/quickshell/Ui/PluginBarApi.qml b/desktop/quickshell/Ui/PluginBarApi.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PluginBarApi.qml rename to desktop/quickshell/Ui/PluginBarApi.qml diff --git a/roles/desktop/files/quickshell/Ui/PointerMoveGate.qml b/desktop/quickshell/Ui/PointerMoveGate.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PointerMoveGate.qml rename to desktop/quickshell/Ui/PointerMoveGate.qml diff --git a/roles/desktop/files/quickshell/Ui/PopupCard.qml b/desktop/quickshell/Ui/PopupCard.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/PopupCard.qml rename to desktop/quickshell/Ui/PopupCard.qml diff --git a/roles/desktop/files/quickshell/Ui/ScreenMoveRemap.qml b/desktop/quickshell/Ui/ScreenMoveRemap.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/ScreenMoveRemap.qml rename to desktop/quickshell/Ui/ScreenMoveRemap.qml diff --git a/roles/desktop/files/quickshell/Ui/SearchableDropdown.qml b/desktop/quickshell/Ui/SearchableDropdown.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/SearchableDropdown.qml rename to desktop/quickshell/Ui/SearchableDropdown.qml diff --git a/roles/desktop/files/quickshell/Ui/SpeedTestOverlay.qml b/desktop/quickshell/Ui/SpeedTestOverlay.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/SpeedTestOverlay.qml rename to desktop/quickshell/Ui/SpeedTestOverlay.qml diff --git a/roles/desktop/files/quickshell/Ui/TelemetryChart.qml b/desktop/quickshell/Ui/TelemetryChart.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/TelemetryChart.qml rename to desktop/quickshell/Ui/TelemetryChart.qml diff --git a/roles/desktop/files/quickshell/Ui/TextField.qml b/desktop/quickshell/Ui/TextField.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/TextField.qml rename to desktop/quickshell/Ui/TextField.qml diff --git a/roles/desktop/files/quickshell/Ui/Toggle.qml b/desktop/quickshell/Ui/Toggle.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/Toggle.qml rename to desktop/quickshell/Ui/Toggle.qml diff --git a/roles/desktop/files/quickshell/Ui/ToggleSwitch.qml b/desktop/quickshell/Ui/ToggleSwitch.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/ToggleSwitch.qml rename to desktop/quickshell/Ui/ToggleSwitch.qml diff --git a/roles/desktop/files/quickshell/Ui/WidgetButton.qml b/desktop/quickshell/Ui/WidgetButton.qml similarity index 100% rename from roles/desktop/files/quickshell/Ui/WidgetButton.qml rename to desktop/quickshell/Ui/WidgetButton.qml diff --git a/roles/desktop/files/quickshell/Ui/qmldir b/desktop/quickshell/Ui/qmldir similarity index 100% rename from roles/desktop/files/quickshell/Ui/qmldir rename to desktop/quickshell/Ui/qmldir diff --git a/roles/desktop/files/quickshell/WebcamOverlay.qml b/desktop/quickshell/WebcamOverlay.qml similarity index 100% rename from roles/desktop/files/quickshell/WebcamOverlay.qml rename to desktop/quickshell/WebcamOverlay.qml diff --git a/roles/desktop/files/quickshell/assets/README.md b/desktop/quickshell/assets/README.md similarity index 87% rename from roles/desktop/files/quickshell/assets/README.md rename to desktop/quickshell/assets/README.md index 09cd5e92..d25d71e7 100644 --- a/roles/desktop/files/quickshell/assets/README.md +++ b/desktop/quickshell/assets/README.md @@ -35,11 +35,10 @@ background. | `grok.svg`, `grok-white.svg` | [CLI Proxy API Management Center: `grok.svg`](https://github.com/router-for-me/Cli-Proxy-API-Management-Center/blob/382c09109db2e70a6e60697c0ae0cf6ef97198e7/src/assets/icons/grok.svg) | `382c09109db2e70a6e60697c0ae0cf6ef97198e7` | Upstream Grok path with shell-silver/white paint; redundant sizing and inline-style metadata removed. | | `t3.svg` | [T3 Code production logo](https://github.com/pingdotgg/t3code/blob/a3a8cbd60539b4af4de8f96c892dbd07a2b6c041/assets/prod/logo.svg) | `a3a8cbd60539b4af4de8f96c892dbd07a2b6c041` | Cropped to the wordmark; background removed. | | `fusebox.svg`, `fusebox-white.svg` | [Fusebox: `assets/icon.svg`](https://github.com/DigitalPals/Fusebox/blob/11fb076e73a96684109cba3b8aa884712846a288/assets/icon.svg) | `11fb076e73a96684109cba3b8aa884712846a288` | Upstream paths and paint; size attributes removed. The hover sibling changes both paints to white. | -| `fedora.svg`, `fedora-white.svg` | [Fedora Design: `fedora_default-horizontal.svg`](https://gitlab.com/fedora/design/team/logos/fedora-project-logos/-/blob/e7ee4e88ac5b43a1acf2ab39157b63c80e8093f2/brand-book-assets/logo-svgs/fedora_default-horizontal.svg) | `e7ee4e88ac5b43a1acf2ab39157b63c80e8093f2` | Cropped to the official standalone Fedora mark; the hover sibling changes blue to white without changing its path. | +| `cybexos.svg`, `cybexos-white.svg` | Original repository C monogram | Repository source | Gray and white paints of the same vector geometry; MIT. | Simple Icons is distributed under CC0 1.0 and Fusebox under the Unlicense. -Tabler Icons, T3 Code, and the CLI Proxy API Management Center are MIT licensed. Fedora Design's artwork is CC -BY-SA 4.0 except for trademark rights. +Tabler Icons, T3 Code, and the CLI Proxy API Management Center are MIT licensed. The CybexOS monogram is original MIT-licensed repository vector code. Notices and modification details are retained in `THIRD_PARTY_LICENSES.md`. Names and logos remain trademarks of their respective owners regardless of the vector files' copyright licences. diff --git a/roles/desktop/files/quickshell/assets/THIRD_PARTY_LICENSES.md b/desktop/quickshell/assets/THIRD_PARTY_LICENSES.md similarity index 82% rename from roles/desktop/files/quickshell/assets/THIRD_PARTY_LICENSES.md rename to desktop/quickshell/assets/THIRD_PARTY_LICENSES.md index e666613a..ef1fdc7c 100644 --- a/roles/desktop/files/quickshell/assets/THIRD_PARTY_LICENSES.md +++ b/desktop/quickshell/assets/THIRD_PARTY_LICENSES.md @@ -84,18 +84,9 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -## Fedora Project mark +## CybexOS monogram -The Fedora mark is adapted from `fedora_default-horizontal.svg` by the Fedora -Design Team, revision `e7ee4e88ac5b43a1acf2ab39157b63c80e8093f2`: -https://gitlab.com/fedora/design/team/logos/fedora-project-logos - -The source repository states that its contents are licensed under -[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), except that -Fedora and other trademarks remain the property of their respective trademark -holders. Local changes crop out the wordmark and redundant SVG metadata; -`fedora-white.svg` changes only the fill from Fedora blue to white. - -Fedora®, the Fedora word design, and the Fedora logo are trademarks of Red -Hat, Inc. Use remains subject to the -[Fedora trademark guidelines](https://docs.fedoraproject.org/en-US/legal/trademarks/). +The CybexOS C monogram is original repository vector code under the repository +MIT license. Its white variant changes paint only. It is not adapted from a +third-party operating-system mark. This code license does not grant rights in +other depicted brands. diff --git a/roles/desktop/files/quickshell/assets/claude-white.svg b/desktop/quickshell/assets/claude-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/claude-white.svg rename to desktop/quickshell/assets/claude-white.svg diff --git a/roles/desktop/files/quickshell/assets/claude.svg b/desktop/quickshell/assets/claude.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/claude.svg rename to desktop/quickshell/assets/claude.svg diff --git a/desktop/quickshell/assets/cybexos-white.svg b/desktop/quickshell/assets/cybexos-white.svg new file mode 100644 index 00000000..014b7564 --- /dev/null +++ b/desktop/quickshell/assets/cybexos-white.svg @@ -0,0 +1 @@ + diff --git a/desktop/quickshell/assets/cybexos.svg b/desktop/quickshell/assets/cybexos.svg new file mode 100644 index 00000000..3990cac3 --- /dev/null +++ b/desktop/quickshell/assets/cybexos.svg @@ -0,0 +1 @@ + diff --git a/roles/desktop/files/quickshell/assets/fusebox-white.svg b/desktop/quickshell/assets/fusebox-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/fusebox-white.svg rename to desktop/quickshell/assets/fusebox-white.svg diff --git a/roles/desktop/files/quickshell/assets/fusebox.svg b/desktop/quickshell/assets/fusebox.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/fusebox.svg rename to desktop/quickshell/assets/fusebox.svg diff --git a/roles/desktop/files/quickshell/assets/gemini-white.svg b/desktop/quickshell/assets/gemini-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/gemini-white.svg rename to desktop/quickshell/assets/gemini-white.svg diff --git a/roles/desktop/files/quickshell/assets/gemini.svg b/desktop/quickshell/assets/gemini.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/gemini.svg rename to desktop/quickshell/assets/gemini.svg diff --git a/roles/desktop/files/quickshell/assets/github-white.svg b/desktop/quickshell/assets/github-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/github-white.svg rename to desktop/quickshell/assets/github-white.svg diff --git a/roles/desktop/files/quickshell/assets/github.svg b/desktop/quickshell/assets/github.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/github.svg rename to desktop/quickshell/assets/github.svg diff --git a/roles/desktop/files/quickshell/assets/grok-white.svg b/desktop/quickshell/assets/grok-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/grok-white.svg rename to desktop/quickshell/assets/grok-white.svg diff --git a/roles/desktop/files/quickshell/assets/grok.svg b/desktop/quickshell/assets/grok.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/grok.svg rename to desktop/quickshell/assets/grok.svg diff --git a/roles/desktop/files/quickshell/assets/kimi-white.svg b/desktop/quickshell/assets/kimi-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/kimi-white.svg rename to desktop/quickshell/assets/kimi-white.svg diff --git a/roles/desktop/files/quickshell/assets/kimi.svg b/desktop/quickshell/assets/kimi.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/kimi.svg rename to desktop/quickshell/assets/kimi.svg diff --git a/roles/desktop/files/quickshell/assets/openai-white.svg b/desktop/quickshell/assets/openai-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/openai-white.svg rename to desktop/quickshell/assets/openai-white.svg diff --git a/roles/desktop/files/quickshell/assets/openai.svg b/desktop/quickshell/assets/openai.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/openai.svg rename to desktop/quickshell/assets/openai.svg diff --git a/roles/desktop/files/quickshell/assets/slack-white.svg b/desktop/quickshell/assets/slack-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/slack-white.svg rename to desktop/quickshell/assets/slack-white.svg diff --git a/roles/desktop/files/quickshell/assets/slack.svg b/desktop/quickshell/assets/slack.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/slack.svg rename to desktop/quickshell/assets/slack.svg diff --git a/roles/desktop/files/quickshell/assets/t3.svg b/desktop/quickshell/assets/t3.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/t3.svg rename to desktop/quickshell/assets/t3.svg diff --git a/roles/desktop/files/quickshell/assets/tabler/LICENSE b/desktop/quickshell/assets/tabler/LICENSE similarity index 100% rename from roles/desktop/files/quickshell/assets/tabler/LICENSE rename to desktop/quickshell/assets/tabler/LICENSE diff --git a/roles/desktop/files/quickshell/assets/tabler/README.md b/desktop/quickshell/assets/tabler/README.md similarity index 100% rename from roles/desktop/files/quickshell/assets/tabler/README.md rename to desktop/quickshell/assets/tabler/README.md diff --git a/roles/desktop/files/quickshell/assets/tabler/aliases.json b/desktop/quickshell/assets/tabler/aliases.json similarity index 100% rename from roles/desktop/files/quickshell/assets/tabler/aliases.json rename to desktop/quickshell/assets/tabler/aliases.json diff --git a/roles/desktop/files/quickshell/assets/tabler/outline.ttf b/desktop/quickshell/assets/tabler/outline.ttf similarity index 100% rename from roles/desktop/files/quickshell/assets/tabler/outline.ttf rename to desktop/quickshell/assets/tabler/outline.ttf diff --git a/roles/desktop/files/quickshell/assets/tailscale-white.svg b/desktop/quickshell/assets/tailscale-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/tailscale-white.svg rename to desktop/quickshell/assets/tailscale-white.svg diff --git a/roles/desktop/files/quickshell/assets/tailscale.svg b/desktop/quickshell/assets/tailscale.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/tailscale.svg rename to desktop/quickshell/assets/tailscale.svg diff --git a/roles/desktop/files/quickshell/assets/whatsapp-white.svg b/desktop/quickshell/assets/whatsapp-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/whatsapp-white.svg rename to desktop/quickshell/assets/whatsapp-white.svg diff --git a/roles/desktop/files/quickshell/assets/whatsapp.svg b/desktop/quickshell/assets/whatsapp.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/whatsapp.svg rename to desktop/quickshell/assets/whatsapp.svg diff --git a/roles/desktop/files/quickshell/assets/youtube-white.svg b/desktop/quickshell/assets/youtube-white.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/youtube-white.svg rename to desktop/quickshell/assets/youtube-white.svg diff --git a/roles/desktop/files/quickshell/assets/youtube.svg b/desktop/quickshell/assets/youtube.svg similarity index 100% rename from roles/desktop/files/quickshell/assets/youtube.svg rename to desktop/quickshell/assets/youtube.svg diff --git a/roles/desktop/files/quickshell/compat/omarchy/LICENSE b/desktop/quickshell/compat/omarchy/LICENSE similarity index 100% rename from roles/desktop/files/quickshell/compat/omarchy/LICENSE rename to desktop/quickshell/compat/omarchy/LICENSE diff --git a/roles/desktop/files/quickshell/compat/omarchy/README.md b/desktop/quickshell/compat/omarchy/README.md similarity index 100% rename from roles/desktop/files/quickshell/compat/omarchy/README.md rename to desktop/quickshell/compat/omarchy/README.md diff --git a/roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-notification-send b/desktop/quickshell/compat/omarchy/bin/omarchy-notification-send similarity index 99% rename from roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-notification-send rename to desktop/quickshell/compat/omarchy/bin/omarchy-notification-send index 845e3145..63e4d6b1 100755 --- a/roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-notification-send +++ b/desktop/quickshell/compat/omarchy/bin/omarchy-notification-send @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # omarchy:summary=Send an Omarchy desktop notification # omarchy:args=[--app-name ] [-g ] [-u ] [-i ] [-t ] [-r ] [-p] [--image ] [description] [--exec [args...]] diff --git a/roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-shell b/desktop/quickshell/compat/omarchy/bin/omarchy-shell similarity index 99% rename from roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-shell rename to desktop/quickshell/compat/omarchy/bin/omarchy-shell index 1d733888..7c10a33d 100755 --- a/roles/desktop/files/quickshell/compat/omarchy/bin/omarchy-shell +++ b/desktop/quickshell/compat/omarchy/bin/omarchy-shell @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # omarchy:summary=Send an IPC call to the running Cybex shell # omarchy:args=[-q] [args...] diff --git a/roles/desktop/files/quickshell/launcher-actions.json b/desktop/quickshell/launcher-actions.json similarity index 100% rename from roles/desktop/files/quickshell/launcher-actions.json rename to desktop/quickshell/launcher-actions.json diff --git a/roles/desktop/files/quickshell/qmldir b/desktop/quickshell/qmldir similarity index 100% rename from roles/desktop/files/quickshell/qmldir rename to desktop/quickshell/qmldir diff --git a/roles/desktop/files/quickshell/safe-mode/shell.qml b/desktop/quickshell/safe-mode/shell.qml similarity index 100% rename from roles/desktop/files/quickshell/safe-mode/shell.qml rename to desktop/quickshell/safe-mode/shell.qml diff --git a/roles/desktop/files/quickshell/scripts/audio-route b/desktop/quickshell/scripts/audio-route similarity index 100% rename from roles/desktop/files/quickshell/scripts/audio-route rename to desktop/quickshell/scripts/audio-route diff --git a/roles/desktop/files/quickshell/scripts/audio-source-route b/desktop/quickshell/scripts/audio-source-route similarity index 100% rename from roles/desktop/files/quickshell/scripts/audio-source-route rename to desktop/quickshell/scripts/audio-source-route diff --git a/roles/desktop/files/quickshell/scripts/battery-health b/desktop/quickshell/scripts/battery-health similarity index 100% rename from roles/desktop/files/quickshell/scripts/battery-health rename to desktop/quickshell/scripts/battery-health diff --git a/roles/desktop/files/quickshell/scripts/bluetooth-tool.py b/desktop/quickshell/scripts/bluetooth-tool.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/bluetooth-tool.py rename to desktop/quickshell/scripts/bluetooth-tool.py diff --git a/roles/desktop/files/quickshell/scripts/calendar-events.py b/desktop/quickshell/scripts/calendar-events.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/calendar-events.py rename to desktop/quickshell/scripts/calendar-events.py diff --git a/roles/desktop/files/quickshell/scripts/clipboard-tool.py b/desktop/quickshell/scripts/clipboard-tool.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/clipboard-tool.py rename to desktop/quickshell/scripts/clipboard-tool.py diff --git a/roles/desktop/files/quickshell/scripts/display-settings.py b/desktop/quickshell/scripts/display-settings.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/display-settings.py rename to desktop/quickshell/scripts/display-settings.py diff --git a/roles/desktop/files/quickshell/scripts/fusebox.py b/desktop/quickshell/scripts/fusebox.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/fusebox.py rename to desktop/quickshell/scripts/fusebox.py diff --git a/roles/desktop/files/quickshell/scripts/hypridle-config.py b/desktop/quickshell/scripts/hypridle-config.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/hypridle-config.py rename to desktop/quickshell/scripts/hypridle-config.py diff --git a/roles/desktop/files/quickshell/scripts/network-speedtest.py b/desktop/quickshell/scripts/network-speedtest.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/network-speedtest.py rename to desktop/quickshell/scripts/network-speedtest.py diff --git a/roles/desktop/files/quickshell/scripts/network-tool.py b/desktop/quickshell/scripts/network-tool.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/network-tool.py rename to desktop/quickshell/scripts/network-tool.py diff --git a/roles/desktop/files/quickshell/scripts/network_vpn_agent.py b/desktop/quickshell/scripts/network_vpn_agent.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/network_vpn_agent.py rename to desktop/quickshell/scripts/network_vpn_agent.py diff --git a/roles/desktop/files/quickshell/scripts/note-title.py b/desktop/quickshell/scripts/note-title.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/note-title.py rename to desktop/quickshell/scripts/note-title.py diff --git a/roles/desktop/files/quickshell/scripts/notification-history.py b/desktop/quickshell/scripts/notification-history.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/notification-history.py rename to desktop/quickshell/scripts/notification-history.py diff --git a/roles/desktop/files/quickshell/scripts/notification-icon.py b/desktop/quickshell/scripts/notification-icon.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/notification-icon.py rename to desktop/quickshell/scripts/notification-icon.py diff --git a/roles/desktop/files/quickshell/scripts/plugin_packages.py b/desktop/quickshell/scripts/plugin_packages.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/plugin_packages.py rename to desktop/quickshell/scripts/plugin_packages.py diff --git a/roles/desktop/files/quickshell/scripts/processes b/desktop/quickshell/scripts/processes similarity index 100% rename from roles/desktop/files/quickshell/scripts/processes rename to desktop/quickshell/scripts/processes diff --git a/roles/desktop/files/quickshell/scripts/remote-server.py b/desktop/quickshell/scripts/remote-server.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/remote-server.py rename to desktop/quickshell/scripts/remote-server.py diff --git a/roles/desktop/files/quickshell/scripts/remote_server_probe.py b/desktop/quickshell/scripts/remote_server_probe.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/remote_server_probe.py rename to desktop/quickshell/scripts/remote_server_probe.py diff --git a/roles/desktop/files/quickshell/scripts/settings-store b/desktop/quickshell/scripts/settings-store similarity index 100% rename from roles/desktop/files/quickshell/scripts/settings-store rename to desktop/quickshell/scripts/settings-store diff --git a/roles/desktop/files/quickshell/scripts/shell-health.py b/desktop/quickshell/scripts/shell-health.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/shell-health.py rename to desktop/quickshell/scripts/shell-health.py diff --git a/roles/desktop/files/quickshell/scripts/shell-recovery.py b/desktop/quickshell/scripts/shell-recovery.py similarity index 98% rename from roles/desktop/files/quickshell/scripts/shell-recovery.py rename to desktop/quickshell/scripts/shell-recovery.py index 13c68728..87b7bbe4 100644 --- a/roles/desktop/files/quickshell/scripts/shell-recovery.py +++ b/desktop/quickshell/scripts/shell-recovery.py @@ -131,7 +131,7 @@ def main(argv: list[str] | None = None) -> int: 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)]) + os.execvp("qs", ["qs", "-p", str(selected)]) return 0 diff --git a/roles/desktop/files/quickshell/scripts/system-settings.py b/desktop/quickshell/scripts/system-settings.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/system-settings.py rename to desktop/quickshell/scripts/system-settings.py diff --git a/roles/desktop/files/quickshell/scripts/system_settings_accounts.py b/desktop/quickshell/scripts/system_settings_accounts.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/system_settings_accounts.py rename to desktop/quickshell/scripts/system_settings_accounts.py diff --git a/roles/desktop/files/quickshell/scripts/system_settings_audio.py b/desktop/quickshell/scripts/system_settings_audio.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/system_settings_audio.py rename to desktop/quickshell/scripts/system_settings_audio.py diff --git a/roles/desktop/files/quickshell/scripts/system_settings_input.py b/desktop/quickshell/scripts/system_settings_input.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/system_settings_input.py rename to desktop/quickshell/scripts/system_settings_input.py diff --git a/roles/desktop/files/quickshell/scripts/system_settings_network.py b/desktop/quickshell/scripts/system_settings_network.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/system_settings_network.py rename to desktop/quickshell/scripts/system_settings_network.py diff --git a/roles/desktop/files/quickshell/scripts/system_settings_region.py b/desktop/quickshell/scripts/system_settings_region.py similarity index 78% rename from roles/desktop/files/quickshell/scripts/system_settings_region.py rename to desktop/quickshell/scripts/system_settings_region.py index fd5c4bcb..ea48df53 100644 --- a/roles/desktop/files/quickshell/scripts/system_settings_region.py +++ b/desktop/quickshell/scripts/system_settings_region.py @@ -1,5 +1,6 @@ -"""System region settings through timedated/localed's normal Polkit policy.""" +"""Read region services; stage persistent NixOS declarations for changes.""" import os +import json import subprocess TIME = 'org.freedesktop.timedate1' @@ -58,10 +59,7 @@ def dispatch(self, request): raise ValueError('Choose an installed timezone') if request.get('previous') != self.properties(TIME).get('Timezone', ''): raise ValueError('The timezone changed elsewhere. Discard the pending change and refresh before applying.') - self.call(TIME, TIME, 'SetTimezone', '(sb)', (value, True), interactive=True) - if self.properties(TIME).get('Timezone') != value: - raise ValueError('The timezone was not retained. Refresh before retrying.') - return {'message': 'System timezone updated'} + return self.stage('timezone', value, request['previous']) if action == 'locale': value = request.get('value') if not isinstance(value, str) or value not in choices(['localectl', 'list-locales', '--no-pager']): @@ -69,11 +67,12 @@ def dispatch(self, request): previous = self.properties(LOCALE).get('Locale', []) if request.get('previous') != previous: raise ValueError('System language settings changed elsewhere. Discard the pending change and refresh before applying.') - # SetLocale replaces the whole array. Keep LC_TIME, LC_NUMERIC and - # every other explicit category; only change the requested LANG. - values = [entry for entry in previous if not entry.startswith('LANG=')] + ['LANG=' + value] - self.call(LOCALE, LOCALE, 'SetLocale', '(asb)', (values, True), interactive=True) - if sorted(self.properties(LOCALE).get('Locale', [])) != sorted(values): - raise ValueError('The language settings were not retained. Refresh before retrying.') - return {'message': 'System language updated. Sign out and back in for applications to use it.'} + return self.stage('locale', value, previous) raise ValueError('Unknown region operation') + + def stage(self, field, value, previous): + result = subprocess.run(['cybex', 'config', 'region', field, value, json.dumps(previous)], + capture_output=True, text=True, timeout=50) + if result.returncode: + raise ValueError('Regional preview could not start. Check authorization and cybex update status.') + return {'staged': True, 'message': 'Preview started. Open Updates, review the complete build and apply the generation to save this setting.'} diff --git a/roles/desktop/files/quickshell/scripts/system_settings_security.py b/desktop/quickshell/scripts/system_settings_security.py similarity index 89% rename from roles/desktop/files/quickshell/scripts/system_settings_security.py rename to desktop/quickshell/scripts/system_settings_security.py index 7a3ad832..c1342f55 100644 --- a/roles/desktop/files/quickshell/scripts/system_settings_security.py +++ b/desktop/quickshell/scripts/system_settings_security.py @@ -2,8 +2,8 @@ No secret crosses this API. fprintd keeps prints in its own root-only store, and a security-key registration is an opaque key handle and a public key. -Changes go through their owners' Polkit checks: fprintd's own enrol action for -fingerprints, and pkexec for the authselect switch (cybexos-auth-policy). +Enrolment goes through fprintd's own Polkit checks. Authentication policy is +declared in personal NixOS modules and applied through the staged lifecycle. `python3 system_settings_security.py enroll FINGER` streams one enrolment as JSON lines from fprintd's EnrollStatus signal; SIGTERM cancels it. @@ -28,13 +28,11 @@ FINGERS = ('right-index-finger', 'left-index-finger', 'right-thumb', 'left-thumb', 'right-middle-finger', 'left-middle-finger', 'right-ring-finger', 'left-ring-finger', 'right-little-finger', 'left-little-finger') -# Must match roles/base/files/cybexos-auth-policy: credentials registered for +# Must match nix/modules/security.nix: credentials registered for # another relying party never verify with the CybexOS PAM steps. ORIGIN = 'pam://cybexos' -AUTHSELECT_CONF = Path('/etc/authselect/authselect.conf') +FINGERPRINT_POLICY = Path('/etc/cybexos/fingerprint-enabled') PAM_FILES = (Path('/etc/pam.d/sudo'), Path('/etc/pam.d/polkit-1'), Path('/etc/pam.d/cybexos-lock')) -# The checkout installs the policy helper under /usr/local, the ISO under /usr. -POLICY_HELPERS = (Path('/usr/local/libexec/cybexos-auth-policy'), Path('/usr/libexec/cybexos-auth-policy')) # KeyHandle,UserKey,CoseType,Options as pamu2fcfg writes them; '*' is the key # handle of a resident credential. CREDENTIAL = re.compile(r'^(?:\*|[A-Za-z0-9+/=_-]{8,1024}),[A-Za-z0-9+/=_-]{8,1024},[a-z0-9]{2,16},[+a-z]{0,64}$') @@ -152,15 +150,14 @@ def render_mapping(user, credentials, others): class SecuritySettings: - def __init__(self, fprintd=None, account=None, runner=run, authselect=AUTHSELECT_CONF, - pam_files=PAM_FILES, helpers=POLICY_HELPERS, which=shutil.which): + def __init__(self, fprintd=None, account=None, runner=run, nixos_policy=FINGERPRINT_POLICY, + pam_files=PAM_FILES, which=shutil.which): self._fprintd = fprintd account = account or pwd.getpwuid(os.getuid()) self.user, self.home = account.pw_name, Path(account.pw_dir) self.run = runner - self.authselect = Path(authselect) + self.nixos_policy = Path(nixos_policy) self.pam_files = tuple(Path(path) for path in pam_files) - self.helpers = tuple(Path(path) for path in helpers) self.which = which @property @@ -193,10 +190,10 @@ def reader(self): def features(self): try: - words = self.authselect.read_text().split() - except OSError: - return None - return words[1:] if words else None + enabled = self.nixos_policy.read_text().strip() == 'enabled' + except FileNotFoundError: + enabled = False + return ['with-fingerprint'] if enabled else [] def fingerprint_snapshot(self): reader, path = self.reader() @@ -244,27 +241,12 @@ def set_fingerprint_auth(self, request): enabled = request.get('enabled') if not isinstance(enabled, bool): raise ValueError('Invalid fingerprint setting') - helper = next((path for path in self.helpers if os.access(path, os.X_OK)), None) - if helper is None: - raise ValueError('The sign-in policy helper is missing. Run cybex update, then try again.') - code, output, _ = self.run(['pkexec', str(helper), 'fingerprint', 'on' if enabled else 'off'], 50) - if code in (126, 127) and not output.strip(): - raise ValueError('Authorization was cancelled or denied. Retry and approve the system prompt.') - try: - result = json.loads(output.strip().splitlines()[-1]) - except (ValueError, IndexError): - result = {} - if result.get('success') is not True: - raise ValueError(result.get('error') if isinstance(result.get('error'), str) - else 'Fingerprint sign-in was not changed. Refresh and try again.') - return {'message': 'Fingerprint sign-in turned ' + ('on' if enabled else 'off')} + raise ValueError('Set cybexos.authentication.fingerprint in /etc/nixos/local, then preview and apply with cybex update stage local. Enrolment remains available here.') # ── security keys ─────────────────────────────────────────────────── def key_state(self, features): if not self.which('pamu2fcfg'): return 'missing' - if features and any(feature in features for feature in ('with-pam-u2f', 'with-pam-u2f-2fa')): - return 'authselect' for path in self.pam_files: try: if 'origin=' + ORIGIN in path.read_text(): @@ -317,9 +299,6 @@ def save(self, credentials, others): os.replace(temporary, self.mapping) finally: Path(temporary).unlink(missing_ok=True) - # Fedora's policy labels this folder for PAM; a folder made here - # otherwise inherits ~/.config's label and pam_u2f may not read it. - self.run(['restorecon', '-R', str(directory)], 10) def add_key(self): state = self.key_state(self.features()) diff --git a/roles/desktop/files/quickshell/scripts/t3-cloud.mjs b/desktop/quickshell/scripts/t3-cloud.mjs similarity index 100% rename from roles/desktop/files/quickshell/scripts/t3-cloud.mjs rename to desktop/quickshell/scripts/t3-cloud.mjs diff --git a/roles/desktop/files/quickshell/scripts/theme-apply.py b/desktop/quickshell/scripts/theme-apply.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/theme-apply.py rename to desktop/quickshell/scripts/theme-apply.py diff --git a/roles/desktop/files/quickshell/scripts/theme-packs.py b/desktop/quickshell/scripts/theme-packs.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/theme-packs.py rename to desktop/quickshell/scripts/theme-packs.py diff --git a/roles/desktop/files/quickshell/scripts/theme_gtk.py b/desktop/quickshell/scripts/theme_gtk.py similarity index 98% rename from roles/desktop/files/quickshell/scripts/theme_gtk.py rename to desktop/quickshell/scripts/theme_gtk.py index 676fcd0b..557bdb9b 100644 --- a/roles/desktop/files/quickshell/scripts/theme_gtk.py +++ b/desktop/quickshell/scripts/theme_gtk.py @@ -148,7 +148,7 @@ def owns_text_scale(current, applied): def reload(tokens, directory): if shutil.which('gsettings') is None: - # glib2 ships gsettings on every Fedora install, so a PATH without it + # glib2 ships gsettings on every CybexOS install, so a PATH without it # is a bare environment (the driver tests run with an empty one), not # a desktop with GTK applications to follow it. return diff --git a/roles/desktop/files/quickshell/scripts/theme_hyprland.py b/desktop/quickshell/scripts/theme_hyprland.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/theme_hyprland.py rename to desktop/quickshell/scripts/theme_hyprland.py diff --git a/roles/desktop/files/quickshell/scripts/theme_hyprlock.py b/desktop/quickshell/scripts/theme_hyprlock.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/theme_hyprlock.py rename to desktop/quickshell/scripts/theme_hyprlock.py diff --git a/roles/desktop/files/quickshell/scripts/theme_kitty.py b/desktop/quickshell/scripts/theme_kitty.py similarity index 96% rename from roles/desktop/files/quickshell/scripts/theme_kitty.py rename to desktop/quickshell/scripts/theme_kitty.py index 6d1e8132..c55b8eda 100644 --- a/roles/desktop/files/quickshell/scripts/theme_kitty.py +++ b/desktop/quickshell/scripts/theme_kitty.py @@ -1,7 +1,7 @@ """System theme target: kitty. Renders kitty.conf, the terminal's font size and colours, into the theme -state directory. The vendor fragment (roles/dotfiles/files/kitty.conf, +state directory. The vendor fragment (desktop/defaults/kitty.conf, installed as ~/.config/kitty/cybexos.conf) carries the same keys for the default dark theme as a fallback and includes this file after them, so these values win once the shell has exported its appearance. The font family and @@ -95,7 +95,7 @@ def reload(tokens, directory): GUI_PATTERN], stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=RELOAD_TIMEOUT) except FileNotFoundError: - # procps-ng ships pkill on every Fedora install, so a PATH without it + # procps-ng ships pkill on every CybexOS install, so a PATH without it # is a bare environment (the driver tests run with an empty one), not # a desktop. New kitty windows read the file regardless. return diff --git a/roles/desktop/files/quickshell/scripts/theme_packs.py b/desktop/quickshell/scripts/theme_packs.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/theme_packs.py rename to desktop/quickshell/scripts/theme_packs.py diff --git a/roles/desktop/files/quickshell/scripts/theme_tokens.py b/desktop/quickshell/scripts/theme_tokens.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/theme_tokens.py rename to desktop/quickshell/scripts/theme_tokens.py diff --git a/roles/desktop/files/quickshell/scripts/user-plugins.py b/desktop/quickshell/scripts/user-plugins.py similarity index 99% rename from roles/desktop/files/quickshell/scripts/user-plugins.py rename to desktop/quickshell/scripts/user-plugins.py index 308626b4..bd7b7b3e 100644 --- a/roles/desktop/files/quickshell/scripts/user-plugins.py +++ b/desktop/quickshell/scripts/user-plugins.py @@ -485,7 +485,9 @@ def shell_stamp(pid: str | int) -> str: """The start time of `pid` if it is a running shell, else an empty string.""" try: proc = Path(f"/proc/{int(pid)}") - if proc.joinpath("comm").read_text().strip() not in ("qs", "quickshell"): + # Nixpkgs' Qt wrapper execs .quickshell-wrapped; Linux truncates comm + # to 15 bytes. It is still the upstream Quickshell process. + if proc.joinpath("comm").read_text().strip() not in ("qs", "quickshell", ".quickshell-wra"): return "" return proc.joinpath("stat").read_text().rsplit(")", 1)[1].split()[19] except (OSError, ValueError, IndexError): diff --git a/roles/desktop/files/quickshell/scripts/wallpaper-download.py b/desktop/quickshell/scripts/wallpaper-download.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/wallpaper-download.py rename to desktop/quickshell/scripts/wallpaper-download.py diff --git a/roles/desktop/files/quickshell/scripts/wallpaper-thumbnail.py b/desktop/quickshell/scripts/wallpaper-thumbnail.py similarity index 100% rename from roles/desktop/files/quickshell/scripts/wallpaper-thumbnail.py rename to desktop/quickshell/scripts/wallpaper-thumbnail.py diff --git a/roles/desktop/files/quickshell/shell.qml b/desktop/quickshell/shell.qml similarity index 100% rename from roles/desktop/files/quickshell/shell.qml rename to desktop/quickshell/shell.qml diff --git a/roles/desktop/files/quickshell/themes/catppuccin-latte/theme.json b/desktop/quickshell/themes/catppuccin-latte/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/catppuccin-latte/theme.json rename to desktop/quickshell/themes/catppuccin-latte/theme.json diff --git a/roles/desktop/files/quickshell/themes/catppuccin-mocha/theme.json b/desktop/quickshell/themes/catppuccin-mocha/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/catppuccin-mocha/theme.json rename to desktop/quickshell/themes/catppuccin-mocha/theme.json diff --git a/roles/desktop/files/quickshell/themes/cybexos/theme.json b/desktop/quickshell/themes/cybexos/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/cybexos/theme.json rename to desktop/quickshell/themes/cybexos/theme.json diff --git a/roles/desktop/files/quickshell/themes/everforest/theme.json b/desktop/quickshell/themes/everforest/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/everforest/theme.json rename to desktop/quickshell/themes/everforest/theme.json diff --git a/roles/desktop/files/quickshell/themes/gruvbox/theme.json b/desktop/quickshell/themes/gruvbox/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/gruvbox/theme.json rename to desktop/quickshell/themes/gruvbox/theme.json diff --git a/roles/desktop/files/quickshell/themes/kanagawa/theme.json b/desktop/quickshell/themes/kanagawa/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/kanagawa/theme.json rename to desktop/quickshell/themes/kanagawa/theme.json diff --git a/roles/desktop/files/quickshell/themes/nord/theme.json b/desktop/quickshell/themes/nord/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/nord/theme.json rename to desktop/quickshell/themes/nord/theme.json diff --git a/roles/desktop/files/quickshell/themes/rose-pine/theme.json b/desktop/quickshell/themes/rose-pine/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/rose-pine/theme.json rename to desktop/quickshell/themes/rose-pine/theme.json diff --git a/roles/desktop/files/quickshell/themes/tokyo-night/theme.json b/desktop/quickshell/themes/tokyo-night/theme.json similarity index 100% rename from roles/desktop/files/quickshell/themes/tokyo-night/theme.json rename to desktop/quickshell/themes/tokyo-night/theme.json diff --git a/desktop/runtime-compatibility.json b/desktop/runtime-compatibility.json new file mode 100644 index 00000000..f0605562 --- /dev/null +++ b/desktop/runtime-compatibility.json @@ -0,0 +1,6 @@ +{ + "schema": 1, + "quickshell": ["0.3"], + "qt": ["6.11"], + "hyprland": ["0.56"] +} diff --git a/docs/architecture/ownership.md b/docs/architecture/ownership.md index c556be96..91e094ac 100644 --- a/docs/architecture/ownership.md +++ b/docs/architecture/ownership.md @@ -1,148 +1,36 @@ -# CybexOS ownership boundary - -CybexOS has two supported installation paths. ISO installations receive vendor -files through the `cybexos-desktop` RPM, installed under -`/usr/share/cybexos`; source-checkout installations use versioned releases and -may opt into a writable development checkout. In both paths, user preferences -and personal packages remain in user-owned locations. - -| Owner | Path | Update behavior | -| --- | --- | --- | -| Vendor, ISO | `/usr/share/cybexos/` | Owned by `cybexos-desktop`; replaced by RPM upgrades | -| Vendor, ISO | `~/.local/share/cybexos/runtime` | Compatibility symlink to `/usr/share/cybexos/runtime` | -| Vendor, source checkout | `~/.local/share/cybexos/runtime/` | Reconciled from the selected verified release | -| Vendor, source checkout | `~/.local/share/cybexos/releases/` and `current` | Staged and atomically selected by the source updater | -| User | `~/.config/cybexos/shell.json` | Shell preferences; preserved by package/release updates | -| User | `~/.config/cybexos/hypr/` | Optional `user.lua`, `hypridle.conf`, and `hyprlock.conf` overrides | -| User | `~/.config/cybexos/displays.json` | Settings → Displays choices per physical monitor | -| User | `~/.config/cybexos/fusebox/` | The Fusebox widget's private management key (0600); written only from its settings | -| User | `~/.local/share/cybexos/themes/` | User theme packages (`cybex theme install`, Settings → Appearance → Save current as theme); not reconciled or pruned | -| User | `~/.local/share/cybexos/plugins/` | API 1 widget packages; not reconciled or pruned | -| User | `~/.config/cybexos/plugins.json` | Widget enablement and preferences; not written by Ansible | -| User | `~/.local/share/cybexos/plugin-data/` | Persistent widget data; retained on update and uninstall | -| State | `~/.local/state/cybexos/` | Health, update, migration, and shell runtime state | -| State, machine | `/var/lib/cybexos/` | Reconciliation state, backups, and hardware setup status | - -On the RPM path, package upgrades defer versioned account and machine policy -updates to `cybexos-reconcile.service` and its timer. Reconciliation preserves -existing edits by backing up files before updating them; inspect status with -`sudo /usr/libexec/cybexos-reconcile --status` and request a retry with -`sudo /usr/libexec/cybexos-reconcile --retry`. The user initialization payload -uses versioned defaults, separately from unversioned tool-seed data. Hardware -setup status is recorded at `/var/lib/cybexos/hardware-status.json` as -`pending`, `completed`, or `failed`; a pending camera setup resumes after a -same-kernel reboot. The welcome window displays this status. On both paths the -NVIDIA role records `/var/lib/cybexos/nvidia-status.json` (world-readable) and, -while Secure Boot key enrollment is pending, the one-time password in -`/var/lib/cybexos/nvidia-mok.json` (root and `wheel` only); it owns -`/etc/cybexos/nvidia-session.env` and `/usr/local/libexec/cybexos-nvidia`. - -On the source-checkout path, the session starts Hyprland with the vendor entry -point. Vendor modules load first, then saved Settings → Displays choices, and -`~/.config/cybexos/hypr/user.lua`, when present, loads last. Bindings it adds -with a `Group: Label` description appear in the Super+K cheatsheet -([keyboard shortcuts](../keyboard-shortcuts.md)). The idle and lock services -prefer their same-named user configuration files and otherwise use vendor -defaults. User overrides are not silently replaced by release updates. - -Quickshell starts with an explicit `qs -p` path. The legacy -`~/.config/quickshell` and `~/.config/hypr` trees are not runtime inputs after -the transition. On the first layered convergence, their entries are classified -against the previously active release. Customized and unrecognized entries are -copied into a timestamped migration directory, a JSON report is written, and -the legacy trees themselves are left untouched. No ambiguous QML or Lua is -translated automatically. - -## Development source switch - -`cybex dev enable /absolute/path/to/checkout` selects live Quickshell sources -and static Hyprland modules from a validated, user-owned Git checkout on the -source-checkout path. Rendered machine modules continue to come from the -installed runtime. The command records only the canonical path and reloads -managed desktop components; it never fetches, resets, merges, commits, or -writes inside the checkout. - -The Hyprland startup hook must also work when the development checkout is -selected on an ISO installation. Checkout installs place the ordered session -starter in `/usr/local/libexec`; ISO/RPM installs place it in `/usr/libexec`. -`autostart.lua` resolves an executable helper at login, including when the -checkout is loaded verbatim without the image packager's path rewriting. -Otherwise Hyprland can start with `hyprland-session.target` inactive, leaving -Quickshell, wallpaper, idle handling and desktop portals unavailable. The -session startup fixtures exercise checkout, packaged and ISO development -layouts in both source gates. - -Use `cybex dev status` to show the active source and `cybex dev disable` to -return to the verified vendor runtime. Internet updates continue to stage and -activate releases while development mode is on; they do not modify the selected -checkout or user-owned paths. ISO installations use RPM updates instead of this -release switch. - -## Enforcement rules - -- ISO package upgrades own files under `/usr/share/cybexos`; do not edit those - deployed vendor files in place. -- Source deployment may prune only its vendor-owned runtime root. -- Normal convergence must not copy, template, link, or remove children below - user-owned roots, except to create an absent directory or perform an - explicitly non-overwriting legacy migration. -- Uninstall removes vendor runtime and integration artifacts, not user-owned - Quickshell, Hyprland, theme, or plugin trees. -- Ownership-preservation checks simulate updates with user data sentinels. - -## Customization compatibility - -File ownership and runtime compatibility are separate requirements. The -[user widget API](user-widgets.md) gives personal QML a versioned interface, -independent preferences, and real-engine compatibility fixtures. Agents use -that interface for personal widgets; a source checkout is for changing the -vendor implementation. A future refactor must retain supported API adapters. - -The distro-wide target is vendor defaults followed by explicit user choices. -New defaults apply to settings without an explicit choice; they must not erase -user choices even when those choices equal an old default. Migration must -preserve unknown fields, retain a recoverable original, and avoid downgrading -data on rollback. A new API or schema needs a compatibility plan and -upgrade/rollback fixtures before it is released. - -Shell settings use a sparse schema-29 document (the version authority is -[`SettingsHelpers.js`](../../roles/desktop/files/quickshell/Common/SettingsHelpers.js)): the presence of a known -key records an explicit choice, including a choice equal to the current -default. Reset removes the override; Undo restores its ownership as well as -its value. Legacy stored values are conservatively treated as explicit. Visual -redesigns no longer infer an untouched preference from equality with an old -default. Unknown JSON fields survive edits and resets, and a newer schema is -read-only on an older shell. - -The asynchronous settings writer merges independent external edits, rejects -conflicting writes, and confirms fsync and atomic publication before reporting -success. A rejected edit is retained in a `shell.json.conflict-*` sidecar before -the form reloads the external values. The first schema migration retains the -exact original in `shell.json.before-migration-*`. These files live beside the -user's settings and are retained for recovery; the shell never prunes them. -The production Qt document component has real-engine lifecycle tests for -queued changes, retries and unknown data, alongside filesystem transaction tests. - -Personal application stores (Fastfetch, Voxtype, Oh My Posh, MIME associations, -XDG directories and npm configuration) are seeded only when absent. Shared -Fish, Kitty, Git and SSH fragments use the same ownership ledger on checkout -and ISO paths. A fragment advances only if it still matches its last installed -bytes; conflicting edits, symlinks and explicit deletions survive. Adopted -bytes are backed up before replacement. Unknown legacy fragments remain -user-owned instead of being guessed at from their filename. - -Managed includes follow each application's precedence: Git/Kitty defaults -come first, while SSH fallbacks come last in an explicit `Host *` scope. -Git credential helpers accumulate instead of overriding, so vendor credential -helpers are omitted when personal configuration provides its own chain. The -SSH vendor fragment lives outside `.ssh/config.d` so wildcard includes cannot -accidentally give it priority over a personal host. Moving an old unedited -include retains its original file; edited include blocks are left intact. - -Remaining release coverage should exercise service overrides, optional package -and service additions, and supported previous releases across failure and -rollback, beyond file sentinels. Application ownership is scoped to these -managed fragments; independent application databases remain the application's -responsibility. - -The widget contract does not imply ownership of unrelated application state. +# Ownership and installation contract + +`nix/modules` is authoritative for fresh NixOS installations. The ISO imports +these modules plus a live-only profile; both checkout entry points invoke that +ISO's `cybexos-install`. They do not provision another operating system. + +| Layer | Locations | Update semantics | +|---|---|---| +| Vendor | Nix store; `/etc/cybexos/runtime`; system-profile binaries | Immutable, selected by authenticated CybexOS input | +| Machine/personal declarations | `/etc/nixos/{flake.nix,flake.lock,machine.nix,local/}` | Root-owned working copy; staged separately; vendor defaults use Nix module priorities | +| Personal desktop | `~/.config/cybexos/{shell.json,plugins.json,displays.json,hypr/user.lua}` | Live preferences; never reconciled by vendor updates | +| Personal code | `~/.local/share/cybexos/{plugins,themes}` | API 1 native/Omarchy packages, retained across system generations | +| Persistent state | Home; `/var/lib`; `/etc/NetworkManager/system-connections`; plugin-data | Independent of system generations; requires application-aware backup/migration | +| Secrets | `/var/lib/cybexos/secrets`; keyring; private runtime credentials | Root directory 0700/files 0600; no plaintext in store, source, argv or logs | +| Deployment records | `/var/lib/cybexos/transactions` | Private immutable source/lock snapshots, archive identities and GC roots | + +The machine's flake has one supported stable `nixpkgs` input. CybexOS follows it +and contributes configuration/assets, not a second host package universe. +Lanzaboote is a vendor dependency following the same Nixpkgs. Personal inputs +remain separately locked. Native camera source pins have a documented narrow +hardware reason and a removal/requalification policy. + +A stage copies the complete personal configuration, updates only selected +inputs, verifies lock ownership and publisher identities, archives inputs, +builds the full system and checks the candidate desktop. Apply requires the +reviewed ID/token and rejects intervening personal edits. Boot/login health +confirms a deployment; an interrupted activation restores coherent configuration +and boot selection. Working edits present at rollback are retained privately. + +Nix module list definitions merge; administrator normal definitions override +vendor `mkDefault` values. The primary user's shell uses priority 900 because +NixOS also supplies it with `mkDefault`; a normal administrator definition wins. +Use `mkForce` deliberately, not to hide conflicts. + +No updater changes `system.stateVersion`. Routine updates advance within 26.05; +a major release upgrade requires explicit edits and fresh qualification. diff --git a/docs/architecture/user-widgets.md b/docs/architecture/user-widgets.md index 87e334f3..5686084b 100644 --- a/docs/architecture/user-widgets.md +++ b/docs/architecture/user-widgets.md @@ -33,10 +33,10 @@ the shell; the command helper honors these when present too. | Package source and assets | `~/.local/share/cybexos/plugins//` | Never reconciled with vendor files | | Enablement, column, order, width, settings | `~/.config/cybexos/plugins.json` | Never written by deployment or shell-settings migrations | | Plugin data | `~/.local/share/cybexos/plugin-data//` | Retained; plugin controls its own format | -| Loader and API adapter | `~/.local/share/cybexos/runtime/quickshell/` | Replaced by distro updates | +| Loader and API adapter | `/etc/cybexos/runtime/quickshell/` | Replaced by distro updates | -Uninstall retains user widget packages and data. Disabling a widget changes -only its enablement flag. Existing releases before API 1 cannot display these +System updates and generation rollback retain user widget packages and data. +Disabling a widget changes only its enablement flag. Existing releases before API 1 cannot display these widgets, but their ownership boundary retains the files for re-enablement on a supporting release. Compatibility claims apply to releases supporting API 1. @@ -130,10 +130,10 @@ column; the default is right. The left and center areas each have 15% of the screen width; the right area has 25%, with room reserved for an overflow count. Excess widgets are hidden with a tooltip listing their names. Placement and width budgets are defined by -[`Bar.qml`](../../roles/desktop/files/quickshell/Bar/Bar.qml) and -[`UserWidgets.qml`](../../roles/desktop/files/quickshell/Bar/UserWidgets.qml); +[`Bar.qml`](../../desktop/quickshell/Bar/Bar.qml) and +[`UserWidgets.qml`](../../desktop/quickshell/Bar/UserWidgets.qml); registry validation lives in -[`user-plugins.py`](../../roles/desktop/files/quickshell/scripts/user-plugins.py). +[`user-plugins.py`](../../desktop/quickshell/scripts/user-plugins.py). Built-in drag ordering and custom shell popouts are not part of this plugin API. Use QtQuick content inside the supplied bounds. @@ -152,7 +152,7 @@ An incompatible plugin is preserved with its enabled preference intact so a future compatible host can load it again. Do not claim that merely retaining its files makes it functional on a host that does not support its API. -`tests/user-plugins.py`, included in `tests/run`, runs a fixed API 1 widget in +`tests/user-plugins.py`, run separately in CI, runs a fixed API 1 widget in the real Quickshell engine with its package outside the runtime. It replaces the runtime tree for simulated N, N+1, and rollback runs, checks package bytes and preferences, tests local QML imports, malformed/incompatible widgets, and diff --git a/docs/audit-implementation-2026-10-21.md b/docs/audit-implementation-2026-10-21.md deleted file mode 100644 index 0b808ae1..00000000 --- a/docs/audit-implementation-2026-10-21.md +++ /dev/null @@ -1,431 +0,0 @@ -# Audit implementation, 2026-10-21 - -This records the engineering changes accepted after the CybexOS repository -audit. The starting point was clean `main` at -`d668048cd95ede9fac357df8cdc42a34c24e04b1`; implementation is on -`codex/implement-audit-20261021`. No release, commit, or public distribution is -implied by this record. Implementation and independent review used GPT-6.1-SOL -agents, with focused scopes and integration in the primary checkout. - -## Accepted changes - -| ID | Result | Main implementation boundary | -| --- | --- | --- | -| F01 | Account-specific, ownership-aware Docker grants and revocation, including explicit legacy opt-outs | Shared `roles/base/tasks/docker-access.yml` and `image/library/cybexos_docker_access.py` | -| F02 | Backups include Steam/Proton saves and readable rootless volumes; partial snapshots are visible failures, with export guidance for inaccessible/database data | `assets/scripts/cybexos-backup`, Backup Settings, shared installation units | -| F03 | Wallpaper preferences seed only when absent; existing sparse, empty, corrupt, future, or concurrently created settings survive | Shared managed-file module and wallpaper tasks | -| F04 | Provisioning has a bounded systemd cgroup lifetime; draining precedes lock release, retry, and recovery | Installed provisioner, reconciler, durable updater | -| F05 | Publication verifies a shared, versioned inventory of completed scenario evidence | `image/qualification_contract.py`, qualification producer and release verifier | -| F06 | The existing encrypted-US upgrade guest also qualifies public-updater interruption, automatic pre-login recovery, another boot, and successful commit | `image/public_update_qualification.py` and release qualification | -| F07 | Required source CI enforces and verifies the same qualified Quickshell series as the product | Shared Fedora CI action and product versionlock policy | -| F08 | Compiler provenance records immutable builder identity, resolved packages/tool versions, and shipped binary digests, exported with SBOM binding | Shared source builder and image/release artifact collection | -| F09 | Hermes authentication returns independently of optional discovery; discovery is single-flight and session-scoped; startup offline failures recover with bounded backoff | Hermes bridge and remote authentication state | -| F10 | GitHub stats cache contains successful results; failed fetches show unavailable/retry rather than permanent zeroes | GitHub singleton and commit popover | -| F11 | Successful unchanged plugin discovery clears its own error; preference failures remain separate; registry lock acquisition expires before mutation | Plugin singleton and existing Python registry helper | -| F12 | Keyboard/touchpad drafts survive Settings search, navigation, and close/reopen; successful Apply advances the exact committed version | Session-owned input draft state and input persistence helper | -| F13 | Compositor targeting uses a stable Settings identity across languages | Settings window and shared compositor rules | -| F14 | The registered notification drawer's On/Focus choices support keyboard and accessible activation with visible focus | Existing accessible button primitive in Drawer Notifications | -| F15 | Settings search matches translated display labels and English aliases | Existing search index and translation catalog | -| F16 | Undocumented bundled wallpapers are replaced by original procedural landscapes; PUBLIC distribution fails closed until actual inventory-bound review | Asset provenance, native/web defaults, distribution inventories and release workflows | -| F17 | Recovery search navigates to the existing recovery section anchor | Settings search destinations and Recovery group | -| F18 | Welcome fits available logical screen space, scrolls, and reveals keyboard focus | Shared Welcome payload and compositor rules | -| F19 | Nonempty launcher queries retain normal text navigation; Ctrl+Tab selects providers | Launcher command-key handler and interaction documentation | - -The ISO remains authoritative. Checkout installation, ISO reconfiguration, and -RPM reconciliation consume the shared policies and desktop sources. Saved -choices, ownership records, stale-write validation, personal settings, and -rollback boundaries remain part of the contract. No migration removes an -installed user's former wallpapers or resets their selection. - -The requested original product artwork remains in source: seven PNGs under -`assets/wallpapers/` total 139,958 bytes; three web variants under -`web/public/wallpapers/` total 91,297 bytes. Their generator and provenance -records accompany them. These replace the undocumented bundled copies while -preserving installed personal files and saved selections. - -## Review corrections - -Independent review found and corrected additional transition failures: - -- Automatic Docker convergence must not grant secondary accounts the primary - account's consent. Installed repair also needs an account-specific intent - boundary; it is not a new installation choice. -- Restic can create a partial snapshot while reporting unreadable subUID-owned - volume contents. Partial backups retain the snapshot and previous successful - timestamp, skip retention, return nonzero for manual runs, and expose bounded - error details. The unreadable count is reported read errors, not an invented - count of omitted child files. -- Input Apply must settle its submitted baseline and exact committed version - together, regardless of snapshot/completion ordering. Cached pre-write - snapshots cannot replace that baseline on reopening or Discard; later - external writes still conflict normally. -- Hermes discovery must restart the selected-session observer when it learns - a different stream path, including after an early default-path 404. Stale - sessions cannot restart old observers. -- An automatically restored baseline has SSH disabled and its firewall closed. - Recovery qualification reuses the authenticated console bootstrap after - recovery's restart; the temporary SSH/firewall changes remain runtime-only. - -## Validation and limits - -The full source runner passed all 17 groups in a disposable Fedora 44 container: -syntax, ShellCheck/Ansible/YAML/Ruff lint, 1,481 JavaScript tests across 152 -files, compositor policy, 357 QML files and four test harnesses, native QML -lifecycle/runtime checks, Quickshell migration, 47 Python helper/repository -fixture suites, hardware/Plymouth/skills/updater/screenshot/brightness/snapshot -and legacy migration checks. Existing qmllint warnings remain: three property -overrides, three missing types, and two incompatible types; no qmllint errors. - -The final refreshed image source gate passed its 30 tooling tests, 649 Python fixtures, and 14 -installer JavaScript checks. Two Python cases skipped as container root were -rerun successfully as UID 1000: writable-ancestor PXE staging and regular-user -installer account seeding. Source/image fixture parity is not an end-to-end -installation result. Final account-intent corrections passed 30 wrapper/module -fixtures, eight application-default checks, and independent caller review; -the refreshed full image gate includes these corrections. - -The pinned Fedora 44 cloud guest, with systemd 259.5 and SELinux enforcing, -executed the real provisioning timeout fixture: a detached writer ran, the -two-second limit drained the complete cgroup, no writes occurred afterward, -and retry retained serialization. Real guest account checks also verified -Docker grant/revoke, explicit legacy opt-out, and unrelated/administrator group -preservation. This checks those mechanisms; it does not qualify a CybexOS ISO. - -That guest exposed a launch failure when `systemd-run --pipe` was used on the -tested Fedora environment. Journal identifiers and bounded finite journal -snapshots replace descriptor forwarding and orphan journal followers. This -alone does not remove the public-bus dependency: systemd 259 also routes -`--wait` through that bus. The verified installer chroot remains the sole -explicit exception to live systemd containment. The regression timeout remains -two seconds. - -Two additional real-build/convergence failures prompted focused corrections. -The candidate build `20261021T200121Z-4cda2099` stopped before producing an ISO: -GitHub's anonymous API allowance was exhausted even though pinned application -payloads were cached. The installer now accepts only a unique, regular cached -asset for an inventory-pinned tag/checksum, verifies a private copy, and installs -from that copy without metadata access. Missing, corrupt, or ambiguous cache -entries retain the existing metadata path; unsafe paths fail closed. - -The first complete checkout VM run made 158 changes, then one change on its -second pass, at `/etc/grub.d`; later assertions and uninstall were not reached. -Fedora's `grub2-common-1:2.12-66.fc44` owns that directory as `0700 root:root`. -Recovery setup now creates only missing hook directories and preserves existing -upstream/admin attributes while managing CybexOS hook files. The later writer -in that failed guest was not identified; the inspected `grub2-mkconfig` did not -contain a permission change. Regression fixtures cover RPM assembly and actual -Ansible second-pass convergence. The refreshed gates and successful build and -convergence runs below include these corrections; the failed attempts are not -qualification. -The retry `20261021T201607Z-ab05186b` passed pinned application installation and -source compilation, then encountered the same exhausted anonymous API allowance -in the intentionally moving T3 Code download. No artifact was produced. Its -response advertised a reset at 2026-10-21 20:34:16 UTC; that external failure did -not justify replacing the moving-input policy or introducing credentials. -After the reset, `20261021T202930Z-ae513a9e` completed both RPMs and reached ISO -composition. Composition failed because the official Fedora OpenH264 repository -returned 404 for the primary metadata referenced by its own `repomd.xml`. -A subsequent direct read of that exact URL returned 200; no repository policy -or dependency was changed in response. No completed ISO or qualification was -produced by that attempt. - -The corrected complete `tests/fedora-vm-convergence` run passed in 12m41s: -157 first-pass changes (`ok 301`), zero second-pass changes (`ok 261`), rendered -configuration/service assertions, uninstall (27 changes, `ok 50`), restoration, -and project-state removal. It used the pinned Fedora 44 base, all tags, and the -unchanged 100-minute bound. Its pre-launch tracked diff SHA-256 was -`da1680a76e45488faef00885d2bc85978d6398fac8aadedeb088b8106fdd507a`. -Existing generic-guest exclusions remained: Docker/optional features, hardware -roles, and live Quickshell. The separate real guest Docker checks above cover -that policy mechanism. This is checkout convergence evidence, not ISO parity or -physical hardware qualification. Its lease, capacity reservation, and artifacts -were released and verified absent. - -Candidate build `20261021T203819Z-03404dcc` completed ISO/RPM assembly and -post-install validation. The ISO SHA-256 is -`8fc6c8f4559aa8d671c4946896efa6240a9ba7636cd44bfb418c30d51e540482`; -its installed source-content identity is -`32291346be09d24db6dfebbb9640de78f5a7e63c0e1fbad6b8f9e44524c5ee09`. -All delivered checksums passed. The independently copied desktop/offline RPMs -under guest qualification matched the final artifacts byte for byte. Compiler -provenance was exported for both source applications, including 193 resolved -builder RPMs, immutable image identity, and the shipped binary digests. -The temporary testing ISO was published through checksum-first PXE staging; -iVentoy refresh, list presence, running PXE status, and service checks passed. -After the fresh-install check completed, that task-owned served ISO and checksum -were removed. The subsequent refresh succeeded, its entry was absent, PXE -remained running, and `iventoy.service` remained active. Prior images were -preserved. - -The candidate's real UEFI `plain-nl` qualification passed all 12 mandatory -fresh-install checks with outbound networking blocked: installer-only session, -graphical installation, plain Btrfs, unchanged guard disk, installed boot -without ISO, password login, desktop/application contract, selected Dutch -locale/timezone/keyboard, live-state cleanup, SELinux enforcing, and -password-required sudo. The report passed the shared evidence validator and -bound the exact ISO and source-content digests above. This run did not request -outcome capture, saved-choice reapplication, or the native Settings lifecycle -extension; those are not established by this guest report. Its guest, disks, -capacity reservation, and temporary browser-path alias were removed, and the -lease was verified closed without remaining resources or cleanup errors. - -The host browser runner used a short task-owned `TMPDIR` symlink into its -managed workspace: Chromium's Unix socket exceeded the pathname limit under -the long lease path. The alias is removed during teardown. Qualification -startup and diagnostic attempts that stopped before the public-updater checks -provide no automatic-recovery evidence. The clean retry records the loaded -module path, SHA-256, and actual launcher command before executing the public -updater phase. - -The encrypted upgrade qualification exposed a host-harness authentication error: -combining `sudo -k` and `-v` validates without retaining a ticket. The launcher -now invalidates separately, then validates through the existing password policy -before executing the ordinary updater. Actual password-required, non-TTY sudo -checks in the disposable Fedora environment confirmed both behaviors. Exit, -timeout, and invalid-JSON diagnostics are bounded and redact the password. -Twelve focused launcher tests, independent review, and both refreshed full -source/image gates passed. This correction -changes the host qualification harness only; the candidate updater, RPMs, and -ISO remain unchanged. - -The upgrade guest uses the retained baseline ISO -`CybexOS-Live-44-20261021T121337Z-87cc6b87.iso`, SHA-256 -`28603ac50e9167f1896d5f0657d171eb8bd3ac429d1a54dbc37540a98b275552`. -The candidate desktop RPM is 2,263,927 bytes, SHA-256 -`25c3fd93c745296893a4da217cc24b6ccaa52aeace92337838bd014024b3795c`; -the offline-apps 1.5.2 RPM is 1,639,393,555 bytes, SHA-256 -`1530c5052112319c856cca17c175f8cdc4fe73360902641c5b313a16a3672366`. -The corrected public-update host helper SHA-256 is -`12b69686b8abd978c0830368e193a355b9241d6795bcad74b6f73855cf333a64`. - -The clean encrypted-US run passed baseline installation, candidate signature -and transaction checks, explicit recovery boot/restore, public-updater -interruption after package application, automatic pre-login rollback/restart, -preservation checks, and a subsequent cold boot. It then failed the second -public update before commit: Fedora's systemd RPM triggers reported transport -disconnects, and the contained reconciliation launch timed out before its unit -started. The updater restored its checkpoint and reported failure. This is a -failed qualification; successful public commit and the final two-second -provisioning check remain unverified in this run. - -PID 1 logged API-bus disconnects at boot seconds 98.211 and 98.582, followed by -a roughly 25-second gap around the failed launch. PID 1 and dbus-broker did not -restart. Full-boot AVC/USER_AVC/SELINUX_ERR searches found no relevant denial. -Later bounded harmless transient-unit probes passed through both the private -manager socket and public system bus, including the updater's `initrc_t` -domain and an outer system-service cgroup. Those probes establish that the -settled manager recovered; they neither reproduce the earlier failure nor -qualify a successful update. The exact disconnect cause remains unresolved; -no security policy was weakened and no update was retried inside the -diagnostic guest. Its lease, capacity reservation, six recorded QMP runtime -directories, processes, temporary path alias, and diagnostic guard were -removed and verified absent. - -Source review supports a bounded synchronization point after the package -transaction. The shared updater now performs one root-private manager reload, -then requires a valid public-manager reply, and checks public readiness again -before commit. Failures use the existing protected rollback path. Reload does -not replay missed RPM-trigger actions. The host qualification therefore also -checks system and user pending service markers, required service states, the -actual public manager bus, and ordinary password-authenticated sudo before -awarding commit evidence. These changes require a rebuilt candidate and a new -complete qualification; the failed report above cannot establish their result. - -The subsequent manager-synchronization build `20261021T215356Z-9bea00da` -completed. Its ISO SHA-256 is -`bdf569e3c72031f8fd7ec0ef0b986837524368c3c582f05eff5f0742267a77f2`, -with source-content identity -`cbfcad17dea46969fb9bcc045fc865cbaf0954bb31fbccd5e32235c5d642ec65`. -The new plain-NL guest passed all 12 standard checks and the report validator. -The wrapper's final whole-checkout guard detected announced subsequent -host-only changes; this does not invalidate the frozen guest artifact result, -but it is not a passing wrapper invocation or qualification of the newer tree. - -An encrypted attempt then exposed another host observation dependency: fresh -SSH/PAM sessions stalled while logind waited for the manager bus during DNF. -The interruption adapter had already written its applied sentinels, but the -host timed out before delivering interruption. The adapter intentionally holds -before returning to the updater, so the product's post-DNF synchronization had -not yet run. Qualification now establishes one ordinary SSH and -password-authenticated root control process before each normal user launch. -Polling, interruption, drain, staging cleanup, and shutdown reuse that process; -EOF or protocol failure fails without reconnecting or relaunching. Final health -still uses fresh ordinary SSH after the controls close. Independent review also -required passive worker verification immediately after commit, before any -timeout test or active cleanup could hide a surviving worker. All 35 public -harness and 25 offline-input fixtures passed, including mutation checks for -the passive leak regression, followed by clean independent review. - -That corrected full run reached 20 of 23 checks. It passed actual after-apply -interruption, automatic pre-login recovery, preservation, and the subsequent -cold boot. The second public update failed reconciliation and rolled back. -Both explicit provisioning bodies completed successfully, including invocation -`dd703fcf90254ec9bdd31c61383d03de`. A later, implicit transient bridge from -`systemctl --user --machine=qualification@.host daemon-reload` is the supported -failing call: the paired transient-launch and daemon-reload errors follow the -successful bodies. At boot second 102.150554, SELinux denied PID 1 write access -to an `initrc_t` pipe; its API bus disconnected immediately afterward. Descriptor -receipt can itself trigger this permission check. The exact pipe endpoint and -kernel error path were not captured, so this is not proof of a PID 1 write -system call or of a provisioning-result collection race. - -Installed reconciliation and feature changes now use the account's local user -bus through `runuser`, with explicit home/runtime/bus environment, a 30-second -deadline, five-second group-kill grace, and an existing-bus guard. Review caught -that killing only `runuser` could leave its client holding the updater's output -pipe; a real stalled-child fixture verifies the external timeout drains that -process group and closes the pipe. Failed reloads remain failures. Checkout -configuration already uses local account user-manager scope. Containment and -post-RPM manager synchronization remain intact. This product correction was -rebuilt and qualified in the final runs below. The failed 20/23 report supplies -no successful-commit, passive post-commit worker, or actual two-second timeout -credit. RPM-trigger bridge errors remain separate: manager reload does not -replay missed trigger work, and final service/marker health remains mandatory. -All failed-run resources were released and verified absent. - -The final product build `20261021T230819Z-ff4d1c80` completed ISO/RPM assembly -and all 18 delivered checksum checks. Its ISO is -`CybexOS-Live-44-20261021T230819Z-ff4d1c80.iso`, 8,059,355,136 bytes, SHA-256 -`a9ac9b820a0c694519085750df77babdc8973368e530c167be06fb505f177c59`. -The built source-content identity is -`6921f61669e9f90fb951af4e2790cb5771ff8b8f9623544399dbcb110a53160c` -across 1,452 files. Its desktop RPM SHA-256 is -`4c9e86666dee64a5b174a8f2eb30355d9f781f052eb2056ee224204cb922515f`; -the offline-apps 1.5.2 RPM SHA-256 is -`df9ce5a556c4717c785e1058f9384cc17b7e94eec664e98fb2d7f1e4901c80f5`. -Candidate copies matched the final delivery. Compiler provenance and shipped -binary/source binding were verified again for this build. - -The final ISO passed all 12 standard UEFI plain-NL fresh-install checks in one -attempt, with outbound networking blocked. Shared validation and both exact -source guards passed without exceptions. The wrapper subsequently exited 1 -because a PXE cleanup API request timed out. Bounded follow-up verification -obtained refresh `success`, confirmed the task's ISO entry absent, PXE running, -and `iventoy.service` active; no guest was rerun or service restarted. All owned -resources were released and independently verified absent. This fresh report -does not include outcome capture, Settings/reapplication extensions, physical -hardware, or Secure Boot. - -Two later encrypted attempts exposed host qualification assertions after the -product had committed. Both remain failed 20/23 reports. First, a raw-source -comparison rejected the RPM's intentional snapshot-helper path rewrite from -`/usr/local/libexec` to `/usr/libexec`. The installed helper matched the canonical -package. Qualification now verifies the exact protected candidate RPM's size -and SHA-256, unique SHA256-manifest entry, installed ownership and content, and -exact installed NEVRA. The packaged helper digest is -`dc9b9d779ca5b634794622bf25df869a0c46b859ac5f26f37c0d696ebb815dba`; -the separately retained raw-source digest is -`1cc456440c28c44bbb012d7a58292b97a06b3d6d3a1842f877c994f5e63d2022`. - -The next attempt completed commit, passive worker observation, and the actual -two-second timeout, but its final fresh health collector passed `-.mount` and -`-.slice` to `systemctl show` without an option terminator. The host collector -now inserts `--` before unit names in both scopes. Nested diagnostics retain -complete stderr up to 2,000 characters; larger stderr is omitted with explicit -size/limit metadata so truncation cannot expose a password suffix before outer -redaction. Literal escaped unit names remain unchanged. Independent review -and mutation regressions cover these corrections. All 43 public-harness and -25 offline-input fixtures passed, followed by the full Fedora gates described -above. Neither correction changes the installed product or weakens health -requirements. - -The final encrypted-US upgrade qualification passed all 23 executed checks, -exit 0, on 2026-10-22. It booted the retained baseline ISO identified above and -used the exact final candidate RPMs plus 82 bound dependency RPMs. It exercised -real after-apply interruption, automatic pre-login rollback/restart, root, -package, vendor and personal-data preservation, and a separate settled cold -boot. Public run `20261022-003645-4061-18130` committed at 00:37:18 UTC. Passive -worker verification followed immediately, before the timeout test or cleanup. -The real two-second detached-writer test verified complete cgroup draining, -no later writes, and retained serialization for retry. - -Both control SSH processes were closed and reaped before final fresh ordinary -SSH health inspection. Public `Manager.Version` returned `259.10-1.fc44`; -432 system and 171 user units had no pending restart/reload markers. All 18 -required system and three user service requirements passed, as did -password-required and password-authenticated sudo. The installed helper matched -the candidate-bound packaged digest. Shared validation and independent root -validation accepted the exact 20 shared checks plus the three additional -executed checks. No retry, extra routine guest probe, security-policy change, -or enlarged timeout was used. - -During that run, host source-content identity -`01cac1bb5713fcb0ca2138acd9814e852fc9ee11d212342d9eccfc6670febfb2` -differed from the built source only in the reviewed public qualification helper -and its tests; all paths and modes matched. All 30 loaded callables, imported -modules, both generated control/health strings, six task scripts, artifact -inputs, and start/end source guards passed unchanged. Documentation and staging -were finalized only after those guards completed. The upgrade report identifies -the baseline ISO it actually booted; final ISO linkage is not a second final-ISO -boot claim. The encrypted task's lease, capacity reservation, six QMP runtime -directories, 12 recorded processes, workspace, cgroup, path alias and mounts -were released and independently verified absent, with no cleanup errors. - -PR #24's Fedora 44 and Fedora 45 source jobs subsequently exposed Hermes -worker starvation on smaller machines. Optional HTTP discovery could occupy -every default executor worker, leaving an authentication probe queued until -those requests finished. Authentication now uses a separate two-worker pool, -closed when the bridge stops. A regression fills a two-worker ordinary pool -and requires status, login and logout to finish while both requests remain -held; restoring shared-worker authentication makes that regression fail. -The discovery fixture that deliberately parks all independent endpoints now -provides explicit worker capacity for those holds, independent of runner CPU -count. Existing authentication deadlines are unchanged. Both installation -paths consume the same bridge sources. This installed Hermes correction -postdates the artifact build above: those exact-artifact guest reports remain -historical evidence, and a release containing this later correction requires -newly rebuilt, same-source artifacts and qualification. - -Real local Restic fixtures cover restoring synthetic saves, readable volumes, -and a SQLite export, plus unreadable-volume partial-result handling. Backing up -a live database directory is still not a consistent database export; stopping -a process does not itself make subordinate-UID-owned files readable. - -The qualified-series policy also selected Quickshell 0.2.1 from a disposable -DNF repository containing both 0.2.1 and 0.3. Native offscreen Welcome checks -exercise small logical screens and focus reveal; 640×480 expanded-error and -bottom-action views were inspected. These do not replace physical display or -assistive-technology checks. - -The browser simulation passed 116 tests, lint, type checking, production build, -and two rendered-output tests. The existing web development service was rebuilt -and restarted; HTTP and health checks passed. Browser inspection confirmed the -new wallpapers load and Settings opens without console errors. This remains a -simulation, not compositor validation. - -No startup or memory speedup is claimed. Existing shared state, lazy views, -guarded polling, source/Cargo caches, portal grace, ownership ledgers, and -deferred reconciliation were retained. The accepted fixes address demonstrated -failure transitions and provenance; compiler provenance alone does not promise -byte-identical rebuilds from moving Fedora packages. - -## Release responsibilities - -Installed payload changes require newly built, same-source ISO and RPM artifacts -and regenerated qualification reports before release. The offline payload -version advances once to 1.5.2. The complete four-scenario installation matrix, -two same-source checkout comparisons, additional prior-image upgrade paths, -physical hardware, and Secure Boot remain distinct release evidence. The final -encrypted run covers only the baseline/candidate pair recorded above; fixture -success must never substitute for those reports. The new public-updater guest -scenario is mandatory in the shared evidence contract. - -Public distribution additionally requires real owner/legal decisions and the -documented protected review environment. `assets/distribution-review.json` -intentionally contains no invented approvals. Proprietary/SDK redistribution, -Steam, codecs/OpenH264, corresponding-source obligations, fonts, firmware, -application notices, artwork/wordmark rights, and trademarks need the recorded -decisions described in [licensing](licensing.md) and the -[redistribution audit](licensing-audit.md). Original replacement wallpapers do -not establish rights to unrelated components or to historical releases. - -Task-owned temporary workers, VM media, screenshots, dependency fixtures, and -failed build attempts were removed through their recorded leases. The final -main validation lease is closed with no remaining resources or cleanup errors; -its Fedora container, network, and approximately 33 GiB workspace are absent. -Bounded review reports, logs, scripts, provenance and cleanup receipts are -retained under `~/.local/state/cybexos/audit-20261021-d668048/`, with a checksum -index and explicit evidence scopes. The source artwork remains a requested -deliverable. Existing operator-owned services, prior ISOs, and unrelated -worktrees are preserved. The web development service remains healthy and -returns HTTP 200. diff --git a/docs/authentication-dialog.md b/docs/authentication-dialog.md deleted file mode 100644 index 2a36fa1d..00000000 --- a/docs/authentication-dialog.md +++ /dev/null @@ -1,82 +0,0 @@ -# Authentication dialog - -CybexOS handles Polkit requests in the managed Quickshell process. The built-in -`PolkitWindow.qml` owns one `Quickshell.Services.Polkit.PolkitAgent`, including -when a plugin replaces the bar. `PolkitPrompt.qml` presents its current flow. -Polkit and the system's PAM configuration still decide which identities may -authenticate and whether their responses succeed; the shell changes no policy. - -The compact card uses `Common/Theme.qml` fonts, density, light/dark colors and -wallpaper palette. It opens on the focused monitor and stays there until the -request ends, with a fallback if that output disappears. Account names wrap, -multiple eligible identities get a selector, and the action ID is available -under Show details. The card scrolls on small outputs or with large text. - -Enter submits the current response, Escape or Cancel aborts the request, and -Tab reaches the controls. Clicking the dimmed background does not dismiss it. -The launcher, drawers and shortcut sheet close during authentication so they -cannot take the password field's keyboard focus. The Network overlay temporarily -hides and releases its focus grab while keeping its requesting helper alive; -it returns when the authentication flow ends. A normal settings window can -remain open behind a request it initiated. - -Passwords start masked, never echo the last character, and can be revealed -with the eye button. PAM prompts that request visible responses are supported, -as are informational messages while waiting for fingerprint or other methods. -When PAM announces a fingerprint (`pam_fprintd`) or security-key (`pam_u2f`) -step and asks for no input, the card says *Touch the fingerprint reader, or -wait to enter your password.* or *Touch your security key, or wait to enter -your password.*, adds *Not recognised.* after a mismatched scan, and shows a -key glyph for the key step. These steps run one after another in Polkit's -stack, so the password field appears when they give up; a stale step message -is not repeated under it. See [Fingerprints and security keys](security.md). -The response is cleared on submission, cancellation, account/prompt changes, -completion and replacement by another request. Closing the window destroys -the input. Responses go directly to the backend; no shell command, IPC method, -log or persistent setting carries them. This does not promise secure erasure -of Qt's or the authentication library's internal memory. - -## Startup and rollback - -`cybexos-runtime exec quickshell` stops a loaded or active -`hyprpolkitagent.service` before registering the integrated agent. Source -deployment removes the old session enablement and managed unit; new images -neither require nor start the standalone agent. Existing RPMs are left -installed. Selecting an older runtime without `PolkitWindow.qml` starts the -legacy agent when its unit is available, including after a deployment rollback. - -`cybexos-runtime ipc polkit status` returns only `registered` and `active`. -There is no diagnostic method to read or submit a response. Registration must -be true before considering a deployment healthy. After switching from an old -installed runtime resolver to development source, use the checkout's resolver -for the managed service too, or stop the old agent before restarting Quickshell. -Do not start a second `qs` instance to test a dialog. - -## Verification - -`tests/run` checks account-label handling, QML, startup integration and icon -coverage. `tests/ownership-layering.py` verifies the agent transition and legacy -fallback with isolated service/executable fixtures. The production-component -lifecycle harness exercises submission, duplicate-submit prevention, retry, -account switching, visible PAM responses, local/remote cancellation, completion -and clearing state between requests. Its Polkit fixture never authenticates -against the host. The real-engine harness runs in CI without an active shell; -on a workstation use `tests/lib/quickshell-live` before and after any controlled -test that stops and restores the service. - -For live acceptance, verify registration, trigger a real Polkit authorization -check, cancel with the keyboard and confirm the requesting process is denied. -Also check details expansion, password visibility, Tab order, background clicks, -blocked competing overlays, large text, both color modes and output removal. -Successful authentication and fingerprint/other PAM methods require an operator -with the relevant credential or hardware; never collect their password through -agent tools. - -On 2026-09-26 the installed Quickshell 0.2.1 build registered successfully and -displayed a real `com.1password.1Password.unlock` authorization check. Escape -returned a dismissed result to `pkcheck`; Tab navigation, visibility, details -and blocking the competing launcher were exercised. The isolated real-engine -conversation fixture and the image's 29 installed-policy/user-parity tests -passed. The managed service finished as the sole `qs` process, with no QML -errors in its current invocation and the old agent inactive. Successful real -authentication, fingerprint hardware and monitor removal were not exercised. diff --git a/docs/cliproxy-rust-model-usage-research.md b/docs/cliproxy-rust-model-usage-research.md deleted file mode 100644 index bc58bafa..00000000 --- a/docs/cliproxy-rust-model-usage-research.md +++ /dev/null @@ -1,394 +0,0 @@ -# CLIProxyAPI Rust support for Model Usage - -Research date: 2026-10-03. Scope: CybexOS's Model Usage widget and the Rust -proxy running at `root@10.10.0.235`. - -The recommended change is a native Rust adapter in the upstream Model Usage -plugin, followed by a vendor update in CybexOS. Rust already exposes the -account inventory, subscription quota windows, and account activity needed -for the main widget. The existing server URL and management key work with -that API. A proxy upgrade is unnecessary for this first stage. - -## Verified deployment and failure - -The active service is `cli-proxy-api.service`, running -`/opt/cli-proxy-api-rust/current/cliproxyapi-rust`. Its current release is -`0.3.2+gui.5862bb7fdbe8`, based on upstream `v0.3.2`, with a local GUI -configuration editor. GitHub's latest official release was also `v0.3.2`. -The backend listens on TLS `127.0.0.1:8318`, behind the existing admission -gate on `:8317`. The configured widget URL, -`https://aiproxy.risk-bull.ts.net`, reaches it through Tailscale Serve. - -The installed RPM and this checkout have identical `usage-fetch.py` files. -Both use the Go implementation's `/v0/management/auth-files` and -`/v0/management/api-call` endpoints. The vendored plugin is version 1.1.1 at -`8266a07495674d2d425f6d76b67833872a69d466`; that was also the upstream plugin's -latest commit at inspection. Re-vendoring the current upstream alone cannot -resolve the incompatibility. - -Authenticated reads through the widget's configured URL produced: - -| Check | Observed result | Implication | -| --- | --- | --- | -| Existing client's account discovery | HTTP 401 and “CLIProxyAPI rejected the management key or remote management access.” | The current error suggests a credential problem. | -| `GET /api/accounts` with the same management key | HTTP 200; three Claude and three Codex OAuth accounts | The saved management key is valid; the API implementation differs. | -| `GET /api/accounts` with a deliberately invalid key | HTTP 401 | Native management authentication is enforced. | -| Native account quota data | Six accounts and nine windows in the feasibility snapshot | Claude had 5-hour and weekly windows; Codex exposed weekly windows at that moment. | -| Native account activity | All six accounts had `last_used` timestamps | Keeper is unnecessary for Rust account activity. | -| `GET /api/overview` | Reported the local Rust version and `round-robin` routing | Do not infer the deployed routing strategy from upstream's `least-used` default. | -| `GET /healthz` | HTTP 200 | The backend was available during inspection. | - -Upstream explicitly documents that `/v0/management` and the Redis usage -queue are unsupported. Unsupported paths can return 401 through the client -authentication middleware; 401 on the legacy path alone does not establish -that the management key is wrong. - -CPA Usage Keeper remains active, but its current logs repeatedly report -failed metadata reads and “management usage queue request returned status -401,” alongside a Redis protocol mismatch. Service activity does not prove -that Keeper is collecting new usage. Preserve its existing database and -historical records; Rust requires a different collection source. - -## Native data and feature limits - -`GET /api/accounts` returns a JSON array of account snapshots. It is an -authenticated read of cached server state, so the widget need not send -OAuth tokens or trigger provider quota calls. Rust normally polls Claude -and Codex quota about every five minutes, checks polling eligibility every -minute, and also observes quota information on model responses. - -| Widget data | Rust field or behavior | Proposed handling | -| --- | --- | --- | -| Provider and account label | `provider`, `email`, `label` | Reuse provider grouping, sanitization, and email hiding. | -| Account identity | `id`; OAuth IDs are `file:`; `file` is also present | Hash a canonical identity. Verify filename mapping against previous Go history and labels before claiming migration continuity. | -| Paused account | `disabled` | Retain it as paused and exclude it from capacity selection. | -| Quota amount | `quota.windows[].used`, a percentage from 0 to 100 | Map to `used` and `remaining = 100 - used`. Validate finite numbers; missing data remains unknown. | -| Window label and scope | `name`, optional `model` | Translate known names such as `5h` and `week`; preserve model scope and unknown names. | -| Reset and freshness | `resets_at`, `quota.updated_at` | Parse timezone-aware timestamps and retain the server's observation time. A successful HTTP read does not make an old quota fresh. | -| Plan | Optional `quota.plan` | Map Codex plan metadata when present. Claude plan metadata was absent in the live response. | -| Last account used | `last_used` | Select the latest account per provider, preserving ties as ambiguous. | -| Credits and banked resets | Absent from account snapshots | Keep values unknown and disable banked-reset actions for Rust. | -| Other provider quotas | Quota polling currently covers Claude and Codex | Show other providers with quota unavailable; preserve inventory and pause state. | - -The API does not guarantee that both 5-hour and weekly windows are present -on every read. Missing windows must not become zero usage or full capacity. -An initial cache can be empty after startup, and failed provider polling can -leave old values in it. A proposed stale policy should allow for the normal -five-minute polling interval and scheduling delay; its threshold still -needs an implementation decision and tests. - -Rust sets `last_used` when a tracked request finishes, including failed -requests. It does not identify an account serving a request still in -progress. Activity and counters reset on process restart. The widget should -describe this as the last completed request, including failures, and return -to unknown when the server has no recorded activity. - -Rust's `POST /api/accounts/{id}/reset` only clears local cooldowns, strikes, -and the last error. It must never substitute for spending a Codex banked -reset. The account API also omits paid credit balances, Claude extra usage, -and Codex code-review/additional quota buckets available to the existing Go -adapter. Full feature parity would require further server API work. - -## Proposed implementation - -Keep `usageSource: cliproxy` and the current connection settings. Detect the -implementation through authenticated account responses, then feed both -implementations into the existing normalized `schemaVersion: 1` contract. -An additive implementation identifier and capability fields can let QML -select activity and reset behavior without a new setup flow. - -1. Extend `scripts/usage-fetch.py` with a Rust client and account normalizer. - Probe `/api/accounts` and the legacy account endpoint within one bounded - deadline, accepting only validated response shapes. In particular, allow - a legacy 401 to be resolved by a successful native probe without treating - every authentication failure as proof of Rust. Preserve redirect refusal, - TLS validation, secret-file protections, response-size limits, and - provider/account bounds. Avoid `/api/overview` for routine discovery: its - response includes client API keys that the widget does not need. -2. Reuse the account grouping, best-capacity selection, quota display, and - history code. Separate fetch time from quota observation time, show stale - or unknown data honestly, and avoid appending unchanged stale observations - as fresh history. Verify canonical account and window identities so - existing private labels and quota history can survive the transition. -3. Adapt `scripts/proxy-activity.py` and `UsageActivityBackend.qml` to read - native `last_used` values for Rust. Remove the Keeper URL requirement for - that implementation and update the current Keeper-only notice. Reuse an - available account snapshot or make a cheap native read for the existing - 15-second activity refresh; do not repeat provider quota checks. -4. Make reset availability depend on implementation capabilities as well as - credit data. Update `ResetBackend.qml` and `scripts/reset-credit.py` so a - Rust connection cannot execute Go-only reset actions. Clear incompatible - cached credits when the detected implementation changes, even if the URL - is unchanged. Missing credits must remain unknown rather than zero. -5. Update the plugin's settings help and documentation to explain Rust - support and its limits. Keep local and T3 transcript cost sources working - independently of the quota adapter. - -Implement this in -[DigitalPals/omarchy-modelusage](https://github.com/DigitalPals/omarchy-modelusage) -first. CybexOS deliberately vendors its runtime files unchanged; local edits -would be overwritten by the next sync. Import the reviewed upstream commit -with `scripts/sync-model-usage `. Update -`Common/SettingsHelpers.js` only if the plugin's settings schema changes. -The shared runtime must ship through both checkout and ISO/RPM paths. - -## Persistent costs are separate work - -Rust has token counts in `GET /api/requests`, aggregate counters in -`/api/overview`, and live events on `/api/live`. These are in memory: -`src/state.rs` retains at most 300 recent request records and 60 minute -buckets. The request log stores an account label rather than a stable -account ID. Polling that ring cannot guarantee complete history across high -traffic, disconnects, or restarts. - -For reliable Rust-backed Keeper costs, add durable events with stable -account IDs, an instance/event identity, replay cursors, and deduplication, -then adapt Keeper ingestion. Preserve historical Go records. That work can -follow the quota/activity adapter; the widget's local/T3 cost collection -does not depend on it. No widget change can recover proxy usage that was -never persisted. - -## Validation required for implementation - -Add native fixtures and HTTP integration coverage for account discovery, -bad keys, implementation detection including the observed legacy 401, -empty inventories, disabled/unknown providers, missing and stale quotas, -model-scoped windows, malformed numbers/timestamps, oversized payloads, -restart behavior, and activity ties. Test private-label/history identity -continuity, capability changes at the same URL, and that Rust polling never -calls `api-call`, account mutation, or banked-reset endpoints. Retain the -existing Go behavior and tests. - -Run `python3 -B tests/model-usage.py`, the source gate `./tests/run`, and -`python3 -B image/check-source`. Add any necessary shared-payload parity -coverage. For live UI testing, use `tests/lib/quickshell-live` and the managed -service, including sole-process and current-invocation journal checks. A -release with changed runtime payload needs a new same-revision RPM/ISO and -installation qualification; fixture tests do not qualify an installation. - -The initial research validated source contracts, authenticated live reads, and an -in-memory normalization experiment. That experiment produced six unique -hashed account identities, nine quota windows, and six activity timestamps -without writing state or calling provider/model/action endpoints. It was -not a production implementation, widget UI test, identity migration test, -or end-to-end installation test. At that stage, only this research document -was added; server, installed desktop, credentials, and preferences were -unchanged. The subsequent implementation and live deployment are recorded -below. - -## Implementation and live verification - -The native adapter was implemented and applied to the workstation on -2026-10-03 at the user's request. Model Usage 1.2.0 is pinned to upstream -commit `d34db479f99ea4afc3edb539cfb8775a50a57433`, published to -`DigitalPals/omarchy-modelusage`'s `main` branch at the user's request. The -CybexOS vendored runtime and test manifest now pin the follow-up commit -`bc771a070ba1a18a3566a7006ecd1ba8c000713c`, which also accepts Rust nanosecond timestamps on -Python 3.10. The original live RPM below was built from `d34db47`; its -Python 3.14 runtime already accepts those timestamps. No proxy-server -change was made. - -The adapter detects a validated native account inventory after an unsupported -legacy endpoint response. Discovery shares one timeout budget. It normalizes -Claude and Codex OAuth quota windows, preserves filename-based account hashes, -and reads native last-completed-request activity without Keeper. Cached quota -observations older than ten minutes are marked stale; missing or expired -windows remain unavailable. Rust credits stay unknown, and banked-reset -actions are blocked in the UI and backend. - -The checkout and RPM packaging now share `prepare_quickshell`, with a -byte-for-byte Model Usage payload regression check. The live installation -received a development `cybexos-desktop` RPM through DNF, preserving the -installed baseline's other files, dependencies, package scripts, and existing -session-file customization. This scoped development build replaces Model -Usage and adds update provenance; it is not a full release build. - -Verification completed: - -- `python3 -B tests/model-usage.py` passed, including 158 upstream Python - tests and JavaScript assertions. Fourteen new Rust tests cover native - discovery, authentication failures, response limits, quota freshness and - identities, activity, and rejection of unsupported actions. -- `./tests/run` passed all 17 stages, including 1,173 unit tests and lint of - 327 QML files. The separate real-engine lifecycle stage was skipped because - the managed live shell was active; QML component runtime checks passed. -- `python3 -B image/check-source` passed image tooling, 276 image unit tests, - and installer JavaScript tests. These are source and payload parity checks, - not an end-to-end ISO installation qualification. -- Authenticated reads through the installed adapter detected Rust, three - Claude accounts, three Codex accounts, and nine fresh quota windows. Native - activity matched the normalized quota identities for both providers. -- The managed live Limits panel rendered both Claude and Codex, each with - three account cards and reset times. The Codex view displayed its plan - metadata and weekly limits. `rpm -V cybexos-desktop` reported no drift after - installation. -- The saved Model Usage preferences, management-key file contents and path, - and private key permissions were unchanged. The restarted - `quickshell.service` was active, its MainPID was the sole `qs` process, and - its current invocation journal contained no QML/runtime errors. Live checks - used `tests/lib/quickshell-live` with cleanup traps. - -After publishing, upstream CI exposed Python 3.10 rejecting native -nanosecond timestamps. The follow-up normalizes fractional seconds to -microsecond precision and adds coverage for one through nine fractional -digits and timezone offsets. All 159 upstream Python tests and JavaScript -assertions passed locally after that correction. - -The development RPM is retained for reinstalling the tested payload in -`/home/john/.local/share/cybexos/images/rust-widget-live.GHVEIEZX/` alongside -`SHA256SUMS`, `provenance.json`, and `rpm-validation.json`. Its filename is -`cybexos-desktop-0.0.0~dev-1.20260926095228.g4054070a4078.hwfix3.fc44.rustwidget1.x86_64.rpm` -and its size is 1,720,360,693 bytes (1.60 GiB). Build intermediates, temporary -test logs, and desktop screenshots were removed after verification. No test -VMs or mounts were created. No ISO was built or qualified; release qualification -remains separate from this live development test. - -## Fusebox live account activity assessment (2026-10-08) - -Adding a live account indicator is feasible at the inspected deployment's -scale. Use one persistent WebSocket connection per configured proxy, shared -by all bars, rather than increasing the frequency of the existing Python -activity subprocess. This assessment does not implement or deploy the feature. - -The service at `root@10.10.0.235` runs -`0.3.2+gui.93d3af0bb855`. Its deployment record identifies clean source commit -`fecb778830c13e80c1cd24e041da2107e9917a51`, also the Fusebox repository's HEAD -at inspection. The deployed README matched that commit byte-for-byte by -SHA-256. There were five configured accounts across Claude and Codex. - -The authenticated `wss://aiproxy.risk-bull.ts.net/api/live` endpoint already -sends `load` events with account IDs and `{in_flight, sessions}` counts. A -connection receives an initial snapshot; the server samples every second -and sends another snapshot only when its serialized contents change. -Bearer authentication in the upgrade headers worked, so a desktop helper -does not need to put the management key in its URL. The existing -`GET /api/accounts` response does not contain these live counts. - -- `in_flight` counts actual account request attempts, including unfinished - streams. The request guard decrements on completion or cancellation. -- `sessions` counts distinct assigned sessions seen in the last five minutes, - plus sessions with a call still in flight, subject to the configured shorter - affinity expiry. It does not mean that every session is generating now. -- `last_used` remains the latest completed request, including failures. Keep - this separate from a new "Serving now" indicator. Concurrent accounts can - all be serving; do not choose one by its quota or last-used timestamp. - -The load computation reads the account pool, atomic request counters, and -the in-memory session registry. It performs no provider quota call or SQLite -query. The full dashboard socket additionally sends request metadata, -account-change notifications, and five-second totals ticks. - -Read-only production measurements, with ordinary model traffic continuing: - -| Measurement | Result | -| --- | --- | -| Account inventory GET | 10.62 ms; approximately 6.7 KB of reserialized JSON | -| WebSocket connection | 11.68 ms; header authentication accepted | -| Load event size | 169 bytes in the observed snapshots | -| 30-second WebSocket observation | 6 load events, 6 ticks, 7 completed-request events; 7,963 payload bytes total (265 bytes/second) | -| Desktop observer CPU | 9.37 ms over 30 seconds, about 0.031% of one core | -| Desktop observer memory | Approximately 33.1 MiB RSS for the whole Python observer; 28 KiB growth during observation | -| Fusebox CPU before connection | 80 ms over 15.24 seconds, about 0.53% of one core | -| Fusebox CPU during connection | 140 ms over approximately 30 seconds, about 0.47% of one core | -| Existing activity helper, three invocations | 87–96 ms elapsed and 75–83 ms child CPU per invocation | - -The desktop observer imported the existing adapter and used the installed -Python `websockets` library; its memory is not a measured incremental -Quickshell allocation. Server CPU and memory include concurrent production -work, so these observations cannot isolate the socket's overhead or prove -performance under a much larger workload. They showed no material CPU -increase at the current scale. No synthetic model requests were generated. - -For implementation, replace Rust activity polling with a bounded persistent -collector and keep the slower quota refresh independent. Publish only changes -to QML, use static indicators, preserve hashed identity matching and hidden -emails, and mark live activity unknown on disconnect. Reconnect with bounded -backoff and stop the collector when its feature is disabled. Python -`websockets` is already included by both the desktop role and RPM dependency -contract. Implement upstream in Model Usage and import through the existing -vendor workflow, including source/image payload parity coverage. - -No Fusebox change is required for one desktop. For wider deployment, an -activity-only subscription would avoid forwarding request metadata and -totals. Compute a shared load snapshot once per second for all subscribers: -the current implementation scans the session registry separately for each -socket while holding its mutex, and the registry can contain up to 10,000 -assignments. Bound slow-client sends and recover with a complete snapshot -after reconnect or lag. These changes would protect scaling without adding -work to every token or provider request. - -Source references at the deployed commit: - -- [Live load snapshots and WebSocket loop](https://github.com/DigitalPals/Fusebox/blob/fecb778830c13e80c1cd24e041da2107e9917a51/src/mgmt.rs#L912). -- [Request guards](https://github.com/DigitalPals/Fusebox/blob/fecb778830c13e80c1cd24e041da2107e9917a51/src/accounts.rs#L366). -- [Recent-session counting](https://github.com/DigitalPals/Fusebox/blob/fecb778830c13e80c1cd24e041da2107e9917a51/src/affinity.rs#L272). -- [Dashboard consumption of load events](https://github.com/DigitalPals/Fusebox/blob/fecb778830c13e80c1cd24e041da2107e9917a51/ui/app.js#L347). - -Temporary source inspection files were removed. No runtime code, -configuration, credentials, service, RPM, or ISO was changed. - -## Sources - -- [Rust v0.3.2 release](https://github.com/IuCC123/CLIProxyAPI-Rust/releases/tag/v0.3.2). -- [Pinned Rust README and compatibility limits](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/README.md#coming-from-cliproxyapi). -- [Management routes and authentication](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/mgmt.rs#L292). -- [Account snapshots and filename identities](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/accounts.rs#L483). -- [Quota fields and polling](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/quota.rs). -- [Activity semantics](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/proxy.rs#L100) and [in-memory request retention](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/state.rs#L195). -- [CybexOS vendoring contract](../roles/desktop/files/quickshell/ModelUsage/README.md), [existing proxy client](../roles/desktop/files/quickshell/ModelUsage/scripts/usage-fetch.py), and [installation parity requirements](installation-parity.md). -- Live observations from `root@10.10.0.235`, its local deployment records, - updater status, authenticated native API, and Keeper error logs on the - research date. No credentials or raw account identities are reproduced. - - -### Implemented session badges - -Model Usage 1.3.1 uses one shared, reference-counted live collector per proxy -URL/key-file connection. Fusebox and its deployment are unchanged. The menubar -shows quota percentages only. A badge at the upper-right of each account card, -aligned with its subscription title, shows Fusebox's recent assigned-session -count. The count is not an unfinished-request count and may stay positive after -requests finish. Tooltip and keyboard-accessible expandable details explain -this. Broken, stale or unsupported streams display “Sessions unknown”. Models -are not displayed or queried. Quota layout, last-used selection, account order, -and saved email privacy are preserved. Obsolete bar-count and separate -recent-session settings are dropped by normalization. - -The collector reads only cached native inventory, coalesces changes to at most -one inventory request per 15 seconds, refreshes idle inventory every 60 seconds, -and throttles stdout heartbeats to five seconds. Frames, queues and QML stdout -are bounded. Initial load and heartbeat expiry prevent false zero counts; -reconnect backoff caps at 60 seconds. Older Rust endpoints retain cached -last-used activity with live activity unavailable. Go/Keeper retains its -existing polling path. No server changes, new connections, per-session queries, -model discovery or extra polling are required for the badge. - -A final 20-second live sample measured 0.1% of one core and 32.3 MiB RSS for the -shared helper; the entire shell consumed 1.2% of one core. The presentation-only -revision retains the existing collector and protocol. This short observation -does not claim a stress benchmark. - -Validation covers real WebSocket authentication, normalization, idle, -disconnect, malformed counts, missing initial snapshots, reconnect backoff and -shared QML lifetime. Badge tests distinguish session counts from unfinished -requests, including zero requests with positive recent sessions. Checkout -settings and ISO desktop payload fixtures share the same settings and transport -dependency. Live verification confirmed quota-only chips and the upper-right -session badge beside “Codex Pro · 20×”. The managed service's current invocation -passed QML journal and sole-managed-`qs` checks. Its development source selection -was preserved. Deployment uses a scoped development RPM preserving the installed -payload's unrelated files, dependencies and scriptlets; this is not a rebuilt or -end-to-end-qualified ISO release. - -### Fusebox naming and repository link - -Model Usage 1.3.2 names the native Rust connection Fusebox in Limits settings, -quota source diagnostics and account notices. Limits settings expose a visible, -keyboard-accessible “Fusebox on GitHub” link to -`https://github.com/DigitalPals/Fusebox`. Before discovery, the source option says -“Fusebox / CLIProxyAPI”; detected Go connections retain the CLIProxyAPI name. -The existing source value, URL/key settings and Rust protocol marker are -unchanged, so saved connections continue to work. This is a presentation change -with no new network calls, polling or processes. The desktop payload equality -fixture covers both checkout and ISO/RPM source packaging. The scoped desktop -RPM is a development update, not an end-to-end-qualified ISO release. diff --git a/docs/customization.md b/docs/customization.md new file mode 100644 index 00000000..a2a6ede9 --- /dev/null +++ b/docs/customization.md @@ -0,0 +1,66 @@ +# Personal configuration and extensions + +Put package, service, hardware and VPN declarations in `/etc/nixos/local/` and +import each from `local/default.nix`. The machine template includes a WireGuard +example with `privateKeyFile`; put the key at a private runtime path. Use the +service's persistent D-Bus API for NetworkManager profiles. Vendor code and +assets in the Nix store remain read-only. Preview/apply changes through the +same lifecycle as updates, using `cybex update stage local`. + +A regular Git repository in `/etc/nixos` keeps its history, index, remotes and +hooks as persistent personal metadata. Selected working configurations reference +the retained original `.git` directory; snapshots and Nix source archives omit +it. The updater never invokes Git or hooks. Do not remove a retained working +directory that is the target of an active `.git` reference. Linked worktrees, +pointer files, unmanaged `.git` symlinks, `core.worktree` overrides and included +Git configuration are refused before application because moving their worktree +would require changing user metadata. Use a regular repository without these +layouts, or manage a separate personal repository and copy declarations into +the supported machine configuration before previewing them. + +Cisco IKEv1/IPsec uses NetworkManager's maintained Libreswan backend. Keep +connection secrets in the keyring or NetworkManager's private persistent +connection files. Libreswan's NSS database lives in root-private +`/var/lib/ipsec/nss`; it is persistent state, outside the Nix store and system +generation rollback. Do not put PSKs, passwords or private keys in +`services.libreswan.connections` or `configSetup` text. Use root-private +runtime files and the service's supported secret-loading configuration. + +Desktop preferences use sparse `~/.config/cybexos/shell.json`; monitor choices +use `displays.json`; personal Hyprland Lua loads last from `hypr/user.lua`. +They apply without rebuilding the OS. Unknown preference fields, explicit +choices and conflict/migration sidecars are retained. Older shells treat a +newer unsupported preference schema as read-only. + +With developer tools enabled, first login seeds a writable LazyVim starter only +when `~/.config/nvim` is absent. The reviewed starter revision is pinned in the +desktop package; update that pin when qualifying new initial editor defaults. +Existing Neovim configuration is always personal. LazyVim downloads its plugins +on first use and manages their lock/state independently of OS updates. + +Personal widget code lives in `~/.local/share/cybexos/plugins/ID`, placement and +preferences in `~/.config/cybexos/plugins.json`, and state in +`~/.local/share/cybexos/plugin-data/ID`. Theme packages have their own personal +data directory. Use `cybex plugin` and `cybex theme`; no personal package is +copied into the vendor runtime or deleted during an OS rollback. + +[Widget API 1](architecture/user-widgets.md) and +[Omarchy compatibility](omarchy-plugin-compatibility.md) are public contracts. +The preserved native interface injects `pluginApi`; all six supported Omarchy +plugin kinds use the existing adapters. Plugins are trusted code and may access +the session's privileges. File survival alone does not prove runtime compatibility; +release qualification must run actual engine/plugin tests. + +Claude Code and Codex discover the same packaged `cybexos` skill through their +user-scoped discovery directories. Only absent slots gain a stable system-profile +symlink. A vendor update/rollback updates its skill target automatically; an +existing user-authored skill is preserved and reported. Other skills are never +modified. [The skill](../agent-skills/cybexos/SKILL.md) documents ownership, +secrets, local declarations, previews, application and recovery. + +Regional Settings previews write the machine-owned `desktop-settings.json` in +an isolated candidate through `cybex config region`. The template's `settings.nix` +imports it. Timezone and system language become persistent declarations after +the normal reviewed apply; they do not use mutable localed/timedated writes that +would disappear on rebuild. Existing locale categories remain separate. This +small typed file is not a place for secrets or arbitrary module expressions. diff --git a/docs/dependency-policy.md b/docs/dependency-policy.md deleted file mode 100644 index 241a625a..00000000 --- a/docs/dependency-policy.md +++ /dev/null @@ -1,239 +0,0 @@ -# Dependency and pinning policy - -The repository separates reproducible inputs from channels that intentionally -track security or nightly updates. Pins live in -`inventory/group_vars/all.yml`; changing one is a review decision, not routine -playbook convergence. - -## Immutable inputs - -- GitHub release applications and Nerd Font archives require an exact release - tag and SHA-256. Before contacting GitHub, the installer can use one regular, - non-symlink cached asset matching the inventory regex in that tag's sanitized - cache directory. It checks the actual SHA-256 of a private copy and installs - that copy, so cache changes cannot substitute unchecked bytes. Symlink or - traversal paths fail safely. Missing, corrupt or ambiguous caches use the - normal authorized release metadata/download path, which checks a GitHub release - digest when supplied and redownloads a bad cache once. It stages non-RPM payloads - and atomically switches `current`. Non-RPM installs retain three version - directories; the cache keeps the two newest releases plus the current pin if - it is older. -- Root-installed GitHub RPMs have the same tag/checksum requirement and are - serialized behind one DNF lock. `--nogpgcheck` does not make them - unauthenticated: the inventory SHA-256 is the explicit authorization for the - exact RPM bytes. -- Source-built applications use a human-readable tag plus a full commit, and - the build container is pinned by manifest digest. The commit, not the tag, - is authoritative. -- The LazyVim starter used for an absent `~/.config/nvim` is pinned to a full - commit. The role never overwrites an existing Neovim configuration. This pin - covers the starter files only; LazyVim plugin state is reproducible only - when the user's configuration has its own `lazy-lock.json`. -- Development Distrobox launchers use fully qualified manifest digests. Each - entry also records the source tag and review date. A changed pin affects a - newly created box; the role deliberately does not destroy an existing box. -- Rust uses a dated nightly (`nightly-YYYY-MM-DD`), not the moving `nightly` - channel. The role installs `rustfmt` and `rust-analyzer`, retains an installed - toolchain on a transient download failure, and fails an incomplete first - install. -- Claude Code and OpenCode versions are explicit inventory pins. - Claude's native installer accepts the exact target and keeps versioned - binaries. Claude Code's pin is the version an install receives and a - minimum afterwards: Claude Code updates itself, and a newer install is not - downgraded. Codex CLI tracks npm's `latest` stable release, resolved on each - installer/user-tool update run. OpenCode and Codex are installed from resolved npm package versions - into separate staged, versioned user directories and exposed only after each - binary reports the requested version. Neither package policy changes agent - permissions or adds unattended-launch flags. -- Vendor repository keys, standalone font files/archives, source inputs, and - XPS camera inputs carry the checksums or commits next to their configuration. - See the relevant inventory and role defaults for their update points. -- The RPM Fusion signing keys are vendored in-tree as the copies that the - `rpmfusion-*-release` packages ship (`roles/apps/files/rpmfusion-*.asc`), so - an already-imported key is recognized without a download; each is still - imported only after its full fingerprint matches. Rotating them means - replacing both files and updating the fingerprints in - `roles/apps/tasks/repos.yml` and the SHA-256 digests in - `tests/quickshell/converge-efficiency.test.cjs`. - -`tests/repository-policy.py` rejects an unpinned GitHub release, a floating -Distrobox image, malformed toolchain pins, drift in the CLI installer -template, or a changed YOLO alias. It runs inside the Python stage of -`tests/run`. - -Each ISO build lists the pinned downloads it bundles, with their URLs and -SHA-256 digests or commits, in its SPDX SBOM; see -[ISO releases](iso-releases.md#software-bill-of-materials). `image/test_sbom.py` -fails when a pin lacks a checksum or commit, or when its `enabled` condition is -not a plain boolean or `{{ features.NAME }}` reference. - -## Updating a pin - -1. Read the upstream release notes and identify the concrete compatibility or - security reason for the change. -2. Resolve the exact release tag/commit or container manifest digest. Download - artifacts from the configured upstream and calculate SHA-256 locally; when - GitHub publishes an asset digest, make sure it agrees. -3. Change the inventory pin and checksum together. For a Distrobox, keep the - reviewed source tag beside the digest and update the review-date comment. -4. Run `./tests/run`, then use - `ansible-playbook site.yml -e @/etc/cybexos/config.yml --check --diff` - or an appropriately scoped direct Ansible check. -5. Apply the change and validate the installed version. Do not delete retained - versions or an existing Distrobox until the replacement is proven and any - data inside the box is accounted for. - -The baseline pins were taken from working installed versions and locally -cached artifacts on 2026-08-27; subsequent pin changes follow the review -procedure above. This keeps clean installations reproducible without silently -authorizing speculative upgrades. - -## Qualified compositor and shell series - -Hyprland (from the `sdegler/hyprland` COPR) and Quickshell (from Fedora) shape -every part of the desktop: the Lua configuration API, IPC, and the QML the -shell is written against. A release is qualified on one series of each, -recorded in `qualified_package_series` in `inventory/group_vars/all.yml`: - -```yaml -qualified_package_series: - - { name: hyprland, minimum: "0.56.2", below: "0.57~" } - - { name: quickshell, minimum: "0.2.1", below: "0.3~" } -``` - -Both installation paths turn each entry into a dnf versionlock entry in -`/etc/dnf/versionlock.toml` that admits `minimum <= version < below`. -`roles/base/tasks/versionlock.yml` does this for a checkout install (before -any role installs either package), an image installation and upgrade -reconciliation, all with `image/library/cybexos_versionlock.py`. Patch -releases inside the series still install and update through `cybex update`; -the next minor or major release is held back. The bounds compare like RPM -versions with an empty release, so `>= 0.56.2` admits `0.56.2-2.fc44`, and a -trailing `~` (`< 0.57~`) also excludes every `0.57` pre-release. - -CybexOS writes only entries whose comment starts with `CybexOS qualified -series`, and replaces edits to them on the next converge. Every other entry is -kept as written. dnf combines the entries for one name with OR, so an -administrator who has qualified another build can admit it with an entry of -their own, for example `sudo dnf versionlock add hyprland-0.57.0-1.fc44`, -without touching ours. Uninstall removes only the CybexOS entries. A file that -cannot be read safely (invalid TOML, an unknown version, values the converge -could not write back) is left untouched and the converge warns that the series -is not enforced. A converge also warns when an installed package is already -outside its series; dnf keeps it but holds it there. - -The image build verifies the series as well: `image/compose` fails when the -resolved `hyprland` or `quickshell` is missing or outside its series, so an -ISO is never published on an unqualified compositor or shell. - -The shared Fedora source-test action installs the same versionlock policy -before selecting Quickshell, then rejects a missing or out-of-series installed -runtime. Required checks therefore exercise the product's supported series; -the next-Fedora preview reports an unavailable qualified build explicitly. - -Because the COPR publishes only its newest build, a fresh install fails with -"matches only packages excluded by versionlock" once upstream has moved to an -unqualified series and the old build has been withdrawn; the same is true of -an image build. That failure is deliberate: it is the point at which the next -series must be reviewed. Installed systems are unaffected. The durable -updater runs `dnf upgrade --no-best` (dnf's default, stated so a local -`best=True` cannot override it), so a library upgrade that only the held-back -compositor could use is skipped instead of failing the whole update. A Fedora -major upgrade honors the lock too, so the target release's COPR must still -carry a build in the series. - -### Qualifying a new series - -1. Read the upstream release notes, and for Hyprland the Lua configuration - changes (`/usr/share/hypr/stubs/hl.meta.lua` and the shipped - `/usr/share/hypr/hyprland.lua`), for anything the CybexOS Lua or the shell - relies on: `hl.*` calls, dispatchers, config keys, events and IPC output. -2. Raise `minimum` and `below` in `qualified_package_series` together, run - `./tests/run` and `image/check-source`, then converge a test machine with - `./install` and confirm `rpm -q hyprland quickshell` reports the new - series and `hyprctl configerrors` is empty. -3. Rebuild and qualify the ISO from the same revision (`image/build`, - `image/qualify`). The compose check fails if the resolved packages are - outside the new series. -4. Release. Installed systems adopt the new lock when the release reconciles - (`cybex update`), and the packages move on the following update. - -To pull in only a reviewed patch release, nothing changes: it is inside the -series already. - -## Intentionally moving channels - -Some inputs should move, but their boundary is explicit: - -- Fedora/RPM Fusion/vendor repository packages and system Flatpaks track their - configured release channels so security fixes arrive through `./update`. - Hyprland and Quickshell move only within their qualified series (above). -- T3 Code is deliberately the Nightly application. Its updater verifies the - selected release asset but tracking a prerelease is part of that feature's - contract. -- Android repository metadata selects the current command-line tools, platform, - and build-tools. Downloads are integrity-checked against Google's metadata - and installed transactionally, but the metadata itself is a moving input. -- The forced Firefox 1Password extension URL tracks Mozilla's current signed - extension release. -- Anthropic's small HTTPS bootstrap script is not byte-pinned. It receives the - exact `claude_code_version`, and the bootstrap validates the native payload - against Anthropic's manifest. Pinning the bootstrap itself would require an - owner-maintained checksum and rotation process that the repository does not - currently have. After that install, Claude Code's own updater moves it past - the pin. - -These are not interchangeable with source-build or root-RPM pins. Adding a new -moving installer requires documenting why currency is more important than -byte-for-byte convergence and how a failed update preserves the working -installation. - -## Source build caches - -Source applications reuse a compiler image keyed by the pinned base and build -recipe. The derived image still installs current Fedora RPMs: the base digest -and requested recipe are not a complete description of the resolved toolchain. -The helper captures the derived image's immutable `sha256:` ID under the -builder lock, then runs compilation by that ID. It records all resolved RPM -NEVRAs, `rustc`, Cargo, GCC and C++ compiler versions, authorized source/base/ -recipe inputs, and output binary SHA-256 digests in each deployment's -`.cybexos-build-provenance.json`. `.cybexos-builder` remains the short image ID. - -Cargo's registry and per-application target caches live under -`/var/cache/cybexos-source-builds/`, outside installed application stores and -the ISO payload. Compiled targets are additionally keyed by the actual derived -image ID, so a recreated compiler image does not reuse another toolchain's -object cache; the Cargo registry remains shared. These caches trade local disk space for faster rebuilds; they -are not automatically expired. When no source build is running, an operator -can remove an obsolete recipe directory and its matching -`localhost/cybexos-source-builder:` Podman image. The next required -build recreates them. An unchanged, healthy installed binary still works -offline without either cache. - -The requested inputs and healthy installed deployment allow an unchanged -converge to return without Git, Podman or network access. They do not promise -identical output after deleting and recreating a builder: Fedora security -updates may change the resolved packages. To refresh a toolchain, remove only -an unused builder image as above and perform a required build (a reviewed -source/recipe change or repair); routine convergence does not silently rebuild -intact installations. This retains normal Fedora package currency without -creating a separate compiler pin registry. - -The image assembler exports these records in `applications.json` and the -checksum-covered `source-build-provenance.json` artifact, and retains them in -the offline application RPM. The ISO's SPDX root binds the exported file's -SHA-256 in `sourceInfo`; public assets carry it alongside the current raw RPM, -Flatpak, package-file and build inventories. This describes the actual derived -builder used, rather than only the requested container base. Missing compiler -metadata fails staging before activation and preserves the previous build. - -## Rust toolchain documentation - -New developer-tool installations use rustup's minimal profile plus explicit -rustfmt, Clippy and rust-analyzer components. The shared applications role -applies this to both checkout and ISO installs. Existing components are retained. -Offline Rust documentation is optional: install it with -`rustup component add rust-docs --toolchain nightly-2026-08-23` (use the current -inventory-pinned toolchain after a version change). `rustup doc` then opens the -local documentation. This keeps the compiler and daily development tools in the -default seed without copying the large documentation tree into every home. diff --git a/docs/desktop-improvements-ledger.md b/docs/desktop-improvements-ledger.md deleted file mode 100644 index a32c97a4..00000000 --- a/docs/desktop-improvements-ledger.md +++ /dev/null @@ -1,182 +0,0 @@ -# Desktop improvements implementation ledger - -Snapshot: 2026-10-21. Exact source/image gates, all four ISO scenarios, both English/Dutch checkout -fresh/saved parity cases, pristine offline/native/recovery and scoped native workspace/action regressions -passed for **tested product `d2037af92cb3a47a08a9755e5996d2c4b0cae28d`**. Actual -startup/pixel/performance measurement is **BLOCKED**: four failed attempts, zero accepted samples. -Physical hardware, generic Wayland CSD, first-frame and unsampled interaction limits remain; -**not fully qualified**. - -[PR #21](https://github.com/DigitalPals/CybexOS/pull/21) merged at -`bee9d722021301875823e6285a6eef2896b283eb` on `2026-10-21T14:01:19Z` after both required hosted checks -succeeded; origin/main was verified there. The intervening CI-only head -`9974aaee9cb6160ddce86fe84d4a40fc4a75c055` changes no installed payload. Tested artifacts retain d2037af -identity; this documentation update is separate from the tested installed product. - -Implementation started on `feat/desktop-improvements-20261021` at -`3b310d106255558ca213c955ef37ee314e8681f2`; original main was clean/unmodified at discovery, -`6f4bdca3168d46cc76e8047fa94d172dac3cb696`. The workspace correction is -`607bce226d9f85c4e6307567a75cc5cfbc8b1243`; d2037af corrects notification equivalence. Historical results -are never transferred to the new revision. - -## Ownership, controls and contracts - -Actual disjoint T3 Codex delegates implemented compositor, UI, notifications and native titlebars; fresh -independent tasks reviewed integration/corrections with verified `codex/gpt-6.1-sol`, xhigh/priority -selections. Parent owns schema 29, English/Dutch strings, shared install/package policy, qualification -and cleanup. - -| Workstream / owner | Delivered behavior and remaining qualification | -| --- | --- | -| Workspace delegate | Shared/default and Per-monitor local slots 1–10, unique explicit IDs, physical identity, guest reachability and reversible transitions. Exact renamed/numbered opening, named/manual groups, owner closure/reload/restoration passed; physical matrix and first-frame remain unqualified. | -| UI delegate | Bar/overview/launcher/dock consume compositor mapping and restore before focus; Tiled/Scrolling/Floating controls and saved settings lifecycle. Parent's encrypted-US settings/empty-overview pixels reviewed; unsampled interaction/assistive technology and new-artifact pointer behavior unqualified. | -| Native delegate + parent | Pinned Fedora titlebars, themed controls, runtime ELF guard, offline forced rebuild/unchanged convergence and recovery passed. Historical pointer/clear-readd/wrong-build-ID evidence retains earlier source scope; generic negotiated Wayland CSD blocked. | -| Notifications delegate | Source snooze, exact bounded dedupe/count, explicit safe code/link actions, grouped popups and optional private history. Native OPEN/open separation/duplicate transfer/ActionInvoked/restoration passed; overflow/cleaned-label edges remain source/runtime fixtures. | -| Parent | One ISO-authoritative installation/security/recovery contract shared with checkout, migrations, upgrade and owned-file uninstall. Actual captures have exact source/content binding and no unreviewed package deltas; reviewed trust-key versus RPM bookkeeping differences are not byte-identical inventories. | -| Startup delegate + parent | R11 scoped inert review clean; actual startup/pixel/performance measurement BLOCKED after warm01–04, zero accepted samples/comparison/speedup. All leases closed/empty. No further VM series planned; future acceptance requires visibly verified Welcome/portals and eight fresh equivalent boots with current prerequisites. | - -`require("workspaces")` alone owns monitor identity, ordered ID assignments, layouts and minimize state; -UI never allocates IDs. Shared remains the first-install/existing-user default, managed base dwindle. -Per-monitor guests remain reachable; transitions never renumber/merge live workspaces, mirrors own no -assignments, and indistinguishable panels with changed connectors use the recorded deterministic -fallback. Native `user.lua` custom layouts remain owned by the user when `manageDefaultLayout:false`; -only deliberate default selection takes ownership. Explicit equal-default overrides persist until -`set_layout(id,"default")`; named/renamed destinations suspend numbered choices and preserve native state -and mode/manual group provenance through reload. - -Personal choices: `$XDG_CONFIG_HOME/cybexos/workspaces.json`; assignments: -`$XDG_STATE_HOME/cybexos/workspace-assignments.json`; schema-v1 runtime snapshot: -`$XDG_RUNTIME_DIR/cybexos/workspaces.json`. No duplicate shell preference owner or startup push. Settings -→ Workspaces controls mode/layout/titlebars; Bar owns existing dock opt-outs/pins/auto-hide. Super+Ctrl+L -cycles layout; Super+Ctrl+Alt+M minimizes; Super+Ctrl+M restores, including with dock disabled; -Super+Ctrl+Shift+arrows moves displays. Super+L lock/Super+M Slack remain intact. Overview/launcher/dock -restore hidden windows before focus; keyboard/mouse fallback works without titlebars. Full -API/storage/ownership contracts remain in [architecture](desktop-workspace-architecture.md) and -[controls](desktop-workspaces.md). - -Notifications retain one D-Bus owner and existing DND/quiet settings. Session snoozes keep suppressed -arrivals in history without replay. Exact action IDs retain case/whitespace; over 16 actions or 256 code -units per exact ID/cleaned label disables dedupe without altering source/snooze/groups. Visible and -accessible labels share plain-text cleanup. Persistence defaults off, uses bounded atomic private -storage, redacts codes across alternate fields and restores no live actions. Copy/link activation is -explicit, URI-constrained, never automatic or shell-interpolated. See [notification -security/controls](desktop-notifications.md). - -## Provenance and artifact binding - -| Component | Pin and retained contract | -| --- | --- | -| Omarchy | `v4.5.0-rc1`, `03ff1eff3812933f7088484533784d2f63eaf19a`; selected adaptations retain MIT notices; no moving-branch fixes imported. | -| Native titlebars | `v0.2.2`, `379ff910fcce9f0cc9b41f9a87c527997076a417`; patched source plus BSD-3-Clause/MIT licenses, fixed `/usr/lib64/cybexos/titlebars.so`. | -| Fedora compositor | Hyprland `0.56.2-2.fc44.x86_64`, source `efb50993780079460b0cbed1363e2166a2de1d9f`, ELF ID `04b20094d13879e70824418ba363f8dcc3253b34`; Scrolling built in, no compositor upgrade. | -| Shell | Quickshell `0.2.1^git20260209.dacfa9d-5.fc44`; bounded public Qt image-provider identity. | - -Both install paths share recipes, payload admission, personal-data preservation, -authentication/firewall/recovery policy and owned-file uninstall boundaries. Exact Fedora SDK/RPM and -running mapped-ELF identity gate native loading; checksum-cached bounded offline builds remove stale -loadable artifacts on failure. Declarative load persists on each parse; registry-gated callbacks, -independent availability/rendering status and exactly-three-button theme setup remain intact. Exact -Nautilus/Text Editor exclusions are policy, not generic CSD detection. See [native -lifecycle/security/licenses](native-titlebars.md). - -Build root: `/home/john/.local/share/cybexos/images/desktop-20261021-d2037af`, clean build -`20261021T121337Z-87cc6b87`. Testing ISO: `/data/pxe/iso/CybexOS-Live-44-20261021T121337Z-87cc6b87.iso` -(8,063,483,904 bytes). Supported checksum/iVentoy refresh/list/PXE/service checks passed. Exact RPM -filenames/bytes/digests and bounded report paths are in [validation](desktop-validation.md). - -| Binding | SHA-256 | -| --- | --- | -| Clean source content | `81249a8df4fad7e208c1d6322e0cd609fb71e3fec2523bbc31485389d5a56a1c` | -| Testing ISO | `28603ac50e9167f1896d5f0657d171eb8bd3ac429d1a54dbc37540a98b275552` | -| Actual-candidate dependency manifest: 82 RPMs/40,923,680 bytes | `4792413da9a4d624acced296892f0638f428776bc50aea1be195a1cdda9ebefb` | -| Converged installed native plugin | `1142347ed31dcfb67733602b8ec6d88733131e0f8140ad8c3f50c870ef24e878` | - -## Evidence, corrections and historical scope - -Current evidence is mirrored under -`/home/john/.local/share/cybexos/images/desktop-20261021-d2037af/validation/evidence/final-r15-run/`. -Here E denotes that path and V its validation root. MAIN/FINAL paths are historical identities; resolve -expected path/hash/size through the durable maps/history described in validation. `superseded/` retains -older reports. R30 accepted selected evidence durability, not release/runtime acceptance. Current/frozen -and all four failed-attempt inputs are recoverable; ten older precursor source bodies were already -absent before audit, so no complete transitive historical source claim is made. Future execution requires -a separately reviewed retarget. - -Exact Fedora source/image gates passed; validation records counts and log identities. The original -three root skips remain recorded, with two UID1000 reruns and unchanged RealReleaseSigning -(1 pass/0 skips, 1.464s, disposable key/RPM/repo) passing supplements; no waiver or actual product -signing. Signing log SHA-256: `ef8b3320a9f323eda412e8971af29709aede4f93337a472a8a82288ad499e882`. -E/`core-qualification-d2037af.json` aggregates prerequisites, not full release acceptance. R27's 234 -read-only checks excluded English, later parent-verified; no tests/live/PXE/large-artifact rechecks. - -| Retained reports | Material disposition | -| --- | --- | -| `completed-review-records.json`, mapped superseded reports | Early layout/default/group/UI/image/origin issues, toast lifecycle, sealed offline admission/native availability and keyboard/variant correction fixed. Earlier runtime/layout/titlebar/D-Bus/output evidence retains its source scope. | -| `review-records-r21-r22.json` | R15 renamed opening and action-ID alias, R16 manual groups, R18 named provenance, R20 overflow/presentation corrections committed as 607bce2/d2037af; R22 clean within final 13-file scope. Focused/comparative counts and hashes are in validation. | -| `review-record-r26.json`, preserved Foot/focus failures | External restoration/race/metadata/exception/fence and Kitty close-confirmation fixes; corrected unsupported `hl.dsp.window.focus` to `hl.dsp.focus`. R26 clean against all 19 pinned dispatch sites, then actual workspace pass. Earlier failures remain restored/preserved, not converted into passes. | -| `independent-acceptance-r27.json` | Clean completed-evidence audit; English separately verified. Recorded first-frame/hardware/CSD/action-edge/startup limits retained; subsequent teardown documented below. | -| `ci-only-revision.json`, `ci-patch-review-r28.json`, `main-push-result.json` | Hosted `titlebars.py` initially failed FileNotFoundError for absent `patch`; exact sole-path CI environment addition reviewed clean, both required hosted checks SUCCESS, PR #21 merged. No installed payload or gate/security change. | -| Durable-root `independent-retention-r29.json`, `retention-r29-fixes.json`, `independent-retention-r30.json` | R29's missing R27 nine-record/1,344-byte ledger snapshot (P2) and 1,111-byte native terminal supplement (P3) recovered; R30 independently verified both repairs, 14,896 scoped retention checks/1,661 mapped non-RPM hashes, no findings. Not new runtime/release acceptance; validation gives exact paths/hashes and historical-source limits. | -| Mapped historical installation/startup reports | b4dfdf6/75 four ISO and 75 pristine upgrade/native/recovery passes remain historical. b4 English checkout passed/Dutch keyboard failed; R14-cleared `75bc4243134ef93d96f208ab6edbb02e83438d18` fixed locale1 discovery, but 75 Dutch outcome capture/cleanup failed SSH255 and is not parity; English 75 was not rerun after R15. Failed inventory/black-bar/launcher/portal observations remain failed; pixels never establish a product portal defect. | - -Historical implementation sequence: 66b14a2, c5b5c96, 9702355, 629219f, 63f7785, 78903e9, 00f71b4, -b4dfdf6, 75bc424, 607bce2, d2037af. Validation records exact native opening/group and OPEN/open -ownership/count/ActionInvoked/restoration scopes, sole PID/current QML0 journals and limits: -first-frame/configure and full R20 bounds/label cases remain unqualified. - -## Startup: measurement blocked - -Actual startup/pixel/performance measurement is **BLOCKED**: four failed attempts, zero startup -samples/comparison. No optimization/speed claim is justified; no further VM series is planned. - -| Inert tooling snapshot | Scoped result / controlled manifest | -| --- | --- | -| Frozen R5 | 195 inert/eight help, three R4 P2s closed; `6898ed59a0d1efaf9b4b34b2e0bd5f83f280f6df8614cfc0005fbe41271d0739`. Standalone observer remains `51eada68b783068e5ac62305dc45381ba7862a20b84f520d2ba63319164ba80a`. | -| R7 | R6 `json` import/measured `controlledBootPolicy` binding fixed; 237 inert (42 onboarding outside legacy adapters), six adversarial rejections, eight help/80 Python/four payload compiles, old NameError negative; 271 files unchanged. Synthetic failures/adapter exclusions are not passing coverage. `9fd45d16597f4df7871fbc85434efda36aeb610dcd56dfc7adcb21aa17ea40ff`; FINAL/`startup-onboarding-independent-r7.json`. | -| R9 | R8 stage-attribution P2 fixed; 260 supplied inert/116 independent mocked preparations/eight help/104 Python/five payload compiles. Clean inert diagnostic scope only; immutable reviewed manifest `92fba8d77a9c35a669c3437ee344b1e788346eef2553a4aa05fa6bf4e694e9c2`; FINAL/`startup-diagnostics-independent-r9.json`. | -| R11 | R10 LF-separator P2 resolved with literal LF/terminating-LF guard after supported-warning support; 283 inert, seven independent methods/19 delimiter variants, four negative-control methods, eight help/128 Python/five payload compiles. FINAL/`startup-grub-independent-r11.json`; current manifest `e8a7f96eb48a972f912d3e0c61628b3451e5ce83ae11445e0f318e5c69194fe0`. Inert scope only. | - -| Attempt | Observed result / evidence / closed-empty lease | -| --- | --- | -| warm01 | Two deadline-no-action/null-hash outcomes, no input; parent recorded clipped/Welcome-covered picker frames, no product root cause established. Two restoration attempts restored actual SSH/firewall/pre-existing active runtime state. E/`startup-controlled-harness/attempt-d2037af-warm-01` and supplement retained; `ed16ccc887dc40e8a65e12336f66bae3`. | -| warm02 | Initial snapshot root command returned 1; diagnostic omitted output by design, raw output/substep/root cause unknown. R7/core/helper-bound; FINAL/`startup-attempt-warm02-summary.json`: 11 files/2,704,256 bytes, four duplicated evidence files; `cc6a72cf312b44b698b474330909e9da`. `startup-host-context-d2037af-warm02.json` retains exact prerequisites/truthful shared-host context. | -| warm03 | `before.root-boot-policy` bounded stderr proves `ValueError: Invalid or duplicate GRUB environment variable`; offending guest bytes unproven. FINAL/`startup-attempt-warm03-summary.json`: 11 files/2,706,700 bytes; `4f6364e98aec411cacd3ea673e04f551`. Exact E/`startup-controlled-harness/attempt-d2037af-warm-03` copy recorded by durable-root `startup-warm03-emergency-copy.json`. | -| warm04 | Initial snapshot/root policy, packaged reconciliation/readiness validation passed. Parent saw black background/pointer only, could not verify Welcome dialog, returned null: `explicit-observe`, no input. Failed `review-action` before SSH fixture/warm-up/measured boots; cause unknown, no product-regression proof. FINAL/`startup-attempt-warm04-summary.json`: 16 files/2,736,770 bytes; exact `startup-controlled-harness/attempt-d2037af-warm-04`; `e9114b0a47bf45418ee25de82abb4804`. Summary/frame SHA-256 identities are in validation. | - -All source/helper/bound inputs remained unchanged. Warm02/03 stopped before reconciliation/Welcome/SSH -fixture/warm-up; warm02–04 dependencies/staging cleanup passed, no candidate transferred. -FINAL/`grub-environment-format-probe.json` reproduces old-parser rejection of Fedora -`grub2-tools-minimal-1:2.12-66.fc44.x86_64`'s standard warning, not specific guest offending bytes or -warm02's cause. Validation owns full stage/privacy/provenance and R30 retention scope. - -Pixel/readiness acceptance is blocked and comparison/performance unavailable. A future separately scoped -benchmark must visibly verify Welcome/portals, revalidate current exact prerequisites and collect eight -fresh equivalent boots: distinct boot IDs/auth sequences, six bound diagnostic/frame samples, SDK82/full -configuration equivalence and root GRUB evidence. Preserve strict privacy/authentication/configuration -guards; label host-cache-warm QEMU readiness upper bounds including auth/probe/UI delays, no -cold-boot/causal-speedup claim. GRUB/binfmt/portal grace unchanged. - -Representative physical lid/mirrors/docks/two externals/connector ambiguity/scaling/rotation and Secure -Boot, generic negotiated CSD, first-frame/new-artifact pointer/unsampled assistive interaction and full -native notification edges remain unqualified. User may `git pull --ff-only` then `./install` for -own-machine testing; no completed user-machine test or agent rollout is claimed. - -## Cleanup and final documentation - -V/`parent-lease-final-cleanup.json`: MAIN lease `29c5e2febb054008b06ff08cd794e589` released at -`2026-10-21T16:33:05Z`, return 0, closed/remaining empty/errors empty, workspace absent; idle container -`6d814bcad464`, network and workspace removed. V/`final-resource-verification.json`: all 53 recorded -CybexOS leases closed, no task QEMU/developer Quickshell/mounts/MAIN containers, shared services untouched; -no temporary VM/container retained. Ordinary documentation worktree remains until protected PR merge, -then Git removal. This documentation update is separate from tested d2037af artifacts. - -Own b4/75 and baseline 3b roots/PXE copies were removed after archival. Baseline cleanup removed -17 files/10,088,530,860 bytes plus PXE ISO 8,027,873,280 bytes/112-byte sidecar after 11 metadata hash -checks. V/`baseline-final-cleanup.json` and `pxe-after-baseline-removal.json`: iVentoy refresh success, -PXE running/service active, candidate present/baseline absent, no restart. - -V/`retained-inventory-cleanup-snapshot.json` records artifacts 18 files/10,123,452,292 bytes, requested -PXE ISO 8,063,483,904 bytes/112-byte sidecar and V archive 1,774 files/274,027,350 bytes. Reasons and -exact paths are in validation. This cleanup-time snapshot excludes itself and later docs/review/PR -records; final handoff recomputes totals. No completed user-machine test is claimed. Active desktop -rollout/restart/reboot and public release remain unauthorized/unperformed. diff --git a/docs/desktop-notifications.md b/docs/desktop-notifications.md deleted file mode 100644 index 37296924..00000000 --- a/docs/desktop-notifications.md +++ /dev/null @@ -1,374 +0,0 @@ -# Notification refinements - -## Shared settings contract (for schema integration) - -All existing notification preferences keep their values. New properties in -`Common/Settings.qml` use the following defaults; normalize finite numeric -values by rounding and clamping, invalid types to defaults. No migration resets -existing DND, quiet hours, duration, appearance or source choices. - -| Property | Type/default | Normalization | -| --- | --- | --- | -| `notifHistoryPersist` | bool `false` | strict boolean | -| `notifHistoryCount` | int `50` | 10–200 | -| `notifHistoryHours` | int `24` | 1–168 | -| `notifHistoryBytes` | int `262144` | 16384–1048576 | -| `notifDedupeSeconds` | int `10` | 0–60; zero disables | -| `notifBurstLimit` | int `5` | 1–20 admissions per source per rolling 10-second window | -| `notifPopupGroups` | int `3` | 1–5 | -| `notifPopupPerGroup` | int `3` | 1–5 | -| `notifFullscreen` | string `"away"` | `"away"` or `"show"`; invalid → `"away"` | - -Source snoozes are session-scoped (including shell hot reload only when the -singleton survives); they never write source names to settings. Durations are -15 minutes, 1 hour and 8 hours, with explicit unsnooze controls in the center -and settings. They do not survive logout. DND and quiet-hour settings retain -their existing persistence and startup policy. Codes never bypass any quiet -policy; there is no implicit exception for critical senders either. - -Persisted history is separate from settings and disabled by default. Disabling -it deletes the owned history file. Clear all clears memory and the file. The -count, age and byte bounds apply together. The runtime session retains at most -`notifHistoryCount` entries regardless of persistence. - -New English keys requiring Dutch translations (all are passed through I18n): -`Notification history`, `Remember notification history`, -`Store private history across shell restarts and logins`, `Retention`, -`1 hour`, `24 hours`, `7 days`, `History limit`, `50 notifications`, -`100 notifications`, `200 notifications`, `Clear notification history`, -`Source snoozes`, `No sources snoozed`, `Unsnooze`, `Unsnooze all`, -`Snooze 15 minutes`, `Snooze 1 hour`, `Snooze 8 hours`, -`Copy verification code`, `Open link`, `Dismiss group`, `Expand group`, -`Collapse group`, `Repeated %1 times`, `Verification notification`, -`Content redacted`, `History storage unavailable`, `Fullscreen notifications`, -`Use another display or history`, `Show over fullscreen`, `Grouped popups`, -`Burst limit`, `Extra notifications collect in the center`, -`Send grouped preview`, `Send verification preview`. - -## Provenance - -Reference: Omarchy tag `v4.5.0-rc1`, commit -`03ff1eff3812933f7088484533784d2f63eaf19a`, reviewed from the pinned checkout -`/tmp/cybexos-desktop-20261021-upstream`. Read `docs/notifications.md`, the -notification plugin manifest, `Service.qml`, `Store.js`, `Detect.js`, -`Security.js`, `Delivery.js`, `Layout.js`, `Settings.js`, and the notification -test runner and logic fixtures before adaptation. The upstream engine uses -Quickshell NotificationServer/Io/Wayland/Hyprland, its own shell/Commons plugin -loader, theme and settings. CybexOS keeps its existing singleton, theme, -settings, actions and sole D-Bus owner rather than importing that plugin. - -`NotifSmart.js` adapts the bounded keyword-near-code detector and narrow HTTP -URL grammar from upstream `Detect.js` and `Security.js`. Its embedded MIT -notice preserves Omarchy's copyright and the engine's Omapager attribution -(Neil Jagdish Patel, 2026). Other notification admission, grouping and private -storage code is implemented for CybexOS. No later moving-branch changes are -incorporated. Upstream executable hints, automatic click fallbacks, network -favicon fetching, live-popup restore and default code quiet bypass are not -used. No dependency version increase is required. - -Implementation behavior and actual validation evidence are appended below. - -## Using the controls - -Hover a card, or Tab to it in the center, to reveal sender actions, verification -code copy, safe link offers and the three snooze durations. Source snoozes show -an end time and an Unsnooze button in both notification views and Settings → -Notifications. Unsnooze all is available there too. Snoozed, DND and quiet-hour -arrivals stay in the center; they never become a delayed popup queue. - -Exact repeated messages show “Repeated N times”. The window is measured from -the first occurrence, so a continuous repeat cannot keep extending it. Source, -full bounded summary/body, urgency, icon/image content and action identity/labels -must match. Opaque action IDs are compared verbatim, including case and -whitespace. Visible and accessible action labels use the same plain-text -cleanup as the comparison. More than 16 actions, or an action ID or cleaned -label longer than 256 code units, disables deduplication without changing -source grouping or snoozes. Quickshell's transient numeric image handles are compared using -bounded native pixel fingerprints. Different handles containing identical -pixels can merge; different pixels remain separate. A missing module, invalid -handle, unavailable provider or oversized image disables deduplication for that -image. Long inputs that exceed admission bounds and progress-hinted messages -never deduplicate. A D-Bus replacement updates its existing slot and restarts -its displayed lifetime, without increasing the occurrence count. Obsolete -senders cannot close or update a slot transferred to a later duplicate. - -The display-only `webOrigin` hostname is separate from the bounded -`webSourceIdentity` used for deduplication, snoozes, burst limits and groups. -Qualified HTTP(S) senders use canonical lowercase scheme/host and effective -port: `https://chat.example` and `https://chat.example:443` both identify -`https://chat.example:443`; `https://chat.example:8443` and -`http://chat.example:80` are separate sources. IPv4/IPv6 forms are validated, -IPv6 spelling is canonicalized, and a DNS root dot stays distinct in source -identity even though the display hostname omits it. Invalid ports, credentials, -controls, escaped authorities and unsafe/ambiguous numeric hosts are rejected. - -A supplied bare hostname has no known scheme. Its identity is -`unqualified:chat.example`, separate from every qualified URL origin; a supplied -port is retained without inventing a scheme. An explicit origin hint keeps -precedence over a body anchor. If only an actual HTTP(S) origin anchor or legacy -origin prefix supplies the source, its supplied scheme and port are retained. -Snooze controls show the qualified authority (or bare host/port) so two ports -remain distinguishable in the unsnooze list. Existing friendly names, brands -and icon consumers continue to use `webOrigin`. - -History's additive `webSourceIdentity` field is at most 320 characters and -contains only an origin authority, never path, query, fragment, credentials or -an executable link. Both admission and the storage helper validate it. Existing -v1 history with only a hostname migrates to the unqualified namespace; the lost -scheme/port cannot be reconstructed. Invalid or mismatched stored identity -also falls back conservatively to its validated unqualified hostname. Code -redaction includes this field in detection and clears it along with all other -source/display fields. Source snoozes remain session-only; no preference or -shared-schema migration is required. - -Popups group by exact website origin or the native desktop-entry/app-name pair; -friendly brand labels do not combine unrelated websites. There are at most -three groups with three cards each by default, with at most five new popup -admissions per source in a rolling ten-second window. Excess arrivals remain in -the center. The front card and group count show while collapsed; hover or -Expand group reveals the members. Each card has its own dismiss control, and -Dismiss group clears the currently displayed members. Hover and explicit expansion -pause all members. New distinct cards from a held group collect in the center -rather than rearranging the controls beneath the pointer. No held cards replay -when the pointer leaves. All motion follows reduced-motion settings. - -A source deck stays on the display chosen at admission. New decks use the -focused display. The default fullscreen policy routes to another available -display, or directly to history when all displays are fullscreen. Changing -focus does not drag a deck between displays; disconnects and fullscreen changes -reconcile existing decks. Popup surfaces never take keyboard focus, including -when they appear beneath the pointer; mouse actions remain available. Use the -notification center for keyboard navigation. Popup height is bounded to the -screen with scrolling for expanded content. - -The opt-in native regression is `python3 -B tests/notification-focus-live.py`. -It uses a disposable Qt input window and actual Wayland keyboard enter/leave -events to check arrival, hover, and low/normal/critical popups appearing beneath -the pointer. It moves the pointer, injects no keys, closes its own notifications, -and restores the pointer and original window focus. On Hyprland 0.56.2 the old -OnDemand mode fails this check at notification arrival; None preserves focus. -The managed service remains the only Quickshell instance throughout the check. - -Code detection requires a nearby English or Dutch verification keyword and a -numeric or mixed uppercase token. It deliberately excludes product names such -as “VS Code”, ordinary years and unlabelled order numbers. Copy is explicit, -uses `wl-copy --sensitive` with separate arguments, and clears the clipboard -after 60 seconds only if it still contains that code. The clipboard manager -must honor the sensitive hint to avoid retaining it. Link offers show their -actual host, expose the full destination to accessibility, and only open on -explicit activation through Qt. HTTP(S) URLs must pass a narrow ASCII hostname, -port and escape grammar; executable schemes, credentials, control/bidi -characters, numeric-host ambiguities and nested escapes fail closed. No -notification content is interpolated into a command shell. Sender text always -renders as plain text. Attached images are limited to safe local files and -Quickshell in-process images; notification arrivals trigger no icon subprocess -or network favicon request. - -## Storage and integration - -When enabled, history lives at -`$XDG_STATE_HOME/cybexos/notifications/history.json` (fallback -`~/.local/state/cybexos/notifications/history.json`). The directory is 0700 and -the file 0600. One long-lived Python standard-library helper handles JSON-lines -requests. Changes coalesce behind a 250 ms timer, with at most one snapshot in -flight and one dirty flag, so a slow disk cannot accumulate notification -snapshots in the process pipe. Reads have a hard 1 MiB cap; requests have an -8 MiB framing cap; count, age and actual encoded byte limits apply together. -Writes use a private exclusive temporary file, fsync and same-directory atomic -replacement. Owned directory components and file reads do not follow symlinks; -clear removes only the owned history filename. Invalid, corrupt, wrong-version, -oversized or nonprivate files recover as empty and are repaired atomically. -Storage failure leaves session history available and shows a settings hint. - -Only inert, whitelisted text and count/time/urgency fields reach disk. A -code-bearing entry becomes a generic verification placeholder: source labels, -origin, desktop entry, raw/displayed text and every alternate/derived string are -discarded, including any identity that repeats the code. The storage helper -also checks alternate/nested input before whitelisting. It never stores images, -hints, links, executable argv, source fingerprints, D-Bus IDs or action handles. -Restored rows have no live, code or link actions and never create popups. New -arrivals received during the startup read take precedence. Obsolete read replies -after Clear all or a preference change are ignored. Disabling persistence -removes the file without creating storage when it never existed. - -Parent integration owns the Settings properties/schema/translations/package -parity and `Common/qmldir`. Register **ordinary types**, not singletons: -`NotifButton NotifButton.qml` and `NotifSnoozes NotifSnoozes.qml`. The existing -recursive shell payload must include the two new JS modules and -`scripts/notification-history.py`; Python 3 and wl-clipboard are existing -runtime dependencies. The helper is invoked through Python, so it does not -need a separate service or executable installation target. There remains one -existing `Notifs.server` NotificationServer owner. - -The Quickshell 0.2.1 source API was additionally checked at the pinned release: -`src/wayland/toplevel_management/qml.hpp` provides fullscreen state and the -screens where a toplevel is visible, and `src/services/notifications/notification.hpp` -provides replacement property signals and action lifecycle. The installed -Fedora Quickshell `0.2.1^git20260209.dacfa9d-5.fc44` removes `image-data`, -`image_data` and `icon_data` from exposed hints in `notification.cpp:152–154`. -Those hints therefore cannot supply stable attached-image identity. Its public -image provider exposes existing images through `QQuickImageProvider::Image`. -No newer API or dependency upgrade is assumed. `NotifSmart.js` also adapts -`Security.js`'s local-image-path validation under its embedded MIT notice. - -## Native attached-image identity and installation - -`roles/desktop/files/notification-images/` is local MIT-licensed repository -source, with no external source dependency or Quickshell SDK. The -`CybexOS.Images 1.0` module uses the current `QQmlEngine`'s public -`imageProvider("qsimage")` API and a checked `QQuickImageProvider` cast. -Its only accepted input is the exact form -`image://qsimage/<1–20 decimal digits>/<1–20 decimal digits>`. It never opens -files, URLs, other providers or commands. Image/Pixmap providers are supported; -missing, null and unsupported providers return a precise bounded error ID. - -Before canonicalization, both dimensions are capped at 4096, canonical storage -and original QImage storage at 16 MiB. SHA-256 covers a domain separator, -big-endian width/height and RGBA8888 pixels, excluding row padding. Pixel data -and image handles are not retained by the module. Runtime fingerprints are -excluded from persisted history along with all images. No subprocess runs -when a notification arrives. `NotifImageFingerprint.qml` loads synchronously -through a fixed-URL lazy Loader only for valid numeric image handles; it must -stay absent from Common/qmldir. A source preview without the module continues -to receive notifications, with dynamic-image dedupe disabled and a diagnostic -such as `native-module-unavailable` or `provider-unavailable`. - -Both installation paths use the identical `notification-images/build.py` -CMake recipe and native CTest. The checkout role is -`roles/desktop/tasks/notification-images.yml`; the image builder runs -`python3 image/notification_images.py` before `image/package`, which calls -`prepare_notification_images(ROOT, build, payload)`. The recipe uses standard -Fedora CMake, C++ compiler, pkgconf, `qt6-qtbase-devel` and -`qt6-qtdeclarative-devel`; public Qt6 Qml/Quick APIs need no compositor ABI pin. -Installation is `/usr/lib64/qt6/qml/CybexOS/Images`. Source checksums, Qt versions -and installed artifact checksums are recorded in -`/usr/share/cybexos/notification-images/build.json`. A changed recipe, Qt version -or installed artifact triggers rebuilding; valid unchanged artifacts skip the -build. The image packager rejects missing/changed sources, unexpected artifacts, -altered binaries or inconsistent build manifests. The compiler's temporary -directory is removed even on failure. - -Parent integration: import `notification-images.yml` from desktop main.yml; -import/call `prepare_notification_images` adjacent to titlebars in image/package; -run image/notification_images.py before desktop-payload and copy -notification-images.json into delivered artifacts. The RPM needs -`qt6-qtbase-devel qt6-qtdeclarative-devel`, the module under -`/usr/lib64/qt6/qml/CybexOS/`, and its license directory under `%license`. -The existing `/usr/share/cybexos/` file entry covers the recipe and manifest. -Uninstall removes only Images/, the recipe directory and its license directory. -Add `notification-images` to tests/run's Python fixtures. No new schema, setting -or translated user-facing string is introduced by image identity. - -## Validation evidence and limits - -Starting revision: `3b310d106255558ca213c955ef37ee314e8681f2`; implementation is -an uncommitted shared-worktree diff as requested. Parent records the final -integrated tested commit. This agent did not install the live desktop, start -Quickshell, launch a host container/VM, commit, reset or stash work. - -Passed: 114 Node tests across notification-policy, notification-history, -notif-helpers, notif-card, notifications-widget, notification-icon, -accessibility-contracts, indicators, settings-layout, typography, idle-timeouts, -view-churn, brand-icons and format test files. The new policy fixtures execute -actual service functions/subscriptions against fake signals, covering -replacement versus duplicate ownership, progress/source separation, quiet -expiry, burst/output routing, stale actions and restore redaction. The history -fixtures run the actual persistent Python process on disposable directories, -cover restart, corruption, permissions, symlinks, alternate-field redaction, -retention, encoded byte caps and clear/disable. Temporary test directories are -removed by each test. `git diff --check` passes. - -The parent's integrated QML lint reported an unused Quickshell -import in NotificationsPage; it has been removed. Parent source-gate findings -for brand allow-list validation and open-coded duration constants were fixed -without weakening their contracts, and the adjacent tests pass. - -The independent review P2 image-identity fix was compiled and tested in the -parent's existing Fedora 44 container `6d814bcad464` using GCC 16.2.1 and Qt -6.11.2. Native CTest passes (9 behavioral cases plus init/cleanup: 11 QtTest -passes): same/different pixel content under different handles, dimensions, -canonical format equality, row padding, URI rejection without provider fetch, -missing/null/unsupported providers, dimensions/original-storage bounds, -Pixmap and a real QML singleton using its engine's fake public provider. -Five Python artifact-admission/parity cases and 27 focused Node cases pass. -The unchanged second installer invocation reports UNCHANGED. Fedora's -tests/qml-lint passes all 355 QML files plus four harnesses with the native -module installed. Ruff, yamllint, ansible-lint and git diff --check pass. -These native tests establish the public Qt contract; the provider is a fake, -so they do not establish actual Quickshell/D-Bus image behavior. - -Independent review R2 found and fixed a further source collision: display-host -normalization had collapsed origins with different schemes/ports. The focused -fixtures now cover effective/default ports, scheme/nondefault-port/root-dot -separation, ambiguous hostname sources, canonical IPv4/IPv6, hostile origin -input, per-origin dedupe/groups/snoozes, additive v1 history migration and -identity-only code redaction. The notification/adjacent suite passes 130 Node -checks; full Fedora QML lint and helper Ruff pass. Parent reports actual D-Bus -image/replacement/snooze/action and private-history restart/redaction checks -passed before this R2 fix; the new origin routing needs its integrated runtime -rerun. This agent made no VM changes for R2. - -After `c5b5c96d284654bf9eb31766aaff09b098fbb574`, the parent's native -group/fullscreen/output matrix passed its behavior assertions but the same -invocation logged a `screenGroups` binding loop. The first change deferred -hold/focus publication during delegate teardown; a native rerun still failed, -so those lifecycle fixtures did not establish that the loop was fixed. - -The confirmed remaining cycle was `screenGroups` → window visibility → native -window creation → `screenChanged` → `screenGroups`. The old filter read -`PanelWindow.screen`. In the installed Quickshell revision `dacfa9d`, -`src/window/proxywindow.cpp`'s `setVisibleDirect` creates the backing window; -`createWindow` calls `completeWindow`, which unconditionally emits -`screenChanged` after connecting it, even for the same output. Toast selection -now reads `modelData.screen`, the authoritative output supplied by Variants. -Geometry can still read the live window screen without making popup admission -depend on window mapping. No timer, count, group bound or output-routing policy -changes are needed. - -`NotifHolds.js` now keeps owner claims in plain JavaScript state and coalesces -reactive publication through `Qt.callLater`, with idempotent no-ops and disposal -guards. Admission reads the claims immediately, so notifications cannot slip -into a hovered group while publication waits. Old delegates cannot release a -new delegate's hold for the same source. Buttons and cards share deferred focus -claims, preserving the aggregate pause during focus transfers and cancelling -callbacks when a deck dies. Group bounds, identities and countdown animation -state remain unchanged. - -The focused notification/adjacent suite passes 137 Node checks, including a -guard against synchronous reactive writes during the actual service's -holdGroup function and owner/focus-transfer races. The guard fails against the -old c5b5c96 holdGroup teardown. A further regression executes the production -`screenGroups` expression with a forbidden live-screen getter and checks -assigned-output changes, mixed-output groups, missing screens and card order. -It fails against the old filter. This is a dependency-boundary test, not a -simulation of native window mapping. A real Qt6 qmltestrunner regression passes 24 -model rebuild/output-removal/queued-focus destruction cycles with every QML -warning treated as a failure (3 QtTest passes including init/cleanup). It uses -the production queues in a Qt model/delegate graph; it does not construct the -Quickshell PanelWindow/ScriptModel. - -The parent's guarded VM experiment changed only the output predicate on top of -the hold/focus change: the full D-Bus matrix and three complete toast matrices -passed, with a clean invocation journal after each. These exercise grouped -burst bounds, single-output fullscreen suppression, alternate-output routing, -output creation/disconnection and focus preservation. The trap restored the -product source/service and verified the sole qs process. The parent then -transferred the final production source and repeated the full actual D-Bus -matrix plus all three toast/output matrices: exit 0, clean journal after each, -and the product shell restored with only the service-owned qs process. Full -Fedora QML lint passes all 355 files and four harnesses; git diff --check passes. -No VM or service was mutated by this agent for the binding-loop fix. - -The Node and Qt fixtures do not establish native behavior; the separate parent -VM runs supply that evidence for the exercised paths. Untested pointer/keyboard -and assistive-technology interactions, physical multi-monitor/scaling behavior -and installed-product qualification remain outside these checks. Settings -provides Send test, Send grouped preview and Send verification preview using -the same in-shell admission path. The parent owns final source/image gates, -independent review and release qualification against the integrated revision. -The native module remains in the parent's existing container for final lint. -A 92,160-byte matching VM module/recipe/license payload is retained at -`/var/lib/agent-resources/leases/29c5e2febb054008b06ff08cd794e589/workspace/notification-images-native-module.tar` -for the parent's isolated VM qualification, SHA-256 -`9dc18af643be70b4fe42211c667aeb4e9749f5a1cab856f027a75e5fa3fef458`. -Its lease owns cleanup. No host services or live desktop were changed. diff --git a/docs/desktop-startup.md b/docs/desktop-startup.md deleted file mode 100644 index 5e03b692..00000000 --- a/docs/desktop-startup.md +++ /dev/null @@ -1,268 +0,0 @@ -# Startup investigation and measurement - -The tested installed product remains `d2037af92cb3a47a08a9755e5996d2c4b0cae28d`. -Source/image gates, exact artifacts/testing PXE publication, four ISO scenarios, both checkout -parity cases, pristine offline/native/recovery and scoped native tests pass within their separate -scopes. **Actual startup/pixel/performance measurement is BLOCKED:** warm01–04 all failed with -zero startup samples, no comparison and no justified optimization/speed claim. No further VM -series is planned in this task. Hardware, first-frame and generic CSD limits remain; -the product is not fully qualified. This documentation update is separate from -the tested installed-product artifacts. - -Reference inspection uses Omarchy `v4.5.0-rc1`, commit -`03ff1eff3812933f7088484533784d2f63eaf19a`, specifically Limine configuration -and its two binfmt overrides. CybexOS retains GRUB, Plymouth, authentication, -encryption, recovery and Docker socket activation. No GRUB/binfmt/portal-grace -optimization has been made. - -## Investigation and unchanged policy - -Omarchy's one-second Limine value is a menu timeout, not complete boot time. -CybexOS live media's five-second menu does not establish installed GRUB delay. -Installed saved environment, failed-boot behavior, dual-boot detection, menu -access and recovery must be observed before changing policy. - -The actual Fedora 44 systemd-binfmt package orders after the mount and automount -without pulling in the mount. Omarchy's eager-mount hypothesis is structurally -relevant, but a container cannot establish this installed system's critical -path or a repeatable benefit. Binfmt remains enabled for tools and emulation. - -`hyprland-session-start` publishes the complete session environment and starts -the session target before the one-second portal-restart grace. Quickshell -starts through that target before the sleep. Removing it would not by itself -prove a one-second improvement to the first usable desktop. A socket or active -unit also does not establish file-picker/screen-sharing readiness. The grace -remains until a reliable bounded alternative and fallback are measured with -actual portal consumers. - -## Evidence contract - -`python3 scripts/startup-evidence.py capture --help` describes the read-only -capture command on an authorized installation. Capture never reboots, stops -services or changes boot configuration. It retains system/user systemd-analyze -time, blame and critical-chain, selected monotonic unit milestones, session -journals, GRUB environment, binfmt unit text, kernel and exact desktop packages. -Failed or unavailable firmware/loader diagnostics remain failures; missing -values are not zeroes. A long unit duration alone does not prove a desktop -critical-path delay. - -Desktop-ready requires a rendered bar and a successful launcher interaction -with a responsive compositor. Portal-ready separately requires an exercised -file picker and screen-sharing selection/Start with valid streams and confirmed -session cleanup. Retain boot-relative observer times, protocol results and -reviewed frames. Human/automated authentication, diagnostic/probe scheduling -and deliberate UI delays must be reported with their timing meaning; do not -silently subtract them from a different milestone. - -The ordinary comparator needs at least three distinct boots per revision on -the same hardware/configuration, exact kernel/dependencies and genuinely -observed milestones. It rejects repeated boot IDs, missing observations and -mixed dependencies even if labels match. Label cold/warm and first/normal -login separately; first-login seeding cannot stand in for normal login. Exact -source, package, monitor, encryption, power and bootloader state belong in the -evidence. Caller-supplied observations still require retained independent -review; the comparison command cannot prove their truth alone. - -## Preserved failed observations - -| Run | Observed evidence | Qualification | -| --- | --- | --- | -| Original baseline 3b310d1 probes | Three probes failed inventory capture and retained no usable UI frames. | Failed/unqualified; preserved. | -| Superseded 63f7785 first encrypted login | systemd reported 27.949s: kernel 0.626s, initrd 18.853s, userspace 8.469s; graphical.target at 8.442s of userspace. Authentication, first-login work and console test-access bootstrap were present. The initial bar frame was black; launcher typing rendered a responsive bar. One Escape cleared its query without dismissing it, obscuring the picker. | Diagnostic only; no desktop-ready or normal-login sample. Picker/ScreenCast did not complete. A long ldconfig unit did not prove recurring delay. | -| Historical b4dfdf6 encrypted observer | Inventory and fixed-path root hashes passed, launcher dismissal and normal-shell audit passed. File picker rendered but Escape returned error 2. ScreenCast reached CreateSession=0, then SelectSources remained pending with unconfirmed cleanup. | Failed/unqualified; no readiness sample or UI attestation. | -| Historical 75bc424 encrypted US observer | Inventory/root fixed-path hashes and two-Escape launcher dismissal passed. File picker rendered large. Inspected actions exceeded the 9/10-second deadlines. SelectSources/source-picker pixels rendered, but selection and Session.Close failed and cleanup was not accepted. | Failed/unqualified; partial visible UI is not portal readiness. | -| Exact d2037af controlled attempt `d2037af-warm-01` | Failed during baseline preparation. Two deadline-no-action outcomes, no input sent. File-picker header/Cancel clipped offscreen; SelectSources covered by first-run Welcome. | Failed/unqualified with zero accepted samples; no readiness timings/comparison/speedup. Pixels do not establish a product root cause. Restoration passed; lease closed/empty. | -| Exact d2037af controlled attempt `d2037af-warm-02` | Initial onboarding snapshot failed before reconciliation/Welcome/SSH fixture/warm-up. Root command returned 1; retained diagnostic omits command output by design, raw output unavailable. Exact substep/root cause unknown. | No input/context/measured boot/accepted sample/comparison/readiness timing. Candidate not transferred; source/helpers/bindings unchanged; lease closed/empty. | -| Exact d2037af controlled attempt `d2037af-warm-03` | Initial snapshot failed at `before.root-boot-policy`; bounded stderr proves `ValueError: Invalid or duplicate GRUB environment variable`. Exact offending guest bytes unretained. | No input/context/samples/timing/comparison; no candidate transferred, source/helpers/bindings unchanged, lease closed/empty. Warm02's cause remains unknown. | -| Exact d2037af controlled attempt `d2037af-warm-04` | Initial snapshot including root boot policy, packaged reconciliation and readiness validation passed. Parent's Welcome frame review saw black background/pointer only; dialog unverified, null response produced `explicit-observe` and no input. | Failed `review-action` before SSH fixture/warm-up/measured boots; zero startup samples. Black-frame cause unknown; no product-regression proof. Lease closed/empty; final measurement blocked. | - -Pinned backend inspection explains the observer assumptions: Hyprland portal -1.4.1 opens its source picker during SelectSources, rather than only Start; -GTK portal 1.15.3 maps Escape/dialog-close to code 2 and explicit Cancel to code -1. The observed failures do not classify a product portal defect. A corrected -observer must interact at SelectSources within its deadlines, use deliberate -file selection or explicit Cancel, complete successful Start/streams and -confirm Close/cleanup. It cannot relabel timeout, code 2 or missing cleanup as -success. All previous failed evidence remains intact. - -The external observer now enforces request/frame context, two-step launcher -dismissal, partial-failure preservation, cleanup gating and no input after -terminal events. Its inert review evidence and historical wrapper findings -are centralized in [desktop validation](desktop-validation.md); they establish -tooling behavior, not portal pixels or actual startup samples. - -## Controlled experiment and failed attempts - -The original baseline and candidate inventories differ by 82 native SDK -packages. Historical pristine offline-upgrade success does not make those -inventories equivalent for performance comparison. The retained controlled -wrapper specifies preparation with only the exact verified 82-package closure -before baseline observations; the pristine upgrade/recovery gate remains a -separate test. - -After 155 phase-1 inert tests, the d2037af startup retarget's fresh independent -R5 review is clean: all three R4 P2s in standalone idle/config validation, -stdout-lock deadline handling and stale standalone-pass handling are closed. -The review passed 195 inert tests and eight help checks, and independently -reproduced the archived failures. `startup-retarget-independent-r5.json` and -the frozen R5 controlled/observer manifest pins are recorded in validation. -These tooling results do not approve subsequent onboarding changes. The completed core aggregate passes all four -ISO scenarios, both checkout cases, pristine offline/native/recovery and -targeted native prerequisites. R27's 234 consistency checks cover completed -evidence excluding English, subsequently verified by the parent. These records -remain separate from readiness qualification. - -Actual attempt `startup-controlled-harness/attempt-d2037af-warm-01`, in the -durable evidence mirror defined in validation, failed during baseline -preparation before any accepted sample. Its two -sidecar outcomes are `deadline-no-action`, with null action hashes and no input -sent. The parent recorded offscreen file-picker header/Cancel controls and a -SelectSources picker covered by first-run Welcome. These failed fixture pixels -do not establish a product root cause or portal defect. R5's clean review and -unchanged source/helper/bound inputs retain their separate scope. - -Two restoration attempts report restored; `fixture-transport.json` in the -main evidence and failure supplement restores the actual original SSH/firewall -state, including pre-existing active/runtime state. The parent verified lease -`ed16ccc887dc40e8a65e12336f66bae3` closed/check empty. The whole attempt and -failure supplement remain retained. Validation centralizes the bounded record -paths and restoration detail. This failed run supplies no readiness timings, -comparison or speedup. Overall teardown is recorded separately in validation. - -Fresh scoped R7 closed R6's missing top-level `json` import and measured -`controlledBootPolicy` preparation binding. Its 237 inert tests include 42 onboarding -tests outside legacy adapters; six adversarial cases were rejected, eight help -checks and 80 Python/four generated-payload compiles passed, and the old NameError -negative control reproduced. All 271 inventoried files were unchanged. Synthetic -fixture failures and adapter exclusions supply no passing coverage. This is -inert tooling review only; exact manifests/report are in validation. - -The fresh R7/core/helper-bound warm02 attempt failed at the initial onboarding -snapshot, before reconciliation, Welcome callback, SSH fixture or warm-up. -The root command returned 1. Its retained diagnostic omits command output by -design (`include_output=False`); raw output is unavailable, so the exact snapshot -substep/root cause is unknown. No product root cause is established. The exact -82-dependency fixture and staging cleanup were prepared; candidate payload was -not transferred. No input, sidecar context, measured boot, accepted sample, -comparison or readiness timing resulted. Source/helper/bound inputs were unchanged. -`startup-attempt-warm02-summary.json` records 11 files/2,704,256 bytes, including -four evidence files duplicated in the failure supplement; lease -`cc6a72cf312b44b698b474330909e9da` is closed/check empty. The truthful host-context -record and exact prerequisites are centralized in validation; shared workloads -and launch load are context, not sustained-idle proof. Warm01 remains unchanged. - -R8's diagnostic stage-attribution P2 is fixed; fresh R9 is scoped clean with -260 supplied inert tests, 116 independent mocked preparations, eight help checks, -104 Python/five payload compiles. FINAL/`startup-diagnostics-independent-r9.json` -pins the immutable reviewed manifest recorded in validation. This is inert -diagnostic review only; original exceptions and strict guards remain preserved. - -Actual warm03 failed at `before.root-boot-policy` during initial onboarding -snapshot, before reconciliation/Welcome/SSH fixture/warm-up. Failed-stage bounded -redacted stderr proves `ValueError: Invalid or duplicate GRUB environment variable`; -exact offending guest bytes were not retained. Outer run omitted-output placeholders -do not contradict the nested captured diagnostic. Dependencies/staging cleanup -passed; no candidate transferred or input/context/measured boot/sample. Source/helper/bound inputs stayed unchanged. FINAL/`startup-attempt-warm03-summary.json` -records 11 files/2,706,700 bytes; lease `4f6364e98aec411cacd3ea673e04f551` closed/check -empty. Its exact attempt is copied under the durable validation root's -`evidence/final-r15-run/startup-controlled-harness/attempt-d2037af-warm-03`; -`startup-warm03-emergency-copy.json` there records it. - -FINAL/`grub-environment-format-probe.json` reproduces old-parser rejection of -a standard warning line from Fedora `grub2-tools-minimal-1:2.12-66.fc44.x86_64`. -This is supported-format reproduction, not proof of particular guest bytes. -Warm02's actual cause remains unknown; warm01/02 evidence is unchanged. - -R10 found an LF-separator P2 after supported-warning support. Literal LF -boundaries and a terminating-LF guard fixed it; fresh R11 is clean: 283 supplied -inert tests, seven independent methods/19 delimiter variants, four negative-control -methods, eight help/128 Python/five payload compiles. FINAL/ -`startup-grub-independent-r11.json` pins current controlled manifest -`e8a7f96eb48a972f912d3e0c61628b3451e5ce83ae11445e0f318e5c69194fe0`. -Strict policy/hash/authentication/comparison guards remain. This is inert review only. - -Actual warm04 passed the baseline initial snapshot including `before.root-boot-policy`, -packaged reconciliation and readiness validation. The parent reviewed a Welcome -frame containing black background and pointer only, could not verify a dialog -and explicitly returned null. Its outcome was `explicit-observe`; no input was sent. -The run failed at `review-action` before SSH fixture, warm-up or measured boots. -One Welcome review context, zero portal contexts/startup samples; dependencies/staging cleanup passed, candidate not transferred, source/helpers/bound inputs -unchanged. Black-frame cause is unknown; no product regression is established. - -FINAL/`startup-attempt-warm04-summary.json` records 16 files/2,736,770 bytes; -summary SHA-256 `0c5d017a9b3b3d143369680d461dc42968d8f53c001908ec15f1e7128ab2b741`. -The exact directory is `startup-controlled-harness/attempt-d2037af-warm-04`; -its `evidence/onboarding-preparation/welcome-review.png` SHA-256 is -`491f31f9d3c41aed91a5756b818bf8828a2f1c3acd70178be2988aa4485f7907`. -Lease `e9114b0a47bf45418ee25de82abb4804` closed/check empty. Warm02's cause -remains unknown; warm03's exact offending bytes remain unproven. All failed -attempts retain their distinct scope; no later tooling pass converts them to samples. - -Startup attempts are stopped. No further VM series is planned in this task. -Actual startup/pixel/performance measurement remains **BLOCKED**. Diagnostic -privacy, named read-only stages, bounded credential-redacted output, original -exceptions and no retained argv/input/environment remain required. - -A future controlled experiment requires one unmeasured warm-up plus three measured -boots on each side: eight distinct actual boot IDs with strictly increasing -positive integer start sequences. Authentication records must match their -captured boot ID, expected role and exact input/transport record. All six -measured samples must bind to their captured diagnostics and reviewed frame -results; replayed authentication or relabelled captures fail closed. Console -SSH bootstrap on a measured boot invalidates it. - -The complete package/configuration comparator remains in force, with only the -two reviewed first-party package exemptions and the reviewed schema 28→29/nine-default settings projection. The prepared SDK inventory and recorded -SSH/firewall fixture must match all observations exactly. All ten root-hash -paths must be present, with required files retained. Supplemental same-boot -root GRUB evidence includes environment bytes, configuration and bounded BLS -entries; pending menu/previous/next/recovery choices block comparison even if -identical across all samples. The reader never clears or edits those choices. -Original unavailable diagnostics remain visible. - -Preparation uses verified, root-sealed dependency-only transport, no trust-key -import and disabled repositories. No candidate payload is transferred before -baseline observations. The unchanged upgrader runs once with candidate-bound -inputs and recovery creation, then candidate observations and recovery checks -follow. SSH/firewall restoration and managed teardown remain required on -success, failure and interruption. An unverified restoration remains a failure. - -## Final blocked result and future acceptance - -If accepted, the experiment must be labelled **sequential host-cache-warm QEMU -stop/start observed readiness upper bounds**, normal login, SDK82-prepared -baseline, explicit SSH fixture. Bounds include authentication, probes and UI -delays. Report median/minimum/maximum and the actual idle test window/host load. -This does not establish representative cold boot, earliest usable frame, -physical-hardware behavior or a causal speedup. Unchanged startup policy alone -does not prove equal boot behavior. An equivalent series can still fail. - -Final disposition: observations failed with zero accepted startup samples across -four attempts; pixel/readiness acceptance is blocked; comparison/performance -results are unavailable. There are no pending startup measurement slots to fill -in this task and no further VM series is planned. - -A future separately scoped benchmark must visibly verify Welcome and both portal -consumers, then collect eight fresh equivalent boots with revalidated current -source/helper/artifact prerequisites and a separately reviewed retarget. Preserve the -authentication, package/configuration, GRUB and six measured-sample diagnostic/frame bindings above. -Representative physical timing and lid/dock/display coverage remain blocked. - -The [validation report](desktop-validation.md) records exact installed/native status, -failed-evidence identities, R30 scoped retention acceptance and completed teardown. -Both R29 repairs were independently verified. Current/frozen sources and all four -failed-attempt inputs are recoverable; ten older precursor source bodies were -already absent before audit, so complete transitive historical source is not claimed. -Original absolute paths are historical map-resolution identities, not a runnable -environment. Baseline 3b and b4/75 payload roots/testing ISOs were removed after -archival; final d2037af testing deliverables and bounded diagnostics remain. -All 53 recorded leases are closed, MAIN's workspace absent and no temporary VM/container -retained; shared services untouched. Validation qualifies the cleanup-time inventory -snapshot, which excludes later documentation/review/PR records. - -[PR #21](https://github.com/DigitalPals/CybexOS/pull/21) merged at -`bee9d722021301875823e6285a6eef2896b283eb` on `2026-10-21T14:01:19Z`, both -required hosted checks successful and origin/main verified. CI-only head -9974aaee adds `patch` to the Fedora CI action; tested installed artifacts remain -d2037af. Active user desktop rollout/restart/reboot and public release remain -unauthorized. This documentation update is separate from the tested artifacts. diff --git a/docs/desktop-validation.md b/docs/desktop-validation.md deleted file mode 100644 index 30558c04..00000000 --- a/docs/desktop-validation.md +++ /dev/null @@ -1,304 +0,0 @@ -# Desktop improvement qualification - -Snapshot: 2026-10-21. **Tested installed product: `d2037af92cb3a47a08a9755e5996d2c4b0cae28d`.** -Exact source/image gates, four ISO scenarios, English/Dutch checkout fresh/saved parity, pristine -offline upgrade/native rebuild/recovery and scoped native workspace/action regressions passed. **Not -fully qualified:** these are QEMU/fixture results. Actual startup/pixel/performance measurement is -**BLOCKED** after four failed attempts, with zero accepted samples; hardware, generic Wayland CSD, -first-frame and unsampled interaction limits remain. - -[PR #21](https://github.com/DigitalPals/CybexOS/pull/21) merged at bee9d722 on -`2026-10-21T14:01:19Z`, after both required hosted checks succeeded; origin/main was verified there. -The later CI-only fix and merge do not change the identity of the d2037af artifacts. This -documentation update is separate from those tested installed-product artifacts. - -## Revisions, artifacts and evidence - -| Role | Exact revision | -| --- | --- | -| Tested product/artifacts | `d2037af92cb3a47a08a9755e5996d2c4b0cae28d` | -| CI-only PR head: adds `patch` solely to `.github/actions/fedora-source-environment/action.yml` | `9974aaee9cb6160ddce86fe84d4a40fc4a75c055` | -| Merged, verified origin/main | `bee9d722021301875823e6285a6eef2896b283eb` | -| Implementation start | `3b310d106255558ca213c955ef37ee314e8681f2` | -| Original main, clean/unmodified at discovery | `6f4bdca3168d46cc76e8047fa94d172dac3cb696` | -| R15/R16/R18 workspace correction, included in tested product | `607bce226d9f85c4e6307567a75cc5cfbc8b1243` | - -Build root: `/home/john/.local/share/cybexos/images/desktop-20261021-d2037af`. Clean build -`20261021T121337Z-87cc6b87`, UTC `2026-10-21T12:13:37.650803+00:00`, binds 1,432 source files to -content SHA-256 `81249a8df4fad7e208c1d6322e0cd609fb71e3fec2523bbc31485389d5a56a1c`. Checkout source -archive SHA-256: `d25860faa4e72d5655474408b03195b128fda6adeba2eff6972c21f613510e7a`. - -Artifacts below are under the build root's `artifacts/`. Digests come from its `SHA256SUMS` and -recorded bindings; sizes are recorded stat values. - -| Artifact | Bytes | SHA-256 | -| --- | --- | --- | -| `CybexOS-Live-44-20261021T121337Z-87cc6b87.iso` | 8,063,483,904 | `28603ac50e9167f1896d5f0657d171eb8bd3ac429d1a54dbc37540a98b275552` | -| `cybexos-desktop-0.0.0~dev-1.20261021121337.gd2037af92cb3.fc44.x86_64.rpm` | 30,146,729 | `b7cf0a08ff5621f1b661b58ae88bf03551da8cea5cc4c739b9383b7c98930ae7` | -| `cybexos-offline-apps-1.5.1-1.fc44.x86_64.rpm` | 1,639,333,037 | `9d28269b1eec5fbff08d684f5098a8902c22b344f0eb17368dbdd97968b466d9` | -| `build-provenance.json` | 335 | `3c7d904f6bf0fd3a15f23a77ae9debed7481b592f24d8c70b7d4b382e878436d` | - -Testing ISO: `/data/pxe/iso/CybexOS-Live-44-20261021T121337Z-87cc6b87.iso`, with the same ISO -bytes/hash. Supported publication completed checksum, iVentoy refresh/list/PXE/service checks. This -is testing publication, not a public release. `pxe-publication.json` is publisher text despite its -suffix; R27 audited that record, not raw API responses or a new live PXE check. - -The durable root is `/home/john/.local/share/cybexos/images/desktop-20261021-d2037af/validation`. -Here **V** denotes that root; **E** is its `evidence/final-r15-run/` mirror. Original **MAIN** was -`/var/lib/agent-resources/leases/29c5e2febb054008b06ff08cd794e589/workspace`; **FINAL** was -`MAIN/final-r15-run`; that disposable workspace is now absent. Relative reports resolve under E -unless marked historical, FINAL or V. Original absolute paths are historical identities, not a -runnable environment. Resolve each original path plus expected SHA-256 and byte count through -V/`archive-map.json`, `archive-history.json`, `superseded-archive-map.json` and sealed `maps/`; -never substitute a newer file by name or rewrite hash-bound evidence. Older reports are under -`superseded/`. R30 accepted selected evidence durability within the limits below; future execution -requires a separately reviewed retarget. - -`artifact-bindings-d2037af.json` and `dependency-verification.json` bind both candidate RPMs to -`dependency-rpms-d2037af/manifest.json`, SHA-256 -`4792413da9a4d624acced296892f0638f428776bc50aea1be195a1cdda9ebefb`: 82 exact RPMs, 40,923,680 bytes. -Offline payload version remains 1.5.1. The historical pristine-upgrade baseline ISO SHA-256 was -`0fdd68f925c64f24323385b40e6383c5f5704fbcdb19581c5d134ee48b945a2d`; its payload was removed -after metadata archival. - -Qualified Fedora 44: Hyprland `0.56.2-2.fc44.x86_64`, source -`efb50993780079460b0cbed1363e2166a2de1d9f`, ELF build ID `04b20094d13879e70824418ba363f8dcc3253b34`; -Quickshell `0.2.1^git20260209.dacfa9d-5.fc44`. Earlier GPU fixtures used KVM, virtio-vga, llvmpipe -and disposable virtual outputs, not representative physical displays. - -## Completed actual qualification - -| Acceptance | Result and reproducible report | -| --- | --- | -| Exact source gate | Passed all 17 stages: 1,451 Node tests/148 files, 46 Python suites, 355 QML files/four harnesses with actual Qt/runtime checks, 233 Lua assertions. `gates-d2037af/results.json`, `source.log`. | -| Exact image gate | Passed: 30 application tests, 559 fixtures with three original skips, 14 installer Node checks. `gates-d2037af/image.log`; all three skips subsequently passed separate supplements below. | -| Four ISO scenarios | Passed within QEMU UEFI/outbound-blocked scope. `qualification-{plain,encrypted}-{us,nl}-evidence/qualification.json` and fresh/saved outcomes. Physical hardware/Secure Boot excluded. | -| Both checkout languages | Passed full graphical install, exact source/content/archive, saved choices and native settings lifecycle. `checkout-plain-{us,nl}-evidence/{checkout-qualification,parity-fresh,parity-saved}.json`: every profile has `differences:{}` and no unreviewed package deltas. English was separately verified by the parent after R27; its lease closed/empty. | -| Pristine offline upgrade/native/recovery | Passed guest signatures, one offline transaction, forced native build, one-line unchanged convergence, candidate runtime and recovery boot/restore. `qualification-offline-upgrade-evidence/{qualification,offline-upgrade-transaction,native-offline-rebuild,native-candidate-runtime}.json`. No SDK preinstallation into pristine baseline. | -| Targeted native workspace/actions | Passed within the stable observations and instrumentation scope below. `qualification-plain-us-evidence/{r15-workspace-rename-native,notification-actions-native}.json`. | -| Core prerequisites / independent audit | `core-qualification-d2037af.json` passed within startup-fixture prerequisite scope. `independent-acceptance-r27.json`: 234 read-only consistency checks, zero failures/findings, no tests executed; English excluded and subsequently parent-verified. Neither grants full project/release acceptance. | -| Main integration | Complete: `main-push-result.json`, `ci-only-revision.json`, `ci-patch-review-r28.json`. Both required checks SUCCESS: Fedora 44 source contract; Image tooling, installer fixtures and account isolation. | - -All ISO cases passed graphical installation, unchanged unused disk, boot without ISO, selected -locale/timezone/keyboard, desktop parity, live cleanup, SELinux Enforcing, password-required sudo -and fresh/saved settings lifecycle. Plain cases passed password login/plain Btrfs. Both encrypted -cases passed verified-encryption autologin, initial keyring unlock, synthetic-secret persistence -across cold reboot, authenticated logout/crash/manager recovery, missing-cache locked-keyring -protection, fallback password unlock and consumed failed-autologin attempt followed by -password-login recovery. These are not startup samples. Checkout package exceptions are reviewed -trust-key records versus ISO desktop/offline RPM bookkeeping; outcome parity does not mean -byte-identical inventories. - -Upgrade records bind newer desktop EVR, offline-apps 1.4.0→1.5.1 and recovery point -`20261021T125122Z-5063`. Forced output was `CHANGED: native titlebars v0.2.2 for -0.56.2-2.fc44.x86_64`, followed by one-line `UNCHANGED: native titlebars v0.2.2`. Converged plugin -SHA-256: `1142347ed31dcfb67733602b8ec6d88733131e0f8140ad8c3f50c870ef24e878`. The candidate snapshot -proves exact provenance, loaded hyprbars, true Scrolling/titlebar capabilities, empty error and -workspace 1 Floating on Virtual-1; it does not requalify pointer controls or -first-frame/client-configure behavior. Two SSH-255 poweroff diagnostics remain as -`root-failure-{1,2}.json` beside the final passed upgrade report. Offline transport retains bounded -metadata/hash checks, installed-trust signatures, root-only no-follow sealed staging, disabled -repositories and one transaction after recovery creation. Startup's SDK82-prepared baseline is a -separate experiment, not the pristine gate. - -| Gate log identity | SHA-256 | -| --- | --- | -| `gates-d2037af/source.log` | `1b917ab451a0de782773114b454556da1cd0d224751df4af9b315dc054406e1c` | -| `gates-d2037af/image.log` | `3f3e54a73b31568c321d209a402b0c612b2e4c864115deaf074a685e3a0b0be1` | -| `gates-d2037af/real-release-signing-supplement.log` | `ef8b3320a9f323eda412e8971af29709aede4f93337a472a8a82288ad499e882` | - -The original root run still records three skips. UID1000 reruns in `exact/candidate-r15` passed -publication permission staging and seeded-account keys/server/one-time-key behavior -(`gates-d2037af/unprivileged-supplement.log`). Unchanged RealReleaseSigning then passed one -test/zero skips in 1.464s after adding `rpm-sign`, `createrepo_c`, `createrepo_c-libs`, `drpm` only -inside the leased gate container. Its disposable key/subkey/RPM/repository directories were verified -absent; source stayed clean (`gates-d2037af/real-release-signing-results.json`). All skips have -passing supplements, not waivers or rewritten original results. No actual product -signing/public-release publication occurred or was authorized. - -## Native and UI evidence limits - -Workspace stable observations passed renamed `research-r15` opening tiled without mode tags, -numbered opening floating with ownership tags, named-group provenance through new -members/original-owner closure/reload/return to numbered Tiled, and manual tiled/floating groups -retaining choices as members joined and reloaded. Preferences/name were restored, test clients -absent, snapshot error empty and terminal fence set. This is not first-frame/client-configure proof. - -The instrumented notification test kept `OPEN`/`open` distinct, transferred the lowercase duplicate -to sender 3/count 2, emitted exact ActionInvoked `[1,"OPEN"]` and `[3,"open"]`, and expired only -superseded lowercase sender 2 before cleanup. No settings were written. Normal -`roles/desktop/files/quickshell/shell.qml` bytes/metadata/timestamps were restored; -original/restored SHA-256: `287edb8c3f4d5fb594e7927f1a5d3b79ad36c1012e251bbad2a2c39d89b3804b`. -Recorded boundaries had sole managed qs PID, checked nonempty current journals and zero QML errors; -entries/probe files cleared. Guest-local terminal state was retained until disposable teardown. R20 -action-count/length and cleaned-label/accessible-presentation edges remain source/runtime fixtures, -not this native test. - -`settings-visual-review.json` records parent's encrypted-US 1280×800 workspace settings, -notification top/grouped-popup/lower history/source-snooze controls and empty overview. Drag frames -covered lower content missed by wheel-only capture. The parent's frame review covers those views; -unsampled interactions, assistive-technology behavior and first-frame qualification remain open. - -Earlier native tests covered all layouts, local navigation/moves, guests, minimize/restore/reload, -custom defaults/overrides and manual groups; a 1600×1000 virtual output at scale 1.25/90° kept -geometry inside usable bounds. Historical titlebar compilation/offline convergence, declarative -reload, clear/re-add, drag/maximize/minimize/close and recompiled wrong-build-ID rejection passed. -The earlier byte-edit guard probe was invalid and supplies no acceptance. Historical writer -JSON/0700/ECHILD success required the exact bounded token and four behavioral regressions. Earlier -D-Bus/source/image separation, replacement/snooze/DND/redaction/restart and three -grouped-burst/fullscreen/output create/disconnect matrices passed after the toast-loop fix. These -predate later corrections; they do not requalify this artifact's pointer, mixed-client CSD, full -notification edge or physical/first-frame behavior. - -## Startup measurement: blocked - -Actual startup/pixel/performance measurement is **BLOCKED**. All four controlled attempts failed -with zero startup samples; no comparison, optimization or speed claim is justified. -Source/helper/bound inputs remained unchanged in each run. No further VM series is planned in this -task. - -| Attempt | Observed failure and limit | Retention / lease | -| --- | --- | --- | -| `d2037af-warm-01` | Baseline preparation failed, `actualAcceptanceClaim:false`; both `portal-actions/00000{1,2}-outcome.json` records were `deadline-no-action`, null action hashes, no input. Parent recorded clipped file-picker header/Cancel and SelectSources covered by first-run Welcome; pixels establish no product root cause. | Whole attempt/supplement retained, two restoration attempts passed; lease `ed16ccc887dc40e8a65e12336f66bae3` closed/check empty. | -| `d2037af-warm-02` | Initial onboarding snapshot root command returned 1. Retained diagnostic omits output by design (`include_output=False`); raw output, exact substep and root cause remain unknown. | FINAL/`startup-attempt-warm02-summary.json`: 11 files/2,704,256 bytes, four evidence files duplicated in supplement; lease `cc6a72cf312b44b698b474330909e9da` closed/check empty. | -| `d2037af-warm-03` | Initial snapshot failed at `before.root-boot-policy`. Bounded redacted failed-stage stderr proves `ValueError: Invalid or duplicate GRUB environment variable`; exact offending guest bytes unretained. Outer run output placeholders remain omitted output. | FINAL/`startup-attempt-warm03-summary.json`: 11 files/2,706,700 bytes, four duplicated evidence files; lease `4f6364e98aec411cacd3ea673e04f551` closed/check empty. | -| `d2037af-warm-04` | Initial snapshot including `before.root-boot-policy`, packaged reconciliation and readiness validation passed. Actual Welcome frame showed black background and pointer only; parent could not verify a dialog and returned null: `explicit-observe`, no input. Failed at `review-action` before SSH fixture/warm-up/measured boots; black-frame cause unknown, no proof of product regression. | FINAL/`startup-attempt-warm04-summary.json`: 16 files/2,736,770 bytes; lease `e9114b0a47bf45418ee25de82abb4804` closed/check empty. | - -Attempt directories are `startup-controlled-harness/attempt-d2037af-warm-0{1,2,3,4}`. Warm01's -`evidence/fixture-transport.json` and failure-supplement copy restore original SSH/firewall state: -enablement disabled/permanent SSH false, preserving originally active/runtime SSH true. Warm02/03 -stopped before reconciliation, Welcome callback, SSH fixture or warm-up: exact 82-dependency -fixtures/staging cleanup passed, no candidate transferred, no input/context/measured boot/sample. - -Warm01 launch load 0.95/1.66/2.40, no other task build/install VMs and remote hosted CI are context. -FINAL/`startup-host-context-d2037af-warm02.json` binds R7/core/helpers and launch load -0.828125/0.81640625/1.03125, finished task build/install VMs and running shared persistent -workloads. Neither record proves sustained idle or readiness. Warm01/02 evidence remains unchanged. - -Warm03's exact attempt is copied under E/`startup-controlled-harness/attempt-d2037af-warm-03`; -V/`startup-warm03-emergency-copy.json` records all 11 files/2,706,700 bytes; R30 later verified -recoverability of all four attempts. FINAL/`grub-environment-format-probe.json` -records an executable-format reproduction with Fedora `grub2-tools-minimal-1:2.12-66.fc44.x86_64`: -its standard warning line is rejected by the old parser. This proves supported-format rejection, not -the exact guest bytes behind warm03, and supplies no explanation for warm02. - -Warm04's exact original directory is FINAL/`startup-controlled-harness/attempt-d2037af-warm-04`. -Summary SHA-256: `0c5d017a9b3b3d143369680d461dc42968d8f53c001908ec15f1e7128ab2b741`; its -`evidence/onboarding-preparation/welcome-review.png` frame SHA-256: -`491f31f9d3c41aed91a5756b818bf8828a2f1c3acd70178be2988aa4485f7907`. The five evidence files, -including the frame, are duplicated in its failure supplement. One Welcome review context, zero -portal sidecar contexts/input/startup samples; dependency preparation/staging cleanup passed, no -candidate transferred. Warm02's cause remains unknown; warm03's offending bytes remain unproven. - -| Frozen tooling review | Scoped result / controlled manifest | -| --- | --- | -| R5: `startup-retarget-independent-r5.json` | Three R4 P2s closed; 195 inert/eight help checks, archived failures reproduced. Full-pipe callback/close 9.001s/0.0003s, unchanged flags/workers joined, not boot times. Manifest `6898ed59a0d1efaf9b4b34b2e0bd5f83f280f6df8614cfc0005fbe41271d0739`. | -| R7: FINAL/`startup-onboarding-independent-r7.json` | R6 global `json`/measured `controlledBootPolicy` preparation binding fixed; 237 inert (42 onboarding outside legacy adapters), six adversarial rejections, eight help/80 Python/four payload compiles, old NameError negative reproduced; 271 files unchanged. Synthetic fixture failures/adapter exclusions are not passing coverage. Manifest `9fd45d16597f4df7871fbc85434efda36aeb610dcd56dfc7adcb21aa17ea40ff`. | -| R9: FINAL/`startup-diagnostics-independent-r9.json` | R8 stage-attribution P2 fixed; clean inert review: 260 supplied (65 onboarding/diagnostic outside legacy adapters), 116 independent mocked preparations, eight help/104 Python/five payload compiles. Failed stages preserve original exceptions/causes, passed stages stay unblamed, strict guard/eligibility AST unchanged. Immutable reviewed manifest `92fba8d77a9c35a669c3437ee344b1e788346eef2553a4aa05fa6bf4e694e9c2`. | -| R11: FINAL/`startup-grub-independent-r11.json` | R10 LF-separator P2 fixed after supported-warning support: literal LF boundaries and terminating-LF guard. Clean inert review: 283 supplied, seven independent methods/19 delimiter variants, four negative-control methods, eight help/128 Python/five payload compiles. Policy/hash/authentication/comparison guards preserved. Current reviewed manifest `e8a7f96eb48a972f912d3e0c61628b3451e5ce83ae11445e0f318e5c69194fe0`. | - -Standalone observer remains `51eada68b783068e5ac62305dc45381ba7862a20b84f520d2ba63319164ba80a`. -These reviews establish inert tooling contracts, with no startup/pixel/performance acceptance. -Diagnostics remain opt-in, bounded and credential-redacted for named read-only stages, preserve -original exceptions and retain no argv/input/environment. The task's startup attempts are stopped. - -Pixel/frame acceptance is blocked: no accepted measured-sample frames or portal readiness. -Comparison/performance is unavailable without a valid equivalent series. - -A future separately scoped benchmark must visibly verify Welcome and both portal consumers, then -collect eight fresh equivalent boots with current revalidated source/helper/artifact prerequisites, -distinct boot IDs/auth sequences, six same-boot diagnostic/frame bindings, SDK82/full configuration -equivalence, reviewed settings projection and root GRUB evidence. Preserve all guards. Label any -accepted series sequential host-cache-warm QEMU stop/start readiness upper bounds including -authentication/probe/UI delays; no representative cold-boot or causal-speedup inference. Full -contract is in [startup](desktop-startup.md). No GRUB/binfmt/portal-grace optimization was made. - -## Retained review and superseded evidence - -Actual fresh T3 Codex tasks used verified `codex/gpt-6.1-sol`, xhigh/priority selections. -Implementers were not sole reviewers. Retained reports distinguish source/inert review, actual -runtime, recorded-evidence audits and release limits. - -| Review/history | Material result and retained reference | -| --- | --- | -| R1–R14 | Ownership/defaults, group/origin/UI/image issues, toast loop, offline admission/root sealing, native availability, wrapper cleanup and locale1 keyboard/variant fixes; `completed-review-records.json` and mapped historical/superseded reports. | -| R15/R16/R18/R20/R22 | Renamed opening/manual and named-group provenance corrected in 607bce2; exact IDs, null signatures over 16 actions or 256 code units per ID/cleaned label, shared plain-text visible/accessibility cleanup in d2037af, without source/snooze/group changes. R22 clean: 105 focused Node, 233 Lua/hotplug/margins, 972 notification, 2,317 layout assertions, 24 comparative probes. Host lacked parity prerequisites; later exact Fedora gates supply them. `review-records-r21-r22.json`. | -| R17/R19/R21/R23–R26 | External helper cleanup/metadata, races, exceptions, terminal fence and Kitty `-o confirm_os_window_close=0` fixed. R23: 4 supplied/20 independent/12 compiles; R25: 6/18/3 negative/6 compiles. Preserved Foot absence and unsupported `hl.dsp.window.focus` failures both restored. R26 corrected to `hl.dsp.focus`: 19 pinned dispatch sites, 7 supplied/14 independent/6 Python/10 Lua plus negatives; later actual workspace pass. `review-record-r26.json`, `preserved-plain-us-foot-launch-failure`, `preserved-native-focus-r25-failure`. | -| R27/R28/main | R27 reproduced content hash from 1,432 immutable Git blobs without large-artifact/live rechecks; English excluded then parent-verified. Initial hosted `titlebars.py` failed FileNotFoundError for missing `patch`; narrow R28 sole-path diff clean, then both hosted checks passed/PR merged. `independent-acceptance-r27.json`, `ci-patch-review-r28.json`, `main-push-result.json`. | -| Historical startup tooling | R8: 74 inert/144 terminal/16 stale-context probes. Earlier 3b→75 wrapper R1 fixed five bindings; R2 found replay; R3: 58 wrapper/72 inert-substitute observer checks (two product-dependent checks excluded), 273 compare/32 failure probes. These pins do not qualify retarget/runtime. | -| Historical installed results | b4dfdf6 and 75 passed four ISO cases; 75 pristine upgrade/native/recovery passed. b4 English checkout passed/Dutch keyboard failed; fixed in 75, whose Dutch install then failed SSH255 during outcome capture/cleanup, not parity. English 75 was not rerun after R15. OPPO font TLS, OpenH264 404/retry and initial pristine missing-SDK solver failures remain preserved, not waived. | -| Historical startup failures | Original 3b probes failed inventory/no frames. 63f7785 first encrypted login had 27.949s systemd diagnostics, black bar/launcher/picker failure with authentication/seeding/console setup, not readiness. b4 picker Escape=2 and ScreenCast cleanup failed. 75 inventory/root hashes/two-Escape dismissal passed, but large picker and inspected actions missed 9/10s deadlines; SelectSources rendered, selection/Close/cleanup failed. Backend 1.4.1 opens picker at SelectSources; GTK 1.15.3 Escape=2/Cancel=1 explains observer assumptions, not a product defect. See mapped `superseded/` reports and startup document. | - -R22 reviewed `75bc4243134ef93d96f208ab6edbb02e83438d18` plus correction diff SHA-256 -`a02af5098c37a48f0aa56950caf6d5f60c1a0909ad02beebf2d014fc902d3fe7`, then committed as -607bce2/d2037af. R26-reviewed native helper/test SHA-256 respectively: -`21eb0c82538e91dfc7b0bff4cc26671cb3a95523be8f6c59467e602939063cee`, -`3db76f20cf2c1c89508ad3350be428327975cfd70a55804996900a49510d446b`. Historical wrapper/R8 manifests -respectively: `0449beffb14af04201854c1ce20361281af25461c4e7e1caddd00d0a507d9649`, -`074746e8cca6909b1e4b0074c65754b5539ab88b0198cd4bae6a01a8a6ed9893`. `progress.json` can lag -completed evidence; preserve originals and use the map. - -## Remaining qualification - -Blocked/unqualified: representative physical lid/mirrors/docks/two externals, connector identity -ambiguity/scaling/rotation; Secure Boot; first-frame/client configure and new-artifact -pointer/unsampled assistive interactions; generic Wayland negotiated-CSD query (exact Nautilus/Text -Editor exclusions are policy only); full native notification overflow/presentation cases; actual -startup/pixel/performance measurement (blocked). No complete project or release qualification is -claimed. - -## Retained evidence and completed cleanup - -V/`independent-retention-r30.json` independently accepted selected evidence durability: 14,896 -record/assertion checks, no findings, 1,661 mapped non-RPM hashes. These are retention checks, not -product tests or release/runtime acceptance. The 80,789-byte report SHA-256 is -`7c79dd51a1abb1ee1451a99b1ab8412d4e88fa9e5412b483906fccf61a91f0fb`. Its audited -`archive-map.json` has 1,438 current records/193,372,721 payload bytes and SHA-256 -`f27ac95d8015900f6960baff3878047b7cd5479fb2edee9b8571daeb0a8c6002`. - -R30 independently verified both R29 findings corrected in V/`retention-r29-fixes.json`; R29 and -its hash-bound originals remain unchanged. - -| Retention correction | Exact retained binding under V | -| --- | --- | -| R29-1, P2: missing R27-bound nine-record ledger snapshot, 1,344 bytes; later ledger was 11 records/1,642 bytes, not a teardown failure | SHA-256 `7ea3038a25d250c89dd412db70de72073ec62c64dc3971a1188aba0a1485af36`, recovered at `history/7ea3038a25d250c89dd412db70de72073ec62c64dc3971a1188aba0a1485af36/final-r15-run/completed-lease-checks.json`; current ledger and R27 unchanged | -| R29-2, P3: missing late native terminal supplement, 1,111 bytes | `evidence/final-r15-run/native-terminal-inventory-binding.json`, SHA-256 `a3506c2f7560a980f2eb8ddd00ecb3fbe00a3e4b68b20882d59344d87c1d6795`; Kitty included/Foot absent, not new native qualification | - -R29's audited map remains at -V/`maps/8832a1321bcffad93bcbf84500dfa33c5ca281e03fbbbf714103d75d43e647b0.json`, with SHA-256 -matching its basename. R30 verifies current/frozen sources and all four failed-attempt inputs are -recoverable, including version-specific preserved helper aliases. Ten older pre-R3/pre-R8 precursor -source bodies were already absent before the MAIN audit; their metadata does not establish complete -transitive historical source preservation. Bounded text/privacy checks do not prove secrets absent -from PNG pixels or RPMs; ISO/RPM payloads were not rehashed by the audit. R30 preceded MAIN teardown; -the subsequent cleanup records below establish resource removal. - -Own b4dfdf6/75bc424 roots and PXE copies were removed after archival -(`superseded-root-cleanup.json`, `superseded-pxe-cleanup.json`, `pxe-after-superseded-removal.json` -under V). V/`baseline-final-cleanup-plan.json` and `baseline-final-cleanup.json` record removal of -`/home/john/.local/share/cybexos/images/desktop-20261021-baseline-3b310d1`: 17 files/10,088,530,860 -bytes, plus its 8,027,873,280-byte PXE ISO and 112-byte sidecar, after 11 metadata hash checks. -V/`pxe-after-baseline-removal.json` records refresh success, PXE running/service active, final -candidate present and baseline absent, without restart. The historical 3b baseline ISO is unavailable. - -V/`parent-lease-final-cleanup.json` records MAIN lease `29c5e2febb054008b06ff08cd794e589` released -at `2026-10-21T16:33:05Z`, return 0, check closed with `remaining:[]`/`errors:[]`, workspace absent; -idle container `6d814bcad464`, network and workspace removed. V/`final-resource-verification.json` -confirms all 53 recorded CybexOS leases closed, no task QEMU/developer Quickshell processes, mounts -or MAIN containers, and shared services untouched. No temporary VM/container is retained. The -ordinary documentation Git worktree `/home/john/Code/CybexOS-desktop-qualification-20261021` remains -until protected PR merge, then removal through Git. - -| Retained path | Cleanup-time count/bytes | Reason | -| --- | --- | --- | -| Build root's `artifacts/` | 18 files/10,123,452,292 bytes | Requested exact d2037af testing deliverables/provenance | -| Testing ISO under `/data/pxe/iso` named above | 8,063,483,904 bytes plus 112-byte sidecar | Requested PXE test image/checksum | -| V | 1,774 files/274,027,350 bytes | Bounded qualification/reviews, helper lineage and unresolved startup diagnostics; private VM state excluded | - -These are the `2026-10-21T16:33:43Z` cleanup-time snapshot in -V/`retained-inventory-cleanup-snapshot.json`, excluding itself and later documentation/review/PR -records; they are not final totals. Final handoff recomputes retained totals. - -User may `git pull --ff-only` then `./install` for own-machine testing with the limits above; no -completed user-machine test is claimed. Agent rollout/restart/reboot and public release remain -unauthorized/unperformed. This documentation update remains separate from tested d2037af artifacts. diff --git a/docs/desktop-workspace-architecture.md b/docs/desktop-workspace-architecture.md deleted file mode 100644 index 49e631a0..00000000 --- a/docs/desktop-workspace-architecture.md +++ /dev/null @@ -1,404 +0,0 @@ -# Workspace compositor contract - -Contract in tested product revision -`d2037af92cb3a47a08a9755e5996d2c4b0cae28d`; source/image gates, the exact-source -ISO/RPM build and testing PXE publication pass. All four ISO scenarios, both -English/Dutch checkout parity cases, pristine offline/native/recovery and scoped -native workspace/notification checks pass. The product is **not fully -qualified**: startup measurement is blocked; first-frame, hardware and generic CSD -limits remain open in [desktop validation](desktop-validation.md). -This documentation update is separate from the tested installed-product artifacts. -Main integration is complete at `bee9d722021301875823e6285a6eef2896b283eb`; -the intervening CI-only fix 9974aaee changes no installed-product payload. -Artifact qualification remains bound to d2037af. - -The compositor's `require("workspaces")` is the sole owner of identity, -numbering, layouts, minimize and persistence. Shared remains the first-install -mode; the managed base configuration uses dwindle. Without a personal workspace -default, explicit user.lua native layout choices remain authoritative. Enabling -per-monitor mode or taking ownership of the default layout is a deliberate choice. -The module never renumbers or merges live workspaces. A persisted assignment is -an ordered list of ten globally unique IDs, rather than an arithmetic block. -It adopts unowned workspaces already on that output, then allocates unused IDs. -This preserves existing windows/layouts and avoids destructive ID collisions. -A disconnected owner's workspaces remain reachable as guests after the ten -local slots on the surviving output; reconnect moves the whole workspace home. -A full local list never makes guest windows inaccessible. - -## API (schema v1) - -`$XDG_RUNTIME_DIR/cybexos/workspaces.json` is atomically replaced on relevant -compositor events. Arrays are JSON arrays (including when empty): - -- `v:1`, `mode:"shared"|"per-monitor"`, - `defaultLayout:"dwindle"|"scrolling"|"floating"|"unmanaged"`, - `defaultLayoutManaged:boolean`, `nativeDefaultLayout:string`, - `titlebarsEnabled:boolean`, `capabilities:{scrolling:boolean,titlebars:boolean}`, - `error:string` (empty on success). - `capabilities.titlebars` describes native availability, independently of the - titlebarsEnabled preference or whether any workspace uses Floating. It stays - true for a loaded, successfully configured component while its bars are hidden. -- `monitors:[{name,identity,label,activeWorkspace,workspaces:[integer IDs]}]`. - Only independent enabled outputs occur here; mirrors own no workspaces. -- `workspaces:[{id,localNumber,monitor,label,layout,layoutManaged,layoutOverride,nativeLayout,guest,ownerIdentity}]`. - `monitor` is the current reachable output, `ownerIdentity` the persisted - physical owner (empty for an unowned guest). `localNumber` is 1–10 for a local - slot; guest rows use 0 and an unambiguous label. In Shared mode regular global - IDs remain labels/numbers. Empty local slots are included. Each row occurs - once. Special/named workspaces are never allocated or rearranged. - `layout` uses the defaultLayout enum. `layoutManaged` is true only for an - explicit workspace override or a managed default. Otherwise native - `workspace.tiled_layout` and `general.layout` remain authoritative. Custom - native layouts such as master report `layout:"unmanaged",nativeLayout:"master"`; - never normalize this to Tiled. Native strings are bounded to 64 safe token - characters. Floating reports its underlying native layout as dwindle. - `layoutOverride:boolean` separately reports a recorded explicit per-workspace - choice. It remains true even when that choice equals the default. An inherited - managed default has `layoutManaged:true,layoutOverride:false`; an inherited - native layout has both false. UI inheritance/reset state must use - layoutOverride, never effective-layout equality. New snapshots always include - the flag; parsers must tolerate its absence in older v1 snapshots and retain - their legacy behavior until receiving the updated compositor snapshot. -- `minimized:[{address,workspace,monitor}]`: original regular workspace and - last known monitor. Windows live in `special:cybexos-minimized` until restore. - The compositor validates the live window before acting; UI cached rows confer - no authority to act on a nonexistent or recycled address. - -Public functions called through `hyprctl eval`: -`focus(localNumber, monitorName)`, `move(address, localNumber, monitorName)`, -`focus_id(id)`, `move_id(address,id)`, `next(direction,monitorName)`, -`new_workspace(monitorName)`, `set_mode(mode)`, `set_default_layout(layout)`, -`set_layout(id,layout)`, `cycle_layout(id)`, `set_titlebars_enabled(boolean)`, -`minimize(address)`, `restore(address)`, `activate(address)`, -`move_monitor(direction,address)`. Optional address/monitor select current focus; -optional layout ID selects the focused regular workspace. `next` accepts -1/1; -`move_monitor` accepts left/right/up/down/next/previous. Invalid/stale targets -raise errors before dispatch. `activate` restores minimized windows first. -`focus_id` permits a currently live regular workspace or a persisted local slot; -`move_id` validates both destination and current live window. Window addresses -must match `0x` plus 1–16 hexadecimal digits. Success returns nil; new_workspace -returns the selected ID. Callers must check process exit status. -`set_layout(id,"default")` removes a workspace override and restores inheritance; -"default" is an API action, never a persisted layout value. Cycling an unmanaged -custom layout starts at dwindle. `set_default_layout` always deliberately takes -ownership, even when selecting the same displayed layout. - -## Personal choices and lifecycle - -`$XDG_CONFIG_HOME/cybexos/workspaces.json` is authoritative personal data: -`{v:1,mode,defaultLayout,manageDefaultLayout,titlebarsEnabled,layouts:{"ID":"layout"}}`. -No preference file means `manageDefaultLayout:false`; the module does not write -`general.layout`, create a preference file, or claim existing native layout rules. -Mode, titlebar and workspace-only edits preserve this false flag, including -across reloads. The stored defaultLayout is a managed enum placeholder until -`set_default_layout` deliberately sets manageDefaultLayout true. Existing v1 -files without the flag retain their previous managed-default semantics. -Every deliberate per-workspace choice is saved, including choices equal to the -current default. Default edits preserve all these overrides and affect only -inheriting workspaces. Only `set_layout(id,"default")` removes an override. -Existing sparse preference maps retain every recorded entry; choices discarded -by an older implementation cannot be reconstructed, and missing entries keep -their inherited semantics. No migration invents missing personal choices. -Settings reads this compositor snapshot and sends setters only for deliberate -edits. workspaceMode/workspaceDefaultLayout/floatingTitlebars are not persisted -in shell.json; there is no second preference owner or startup push of defaults. -The custom Workspaces settings page can use `Settings.sectionKeys.workspaces=[]`. -Display custom values as “Custom (master)” / “Aangepast (master)”, or equivalent; -the unmanaged value is informational, never a setter argument. The reset action -must send `set_layout(id,"default")`, rather than the displayed default enum. -An absent native layout query falls back to dwindle for reporting only. -No installer writes personal choices into managed runtime trees. - -`$XDG_STATE_HOME/cybexos/workspace-assignments.json` retains physical assignments -across compositor sessions. A bounded session file in the runtime directory -retains minimize metadata across Lua reloads, scoped by HYPRLAND_INSTANCE_SIGNATURE. -The module also retains live floating ownership on window tags, which survive -config reloads. A shell restart needs only reread the snapshot. A compositor -restart preserves choices/assignments, but cannot restore windows that exited. -Old event subscriptions/timers/rules are retired on Lua reload. - -All input is strict bounded JSON parsed using the existing displays.lua reader; -no personal file is executed as Lua. Writes use private directories and atomic -replacement. Invalid files are ignored as a whole and reported in snapshot -error; setters refuse to overwrite a corrupt personal file. Native titlebar -availability is reported independently of the user's enabled preference. - -## Integration and qualification - -Deploy `workspaces.lua` and every `workspace_*.lua` companion alongside the -other compositor modules on both install paths. Load after monitors/displays, -looknfeel and user.lua. The XPS Shared policy exposes a mode switch to the -authoritative owner; it is disabled only while Per-monitor is selected. -Opening ownership runs synchronously on `window.open`, bound to the qualified -compositor's `openLate` event after native floating rules and grouping. It reads -the actual workspace name and native floating state before mapping returns. -Numeric `r[]` and `n[false]` selectors both include renamed positive-ID -workspaces, so no numeric opening window rule is installed. The shared -float-exclusion collector is no longer consumed or modified by this module; -native rules have already established independent floaters before the event. - -Native scrolling and optional decoration provenance, shortcuts, behavioral -evidence and remaining gates are recorded below and in desktop-validation.md. -Fixtures are source tests, not compositor or ISO qualification. - -## Identity, collisions and transitions - -The qualified HLMonitor exposes a short make/model description and a separate -serial, not separate manufacturer/model fields. Their length-prefixed values -form the physical fingerprint. Placeholder serials (`0`, all zeroes, `unknown`, -`none`, `null`) count as missing. A serial with no description remains stable -across connectors. With neither description nor serial the connector is the -only available identity. Internal panels are considered before externals; -connector order breaks ties, never Hyprland's recycled numeric monitor ID. - -A connected monitor retains its existing fingerprint/identity binding. Saved -connector matches come next; remaining matching identities are assigned in -stable order. Duplicate or absent serials can distinguish attached survivors, -but two indistinguishable panels whose connectors both change cannot be -physically distinguished; stable connector order is the documented fallback. -No claim of perfect physical tracking is made for that ambiguous case. - -Assignments own ten explicit IDs. Both live occupied IDs (including positive -IDs renamed into named workspaces) and every offline assignment are reserved. -New owners adopt unowned regular workspaces on their own output, then fill with -unused IDs. A renamed slot receives a new empty slot; the named workspace is -left intact. All ten occupied slots make `new_workspace` report capacity; -existing guest windows are still listed and reachable. Assignments are bounded -to 64 monitor identities, 640 slots; preferences to 1024 layout exceptions; -minimize to 256 windows; every data file to 256 KiB. Exceeding a bound reports an -error instead of stealing an ID or dropping live windows. - -Shared ↔ Per-monitor transitions do not renumber or merge workspaces, move -individual windows, alter window groups, or reset layouts. Shared keeps global -numbered shortcuts and the existing XPS rule owner. Per-monitor disables that -owner, installs one set of monitor rules, and reclaims whole owned workspaces -using workspace.move rather than the focus dispatcher's visible-workspace -swap behavior. Offline owners become guests without losing their IDs or layout -choices. Reconnection reclaims those IDs and restores unrelated visible -workspaces. Mirror outputs never acquire assignments. Lid close/open is the -same disabled-output/reconnect contract. Physical scaling, rotation and reserved -bar/dock areas are read when restoring normalized floating geometry. - -The vendor three-finger horizontal swipe is replaced with an event-driven local -list gesture while Per-monitor is selected and restored in Shared. Existing -Touchpad opt-outs and user.lua gesture overrides take precedence. Special and -named workspaces keep their existing bindings/behavior. Floating windows get -mode-owned tags only when the mode actually floats them, or a legacy opening -tag proves ownership. Pre-existing floaters and native app/dialog rules keep -their ownership. A new member of a mode-owned floating group inherits that -ownership before the original member can close. All -members are claimed before any grouped window floats. Fullscreen and pinned -windows retain their state; reconciliation runs again when they leave it. -Explicit Super+F floating choices release mode ownership for every member of -the selected window's group before the compositor toggles that group once. New -members inherit the existing group's manual choice before arrangement, so -opening a grouped client cannot reverse manual tiling or floating through reload. -Group provenance also follows new members while named or special, so renaming -back or closing the original owner cannot discard that choice; it does not -change the native floating state on those destinations. -That manual tag takes precedence over a legacy opening-rule tag, including its -dynamic `*` form, so newly opened Floating windows remain tiled after Super+F -through subsequent events and configuration reloads. - -Renamed/named/special workspaces retain their native floating state and never -gain Floating-workspace decorations. Existing origin ownership stays with a -window for a later return to a numbered destination. A saved numbered layout -override suspends while its ID has a user name and resumes when numbered again; -renaming never deletes the saved choice. Previously unowned floating windows -are preserved because they cannot be distinguished safely from deliberate user -floaters. - -Minimize records every group member and uses a hidden special workspace with a -window-local origin tag. PID, live mapped address, tag and compositor-session -signature guard stale metadata. `activate` restores before focus. Restoring a -floating window rescales normalized geometry into the current usable rectangle -and clamps the whole frame; fullscreen internal/client state is retained. -Super+Ctrl+M restores a minimized window even with the dock disabled. The -runtime session file is not loaded into another compositor session, and tags -die with their windows, so recycled addresses do not inherit hidden state. - -## Shortcuts and titlebars - -- Super+1…0 and Super+Shift+1…0: focused monitor's local slot / move window. -- Super+mouse wheel and the vendor horizontal swipe: monitor-local navigation. -- Super+Ctrl+L: cycle workspace layout. Super+L remains screen lock. -- Super+Ctrl+Alt+M: minimize. Super+M remains the Slack web app. -- Super+Ctrl+M: restore a minimized window, including when the dock is disabled. -- Super+Ctrl+Shift+arrows: separate display move, retaining useful geometry. -- Existing Super+left mouse drag, Super+right mouse resize, Super+Q close and - Super+F floating continue to provide controls if native titlebars are absent. - -Optional native titlebars are declared on every compositor config parse when installed, -and render only for Floating layouts. The fixed path is -`/usr/lib64/cybexos/titlebars.so`. `titlebarsEnabled` is the personal request; -`capabilities.titlebars` means the native registry confirms the plugin, its -required callback exists, and native configuration/button setup succeeded. -Loaded Tiled and disabled Floating modes report capability true with native -rendering disabled. Missing/unrelated registry entries, missing required APIs, -or failed configuration/button setup report false, even with an installed file. -Setup runs once per Lua lifecycle/theme change while hidden or visible; -rendering changes update only the enabled option. Unchanged event refreshes -reuse successful setup without config writes, button resets or duplicate buttons. -Loss of registry/API availability or any setup failure invalidates the cached -success, so recovery verifies setup again even when returning to old preferences. -A missing/ABI-incompatible plugin never disables keyboard/mouse controls. -Titlebars consume `cybexos/theme/tokens.json` (`surfaceRaised`, `text`, UI font -and text size) through the strict JSON reader. The existing -`cybexos_system_theme()` trigger refreshes them without another theme owner or -polling. The packaged native `clear_buttons()` extension clears existing -buttons before exactly three themed buttons are added, preventing duplicate -hit targets and stale icon textures. Its clear/re-add and pointer/theme behavior -passed historical isolated native tests on earlier product source; the exact -evidence scope and current-candidate gates are in desktop-validation.md. -User.lua can supply -`__cybexos_titlebar_colors={background,foreground,font,size}` deliberately. -The loaded-plugin registry gates all native callbacks and dynamic no-bar rules. -Runtime events, preference edits and theme refresh never declare/load plugins. -After hyprbars is registered, an exact known-CSD class rule suppresses its bars -for `org.gnome.Nautilus` and `org.gnome.TextEditor`; that rule is retired on -reload/plugin absence. This is an explicit application policy, not automatic -Wayland CSD detection or a claim about Firefox/Electron and arbitrary clients. - -Directory initialization uses system Python once per output directory per -compositor lifetime: O_NOFOLLOW directory open, effective-UID ownership check, -fchmod(0700), and a cryptographically random temporary prefix. Event writes use -only Lua I/O and atomic rename in that private directory; identical snapshots -and assignments are skipped. File modes inherit the process umask; the checked -0700 directory is the privacy boundary. Invalid or unreadable personal files -cannot be silently overwritten by setters. A same-UID actor can edit their own -preferences; this is not a sandbox against the account owner. - -## Provenance and native dependencies - -Inspected Omarchy tag `v4.5.0-rc1`, commit -`03ff1eff3812933f7088484533784d2f63eaf19a`: monitor-workspaces.lua, -workspace-modes.lua, workspace-layouts.lua, workspace-layout-toggle, its package -list and LICENSE, and shell tests hyprland-monitor-workspaces-test.sh, -hyprland-floating-workspaces-test.sh and hyprland-workspace-layout-test.sh. -The tag's migration cases for named workspace selection and saved layout/default -handling informed strict numeric selectors and explicit default overrides. -No subsequent moving-branch fixes were copied. Monitor allocation/persistence is -an independent implementation; floating claim tags, claim-before-group-float, -legacy opening ownership and logical/rotated geometry are adaptations of the -pinned workspace-modes.lua. Its MIT copyright/permission/warranty notice is -retained in workspace_layouts.lua. - -Inspected Hyprland `v0.56.2`, commit -`efb50993780079460b0cbed1363e2166a2de1d9f`: Lua query/dispatcher/rule/event/window, -monitor/workspace objects, and LuaFunctionGesture.cpp. Native scrolling is -already included (`src/layout/algorithm/tiled/scrolling`); `layout="scrolling"` -is a workspace rule. No compositor upgrade or scrolling plugin is needed. -Runtime availability probes the registered `scrolling.column_width` option; -unavailable Scrolling is not offered by cycle/setters and falls back to dwindle -when reading saved choices from an incompatible build. The actual Fedora -`hyprland-0.56.2-2.fc44` package and its installed stubs were inspected. -Earlier all-layout native runs are recorded in desktop-validation.md; -source inspection alone is not native qualification. - -Inspected optional `omacom/omarchy-hyprland-titlebars` commit -`379ff910fcce9f0cc9b41f9a87c527997076a417` (v0.2.2), README, -CMakeLists, main.cpp, barDeco.cpp, tools/verify.py and both licenses. Native -build needs CMake >=3.27, C++23, pkg-config `hyprland`, `libdrm`, `libinput`, -`libudev`, `pixman-1`, `wayland-server`, `xkbcommon`; it uses `-fno-gnu-unique`. -The shared object checks exact host/client Hyprland build hashes. Build against -exact Fedora Hyprland headers and rebuild/requalify on each compositor package -update; an Arch binary or matching minor version alone is insufficient. Package -both BSD-3-Clause LICENSE and MIT LICENSE.MIT. The plugin's Lua namespace stays -hyprbars, with workspace_tag and add_button support. - -Upstream skips X11 border opt-outs and supports explicit hyprbars:no_bar rules; -it does not automatically detect Wayland client-side titlebars. Automatic CSD -avoidance remains blocked on a supported API. The packaged clear_buttons patch -has historical isolated native runtime evidence; exact-class exclusions remain -explicit policy with mixed-client acceptance limited as documented in -native-titlebars.md and desktop-validation.md. The inspected 0.56.2 -`LuaBindingsConfigRules.cpp` resolves nonbuiltin `hl.window_rule` keys through -the registered window effect table, so `['hyprbars:no_bar']=true` is added only -after the native registry proves the plugin is loaded. Do not advertise general -duplicate-decoration avoidance. Its verifier needs a running outer -Hyprland, foot, grim, wayland-scanner and a C compiler; it launches a nested -compositor. On thebeast that verifier must run under an agent-resource managed -task/VM lifecycle. - -## Evidence and remaining gates - -Starting revision: `3b310d106255558ca213c955ef37ee314e8681f2`. The R15/R16/R18 -workspace corrections are committed in -`607bce226d9f85c4e6307567a75cc5cfbc8b1243` and included in exact tested product -`d2037af92cb3a47a08a9755e5996d2c4b0cae28d`. The contracts above include the -reviewed renamed-opening, manual-group and named-provenance corrections. - -`./tests/hyprland-workspace-modes` passes 233 pure-Lua behavioral assertions. -The exact Fedora source gate includes those assertions; XPS hotplug/policy, -single-window margins, syntax, source/image parity and actual Qt/runtime gates -also pass. These checks cover: - -- Physical identity ambiguity, connector changes, occupied/named IDs, full - slots/guests, mirrors/lid reconnect, visible-workspace swap avoidance and - Shared/Per-monitor transitions. -- Native capability failure, gesture opt-outs, independent floaters, fullscreen, - groups/opening tags, manual ownership, minimize/reload/activate/group restore, - scale/rotation/reserved areas and stale live-window validation. -- Strict/bounded/corrupt JSON, safe writer behavior and event-subscription - retirement. user.lua master defaults and unmanaged native rules survive mode/titlebar edits and reload without taking ownership. Explicit choices equal - to defaults survive default edits; reset, cycling and legacy sparse/v1 - inheritance retain their distinct meanings. -- R15 renamed positive-ID openings and suspended overrides, native dialog/user - float rules and group ownership. There are no numeric opening rules and no - consumption/mutation of the shared float-exclusion arrays. -- R16 manual-group provenance inherited before arrangement, and R18 ownership - inherited while named/special without changing native floating state. Reload, - original-owner closure and return to numbered workspaces preserve the choice. -- Declarative titlebar/registry/known-CSD rule lifecycle; loaded Tiled and disabled - Floating capability reports; missing APIs/registry and native setup failures; - recovery, cached successful setup and exactly-three-button theme refresh - without event loads, redundant writes or duplicate buttons. - -Fresh independent R22 found no actionable issue in the final correction scope. -Its detailed probes, historical native runs, external-helper reviews and -resource status are centralized in desktop-validation.md and the ledger. -Earlier native layout/titlebar tests retain their earlier product scope. - -The writer accepts Linux ECHILD from `pclose` only with the exact bounded helper -success token; absent/malformed tokens and explicit nonzero exit status remain -failures. Its historical native JSON/0700 result and regression evidence are -recorded in validation. - -All four exact d2037af ISO scenarios and English/Dutch checkout fresh/saved parity, -settings lifecycle and saved choices passed. Pristine offline upgrade/native -rebuild/convergence/recovery also passed. Its candidate-runtime report verifies -exact provenance, loaded titlebars, true capabilities, empty snapshot error and -one explicit Floating workspace. - -The separate `qualification-plain-us-evidence/r15-workspace-rename-native.json` -report passed renamed/numbered opening, named-group provenance, original-owner -closure, manual tiled/floating groups, reload and restoration. These are stable -native observations, not first-frame/client-configure proof. Exact notification -action separation and restoration passed separately; validation records its -scope and the source/runtime-fixture coverage of action-bound/label edges. - -The core prerequisite aggregate passes. Independent R27 is clean within its -completed scopes; English checkout was verified separately by the parent. -Actual startup/pixel/performance measurement is **BLOCKED** after four failed -attempts, zero accepted samples/comparison. R11 is scoped inert review only. -Warm04 passed root-policy/reconciliation validation but its Welcome frame was -black background/pointer only; null review response sent no input. Failure -preceded SSH fixture/warm-up; cause unknown, no proof of product regression. -Warm02's cause and warm03's exact offending bytes remain unknown. All startup -leases are closed/empty. No further VM series is planned; a future benchmark -requires visibly verified Welcome/portals and eight fresh equivalent boots with -current prerequisites, a reviewed retarget and unchanged guards, as detailed in -validation. R30 independently accepted scoped retention and verified both R29 -repairs; it does not grant runtime/release acceptance. All 53 recorded leases are -closed, MAIN's workspace absent and no temporary VM/container retained; shared -services untouched. Baseline/intermediate payloads were removed; final testing -deliverables and bounded evidence remain. Original paths are historical map-resolution -identities. Validation records the historical-source limit and cleanup-time inventory -snapshot, which excludes later documentation/review/PR records. -Physical lid/mirror/dock/two-external/connector/identity/scaling permutations and generic Wayland CSD remain blocked. -[PR #21](https://github.com/DigitalPals/CybexOS/pull/21) merged at bee9d722 on -`2026-10-21T14:01:19Z` after both required hosted checks succeeded; origin/main -was verified at that merge. Validation distinguishes the tested d2037af -artifacts from the CI-only fix and merge. Active user desktop rollout -and public release remain unauthorized. These scoped results do not establish -full desktop/release acceptance. diff --git a/docs/desktop-workspaces.md b/docs/desktop-workspaces.md deleted file mode 100644 index 2ff3a046..00000000 --- a/docs/desktop-workspaces.md +++ /dev/null @@ -1,225 +0,0 @@ -# Desktop workspace controls - -Open **Settings → Workspaces** to choose **Shared** or **Per-monitor** numbering, -the default layout, and a layout for an individual workspace. Existing and new -installations start in Shared mode. Without a saved managed layout choice, the -compositor preserves the native `general.layout` from the user's configuration -(normally dwindle). Floating workspaces show the dock automatically on their -own display; changing layout never changes its saved pins or auto-hide preference. - -In Per-monitor mode the compositor supplies each display's local numbers 1–10. -Each bar marks the workspace visible on its own display, including displays -without keyboard focus. Numbered/dot style, minimum visible slots and hide-empty -remain the existing bar preferences. Occupied and current workspaces stay -visible. A disconnected display's reachable visiting workspaces use the -compositor's explicit labels, including their old connector where needed. The -UI does not infer monitor identity or calculate IDs from arithmetic blocks. - -The layout symbol next to each bar's workspace strip shows Tiled, Scrolling or -Floating, or Custom with the native layout name (for example, master). Click it -to cycle that display's active numbered workspace. Scrolling appears -in the settings choices only when the compositor reports native support. A -missing decoration component is shown explicitly; the titlebar switch saves the -preference but does not claim an unavailable plugin can draw titlebars. -The default layout readout says **Unmanaged (master)** when the user's native -layout remains authoritative. Choosing or resetting the default deliberately -enables compositor management; simply opening Settings never changes it. -An individual workspace's **Default** option and reset clear its saved override -with `set_layout(id, "default")`, including when the default is unmanaged. -The action value `default` is never a saved layout preference. -An explicit layout choice remains owned by that workspace even when it equals -the global default. For example, choosing Tiled while the default is Tiled -keeps that workspace Tiled after changing the default to Floating. The picker -continues to show Tiled and offers reset; only Default clears the override. - -## Window and keyboard controls - -| Control | Action | -| --- | --- | -| Super+1…0 | Focus workspace 1–10, local to the focused display in Per-monitor mode | -| Super+Shift+1…0 | Move the focused window using the same mapping | -| Super+Ctrl+Shift+arrow | Move the focused window to another display | -| Super+Ctrl+L | Cycle workspace layout; Super+L continues to lock | -| Super+Ctrl+Alt+M | Minimize the focused window; Super+M continues to open Slack | -| Super+Ctrl+M | Restore a minimized window | -| Super+Tab | Open/close the overview | -| Overview arrows / Tab | Select a window | -| Overview Page Up / Page Down | Select a workspace, including the New workspace tile | -| Overview Enter | Restore/focus the selected window or focus the selected workspace | -| Overview Ctrl+N / Ctrl+L | New workspace / cycle the selected workspace layout | -| Overview Ctrl+M / Ctrl+Enter / Ctrl+W | Minimize / maximize or restore size / gracefully close the selected window | -| Overview drag | Move a window to the destination tile; New workspace allocates through the compositor | - -The overview includes minimized windows on their original workspace, with an -icon and a Minimized label instead of a live capture. Typing filters them like -other windows. The launcher Windows provider also labels minimized windows and -their monitor/workspace, and restores them before focus. Scratchpads and other -special workspaces keep their existing behavior. Named regular workspaces remain -in the overview and use the validated native named-workspace dispatcher. This -also covers `renameworkspace 1 research`, which retains a positive ID: native -name and numbered/named kind survive tile serialization, selection and drops. -Live workspace/window metadata takes priority over a stale numbered snapshot, -and works before the initial snapshot exists. Names are escaped as Lua data; -special workspaces and control characters remain rejected. Named-workspace -windows remain reachable through the launcher and dock; numbered-layout and -minimize controls are unavailable for them, matching the compositor API. - -The dock restores minimized windows on click or through **Restore -window** in its menu. Clicking a focused app with exactly one window minimizes -it; apps with multiple windows retain cycling. Its menu also provides minimize. -Configure it through **Settings → Bar → Dock**, linked from the workspace page. -When the dock is absent in other layouts, global minimize/restore shortcuts -and the overview/launcher remain available. If native titlebars are unavailable, -Super+mouse drag/resize and the overview's maximize, minimize and close controls -remain usable. - -The compositor's personal choices live only in -`$XDG_CONFIG_HOME/cybexos/workspaces.json` (normally -`~/.config/cybexos/workspaces.json`). Shell restarts read them through the runtime -snapshot and never push startup defaults. Assignment persistence, monitor -identity ambiguity, reconnect behavior and mode-owned floating state are -documented in [the compositor contract](desktop-workspace-architecture.md). - -## Integration contract - -- Register `singleton DesktopWorkspaces DesktopWorkspaces.qml` in - `Common/qmldir` (lead has done this) and initialize it from `shell.qml`, so its one snapshot reader - exists even when the dock and workspace bar module are off. No startup - commands or preference mirroring are performed. -- Register `Settings/WorkspacesPage.qml` as the `workspaces` settings page and - searchable rows for `workspaceMode`, `workspaceDefaultLayout`, `floatingTitlebars`. - `sectionKeys.workspaces` is empty: those choices belong exclusively to the - compositor's user-owned `~/.config/cybexos/workspaces.json`, not shell.json. - Custom rows intentionally have no shell `settingKey`; search opens this page - without treating these values as shell preferences. Individual rows provide - custom reset actions. Dock controls have one owner, the Bar page. -- The page reads the runtime snapshot and sends deliberate setter calls only - after `Settings.loaded`. Missing/invalid snapshots disable compositor-only - controls and retain existing shared navigation. A shell restart never sends - default values back to the compositor. -- Snapshot v1 uses monitor `workspaces` integer-ID arrays, workspace `guest` - and `ownerIdentity` fields, `capabilities.scrolling/titlebars` booleans, an - `error` string, and `titlebarsEnabled` for the saved titlebar choice. Guest - `localNumber:0` is preserved. Only the compositor allocates new IDs. -- Optional v1 `defaultLayoutManaged`/`nativeDefaultLayout` and workspace - `layoutManaged`/`nativeLayout` fields preserve native custom layout readouts. - Earlier v1 snapshots without ownership flags retain managed behavior. - `layout: "unmanaged"` and unknown custom layouts never become Tiled in the UI. - Snapshot rows carry an explicit numbered kind in presentation; live named - workspaces are not decorated or dispatched as those cached rows. -- Workspace `layoutOverride:boolean` is the authoritative inheritance bit. - The picker and its reset state use it independently of `layoutManaged` and - equality with the global default. `layoutManaged` describes layout support/ - management, not inheritance. For older v1 snapshots missing `layoutOverride`, - the parser retains the previous heuristic: unmanaged rows and rows equal to - a managed default display Default; other managed rows display their layout. - That compatibility path cannot reconstruct a same-layout explicit choice - absent from the older snapshot. No UI setter is sent to repair or guess it. -- The UI also touches the dedicated `Common/Dock.qml` model, in addition to - DockWindow.qml, to route all dock activation through restore-before-focus. -- Public module calls execute with argv `["hyprctl", "eval", expression]`, in - one bounded queue, with a live-address recheck immediately before execution. - Success requires normal exit, status zero, and exactly `ok` on stdout: - hyprctl may otherwise return a Lua error while exiting zero. Errors remain - visible in the workspace settings page. A five-second operation deadline and - missing-binary handling release the queue. Native fallback dispatcher objects - continue through `Hyprland.dispatch`. No shell interpolation is used. -- New-workspace drag uses one eval expression: the module's returned ID is - passed directly to `move_id`. Creating a new workspace selects it through the - module; dropping on an existing tile remains a silent move. - -## Provenance - -Reviewed Omarchy `v4.5.0-rc1`, exact revision -`03ff1eff3812933f7088484533784d2f63eaf19a`: its -`shell/plugins/bar/widgets/Workspaces.qml`, `default/hypr/workspace-modes.lua`, -`default/hypr/workspace-layouts.lua`, and -`bin/omarchy-hyprland-workspace-layout-toggle`, with relevant tests -`test/shell.d/hyprland-monitor-workspaces-test.sh`, -`hyprland-floating-workspaces-test.sh`, `hyprland-workspace-layout-test.sh`, -`workspace-layout-mode-migration-test.sh`, and the monitor-overlay acceptance -fixture. Upstream's MIT license (David Heinemeier Hansson) was read. The UI -implementation here is original CybexOS code built on its existing bar, dock, -overview, settings, theme and validated command helpers. It adopts the ideas of -per-display active state, visible layout control and mode-aware activation; -it does not copy upstream's connector/block mapping, layout subprocess/cache, -shell execution or substantial source. No later moving-branch fix was imported. - -## UI validation - -Tested against the local diff based on -`3b310d106255558ca213c955ef37ee314e8681f2`; no UI-agent commit was made in the -shared checkout. The integrated final revision and release gates are the lead's -responsibility. - -- **Passed:** 105 Node tests across desktop-workspaces, window-commands, - workspaces, overview-helpers, desktop-navigation, launcher-providers, - dock-helpers and tabler-icons. These exercise arbitrary non-block IDs, - monitor-specific state, connector changes, guests, missing/corrupt snapshots, - minimized-window inclusion/activation, stale addresses, eval error responses, - capability gating, startup preference guards, commands and existing controls. - Review regressions additionally exercise positive-ID named tile selection - and drops, missing/stale snapshots, named launcher/dock activation, closed - native windows, safe quoted names, unmanaged/custom layout fields, older v1 - compatibility, Default override clearing, and focused dock minimization. - The R2 explicit-override follow-up was tested on the uncommitted diff based - on `66b14a22dcc691851ed5ba87c57d88c7f98df8ef`: an explicit Tiled choice equal - to the default remains selected after a global Floating change, Default - clears ownership, and older snapshots retain their previous heuristic. -- **Passed:** `git diff --check` for the working tree at the UI handoff. -- **Blocked on host prerequisites:** `python3 -B tests/installation-parity.py` - ran 21 tests and reported 24 fixture errors from missing `ansible-playbook`; - `PYTHONPATH=image python3 -B -m unittest image.test_installation_parity - image.test_user_parity` ran 34 tests and reported 9 errors from missing Ansible - and the GNOME SMB schema. These runs do not pass parity. The lead's Fedora - environment must run both gates after integration. -- **Pending integration/runtime:** QML lint/loading and real compositor - multi-monitor, scaling/rotation, lid/mirror, minimize geometry, fullscreen, - groups, native scrolling and decoration qualification. The UI agent did not - alter the active desktop or create host containers/VMs. Node fixtures are not - runtime or installation qualification. Temporary UI test logs are removed - after this report; no UI-agent build artifacts are retained. - -## Dutch strings for lead integration - -Add these missing catalog entries through the existing catalog workflow: -The earlier handoff strings below have been integrated by the lead. The review -follow-up adds only `Custom (%1)` and `Unmanaged (%1)`; `Default` already exists. - -```json -{ - "Custom (%1)": "Aangepast (%1)", - "Unmanaged (%1)": "Onbeheerd (%1)", - "Configure dock": "Dock instellen", - "Cycle workspace layout": "Werkbladindeling wisselen", - "Default workspace layout": "Standaardindeling voor werkbladen", - "Display": "Beeldscherm", - "Enter to restore or switch · drag to move · Page Up/Down for workspaces": "Enter om te herstellen of wisselen · sleep om te verplaatsen · Page Up/Down voor werkbladen", - "Floating titlebars": "Titelbalken voor zwevende vensters", - "Floating windows": "Zwevende vensters", - "Maximize / restore": "Maximaliseren / herstellen", - "Minimize": "Minimaliseren", - "Minimized": "Geminimaliseerd", - "Native titlebars are unavailable. Use Super-drag and the overview controls.": "Native titelbalken zijn niet beschikbaar. Sleep met Super en gebruik de knoppen in het overzicht.", - "Native titlebars for windows that need them": "Native titelbalken voor vensters die ze nodig hebben", - "New workspace": "Nieuw werkblad", - "Open the overview with Super+Tab. Enter restores a window; Ctrl+M minimizes; Ctrl+L cycles layouts; Page Up/Down selects workspaces.": "Open het overzicht met Super+Tab. Enter herstelt een venster; Ctrl+M minimaliseert; Ctrl+L wisselt de indeling; Page Up/Down selecteert werkbladen.", - "Per-monitor": "Per beeldscherm", - "Restore window": "Venster herstellen", - "Scrolling": "Schuivend", - "Scrolling is unavailable in this compositor build": "Schuivende werkbladen zijn niet beschikbaar in deze compositorversie", - "Select a workspace": "Selecteer een werkblad", - "Shared": "Gedeeld", - "Shared keeps global numbers. Per-monitor gives each display its own 1–10.": "Gedeeld gebruikt globale nummers. Per beeldscherm geeft elk beeldscherm eigen nummers van 1 tot 10.", - "The bar layout button cycles the layout of its own display": "De indelingsknop in de balk wisselt de indeling van het eigen beeldscherm", - "The dock appears on floating workspaces. Minimized windows remain in the overview and launcher.": "Het dock verschijnt op zwevende werkbladen. Geminimaliseerde vensters blijven in het overzicht en de starter.", - "Tiled": "Getegeld", - "Used by workspaces without their own layout choice": "Voor werkbladen zonder eigen indelingskeuze", - "Visiting workspace": "Werkblad van een ander beeldscherm", - "Workspace": "Werkblad", - "Workspace command failed": "Werkbladopdracht mislukt", - "Workspace controls are waiting for the compositor": "Werkbladinstellingen wachten op de compositor", - "Workspace layout": "Werkbladindeling", - "Workspace mode": "Werkbladmodus" -} -``` diff --git a/docs/efficiency-review-2026-10-15.md b/docs/efficiency-review-2026-10-15.md deleted file mode 100644 index 5f4359f2..00000000 --- a/docs/efficiency-review-2026-10-15.md +++ /dev/null @@ -1,215 +0,0 @@ -# Efficiency review and implementation — 15 October 2026 - -The audit compared the working implementation with source revision -`5ce58a3ec8cfa75164f60923d640bfae5cc7227c`. Claude Fable 5.1 was asked to -verify the findings at Max effort. It confirmed the principal mechanisms, -qualified their scope, and identified the default Rust documentation as an -additional large seed cost. This independent review covered the findings; -the implementation was subsequently tested and measured as described below. - -Raw observations, workloads and caveats are in -[the measurement record](measurements/efficiency-2026-10-15.json). - -## Measured changes - -Paired artifact measurements use the first optimized build. The final build -after installation-parity corrections is identified below and in the JSON -record; runtime and component workloads are unchanged. - -| Area | Before | After | Scope | -| --- | --- | --- | --- | -| Production web Docker image | 803,746,314 B | 104,205,923 B | 87.0% smaller uncompressed image layers; standalone runtime includes its traced dependencies. | -| Web runtime `node_modules` | 498,699 KiB | 20,144 KiB | Development compiler and Cloudflare tooling stay in the build stage. | -| Browser wallpaper | 1,838,747 B | 90,336 / 250,438 / 678,532 B | 1280 / 2560 / 5120 pixel WebPs. The tested 2× browser fetched 250,438 B, 86.4% less. | -| Unrelated window renders during 20 clock ticks | 20 | 0 | Paired React test; the displayed clock crossed a minute. This measures render work, not browser CPU. | -| Quiet Sound page, four seconds after settling | 40 `pactl` probes | 0 | Real PipeWire-Pulse and QML. The 15-second safety poll remains; a real volume change still generated one five-probe snapshot. | -| Two-output widget RSS, median of three runs | 97,222,656 B | 80,986,112 B | About 15.5 MiB less in the isolated widget workload, before first popup use. | -| Two-output widget construction, median | 462 ms | 406 ms | About 12% faster. Headless Sway, software rendering and fake quota responses; not whole desktop login time. | -| Forced Wayfreeze rebuild, median of three runs | 129.39 s | 2.76 s | About 47× faster with a warm compiler/Cargo cache. Includes variable network time in the baseline. | -| Seven native JPEG wallpapers | 28,282,513 B | 27,936,923 B | 345,590 B saved through lossless optimization; decoded pixels, dimensions and filenames preserved. | -| Complete xz ISO | 8,071,483,392 B | 7,996,391,424 B | 75,091,968 B (0.93%) smaller. Every upstream RPM identity and Flatpak deployment matches between builds. | -| Build source archive | 33,331,560 B | 32,277,823 B | 1,053,737 B (3.16%) smaller; each archive records its exact source snapshot. | - -The initial cached builder image took 84.73 seconds to provision in a separate -run; an unchanged application then returned in 4.4 ms without network access. -These are different paths from a forced rebuild. Network variation prevents a -reliable claim that the first ever build became faster. - -All nine original findings and the additional Rust documentation finding are -addressed below. The browser container now uses the traced standalone Node -runtime; the Worker build remains available. The clock update is isolated and -aligned to minutes, while the open usage panel retains second-level updates. - -## Native runtime - -Sound subscriptions now accept card, sink, source, stream and server events, -and ignore client/module churn caused by the read-only probes themselves. -`LC_ALL=C` makes the filter independent of the desktop language. The measured -loop occurred only while the Sound settings page was open. - -Model Usage quota and legacy activity workers are shared by connection, -credentials, provider selection and refresh policy. Reference counts retain -workers across removal of the first output, and awake consumers control -polling. Manual refresh remains available while automatic work is paused. -Local loopback proxies remain usable without external network connectivity. -The existing shared Rust live stream is retained. A single-output system does -not gain from deduplicating output subscriptions. -The actual Quickshell engine fixture starts two matching consumers and records -one quota child and one legacy-activity child for their initial fetch. Removing -the awake consumer pauses automatic work; one manual refresh adds one child -of each kind, and changing credentials starts one new child of each kind. -These are controlled helper-invocation counts, not external API traffic. - -The popup's forms and delegates are constructed on first use and retained -after closing. The headless Wayland test checks synchronous settings creation, -keyboard focus, retained nonsecret drafts, and switching between limits, costs -and their settings. The memory gain concerns QML objects and bindings; it is -not a claim about GPU surfaces. The vendored integration is recorded as a -reviewed patch, checked before an upstream sync can replace the current copy. - -Plugin discovery keeps its registry watcher and immediate refresh on relevant -page changes. Its two-second package scan now applies only to Bar and Plugins; -unrelated Settings pages use the existing five-minute interval. A real helper -benchmark with Appearance open measured five scans in ten settled seconds -before the change and zero afterward (one initial scan was retained). - -## Application payload and update downloads - -New Rust installations use the minimal profile with explicit rustfmt, Clippy -and rust-analyzer. Existing user components are preserved. The pinned -`nightly-2026-08-23` documentation contains 739,153,743 bytes in 65,926 -files. Copying that tree with the existing seed function took 8.58 seconds on -the host's ZFS storage. That is a component benchmark, not installed Btrfs -first-login timing. Offline docs can be added with `rustup component add -rust-docs --toolchain nightly-2026-08-23`. - -The baseline application seed alone contained 3,586,210,627 bytes across -85,475 files; the rebuilt seed contains 2,847,058,243 bytes in 19,548 files, -a 20.6% byte reduction and 77.1% fewer files. Application stores and system -fonts are additional to these figures. Its `.rustup` directory -accounted for 1,428,749,636 bytes. The ISO also duplicated 6,468,676 bytes of -pinned fonts in 20 additional files. Packaging now verifies the canonical -prepared fonts and copies only the required notices to the license directory. -Missing or corrupt prepared fonts fail packaging. - -The desktop and large offline application seed now have separate RPMs. The -seed's independent version and reviewed input digest prevent shell-only -releases from forcing an application download. The desktop requires that -exact input identity. Spec ownership, signing, repository publication, SPDX -artifact bindings and offline upgrade qualification account for both RPMs. -The actual desktop RPM fell from 1,739,588,415 to 29,824,379 bytes; the -companion is 1,641,343,352 bytes. Together they are 68,420,684 bytes (3.9%) -smaller than the original monolith. A shell update that reuses the companion -needs a 98.3% smaller desktop download. The seed remains in a fresh ISO. -An actual RPM transaction from the monolith to both packages verified all -65,548 regular-file/symlink payload paths with zero duplicate regular-file -owners and a clean RPM verification. That private-root transaction excluded -scriptlets; it is not a boot or installation qualification. - -Published offline RPM identities must remain -immutable: the signing workflow authenticates the previous channel manifest -and rejects changed payload contents or ownership under the same identity. -Reuse an existing artifact or advance its version when moving upstream inputs -change. See [the release policy](releasing.md). - -Source builds provision a reusable compiler image per pinned base and build -recipe, mount a shared Cargo registry and separate locked target directories, -and copy only the requested executables. Cache data lives outside installed -application stores. The transactional update and healthy offline skip paths -are retained. - -## Compression choice - -A new `image/benchmark-compression` tool compares identical input hashes, -verifies extracted contents, alternates codecs across repeated runs, and -removes its temporary images. On the 4,457,839,657-byte baseline RPM payload -with 131,381 files and eight processors, one paired run measured: - -| SquashFS codec | Image bytes | Compression | Extraction | -| --- | --- | --- | --- | -| xz | 1,496,461,312 | 55.48 s | 6.55 s | -| zstd | 1,571,086,336 | 33.24 s | 4.86 s | - -Zstd was faster and 5.0% larger on this workload. A separate two-run Rust-docs -comparison was 2.9% larger without a consistent extraction advantage. These -measurements do not establish boot or installation time for the full ISO. -The qualified xz default remains, and the existing zstd build option remains -available. Binary stripping stays disabled because the payload contains -upstream binaries whose verified bytes must be retained. - -The review also checked the Android SDK selection and native timer guards. -The SDK already installs only platform-tools, the selected platform and -build-tools. The native timers examined already stop when their work or view -is inactive. No pruning or timer removal was justified for either item. - -## Validation and deployment - -The full source contract passes: 1,369 unit tests, QML lint and actual engine -lifecycle checks, 43 Python isolation/policy fixtures, installer convergence, -updater, snapshot and migration checks. Image checks pass 27 primary tests, -518 Python tests (two environment-specific skips) and 14 installer JavaScript -tests. The web passes lint, TypeScript, 116 component tests and two production -Worker/asset tests. Its Compose service was rebuilt/restarted on `thebeast` -and is healthy; desktop/mobile browser checks passed. - -Real qualification exposed checkout defaults that differed from the ISO. -Checkout now reads the ISO package selections, groups, exclusions and desktop -runtime requirements after shared repository setup. Prerequisite commands, -upstream RPM installation and Ansible disable weak dependencies. Both paths -use the complete locale and firmware baseline. Checkout prepares the shared -codec repositories and selects the ISO providers before base/desktop package -transactions, avoiding nine dependencies left by replacing interim codecs. It -also selects the ISO's `ocl-icd` OpenCL dispatch library in that transaction. -Shared policy selects graphical boot without interrupting the running session; -the test asserts that result. -Saved choices and user-owned data remain protected. - -The final ISO passed a complete plain-US graphical installation with outbound -networking blocked, boot without the ISO, application/toolchain checks, -password login and sudo, enforcing SELinux and managed Settings lifecycle -checks. Offline reconfiguration preserved explicit saved choices and personal -settings. A separate actual public checkout installation used the exact -archived source on disposable Fedora Cloud, rebooted into SDDM and repeated -the lifecycle/reconfiguration checks. The delivered source archive records -the installed product snapshot; subsequent changes are limited to documentation -and qualification comparison tooling. Fresh and saved installed outcomes pass -the strict same-source comparison, including package versions, effective user -unit activation, boot target, authentication and firewall policy. - -Cloud fixture preparation updates only its owned OS baseline, removes named -preexisting Cloud packages, selects the current generic Fedora identity and -boots the current kernel before installation. -The public installer does not prune user package inventories or old kernels. -Only the UTC spelling alias and equivalent session-target activation are -normalized; masks, missing units and inactive services remain differences. -The signed Brave keyring's background importer adds three prerelease keys on -running Fedora. The initial ISO chroot lacks `timeout` during that transaction; -both paths retain the identical keyring package and pinned stable repository. -Those three exact fingerprint/release identities are documented exceptions; -unknown keys and family-wide key exceptions fail. No trust records were deleted. -Every allowed package difference retains an explicit reason. See -[the installation contract](installation-parity.md). - -A new encrypted-US installation from the supported October 14 ISO passed, -followed by installation of these exact final split RPMs, reboot and recovery -boot/restore. It also verified the unused disk, encrypted Btrfs, login policy, -desktop parity, selected regional settings and live-system cleanup. These are -actual QEMU installations, not fixture-only evidence. Physical hardware, -Secure Boot and the full public-release matrix are outside these runs. - -Final build: `20261015T183818Z-25fb4f79`. Its ISO is `7,996,391,424` bytes; -the desktop RPM is `29,830,738` bytes and the offline companion -is `1,641,308,348` bytes. All artifact checksums and the SPDX ISO/RPM -bindings pass. The assembled payload also passed an exact credential-byte audit. - -The ISO is served at `/data/pxe/iso/CybexOS-Live-44-20261015T183818Z-25fb4f79.iso`. -The complete artifact bundle is `/data/cybexos-efficiency-20261015-yCv5AF/optimized-build-qualified-r6/artifacts` -(10,057,712,493 logical bytes), retained with compact qualification -reports for review. iVentoy refresh, image-list, running PXE status and service -checks passed. The JSON record contains final hashes and qualification reports. - -Disposable builds, test VMs, download/compiler caches, temporary logs and the -test container image were removed. The final ISO shares storage with the -retained artifact bundle. Preexisting PXE images were preserved, and the final -iVentoy refresh/list/service checks passed. No task-owned processes or mounts -remain. Compact cleanup and service checks are retained with the evidence. diff --git a/docs/fedora-major-upgrade.md b/docs/fedora-major-upgrade.md deleted file mode 100644 index 3488dba3..00000000 --- a/docs/fedora-major-upgrade.md +++ /dev/null @@ -1,368 +0,0 @@ -# Fedora major-upgrade runbook - -## Guided upgrade workflow - -`cybex upgrade-system` now coordinates preflight, a durable background DNF5 -**download**, an explicit offline reboot, target convergence and rollback. It -requires a separately reviewed target release; the current source manifest -supports **Fedora 44 only**. This workflow does not qualify or advertise Fedora -45. Incrementing a release number is insufficient. - -Select either a clean reviewed target checkout/release directory whose -`release-manifest.json` **and** inventory declare the target, or a signed -`cybexos-desktop` RPM for that release and architecture which provides -`cybexos-supported-fedora = `. The RPM signature must verify against the -installed trusted keyring. Selecting local source is an explicit administrator -trust decision, as with bootstrap; the workflow verifies compatibility and -freezes the selected bytes, but does not claim a local checkout hash proves its -author. A Git checkout must be clean and only tracked files enter the frozen -payload. No branch is pulled, reset or switched. - -Make and test an external backup first. Supply a JSON receipt beside its tar -archives, with paths relative to the receipt: - -```json -{ - "v": 1, - "createdAt": "2030-01-01T12:00:00Z", - "archives": [{ - "path": "workstation.tar.zst", - "sha256": "REPLACE_WITH_THE_ARCHIVE_SHA256", - "covers": ["/home/john", "/etc", "/var/lib/xps-hardware", "/etc/pki/akmods"] - }] -} -``` - -Use the actual backup timestamp, account home and checksum. The validator -requires a backup from the last seven days on another filesystem UUID, hashes -each archive, reads it completely through tar and verifies its declared -coverage. The account home and `/etc` are mandatory, plus the hardware/signing -paths when present. Archive members should be root-relative (`home/john/…`, -`etc/…`). This verifies integrity and coverage; the independent restore test -remains part of preparing the backup. - -```sh -cybex upgrade-system check --target --source /path/to/reviewed-release --backup /mnt/backup/receipt.json -cybex upgrade-system prepare --target --source /path/to/reviewed-release --backup /mnt/backup/receipt.json -# On an RPM installation, use --rpm /path/to/signed-target.rpm instead. -cybex upgrade-system status -# Once status is ready, close applications and explicitly start the offline upgrade: -cybex upgrade-system reboot -``` - -`prepare` returns after starting a durable root service. It does not reboot or -install packages into the running OS. Status becomes `ready` only after the -DNF download succeeds and the rollback checkpoint is armed. Closing the terminal -does not cancel the service. Its journal is under the `downloadUnit` named by -status. The helper never adds `--allowerasing` or disables signatures. - -Preflight requires the normal managed Btrfs root, free space on root/var/boot, -a clean RPM database, completed current-release updates, the default current -kernel, no pending hardware reboot, usable signed repositories, and healthy -installed camera ABI/userspace checks. Enabled repository URLs must follow -`$releasever` or be release independent; a URL pinned to the old Fedora release -must be reviewed first. Do not point the running system at target-only package -repositories. For an RPM target, the old CybexOS channel is omitted from the -offline download and the explicitly signed target RPM is applied after boot. - -`cybex upgrade-system cancel` is available after a completed or failed download, -before reboot is scheduled. It checks the saved metadata fingerprint before -cleaning DNF's offline state. It refuses to interrupt active DNF work or delete -an offline transaction that another operation replaced. An interrupted worker -retains diagnostic state rather than guessing which cached transaction to erase. -A normal reboot before scheduling the upgrade does not strand the download: -cancel or schedule it afterwards if the installed Fedora release and the saved -offline metadata are unchanged and no offline reboot is already scheduled. - -The first target boot converges the frozen source with the saved installation -choices, or installs/reconciles the staged RPM. RPM convergence requires the -current package's durable reconciliation status and every account to be ready; -a successful command exit alone is insufficient. Recovery waits for mounts, -the system bus and network availability, with login ordered after recovery. -Failed convergence, root health, -kernel or signing checks select the previous root and vendor desktop checkpoint -and reboot before allowing login. Successful root checks produce -`awaiting-desktop`, not success: the validation timer waits for an actual -Hyprland session, then verifies the managed shell through the transaction health -checks. A recovery bar does not count as a healthy desktop. A failed desktop -check selects rollback and records `restartRequired`; the active session is not -abruptly rebooted. Use `cybex upgrade-system reboot` to enter that restored root. - -Status is private under `/var/lib/cybexos/major-upgrade/`. Frozen release payloads -are under `/var/lib/cybexos/major-upgrade-payloads//`; source is retained for -reproducibility, while a committed RPM payload or explicitly cancelled payload -is removed. The transaction journal and checkpoint are independently stored in -the Btrfs recovery store. Personal files and settings are never reverted by the -vendor checkpoint. The backup covers data outside that checkpoint. - -`tests/major-upgrade.py` exercises artifact compatibility, signature gates, -staging, the download/reboot boundary, cancellation ownership, backup checks, -boot failure/rollback, deferred desktop validation and process-group cleanup -using fixtures. It is not an end-to-end Fedora major-upgrade qualification. - -## Release engineer preparation - -The playbook supports exactly the release named by `fedora_release`; this is a -safety boundary, not a default. Prepare and test repository support for the -next Fedora release before upgrading the workstation. Do not change the value -on the current-release branch and then continue applying that branch to the old -OS: the `always` preflight correctly refuses that mismatch. - -Replace `` below with the reviewed numeric release and use a dedicated -branch for all compatibility changes. - -## 1. Establish a recoverable baseline - -1. Finish or cancel any updater run. `cybexos-update-run status` must be terminal, - not `queued` or `running`. -2. Fast-forward the current-release branch, run `./update`, reboot into the - newest current-release kernel, and run `./verify --require-hyprland`. -3. Confirm that Secure Boot and the signed camera modules are healthy with the - checks in `docs/xps-2026-hardware.md`. Do not begin a release upgrade while - `/var/lib/xps-hardware/ipu7/reboot-required` describes the current boot. -4. Make and test a backup that covers the user's home, repository checkout, - `/etc`, `/var/lib/xps-hardware`, and `/etc/pki/akmods`. Also record - `rpm -qa`, `flatpak list --system`, enabled repositories, and the current - kernel. The guided workflow restores its pre-upgrade root/vendor checkpoint on - failure; the external backup and recovery media cover unsupported layouts - and personal data. Never attempt a mass package downgrade. -5. Ensure the prepared target-release branch and recovery media are available - without relying on this machine's graphical session. - -## 2. Prepare repository support - -Create an upgrade branch and audit every release-coupled input. At minimum: - -- change `fedora_release` and `supportedFedora` together, the README support - text, and the required job's container and name in - `.github/workflows/tests.yml` (its release assertion and the image tooling - read `fedora_release`); see [Flipping the release](#flipping-the-release); -- review required and optional RPM names (including the explicit `nodejs24` - packages), RPM Fusion release packages, vendor repositories, COPR output, - Flatpak behavior, and SELinux policy on the target release; -- publish and checksum a target-release `mdview` RPM instead of weakening its - `fc44` asset selector; review every other root RPM against the new DNF/RPM - stack; -- resolve and record a new digest for `source_build_container_image`; the - image must match the Fedora release used to build source applications; -- refresh the Fedora Distrobox source tag/digest if that box should follow the - host release. Existing boxes are not automatically recreated; -- rebuild and validate the XPS camera RPM/DKMS bundle against the target stock - kernel. Review the Fedora 44-specific BTF ABI baseline, spec description, - package names, patches, and hardware documentation rather than merely - changing comments; -- review the dated Rust nightly and all other immutable dependency pins for - compatibility, upgrading only those with a concrete reason; and -- run `python3 tests/fedora-release.py`, which names every remaining - release literal that disagrees with `fedora_release`, and inspect the rest - with: - - ```bash - rg -n 'Fedora 44|fedora:44|fc44|nodejs24|releasever|fedora_release' \ - README.md docs inventory roles site.yml tests image .github - ``` - -Parser fixtures containing `fc44` transaction examples do not need mechanical -replacement if they remain valid cross-release inputs. Change tests when the -contract changes, not for cosmetic consistency. - -Run `./tests/run` on the branch and require its target-release CI job to pass. -Exercise a disposable target-release VM or equivalent hardware-safe test host -for package resolution and Ansible check mode. A container can validate source -contracts, but it cannot validate SDDM, user systemd, Secure Boot, DKMS, camera, -audio, haptics, or the live Hyprland session. - -## 3. Download and perform Fedora's offline upgrade - -On the still-supported current release, commit or stash local work and verify -the DNF5 plugin exposes the expected commands: - -```bash -dnf system-upgrade --help -sudo dnf upgrade --refresh -sudo dnf system-upgrade download --releasever= -``` - -Read the proposed transaction. Stop and resolve repository, dependency, -package-removal, disk-space, or signature problems; do not reflexively add -`--allowerasing` or disable GPG checks. Once the download succeeds, recheck -that no repository updater is active, close applications, and start the -offline transaction: - -```bash -sudo dnf system-upgrade reboot -``` - -Do not interrupt power during the offline transaction. - -## 4. Converge the upgraded system - -1. After the first boot, confirm `/etc/os-release`, network access, storage, - Secure Boot state, and the running kernel from a console if the graphical - session is unhealthy. -2. Switch the checkout to the already-tested target-release branch. The old - branch should now fail its OS preflight, which prevents accidental use. -3. Inspect convergence first, then apply it: - - ```bash - ./tests/run - ./bootstrap --check --diff - ./bootstrap - ``` - -4. Let package, DKMS, and initramfs handlers finish. If the play or camera - diagnostics request a normal reboot, do it only after Ansible has reached a - terminal result. -5. Run `./verify --require-hyprland`, inspect the current boot and - `quickshell.service` journals, and manually verify camera, audio, haptics, - fingerprint, external displays, suspend/resume, Flatpaks, Distroboxes, and - the fallback GNOME session. -6. Run `sudo dnf system-upgrade log` if the offline transaction needs diagnosis. - Keep the old kernel and retained application/tool versions until the new - release has survived normal work and at least one suspend/resume cycle. - -Only merge the target-release branch and update the public support statement -after the installed-system checks pass. Record hardware gaps in -`docs/xps-2026-hardware.md` rather than hiding them with ignored failures. - -## Fedora 45 qualification - -**Fedora 45 is not qualified.** `fedora_release` remains `"44"` and -`release-manifest.json` lists only `"44"` in `supportedFedora`. Neither changes -until every item below has passed on a dedicated target branch, recorded with -dates, versions and run links. The `Fedora preview` CI job is an early warning -at source level; a passing fixture suite is not an installation result. - -### Repositories and COPR availability - -- [ ] Fedora 45 `fedora`, `updates` and `fedora-cisco-openh264` metalinks - resolve for x86_64, and the builder's `fedora-gpg-keys` ships - `RPM-GPG-KEY-fedora-45-x86_64`. `image/build.repo` uses `$releasever`, so it - needs no edit if the repository IDs keep their `fedora-N`, - `updates-released-fN` and `fedora-cisco-openh264-N` forms. -- [ ] RPM Fusion publishes `rpmfusion-free-release-45` and - `rpmfusion-nonfree-release-45` at the URLs `roles/apps` derives from - `fedora_release`, signed by the pinned key fingerprints, and every codec and - akmod package the roles request from it resolves. -- [ ] The Brave, 1Password, ChatGPT and Tailscale repositories only use - `$basearch`. Install each vendor RPM on Fedora 45 anyway: dependencies, - signatures under the new RPM, and scriptlets (the ChatGPT `%pretrans` that - `image/compose` installs in a second transaction) can still break. -- [ ] The `sdegler/hyprland` COPR has a `fedora-45-x86_64` chroot with - successful builds of every package the desktop requires: `hyprland`, - `hyprland-guiutils`, `quickshell`, `hypridle`, `hyprlock`, `hyprsunset` and - `xdg-desktop-portal-hyprland` (`image/cybexos-desktop.spec`). Its signing - key must still match the pinned fingerprint. -- [ ] Flathub has no Fedora release, but install every `apps_flatpaks` entry - on Fedora 45 to cover its Flatpak and OSTree versions. -- [ ] Required and optional RPM names resolve, including `nodejs24` and - `nodejs24-npm`, `base_required_packages`, and the CI dependency list in - `.github/actions/fedora-source-environment/action.yml`. - -### Hyprland and Quickshell - -- [ ] Record the Hyprland and Quickshell versions the COPR builds for Fedora - 45 and read the upstream changes since the last verified Fedora 44 versions - (Hyprland 0.56.2 and Quickshell 0.2.1 in this documentation). -- [ ] The compositor configuration is Hyprland Lua - (`roles/desktop/files/*.lua`, `roles/desktop/templates/*.lua.j2`). Confirm - that the API it calls keeps its semantics: `hl.bind`, `hl.unbind`, - `hl.dispatch`, `hl.exec_cmd`, the `hl.dsp` dispatchers (`exec_cmd`, - `window`, `focus`, `layout`, `global`, `exit`, `send_key_state`), - `hl.config`, `hl.get_config`, `hl.env`, `hl.monitor`, `hl.get_monitors`, - `hl.on`, `hl.window_rule`, `hl.layer_rule`, `hl.workspace_rule`, - `hl.animation` and `hl.curve`. The luajit fixtures in `tests/hyprland-*` - stub this API, so only a live session with no configuration errors proves - it. -- [ ] Run `tests/qml-lint` and the QML runtime stages with Fedora 45's Qt and - the COPR's `qs`, then run the shell through `quickshell.service` and check - its journal for QML errors. Check hypridle, hyprlock, the portals, screen - sharing and the authentication agent in the same session. - -### Anaconda and the custom installer - -- [ ] Diff the Fedora 45 Anaconda and anaconda-webui against the reviewed - reference, Anaconda 44.30 and anaconda-webui 68 (`image/INSTALLER.md`). - Review every interface `image/live-rootfs/usr/libexec/cybexos-installer-backend` - calls (storage scan, automatic partitioning with Btrfs and LUKS2, disk - initialization, tasks, users, localization and `InstallWithTasks`). Update - the strict proxy member sets in `image/test_installer.py` from the new - source rather than loosening them. -- [ ] Re-check `image/live-rootfs/etc/anaconda/conf.d/20-cybexos.conf`: the - `webui_web_engine` hook, hidden pages and spokes, the Btrfs default scheme - and the network default. Re-check the liveinst launcher's authorization and - its refusal of Kickstart-based live installations. -- [ ] Confirm that Anaconda still runs the post-scripts that - `usr/share/anaconda/interactive-defaults.ks` includes from - `post-scripts/90-cybexos.ks` through the Runtime DBus module, and validate - both kickstarts with pykickstart's `F45` handler. - -### Image build - -- [ ] `image/live.ks` and `image/build.repo` name no release, and - `image/compose`, `image/build-in-guest` and `image/build` read - `fedora_release` through `image/fedora_target.py`. -- [ ] Review the builder's livecd-tools/imgcreate. `image/compose` adds the - signature verification livecd-tools 31 skips, loads RPM 6's SQLite database, - installs `Requires(pretrans)` packages in a second transaction and hooks - `_configure_bootloader`. Re-read each workaround against the new version and - never drop signature verification. -- [ ] Pin the official Fedora Cloud 45 image name and SHA-256 together, from - Fedora's signed CHECKSUM file, in `image/build`, `image/qualify-checkout` - and `tests/fedora-vm-convergence` (and the literal in - `tests/repository-policy.py`). The builder runs the target release; the - source contract rejects a builder image for another release. -- [ ] Check the live initramfs that compose regenerates with - `image/dracut-live.conf`, the Plymouth `cybex` theme, GRUB and syslinux - branding on UEFI and BIOS, and the kernel, firmware and Mesa packages - compose requires. -- [ ] Sign the desktop RPM in a Fedora 45 container - (`.github/workflows/desktop-release.yml`) and publish a separate update - channel: `image/channels/stable.json` and `image/github_release.py` use the - `/44/x86_64` path. - -### CI preview job - -- [ ] The `Fedora preview` workflow (`.github/workflows/fedora-preview.yml`) - runs `./tests/run` in `fedora:45` on pull requests, pushes to `main`, - weekly and on demand. It shares the required job's dependency and COPR - setup, installs what Fedora 45 provides, runs every check it can, and then - fails the job if anything was unavailable. The job may fail without failing - its run; it is not a required check and `release.yml` never calls it. - Require a run with every stage passing and no `Unavailable` report before - flipping; even a clean preview qualifies nothing. - -### VM qualification - -- [ ] `./tests/fedora-vm-convergence` on the pinned Fedora Cloud 45 image: - a zero-change second pass and uninstall restoration. -- [ ] Build a Fedora 45 ISO from the target revision and run `image/test-live` - and `image/qualify` for every scenario in `image/qualification.py` - (`encrypted-us`, `plain-us`, `encrypted-nl`, `plain-nl`, `encrypted-de`, - `plain-de`), then `image/qualify-checkout` and `image/release-gate`, which - also requires the prior-release RPM upgrade and recovery check. -- [ ] Upgrade lifecycle: install the current Fedora 44 release, then run - `cybex upgrade-system` to 45 once with a signed target RPM and once with a - reviewed target checkout, plus a deliberately failed convergence that must - roll back. Use encrypted and plain installations. -- [ ] Hardware: the XPS camera bundle and BTF ABI baseline, Secure Boot, - audio, haptics, fingerprint, external displays and suspend/resume, as in - [section 4](#4-converge-the-upgraded-system). - -### Flipping the release - -Only after every item above passes, on the target branch and in one change: - -1. Set `fedora_release: "45"` in `inventory/group_vars/all.yml` and - `"supportedFedora": ["45"]` in `release-manifest.json`. The playbook - targets exactly one release, so the manifest names only that one. -2. Run `python3 tests/fedora-release.py`. It fails with each remaining - literal to review: the required job's container and name, the Fedora Cloud - pins, the signing container, the update channel path, the `fc45` mdview - asset and the source-build image comment. -3. Rename the required check to `Fedora 45 source contract` in branch - protection and `docs/releasing.md`; the `fedora-44` job id can stay. -4. Move the preview matrix to the following release. The source contract - rejects a preview of a supported release. -5. Rebuild and qualify the ISO and RPM from that revision. Earlier reports do - not carry over. diff --git a/docs/fusebox-widget.md b/docs/fusebox-widget.md index 68c69cea..7be08481 100644 --- a/docs/fusebox-widget.md +++ b/docs/fusebox-widget.md @@ -20,7 +20,7 @@ Open Settings → Menubar → Widgets → Fusebox: `https://fusebox.example.ts.net`. A pasted dashboard link such as `…/#/accounts` is accepted. User names, passwords and queries are refused. - **Management key**: the server's `management-key`. The field is a password - field. Committing it hands the key to `scripts/fusebox.py` over stdin, which + field. Committing it hands the key to `desktop/quickshell/scripts/fusebox.py` over stdin, which writes it to `~/.config/cybexos/fusebox/management.key` (mode 0600 in a 0700 directory, replaced atomically). The key is never stored in `shell.json` or on a command line. It is sent only to this server, as a bearer token in @@ -215,7 +215,7 @@ request history. `Common/Fusebox.qml` owns one `Process` for the whole shell, independent of monitor count. It runs while the widget is on, or while its dashboard or -settings are open, and a server and key are set. `scripts/fusebox.py live` +settings are open, and a server and key are set. `desktop/quickshell/scripts/fusebox.py live` holds one WebSocket to `/api/live`. On every connect it first reads `/api/overview`, `/api/accounts`, `/api/requests?limit=40` and `/api/faults`, then forwards `load`, `faults`, `request` and `tick` events. Line-delimited @@ -256,7 +256,7 @@ restarts a helper that stays silent for 30 seconds; the helper prints a heartbeat at least every five seconds. The 60-minute history starts from the server's own minute buckets on connect and counts each finished request into its minute, as Fusebox's dashboard does. Breaker actions, priority and account details -are one-shot `scripts/fusebox.py action|activity` requests through +are one-shot `desktop/quickshell/scripts/fusebox.py action|activity` requests through `CommandRequest`; `action priority` sends only 10, 0 or -10, the dashboard's First, Normal and Last. Nothing is retried automatically. @@ -268,33 +268,22 @@ cybexos-runtime ipc popouts open fusebox ``` To remove the key, use the undo chip on the Management key row, or delete -`~/.config/cybexos/fusebox/management.key`. Uninstalling CybexOS leaves user -configuration in place. +`~/.config/cybexos/fusebox/management.key`. System updates and generation +rollback retain this runtime credential. ## Tests -`tests/fusebox.py` covers key custody (permissions, atomic replacement, -symlinks, invalid keys, CLI output), URL handling, timestamps with Fusebox's -nanoseconds, the account, overview, fault, request, session and client -allowlists (plans, priority, reset plans, attribution, sources, limits read -from a configuration full of keys that must not leak), gzip answers and their -size bound, lockout, restart and rejected-key pacing, the derived fault rules, -and the live stream against a local Fusebox fake. The stream -tests check header-only authentication, snapshot order, event forwarding, -coalesced account refetches, the older-release fallback, rejected keys, -refused redirects and missing keys, and that seeded requests drop session -fingerprints. Its banked-reset tests check that reading status never spends, -that a spend sends the confirmed quote and grant exactly once, that a changed -inventory is refused, that a timeout, server error or unreadable answer is -reported as uncertain without a second request, and that unknown operation -states fail closed. No test spends a real reset. `tests/quickshell/fusebox.test.cjs` covers +Key custody, HTTP/WebSocket transport, reconnects, secret redaction and +banked-reset failures still require isolated native runtime qualification; +no test may spend a real +reset. `tests/quickshell/fusebox.test.cjs` covers the banked-reset rules and the guarantee that only the confirmation spends, the settings schema and migration, account state, quota meters and their Used/Remaining display, pace and run-out times, plan names, combined weekly limits, who used a window, sources, budgets, priority, badge-worthy faults, hidden names, request counting, figures, fault timing and notification rules, dashboard -links and registration. `image/test_desktop_payload.py` checks that the ISO +links and registration. The shared NixOS module checks that the ISO payload ships the same widget and derives the same layout as the checkout. -Run `./tests/run` and `python3 -B image/check-source`; use +Run `./tests/run` and `nix flake check`; use `tests/lib/quickshell-live` and the managed service for live checks. diff --git a/docs/gdm-replacement-research.md b/docs/gdm-replacement-research.md deleted file mode 100644 index b83fa27c..00000000 --- a/docs/gdm-replacement-research.md +++ /dev/null @@ -1,241 +0,0 @@ -# Replacing GDM while preserving single-password startup - -Research date: 2026-09-24. CybexOS baseline: `f89ae3d`. -Implementation: SDDM integration is present in both workstation provisioning -and image packaging. The running workstation has not been switched from GDM. -Source checks pass; full encrypted-install qualification is still pending. -ISO creation and PXE publication were deferred at the user's request. The -disposable builder completed the desktop RPM; it was stopped during image -composition, and its temporary artifacts were removed. No replacement ISO -was completed or booted. -The implementation uses only Fedora packages and the shared configuration -helper described below. - -## Validation completed - -- `./tests/run`: passed, including static checks, 1,040 unit tests and - 20 Python fixture programs. The active workstation's live QML lifecycle - check was skipped by the gate; no live shell changes were made. -- `image/check-source`: passed 13 welcome checks, 83 Python tests and four - Node checks. These include the guest-only login/keyring qualification harness. -- Login policy: 18 tests cover encryption checks, boot-attempt consumption, - concurrent starts, invalid policy, live-media isolation and safe defaults. - Both encryption verifiers also recognized the actual encrypted Btrfs host - through read-only inspection. -- Session launchers: six process scenarios cover normal exit, startup failure - and a real compositor-child SIGKILL in both deployment paths. -- An isolated GNOME Keyring/Secret Service fixture successfully created, read - and deleted a synthetic secret. This does not establish LUKS/PAM handoff. -- Full disposable Fedora 44 Ansible test: first install passed (233 tasks OK, - 115 changes); the second pass had 197 tasks OK and **zero changes**. Rendered - configuration/service assertions passed. Uninstall passed (40 tasks OK, - 18 changes), including exact restoration of the previous display-manager - alias, PAM/configuration and existing user preferences. SDDM was selected for - next boot without being started during provisioning. - -The VM fixture now seeds valid JSON preferences and optionally reuses -inventory-pinned downloads, checked on both host and guest, to avoid transient -font-server timeouts. No installation or restoration assertions were skipped. -All disposable builder/test artifacts were removed after these checks. - -**Still unqualified:** actual encrypted boot, boot-cache/keyring handoff, -graphical greeter recovery and physical hardware behavior. The four-cold-boot -qualification harness covers these software recovery cases when ISO testing -resumes. The running workstation remains on GDM. - -## Decision - -Moving completely away from GDM is technically viable with Fedora's **SDDM**, -systemd's `pam_systemd_loadkey` and GNOME Keyring's existing PAM module. -This is the most practical candidate for an encrypted, single-user CybexOS -installation. It needs managed configuration and lifecycle handling, but no -new authentication module or fork of a login manager. - -Keep the running workstation on GDM until a replacement image passes the -acceptance checks below. Source-level viability is not evidence that our -complete Fedora initramfs, SELinux, installer and desktop path works with SDDM. - -Stock Fedora 44 greetd is less suitable for the complete requirement. Its -automatic `initial_session` skips PAM authentication, which prevents the -standard password-loading/keyring authentication sequence from running. -Its ordinary password-login keyring support does not resolve this difference. -[Versioned greetd worker](https://github.com/kennylevinsen/greetd/blob/0.10.3/greetd/src/session/worker.rs), -[initial-session implementation](https://github.com/kennylevinsen/greetd/blob/0.10.3/greetd/src/context.rs). - -An upstream module, `pam_fde_boot_pw`, exists specifically for this greetd gap, -but it was not available in the configured Fedora repositories. Adopting it -would add a third-party authentication package for CybexOS to maintain and -qualify. That is a poor trade for this task compared with SDDM's standard path. -[Module documentation](https://git.sr.ht/~kennylevinsen/pam_fde_boot_pw/blob/master/README.md). - -## Credential and session flow - -The intended sequence is: - -1. The owner enters the LUKS passphrase during boot. -2. The boot password agent temporarily caches it in the kernel keyring. -3. SDDM automatically starts the selected owner's CybexOS session. -4. Its PAM authentication stack loads the cached password and supplies it to - GNOME Keyring; the session stack starts/unlocks the keyring. -5. The existing `hyprland-quickshell` launcher starts the desktop. - -Systemd explicitly documents this use with `sddm-autologin`: load the password -with `pam_systemd_loadkey`, then use `pam_gnome_keyring`, with a service -`KeyringMode=inherit` override. The keyring password must match the LUKS -passphrase. The installed systemd-pam 259.9 package provides the module. -[Version-matched systemd documentation](https://github.com/systemd/systemd/blob/v259/man/pam_systemd_loadkey.xml). - -Fedora's existing SDDM autologin PAM stack must be extended in the correct -order, retaining its SELinux, account, session and login bookkeeping. Keep -`gnome-keyring` and explicitly retain `gnome-keyring-pam` after removing GDM. -No blank keyring password, plaintext password file or password-processing -shell helper is required by this design. -[Fedora autologin PAM configuration](https://src.fedoraproject.org/rpms/sddm/raw/f44/f/sddm-autologin.pam), -[SDDM authentication implementation](https://github.com/sddm/sddm/blob/v0.21.0/src/helper/backend/PamBackend.cpp). - -The existing CybexOS installer initially gives the disk and account the same -password. These passwords can later diverge; the keyring is another separately -encrypted store. Migration must preserve existing keyrings and must never -silently reset them. The current installer promises protected keyrings with -possible first-use unlocking; it does not qualify an end-to-end boot-password -handoff. See [the installer contract](../image/INSTALLER.md). - -The standard cache expires after 2.5 minutes. Long boot delays, an unavailable -cache, a different recovery passphrase, a mismatched keyring password or token -unlock without the expected passphrase can therefore require manual keyring -unlock. Preserve that fallback. Do not persist passwords or make the cache -unlimited to conceal these cases. Multiple encrypted volumes also require a -test because the password loader selects the last cached password. -[Password-cache documentation](https://github.com/systemd/systemd/blob/v259/man/systemd-ask-password.xml). - -## Once-per-boot policy - -| Event | Required result | -| --- | --- | -| Ordinary encrypted cold boot | LUKS unlock, automatic desktop, unlocked keyring when the matching boot password is available | -| Lock or suspend/resume | Existing Hyprlock authentication | -| Logout or compositor crash | Authenticated recovery login | -| Login-manager restart during this boot | No repeated automatic desktop entry | -| Missing keyring password/cache | Protected keyring remains locked until explicitly unlocked | -| Unencrypted installation | Existing normal-login policy remains in force | - -SDDM's `Relogin=false` handles session exit while its daemon remains alive. -However, a fresh daemon process treats startup as its first display again. -Thus it needs an additional once-per-boot guard for our stricter restart -policy. This is not a claim that our current GDM setup already guarantees -that restart behavior. -[SDDM display lifecycle](https://github.com/sddm/sddm/blob/v0.21.0/src/daemon/Display.cpp). - -SDDM clears that first-display flag **before** attempting autologin, so PAM or -early session failure does not authorize another attempt in the same daemon. -Greeter recovery also relies on Fedora's patch introduced in SDDM 0.21.0-12: -it maps every nonzero session exit or signal to a session error. Unpatched -0.21.0 can mistake launcher exit status 1 for an authentication error and leave -the greeter unavailable. Fedora 44's 0.21.0-13 includes the fix. The disposable -qualification deliberately injects exit status 1 to exercise this case; -source inspection and fixtures do not establish a successful boot result. -[First-attempt ordering](https://github.com/sddm/sddm/blob/v0.21.0/src/daemon/Display.cpp#L255-L269), -[Fedora patch](https://src.fedoraproject.org/rpms/sddm/raw/f44/f/sddm-0.21.0-fix-restart-greeter-when-helper-is-in-a-wrong-state.patch), -[Fedora package and changelog](https://src.fedoraproject.org/rpms/sddm/raw/f44/f/sddm.spec). - -Both CybexOS launchers run Hyprland once and stop the desktop user target when -it exits. They deliberately omit `start-hyprland`: its watchdog restarts a -crashed compositor inside the same login session, whereas this policy requires -returning to the password greeter. The watchdog can preserve a prior lock state, -but it does not implement that login policy. Launcher fixtures verify normal -exit, startup failure and a real SIGKILL without a second compositor start. -[Hyprland 0.56.2 watchdog](https://github.com/hyprwm/Hyprland/blob/v0.56.2/start/src/main.cpp). - -The shared root-owned `/usr/libexec/cybexos-login-prepare` helper runs before -SDDM. It reads `/etc/cybexos/login.json`, verifies the owner and root encryption -(including every member of a multi-device Btrfs filesystem), -and atomically consumes `/run/cybexos-login/autologin-used` before the first -automatic-login attempt. Subsequent daemon starts disable autologin. The marker -survives service stop/restart and disappears at reboot. Failed early attempts -also consume the marker. Concurrent preparation is serialized with a file lock. -A fresh password greeter defaults to the CybexOS session; an existing SDDM -session choice is preserved. This helper never reads or handles passwords. -The live ISO exception requires -both its boot command line and a root-owned live-account marker. - -SDDM 0.21 does not accept a `--config` file option. Its managed configuration -uses the highest-precedence `/etc/sddm.conf`, written atomically as a regular -file with restored SELinux labeling. Preparation first disables stale autologin -from a previous boot. A file under `/run` alone would not be read. [Configuration reader](https://github.com/sddm/sddm/blob/v0.21.0/src/common/ConfigReader.cpp). - -## Integration and acceptance - -Fedora 44 provides SDDM 0.21.0-13 and a generic Wayland greeter backend using -Weston. Use a packaged backend first. A custom Hyprland greeter/theme would be -additional work without improving ordinary automatic startup. -[Fedora SDDM](https://packages.fedoraproject.org/pkgs/sddm/sddm/fedora-44.html), -[generic Wayland backend](https://packages.fedoraproject.org/pkgs/sddm/sddm-wayland-generic/fedora-44.html). - -SDDM is Fedora-packaged, but should not be described as Fedora KDE's guaranteed -long-term default: Fedora documents a transition to Plasma Login Manager. -Package availability and upstream maintenance should be rechecked when the -replacement is qualified. [Fedora change](https://fedoraproject.org/wiki/Changes/PlasmaLoginManager). - -Workstation provisioning installs SDDM and the generic Weston greeter backend, -then selects SDDM for the next boot without restarting the current display -manager. The new boolean `desktop_autologin` setting accepts saved -`gdm_autologin` answers as a compatibility alias; an explicit new value wins. -Provisioning backs up the previous display-manager alias, PAM and managed -configuration once. Uninstall restores them without interrupting the session. -Existing GDM packages remain available for recovery; new images do not request -GDM. The existing session launcher and Hyprlock remain in use. - -The desktop RPM installs the same helper, PAM template and systemd drop-in. -Its post-transaction step applies the PAM template while preserving the original -SDDM-owned file. Fedora retains ownership of that PAM path. The service uses -`KeyringMode=inherit`; the authentication stack loads the systemd boot key before -calling GNOME Keyring, preserving Fedora account/SELinux/session bookkeeping. - -Qualify a disposable encrypted installation before changing the default: - -- Fresh boot creates/unlocks a protected login keyring without a second prompt; - existing keyrings and a synthetic saved secret survive repeated boots. -- Normal logout, Hyprland crash, SDDM crash/restart and early startup failure - cannot produce another unprotected automatic session. -- Missing/expired cache, password mismatch and recovery-key boot retain a - working manual unlock path, without resetting the keyring. -- Suspend/resume, alternate keyboard layouts, monitor/GPU behavior, portals - and account access work with SELinux enforcing. -- Both live-media startup and installed-system behavior work, including - unencrypted installs and rollback to the previous login manager. - -Removing GDM does not itself remove every GNOME package. Calendar still calls -GNOME Settings, which still pulls in settings-daemon. The former explicit -settings-daemon dependency and GNOME idle-power writes have been removed. Retain GOA, Evolution Data -Server and GNOME Keyring for the native account/calendar features. Measure the -replacement image's actual dependency and size reduction before claiming a -performance or footprint improvement. - -## Operation and recovery - -For checkout installations, configure `desktop_autologin` through the saved -installer configuration. Provisioning records the selected owner and choice in -`/etc/cybexos/login.json`; the helper makes the final eligibility decision at -boot. Image installations receive this policy from the confirmed installation -plan. Turning the policy off leaves normal password login available. - -The nonsecret decision can be inspected with: - -```bash -sudo cat /run/cybexos-login/status.json -systemctl show sddm.service -p KeyringMode -sudo journalctl -b -u sddm.service -``` - -Do not reset an existing keyring to resolve an unlock prompt. Its password may -differ from the disk/account password, or the brief boot cache may be missing. -Ordinary SDDM password login uses Fedora's existing PAM configuration, and -applications retain their normal keyring-unlock prompt. - -If a workstation migration needs recovery, log into a text console and select -the retained GDM service for the next boot with -`sudo systemctl enable --force gdm.service`. This requires the retained GDM -package; fresh SDDM-only images do not include it. The one-time login backups -are under `/var/lib/cybexos/backups/login/`. A full CybexOS uninstall restores -those original files and the original display-manager alias. Selecting a -next-boot manager does not terminate the running graphical session. diff --git a/docs/hardware-security.md b/docs/hardware-security.md new file mode 100644 index 00000000..3f782cf3 --- /dev/null +++ b/docs/hardware-security.md @@ -0,0 +1,110 @@ +# Hardware and security qualification + +The supported target is fresh x86_64 UEFI NixOS 26.05. Current implementation +status is developmental. This document distinguishes declarations from tested +hardware support; no physical hardware was qualified during this implementation. + +| Area | Native implementation | Qualification still required | +| --- | --- | --- | +| Generic desktop | Hyprland pinned by the documented compatibility overlay; Quickshell and Qt from machine Nixpkgs; PipeWire, portals, Bluetooth, Gutenprint/free HPLIP printing and scanning, Orca/AT-SPI/speech-dispatcher, IBus typing booster, firmware/microcode, NetworkManager | Installed compositor/titlebar runtime, real login, suspend/resume, external displays and peripheral matrix | +| Encryption | LUKS2 root, ext4, separate ESP, zram; runtime ancestry verification gates automatic login | Encrypted fresh install/first boot, wrong/missing unlock, every persistent storage branch and swap | +| Login/authentication | Password-only SDDM login; GNOME Keyring; fingerprint/FIDO2 alternatives for sudo, Polkit and lock with password fallback; no crash relogin | Physical readers/keys, PAM failure paths, keyring unlock and lock-before-suspend | +| Firewall | Enabled; LocalSend 53317 TCP/UDP and mDNS 5353 UDP; gaming ports explicitly opt-in | Packet-level tests, VPN routing, application/container interactions | +| NVIDIA | NixOS packaged driver, explicit open-module choice, modesetting/power options | Representative supported GPUs, hybrid graphics/PRIME choices, suspend, signing with Secure Boot | +| Dell XPS 2026 | Exact DMI/SKU 0DB9/0DBA, Panther Lake, panel EDID and sensor gates; latest Nixpkgs kernel, OLED quirks, haptics, speaker filter, adaptive thermald and Intel LPMd | Both SKUs, panel variants, audio routing/limiter, repeated suspend/hotplug, fingerprints | +| XPS processed camera | Pinned Intel HAL/bins/icamerasrc and PSYS companion; native ISYS/core/CVS stay upstream; private-layout guard before PSYS load | Current kernel BTF/private ABI, HAL paths, v4l2 relay, actual processed frames, camera privacy and suspend/resume | +| Secure Boot | Native Lanzaboote option and runtime signing-key path; firmware verification is never disabled | Signed installation media, safe enrollment, key backup/rotation/revocation, NVIDIA/camera module signing and physical boot qualification | + +Secure Boot installation currently stops before erasure. It is not supported +merely because Lanzaboote evaluates. Existing firmware verification must not be +disabled to get the installer to boot. Keys belong in +`/var/lib/cybexos/secrets/secureboot` with root-only permissions, outside the +store and source. Completing signed media, enrollment and module signing is a +release blocker, not an optional post-release improvement. +The kernel must enforce the intended signing/lockdown policy, and installation +media must authenticate its external compressed system payload as well as its +boot executable. A signed UKI alone does not establish either property. No +Secure Boot or tamper-resistance qualification is claimed. + +NVIDIA driver selection is independent of proprietary applications. Selecting +the driver permits only its required unfree NVIDIA packages even with the +proprietary application group disabled; the open kernel module still needs +proprietary userspace. PRIME offload keeps the integrated GPU as the desktop +renderer and exposes explicit offload for applications. + +The XPS camera recipes retain the processed ISP path rather than presenting a +raw sensor node as a working camera. A sensor name or loaded driver is not +capture qualification. The guarded external companion must never shadow an +upstream core/ISYS/CVS module. An absent/unreviewed BTF layout refuses loading. +Do not extend a reviewed layout signature based only on a matching kernel +version. Compare compiled structure sizes/offsets and test actual frames. +On the selected physical camera system, explicitly run +`xps-ipu7-camera-frame-check --device /dev/video50` with the actual processed +relay device. It skips warm-up buffers and requires complete, changing frames +in memory. It never saves images or runs automatically; a static scene can +require moving an object to prove freshness. Its source fixtures do not +qualify physical capture. +On the currently selected Linux 7.3 kernel, the compiled stock BTF reports an +`ipu7_bus_device` size of 1008 bytes with `auxdrv` at offset 872. Neither +reviewed signature accepts this layout; Linux 7.2.11 has the same mismatch. +A task-local unstripped PSYS build matched the stock layout for eight inspected +structures, but the ninth could not be parsed and no processed frames were +available. That probe does not authorize extending the signature. The managed +ABI service fails closed and blocks camera initialization and update health +confirmation on this configuration. This is a concrete XPS camera release +blocker, despite the complete XPS system building successfully. Do not disable +the guard or omit the camera profile to qualify a machine that has that camera. + +The relay retains the `ov08x40-uf` sensor, 180-degree rotation and 1920x1080 +NV12 output. Initialization acquires CVS before probing the sensor; suspend +quiesces the relay and resume reruns initialization without unloading upstream +core, ISYS or CVS. Raw ISYS nodes lose the desktop uaccess grant before seat +ACL processing. These declarations still require privacy and capture tests. + +Pinned vendor camera sources need requalification at every relevant kernel/HAL +change and should be removed when Nixpkgs supplies the full processed stack. + +The exact XPS profile enables adaptive thermald alongside Power Profiles +Daemon. Intel LPMd 0.1.0 is a narrowly pinned native package because the selected +Nixpkgs lacks it; remove the recipe when Nixpkgs supplies a maintained package. +Its upstream Panther Lake policy and platform checks remain intact: the M204 +profile does not restrict CPUs under PPD, but supplies the SoC power slider. +Do not infer measured power savings from the daemon being installed. LPMd, +thermald and camera initialization participate in system health checks when +applicable; power, thermal safety and repeated sleep require physical tests. + +AppArmor, restricted kernel inspection/ptrace, sudo authentication and Polkit +remain enabled. The replaced system qualified SELinux in enforcing mode. +Enabling AppArmor on NixOS does not reproduce that broad mandatory access +control policy: the current explicit application exception supports Chromium +user namespaces and is not equivalent service/application confinement. A +reviewed native confinement policy, enforced-profile/process coverage and +negative access tests are still required before release. Do not report the +AppArmor enable flag as security parity. This is a separate security release +blocker alongside Secure Boot. Login passwords are initialized through stdin in the target; +subsequent `passwd` changes persist across rebuilds. No login password/hash is +embedded in Nix source. Runtime VPN/credential files use root-private paths or +NetworkManager's persistent secret storage. Nix evaluation/build logs can contain +administrator-supplied module text: keep secrets out of declarations as well as +out of Git. The updater only exposes private logs after administrator authentication. + +Settings enrolls/removes fingerprints and security-key credentials through their +persistent APIs. Authentication policy belongs to personal NixOS modules: +set `cybexos.authentication.fingerprint = true;` or `false`, then preview with +`cybex update stage local` and apply the reviewed transaction. Settings displays +the active policy without offering a switch that bypasses the staged lifecycle. +Firmware has a separate explicit action in the Update drawer; its device writes +are outside Nix generations and cannot be undone by a system rollback. + +NetworkManager's OpenVPN, OpenConnect, SSH and maintained Libreswan plugins are +included. Libreswan supplies Cisco-compatible IKEv1/IPsec, as recommended by +[GNOME's security notice](https://blogs.gnome.org/mcatanzaro/2026/09/15/privilege-escalation-vulnerabilities-in-networkmanager-plugins/). +The archived networkmanager-vpnc 1.4.0 remains vulnerable to +CVE-2026-91840/91841 and is excluded; insecure packages are not globally +permitted. Real Cisco gateway authentication, routing, DNS and reconnect still +require qualification. The native wrapped T3 Code AppImage builds but needs runtime +qualification; native titlebar/compositor integration is still being completed. +Connected account services and the Hermes bridge have native declarations; +their real account/credential flows remain unqualified. Installing a related +command is not proof of product parity. See the current +[validation record](validation.md). diff --git a/docs/installation-parity.md b/docs/installation-parity.md deleted file mode 100644 index 07928c6d..00000000 --- a/docs/installation-parity.md +++ /dev/null @@ -1,217 +0,0 @@ -# Installation parity - -The ISO defines CybexOS's installed product. A checkout installation and an ISO -installation of the same release, on equivalent hardware with the same user -choices, must receive the same desktop, applications, account defaults, -authentication policy, services, firewall, and hardware configuration. - -Fresh installations use these ISO defaults: - -| Choice | Default on both paths | -| --- | --- | -| Applications | Every standard application group enabled | -| Personal defaults | Enabled, including Fish, Kitty, Git/SSH and browser preferences | -| Sudo and local Polkit | Password required | -| Docker administrator access | Sudo required | -| Desktop automatic login | Enabled only after complete root encryption is verified | -| Next boot | Graphical login through SDDM; convergence leaves the current session running | -| Additional local-network firewall ports | Disabled; LocalSend and mDNS (printer and scanner discovery) remain open | -| Files SMB workgroup | `WORKGROUP`; explicit per-user workgroups take precedence | -| Machine identity | Preserve the identity already configured by Fedora/Anaconda | - -`inventory/group_vars/all.yml` is the common default input. -`image/installation_policy.py` defines the account policy used by both -`scripts/installer-defaults` and the packaged `cybexos-config` helper. The -checkout questionnaire starts from that policy instead of maintaining its own -booleans. It uses the ISO's boot-time encryption verifier, including every -Btrfs member. Unverifiable or mixed encrypted/plaintext storage disables -automatic login. Real installs run this read-only probe with administrator -access; `./install --check` never elevates or installs dependencies and cannot -enable autologin if its access is insufficient to verify encryption. - -Saved choices take precedence. Re-running or updating an installation must not -silently turn on personal defaults or passwordless access for an older account. -Docker membership uses the same ownership-aware grant/revoke policy on checkout, -ISO configuration and RPM reconciliation. An explicit saved choice for the -selected account can revoke Docker access even on an older ISO without an -ownership marker. Automatic convergence without that choice removes only -CybexOS-owned grants; independently administered access and unrelated groups -are preserved. Changes take effect in new login sessions. When repair first -records an older installation's choices, it retains existing Docker access -until the administrator explicitly changes it with `cybex configure`. -`--reconfigure` offers the saved boolean choices as its defaults. An explicit -identity change in the checkout questionnaire still applies that choice. -Neither parity nor a release update authorizes repartitioning an existing -Fedora installation or resetting user settings to match a clean account. -Shell preferences, including wallpaper selection, are seeded only if the -entire settings file is absent. An explicit empty wallpaper, sparse overrides, -unknown fields and future or corrupt documents remain unchanged on both paths; -concurrent creation of a settings file takes precedence over seeding. -Fastfetch, Voxtype, Oh My Posh, MIME associations, and npm configuration are -seeded only when absent, as on the ISO. Managed Fish, Kitty, Git and SSH fragments remain updateable while their bytes -match the shared ownership ledger. Conflicting edits and deletions are preserved -on both paths. Git/Kitty includes precede user values; SSH fallbacks follow them. -The default web apps (Slack, WhatsApp, YouTube, Google Photos and X) are seeded -once per account by the same helper, `cybexos-webapp seed`: the checkout's -dotfiles role runs it, and the ISO's `cybexos-user-init` runs it at -installation and login. The helper records each default it seeds, so a web app -the user removed is never added back on either path. - -The checkout path installs onto existing Fedora and retains source-release -updates and uninstall; the ISO uses Anaconda for disk/account creation and RPM -delivery for desktop updates and repair. These mechanisms are distinct from the -installed policy. - -`image/system_packages.py` reads the system selections and exclusions from -`image/live.ks` and the runtime requirements from the desktop RPM spec. After -the shared repositories are configured, checkout installs that contract, -including its package groups and version constraints. The local delivery RPMs -are supplied by checkout's application and desktop roles. Both paths disable -weak dependencies and use the complete shared locale and firmware baseline; -only device-specific configuration is gated on detecting the device. The -application builder leaves system composition to the ISO compose step. -Checkout resolves the ISO's full codec providers and `ocl-icd` in its first -base transaction, before desktop dependencies can select alternative providers. - -Installer-only paths keep that policy. An unattended seed's `config` choices -(and the choices a factory reset keeps for the next owner) pass through -`installation_policy.choices`/`apply_choices`, the function the checkout -installer's saved-choice merge uses; `test_installation_parity.py` checks that -the same explicit choices produce the same configuration on both paths. The -new-owner setup provisions its account with the same fresh-account -`cybexos-configure-installed --offline` run as Anaconda's in-target hook. The -first-start, new-owner and factory-reset units ship only in the ISO's RPM -(with the graphical new-owner window and the `cage` package it needs): a -checkout installation runs on an existing Fedora system with its own account, -never has OEM state or a factory recovery point, and `cybex factory-reset` -explains that. Factory-point retention lives in the shared -`cybexos-system-snapshot`, so both paths prune identically. Comparing a development checkout with an older ISO is not a -parity test: both artifacts must come from the same revision, with completed -hardware setup and equivalent application choices. - -## Required checks for changes and releases - -Run `./tests/run` and `python3 -B image/check-source`. Both required CI jobs run -`image/test_installation_parity.py`. It executes the real checkout questionnaire -and non-interactive dry run, then compares the complete generated configuration -with ISO provisioning and target finalization for encrypted and plaintext -installations. It also checks saved opt-outs, existing personal files, mixed or -unverifiable Btrfs, and the policy module shipped in the repair payload. - -The existing image package, desktop payload, installed policy and user parity -tests cover package selections and shared task/template sources. Extend those -checks whenever a package, setting, service or hardware path changes; a new -feature cannot be added to only one installer's schema. Hardware roles run -unchanged on both paths: `image/test_nvidia.py` checks that the checkout and -the ISO's boot-time hardware setup apply the same NVIDIA role, payload and -session environment. - -Recovery boot hooks preserve existing upstream directory permissions and -ownership on both paths. Fedora's -[`grub2-common-1:2.12-66.fc44` RPM](https://kojipkgs.fedoraproject.org/packages/grub2/2.12/66.fc44/noarch/grub2-common-2.12-66.fc44.noarch.rpm) -(SHA-256 `8482c540ca4badf57eaee30576af74f238bc23eabfd456498e00aa1a29d111b0`) -owns `/etc/grub.d` as `0700 root:root`; checkout must not force it to `0755`. -Checkout creates missing hook directories and manages its own root-owned files. -The image RPM owns the GRUB hook and vendor kernel-install hook, without -claiming their upstream parent directories. `image/test_update_recovery.py`, -also run by the source parity gate, executes the real Ansible tasks twice and -compares the RPM assembly with existing restricted and missing directories, -including preservation of administrator ownership. - -Release changes must pass the source and image gates from the same Git revision -and the existing generic Fedora and ISO installation/upgrade qualification -gates. Build and qualify a new ISO/RPM when its payload changes. Retain the -revision and artifact digests in the qualification evidence. Fixture checks -compare the installation contract; they are not evidence that fresh physical -or VM installations have been performed. Do not use an older ISO qualification -as evidence for a newer checkout. - -## Real installed-outcome gate - -`image/release-gate` requires two full checkout installations, `plain-us` and -`plain-nl`, in addition to the existing four graphical ISO installations and -prior-release RPM upgrade/recovery check. They use a checksum-pinned Fedora 44 -Cloud image, the same QEMU hardware/UEFI configuration as the ISO guests, all -normal feature defaults, the public `./install --non-interactive` entry point, -and real SDDM password login after reboot. `tests/fedora-vm-convergence` remains -a separate convergence/uninstall test; its feature opt-outs cannot satisfy -this gate. - -Before invoking the public installer, qualification updates only its newly -created Cloud guest to the current Fedora OS baseline, removes the explicitly -listed preexisting Cloud packages, selects the generic Fedora release identity, -reboots onto the current kernel, and uses -DNF's supported old-installonly cleanup. The report records that preparation. -The public installer does not prune users' old kernels or package inventories. -The test asserts the installer's graphical boot target and SDDM enablement -instead of setting them after installation. - -The builder includes the complete reviewed source set in `source.tar.gz` -alongside its ISO/RPM artifacts. A canonical digest covers file names, content -and executable bits, including intentional non-ignored working-tree changes. -It is embedded in the ISO's existing `build.json`. The builder verifies that -the archived content matches, so an edit during source capture aborts the -build. Checkout qualification extracts this exact archive, validates its -checksum and content, and requires the ISO qualification to identify that -exact source and ISO digest. Extraction rejects links, traversal and duplicate -paths. The runner's newer checkout cannot substitute for the tested source. - -Each guest produces `outcomes-fresh.json`, then saves explicit shell/input -preferences, a valid personal Hyprland override and unknown future settings -fields. It reapplies its own installation path, reboots, verifies those values -survived and produces `outcomes-saved.json`. Captures require a running desktop -and exactly one Quickshell process owned by `quickshell.service`. The managed -Settings lifecycle test exercises Network, Sound, Online Accounts, Keyboard, -Touchpad and Region, including watcher cleanup and the current QML journal. - -`image/installed_outcomes.py` compares effective shell/input settings, saved -installer choices, login policy, actual sudo authorization, Polkit policy, -account groups/shell, required RPM versions, Flatpaks, application commands and -associations, service enablement/activation, firewall policy, SELinux, recovery -support, default boot target, filesystem and personal-file identities. Target -dependencies and per-user unit links can provide equivalent session activation; -masked/missing units and inactive services remain differences. The UTC timezone -alias is canonicalized for comparison without changing the saved choice. -It retains the complete RPM -inventories. Every additional package difference needs a reasoned entry in -`image/parity-exceptions.json`; required package/version differences always -fail. Exceptions cover the ISO delivery RPMs, Cloud provisioning tools and three -exact Brave prerelease key records. The signed `brave-keyring` package imports -them in its background postinstall script on running Fedora; the initial ISO -chroot lacks `timeout` at that point. Both paths have the same keyring payload -and pinned stable repository. The exception includes each full fingerprint and -RPM release; unknown keys still fail, and family-wide signing-key exceptions -are rejected. Review new actual baseline differences before extending that file. - -`parity-fresh.json` and `parity-saved.json` name mismatches. The release gate -embeds passed same-source checkout evidence in both plain ISO reports. -`image/prepare-github-release` also rejects missing, stale or fixture-only -parity evidence when invoked independently. - -To rerun checkout comparison against a completed candidate (its corresponding -ISO qualification must have used `--capture-outcomes`): - -```sh -image/qualify-checkout --execute-vm --scenario plain-us \ - --artifacts /path/to/build/artifacts \ - --iso-results /path/to/qualification/plain-us \ - --output /path/to/new-task-specific-checkout-output -``` - -The runner removes task-owned VM disks, SSH keys, cloud seeds, transient logs -and screenshots on success/failure, retaining compact reports. -`--keep-artifacts` retains unresolved diagnostics, but never the cloud seed or -SSH private key. Testing OS ISOs still use `/data/pxe/iso` and the existing -checksum/iVentoy workflow. Existing Fedora hosts are never repartitioned. - -The saved-choice ISO step uses `cybexos-configure-installed --offline` with -the prepared application payload, so outbound networking remains blocked -through reconfiguration. Checkout qualification runs the public installer -with its normal repository access. It prepares the shared codec repositories -and selects the ISO's final providers before base and desktop transactions, -avoiding dependencies left behind by replacing interim Fedora codecs. - -`image/test_installed_outcomes.py` tests content identities, archive validation, -comparison guards and guest workflow construction without booting a VM. These -source tests do not qualify an installation or imply the expanded matrix ran. -New release evidence must come from executing the gate. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 00000000..0a408cb4 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,60 @@ +# Fresh installation + +Boot a completed CybexOS x86_64 ISO in UEFI mode. The live desktop opens a +terminal installer with disk identities, machine/user/regional choices, +encryption and automatic login. `cybex install`, `./install` and `./bootstrap` +enter the same implementation and refuse to run outside the live environment. +Connect to the internet before installing: input sources, hardware-specific +components and selected applications may need downloads from upstream caches. +The installed machine uses its flake and lock file; legacy Nix channels are +disabled by default and are not copied from the live environment. + +The ISO retains its tested flake input sources. A fresh machine's first lock +starts with that exact Nixpkgs revision and content hash, retaining the selected +stable branch as its upstream update source. Installation never resolves a newer +branch head implicitly. Later upstream-only updates advance this machine-owned +input independently of CybexOS. This does not make installation fully offline: +release authentication and packages not bundled on the medium require internet. + +Use a backed-up target disk of at least 32 GiB. Installation erases only the +explicitly selected whole disk after its serial is confirmed. Mounted disks, +active swap and changed identities are rejected. Passwords use private input, +not arguments or configuration source. The installer creates a 1 GiB ESP and +an ext4 root, optionally under LUKS2, with zram rather than persistent plaintext +swap. Root stays locked; the chosen wheel user authorizes administration. + +`nixos-generate-config` records actual hardware and filesystems. DMI/PCI/ACPI/ +EDID detection selects narrowly scoped XPS/NVIDIA declarations. The machine +flake follows the stable Nixpkgs input and pins the authenticated CybexOS exact +revision/NAR identity. The complete target is built and installed through the +shared modules. First login installs missing vendor-skill discovery symlinks +and never overwrites a pre-existing personal skill. + +Production media install only their own exact tested source. Before asking for +choices, the installer fetches the immutable signed manifest for the medium's +`/etc/cybexos/release.json` version (`releases/download/vVERSION/manifest.json`) +over public HTTPS, verifies its Ed25519 signature and supported metadata, and +requires its version, exact revision and NAR to equal the medium's. A channel is +never consulted to select a newer release; the medium's channel policy still +applies (stable rejects a prerelease medium). Any mismatch stops before disk +erasure and vendor evaluation. The medium's channel is written to `machine.nix` +as an explicit `cybexos.updates.channel`, so a later stable vendor release does +not move a beta machine; the vendor default is `beta` for prerelease versions and +`stable` otherwise, and the administrator may change it. + +Production installation is blocked until owner-controlled trust keys and an +authenticated published release exist. For isolated development qualification, +`cybexos-install --development-source /path/to/source` explicitly selects an +unreleased content-addressed test source. Such a result is not an authenticated +release qualification. + +Secure Boot is not disabled. Native Lanzaboote configuration is provided, but +fresh enrollment/signed installation media and out-of-tree-module signing have +not yet been qualified; the installer currently refuses Secure Boot installation +before erasure. See [hardware blockers](hardware-security.md). + +Automated isolated installation accepts a non-secret JSON plan with `disk`, +`serial`, `hostname`, `username`, `timezone`, `locale`, `keyboard`, `encrypted`, +`autoLogin`, `secureBoot`, `nvidiaOpen`, plus password/passphrase JSON through +`--secrets-fd`. The same validation and disk-erasure guard run in both modes. +No installer reboots automatically. diff --git a/docs/integration-boundaries.md b/docs/integration-boundaries.md index 0f92a67b..c140074a 100644 --- a/docs/integration-boundaries.md +++ b/docs/integration-boundaries.md @@ -8,8 +8,8 @@ transport lifetime, domain state and view code have separate owners: `registry.py` owns atomic conversation metadata; `gateway.py` owns bounded local-client delivery and the reconnecting local upstream. `hermes_bridge.py` coordinates the conversation domain and keeps its public imports for tools - and existing tests. Modules never import the bridge entrypoint. Both install - paths must ship the package beside the executable. + and existing tests. Modules never import the bridge entrypoint. The native Nix package + ships the Python package beside the executable. Authentication/status replies do not wait for optional discovery or the conversation list. Discovery shares in-flight work and caches results for the current origin and sign-in session; replacing that session cancels stale @@ -26,17 +26,18 @@ transport lifetime, domain state and view code have separate owners: feedback. The RPC facade forwards its existing command methods and properties, preserving callers. Request acceptance still does not resolve provider approvals; a disconnected or partially accepted batch never replays. -- **Fusebox:** `scripts/fusebox.py` owns the management key file, the +- **Fusebox:** `desktop/quickshell/scripts/fusebox.py` owns the management key file, the authenticated transport, reconnects and the allowlist of what reaches QML. `Fusebox.qml` owns connection state, the request history, fault notifications and one-shot breaker requests through `CommandRequest`; `FuseboxHelpers.js` owns the dashboard-equivalent rules. Popovers and Settings retain presentation only. -- **Updates:** `UpdateLogReader.qml` owns bounded byte-range transport and - run/offset validation. `Updates.qml` owns transaction state and parses only - accepted log data. The privileged updater still owns the transaction; a shell - reload does not stop it. Log responses for another run or an earlier offset - trigger a fresh read instead of modifying the current transaction. +- **Updates:** `desktop/quickshell/Common/Updates.qml` invokes the supported + `cybex update` CLI and polls durable transaction status. The privileged updater + owns staging and activation; reloading the shell does not interrupt its + systemd worker. Apply requests name the reviewed transaction and token. + Independent source errors remain visible instead of implying that an + unreachable channel is current. `CommandRequest` has `command`, `running`, `stdinEnabled`, `inputText`, `timeoutMs`, `killGraceMs` and `timeoutMessage` inputs. Set the command and then @@ -46,8 +47,8 @@ transport lifetime, domain state and view code have separate owners: partial output before hanging. A second expiry sends SIGKILL. Do not launch the next command while `running` is true. No command is retried by the transport. -The real-engine lifecycle fixture covers output, missing executables, -SIGTERM-resistant timeouts, reuse after timeout and stdin. It runs in CI with an -isolated HOME/session when no other Quickshell is active. Node behavior tests -exercise GitHub queue priority and T3 partial-batch, approval and expiry rules; -the existing Hermes HTTP/WebSocket fixtures exercise the split Python modules. +Node behavior tests exercise GitHub queue priority, T3 partial-batch and +approval rules, and the Hermes/Fusebox presentation boundaries. They do not +establish real subprocess interruption, remote HTTP/WebSocket behavior or +installed-service qualification. Exercise those paths in isolated runtime tests +before release. diff --git a/docs/iso-releases.md b/docs/iso-releases.md deleted file mode 100644 index 035f3d5b..00000000 --- a/docs/iso-releases.md +++ /dev/null @@ -1,282 +0,0 @@ -# ISO and desktop RPM releases - -CybexOS ISO releases and source archive releases share a version tag but have -separate build gates and artifacts. A trusted Debian 13 PXE runner builds the -ISO and desktop RPM and exercises the installer in disposable guests. It -transfers only the build artifacts and qualification reports. A separate -GitHub-hosted signing job prepares the signed desktop assets; private signing -material never goes to the PXE host. Signed RPM repository metadata is deployed -to GitHub Pages after the GitHub Release has been published and confirmed -immutable. - -The desktop update endpoint is -[`https://digitalpals.github.io/CybexOS/44/x86_64`](https://digitalpals.github.io/CybexOS/44/x86_64). -Pages serves `update-channel.json`, the public key, signed release and -`repodata`; the RPMs themselves are assets of the matching immutable GitHub -Release. The channel configuration is [stable.json](../image/channels/stable.json), -which pins signing fingerprint -`16C60642B7278AECE3A933C354220839FDF7099E`. - -## Repository and runner setup - -The GitHub repository already has immutable releases enabled and GitHub Pages -configured. The Pages site is empty until a release is published. The -`desktop-release` Actions environment and its `CYBEXOS_RPM_SIGNING_KEY` secret -are configured. The secret contains the RPM signing subkey and is used only by -the GitHub-hosted Fedora 44 signing job. The PXE runner receives no private -signing material. The primary private key remains in a protected local -keyring; move it to offline custody before public release. Never print or copy -signing material into the checkout or expose it in workflow arguments or -logs. - -Before the first public release: - -- The repository includes its MIT license. This covers repository code; it - does not settle redistribution rights for third-party software and bundled - assets. Complete that audit before public distribution; see - [licensing and asset provenance](licensing.md). -- Use the trusted Debian 13 x86_64 PXE operator account with `/dev/kvm`, - at least 180 GiB staging space and 24 GiB available RAM, the - `image/build --preflight` dependencies, a Chromium browser, write access to - `/data/pxe/iso`, and an active `iventoy.service`. The workflow provides Node - 24 and isolated `playwright-core`. The operator also needs authenticated - `gh` access with permission to manage this repository's runners, Python - 3.12 or newer, the GitHub runner's native .NET dependencies, and a reachable - systemd user manager. Keep signing material on GitHub's hosted signing job. -- Repository variable `CYBEXOS_BASELINE_ISO` is configured as - `/data/pxe/iso/CybexOS-Live-44-20260926T055804Z-dbdd33d6.iso`. Keep this older - supported ISO and its matching `.sha256` sidecar in place. The qualification - job requires a regular, checksum-verified ISO under `/data/pxe/iso`. -- Start an ephemeral runner only for the reviewed queued run below, and pass all release gates. The qualification - has to pass on the exact release build; configuring Pages, the baseline - variable, and signing environment alone does not make a public desktop - channel available. - -The PXE machine does not keep a public-repository runner listening. Once a -reviewed `release.yml` or `desktop-release.yml` run has its qualification job -queued, use a clean checkout whose `HEAD` exactly matches that run. From the -unprivileged PXE operator's authenticated session: - -```bash -gh auth status --hostname github.com -systemctl --user show --property=Version --value -run_id=REVIEWED_RUN_ID -./image/release-runner --run-id "$run_id" -./image/release-runner --run-id "$run_id" --execute \ - --work-root /data/cybexos-runners -``` - -The first helper command only validates the API run, job, source commit and -checkout. `--execute` downloads the current Linux x64 runner with the SHA-256 -pin supplied by GitHub's API and registers one ephemeral runner. Its only -label is `cybexos-iso-RUN_ID`; it has no generic `self-hosted`, `linux`, `x64`, -or `cybexos-iso` labels. The workflow requests that exact run-specific label. -The helper refuses pull requests, other source repositories, non-release -workflows, refs outside `main` or version tags, dirty or mismatched checkouts, -an existing runner for the run, or another queued job targeting its label. -No runner has been registered by this setup; registration is an explicit -operator action when a reviewed job is queued. - -The listener runs in a transient user systemd service with a seven-hour limit -(`--timeout` accepts 60 seconds through eight hours). Cancellation allows -120 seconds for job cleanup, then systemd terminates the complete service -cgroup, including build and VM processes that created separate sessions. -The helper stops and verifies this exact unit before unregistering the runner -and removing its unique task directory. If it cannot confirm the unit stopped, -it reports and retains that directory for diagnosis instead of deleting live -VM files. A failed API cleanup reports the exact registration to remove. -The registration token stays out of command arguments and logs, and the runner -gets an isolated home and environment without the operator's GitHub tokens. - -This is a trusted-code runner, not a sandbox. Do not approve or queue untrusted -workflows while it is active: someone allowed to execute a workflow could -intentionally request the known run-specific label. Signing keys are available -only to the separate GitHub-hosted `sign` job and never to this PXE listener. - -The release checks the source contract, image-source contract, and a -twice-converged generic Fedora VM before calling -[desktop-release.yml](../.github/workflows/desktop-release.yml) on the trusted -PXE runner. Its `qualify` job builds the ISO with the public stable channel, -publishes the completed testing ISO and checksum to iVentoy, and runs five -QEMU guests with isolated disposable disks. It uploads only the raw build -artifacts and bounded qualification reports as `cybexos-qualified-desktop` for -two days. The `qualify` job also retains compact qualification evidence for -fourteen days. The separate GitHub-hosted `sign` job downloads that exact -artifact and produces `cybexos-desktop-release`, retaining the prepared assets -for two days. The signing job needs a -Docker-capable GitHub-hosted runner with at least 24 GiB free staging space. - -| Scenario | Encryption | Keyboard and locale | Timezone | -| --- | --- | --- | --- | -| `encrypted-us` | LUKS | US / `en_US.UTF-8` | UTC | -| `plain-us` | Off | US / `en_US.UTF-8` | UTC | -| `encrypted-nl` | LUKS | Dutch / `nl_NL.UTF-8` | Europe/Amsterdam | -| `plain-nl` | Off | Dutch / `nl_NL.UTF-8` | Europe/Amsterdam | - -Each of the four fresh scenarios boots the exact candidate ISO and completes -the graphical installer. A fifth guest uses the older baseline ISO, creates a -recovery point, applies the exact candidate desktop RPM, then boots and restores -that pre-upgrade point and verifies that the baseline RPM is active again. This -older-image upgrade and recovery qualification is separate from the four -fresh-install scenarios. - -Release admission uses the versioned scenario-specific evidence contract in -`image/qualification_contract.py`. Every encrypted fresh scenario must include -its executed keyring, cold-boot, logout/crash/restart and authentication recovery -checks. The existing encrypted-US upgrade guest must additionally qualify public -updater commit, interruption after applying, automatic pre-login recovery and a -further settled boot, including root/package/vendor identity, personal-file -preservation and empty worker cgroups. The producer and verifier reject missing -checks or incompatible execution modes. Old compact reports cannot validate a -new release by copying only their `passed` status. - -A release is rejected unless qualification reports identify the SHA-256 of the -exact ISO and candidate RPM and all five scenarios pass. Reports are retained -as bounded workflow artifacts; the desktop assets are retained briefly for the -publishing job. Do not treat source fixtures, a local RPM build, or an earlier -ISO boot as a substitute for these release gates. - -## Published assets and ISO reconstruction - -The publishing job creates the tagged source archive and its offline-verifiable -provenance bundle, then uploads those with the desktop release assets and -`SHA256SUMS` to a draft GitHub Release. It publishes the draft after uploading -all assets, then polls until GitHub marks the release immutable; failure to -confirm immutability fails the workflow after publication. Only after this job -passes does a stable tag deploy signed metadata to Pages. A prerelease tag -publishes downloadable assets but does not update the stable Pages repository. -The ISO is split into files below GitHub's per-asset size limit. `assets/` also -contains the part manifest, original ISO SHA-256 file, reconstruction script, -qualification reports, signed RPMs, the ISO's SPDX SBOM, and -`desktop-SHA256SUMS`. - -Download `desktop-SHA256SUMS` and every file it names into one directory, then -verify and reconstruct without replacing an existing output: - -```bash -sha256sum -c desktop-SHA256SUMS -python3 reconstruct-iso.py CybexOS-Live-44-BUILD-ID.iso.parts.json -sha256sum -c CybexOS-Live-44-BUILD-ID.iso.sha256 -``` - -The reconstruction tool checks each ordered part against the JSON manifest, -then checks the complete ISO size and SHA-256 before publishing the output. -It refuses to overwrite an existing ISO. Keep the manifest, parts, and helper -in the same directory. Check the signing fingerprint independently before -trusting the RPM or channel metadata. - -After the immutable GitHub Release is available, the Pages job deploys the -signed metadata snapshot. Metadata points each package URL at that release's -RPM asset, so Pages never needs to host the large RPM. Publication is ordered: -the Pages deployment waits until the immutable Release and RPM assets exist. -Until both jobs complete successfully, the channel is not ready for enrollment. - -## Software bill of materials - -Each image build writes an SPDX 2.3 JSON SBOM beside the ISO and names it after -the ISO: `CybexOS-Live-44-BUILD-ID.spdx.json`. `image/compose` records the -finished image root in `rpm-inventory.tsv` (the `RPM_QUERYFORMAT` in -[`image/sbom.py`](../image/sbom.py)) and `flatpak-inventory.tsv`. When the -artifacts reach the host, `image/build` generates the SBOM from those files, -`build-provenance.json` and the inventory, and lists it in `SHA256SUMS`. The -SBOM contains: - -- a root `CybexOS` package with the version, Git revision, source content - digest, build ID, and the ISO filename and SHA-256; -- every installed RPM, with epoch, version, release, architecture, declared - license, source RPM, vendor, and a `pkg:rpm` package URL. A License tag that - is not a valid SPDX expression becomes a `LicenseRef-` entry holding the - verbatim text; -- every installed Flatpak application and runtime, with its ref, origin, and - full OSTree commit; -- the downloads from `inventory/group_vars/all.yml` and the apps role defaults - that the image bundles: GitHub release applications, Nerd Fonts, font files - and archives, Omawrite, source builds, the LazyVim starter, and vendor - repository keys. Each has its URL and its SHA-256 or Git commit. - -The `cybexos-desktop` and `cybexos-offline-apps` entries each have the SHA-256 -of their unsigned RPM that `release.json` records as `unsigned_input_sha256`. -Bundled applications, toolchains and fonts belong to the offline package in -the SPDX containment relationships. Generation is deterministic: -the timestamp and namespace come from build provenance, and packages and -relationships are sorted. The release gate requires one SBOM in the build -artifacts. Before it signs anything, `prepare-github-release` checks that the -SBOM describes the exact qualified ISO and both candidate RPMs. It then copies the -SBOM into `assets/` and `desktop-SHA256SUMS`. From a checkout, check a -downloaded SBOM against the ISO checksum file: - -```bash -python3 image/sbom.py verify CybexOS-Live-44-BUILD-ID.spdx.json \ - --iso-checksum CybexOS-Live-44-BUILD-ID.iso.sha256 -``` - -The publishing job adds the SBOM file as a second subject of the release -provenance attestation, so the source archive's bundle also verifies it. It -also creates an SPDX attestation (predicate type -`https://spdx.dev/Document/v2.3`) whose subject is the ISO digest and whose -predicate is the SBOM. That bundle is published as -`CybexOS-Live-44-BUILD-ID.iso.sbom.sigstore.jsonl`. Verify both offline, the -second against the reconstructed ISO: - -```bash -gh attestation verify CybexOS-Live-44-BUILD-ID.spdx.json \ - --bundle cybexos-VERSION.tar.zst.sigstore.jsonl \ - --repo DigitalPals/CybexOS \ - --signer-workflow DigitalPals/CybexOS/.github/workflows/release.yml \ - --source-ref refs/tags/vVERSION --deny-self-hosted-runners -gh attestation verify CybexOS-Live-44-BUILD-ID.iso \ - --bundle CybexOS-Live-44-BUILD-ID.iso.sbom.sigstore.jsonl \ - --predicate-type https://spdx.dev/Document/v2.3 \ - --repo DigitalPals/CybexOS \ - --signer-workflow DigitalPals/CybexOS/.github/workflows/release.yml \ - --source-ref refs/tags/vVERSION --deny-self-hosted-runners -``` - -The workflow verifies the archive and SBOM provenance offline before it -publishes. It checks the SBOM attestation's subject digest and predicate type -from the bundle, because the publishing job has only the ISO parts. - -## Enroll the installed RPM channel - -An ISO built without `--update-channel` bundles a disabled channel. The release -gate explicitly enables the pinned stable channel. Check the installed system -with read-only diagnostics; channel status describes local configuration and -does not test remote availability: - -```bash -cybex doctor --json -cybex update-channel status --json -``` - -For enrollment, obtain `update-channel.json` and `CYBEXOS-desktop.asc` from the -same published release's verified assets, keeping them together in one -folder. Review the URL and compare the configuration's full fingerprint with -the independently trusted value above. Validate connectivity and signatures -without changing the system: - -```bash -cybex update-channel enroll /path/to/release/update-channel.json \ - --fingerprint 16C60642B7278AECE3A933C354220839FDF7099E --check -``` - -When the check succeeds, explicitly enable the repository: - -```bash -sudo cybex update-channel enroll /path/to/release/update-channel.json \ - --fingerprint 16C60642B7278AECE3A933C354220839FDF7099E -cybex update-channel status --json -``` - -Enrollment verifies the public key fingerprint and signed repository metadata, -then pins the public key and repository configuration locally. It does not -install an update. The ordinary system package update path can install the -signed desktop RPM once the channel is ready. `cybex update --check` is a -source-checkout command and is not supported for ISO installations. Key -rotation requires a separately reviewed migration; do not enroll a different -fingerprint as a routine update. - -The current GitHub repository has not published its first public release and -the Pages endpoint is still empty. The repository has selected the MIT -License for its code, but the bundled software and asset redistribution audit -still applies. The explicitly started ephemeral runner and complete release workflow must -pass before signed desktop metadata is available to users. diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index b3488cf6..619ff484 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -33,9 +33,9 @@ The Web apps group opens the web apps CybexOS installs for every account: Photos and `Super+Shift+X` X. Each binding runs `cybexos-webapp launch ID`, which opens the site in Brave Origin's app mode when Brave Origin is installed and in Firefox otherwise. Remove one with **Remove web app…** in the launcher -or `cybex webapp remove ID`; it is not added back by later updates, and its +or `cybexos-webapp remove ID`; it is not added back by later updates, and its shortcut then shows how to reinstall it. Add other sites with **Install web -app…** in the launcher or `cybex webapp install --url ADDRESS`. Installed web +app…** in the launcher or `cybexos-webapp install --url ADDRESS`. Installed web apps appear in the launcher's Apps tab. ## Clipboard history diff --git a/docs/launcher-ibus-focus-investigation.md b/docs/launcher-ibus-focus-investigation.md deleted file mode 100644 index 9ee610b7..00000000 --- a/docs/launcher-ibus-focus-investigation.md +++ /dev/null @@ -1,160 +0,0 @@ -# Launcher typing and IBus focus handoff - -Investigated on 2026-10-17 at CybexOS source revision `6f4bdca`, using -Hyprland 0.56.2 (`efb50993780079460b0cbed1363e2166a2de1d9f`), -Quickshell 0.2.1 (`dacfa9d`), Qt 6.11.2 and IBus 1.5.34 on Fedora 44. - -The fault is in Hyprland's focus-grab path. Activating the launcher's -`HyprlandFocusGrab` before the overlay maps can change seat keyboard focus -without updating the input-method relay. IBus consequently retains the -previous application's context and keyboard grab. Its printable-key commits -are discarded, while its virtual keyboard can still forward navigation keys. - -The local fix lets the exclusive layershell surface obtain keyboard focus -first. `LauncherWindow.qml` observes the backing window's `Window.active` -notification and then enables the dismissal grab. Readiness is latched until -close so a focus loss cannot delete the grab before its `cleared` event. -Reopening during the exit animation also handles an already-active window. -After the grab reports active, layershell keyboard mode becomes OnDemand. -Hyprland 0.56 forces pointer hit tests onto exclusive layers even outside their -geometry (`InputManager.cpp`, the `m_exclusiveLSes` branch). Switching only -after the grab is established keeps keyboard ownership and permits outside -clicks to clear the grab. Keeping Exclusive throughout prevented dismissal in -the live check. -The search remains an editable native Qt `TextInput`; applications and the -launcher retain IBus, Compose, preedit, and dead-key support. - -Protocol evidence was captured with a temporary runtime drop-in for -`cybexos-input-method.service`, setting `WAYLAND_DEBUG=client` and redirecting -stderr to a task-owned file. The service was restarted before capture and -again after removing the drop-in. Hyprland logging settings were untouched. -The excerpts below contain only the test's synthetic input. - -Before the fix, a native Qt fixture accepted exactly `hello` through IBus. -After clearing it, opening the launcher left `CurrentInputContext` at -`/org/freedesktop/IBus/InputContext_3`. The fixture received -`wl_keyboard.leave` at 07:13:14.364, but no `zwp_text_input_v3.leave` until -the launcher closed. There was no IME `deactivate`, `activate`, or grab release -between opening and typing. All five characters disappeared in this captured -run, and the fixture remained empty. The relevant IBus trace was: - -```text -07:13:14.364957 zwp_input_method_v2#34.done() -07:13:14.364967 zwp_input_method_v2#34.done() -07:13:14.781402 launcher open; CurrentInputContext = InputContext_3 -07:13:14.834857 zwp_input_method_keyboard_grab_v2#39.key(..., 35, 1) -07:13:14.837043 -> zwp_input_method_v2#34.commit_string("h") -07:13:14.837058 -> zwp_input_method_v2#34.commit(13) -07:13:14.996168 -> zwp_input_method_v2#34.commit_string("e") -07:13:15.153150 -> zwp_input_method_v2#34.commit_string("l") -07:13:15.298776 -> zwp_input_method_v2#34.commit_string("l") -07:13:15.455469 -> zwp_input_method_v2#34.commit_string("o") -07:13:16.263956 launcher OCR: Search applications; previous app: empty -07:13:16.342171 zwp_input_method_v2#34.deactivate() [launcher closing] -07:13:16.342217 zwp_input_method_v2#34.done() -07:13:16.342309 -> zwp_input_method_keyboard_grab_v2#39.release() -``` - -With the focus gate, the same transition released the old grab and created a -new launcher context before typing. The old fixture stayed empty and OCR -read `hello` in the launcher: - -```text -07:13:49.783058 before open; CurrentInputContext = InputContext_3 -07:13:49.929896 zwp_input_method_v2#34.deactivate() -07:13:49.929938 zwp_input_method_v2#34.done() -07:13:49.930028 -> zwp_input_method_keyboard_grab_v2#39.release() -07:13:49.930641 zwp_input_method_v2#34.activate() -07:13:49.930646 zwp_input_method_v2#34.done() -07:13:50.300399 launcher open; CurrentInputContext = InputContext_4 -``` - -The source explains the missing notification: - -- [`CSeatManager::refocusGrab()` in 0.56.2](https://github.com/hyprwm/Hyprland/blob/efb50993780079460b0cbed1363e2166a2de1d9f/src/managers/SeatManager.cpp#L582) - calls `setKeyboardFocus()` directly. That updates seat focus and emits - `m_events.keyboardFocusChange`, but not the global keyboard-focus event. - `CFocusGrab::start()` installs the seat grab first; its later - `refocusKeyboard()` returns when that surface already has seat focus. -- [`CFocusState::rawSurfaceFocus()`](https://github.com/hyprwm/Hyprland/blob/efb50993780079460b0cbed1363e2166a2de1d9f/src/desktop/state/FocusState.cpp#L223) - returns immediately when the seat already focuses the requested surface. - When the layer subsequently maps, this skips its focus-state update and - global keyboard-focus notification. -- [`CInputMethodRelay`](https://github.com/hyprwm/Hyprland/blob/efb50993780079460b0cbed1363e2166a2de1d9f/src/managers/input/InputMethodRelay.cpp#L9) - listens to that global event. Its normal `onKeyboardFocus()` and - `CTextInput::leave()` deactivation path is therefore bypassed. -- [`IBus 1.5.34 input_method_done_v2()`](https://github.com/ibus/ibus/blob/1.5.34/client/wayland/ibuswaylandim.c#L2327) - acts on pending activate/deactivate flags. The missing compositor - `deactivate` never sets the flag. The captured close and fixed-open - sequences show that IBus releases its grab when deactivation is delivered. - -Upstream searches covered issues and commits in both projects. Hyprland -[#16359](https://github.com/hyprwm/Hyprland/issues/16359) reports a related -0.56.2 focus-grab/text-input mismatch for an unmapped popup. It was closed by -the issue-policy bot without a technical fix; its popup-selection problem is -related evidence, not an exact match for this layer-surface reproduction. -Hyprland main at -[`25f09b26`](https://github.com/hyprwm/Hyprland/blob/25f09b26f9db7dbca4a4b3c265fdb96802db122c/src/managers/SeatManager.cpp) -still uses direct seat focus in `refocusGrab()`. - -IBus [#2742](https://github.com/ibus/ibus/issues/2742) was fixed by -[`5f2c6c4`](https://github.com/ibus/ibus/commit/5f2c6c444d5d01d147829647ca423bf2056a7752), -destroying the virtual keyboard on deactivation. That behavior is already -present in 1.5.34. Its subsequent Sway zero-size-keymap fix, -[`8757b87`](https://github.com/ibus/ibus/commit/8757b8741ce942a0a9ddfc48eaec4a2668d14861), -is present too. IBus [#2870](https://github.com/ibus/ibus/issues/2870) and -[#2872](https://github.com/ibus/ibus/issues/2872) concern monitor-hotplug serial -and modifier state; the captured launcher failure has no such hotplug or -keymap transition. No upstream fix for this exact missing handoff was found. -A compositor fix should synchronize focus state and the input-method relay -when a seat grab redirects keyboard focus. - -The opt-in live regression is: - -```bash -python3 -B tests/launcher-input-live.py -``` - -It requires the managed shell and IBus services, `python3-evdev`, user access -to `/dev/uinput`, Qt's `qml` runner, `grim`, and `tesseract`. It verifies a -native Qt control accepts exactly `hello` and enables text-input-v3, runs at -least ten launcher trials, and repeats the control and launcher check with -`QT_IM_MODULE=compose` on the fixture to verify the non-IBus path. The launcher -keeps its normal input module. Each injected key is guarded by service PID, -fixture keyboard-focus events, launcher-layer ownership, and current window -and authentication-surface checks. No Enter is injected. It also checks -outside-click dismissal using a disposable mouse and an exposed area of the -fixture, then restores the pointer and original window focus. All fixture -processes, input devices, screenshots and logs are cleaned on exit. - -Separate workstation checks verified Caps Lock produces `Aa` when toggled -between two `a` presses, and Right Ctrl, apostrophe, `e` produces `é` in both -the IBus-active fixture and launcher. Escape cleared the query and then closed -the launcher. The user's blur preference was preserved in `user.lua`; a -personal `compose:rctrl,lv3:ralt_switch` override supplies the requested Caps -Lock/Compose mapping. Its original 127-byte file is backed up beside it as -`user.lua.before-caps-lock-20261017`. - -Both installation paths already consume the shared launcher source. The -desktop-payload regression packages and compares `LauncherWindow.qml`, -`LauncherView.qml`, and `shell.qml` against the checkout. These are source and -payload fixture checks; no new RPM/ISO was built and no end-to-end installation -qualification is claimed. A release containing this change requires rebuilding -and qualifying its installed payload from the release's source revision. - -Final validation on the completed workaround: - -- The retained live regression passed 10/10 IBus-active opens, the 1/1 - non-IBus control, and outside-click dismissal from both fixture modes. -- `./tests/run`: 16/17 stages passed, including 1,373 JavaScript tests and QML - lint. The Python stage reproduced only the two failures disclosed before - this task: report redaction's `xps`/`XPS 14` collision, and the hardware - package parity fixture's root-owned temporary JSON permission error. -- `python3 -B image/check-source`: the new launcher payload comparison - passed. The 519-test image suite reproduced the same hardware-package - permission error; the 14 installer JavaScript tests passed. -- Every live run used `tests/lib/quickshell-live`. The final service was - active with its MainPID as the only `qs` process, and its current invocation - had no QML errors. Temporary input-method drop-ins, fixture windows, - uinput devices, screenshots, source downloads and diagnostic logs were - removed. The small personal configuration backup is intentionally retained. diff --git a/docs/licensing-audit.md b/docs/licensing-audit.md deleted file mode 100644 index 0a94fd36..00000000 --- a/docs/licensing-audit.md +++ /dev/null @@ -1,439 +0,0 @@ -# Third-party redistribution audit - -This is an engineering inventory of the third-party components that CybexOS -installs, and of what a public ISO would redistribute. It records the license -tags, upstream license files, and vendor terms that could be checked, and marks -everything else **Unverified**. It is not a legal opinion and grants no rights. -The owner, with legal review where needed, must decide every item before a -public ISO, desktop RPM, or source archive is published. See -[licensing and asset provenance](licensing.md) for the repository's own license. - -## Audit basis - -- Repository state of 2026-10-13, based on commit `b0a97b7`. -- Package selections were read from `image/applications` (`package_names()` and - `PACKAGE_TASKS`), `image/live.ks`, `roles/base/defaults/main.yml`, - `roles/apps/defaults/main.yml`, `roles/apps/tasks/*.yml`, and - `roles/desktop/tasks/main.yml`. Pinned downloads were read from - `inventory/group_vars/all.yml`. -- License tags and source repositories come from a `dnf repoquery` of Fedora - 44, RPM Fusion free and nonfree, the Brave, 1Password, ChatGPT and Tailscale - repositories, and the `sdegler/hyprland` COPR on 2026-10-13. A tag is the - packager's declaration, not a review of the package contents. -- Upstream licenses were read from the pinned upstream repositories. Vendor - terms were read from the vendors' public pages on 2026-10-13; quotations are - excerpts. -- Evidence of what an image actually contained comes from the retained package - list and payload file manifest of the 2026-09-25 build - `20260925T140839Z-5f33cb32` (revision `a2d31c2c13b5`). That build predates - later selections, including `gstreamer1-plugin-openh264` and the XPS - packages, so it does not describe the current payload. No current image was - built or inspected for this audit. - -"Bundled" means the bytes are inside the ISO: an RPM composed into the image, -the Flatpak installation copied from the builder, or the offline application -seed (`/opt/cybexos-apps`, `/opt/cybexos-builds`, `/usr/local/share/fonts`, -`/usr/share/cybexos/user-seed`). "Downloaded" means the user's machine fetches -the component from its upstream at installation or first use. - -## Distribution channels - -| Channel | Third-party content it redistributes | -| --- | --- | -| Public Git repository and source archive | Wallpapers, brand SVGs, Plymouth artwork, the Tabler icon font subset, vendored MIT code (Omarchy adapter, Model Usage, T3 Code-derived logic), and repository keys for RPM Fusion and ChatGPT | -| `cybexos-desktop` RPM | The repository content above that the desktop needs, including all wallpapers in the user seed and the Plymouth theme | -| ISO | Everything above, every composed RPM and its dependencies, the system Flatpak installation, and the offline application seed | -| Source checkout (`./install`) | Repository content only; every application, font, and toolchain is downloaded by the user's machine from its upstream | - -The checkout path therefore redistributes much less than the ISO. It still -automates license acceptance for the Android SDK (see below) and installs the -same repository assets. - -## RPM packages by repository - -| Repository | Packages CybexOS selects | License tags (repoquery) | ISO | Checkout | -| --- | --- | --- | --- | --- | -| Fedora 44 (`fedora`, `updates`) | About 300 named packages plus dependencies: the shared baseline, desktop, applications, developer tools, fonts, firmware, Docker (`moby-engine`), Podman, Java 25 | Fedora-approved licenses; firmware uses `LicenseRef-Fedora-Firmware` | Bundled | Downloaded | -| Fedora Cisco OpenH264 (`fedora-cisco-openh264`) | `openh264`, a possible provider of the library that `gstreamer1-plugin-openh264` needs; Fedora's `noopenh264` stub is the other | `BSD-2-Clause`; Cisco binary, see codecs below | Repository enabled in `image/build.repo`; selected provider **Unverified** | Downloaded from Cisco | -| RPM Fusion free | `rpmfusion-free-release`, `ffmpeg`, `ffmpeg-libs`, `x264-libs`, `x265-libs` | `GPLv2+`, `GPLv3+`, `GPLv2+ and BSD` | Bundled | Downloaded | -| RPM Fusion nonfree | `rpmfusion-nonfree-release`, `steam`, `intel-media-driver` | `Steam License Agreement`; `MIT and BSD` | Bundled (`intel-media-driver` on every image, not only XPS hardware) | Downloaded (`intel-media-driver` only on detected XPS hardware) | -| Brave | `brave-origin`, `brave-keyring` | `Multiple, see https://brave.com/` | Bundled | Downloaded | -| 1Password | `1password`, `1password-cli` | `LicenseRef-1Password-Proprietary` | Bundled | Downloaded | -| OpenAI ChatGPT | `chatgpt` | `Proprietary` | Bundled | Downloaded | -| Tailscale | `tailscale` | `MIT`; upstream source is BSD-3-Clause | Bundled | Downloaded | -| COPR `sdegler/hyprland` | `hyprland`, `hypridle`, `hyprlock`, `hyprpicker`, `hyprsunset`, `xdg-desktop-portal-hyprland`; it also offers `cliphist`, `satty`, `nwg-look` and `matugen`, while `fuzzel` and `matugen` are deliberately installed from Fedora | BSD-3-Clause, MIT, GPL and similar | Bundled | Downloaded | -| Local build repository | `cybexos-desktop` (this repository), `mdview` (DigitalPals/mdview), `voxtype` (MIT) pinned GitHub release RPMs | `mdview`: no license file in its repository, **Unverified** | Bundled | Downloaded | - -The 2026-09-25 package list contained 1,700 packages. A complete per-package -license report for the actual release payload has not been produced. - -## Proprietary and non-RPM applications - -| Component | License or terms | ISO | Checkout | Status | -| --- | --- | --- | --- | --- | -| Brave Origin | Brave's terms grant "a personal, non-exclusive license to install and use the executable code version of the Brave browser" and no right to use Brave's marks; source parts are MPL-2.0 | Bundled | Downloaded | No redistribution grant located | -| 1Password and CLI | Proprietary RPM tag; the individual terms allow use "only for your own non-commercial use", the business terms (4.2) prohibit distributing "any part or content of the Services" | Bundled | Downloaded | No redistribution grant located | -| ChatGPT desktop | Proprietary RPM tag; OpenAI's terms page refused automated access | Bundled | Downloaded | Terms **Unverified**; no grant located | -| Spotify (`com.spotify.Client`) | Flathub declares `LicenseRef-proprietary` (Spotify end-user agreement), and the Flatpak fetches Spotify's binary as extra data at install time. The agreement (section 3) says "You agree that you will not redistribute, sell or transfer the Spotify Service or the Content." | Bundled: the image copies the builder's installed Flatpaks, including the downloaded binary | Downloaded by Flatpak | Conflicts with the quoted terms | -| Steam | RPM Fusion's spec states "Redistribution and repackaging for Linux is allowed, see license file"; the shipped license file is the Steam Subscriber Agreement, whose section 2.G restricts distribution without Valve's written consent. No explicit launcher grant was found. | Launcher and bootstrap bundled; client and games downloaded at first launch | Downloaded | **Unverified** | -| Android SDK (command-line tools, platform-tools, build-tools, platforms) | Android SDK License Agreement 3.4: "you may not copy (except for backup purposes), modify, adapt, redistribute ... the SDK or any part of the SDK"; 2.1 requires agreeing before use. `android-sdk-update` runs `yes \| sdkmanager --licenses`, and the 2026-09-25 seed contained more than 11,700 SDK files and `licenses/android-sdk-license`. | Bundled in the user seed | Downloaded; acceptance automated | Conflicts with the quoted terms; acceptance is made on the user's behalf on both paths | -| Claude Code 2.1.247 | Upstream `LICENSE.md`: "© Anthropic PBC. All rights reserved. Use is subject to Anthropic's Commercial Terms of Service." Those terms (D.4) prohibit reselling the Services without approval and contain no redistribution grant. | Bundled in the user seed | Downloaded from Anthropic | No redistribution grant located | -| Codex CLI | Apache-2.0 (`openai/codex`); tracks npm `latest`, so each image may contain a different version | Bundled | Downloaded | Redistributable with notices | -| OpenCode 1.18.27 | MIT (`anomalyco/opencode`) | Bundled | Downloaded | Redistributable with notices | -| T3 Code Nightly | MIT (`pingdotgg/t3code`); the AppImage also contains Electron and Chromium components with their own notices, **Unverified** | Bundled | Downloaded | Redistributable with notices | -| Rust nightly toolchain | MIT or Apache-2.0; Rust trademark policy not reviewed | Bundled | Downloaded | Redistributable with notices | -| Voxtype `ggml-base.bin` model | Whisper weights are MIT (`openai/whisper`); download source and checksum are not pinned in the inventory | Bundled | Downloaded at install | Record source and checksum | -| LocalSend, ProtonUp-Qt (Flathub) | Apache-2.0; GPL-3.0 | Bundled | Downloaded | Redistributable; GPL source obligation | -| Flatpak runtimes | Mostly free software. A Freedesktop runtime may pull the `org.freedesktop.Platform.openh264` extension, which downloads Cisco's binary as extra data. | Bundled with the whole system Flatpak tree; contents **Unverified** | Downloaded | Inspect the built image | -| lazygit, lazydocker, oh-my-posh | MIT | Bundled | Downloaded | Redistributable with notices | -| Bun | MIT; statically links LGPL-2 JavaScriptCore, with upstream relinking instructions | Bundled | Downloaded | Redistributable; LGPL obligations | -| balena CLI | Apache-2.0; the standalone bundle contains Node.js and many npm packages, notices **Unverified** | Bundled | Downloaded | Redistributable with notices | -| Omawrite | MIT (`omacom/omawrite`), built from source | Bundled | Built locally | Redistributable with notices | -| awww, wayfreeze | GPL-3.0; AGPL-3.0; built from pinned source | Bundled | Built locally | Corresponding source must be offered | -| LazyVim starter | Apache-2.0 | Bundled | Downloaded | Redistributable with notices | - -Downloaded only, never bundled: the Steam client and games, Wallhaven images -in the welcome window, the Firefox 1Password extension, Distrobox images, and -the XPS IPU7 camera stack (including `intel/ipu7-camera-bins`), which is fetched -only on detected XPS hardware. The optional private hooks, off by default, -download the Conthrax font; they are not part of the ISO. - -## Fonts - -| Font | Source | License | Notes | -| --- | --- | --- | --- | -| JetBrainsMono and FiraCode Nerd Fonts 3.5.1 | `ryanoasis/nerd-fonts` release archives | OFL-1.1 for patched fonts | The archive `LICENSE` is kept beside the fonts | -| iA Writer Mono, Duo, Quattro, Duospace | `iaolo/iA-Fonts`, pinned commits | OFL-1.1, Reserved Font Names "iA Writer" and "Plex" | License installed beside each font | -| Figtree 1.2, Geist Mono 1.5.0, Urbanist 1.321 | Pinned upstream commits | OFL-1.1 | License installed beside each font | -| Google Sans Flex | `end-4/google-sans-flex`, an unofficial mirror | Mirror README says OFL-1.1, but its `LICENSE` is the unfilled OFL template without a copyright line. Google's `google/fonts` repository (`ofl/googlesansflex`) has an `OFL.txt` with a copyright notice and a `TRADEMARKS.md`. | Source from the official repository | -| OPPO Sans 4.0 | OPPO archive, pinned by SHA-256 | OPPO Sans Fonts License Agreement | See below | -| Tabler outline subset | `@tabler/icons-webfont` 3.48.0, in the shell | MIT | License shipped in `assets/tabler/` | -| Fedora font packages | Fedora 44 | OFL-1.1 for most; `OFL-1.1-RFN` (Liberation); MIT (`fontawesome-fonts-all`); CC-BY-4.0 (`fontawesome-fonts-web`); Apache-2.0 (Open Sans); `GPL-1.0-or-later` (Madan); `GPL-2.0-only WITH Font-exception-2.0` (PakType Naskh Basic); `LicenseRef-Fedora-UltraPermissive` (Symbola) | Redistributed under Fedora's packaging | - -The OPPO Sans agreement grants a revocable license to "use, copy, merge, embed, -bundle, redistribute and/or sell unmodified copies of OPPO Sans Fonts with any -software except for fonts software", on conditions that include: "YOU shall -make a prominent notice in the software to state that OPPO Sans Fonts are -used", no modification, no stand-alone redistribution, and retaining the -copyright notice and agreement. The agreement file is installed beside the -font, but no notice inside CybexOS was found. - -## Wallpapers and artwork - -The former seven undocumented bundled wallpapers, including the character -images and default photograph, and the three web derivatives have been removed -from the current distributable checkout. They are replaced by original -procedural landscapes generated by `scripts/generate-wallpapers.py`. The native -default is `moon-mountains.png`; the web's matching PNGs are rendered from the -same palette/geometry at 1280, 2560 and 5120 pixels. All ten files have byte and -generator digests and truthful authorship-method records in -`assets/PROVENANCE.json`. The original artwork uses the repository MIT license; -this says nothing about the removed files' rights. Git history and previously -published releases are not rewritten or cleared by the replacement. - -Installers retain existing personal files and saved choices. Only the fresh -payload changes on both paths via `assets/desktop-contract.json` and the shared -wallpaper source directory. No migration deletes a user's old wallpapers. - -The Plymouth theme in `roles/boot/files` (logo, entry, bullet and progress -images, and the script) is shipped in the desktop RPM. The checkout role also -uses the pinned `DigitalPals/omarchy-cybex` repository, which has no license -file. The Cybex wordmark SVGs are in the welcome window, the live installer, -and the browser demo. These files are below the 1 MiB inventory threshold and -have no recorded provenance; the owner should confirm authorship. - -## Brand icons and trademarks - -The shell's product marks are listed with their sources in -`roles/desktop/files/quickshell/assets/README.md` and -`THIRD_PARTY_LICENSES.md`: Simple Icons (CC0) for Claude, Gemini, GitHub, Kimi, -Tailscale, WhatsApp and YouTube; Tabler (MIT) for OpenAI and Slack; MIT -sources for Grok and T3 Code; the Unlicense for Fusebox; and Fedora Design -artwork (CC BY-SA 4.0) for Fedora. The Model Usage widget redistributes the -Claude and Codex marks from Omarchy (MIT), and the browser demo serves -Fedora, Claude and Codex marks. These licenses cover copyright only. The -trademark guidelines of the individual brands were not reviewed -(**Unverified**). - -Fedora's [trademark guidelines](https://docs.fedoraproject.org/en-US/legal/trademarks/) -allow software that combines Fedora with other software to use the "Fedora -Remix" marks provided that "the fedora-logos, fedora-release, and -fedora-release-notes RPM packages are removed" (replacements must not contain -Fedora trademarks) and that a notice prominently states that the software is -not provided or supported by the Fedora Project and links to -. Their general conditions add that "no other -Fedora Trademarks are used for remix products". Findings: - -- `image/live.ks` installs `generic-logos` and `generic-release-notes`, and - `fedora-logos` was absent from the 2026-09-25 package list. -- `image/live.ks` also installs `fedora-release-common` and - `fedora-release-identity-basic`, which set the operating system name to - Fedora Linux. The 2026-09-25 list also contained the `fedora-release` - metapackage. Fedora's `generic-release` replaces them with a placeholder - identity (`ID=generic`, `ID_LIKE=fedora`), but CybexOS code checks for - `ID=fedora` in `cybexos-update-channel`, `cybexos-major-upgrade`, and the - shell's system information. A dedicated release package needs engineering - work. -- The menubar's Control Center button always shows the Fedora logo - (`Bar/Modules/Control.qml`), and the drawer shows it on Fedora systems. -- No "not provided or supported by the Fedora Project" notice was found in the - README, welcome window, or installer. - -No trademark registration or clearance search for the CybexOS or Cybex name -and wordmark is recorded. "Cybex" is also used as a brand by unrelated -companies. Bundled vendor applications (Brave, 1Password, ChatGPT, Spotify, -Steam, Firefox and others) carry their owners' marks; whether a third-party ISO -may redistribute them under those marks is **Unverified**. - -## Firmware - -- `linux-firmware` (`GPL-1.0-or-later AND GPL-2.0-or-later AND MIT AND - LicenseRef-Fedora-Firmware`) and its split packages (`amd-gpu-firmware`, - `intel-gpu-firmware`, `nvidia-gpu-firmware`, Wi-Fi, Bluetooth, audio, - microcode, and the XPS NPU and vision firmware) are bundled from Fedora. - `LicenseRef-Fedora-Firmware` is Fedora's identifier for firmware it accepts - as redistributable without modification; its definition was not re-read for - this audit. `linux-firmware-whence` was in the 2026-09-25 package list. -- `nvidia-gpu-firmware` ships `LICENCE.nvidia`. Its section 2.1.2 allows the - firmware to be "copied and redistributed solely for use on operating systems - distributed under the terms of an OSI-approved open source license ..., - provided that the binary files thereof are not modified, and Customer - provides a copy of this license with the SOFTWARE." -- The image is composed with `%packages --excludedocs`. RPM normally keeps - `%license` files with that option, but whether `/usr/share/licenses` is - complete in the built image is **Unverified**. -- Proprietary NVIDIA drivers are not bundled. On machines with an NVIDIA GPU, - the `nvidia` hardware role downloads `akmod-nvidia`, - `xorg-x11-drv-nvidia-cuda-libs` and `libva-nvidia-driver` from RPM Fusion - nonfree after installation and builds the module locally (see - [NVIDIA graphics](nvidia.md)), so the user's machine, not the ISO, receives - NVIDIA's software. Re-audit if the ISO ever composes these packages. - -## Codecs and patents - -These are patent-licensing questions, separate from copyright. Fedora omits -these codecs from its own media. - -- RPM Fusion's `ffmpeg`, `x264-libs`, and `x265-libs` (H.264 and HEVC) and - `intel-media-driver` are composed into every image. Whether CybexOS may - distribute them, and from which jurisdiction, needs a legal decision. -- Cisco's [OpenH264 binary license](http://www.openh264.org/BINARY_LICENSE.txt) - provides patent coverage only when the binary is "separately downloaded to an - end user's device" and "not integrated into or combined with third party - software prior to being downloaded". It also requires user control and the - attribution "OpenH264 Video Codec provided by Cisco Systems, Inc." Composing - `openh264`, or copying the Flatpak `openh264` extension, into the ISO would - fall outside that coverage. The installed system's enabled Fedora Cisco - repository can download it instead. - -## Source-code obligations - -The ISO distributes GPL, LGPL and AGPL binaries from Fedora, RPM Fusion and the -COPR, plus source-built `awww` (GPL-3.0) and `wayfreeze` (AGPL-3.0), Quickshell -(LGPL-3.0), and Bun's LGPL JavaScriptCore. A distributor of these binaries has -source obligations of its own. The release workflow publishes only the -CybexOS source archive. Whether pointing to upstream sources is sufficient is -for legal review; publishing the corresponding sources for each ISO avoids the -question. - -## Blockers before public ISO distribution - -1. **Wallpapers — engineering replacement completed.** The undocumented - images are absent from the new source/native/web payloads. Original - reproducible landscapes replace them; this does not clear earlier releases - or approve the remaining third-party artwork, software or trademarks. -2. **Bundled proprietary applications.** No redistribution permission was - located for Spotify, the Android SDK, Claude Code, 1Password, ChatGPT or - Brave Origin, and the quoted Spotify and Android SDK terms conflict with - bundling. The Android SDK license is accepted on the user's behalf. -3. **Cisco OpenH264.** If the image composes `openh264` or the Flatpak - `openh264` extension, Cisco's patent coverage does not apply to those - copies. -4. **Fedora trademarks.** The image keeps `fedora-release` packages, the shell - shows the Fedora logo, and the required notice is missing. -5. **Patent-encumbered codecs** from RPM Fusion need a documented legal - decision. -6. **Corresponding source** for GPL, LGPL and AGPL components is not published - with the ISO. -7. **OPPO Sans** requires a prominent in-software notice that does not exist. - -Items needing confirmation rather than known conflicts: Steam's launcher -redistribution, vendor trademark use, the Flatpak runtime contents, retained -license files under `--excludedocs`, `mdview` and Plymouth artwork licenses, -and per-brand icon guidelines. - -## Recommended actions - -1. Keep the original wallpaper generator and complete native/web provenance - inventory synchronized. The replacement is complete; review any additional - asset against the actual distribution inventory before publication. -2. Stop bundling proprietary applications whose terms do not allow it. - Download them at first boot or first use from the vendor repositories the - image already configures with pinned keys, or obtain written permission - and record it in the repository. Keep the offline payload for software - whose license allows it. -3. Download the Android SDK at first use and ask the user to accept Google's - terms, instead of running `yes | sdkmanager --licenses` on both paths. -4. Exclude Cisco's `openh264` and the Flatpak `openh264` extension from the - image; install them after installation from Cisco's repository. -5. Ship a CybexOS release-identity package in place of `fedora-release*`, - update the `ID=fedora` checks, replace the Fedora logo in the menubar, and - add the required Fedora notice to the README, welcome window, and About - page. Use "Fedora Remix" wording only as the guidelines allow. -6. Generate a license report from each build's package list (name, version, - repository, License tag), publish it with the release, and fail the build - on unreviewed proprietary tags or repositories. -7. Publish source RPMs and source archives for every composed package and - source-built tool with each ISO, or obtain legal advice on an alternative. -8. Add a third-party notices page (for example under Settings → About) that - includes the OPPO Sans notice, and the OpenH264 attribution if it is used. -9. Source Google Sans Flex from `google/fonts` with its official `OFL.txt`. - Pin the Codex CLI version and the Voxtype model checksum so each image's - payload can be reviewed. -10. Add licenses to `DigitalPals/mdview` and `DigitalPals/omarchy-cybex`, and - record the provenance of the Plymouth artwork and Cybex wordmark. -11. Run a trademark clearance check for CybexOS and Cybex, and review the - brand guidelines for each product mark in the shell. -12. Have the owner and legal counsel review this list, and record each - decision here before the first public ISO. - -## Not verified - -- OpenAI's terms for the ChatGPT desktop application. -- An explicit Valve grant for redistributing the Steam launcher. -- Trademark policies of Brave, 1Password, OpenAI, Spotify, Valve, Mozilla, - Rust, and each brand in the shell. -- Which OpenH264 provider the current image composes, and the Flatpak runtime - and extension contents. -- The complete contents and notices of the T3 Code AppImage, balena CLI - bundle, and Android SDK components that may fall under the SDK's - open-source exception (section 3.5). -- The license of `DigitalPals/mdview` and of the Plymouth artwork and wordmark. -- Retention of `/usr/share/licenses` in the built image. -- The current revision's payload; no current ISO was built for this audit. - - -## Public distribution gate - -The historical package counts and September image cited above remain audit -leads, not evidence for a new release. No current ISO has been built for this -implementation. `assets/distribution-review.json` explicitly lists unresolved -channels and contains **no approved release records**. The MIT code license, -Fedora RPM License tags and SPDX `NOASSERTION` fields are never distribution -approval. - -`image/distribution_review.py` separates internal capture from PUBLIC review: - -- `source`: full extracted source-file/link inventory and the actual archive's - byte digest. The release workflow makes that archive reproducible for the - same revision so retrying publication preserves its identity. -- `desktop-rpm` and `offline-rpm`: the actual signed RPM byte digest plus every - header payload path, SHA-256, mode and symlink target, queried without - installing or running the RPM. Header capture runs in the existing hosted - Fedora signing container; the public job rechecks the signed artifact's - digest and size before using that evidence, so it needs no older Ubuntu RPM - parser and cannot reuse it with replaced package bytes. -- `iso`: every prepared release asset's bytes, the SPDX packages of the actual - composed root, and the current `rpm-inventory.tsv`, `flatpak-inventory.tsv`, - `package-manifest.json`, `source-build-provenance.json` and - `build-provenance.json`. It verifies the ISO parts against their manifests, - recomputes the complete ISO digest without writing another ISO, checks the - SPDX subject, and requires the source-toolchain artifact digest in the SBOM. -- `web`: every file/link in the compiled output, including generated CSS/JS, - fonts, notices, logos and wallpaper derivatives. Reviewing `web/public` - alone does not qualify the compiled web distribution. - -Every review selects exactly one channel and the SHA-256 of its entire -canonical inventory. Each component records an explicit approved decision, -license or verified grant, obligations to satisfy, trademark review status, -and a repository evidence file with its SHA-256. Its exact item list, or -compact prefix selectors with explicit exclusions, must cover the entire -inventory once. Prefixes apply only to that captured inventory: adding, -removing or changing any item invalidates the approval. Group items by their -actual rights holder/terms; use separate groups for original code, each -third-party component, fonts and marks. A reviewer must inspect opaque bundled -applications and dependency notices; a byte inventory is not a legal opinion. - -### Supplying verified records - -1. Before the final candidate build, collect verifiable license texts, source - offers, notices, vendor grants and trademark decisions under an appropriate - repository documentation directory. Include who made each decision and its - scope. Do not commit private correspondence or secrets; retain a public - reference or a non-sensitive verified decision record instead. Changing - these files later changes the source candidate and requires a new build. -2. Build/qualify the final candidate internally from one revision. Local source - and image gates and the `desktop-release.yml` build/sign/qualification jobs - do not require public review. They do not grant permission to distribute. -3. Run `capture`, writing output outside the candidate directory. Examples: - - ```bash - python3 -B image/distribution_review.py capture --channel source \ - --root /review/extracted-source --artifact /review/cybexos-VERSION.tar.zst \ - --output /review/source-distribution-inventory.json - python3 -B image/distribution_review.py capture --channel desktop-rpm \ - --artifact /review/cybexos-desktop-VERSION.x86_64.rpm \ - --output /review/desktop-distribution-inventory.json - python3 -B image/distribution_review.py capture --channel offline-rpm \ - --artifact /review/cybexos-offline-apps-VERSION.x86_64.rpm \ - --output /review/offline-distribution-inventory.json - python3 -B image/distribution_review.py capture --channel iso \ - --root /review/publication/assets --sbom /review/publication/assets/NAME.spdx.json \ - --output /review/iso-distribution-inventory.json - ``` - - The publishing job retains `public-distribution-inventories` before checking - approval, so an unresolved review fails publication with reviewable output. - Review actual artifacts from that same build, never substitute an old audit - or a different build's SBOM. Their transient CI retention is not long-term - legal evidence; retain the needed records through the owner's review process. -4. Prepare an **external** review JSON file using schema 1 and - `"publication": "PUBLIC"`, with a `reviews` array. For each channel include - its printed `inventorySha256`, `decision`, `reviewedBy`, timezone-qualified - `reviewedAt`, and `components`. Each component needs `name`, `decision`, - `license`, `obligations`, `trademarks` (`reviewed` or `not-applicable`), - `evidence: {"path": "docs/...", "sha256": "..."}`, and `items` (an exact - array of inventory keys). Large groups may use - `"items": {"prefixes": ["file:PATH/"], "excludePrefixes": ["file:PATH/OTHER/"]}`. - `UNKNOWN`, `NOASSERTION`, unresolved decisions, empty evidence and missing - coverage fail. Only the owner/reviewer may set `decision` to `approved` - after verifying the rights and satisfying obligations. No engineering tool - manufactures those decisions. -5. Check locally with the same capture arguments, replacing `capture` with - `check-public` and adding `--reviews /review/owner-review.json`. This always - captures the candidate again. Do not put that approval inside the reviewed - source archive: doing so creates a self-referential digest. Commit evidence - first; supply the final inventory-bound decisions outside the payload. -6. Repository operators must configure the GitHub `public-distribution` - environment with required owner reviewers and store the reviewed JSON in - `CYBEXOS_PUBLIC_DISTRIBUTION_REVIEW` as an environment variable (not a secret). - Compact groups keep this within GitHub variable limits. Then rerun only the - publishing job against the same retained qualified artifacts. It requires - all four release channels before attesting or publishing anything; the - subsequent Pages publication depends on that job. Missing records fall back - to the checked-in unresolved manifest and fail closed. Successful releases - publish and attest the inventories and review record alongside the compiler - evidence, SBOM, source, RPMs and ISO parts. - -For the browser demo, run its normal build then `npm run review:inventory` -inside `web/` to capture `dist/`. Before any public hosting, run the matching -`check-public --channel web --root web/dist --output /review/web.json --reviews -/review/owner-review.json` from the repository root. `npm run review:public` -uses the checked-in unresolved manifest by default and fails until an actual -review is supplied. Preserve the exact reviewed compiled output for hosting; -rebuilding it requires recapture/review. The existing web CI only builds and -tests internally; it does not publish or imply approval. The gate cannot -control a person manually uploading files outside these workflows. - -### Decisions still required - -Owner/legal review remains required for proprietary application redistribution -(Spotify, Android SDK, Claude Code, 1Password, ChatGPT, Brave Origin), Android -SDK acceptance, Steam launcher terms, the actual OpenH264 provider and Flatpak -extensions, codec patent treatment, corresponding-source offers for copyleft -components, OPPO Sans notice/conditions, Google Sans Flex provenance, `mdview` -and Plymouth rights, wordmark authorship, notices for bundled app dependencies, -firmware terms, and Fedora/vendor trademarks. Resolve each against the current -channel's actual payload, or remove it from that public channel in a separate -product change. These decisions were not invented by the wallpaper replacement -or by adding the gate. diff --git a/docs/licensing.md b/docs/licensing.md deleted file mode 100644 index 1743e025..00000000 --- a/docs/licensing.md +++ /dev/null @@ -1,94 +0,0 @@ -# Licensing and asset provenance - -The repository's original code and configuration are licensed under the MIT -License; see the repository-root `LICENSE`. That grant applies to CybexOS -copyrighted code and does not replace licenses or permissions for third-party -software, assets, product names, or marks included in a source archive, ISO, or -RPM. - -## Third-party software and branding - -Files copied or downloaded from other projects remain under their upstream -terms. In particular: - -- pinned single-file fonts install their verified upstream license text beside - the font; -- the OPPO Sans archive supplies its own font license agreement, which the role - preserves beside the installation; -- the Cybex role checks out a pinned upstream artwork revision and then overlays - repository-local theme files; and -- product names, logos, and brand SVGs may also be subject to trademark rules, - independently of the MIT License. - -A public source archive, desktop RPM, or ISO bundles more than original CybexOS -code. Complete and document the software and asset redistribution audit for the -actual release payload before public distribution. The MIT License does not -itself grant rights to redistribute third-party packages, artwork, fonts, -wallpapers, or trademarks. - -## Repository assets - -The bundled wallpaper collection is installed into `~/Pictures/Wallpapers`. -The installer selects the mountain wallpaper when no wallpaper is configured, -while preserving existing wallpaper selections and custom folders. - -The seven native wallpapers and three responsive web variants are original -procedural landscapes. `python3 -B scripts/generate-wallpapers.py` recreates -them from geometric shapes using Python's standard library, without input -images, downloaded assets, or generative image services. `--check` compares all -rendered pixels and recorded artifact digests/provenance; zlib implementations -can encode identical pixels into different PNG bytes. `assets/PROVENANCE.json` records every native and web -wallpaper regardless of size, its dimensions, byte digest, generator digest, -palette and authorship method. Newly authored files do not claim a historical -introduction commit. These original assets use the repository's MIT license. - -The former undocumented bundled JPEGs and web derivatives have been removed -from the distributable tree. Installers copy new wallpapers only when absent -and retain saved selections and user-owned files, including earlier wallpapers -already in a user's home. This replacement does not rewrite Git history or -assert rights in earlier distributions. - -`tests/repository-policy.py` verifies complete native/web wallpaper coverage, -checksums and generator identity. This is distinct from the owner review of -other third-party software and marks needed for public distribution. - -## Redistribution audit - -[The redistribution audit](licensing-audit.md) inventories the third-party -packages, applications, fonts, wallpapers, marks, and firmware in each release -channel, records what could and could not be verified, and lists the blockers -before a public ISO. It is an engineering inventory for owner and legal review, -not a legal opinion. Public publication additionally requires a channel-specific, -reviewed distribution manifest bound to the actual artifact inventories; see -[the distribution review process](licensing-audit.md#public-distribution-gate). -Internal builds, fixture tests and ISO qualification remain available while -review is unresolved. - -## Built-in theme palettes - -The [built-in themes](shell-appearance.md#themes) under -`roles/desktop/files/quickshell/themes/` are data files: the colour values -of published palettes, mapped onto the shell's roles, with no code, artwork -or images from those projects. Each `theme.json` names its source in its -`author`, `license` and `source` fields; `tests/theme-packs.py` requires an -MIT license and an https source for every built-in theme. - -| Theme | Palette | Author | License | Source | -| --- | --- | --- | --- | --- | -| CybexOS | The shell's own defaults | CybexOS | MIT | this repository | -| Nord | Polar Night, Snow Storm, Frost, Aurora | Arctic Ice Studio and Sven Greb | MIT | | -| Gruvbox | gruvbox dark (medium) | Pavel Pertsev | MIT | | -| Tokyo Night | Tokyo Night (night) | Enkia | MIT | | -| Catppuccin Mocha | Mocha | Catppuccin | MIT | | -| Catppuccin Latte | Latte | Catppuccin | MIT | | -| Rosé Pine | Rosé Pine (main) | Rosé Pine | MIT | | -| Everforest | Everforest dark (medium) | Sainnhe Park | MIT | | -| Kanagawa | Kanagawa wave | Tommaso Laurenzi | MIT | | - -Where a palette has no colour for a role (Rosé Pine has no green; it uses -foam), the nearest named colour of the same palette is used. Roles a theme -leaves out are derived by the shell and are not part of the upstream palette. -Theme names identify the palettes they reproduce; they are their projects' -names, used descriptively. User themes installed with `cybex theme install` -keep their own license terms; the installer records where each came from but -does not establish redistribution rights. diff --git a/docs/measurements/efficiency-2026-10-15.json b/docs/measurements/efficiency-2026-10-15.json deleted file mode 100644 index 13ca8728..00000000 --- a/docs/measurements/efficiency-2026-10-15.json +++ /dev/null @@ -1,1383 +0,0 @@ -{ - "artwork_lossless": [ - { - "bytes": 2136966, - "candidates": [ - { - "bytes": 2114005, - "file": "01-black-widow-warrior-with-katana-ks-progressive.jpg", - "sha256": "42f63a048ca56e193f5243f915aeef67cee6471f2f3c69ad8fa9b03b4afc64fe" - }, - { - "bytes": 2149891, - "file": "01-black-widow-warrior-with-katana-ks-baseline.jpg", - "sha256": "de40862efac4c82ea96bbf5180c67dc97f0770043468e3dcb31eabf910926207" - } - ], - "source": "01-black-widow-warrior-with-katana-ks.jpg" - }, - { - "bytes": 4651376, - "candidates": [ - { - "bytes": 4631500, - "file": "05-joker-chaos-in-a-purple-suit-nq-progressive.jpg", - "sha256": "101dbdd19259601c29cb65339cafc4061d707e10e31cb3a8c86f2c81eba8eaf0" - }, - { - "bytes": 4798737, - "file": "05-joker-chaos-in-a-purple-suit-nq-baseline.jpg", - "sha256": "be490579639d52954c9eb414188e9932f9523d3744fc729162a1331658e86644" - } - ], - "source": "05-joker-chaos-in-a-purple-suit-nq.jpg" - }, - { - "bytes": 6352485, - "candidates": [ - { - "bytes": 6307720, - "file": "diana-prince-amazonian-legend-hn-progressive.jpg", - "sha256": "3e86df25a8fb3215ec8402568a844f36dc8e867b68ed2ae6d9b17fbc9d46797b" - }, - { - "bytes": 6708684, - "file": "diana-prince-amazonian-legend-hn-baseline.jpg", - "sha256": "d5cdb50fa037878f7af719d6a9750913cb96417405dacc200ff538e97e160368" - } - ], - "source": "diana-prince-amazonian-legend-hn.jpg" - }, - { - "bytes": 7256499, - "candidates": [ - { - "bytes": 7118373, - "file": "joker-ghost-in-dark-rt-progressive.jpg", - "sha256": "4f886a8aa3c46a1a8dc2a855a32fa10867268df7e0a22086f2fd14d38fc4c432" - }, - { - "bytes": 7408322, - "file": "joker-ghost-in-dark-rt-baseline.jpg", - "sha256": "afb095ddea3cccc012da27aab0ae5c5608af01142d447f733f55356695c1daa5" - } - ], - "source": "joker-ghost-in-dark-rt.jpg" - }, - { - "bytes": 1838747, - "candidates": [ - { - "bytes": 1816984, - "file": "snow-capped-mountains-with-full-moon-lo-progressive.jpg", - "sha256": "77a782d73220b7fac27c5cd933a9b4c828bc13257cb0e4d6ce0fa1d7af60a2f5" - }, - { - "bytes": 1865433, - "file": "snow-capped-mountains-with-full-moon-lo-baseline.jpg", - "sha256": "7850e3f857c44e585e5080cc99c8b707b2765ea121755d2fe1d2b9e1d282f9d6" - } - ], - "source": "snow-capped-mountains-with-full-moon-lo.jpg" - }, - { - "bytes": 2082942, - "candidates": [ - { - "bytes": 2026297, - "file": "spider-man-guardian-of-the-skyline-yx-progressive.jpg", - "sha256": "0bc48a0edb1495bf189b750354967b128be7f4d11bfe39d377e26fc9274fac5d" - }, - { - "bytes": 2082879, - "file": "spider-man-guardian-of-the-skyline-yx-baseline.jpg", - "sha256": "b9ef1b887a9d570481e3654d115e1881e37c768961c93d892bd7fc30731e3cc2" - } - ], - "source": "spider-man-guardian-of-the-skyline-yx.jpg" - }, - { - "bytes": 3963498, - "candidates": [ - { - "bytes": 3922044, - "file": "spider-man-the-hero-beyond-the-stars-cx-progressive.jpg", - "sha256": "eb741559491d07e157c262494ece78ad104c24c7574ca47f04debb64bf2c42cb" - }, - { - "bytes": 4122232, - "file": "spider-man-the-hero-beyond-the-stars-cx-baseline.jpg", - "sha256": "2c0207f177ad12f36e8ea2fe4446da5fc5732f62e675ae8510776aadd627661c" - } - ], - "source": "spider-man-the-hero-beyond-the-stars-cx.jpg" - } - ], - "baseline_payload": { - "opt/cybexos-apps": { - "bytes": 485608784, - "files": 44858 - }, - "opt/cybexos-builds": { - "bytes": 12213834, - "files": 5 - }, - "seed_components": { - ".config": { - "bytes": 0, - "files": 0 - }, - ".local": { - "bytes": 1668397370, - "files": 7647 - }, - ".npm-global": { - "bytes": 0, - "files": 0 - }, - ".rustup": { - "bytes": 1428749636, - "files": 66102 - }, - "Android": { - "bytes": 489063621, - "files": 11726 - }, - "Pictures": { - "bytes": 0, - "files": 0 - } - }, - "usr/local/share/fonts": { - "bytes": 322293207, - "files": 163 - }, - "usr/share/cybexos/user-seed": { - "bytes": 3586210627, - "files": 85475 - }, - "usr/share/fonts/cybexos": { - "bytes": 6468676, - "files": 20 - } - }, - "baseline_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c", - "cleanup": { - "final_iso_hardlinked_to_artifact_bundle": true, - "first_attempt_removed_disposable_artifacts_before_refresh_retry": true, - "iventoy_refresh_list_running_service": "passed", - "preexisting_images_preserved": true, - "refresh_retry_report": "/data/cybexos-efficiency-20261015-yCv5AF/reports/cleanup-refresh-retry.json", - "removed_served_isos": [ - "CybexOS-Live-44-20261015T135845Z-a65b1c60.iso", - "CybexOS-Live-44-20261015T163902Z-263beebe.iso", - "CybexOS-Live-44-20261015T174513Z-aab97405.iso", - "CybexOS-Live-44-20261015T181251Z-4dff45c4.iso" - ], - "removed_task_entries": [], - "retained_directory": "/data/cybexos-efficiency-20261015-yCv5AF", - "retained_disk_bytes": 10007286784, - "retained_logical_bytes": 10058779495, - "retained_reason": "Final complete ISO/RPM/source/SBOM bundle and compact qualification/provenance evidence for review.", - "status": "passed", - "task_owned_mounts": "none after cleanup", - "task_owned_processes": "none after cleanup", - "test_container_image_removed": true - }, - "compression_payload": { - "measurements": [ - { - "bytes": 1496461312, - "compress_seconds": 55.47804500899974, - "compression": "xz", - "extract_seconds": 6.546740841999963, - "run": 1 - }, - { - "bytes": 1571086336, - "compress_seconds": 33.241616783000154, - "compression": "zstd", - "extract_seconds": 4.855884554999648, - "run": 1 - } - ], - "processors": 8, - "scope": "SquashFS compression and extraction; not boot or installation time", - "source": { - "bytes": 4457839657, - "files": 131381, - "sha256": "fbfd61d3d0a26215be68195c5f5fa6ef01dcb5d3eec3d9cdc2ecb8a42a8b6ede" - }, - "status": "passed" - }, - "compression_rust_docs": { - "measurements": [ - { - "bytes": 58544128, - "compress_seconds": 8.530532367993146, - "compression": "xz", - "extract_seconds": 1.648576760198921, - "run": 1 - }, - { - "bytes": 60223488, - "compress_seconds": 8.299263254040852, - "compression": "zstd", - "extract_seconds": 2.1048905889037997, - "run": 1 - }, - { - "bytes": 60223488, - "compress_seconds": 8.823862102115527, - "compression": "zstd", - "extract_seconds": 2.125140634132549, - "run": 2 - }, - { - "bytes": 58544128, - "compress_seconds": 11.00238976906985, - "compression": "xz", - "extract_seconds": 2.183411896927282, - "run": 2 - } - ], - "processors": 8, - "scope": "SquashFS compression and extraction; not boot or installation time", - "source": { - "bytes": 739153743, - "files": 65926, - "sha256": "81f92b29c550e6863413a55e20c0c6e07775bcc2ea373ab0a4c48314ace58194" - }, - "status": "passed" - }, - "date": "2026-10-15", - "final_artifacts": { - "baseline_upstream_comparison": { - "flatpak_inventories_match": true, - "imported_signing_key_inventories_match": true, - "imported_signing_keys_after": 10, - "imported_signing_keys_before": 10, - "other_rpm_identities_match": true, - "rpm_identity_differences": [ - { - "after": [ - "1", - "0.0.0~dev", - "1.20261015183819.g5ce58a3ec8cf.dirty.fc44" - ], - "arch": "x86_64", - "before": [ - "1", - "0.0.0~dev", - "1.20261015131239.g5ce58a3ec8cf.fc44" - ], - "name": "cybexos-desktop" - }, - { - "after": [ - "1", - "1.4.0", - "1.fc44" - ], - "arch": "x86_64", - "before": null, - "name": "cybexos-offline-apps" - } - ], - "rpm_packages_after": 2260, - "rpm_packages_before": 2259 - }, - "build_id": "20261015T183818Z-25fb4f79", - "desktop_rpm": { - "bytes": 29830738, - "filename": "cybexos-desktop-0.0.0~dev-1.20261015183819.g5ce58a3ec8cf.dirty.fc44.x86_64.rpm", - "sha256": "ee019fe04267802824b4123e5038f06e2bd4d6e61530add62362c109817db577" - }, - "directory": "/data/cybexos-efficiency-20261015-yCv5AF/optimized-build-qualified-r6/artifacts", - "iso": { - "bytes": 7996391424, - "path": "/data/pxe/iso/CybexOS-Live-44-20261015T183818Z-25fb4f79.iso", - "sha256": "fea4ac94242869d7e35f528e5fd40549cce87981ece02ce95d594d2901fb19e2" - }, - "logical_bytes": 10057712493, - "offline_payload": { - "bytes": 3676758076, - "files": 64619, - "groups": { - "opt/cybexos-apps": { - "bytes": 485608784, - "files": 44858 - }, - "opt/cybexos-builds": { - "bytes": 12213964, - "files": 7 - }, - "usr/local/bin": { - "bytes": 0, - "files": 0 - }, - "usr/local/libexec": { - "bytes": 39053, - "files": 6 - }, - "usr/local/share/applications/omawrite.desktop": { - "bytes": 332, - "files": 1 - }, - "usr/local/share/fonts": { - "bytes": 322293207, - "files": 163 - }, - "usr/share/cybexos/final-seed": { - "bytes": 9455393, - "files": 14 - }, - "usr/share/cybexos/offline-inputs.json": { - "bytes": 112, - "files": 1 - }, - "usr/share/cybexos/seed-groups.json": { - "bytes": 27, - "files": 1 - }, - "usr/share/cybexos/user-seed": { - "bytes": 2847057353, - "files": 19548 - }, - "usr/share/icons/hicolor/scalable/apps/omawrite.svg": { - "bytes": 0, - "files": 0 - }, - "usr/share/licenses/cybexos-fonts": { - "bytes": 89851, - "files": 20 - } - }, - "inputs_sha256": "515765a7601038f478ad55bfbba9ce00f7ed7e6ce7f6d491ad6508063e75648e", - "version": "1.4.0" - }, - "offline_rpm": { - "bytes": 1641308348, - "filename": "cybexos-offline-apps-1.4.0-1.fc44.x86_64.rpm", - "sha256": "a2efda36c8a3e02b95048e41c4e2e7a16ec3341612c6274f7c73028c887d186c" - }, - "scope": "Final build after the installation parity corrections, qualified through actual ISO/source installation and encrypted upgrade/recovery. Earlier paired benchmark artifacts remain identified separately.", - "source_archive_sha256": "737c1b826cbc38a1e10acf42b6c89d192c559c7d4dddf5a00ec467847903f501" - }, - "iso_package_comparison": { - "flatpak_inventories_match": true, - "measurement": "Compared complete composed-image RPM name/architecture/epoch/version/release inventories and full Flatpak deployment records, including commits.", - "other_rpm_identities_match": true, - "rpm_identity_differences": [ - "cybexos-desktop: baseline monolithic build versus current split build", - "cybexos-offline-apps: added independent companion package" - ], - "rpm_packages_after": 2260, - "rpm_packages_before": 2259 - }, - "isos": { - "after": { - "bytes": 7996391424, - "file": "CybexOS-Live-44-20261015T135845Z-a65b1c60.iso", - "sha256": "4aace4005196dab0db00eac0cfc980508420bc1fb26aca73c0312cb1693007b0", - "source_archive_bytes": 32277823, - "source_archive_sha256": "83ce60619c2dbe6577043b83cdbfa94d24d2593abdddbae4fa9bfa671f9211d2", - "source_content_sha256": "b41f64ba782ece5db5144bb29fdb776db9038bcde80d6cfd1a7aca9e87c1054e", - "source_dirty": true, - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c" - }, - "before": { - "bytes": 8071483392, - "file": "CybexOS-Live-44-20261015T131239Z-f3ae6fb5.iso", - "sha256": "9a847aca4ef91a5a48de33b77efd45befb97b7b6bf70f0f668cd6e847b819818", - "source_archive_bytes": 33331560, - "source_archive_sha256": "5da368adb0467b5ab925360ff9571f02e4db076ae1d4d48ce73590196bbf8d9c", - "source_content_sha256": "1149a9bd1fb81ecf55503d895e8afd91c3c7eb0edbb2fab6f471c4a44f1df4c6", - "source_dirty": false, - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c" - }, - "measurement": "Complete xz ISOs from clean baseline and optimized snapshots at the same base revision; all upstream RPM identities and Flatpak deployment records match. Build and installation times are not compared.", - "saved_bytes": 75091968, - "saved_percent": 0.9303366475910355 - }, - "model_usage_sharing": { - "automatic_polling_after_last_awake_consumer_removed": "paused", - "changed_credentials": { - "legacy_activity_helpers": 1, - "quota_helpers": 1 - }, - "initial_legacy_activity_helpers": 1, - "initial_matching_consumers": 2, - "initial_quota_helpers": 1, - "manual_refresh_after_unplug": { - "legacy_activity_helpers": 1, - "quota_helpers": 1 - }, - "measurement": "Actual Quickshell QML engine with two matching consumers and counting quota/activity child helpers; no external network/API requests.", - "scope": "Controlled engine lifecycle, credential isolation and invocation counts; not a physical multi-monitor CPU or external API benchmark." - }, - "optimized_payload": { - "offline": { - "bytes": 3676758933, - "files": 64619, - "groups": { - "opt/cybexos-apps": { - "bytes": 485608784, - "files": 44858 - }, - "opt/cybexos-builds": { - "bytes": 12213964, - "files": 7 - }, - "usr/local/bin": { - "bytes": 0, - "files": 0 - }, - "usr/local/libexec": { - "bytes": 39020, - "files": 6 - }, - "usr/local/share/applications/omawrite.desktop": { - "bytes": 332, - "files": 1 - }, - "usr/local/share/fonts": { - "bytes": 322293207, - "files": 163 - }, - "usr/share/cybexos/final-seed": { - "bytes": 9455393, - "files": 14 - }, - "usr/share/cybexos/offline-inputs.json": { - "bytes": 112, - "files": 1 - }, - "usr/share/cybexos/seed-groups.json": { - "bytes": 27, - "files": 1 - }, - "usr/share/cybexos/user-seed": { - "bytes": 2847058243, - "files": 19548 - }, - "usr/share/icons/hicolor/scalable/apps/omawrite.svg": { - "bytes": 0, - "files": 0 - }, - "usr/share/licenses/cybexos-fonts": { - "bytes": 89851, - "files": 20 - } - }, - "inputs_sha256": "9867d5da2c6bf3db04cb597992103a83dcab5f199f3fd298c3221f8115eb5a5d", - "version": "1.0.0" - }, - "rust_docs": [], - "usr/local/share/fonts": { - "bytes": 322293207, - "files": 163 - }, - "usr/share/cybexos/user-seed": { - "bytes": 2847058243, - "files": 19548 - }, - "usr/share/cybexos/user-seed/.rustup": { - "bytes": 689595743, - "files": 175 - }, - "usr/share/fonts/cybexos": { - "bytes": 0, - "files": 0 - } - }, - "plugin_discovery": { - "measurement": "Actual user-plugins helper scans while Appearance settings remain open; first second excluded", - "runs": [ - { - "scans_after_first_second": 5, - "steady_window_seconds": 10, - "total_scans": 6, - "variant": "baseline" - }, - { - "scans_after_first_second": 0, - "steady_window_seconds": 10, - "total_scans": 1, - "variant": "optimized" - } - ] - }, - "qualification": { - "checkout_download_cache": { - "scope": "Verified RPM downloads only; normal metadata/checksum/signature checks run. The builder also reused this task-built compiler/Cargo cache. GitHub metadata used existing authenticated access after anonymous rate limiting. No complete-install/build speed claim.", - "status": "imported", - "verified_rpm_downloads": 2255 - }, - "checkout_installation": { - "applications_manifest_sha256": "2dacee3ab1d499194071b9de0c5491415ca503f3087f3f00369f460ae23d3dc4", - "checks": [ - "full-checkout-installation", - "captured-installed-outcomes-fresh", - "captured-installed-outcomes-saved", - "native-settings-lifecycle", - "saved-choice-reconfiguration", - "full-graphical-session", - "same-source-content", - "exact-final-application-contract", - "installed-parity-fresh", - "installed-parity-saved" - ], - "comparison_policy": { - "comparison_helper_sha256": "5ab5322b8b33ce14926b0482dfa633df6489f3d6e5daad8a3283286f39b3af78", - "policy_sha256": "382e0676809aadc531c0200051137fb736d5fc2ba44a3aa94e0728dd3eac7f95", - "reviewed_signing_key_evidence": "/data/cybexos-efficiency-20261015-yCv5AF/reports/brave-keyring-difference.json", - "scope": "Three exact Brave prerelease key identities added to the reviewed exceptions after capture. No changes to installed product inputs or capture data." - }, - "fedora_baseline": { - "installed_kernel_core": [ - "7.2.10-200.fc44.x86_64" - ], - "installed_packages": [ - "7zip.x86_64=26.04-1.fc44", - "NetworkManager-libnm.x86_64=1:1.56.1-2.fc44", - "NetworkManager.x86_64=1:1.56.1-2.fc44", - "aardvark-dns.x86_64=2:1.17.1-1.fc44", - "alternatives.x86_64=1.33-5.fc44", - "ansible-core.noarch=2.20.7-1.fc44", - "audit-libs.x86_64=4.2.1-1.fc44", - "audit-rules.x86_64=4.2.1-1.fc44", - "audit.x86_64=4.2.1-1.fc44", - "authselect-libs.x86_64=1.7.1-1.fc44", - "authselect.x86_64=1.7.1-1.fc44", - "bash-completion.noarch=1:2.17-2.fc44", - "bash.x86_64=5.3.9-3.fc44", - "btrfs-progs.x86_64=7.1-1.fc44", - "bzip2-libs.x86_64=1.0.8-23.fc44", - "c-ares.x86_64=1.34.8-1.fc44", - "ca-certificates.noarch=2026.2.90_v9.0.317-1.fc44", - "catatonit.x86_64=0.2.1-5.fc44", - "checkpolicy.x86_64=3.11-1.fc44", - "chrony.x86_64=4.9-1.fc44", - "composefs-libs.x86_64=1.0.8-5.fc44", - "conmon.x86_64=2:2.2.1-2.fc44", - "container-selinux.noarch=4:2.251.0-1.fc44", - "containers-common-extra.noarch=5:0.67.2-1.fc44", - "containers-common.noarch=5:0.67.2-1.fc44", - "coreutils-common.x86_64=9.10-5.fc44", - "coreutils.x86_64=9.10-5.fc44", - "cpio.x86_64=2.15-9.fc44", - "cracklib-dicts.x86_64=2.10.3-1.fc44", - "cracklib.x86_64=2.10.3-1.fc44", - "crun.x86_64=1.28-1.fc44", - "crypto-policies-scripts.noarch=20251128-3.git19878fe.fc44", - "crypto-policies.noarch=20251128-3.git19878fe.fc44", - "cryptsetup-libs.x86_64=2.8.8-1.fc44", - "curl.x86_64=8.18.0-14.fc44", - "cyrus-sasl-gssapi.x86_64=2.1.28-35.fc44", - "cyrus-sasl-lib.x86_64=2.1.28-35.fc44", - "dbus-broker.x86_64=37-8.fc44", - "dbus-common.noarch=1:1.16.2-1.fc44", - "dbus-libs.x86_64=1:1.16.2-1.fc44", - "dbus.x86_64=1:1.16.2-1.fc44", - "device-mapper-event-libs.x86_64=1.02.212-2.fc44", - "device-mapper-event.x86_64=1.02.212-2.fc44", - "device-mapper-libs.x86_64=1.02.212-2.fc44", - "device-mapper-persistent-data.x86_64=1.3.3-1.fc44", - "device-mapper.x86_64=1.02.212-2.fc44", - "diffutils.x86_64=3.12-5.fc44", - "dnf-data.noarch=4.24.0-3.fc44", - "dnf5-plugins.x86_64=5.4.6.0-1.fc44", - "dnf5.x86_64=5.4.6.0-1.fc44", - "dns-root-data.noarch=2026260100-2.fc44", - "dracut-config-generic.x86_64=108-8.fc44", - "dracut.x86_64=108-8.fc44", - "e2fsprogs-libs.x86_64=1.47.3-4.fc44", - "e2fsprogs.x86_64=1.47.3-4.fc44", - "efi-filesystem.noarch=6-6.fc44", - "efibootmgr.x86_64=18-11.fc44", - "efivar-libs.x86_64=39-12.fc44", - "elfutils-debuginfod-client.x86_64=0.196-1.fc44", - "elfutils-default-yama-scope.noarch=0.196-1.fc44", - "elfutils-libelf.x86_64=0.196-1.fc44", - "elfutils-libs.x86_64=0.196-1.fc44", - "expat.x86_64=2.8.5-1.fc44", - "fedora-gpg-keys.noarch=44-2", - "fedora-release-common.noarch=44-18", - "fedora-release-identity-basic.noarch=44-18", - "fedora-release.noarch=44-18", - "fedora-repos.noarch=44-2", - "file-libs.x86_64=5.46-10.fc44", - "file.x86_64=5.46-10.fc44", - "filesystem.x86_64=3.18-52.fc44", - "findutils.x86_64=1:4.10.0-7.fc44", - "firewalld-filesystem.noarch=2.4.4-1.fc44", - "firewalld.noarch=2.4.4-1.fc44", - "fmt.x86_64=11.2.0-4.fc44", - "freetype.x86_64=2.14.3-1.fc44", - "fuse-common.x86_64=3.18.3-1.fc44", - "fuse3-libs.x86_64=3.18.3-1.fc44", - "fuse3.x86_64=3.18.3-1.fc44", - "gawk.x86_64=5.3.2-3.fc44", - "gdbm-libs.x86_64=1:1.23-11.fc44", - "gdbm.x86_64=1:1.23-11.fc44", - "gettext-envsubst.x86_64=0.26-5.fc44", - "gettext-libs.x86_64=0.26-5.fc44", - "gettext-runtime.x86_64=0.26-5.fc44", - "git-core-doc.noarch=2.56.0-1.fc44", - "git-core.x86_64=2.56.0-1.fc44", - "git.x86_64=2.56.0-1.fc44", - "glib2.x86_64=2.88.4-1.fc44", - "glibc-all-langpacks.x86_64=2.43-9.fc44", - "glibc-common.x86_64=2.43-9.fc44", - "glibc-gconv-extra.x86_64=2.43-9.fc44", - "glibc.x86_64=2.43-9.fc44", - "gmp.x86_64=1:6.3.0-5.fc44", - "gnulib-l10n.noarch=20241231-2.fc44", - "gnupg2-dirmngr.x86_64=2.4.9-16.fc44", - "gnupg2-gpg-agent.x86_64=2.4.9-16.fc44", - "gnupg2-gpgconf.x86_64=2.4.9-16.fc44", - "gnupg2-keyboxd.x86_64=2.4.9-16.fc44", - "gnupg2-verify.x86_64=2.4.9-16.fc44", - "gnupg2.x86_64=2.4.9-16.fc44", - "gnutls.x86_64=3.8.13-1.fc44", - "gpg-pubkey.(none)=36f612dcf27f7d1a48a835e4dbfcf71c6d9f90a6-6786af3b", - "gpgme.x86_64=2.0.1-5.fc44", - "graphite2.x86_64=1.3.14-20.fc44", - "grep.x86_64=3.12-3.fc44", - "groff-base.x86_64=1.23.0-12.fc44", - "grub2-common.noarch=1:2.12-66.fc44", - "grub2-efi-ia32.x86_64=1:2.12-66.fc44", - "grub2-efi-x64.x86_64=1:2.12-66.fc44", - "grub2-pc-modules.noarch=1:2.12-66.fc44", - "grub2-pc.x86_64=1:2.12-66.fc44", - "grub2-tools-efi.x86_64=1:2.12-66.fc44", - "grub2-tools-extra.x86_64=1:2.12-66.fc44", - "grub2-tools-minimal.x86_64=1:2.12-66.fc44", - "grub2-tools.x86_64=1:2.12-66.fc44", - "grubby.x86_64=8.40-88.fc44", - "gzip.x86_64=1.14-2.fc44", - "harfbuzz.x86_64=14.1.0-2.fc44", - "hiredis.x86_64=1.2.0-8.fc44", - "hostname.x86_64=3.25-4.fc44", - "ima-evm-utils-libs.x86_64=1.6.2-8.fc44", - "inih.x86_64=62-2.fc44", - "iproute.x86_64=6.17.0-2.fc44", - "iptables-libs.x86_64=1.8.11-13.fc44", - "iputils.x86_64=20250605-2.fc44", - "jansson.x86_64=2.14-4.fc44", - "json-c.x86_64=0.18-8.fc44", - "kbd-legacy.noarch=2.9.0-4.fc44", - "kbd-misc.noarch=2.9.0-4.fc44", - "kbd.x86_64=2.9.0-4.fc44", - "kernel-core.x86_64=7.2.10-200.fc44", - "kernel-modules-core.x86_64=7.2.10-200.fc44", - "keyutils-libs.x86_64=1.6.3-7.fc44", - "kmod-libs.x86_64=34.2-4.fc44", - "kmod.x86_64=34.2-4.fc44", - "krb5-libs.x86_64=1.22.2-4.fc44", - "less.x86_64=704-4.fc44", - "libacl.x86_64=2.4.0-1.fc44", - "libaio.x86_64=0.3.111-23.fc44", - "libarchive.x86_64=3.8.7-1.fc44", - "libassuan.x86_64=2.5.7-5.fc44", - "libattr.x86_64=2.6.0-1.fc44", - "libbasicobjects.x86_64=0.1.1-61.fc44", - "libblkid.x86_64=2.41.5-1.fc44", - "libbpf.x86_64=2:1.6.3-2.fc44", - "libbrotli.x86_64=1.2.0-3.fc44", - "libcap-ng.x86_64=0.9.6-1.fc44", - "libcap.x86_64=2.78-1.fc44", - "libcbor.x86_64=0.13.0-2.fc44", - "libcollection.x86_64=0.7.0-61.fc44", - "libcom_err.x86_64=1.47.3-4.fc44", - "libcomps.x86_64=0.1.24-1.fc44", - "libcurl.x86_64=8.18.0-14.fc44", - "libdhash.x86_64=0.5.0-61.fc44", - "libdnf.x86_64=0.76.0-1.fc44", - "libdnf5-cli.x86_64=5.4.6.0-1.fc44", - "libdnf5.x86_64=5.4.6.0-1.fc44", - "libeconf.x86_64=0.7.9-3.fc44", - "libedit.x86_64=3.1-59.20260512cvs.fc44", - "libevent.x86_64=2.1.13-1.fc44", - "libfdisk.x86_64=2.41.5-1.fc44", - "libffi.x86_64=3.5.2-2.fc44", - "libfido2.x86_64=1.16.0-5.fc44", - "libfsverity.x86_64=1.6-4.fc44", - "libgcc.x86_64=16.2.1-2.fc44", - "libgcrypt.x86_64=1.12.2-1.fc44", - "libgomp.x86_64=16.2.1-2.fc44", - "libgpg-error.x86_64=1.58-2.fc44", - "libicu.x86_64=77.1-3.fc44", - "libidn2.x86_64=2.3.8-3.fc44", - "libini_config.x86_64=1.3.1-61.fc44", - "libkcapi-hasher.x86_64=1.5.0-7.fc44", - "libkcapi-hmaccalc.x86_64=1.5.0-7.fc44", - "libkcapi.x86_64=1.5.0-7.fc44", - "libksba.x86_64=1.6.7-5.fc44", - "liblastlog2.x86_64=2.41.5-1.fc44", - "libldb.x86_64=2:4.24.7-1.fc44", - "libmnl.x86_64=1.0.5-9.fc44", - "libmodulemd.x86_64=2.15.3-1.fc44", - "libmount.x86_64=2.41.5-1.fc44", - "libndp.x86_64=1.9-5.fc44", - "libnetfilter_conntrack.x86_64=1.1.1-1.fc44", - "libnfnetlink.x86_64=1.0.1-32.fc44", - "libnftnl.x86_64=1.3.1-2.fc44", - "libnghttp2.x86_64=1.68.0-5.fc44", - "libnghttp3.x86_64=1.18.0-1.fc44", - "libnl3.x86_64=3.12.0-3.fc44", - "libnsl2.x86_64=2.0.1-5.fc44", - "libnvme.x86_64=1.16.2-1.fc44", - "libpath_utils.x86_64=0.2.1-61.fc44", - "libpipeline.x86_64=1.5.8-4.fc44", - "libpkgconf.x86_64=2.5.1-1.fc44", - "libpng.x86_64=2:1.6.58-1.fc44", - "libpsl.x86_64=0.21.5-7.fc44", - "libpwquality.x86_64=1.4.5-15.fc44", - "libref_array.x86_64=0.1.5-61.fc44", - "librepo.x86_64=1.21.2-1.fc44", - "libseccomp.x86_64=2.6.1-2.fc44", - "libsecret.x86_64=0.21.8.2-2.fc44", - "libselinux-utils.x86_64=3.11-2.fc44", - "libselinux.x86_64=3.11-2.fc44", - "libsemanage.x86_64=3.11-1.fc44", - "libsepol.x86_64=3.11-1.fc44", - "libsmartcols.x86_64=2.41.5-1.fc44", - "libsolv.x86_64=0.7.40-1.fc44", - "libss.x86_64=1.47.3-4.fc44", - "libssh-config.noarch=0.12.2-1.fc44", - "libssh.x86_64=0.12.2-1.fc44", - "libsss_certmap.x86_64=2.13.1-2.fc44", - "libsss_idmap.x86_64=2.13.1-2.fc44", - "libsss_nss_idmap.x86_64=2.13.1-2.fc44", - "libsss_sudo.x86_64=2.13.1-2.fc44", - "libstdc++.x86_64=16.2.1-2.fc44", - "libtalloc.x86_64=2.4.4-1.fc44", - "libtasn1.x86_64=4.21.0-1.fc44", - "libtdb.x86_64=1.4.15-1.fc44", - "libtevent.x86_64=0.17.1-4.fc44", - "libtextstyle.x86_64=0.26-5.fc44", - "libtirpc.x86_64=1.3.8-0.fc44", - "libtool-ltdl.x86_64=2.5.4-10.fc44", - "libunistring.x86_64=1.1-11.fc44", - "liburing.x86_64=2.13-2.fc44", - "libusb1.x86_64=1.0.30-1.fc44", - "libuuid.x86_64=2.41.5-1.fc44", - "libverto.x86_64=0.3.2-12.fc44", - "libxcrypt.x86_64=4.5.2-3.fc44", - "libxkbcommon.x86_64=1.13.1-2.fc44", - "libxml2.x86_64=2.12.10-6.fc44", - "libyaml.x86_64=0.2.5-18.fc44", - "libzstd.x86_64=1.5.7-5.fc44", - "lmdb-libs.x86_64=0.9.34-2.fc44", - "lua-libs.x86_64=5.4.8-5.fc44", - "lvm2-libs.x86_64=2.03.38-2.fc44", - "lvm2.x86_64=2.03.38-2.fc44", - "lz4-libs.x86_64=1.10.0-4.fc44", - "lzo.x86_64=2.10-16.fc44", - "man-db.x86_64=2.13.1-3.fc44", - "mokutil.x86_64=2:0.7.2-3.fc44", - "mpdecimal.x86_64=4.0.1-3.fc44", - "mpfr.x86_64=4.2.2-3.fc44", - "mtools.x86_64=4.0.49-3.fc44", - "nano.x86_64=8.7.1-2.fc44", - "ncurses-base.noarch=6.6-1.fc44", - "ncurses-libs.x86_64=6.6-1.fc44", - "ncurses.x86_64=6.6-1.fc44", - "netavark.x86_64=2:1.17.2-1.fc44", - "nettle.x86_64=3.10.1-3.fc44", - "nftables-services.noarch=1:1.1.6-2.fc44", - "nftables.x86_64=1:1.1.6-2.fc44", - "ngtcp2-crypto-ossl.x86_64=1.22.1-1.fc44", - "ngtcp2.x86_64=1.22.1-1.fc44", - "npth.x86_64=1.8-4.fc44", - "numactl-libs.x86_64=2.0.19-4.fc44", - "openldap.x86_64=2.6.15-1.fc44", - "openssh-clients.x86_64=10.2p1-14.fc44", - "openssh-server.x86_64=10.2p1-14.fc44", - "openssh.x86_64=10.2p1-14.fc44", - "openssl-libs.x86_64=1:3.5.9-1.fc44", - "openssl.x86_64=1:3.5.9-1.fc44", - "os-prober.x86_64=1.81-11.fc44", - "p11-kit-trust.x86_64=0.26.5-1.fc44", - "p11-kit.x86_64=0.26.5-1.fc44", - "pam-libs.x86_64=1.7.2-2.fc44", - "pam.x86_64=1.7.2-2.fc44", - "parted.x86_64=3.6-15.fc44", - "passt-selinux.noarch=0^20261002.gcba3570-1.fc44", - "passt.x86_64=0^20261002.gcba3570-1.fc44", - "pcre2-syntax.noarch=10.47-1.fc44.1", - "pcre2.x86_64=10.47-1.fc44.1", - "perl-AutoLoader.noarch=0:5.74-525.fc44", - "perl-B.x86_64=0:1.89-525.fc44", - "perl-Carp.noarch=1.54-521.fc44", - "perl-Class-Struct.noarch=0:0.68-525.fc44", - "perl-Data-Dumper.x86_64=2.192-523.fc44", - "perl-Digest-MD5.x86_64=2.59-521.fc44", - "perl-Digest.noarch=1.20-521.fc44", - "perl-DynaLoader.x86_64=0:1.57-525.fc44", - "perl-Encode.x86_64=4:3.21-521.fc44", - "perl-Errno.x86_64=0:1.38-525.fc44", - "perl-Error.noarch=1:0.17030-3.fc44", - "perl-Exporter.noarch=5.79-521.fc44", - "perl-Fcntl.x86_64=0:1.20-525.fc44", - "perl-File-Basename.noarch=0:2.86-525.fc44", - "perl-File-Path.noarch=2.18-522.fc44", - "perl-File-Temp.noarch=1:0.231.200-2.fc44", - "perl-File-stat.noarch=0:1.14-525.fc44", - "perl-FileHandle.noarch=0:2.05-525.fc44", - "perl-Getopt-Long.noarch=1:2.58-521.fc44", - "perl-Getopt-Std.noarch=0:1.14-525.fc44", - "perl-Git.noarch=2.56.0-1.fc44", - "perl-HTTP-Tiny.noarch=0.096-1.fc44", - "perl-IO-Socket-IP.noarch=0.43-522.fc44", - "perl-IO-Socket-SSL.noarch=2.098-2.fc44", - "perl-IO.x86_64=0:1.55-525.fc44", - "perl-IPC-Open3.noarch=0:1.24-525.fc44", - "perl-MIME-Base32.noarch=1.303-25.fc44", - "perl-MIME-Base64.x86_64=3.16-521.fc44", - "perl-Net-SSLeay.x86_64=1.94-12.fc44", - "perl-POSIX.x86_64=0:2.23-525.fc44", - "perl-PathTools.x86_64=3.94-521.fc44", - "perl-Pod-Escapes.noarch=1:1.07-521.fc44", - "perl-Pod-Perldoc.noarch=3.28.01-522.fc44", - "perl-Pod-Simple.noarch=1:3.47-4.fc44", - "perl-Pod-Usage.noarch=4:2.05-521.fc44", - "perl-Scalar-List-Utils.x86_64=5:1.70-2.fc44", - "perl-SelectSaver.noarch=0:1.02-525.fc44", - "perl-Socket.x86_64=4:2.043-1.fc44", - "perl-Storable.x86_64=1:3.37-522.fc44", - "perl-Symbol.noarch=0:1.09-525.fc44", - "perl-Term-ANSIColor.noarch=5.01-522.fc44", - "perl-Term-Cap.noarch=1.18-521.fc44", - "perl-TermReadKey.x86_64=2.38-27.fc44", - "perl-Text-ParseWords.noarch=3.31-521.fc44", - "perl-Text-Tabs+Wrap.noarch=2024.001-521.fc44", - "perl-Time-HiRes.x86_64=4:1.9778-521.fc44", - "perl-Time-Local.noarch=2:1.350-521.fc44", - "perl-URI.noarch=5.36-1.fc44", - "perl-Unicode-Normalize.x86_64=1.32-521.fc44", - "perl-base.noarch=0:2.27-525.fc44", - "perl-constant.noarch=1.33-522.fc44", - "perl-if.noarch=0:0.61.000-525.fc44", - "perl-interpreter.x86_64=4:5.42.3-525.fc44", - "perl-lib.x86_64=0:0.65-525.fc44", - "perl-libnet.noarch=3.15-522.fc44", - "perl-libs.x86_64=4:5.42.3-525.fc44", - "perl-locale.noarch=0:1.13-525.fc44", - "perl-mro.x86_64=0:1.29-525.fc44", - "perl-overload.noarch=0:1.40-525.fc44", - "perl-overloading.noarch=0:0.02-525.fc44", - "perl-parent.noarch=1:0.244-521.fc44", - "perl-podlators.noarch=1:6.0.2-522.fc44", - "perl-vars.noarch=0:1.05-525.fc44", - "pinentry.x86_64=1.3.2-3.fc44", - "pkgconf-m4.noarch=2.5.1-1.fc44", - "pkgconf-pkg-config.x86_64=2.5.1-1.fc44", - "pkgconf.x86_64=2.5.1-1.fc44", - "podman-sequoia.x86_64=0.3.2-2.fc44", - "podman.x86_64=5:5.8.7-1.fc44", - "policycoreutils.x86_64=3.11-2.fc44", - "popt.x86_64=1.19-10.fc44", - "prefixdevname.x86_64=0.2.0-8.fc44", - "procps-ng.x86_64=4.0.6-1.fc44", - "protobuf-c.x86_64=1.5.2-2.fc44", - "psmisc.x86_64=23.7-7.fc44", - "publicsuffix-list-dafsa.noarch=20260116-1.fc44", - "python-pip-wheel.noarch=26.0.1-3.fc44", - "python3-argcomplete.noarch=3.6.3-4.fc44", - "python3-attrs.noarch=25.4.0-2.fc44", - "python3-audit.x86_64=4.2.1-1.fc44", - "python3-cffi.x86_64=2.0.0-3.fc44", - "python3-charset-normalizer.noarch=3.4.4-2.fc44", - "python3-cryptography.x86_64=50.0.0-1.fc44", - "python3-dbus.x86_64=1.4.0-9.fc44", - "python3-distro.noarch=1.9.0-11.fc44", - "python3-dnf.noarch=4.24.0-3.fc44", - "python3-firewall.noarch=2.4.4-1.fc44", - "python3-gobject-base.x86_64=3.56.3-1.fc44", - "python3-hawkey.x86_64=0.76.0-1.fc44", - "python3-idna.noarch=3.18-1.fc44", - "python3-jinja2.noarch=3.1.6-7.fc44", - "python3-jsonschema-specifications.noarch=2024.10.1-7.fc44", - "python3-jsonschema.noarch=4.23.0-7.fc44", - "python3-libcomps.x86_64=0.1.24-1.fc44", - "python3-libdnf.x86_64=0.76.0-1.fc44", - "python3-libdnf5.x86_64=5.4.6.0-1.fc44", - "python3-libs.x86_64=3.14.8-1.fc44", - "python3-libselinux.x86_64=3.11-2.fc44", - "python3-libsemanage.x86_64=3.11-1.fc44", - "python3-markupsafe.x86_64=3.0.2-7.fc44", - "python3-nftables.noarch=1:1.1.6-2.fc44", - "python3-packaging.noarch=25.0-8.fc44", - "python3-policycoreutils.noarch=3.11-2.fc44", - "python3-psutil.x86_64=7.2.2-1.fc44", - "python3-pycparser.noarch=2.22-8.fc44", - "python3-pyyaml.x86_64=6.0.3-3.fc44", - "python3-referencing.noarch=0.36.2-6.fc44", - "python3-requests.noarch=2.33.1-1.fc44", - "python3-resolvelib.noarch=1.0.1-12.fc44", - "python3-rpds-py.x86_64=0.29.0-6.fc44", - "python3-rpm.x86_64=6.0.2-1.fc44", - "python3-setools.x86_64=4.6.0-6.fc44", - "python3-six.noarch=1.17.0-8.fc44", - "python3-urllib3.noarch=2.8.0-1.fc44", - "python3.x86_64=3.14.8-1.fc44", - "qrencode-libs.x86_64=4.1.1-12.fc44", - "readline.x86_64=8.3-4.fc44", - "rootfiles.noarch=9.0-6.fc44", - "rpm-build-libs.x86_64=6.0.2-1.fc44", - "rpm-libs.x86_64=6.0.2-1.fc44", - "rpm-plugin-selinux.x86_64=6.0.2-1.fc44", - "rpm-sequoia.x86_64=1.10.3-1.fc44", - "rpm-sign-libs.x86_64=6.0.2-1.fc44", - "rpm.x86_64=6.0.2-1.fc44", - "rsync.x86_64=3.5.1-1.fc44", - "samba-common.noarch=2:4.24.7-1.fc44", - "samba-core-libs.x86_64=2:4.24.7-1.fc44", - "samba-ndr-libs.x86_64=2:4.24.7-1.fc44", - "sdbus-cpp.x86_64=2.2.1-2.fc44", - "sed.x86_64=4.9-7.fc44", - "selinux-policy-targeted.noarch=44.11-1.fc44", - "selinux-policy.noarch=44.11-1.fc44", - "setup.noarch=2.15.0-28.fc44", - "shadow-utils-subid.x86_64=2:4.19.0-7.fc44", - "shadow-utils.x86_64=2:4.19.0-7.fc44", - "shim-ia32.x86_64=16.1-7", - "shim-x64.x86_64=16.1-7", - "sqlite-libs.x86_64=3.51.2-2.fc44", - "sssd-client.x86_64=2.13.1-2.fc44", - "sssd-common.x86_64=2.13.1-2.fc44", - "sssd-kcm.x86_64=2.13.1-2.fc44", - "sssd-krb5-common.x86_64=2.13.1-2.fc44", - "sudo.x86_64=1.9.17-10.p2.fc44", - "systemd-libs.x86_64=259.9-1.fc44", - "systemd-oomd-defaults.noarch=259.9-1.fc44", - "systemd-pam.x86_64=259.9-1.fc44", - "systemd-resolved.x86_64=259.9-1.fc44", - "systemd-shared.x86_64=259.9-1.fc44", - "systemd-sysusers.x86_64=259.9-1.fc44", - "systemd-udev.x86_64=259.9-1.fc44", - "systemd.x86_64=259.9-1.fc44", - "tar.x86_64=2:1.35-9.fc44", - "tpm2-tss.x86_64=4.1.3-9.fc44", - "tzdata.noarch=2026e-1.fc44", - "unbound-libs.x86_64=1.26.1-1.fc44", - "unzip.x86_64=6.0-69.fc44", - "userspace-rcu.x86_64=0.15.6-1.fc44", - "util-linux-core.x86_64=2.41.5-1.fc44", - "util-linux.x86_64=2.41.5-1.fc44", - "vim-data.noarch=2:9.2.1129-1.fc44", - "vim-minimal.x86_64=2:9.2.1129-1.fc44", - "which.x86_64=2.25-1.fc44", - "whois-nls.noarch=5.6.6-1.fc44", - "xfsprogs.x86_64=7.1.1-1.fc44", - "xkeyboard-config.noarch=2.47-1.fc44", - "xxhash-libs.x86_64=0.8.4-1.fc44", - "xz-libs.x86_64=1:5.8.2-2.fc44", - "xz.x86_64=1:5.8.2-2.fc44", - "yajl.x86_64=2.1.0-40.fc44", - "zchunk-libs.x86_64=1.5.4-1.fc44", - "zlib-ng-compat.x86_64=2.3.3-3.fc44", - "zram-generator-defaults.noarch=1.2.1-5.fc44", - "zram-generator.x86_64=1.2.1-5.fc44", - "zstd.x86_64=1.5.7-5.fc44" - ], - "running_kernel": "7.2.10-200.fc44.x86_64", - "scope": "Disposable Fedora OS preparation before the public CybexOS installer; no product payload imported" - }, - "fedora_cloud_sha256": "28680fe5b371a5a82ebf43a31926e086a168e59949d03969c5093e7071f90b7f", - "iso_sha256": "fea4ac94242869d7e35f528e5fd40549cce87981ece02ce95d594d2901fb19e2", - "outcome_captures": { - "fresh": { - "checkout_capture_sha256": "21042e71dc83eb536f34ad9f0c90f077ec024ddb2875e2f18b0ba7c81ae41bee", - "iso_capture_sha256": "00c39ab95eebfb34357c783609e568ba082144f5dfb50e4ad0c1dc2451569945" - }, - "saved": { - "checkout_capture_sha256": "358db8dee1b457e7535d06903aefde4cf60baea7ed0334e0289aad0bdbb9a5ce", - "iso_capture_sha256": "f443473fbe278a68c7a4e89c8c469d89e261df0819266274d712f9c8b083ef61" - } - }, - "report": "/data/cybexos-efficiency-20261015-yCv5AF/qual-checkout-qualified-r6/checkout-qualification.json", - "scenario": "plain-us", - "scope": "Actual public checkout installation and saved-choice reconfiguration on pinned Fedora Cloud, compared with real fresh and saved installations of the exact matching ISO. Every package/version delta is explicit and reasoned; unknown signing keys fail. QEMU, not physical hardware or Secure Boot.", - "source_archive_sha256": "737c1b826cbc38a1e10acf42b6c89d192c559c7d4dddf5a00ec467847903f501", - "source_content_sha256": "09120eb1c604e1c178a6c27b89894bb2d58746a5bf8798f733728cec2085441d", - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c", - "status": "passed" - }, - "encrypted_upgrade_recovery": { - "boot_entry": "install", - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_offline_rpm_sha256": "a2efda36c8a3e02b95048e41c4e2e7a16ec3341612c6274f7c73028c887d186c", - "candidate_rpm_sha256": "ee019fe04267802824b4123e5038f06e2bd4d6e61530add62362c109817db577", - "checks": [ - "live-boot-and-offline-applications", - "graphical-installer", - "encrypted-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "encrypted-btrfs", - "autologin", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "installed-rpm-upgrade", - "recovery-boot-restore" - ], - "delivered_candidate_rpms_match": true, - "firmware": "uefi", - "iso_sha256": "d506b10555b2c422eae181c09c106e495db5551b9317eeb975c56669a01961a2", - "network": "outbound-blocked", - "report": "/data/cybexos-efficiency-20261015-yCv5AF/qual-qualified-r6-upgrade/qualification.json", - "scenario": "encrypted-us", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "status": "passed" - }, - "final_web_service": { - "health": "healthy", - "image": "sha256:6ed4a27a6161daf0f2aacaa6ce9b8c89f0f566dc30f0fc269c0b55a017903712", - "image_bytes": 104205776, - "rebuild_restart": "completed", - "responses": [ - { - "bytes": 20588, - "path": "/", - "status": 200 - }, - { - "bytes": 90336, - "path": "/wallpapers/moon-mountains-1280.webp", - "status": 200 - }, - { - "bytes": 250438, - "path": "/wallpapers/moon-mountains-2560.webp", - "status": 200 - }, - { - "bytes": 678532, - "path": "/wallpapers/moon-mountains-5120.webp", - "status": 200 - } - ], - "status": "passed" - }, - "image_source_gate": "passed", - "image_unit_tests": { - "run": 518, - "skipped": 2, - "status": "passed" - }, - "initial_checkout_attempt": { - "checks": [ - "full-checkout-installation" - ], - "error": "Installed outcome capture failed: Traceback (most recent call last):\n File \"\", line 234, in \n File \"\", line 223, in main\n File \"\", line 87, in capture\n File \"\", line 43, in command\nRuntimeError: Installed outcome read failed: rpm -q --qf \u2026 alsa-sof-firmware\n", - "fedora_cloud_sha256": "28680fe5b371a5a82ebf43a31926e086a168e59949d03969c5093e7071f90b7f", - "scenario": "plain-us", - "scope": "Full checkout installation and graphical session on disposable QEMU Fedora Cloud; no physical hardware qualification", - "source_archive_sha256": "83ce60619c2dbe6577043b83cdbfa94d24d2593abdddbae4fa9bfa671f9211d2", - "source_content_sha256": "b41f64ba782ece5db5144bb29fdb776db9038bcde80d6cfd1a7aca9e87c1054e", - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c", - "status": "failed" - }, - "installer_javascript_tests": { - "run": 14, - "status": "passed" - }, - "new_iso_plain_us": { - "boot_entry": "install", - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_offline_rpm_sha256": null, - "candidate_rpm_sha256": null, - "checks": [ - "installation-only-session", - "graphical-installer", - "plain-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "plain-btrfs", - "password-login", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "sudo-requires-password", - "installed-outcomes-captured", - "saved-choice-reconfiguration", - "native-settings-lifecycle" - ], - "firmware": "uefi", - "iso_sha256": "fea4ac94242869d7e35f528e5fd40549cce87981ece02ce95d594d2901fb19e2", - "network": "outbound-blocked", - "report": "/data/cybexos-efficiency-20261015-yCv5AF/qual-qualified-r6-plain-us/qualification.json", - "scenario": "plain-us", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_content_sha256": "09120eb1c604e1c178a6c27b89894bb2d58746a5bf8798f733728cec2085441d", - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c", - "status": "passed" - }, - "optimized_artifacts": { - "checksums": "passed", - "iventoy_refresh_list_running_service": "passed", - "payload_credential_audit": { - "build_id": "20261015T183818Z-25fb4f79", - "matching_paths": [], - "regular_files_scanned": 65443, - "scope": "Exact existing GitHub credential bytes absent from assembled product payload; credential value never logged", - "status": "passed" - }, - "pxe_publication": "passed", - "sbom_iso_and_both_rpms_binding": "passed" - }, - "parity_correction": { - "changes": [ - "Reuse the ISO firmware/media baseline on generic hardware", - "Read ISO system selections, exclusions and desktop runtime requirements for checkout", - "Disable weak dependencies in Ansible and prerequisite/upstream RPM commands", - "Use the shared complete locale baseline", - "Resolve final codec providers before base/desktop transactions, avoiding nine orphaned dependencies", - "Select the ISO OpenCL dispatch provider in the first base transaction", - "Select graphical boot in shared policy; qualification asserts the installed result" - ], - "fixture_preparation": "Only the owned Fedora Cloud guest is updated to the current OS baseline, stripped of its explicitly reviewed preexisting Cloud packages and rebooted onto its current kernel before the public installer. Preparation is recorded in the checkout report.", - "rebuild": "20261015T183818Z-25fb4f79", - "regression": "Actual Ansible selection and boot-link fixtures, shared package reader tests, strict fresh/saved installed outcomes", - "status": "passed" - }, - "qml_lazy_popup": "passed actual headless Wayland lifecycle: creation, focus, retained drafts and view switching", - "resolved_harness_issues": [ - "Legacy desktop ISO boot did not start Anaconda after the installer-only default was added. Qualification now starts it when absent and preserves an already running installer; two regression cases pass.", - "Installed-console bootstrap now retries a visibly empty login prompt at most three times if the initial username input is lost during a VT switch; tests verify recovery and failure without sending a secret.", - "The network-blocked saved-choice ISO fixture invoked online reconfiguration, which attempted Fedora metadata downloads. It now uses the supported --offline policy mode; regression tests cover offline invocation and bounded redacted diagnostics.", - "Concurrent installer initialization can briefly lock keyboard selection. The browser driver now retries only the explicit busy response, with bounded attempts/deadline; four JavaScript regression cases cover recovery, exhaustion, other errors and an expired deadline." - ], - "reviewed_brave_keyring_difference": { - "comparison_policy_sha256": "382e0676809aadc531c0200051137fb736d5fc2ba44a3aa94e0728dd3eac7f95", - "decision": "Accept only these three full fingerprint/release identities as explained upstream-scriptlet differences. Preserve existing trust state and identical signed package payloads; unrecognized keys still fail. This does not enable any prerelease repository.", - "exact_checkout_only_rpm_identities": [ - "gpg-pubkey.(none)=56f49901ab19baf099a95a76c3de1dd4f661cdcb-63ab09ad", - "gpg-pubkey.(none)=8c1f16ab24df8f75c1cf56595929a141e0e87f1f-67d85d48", - "gpg-pubkey.(none)=b721e073b7ef8e56acc6b23ecbc67d2399225ccf-68908a61" - ], - "installed_product_payload_changed": false, - "iso_initial_chroot_evidence": "Running scriptlet: brave-keyring-1.19-1.noarch; /var/tmp/rpm-tmp.C7pbw1: line 1: timeout: command not found", - "package": "brave-keyring-1.19-1.noarch", - "package_prerelease_key_bytes_match_upstream_bundle": true, - "packaged_prerelease_keys_sha256": "1a96d8e20402915a6901faed67c4765250b5355742b3b3c9cea50837fd98701e", - "pinned_release_key_bundle_sha256": "bf978bd03e49f6006568d99f620763bf43a6455f6209dff791f52ebc6239f738", - "rpm_sha256": "f416dcf7f3c0c6c26c2c3c915e733ba2a93ae0cf21686b1bfc18fcae8907256b", - "signature_verification": "/data/cybexos-efficiency-20261015-yCv5AF/key-review/brave-keyring-1.19-1.noarch.rpm:\n Header V4 RSA/SHA512 Signature, key ID 20038257: OK\n Header SHA256 digest: OK\n Header SHA1 digest: OK\n Payload SHA256 digest: OK\n MD5 digest: OK", - "status": "reviewed", - "upstream_importer": "Imports every /etc/pki/rpm-gpg/RPM-GPG-KEY-brave* file; stable and prerelease key files are shipped by the same signed package.", - "upstream_postinstall": "timeout 10m sh -c \"while ! sh /usr/libexec/brave-key-updater >/dev/null 2>&1; do sleep 2; done\" &", - "verified_signer_fingerprint": "DBF1A116C220B8C7164F98230686B78420038257" - }, - "source_gate": "passed", - "source_snapshot": { - "build_id": "20261015T183818Z-25fb4f79", - "installed_product_unchanged_since_snapshot": true, - "post_snapshot_changes": [ - "docs/efficiency-review-2026-10-15.md", - "docs/installation-parity.md", - "docs/measurements/efficiency-2026-10-15.json", - "image/installed_outcomes.py", - "image/parity-exceptions.json", - "image/test_installed_outcomes.py" - ], - "source_content_sha256": "09120eb1c604e1c178a6c27b89894bb2d58746a5bf8798f733728cec2085441d", - "source_dirty": true, - "source_epoch": 1792063093, - "source_file_count": 1375, - "source_revision": "5ce58a3ec8cfa75164f60923d640bfae5cc7227c", - "subsequent_changes": "Documentation and qualification comparison tooling only; full source/image gates pass. Installed product inputs are unchanged; checkout installation uses the exact archived source. Comparison accepts only three explicitly reviewed Brave key identities and rejects family-wide key exceptions.", - "utc": "2026-10-15T18:38:19.004651+00:00" - }, - "source_unit_tests": { - "run": 1369, - "status": "passed" - }, - "status": "passed", - "upgrade_baseline": { - "iso": "CybexOS-Live-44-20261014T075500Z-8f671e4e.iso", - "scope": "Retained supported prior ISO; current split-RPM upgrade/recovery is tested again, not inferred from its old report.", - "sha256": "d506b10555b2c422eae181c09c106e495db5551b9317eeb975c56669a01961a2", - "source_revision": "b0a97b7256de3dca989b8a25b410ddd9c3f683dd" - }, - "web": "116 component tests, typecheck, lint, build and 2 Worker/asset tests passed; service rebuilt and healthy" - }, - "quickshell_two_output": { - "measurement": "Three alternating runs of two widget instances under headless Sway, fake quota process, software rendering. Ready signal measures QML construction, not complete desktop startup; RSS sampled after two seconds", - "runs": [ - { - "Pss": 92295168, - "Rss": 97222656, - "run": 1, - "startup_seconds": 0.4821630041114986, - "variant": "baseline" - }, - { - "Pss": 76975104, - "Rss": 80986112, - "run": 1, - "startup_seconds": 0.40476424200460315, - "variant": "optimized" - }, - { - "Pss": 77203456, - "Rss": 81158144, - "run": 2, - "startup_seconds": 0.40649795602075756, - "variant": "optimized" - }, - { - "Pss": 92192768, - "Rss": 97157120, - "run": 2, - "startup_seconds": 0.4617449170909822, - "variant": "baseline" - }, - { - "Pss": 92254208, - "Rss": 97222656, - "run": 3, - "startup_seconds": 0.4403403699398041, - "variant": "baseline" - }, - { - "Pss": 76684288, - "Rss": 80719872, - "run": 3, - "startup_seconds": 0.42707683402113616, - "variant": "optimized" - } - ] - }, - "reviewer": { - "model": "claude-fable-5-1", - "requested_effort": "max", - "scope": "Read-only verification of the initial audit; not a review of the final implementation" - }, - "rpm_payload_transaction": { - "duplicate_regular_file_owners": 0, - "monolithic_to_split_transaction": "passed (no scriptlets; not a boot/install qualification)", - "packages": { - "cybexos-desktop": { - "bytes": 29824379, - "files": 935 - }, - "cybexos-offline-apps": { - "bytes": 1641343352, - "files": 75284 - } - }, - "regular_files_covered": 65548, - "status": "passed" - }, - "rpms": { - "after_desktop_bytes": 29824379, - "after_offline_bytes": 1641343352, - "before_monolithic_bytes": 1739588415, - "measurement": "Actual same-base-revision RPM builds; total decrease 68,420,684 bytes. Shell update savings require reusing an unchanged offline package identity." - }, - "rust_docs": { - "archive_bytes": 24363664, - "files": 65926, - "installed_bytes": 739153743, - "seed_filesystem": "ZFS on thebeast; isolated documentation copy, not a complete installed login", - "seed_seconds": 8.577959778020158, - "sha256": "eef31ef06ee05903f3962a4861e38de8f2cbdfd023b057275d308e64d311c522", - "unpack_seconds": 9.30019044619985, - "url": "https://static.rust-lang.org/dist/2026-08-23/rust-docs-nightly-x86_64-unknown-linux-gnu.tar.xz" - }, - "sound": { - "measurement": "Real isolated PipeWire-Pulse, QML backend and pactl; first second excluded; real volume event at 5 seconds", - "runs": [ - { - "all_pactl_execs": 94, - "event_pactl_execs": 43, - "steady_pactl_execs": 40, - "steady_seconds": 4.000614839140326, - "variant": "baseline" - }, - { - "all_pactl_execs": 11, - "event_pactl_execs": 5, - "steady_pactl_execs": 0, - "steady_seconds": 4.000354326795787, - "variant": "optimized" - } - ] - }, - "source_build": { - "application": "Wayfreeze 0.2.1", - "initial_builder_setup_seconds": 84.725, - "measurement": "Forced same-source rebuild; baseline reinstalls DNF compilers each time; optimized reuses builder and Cargo caches; network variability included", - "revision": "59ac913ebb71920992c52dc1002535b733994397", - "runs": [ - { - "attempt": 1, - "exit": 0, - "seconds": 295.25670170318335, - "variant": "baseline" - }, - { - "attempt": 2, - "exit": 0, - "seconds": 97.66321819508448, - "variant": "baseline" - }, - { - "attempt": 3, - "exit": 0, - "seconds": 129.39154352108017, - "variant": "baseline" - }, - { - "attempt": 1, - "exit": 0, - "seconds": 2.8589652359951288, - "variant": "optimized" - }, - { - "attempt": 2, - "exit": 0, - "seconds": 2.4744310479145497, - "variant": "optimized" - }, - { - "attempt": 3, - "exit": 0, - "seconds": 2.7586984809022397, - "variant": "optimized" - } - ], - "unchanged_seconds": 0.00438 - }, - "web_clock": { - "after_unrelated_window_renders": 0, - "before_unrelated_window_renders": 20, - "measurement": "Paired React component test with identical clock ticks; not a browser CPU profile", - "seconds": 20 - }, - "web_image": { - "after_bytes": 104205923, - "after_node_modules_KiB": 20144, - "before_bytes": 803746314, - "before_node_modules_KiB": 498699, - "measurement": "docker image inspect Size; uncompressed image layers" - }, - "web_wallpaper": { - "browser_dpr": 2, - "browser_selected_bytes": 250438, - "original_bytes": 1838747, - "widths": { - "1280": 90336, - "2560": 250438, - "5120": 678532 - } - } -} diff --git a/docs/native-system-settings-research.md b/docs/native-system-settings-research.md deleted file mode 100644 index 894cf5ec..00000000 --- a/docs/native-system-settings-research.md +++ /dev/null @@ -1,194 +0,0 @@ -# Integrating system settings into CybexOS - -Research date: 2026-09-24. Repository baseline: `834c1f0`. -Status: research baseline. The recommendations below describe the repository -before implementation. See [the implemented settings integration](native-system-settings.md) -for the delivered behavior, validation and remaining coverage. - -**Recommendation.** Add Network, Sound and Online Accounts pages to the existing -CybexOS Settings window. Reuse the current shell services and let NetworkManager, -PipeWire/WirePlumber and GNOME Online Accounts (GOA) remain responsible for system -state. Keep GNOME Settings installed during the transition. Its removal is a -separate milestone after both shell and application entry points are covered. - -The first implementation should deliver Sound settings and shared navigation. -In parallel with that work in the roadmap, validate the Fedora-packaged standalone -GOA frontend as the account setup fallback. Full native account authentication -should not block improvements to everyday system controls. - -**What exists today.** Paths below are relative to -`roles/desktop/files/quickshell/` unless stated otherwise. - -| Area | Existing implementation | Remaining external dependency | -| --- | --- | --- | -| Network | `Common/WifiState.qml`, `Common/NetworkDetails.qml`, `scripts/network-tool.py`: radio/scanning, connect/disconnect/forget, hidden Wi-Fi, DNS presets/custom DNS, band selection, QR sharing and diagnostics | `Popovers/WifiPopover.qml` and `NetworkOverlayWindow.qml` launch `nm-connection-editor`, falling back to `gnome-control-center network` | -| Sound | `Common/Audio.qml`, `Popovers/Drawer/DrawerSound.qml`: output/input selection, mute/volume, microphone meter and playback application volumes; routing helpers move existing streams | The drawer launches `pavucontrol`, falling back to `gnome-control-center sound`; no general card profile/port editor | -| Online accounts | `Common/Calendar.qml` and `scripts/calendar-events.py` read calendars through GOA and Evolution Data Server (EDS) | `Calendar.manageAccounts()` opens GNOME Settings with a process-local `XDG_CURRENT_DESKTOP=GNOME` override | -| Settings window | `Settings/SettingsView.qml`, `Common/Settings.qml`, `Common/SettingsSearchData.js` already provide navigation, search, page routing and focus behavior | No dedicated Network, Sound or Online Accounts pages | - -On the inspected Fedora 44 workstation, `nm-connection-editor` is installed and -`pavucontrol` is absent. Neither is explicitly selected in the shared desktop -package task or desktop RPM. `pulseaudio-utils`, which supplies the already-used -`pactl`, is explicitly selected only by the XPS hardware role. Portable settings -must declare their dependencies rather than inherit the builder/workstation's -package history. - -ISO package changes need attention in two places: `image/cybexos-desktop.spec` -and `roles/desktop/tasks/main.yml`. `image/applications:package_names()` also -imports that desktop task's package list into the ISO application payload. -Changing the RPM dependency alone would therefore leave GNOME Settings in the -image. GNOME Calendar is already selected through that shared task. - -**Shared integration design.** Use three pages, tentatively `NetworkPage.qml`, -`SoundPage.qml` and `AccountsPage.qml`, within the existing Settings window. Add -their identifiers to the page allowlist, navigation, loaders and search index. -Route menubar actions to `Settings.showSetting()` so search and popovers reach -the same controls. Preserve the compact popovers for quick changes. - -Keep device/profile/account data in shared service models, outside `shell.json`. -That file should retain presentation preferences only. Network edits need a -draft, validation and explicit Apply/Cancel; sound sliders can apply live. -The Settings window's current global “Saved · applies live” status must not -claim that a pending system operation succeeded. Show operation-specific pending, -failed, applied and rollback states. Shell reset/undo must not erase system -connections or accounts. - -Use the existing Python/JSON helper pattern for capabilities unavailable in QML. -Pass structured input through stdin, use bounded requests and sanitized errors, -and correlate replies to the request/device that initiated them. Run as the -session user and let system services enforce authorization through their normal -polkit path. Start listeners on demand, share them across views and release them -when no consumer remains. Reuse current services before introducing a general -settings daemon or a second settings application. - -**Network: extend the working implementation.** The first page should expose -physical interfaces and saved profiles, connection status, autoconnect, metered -status, per-profile DNS, and IPv4/IPv6 automatic/manual configuration. Preserve -existing QR, band and diagnostics behavior. Current DNS presets modify multiple -saved physical profiles; present that as an explicitly broad operation and keep -it distinct from editing one connection. - -For profile editing, add a focused Python PyGObject/libnm adapter. The installed -`NM 1.0` namespace supports connection validation, `RemoteConnection.update2`, -profile version IDs and checkpoint operations. Preserve the full original -profile, change only supported fields and reject concurrent edits. NetworkManager -updates replace the supplied settings map, so constructing a partial profile -risks losing unrelated configuration. Ordinary settings reads exclude secrets; -preserving secret flags and stored credentials needs dedicated tests. -[Connection update contract](https://networkmanager.dev/docs/api/latest/gdbus-org.freedesktop.NetworkManager.Settings.Connection.html), -[libnm connection API](https://networkmanager.dev/docs/libnm/latest/NMRemoteConnection.html). - -For changes to an active connection, use a NetworkManager checkpoint with a -rollback timeout and a “Keep changes” action. This lets NetworkManager restore -connectivity if the UI closes or crashes. Verify saved-profile and runtime -restoration on Fedora 44; do not assume a successful helper exit proves both. -Keep the checkpoint limited to affected devices. -[Checkpoint API](https://networkmanager.dev/docs/api/latest/gdbus-org.freedesktop.NetworkManager.html). - -Keep `nm-connection-editor` as a clearly named advanced editor initially. VPN -plugins, bridges, VLANs, complex routes and enterprise certificate configuration -need more than a basic form. The current helper creates enterprise Wi-Fi with -PEAP/MSCHAPv2; this is not complete enterprise support. A new enterprise editor -must handle CA certificates and server identity validation before claiming that -coverage. Do not expand this first task into a full NetworkManager replacement. - -**Sound: the best first native page.** Reuse `Audio.qml` and factor the drawer's -device/application controls into shared components. Add hardware profile selection -(for example Bluetooth headset versus playback mode), available ports, channel -balance, per-application mute and explicit application routing. Keep existing XPS -speaker tuning behavior: its virtual output and physical volume-control sink are -intentionally different. - -The installed Quickshell build exposes writable channel volumes, stream nodes, -default devices and a peak monitor. Its installed QML type declarations do not -expose a `PwDevice` card-profile API. Upstream documentation from another version -must not be assumed to match this build. -[Quickshell node API](https://quickshell.org/docs/v0.2.1/types/Quickshell.Services.Pipewire/PwNode/). - -Use native QML bindings for existing controls. For card profiles, ports and -stream routing, a small helper using `pactl --format=json list ...` and explicit -argument arrays fits the existing PipeWire-Pulse stack. Refresh capabilities -after hotplug/profile changes and resolve current IDs before mutations; do not -persist transient numeric IDs. Subscribe to changes while the page is open. -Declare `pulseaudio-utils` in the shared package contract. `wpctl` offers profile -and route setters too, but mixing both backends for the same control would add -unnecessary state coordination. -[pactl reference](https://github.com/pulseaudio/pulseaudio/blob/v17.0/man/pactl.1.xml.in), -[WirePlumber control API](https://pipewire.pages.freedesktop.org/wireplumber/man/wpctl.html). - -Retain an explicitly packaged `pavucontrol` advanced fallback until the page -covers the functions users need. Replacing the GNOME fallback is useful even -before every advanced control is native. Do not add meters that keep recording -after the page closes, or rebuild the microphone meter for every monitor. - -**Accounts: native management, delegated authentication.** Keep GOA, EDS and -GNOME Keyring. A CybexOS account page can list provider/identity, show whether -attention is needed, toggle calendar integration and remove a local account -through GOA. Removal should explain that other applications sharing the account -are affected; it does not delete the remote Google account. - -Build its model using Python `gi.repository.Goa`/Gio and GOA object/property -notifications. Return only display metadata and action results to QML. Do not -read access tokens just to display account status or copy them into shell -configuration. The installed GI namespace exposes account properties through -GObject properties; generated C getter names are not all Python methods. -Use an asynchronous D-Bus Properties.Set with completion/error handling for -changes, and verify it against the installed version. -[GOA interface contract](https://github.com/GNOME/gnome-online-accounts/blob/3.58.1/data/dbus-interfaces.xml). - -Two API distinctions change the implementation plan: `Manager.AddAccount` -accepts credentials and account details; it is not a browser-sign-in command. -`EnsureCredentials` checks/refreshes existing credentials and can report expired -authorization; it is not an interactive reconnect dialog. -[AddAccount API](https://gnome.pages.gitlab.gnome.org/gnome-online-accounts/method.Manager.call_add_account.html), -[EnsureCredentials contract](https://github.com/GNOME/gnome-online-accounts/blob/3.58.1/data/dbus-interfaces.xml). - -| Authentication option | Assessment | -| --- | --- | -| Launch `gnome-online-accounts-gtk` | Best initial fallback. Fedora 44 packages the standalone GTK frontend with GOA, GTK4 and libadwaita dependencies; it does not list GNOME Settings as a dependency. Validate real Google sign-in/reconnect on Hyprland before switching. | -| Small CybexOS C/GTK4 helper around libgoa-backend | Best later integration if direct Add/Reconnect actions are needed. Host GOA's provider dialogs in a separate process; keep account overview and controls in QML. Requires building and maintaining a small version-sensitive native helper. | -| Implement a separate OAuth/calendar stack | Defer. It adds ownership of OAuth client setup, callbacks, refresh/revocation, credential storage and calendar integration for little immediate benefit. | - -Sources: [Fedora 44 standalone frontend](https://packages.fedoraproject.org/pkgs/gnome-online-accounts-gtk/gnome-online-accounts-gtk/fedora-44.html), -[XApp project](https://github.com/xapp-project/gnome-online-accounts-gtk), -[GOA provider API](https://github.com/GNOME/gnome-online-accounts/blob/3.58.1/src/goabackend/goaprovider.h), -[Google desktop OAuth requirements](https://developers.google.com/identity/protocols/oauth2/native-app). - -The GOA 3.58.1 provider API takes a `GtkWidget` parent for asynchronous Add, -Refresh and Show operations. A QML window cannot simply be passed as that GTK -parent. `GoaBackend` introspection is unavailable on the inspected host, while -`Goa` introspection is available. Therefore do not plan a pure Python/QML call to -the provider UI. The small compiled helper should own its GTK window, cancellation -and completion; browser/provider UI stays outside Quickshell. This is a researched -architecture, not a validated sign-in prototype. - -There is an application-level dependency beyond the shell: GNOME Calendar's -new-calendar and edit-calendar pages call its GNOME Settings launcher for online -accounts. Before removing `gnome-control-center`, adapt those entry points through -a maintained application change, replace the relevant application workflow, or -retain GNOME Settings. Do not install a fake `gnome-control-center` executable -or impersonate GNOME's settings D-Bus service. -[New-calendar entry point](https://github.com/GNOME/gnome-calendar/blob/gnome-50/src/gui/calendar-management/gcal-new-calendar-page.c), -[Existing-account entry point](https://github.com/GNOME/gnome-calendar/blob/gnome-50/src/gui/calendar-management/gcal-edit-calendar-page.c). - -**Delivery sequence and acceptance.** - -| Milestone | Deliverable | Evidence required | -| --- | --- | --- | -| 1. Shared routing and Sound | Native Sound page, reusable controls, explicit helper dependencies and standalone advanced fallback | Hotplug, Bluetooth profile changes, default and existing-stream routing, XPS filter preservation, microphone lifetime and backend failure tests | -| 2. Network profiles | Native common connection editor, per-profile DNS, checkpoints and advanced editor | DHCP/manual IPv4/IPv6, preservation of unknown settings/secrets, concurrent edits, polkit denial, rollback after lost UI, multiple adapters and unsupported profile handling | -| 3. Online Accounts | Native account overview/actions; standalone GOA frontend for Add/Reconnect | Existing-account compatibility, browser return/cancel, expired authorization, locked keyring/autologin, offline state, GOA restart and calendar enable/remove behavior | -| 4. Direct sign-in integration, if warranted | Small GOA provider-dialog helper | Build/runtime compatibility with target GOA, duplicate-window prevention, cancellation, focus/parenting, service failure and clean process exit | -| 5. Package removal | Remove explicit GNOME Settings dependencies only once remaining launchers are covered | Shell and application launcher audit, clean Fedora dependency resolution, installed package manifest and ISO first-boot verification | - -Extend existing audio, network, calendar and Settings tests around behavior and -failure cases. Run the repository checks before deployment. Future live shell -tests must use `tests/lib/quickshell-live` at start/end and leave the service-owned -Quickshell as the sole instance. Source checks cannot qualify OAuth sign-in, -hardware routing or a clean ISO installation; report those separately. - -Installed versions inspected: Quickshell -`0.2.1^git20260209.dacfa9d-5.fc44`, NetworkManager `1.56.1`, GOA `3.58.1`, -GNOME Settings `50.4`, WirePlumber `0.5.17`, PulseAudio utilities `17.0`. -These are workstation observations, not a future ISO manifest. Space savings -and the dependency closure after removal remain unmeasured. diff --git a/docs/native-system-settings.md b/docs/native-system-settings.md deleted file mode 100644 index c99a377c..00000000 --- a/docs/native-system-settings.md +++ /dev/null @@ -1,376 +0,0 @@ -# Native system settings - -CybexOS Settings includes Network, Sound, Displays, Keyboard, Touchpad, Region, -Online Accounts and Security. The -Wi-Fi popover, network details, sound drawer and calendar account action open -these pages. Settings search includes them too. The compact controls remain -available. - -## Delivered behavior - -| Page | Native controls | External support | -| --- | --- | --- | -| Network | A list of saved Ethernet/Wi-Fi profiles with their status, and the physical adapters; VPN and WireGuard profiles with a connect switch each, import of OpenVPN/WireGuard files and confirmed removal; a Wi-Fi hotspot with its name, password and join QR code; for the selected profile, autoconnect, metering, automatic/manual/disabled IPv4 and IPv6, address, gateway and DNS; edits wait in the page's Apply bar | `nm-connection-editor` for other VPN types and detailed VPN settings, enterprise certificates, bridges and advanced routing | -| Sound | Output and input device lists (network outputs folded), volume and mute, microphone level meter; ports of the default devices, output balance, hardware profiles, and per-application level, mute and playback/recording routing | `pavucontrol` for advanced controls | -| Online Accounts | Provider, identity and attention status; calendar enable/disable; confirmed local removal; administrator locks | `gnome-online-accounts-gtk` for provider setup, browser authentication and reconnection | -| Displays | Arrangement preview that also selects the display to edit, with drag and keyboard placement; per display: on/off, resolution, refresh rate, scale, rotation, flip, mirroring and adaptive sync; a 15-second trial before anything is saved | `~/.config/cybexos/hypr/user.lua` for monitor rules the page does not cover (bit depth, HDR, reserved areas) | -| Keyboard | Search installed XKB layouts and variants; add, remove and reorder up to four layouts; configure switching shortcuts and switch immediately | Personal `user.lua` rules retain final precedence | -| Touchpad | Tap to click, natural scroll direction, three-finger swipe between workspaces and pointer sensitivity, drafted behind Apply; existing scroll speed control applies immediately | Pointer sensitivity is Hyprland's shared mouse/touchpad setting; per-device rules remain available in `user.lua`. The swipe is a gesture, saved as `touchpad.workspaceSwipe` in `input.json`; a `user.lua` gesture on the same fingers replaces it | -| Region & formats | Search installed timezones and locales, then explicitly apply system timezone or language; existing clock and temperature format controls | Normal system Polkit authorization; additional locales require Fedora language packs | -| Security | Fingerprint reader and enrolled fingers, guided enrolment with live progress, confirmed removal, the system fingerprint switch; registered security keys, adding by touch, confirmed removal | fprintd's own Polkit authorization for enrolment; pkexec for the authselect switch; a terminal for keys that need a PIN to register ([details](security.md)) | - -GOA continues to own account authentication and credentials. The shell reads -metadata and never requests access tokens or passwords. Calendar data still -comes from Evolution Data Server. Disabling calendars or removing an account -affects other applications using that account on this computer; removal does not -delete the remote account. - -GNOME Settings remains installed for GNOME Calendar's own account-management -entry point. CybexOS's settings entry points no longer launch it or impersonate -a GNOME session. Removing GNOME Settings, replacing GNOME Calendar, replacing -the login manager and building a new ISO are separate work. - -## State and failure handling - -NetworkManager, PipeWire/WirePlumber and GOA own their settings. None of the new -device, connection or account data is stored in `shell.json`. Shell reset/undo -does not change these services. System pages show a status line only while -their first read is in flight and after a failure, which offers Refresh. -Network and Displays edits wait in an Apply bar pinned to the page foot. It -names the pending changes, or the problem that blocks them, and during a -trial counts down to the automatic restore with **Revert now** and **Keep -changes**. Sound changes apply at once. - -Network edits clone the full libnm connection and validate the result. Version -checks reject concurrent changes. Unedited settings, routes, secret flags, -stored Wi-Fi credentials and secondary addresses in DHCP profiles are preserved. -Unsupported IP methods remain accessible through the advanced editor. - -Applying an active profile creates a 60-second NetworkManager checkpoint and -changes only the in-memory profile. **Keep changes** saves the profile and ends -the checkpoint. **Revert now**, leaving the page or closing Settings restores the -previous settings. NetworkManager also owns the timeout if the UI or helper -exits. Inactive profiles save without activation. A trial requires user -confirmation of connectivity; activation alone does not prove internet access. - -The sound helper re-resolves capabilities before changing them. Application -streams require their PipeWire serial as well as their transient index, so an -index reused by another stream is rejected. Internal filter streams are excluded -from routing controls, and the XPS speaker filter retains its existing gain -policy. Hardware and stream rows keep stable identities during refreshes. - -`Common/SystemSettingsBackend.qml` owns one request channel per domain. Its -consumers acquire and release service monitors; monitors and snapshot readers -stop when no page needs them. Requests use bounded JSON through stdin with -explicit argument arrays, timeouts and sanitized errors. Network mutations use -the session user's normal NetworkManager authorization path. - -The shared desktop Ansible package task and `image/cybexos-desktop.spec` declare -the standalone editors, libnm, GLib tools and PulseAudio client utilities. The -image application payload consumes the shared task's package list too. - -## VPN and hotspot - -The Network page has a **VPN** group and a **Hotspot** group between the -connection list and the selected connection's settings. The drawer's Network -tab adds one compact VPN switch under Tailscale, but only when at least one -VPN profile exists: the connected or connecting profile, else the one used -most recently. Tailscale keeps its own row and is not a VPN profile here. - -Both surfaces use `scripts/network-tool.py` (`vpn` and `hotspot` requests, -JSON on stdin). The Settings page reaches it through the network settings -service (`system_settings_network.py`), so VPN and hotspot requests share the -page's one request channel and busy state. Their failures appear on the row -they concern rather than as the page's status line, and they never touch a -pending connection edit. - -### VPN - -- **List.** NetworkManager profiles of type `vpn` (OpenVPN, OpenConnect, - vpnc, SSH and other installed plugins) and `wireguard`, with their type and - state, from the same `nmcli connection show` the network snapshot already - runs. Naming each plugin costs one extra `nmcli` read on the Settings page. -- **Connect.** CybexOS runs no network applet, so nothing would answer - NetworkManager when a VPN needs a password. For one user-requested - activation, `scripts/network_vpn_agent.py` registers a secret agent on the - system bus, activates the profile from the same process (NetworkManager - asks that process's agent first), and answers only that profile's `vpn` - secret request by running the plugin's own sign-in dialog, the - `auth-dialog` from its `NetworkManager-*-gnome` package that nm-applet also - uses. Every other request gets `NoSecrets`, so another agent can still - answer it. Secrets go from the dialog straight into the D-Bus reply; they - are never logged, written to disk or passed to the shell, and `SaveSecrets` - is declined, so a profile keeps its own secret storage. The agent - unregisters when the activation succeeds, fails, is canceled or times out - (50 seconds). Failures are reported from NetworkManager's reason: a - canceled sign-in, a rejected login, a missing plugin, no network to run - over, and so on. -- **Import.** *Import VPN…* opens a file picker (the Settings folder dialog in - file mode, starting in Downloads) for an OpenVPN `.ovpn` or a WireGuard or - OpenVPN `.conf` file of at most 1 MiB. The helper only sniffs the format, - then runs `nmcli connection import type openvpn|wireguard file …`. - NetworkManager names a WireGuard profile and interface after the file, so a - file name that is not a valid interface name is imported through a - symlink with a valid name in a private temporary directory (the key is - never copied) and the profile is renamed after the file. Imported profiles - get `connection.autoconnect no`; NetworkManager starts an imported - WireGuard profile at once, so the import also takes it down. `nmcli`'s - diagnostics are classified, never shown, because a parser error can quote - a line of the file. -- **Remove.** *Remove…* asks on a second row; only the confirming press - sends `confirmed: true`, and the helper refuses a removal without it, or - for a profile that is not a VPN. -- **Advanced editing** stays in `nm-connection-editor`. - -### Hotspot - -The group appears only on a computer with a Wi-Fi adapter. It is one switch, -the network name and, while the hotspot is on, *Show password and QR code*. - -- The hotspot is one NetworkManager profile, `CybexOS hotspot` with a fixed - UUID, created on first use: access-point mode on 2.4 GHz, WPA2-PSK (CCMP, - PMF off for the widest client and driver support), `ipv4.method shared`, - `connection.zone nm-shared` and `connection.autoconnect no`. It never - starts at boot; the switch starts it again. -- The password is generated (16 characters from an alphabet without - look-alike characters, about 79 bits) and reaches `nmcli` only through - `passwd-file` on stdin, like a Wi-Fi password. NetworkManager stores it in - the profile on the first start and reuses it afterwards. The Settings page - never holds it: the join card, the network overlay's QR page, asks the - helper for the code and the password together and drops both when it - closes. Change the password in the advanced editor. -- The adapter is chosen to avoid dropping Wi-Fi: an idle second adapter that - can host an access point is preferred. NetworkManager runs one connection - per adapter, so on a single adapter the Wi-Fi connection drops while the - hotspot runs; the group says so before it is switched on. An adapter - without access-point support, or Wi-Fi switched off, disables the switch - with the reason. -- Renaming a running hotspot restarts it under the new name. - -### Firewall - -Shared mode runs NetworkManager's DHCP and DNS server (dnsmasq, which no -Fedora package pulls in, so the desktop package task and the RPM's -`Requires` both install it) for the clients and NAT for their traffic. NetworkManager puts a shared connection in firewalld's -`nm-shared` zone, which accepts DHCP, DNS and ICMP to this computer and -forwards client traffic; the strict `cybexos` zone (target `DROP`) would -refuse the clients' DHCP and DNS. The hotspot profile therefore names -`nm-shared` explicitly. The checkout installer's adoption of physical -profiles into the `cybexos` zone skips profiles whose `ipv4.method` is -`shared`, and the ISO never rebinds profiles, so both installation paths -leave the hotspot (and any other shared connection) in `nm-shared`. -`tests/verify-system` and the VM convergence check expect exactly that. The -`cybexos` zone template itself is unchanged. - -## Displays - -The Displays page lists every output Hyprland reports, including disabled -ones. The arrangement is drawn in Hyprland's layout coordinates. A dropped -display snaps to the nearest shared edge (at least 64 logical pixels long) and -never overlaps another. Changing a display's size keeps its neighbours on the -side they were on. The arrangement also picks the display the rows below -edit. Each display is a Tab stop: Enter or Space selects it, and arrow keys -place it beside the others. A display that is off or mirroring takes no room -in the layout and appears as a chip under it, so it can still be selected. -Scale presets are limited to values Hyprland keeps -for the chosen mode, that is multiples of 1/120 that divide the mode into -whole logical pixels. - -**Apply** in the page's Apply bar starts a trial: - -1. `scripts/display-settings.py` writes the candidate to - `$XDG_RUNTIME_DIR/cybexos/displays-trial.json`. -2. It arms a transient `systemd-run --user` timer - (`cybexos-display-trial-`), which restores the previous settings - after 20 seconds. -3. It runs `hyprctl eval 'require("displays").apply_trial(...)'`, so the - trial goes through the same Lua that loads the saved file at startup. - -If the timer cannot be armed, nothing is applied. If Hyprland rejects a rule, -the helper undoes the change immediately. - -The Apply bar counts down 15 seconds: - -- **Keep changes** saves the candidate atomically to - `~/.config/cybexos/displays.json` and disarms the timer. A confirm is - refused, and the change undone, when the saved file changed since the page - read it or when it arrives more than three seconds after the countdown. -- **Revert now**, Escape, the countdown ending or closing Settings reload - Hyprland, which rebuilds every rule from the unchanged files on disk. The - timer does the same if the shell is not running. - -The saved file is JSON owned by the user, keyed per physical monitor: - -- `desc:` plus Hyprland's make/model/serial description, so choices follow a - monitor across ports. -- The connector name when that description is empty or could match another - connected monitor, for example twin panels without serials. - -Entries for monitors that are not connected are kept. - -When a connected monitor moves from a connector key to its physical identity, -Apply merges the old entry into the physical entry and removes the connector -alias. This includes early files whose connector entries have no description. -Unknown fields survive, with the physical entry taking precedence. Keeping -both entries could silently undo an edit: `displays.lua` emits sorted keys, -and Hyprland's [monitor rule manager](https://github.com/hyprwm/Hyprland/blob/v0.56.2/src/config/shared/monitor/MonitorRuleManager.cpp) -chooses the last matching rule. A stale `eDP-1` entry therefore overrode the -new `desc:LG Display ...` position, scale and mode. Connector entries explicitly -describing other hardware are left intact. - -Live verification on Hyprland 0.56.2 reproduced an Apply requesting the -built-in display at `(0, 540)` while the stale connector rule kept it at -`(0, 0)`. With the alias migrated, Apply and Keep placed it at `(0, 540)` and -the layout survived a reload. A 160% / 60 Hz trial applied and reverted to -200% / 120 Hz; an unattended arrangement trial also restored through the -systemd watchdog. The repaired preferences work with the installed runtime. - -`roles/desktop/files/displays.lua` reads the file with a strict, size- and -depth-bounded JSON parser and never executes it: - -- It is loaded from `hyprland.lua` in a `pcall`, after the vendor `monitors` - module and before `~/.config/cybexos/hypr/user.lua`. -- A missing file changes nothing. An invalid one is ignored as a whole and - reported in `$XDG_RUNTIME_DIR/cybexos/displays-status.json`, and the page - shows that error. The vendor rules then stay in effect. -- Each saved rule starts from the vendor rules that match the same monitor, - which the vendor module records in `_G.__cybexos_vendor_monitor_rules`. This - keeps fields such as bitdepth, colour management or a vendor VRR policy - unless the user chose another value. -- Every field the page owns is written explicitly, so a trial never inherits - a previous trial's value. A reload retires the module's event subscriptions. -- Hyprland applies monitor rules last-first, so saved choices override vendor - rules and `user.lua` overrides both. - -A display turned off on this page records the displays that were on beside it -(`disabledWith`). It stays off only while one of those companions is -connected and on. `displays.lua` re-evaluates this on `monitor.added` and -`monitor.removed`, so undocking a laptop whose panel was turned off beside an -external monitor turns the panel back on. A display is only turned off while -a different one remains on. - -Not covered: per-dock profiles for different sets of monitors, HDR and -bit-depth controls, and custom modelines. Use `user.lua` for those. - -## Validation and maintenance - -### Keyboard, touchpad and system region - -Input preferences are user-owned data in -`$XDG_CONFIG_HOME/cybexos/input.json` (normally `~/.config/cybexos/input.json`). -Only edited fields are saved. Unknown top-level, section and unchanged-layout -metadata survive; version hashes reject concurrent writes. A malformed file or -symlink is left untouched and explained in Settings. The helper takes an -exclusive lock, atomically writes the new file, and reloads Hyprland. A rejected -reload restores the original bytes and reloads again; failure of that recovery -is explicitly reported. It never rewrites `user.lua`. - -The shared `input_preferences.lua` loader uses the existing bounded JSON -parser, validates all fields before applying any, and contains corrupt-file -errors so the compositor can start. It loads after the release's input defaults -and before `user.lua`. Therefore a personal override can intentionally supersede -a saved setting. Settings reads effective compositor values when refreshed. -Layout switching retains the release's compose/third-level options; selecting -Caps Lock as a switch replaces its Compose role. Switching to the next layout -changes the current session only, while the first saved layout is the sign-in -default. Shell reset/undo does not reset these input preferences. - -Region changes go through `org.freedesktop.timedate1.SetTimezone` and -`org.freedesktop.locale1.SetLocale` on the system bus, with normal interactive -Polkit authorization. No privileged helper, password capture or custom Polkit -exception is installed. Choices come from the installed timezone and locale -catalogs. Requests reject stale values, preserve existing `LC_*` overrides, -verify the resulting properties, and report authorization denial, cancellation -or service failure. Changing the system language sets `LANG`; applications use -it after signing out and back in. Separate timezone/language Apply actions -avoid a misleading partially successful combined change. Shell clock and -temperature formats remain independent personal settings. - -`tests/native-input-region.py` exercises the production helpers with isolated -preferences and mocked compositor/system buses, including failed reload -recovery, unknown-field preservation, stale writes, invalid inputs, symlinks, -real stdin/subprocess failure handling, authorization requests and preserved -locale categories. `tests/hyprland-input` executes the shipped Lua loader under -LuaJIT. `tests/qml/tst_input_draft.qml` exercises the pages' draft, reorder and -filter functions with Qt's JavaScript engine. These tests do not claim a live -authorization prompt, physical keyboard/touchpad or fresh-install validation. - -Both installation paths ship the same loader, pages and helpers, and use the -same XKB/timezone package resources. No installer applies these personal or -system region choices implicitly during reconfiguration. - -Run the normal repository gate before deployment: - -```sh -./tests/run -PYTHONDONTWRITEBYTECODE=1 python3 image/check-source -``` - -Additional integration checks: - -```sh -# Private mount/network/PID namespaces with a separate D-Bus and NetworkManager. -# Requires sudo and never edits the workstation's network profiles. -./tests/system-settings-network-namespace - -# After deploying through Ansible: uses only the managed quickshell.service. -./tests/system-settings-live -``` - -Display settings are covered without touching a live compositor: - -- `tests/display-settings.py` runs the helper against fake `hyprctl`, - `systemd-run` and `systemctl`. It covers trial, confirm, rollback, timer - expiry, rejected rules, a timer that cannot be armed, concurrent edits, and - symlinked or invalid saved files. -- `tests/hyprland-displays` runs `displays.lua` under LuaJIT with a recording - `hl` mock. -- `tests/displays/documents.json` holds valid and invalid documents that the - Lua and Python validators must agree on. -- `tests/quickshell/display-helpers.test.cjs` covers modes, scales, identities - and the arrangement. -- `tests/hyprland-features` loads the whole configuration tree with a - truncated and a hostile `displays.json`. - -The isolated NetworkManager test covers active in-memory edits, confirmation and -persistence, explicit and automatic rollback, expired confirmation rejection, -inactive-profile edits and saved Wi-Fi password preservation. Backend unit -tests also cover address validation, concurrent changes, typed D-Bus values, -capability changes, stale audio streams and account action guards. - -VPN and hotspot behaviour is covered without a real NetworkManager: - -- `tests/network-connections.py` runs `network-tool.py` and the settings - service with `nmcli` stubbed on `PATH`: parsing of terse `nmcli` output, - import argument construction (including the WireGuard symlink), file - validation, confirmed removal, hotspot profile creation with the password - only on stdin, adapter choice, and error mapping that never echoes - diagnostics. The secret agent runs against a fake NetworkManager on a - private D-Bus daemon with a fake plugin dialog: only the activating - profile's secrets are answered, a canceled dialog is reported as such, the - agent always unregisters and no secret reaches the helper's output. -- `tests/quickshell/network-vpn-hotspot.test.cjs` covers the helpers and the - structure of the drawer switch, the VPN and Hotspot groups, the join card - and the folder dialog's file mode; the real-engine lifecycle harness - constructs both groups against a hand-set snapshot. -- `image/test_installation_parity.py` runs the checkout's zone-adoption - script against a stubbed `nmcli` and checks that the ISO path never - rebinds profile zones. - -A real VPN sign-in, a hotspot client joining, and the hotspot staying off -after a reboot need hardware and are on the manual checklist -(`docs/shell-settings-manual-verification.md`). - -The managed live check opens and reopens all three pages, verifies their data -and monitor readiness, closes them and checks that readers/monitors stop. It -restores the previous Settings window state, verifies that the service owns the -sole Quickshell process, and checks the current journal for QML errors. -The `settings status` IPC method reports lifecycle flags without account or -device metadata. - -Validated on Fedora 44 on 2026-09-24: repository gate, image source checks, -isolated NetworkManager transactions, managed page lifecycle and visual layout. -The standalone GOA frontend opened its provider picker under Hyprland. Real -provider sign-in, account deletion, Bluetooth hardware profile switching and a -fresh ISO boot were not exercised. Those remain release acceptance checks; -the existing tests do not imply their completion. diff --git a/docs/native-titlebars.md b/docs/native-titlebars.md deleted file mode 100644 index b1b4972f..00000000 --- a/docs/native-titlebars.md +++ /dev/null @@ -1,190 +0,0 @@ -# Native Fedora titlebars - -The exact tested product revision is -`d2037af92cb3a47a08a9755e5996d2c4b0cae28d`; source/image gates, the exact-source -ISO/RPM build and testing PXE publication pass. All four ISO scenarios, both -English/Dutch checkout parity cases, pristine offline/native/recovery and scoped -native workspace/notification checks pass. The core aggregate and R27 evidence -audit pass within their recorded scopes; English was verified separately by -the parent. The product is **not fully qualified**: startup measurement is blocked; -first-frame acceptance remains open, and representative hardware and generic -Wayland CSD detection remain blocked. This documentation update is separate -from the tested installed-product artifacts. -Main integration is complete at `bee9d722021301875823e6285a6eef2896b283eb`; -the intervening CI-only fix 9974aaee changes no installed-product payload. -The titlebar artifacts and installed qualification remain bound to d2037af. - -The optional Floating layout titlebars are built from -`omacom/omarchy-hyprland-titlebars` tag `v0.2.2`, full commit -`379ff910fcce9f0cc9b41f9a87c527997076a417`. The authorized codeload archive -SHA-256 is `a453ae6b0a39f75e9a559a0258746e7df24a9432d5571ce505501cd0a333aa72`. -Inventory records all three values. No Arch binary is installed on Fedora. -The fixed library path is `/usr/lib64/cybexos/titlebars.so`; the native plugin -name and Lua namespace remain `hyprbars`. - -`roles/desktop/tasks/titlebars.yml` and `image/titlebars` use the same source -pin, patch, native installer and rebuild units. The image builder stages a -complete build before assembling the desktop RPM. `image/titlebars_payload.py` -rejects missing builds, changed source/recipe/patch, damaged binary, source -archive or license files. The image's desktop RPM must require the exact -Hyprland EVR recorded in `titlebars.json`, so repository changes between native -build and image composition cannot silently select another compositor build. -Every released Hyprland update therefore needs a rebuilt desktop RPM from the -same source revision as its image and qualification report. - -The native build requires CMake >=3.27, C++23, pkg-config `hyprland`, `libdrm`, -`libinput`, `libudev`, `pixman-1`, `wayland-server`, and `xkbcommon`. The Fedora -dependency task is also the image builder's dependency source. The upstream -`-fno-gnu-unique` option is retained for clean unload/reload; whole-library -hidden visibility is deliberately avoided because Hyprland shares inline state. -The original BSD-3-Clause `LICENSE` and Omarchy MIT `LICENSE.MIT` are installed -under `/usr/share/licenses/cybexos-titlebars/` on both paths. - -## ABI and update lifecycle - -`/usr/libexec/cybexos-titlebars-install` verifies identical installed Fedora -`hyprland` and `hyprland-devel` EVR/architecture, verifies their RPM-owned files, -and compares the headers' ABI string with the installed compositor's -`Hyprland --version` output. This version query does not start a compositor. -It records the binary digest, header version digest and GNU ELF build ID in -`/usr/lib64/cybexos/titlebars.json`, along with the verified source pin, recipe, -patch and output/license digests. Identical healthy builds converge offline; -damaged output or changed inputs require a rebuild. - -The patch embeds that exact compositor ELF build ID. During plugin -initialization it reads the **running process's mapped ELF notes**, so replacing -`/usr/bin/Hyprland` during an update cannot fool an old compositor into loading -a plugin built for the new executable. Missing or different build IDs fail -before assigning the plugin handle or creating plugin state/event hooks. -Upstream's source/library ABI hash -check remains in place too. The ELF guard also detects Fedora rebuilds that -retain the same source commit and upstream API hash. - -`cybexos-titlebars-build.path` watches the compositor, installed SDK identity and -managed recipe. `cybexos-titlebars-build.service` also checks at boot, rebuilding -from the checksum-verified cached source archive without network access. Builds -are locked, bounded to four compiler jobs and a 15-minute service timeout. -The service retries up to three times to cover an update transaction replacing -the binary and SDK separately. It never restarts the compositor or shell. -The native guard covers the interval before the asynchronous rebuild completes. - -A stale binary/manifest is removed before rebuilding. Missing headers, a failed -compiler, invalid source, or a compositor update during compilation leaves the -optional component unavailable instead of retaining a stale loadable plugin. -The next successful build restores it. Existing keyboard and mouse controls -remain available. A plugin already mapped by a running compositor stays mapped -until its normal unload/session end; no updater forces live desktop changes. - -Hyprland 0.56 Lua's `hl.plugin.load(path)` is a **configuration declaration**. -Keep it present on every config parse when the optional file exists, including -when the plugin is already in the native loaded registry. Omitting it on the -next parse removes a config-owned plugin, triggering another reload. Alternating -the declaration based on native loaded state causes recursive load/unload/reload -and starves IPC. Check `hl.get_loaded_plugins()` before invoking native options -and functions; use `enabled=false` to hide decorations outside Floating layouts. -An optional plugin can remain loaded while drawing no bars. - -To inspect a failed rebuild, use -`journalctl -u cybexos-titlebars-build.service`. After repairing the matching SDK -or restoring cached source through checkout convergence, use -`sudo systemctl reset-failed cybexos-titlebars-build.service` and -`sudo systemctl start cybexos-titlebars-build.service`. The cached archive and -patch live in `/usr/share/cybexos/titlebars/`. Uninstall removes only these -managed files, the helper/units, build cache and fixed plugin/manifest/licenses; -user titlebar settings and independently installed plugins are user-owned. - -## Theme API and client decorations - -The CybexOS patch adds `hl.plugin.hyprbars.clear_buttons()`. It clears the native -button vector, marks every existing bar's buttons dirty and damages the bars. -The compositor theme hook can clear then add exactly three themed buttons -without accumulating duplicate hit targets or retaining old icon textures. -Upstream `add_button`, `workspace_tag` and explicit `hyprbars:no_bar` rules keep -their existing behavior. - -**Generic automatic Wayland CSD detection is blocked on the inspected Hyprland -0.56.2 API.** `CWindow` exposes X11 border hints, but no negotiated per-window -Wayland decoration mode. `CXDGDecorationProtocol` keeps its toplevel-decoration -map private. The pinned `XDGDecoration.cpp` always configures server-side mode, -including when a client requests client-side mode. Clients such as GTK and -libadwaita applications can draw their own titlebars without using that protocol -at all. Absence of an xdg-decoration object consequently proves neither that a -client has a titlebar nor that it needs a server titlebar. - -Use explicit `hyprbars:no_bar` class rules for applications known to draw CSD, -for example exact `org.gnome.Nautilus` and `org.gnome.TextEditor` classes. -Confirm the actual class with `hyprctl clients` before adding an exclusion; -Firefox and Electron classes/settings vary. Keep these exclusions in the shared -compositor configuration on both installation paths. This is an explicit class -policy, not automatic CSD detection. Native `decorate=false`, explicit no-bar -rules, upstream X11 border opt-outs and missing-component fallback remain -supported. A future automatic solution needs a supported compositor API or -protocol-aware compositor patch and real mixed-client qualification. - -## Validation and scope - -The native source pin and qualified Fedora compositor identity are unchanged: -Hyprland `0.56.2-2.fc44.x86_64` plus matching `hyprland-devel`, source -`efb50993780079460b0cbed1363e2166a2de1d9f`, executable ELF build ID -`04b20094d13879e70824418ba363f8dcc3253b34`. The exact d2037af ISO/RPM set is -built and published for testing; artifact paths, sizes, hashes and source -provenance are in [desktop validation](desktop-validation.md). - -The exact pristine-upgrade fixture passed forced native compilation, one-line -unchanged convergence, candidate boot/runtime snapshot and recovery boot/restore. `native-offline-rebuild.json` in the recorded offline-upgrade evidence -binds the converged plugin SHA-256 -`1142347ed31dcfb67733602b8ec6d88733131e0f8140ad8c3f50c870ef24e878`. -`native-candidate-runtime.json` confirms exact clean d2037af provenance, loaded -hyprbars, Scrolling/titlebar capabilities, empty error and a Floating workspace. -The separate exact-source native workspace report now passes renamed/numbered -opening, named/manual group provenance, original-owner closure, reload and -restoration. Those stable observations do not establish first-frame behavior -or requalify titlebar pointer controls on this artifact. - -Historical isolated native tests passed compilation/offline convergence, -load/capability discovery, stable declarations/reloads, themed clear/re-add, -pointer drag/maximize/minimize/close and an actually recompiled wrong-build-ID -rejection. Those results retain their earlier product scope. Detailed native -failure/correction evidence, helper reviews, gate skips and resource history -are centralized in validation and the ledger. - -`python3 tests/titlebars.py` covers CMake/C++ fixture builds, offline convergence -and repair, same-source Fedora rebuilds, checksum/traversal rejection, -compiler/SDK failures and concurrent updates. A release-optimized native probe -checks the running-executable ELF guard and unchanged handle state after -rejection. These and payload-parity/admission checks establish local contracts; -they do not complete installed-session or mixed-client/hardware qualification. - -| Exact d2037af acceptance | Current status | -| --- | --- | -| ISO/RPM build and testing PXE publication | Passed; exact identities and publisher checks in validation | -| Installed forced native build/offline unchanged convergence, boot and recovery | Passed within the exact pristine-upgrade fixture; runtime evidence is the limited plugin/capability/Floating snapshot described above | -| Renamed opening and named/manual group lifecycle | Passed within the exact plain-US native fixture; stable observations, not first-frame/configure proof or a new titlebar pointer test | -| Settings pixels and remaining interaction coverage | Recorded encrypted-US settings/empty-overview frame review passed within scope; unsampled interactions, assistive technology and first-frame behavior remain unqualified | -| Generic negotiated Wayland CSD detection | Blocked on a supported qualified compositor API and mixed-client tests | -| Physical lid/dock/mirror/two-external/scaling placement | Blocked on representative hardware | - -The exact-class no-bar policy and keyboard/overview fallback controls remain -supported. First-frame, new-artifact pointer and unavailable hardware/CSD -acceptance remain open; general duplicate-decoration avoidance must not be -advertised as complete. [PR #21](https://github.com/DigitalPals/CybexOS/pull/21) -merged at bee9d722 on `2026-10-21T14:01:19Z` after both required hosted checks -succeeded; origin/main was verified at that merge. The missing-`patch` CI -failure, exact sole-path correction and clean R28 review are recorded in -validation/ledger, separately from d2037af artifact qualification. Startup/pixel/performance measurement is **BLOCKED** after four failed attempts, -zero accepted samples/comparison. R11 is scoped inert tooling review only. -Warm04 passed root-policy/reconciliation validation but its Welcome frame -showed black background/pointer only; null review response sent no input. -Failure preceded SSH fixture/warm-up; cause unknown, no product-regression proof. -Warm02's cause and warm03's exact offending bytes remain unknown. All leases -closed/empty. No further VM series is planned; any future benchmark needs -visibly verified Welcome/portals, a reviewed retarget and eight fresh equivalent -boots with current prerequisites. Detailed identities and guards are in validation. -Validation records R30 scoped retention acceptance, both independently verified -R29 repairs and completed teardown: all 53 recorded leases closed, MAIN absent, -no temporary VM/container retained, shared services untouched. Baseline/intermediate -payloads were removed; final testing deliverables and bounded evidence remain. -Original evidence paths are historical identities resolved through durable maps; -R30 is not runtime/release acceptance. Its historical-source limits and cleanup-time -inventory snapshot are documented in validation. -Active user desktop rollout/restart/reboot and public release remain unauthorized. diff --git a/docs/nvidia.md b/docs/nvidia.md deleted file mode 100644 index 694bc52d..00000000 --- a/docs/nvidia.md +++ /dev/null @@ -1,119 +0,0 @@ -# NVIDIA graphics - -CybexOS installs NVIDIA's proprietary driver from RPM Fusion automatically -when it finds an NVIDIA GPU. Machines without one are left alone. The driver -is never bundled in the ISO: it is built for the installed kernel after -installation, so the open-source `nouveau` driver runs the desktop until the -first restart with the NVIDIA driver. - -## What happens - -Both installation paths run the same `nvidia` role: - -- a source checkout runs it from `site.yml` (after the applications role, - `--tags nvidia` or `--tags hardware`); -- an ISO installation runs it from `cybexos-hardware-setup.timer` once the - network is up after boot, and again after every kernel change, through - `/usr/libexec/cybexos-configure-installed --hardware`. - -The role: - -1. detects display controllers (PCI class `03xx`) with vendor `10de` from - sysfs. A laptop that also has an Intel or AMD GPU is treated as hybrid; -2. installs `akmods` and `mokutil`, and creates the akmods signing key before - anything is built, so the module is signed for Secure Boot; -3. enables RPM Fusion free and nonfree with the applications role's - fingerprint-checked keys (`roles/apps/tasks/rpmfusion.yml`); -4. installs `akmod-nvidia`, `xorg-x11-drv-nvidia-cuda-libs` (NVDEC/NVENC for - FFmpeg and OBS) and `libva-nvidia-driver` (Fedora's name for - `nvidia-vaapi-driver`, VA-API decoding for browsers and mpv), plus the - running kernel's `kernel-devel` when it is still available; -5. builds the module for the running kernel with `akmods` and verifies it with - `modinfo`. `akmods` waits for the package's own background build; -6. with Secure Boot enabled, queues the akmods key for enrollment (below); -7. records the outcome in `/var/lib/cybexos/nvidia-status.json`. - -A failure in any step is recorded, shown, and never fails the rest of the -installation. RPM Fusion's `nvidia-fallback.service` loads `nouveau` whenever -the NVIDIA module is missing at boot, so a failed build, an unenrolled key or -a new kernel without a module still starts the desktop. - -Kernel mode setting: driver 560 and later enable `nvidia-drm.modeset` and -`nvidia-drm.fbdev` by default (the current RPM Fusion driver for Fedora 44 is -615). When the loaded driver reports either as off, the role adds -`nvidia-drm.modeset=1 nvidia-drm.fbdev=1` with `grubby` and asks for a -restart. An explicit `nvidia-drm.modeset=0` on the kernel command line is left -as it is. - -## Secure Boot - -CybexOS never disables Secure Boot. When it is enabled and the akmods key -(`/etc/pki/akmods/certs/public_key.der`) is not enrolled yet, the role runs -`mokutil --import` with a one-time password of eight digits (the MOK manager -uses a US keymap; digits are typed the same on every common layout). The -password is kept in `/var/lib/cybexos/nvidia-mok.json`, readable by root and -the `wheel` group only, and removed once the key is enrolled. - -To finish, restart. At the blue **MOK management** screen choose -**Enroll MOK**, **Continue**, **Yes**, type the password, then **Reboot**. -The password is shown - -- in a desktop notification, once per boot, until the driver runs; -- in the welcome window's setup list on ISO installations; -- by `sudo cat /var/lib/cybexos/nvidia-mok.json`. - -If the MOK screen times out, the request lapses and the open-source driver -stays in use. The boot-time setup does not ask again on every restart; request -it again with `cybex repair --hardware` (ISO) or -`cybex install -- --tags nvidia` (source checkout). The same key signs the -XPS camera modules, so one enrollment covers both. - -## Hybrid laptops - -The integrated GPU (the firmware's boot display device) stays the renderer -for Hyprland and applications. `switcheroo-control` is enabled, so launchers -can start an application on the NVIDIA GPU. Only `NVD_BACKEND=direct` is set -for the session; the NVIDIA-only variables below would move rendering to the -dGPU and break VA-API on the integrated GPU. - -## Session variables - -The role writes `/etc/cybexos/nvidia-session.env`. Both session wrappers -(`hyprland-quickshell`) read it as data, accept only the names below, and -apply it only while `nvidia_drm` is loaded, so a boot that fell back to -`nouveau` never inherits NVIDIA settings. The variables are exported to -Hyprland and imported into the user manager for the shell and its launches. - -| Variable | NVIDIA-only desktop | Hybrid laptop | -| --- | --- | --- | -| `LIBVA_DRIVER_NAME` | `nvidia` | not set | -| `__GLX_VENDOR_LIBRARY_NAME` | `nvidia` | not set | -| `NVD_BACKEND` | `direct` | `direct` | - -`GBM_BACKEND` is not set: Mesa selects the NVIDIA GBM backend from the DRM -driver name, and forcing it is a known cause of browser crashes. - -## Status and troubleshooting - -| State | Meaning | -| --- | --- | -| `ready` | The NVIDIA driver is loaded for the running kernel. | -| `reboot` | The driver is built; restart to switch from `nouveau`. | -| `enroll` | Secure Boot key enrollment is pending (or was skipped). | -| `failed` | Packages, the build or the key request failed; `reason` says why. | - -- `cybex doctor` reports an `nvidia` check on ISO installations; - `cybex verify` does on source checkouts. Neither prints the password. -- `/usr/local/libexec/cybexos-nvidia status` prints the recorded outcome, - corrected for a restart since it was written. -- Build logs: `/var/cache/akmods/nvidia/*-for-$(uname -r).failed.log`. -- Boot logs: `journalctl -b -u akmods.service -u nvidia-fallback.service` - and, on ISO installations, `journalctl -b -u cybexos-hardware-setup.service`. -- `cybex report` collects these into a redacted bundle for a bug report. - -Uninstalling a source checkout removes the helper and the session variables -but keeps the driver and the akmods key: the machine's graphics depend on -them. Remove `akmod-nvidia` with `dnf` if you no longer want the driver. - -Physical NVIDIA hardware, hybrid switching and the MOK enrollment screen are -not covered by the VM qualification; verify them on real machines. diff --git a/docs/omarchy-plugin-compatibility.md b/docs/omarchy-plugin-compatibility.md index dcf4a97d..bffca1f4 100644 --- a/docs/omarchy-plugin-compatibility.md +++ b/docs/omarchy-plugin-compatibility.md @@ -11,13 +11,11 @@ The implementation targets Omarchy commit inspected on 2026-09-19. Its `schemaVersion: 1` describes manifest structure; it does not freeze the host API across future upstream releases. -## Parity assessment and implementation (2026-09-19) +## Compatibility assessment -**The main management and runtime gaps are now implemented; compatibility is -still qualified by the limits below.** The preceding audit used a fresh -`git ls-remote` and upstream checkout to confirm that the pinned commit above is -still Omarchy's default-branch HEAD. This comparison does not cover its separate -development branch. +The implemented contract targets the exact revision above. Future Omarchy +changes require an explicit API comparison and runtime qualification; a moving +default branch is not the compatibility boundary. | Capability | Assessment against that upstream revision | | --- | --- | @@ -47,14 +45,9 @@ Comparison sources at the pinned revision: [management menu](https://github.com/omacom/omarchy/blob/60663faf8764253646f1d6166e864b608d4a0fa1/bin/omarchy-menu-plugin), and [service proxies](https://github.com/omacom/omarchy/blob/60663faf8764253646f1d6166e864b608d4a0fa1/shell/services/PluginFirstPartyServiceApi.qml). -Audit verification: `node --test tests/quickshell/*.test.cjs` completed with -736 passes, two skips (installed Qt enum checks unavailable on the Debian host), -and no failures. `tests/user-plugins.py` passed in a disposable Fedora 44 -container with Quickshell and two headless Sway outputs, including runtime -replacement/rollback and the Omarchy entrypoint suite. Eight Omarchy fixture -files matched current upstream HEAD byte-for-byte; both Pomodoro fixture files -matched their recorded commit. This audit did not repeat the full `tests/run` -pipeline or physical desktop qualification. That audit changed only this document; the implementation below adds code and regression tests. +Current NixOS validation and its limits are recorded in +[the validation record](validation.md). Historical source counts do not qualify +the current installed desktop. ## Install and configure @@ -107,7 +100,6 @@ reloads at once, and `cybex plugin reload` requests an immediate scan through the running shell. `cybex plugin restart` explicitly restarts the managed `quickshell.service`. Disable with `cybex plugin disable `; files and preferences remain. -Deploy the updated Quickshell role before using this on an older desktop. Manage packages in **Settings → Omarchy plugins**, or from the CLI. The page links the community directory at ; its Source @@ -191,11 +183,6 @@ but the scripts share their state and saved keys (`~/.local/state/omarchy/`, `~/.config/omarchy/model-usage/`), so re-enter only the source and server settings. -The first built-in usage widget, retired in settings schema 25, left a quota -cache (`~/.cache/quickshell/model-usage.json`) that deployment deletes. Proxy -management keys it saved under `~/.local/state/quickshell/model-usage-*.key` -are left in place; delete them yourself if you no longer need them. - ## Host contract | Area | Implemented behavior | @@ -256,7 +243,7 @@ plugins that create their own windows control their own placement. Native surfaces and Omarchy plugins share the font, logical-pixel sizing, accessibility scale, spacing density, border and corner settings described in -[Shell appearance](shell-appearance.md). Appearance settings apply individually; +[Shell typography](shell-typography.md). Appearance settings apply individually; there are no appearance presets. The defaults use JetBrainsMono Nerd Font at 14px, standard spacing, no panel @@ -297,7 +284,7 @@ retain their status indications. - No automatic emulation of Arch packages, Omarchy's general command suite, Hyprland scripts, system paths, or external backends. Plugins depending on - those still need their dependencies installed or a Fedora-specific adapter. + those still need their dependencies installed or a NixOS-specific adapter. - Authentication, lock-screen/polkit services, and privileged first-party host capabilities are not supplied. Cross-plugin private service lookup is unavailable; full bars receive only the four narrow service adapters. The compatibility theme's lock/polkit colors do not implement @@ -334,7 +321,7 @@ retain their status indications. - Mihoro's network backend and arbitrary marketplace packages have not been qualified. The fixture results must not be generalized to every plugin. -The [vendored source notes](../roles/desktop/files/quickshell/compat/omarchy/README.md) +The [vendored source notes](../desktop/quickshell/compat/omarchy/README.md) describe upstream provenance and modifications. MIT notices accompany the runtime components and immutable test fixtures. @@ -361,29 +348,13 @@ engine checks if another `qs` is active. Task-owned engines, compositors, sockets and user roots are cleaned up. Static QML lint covers the whole shared UI kit; not every form control, media backend or gesture has runtime coverage. -Validation runs in Fedora 44 containers on a Debian build server, which has no managed -Quickshell desktop service. No persistent user plugins are installed by the -tests. A physical Hyprland session, real audio/notifications, and pointer/grab -behavior still need live qualification under the repository's sole-PID and -journal checks. Headless validation does not establish universal or future -version compatibility. - -The earlier runtime implementation passed all 16 `tests/run` source stages, including 738 JavaScript tests and -242 QML files (no lint errors; six nonfatal upstream warnings). The final -active-bar geometry change was rechecked with QML lint and the complete -real-engine plugin suite. The container and downloaded upstream sources used -for this validation were disposable; no persistent desktop deployment was made. - -Implementation verification adds `tests/plugin-packages.py` for Git installation, -preview, valid/invalid updates, dirty-checkout protection, clone restoration, -built-in imports, removal retention and snapshot identity. The Quickshell suite -also loads the Plugins settings page, exercises added IPC calls and first-party -adapter scope, and edits a relative JS import while both outputs are running to -verify UI reload and retained service identity/state. QML lint covers the new UI. -Physical audio/idle/nightlight behavior is not qualified by the headless fixture. - -For this management/reload change, validation passed with 736 JavaScript tests -(two host Qt-enum skips), all 244 QML files linted, the extended two-output engine -suite, package transaction tests, both deployment fixtures, Ruff and ShellCheck. -The full 16-stage source gate was not repeated. No persistent desktop deployment -was possible on the Debian build server, which has no installed Quickshell. +The current port's real-engine plugin suite passed in an isolated Nix build +container on a Debian host. No persistent user plugins or workstation desktop +were changed. Physical Hyprland, audio/notifications, and pointer/grab behavior +still need qualification under the repository's sole-PID and journal checks. +Headless validation does not establish universal or future version compatibility. + +`tests/plugin-packages.py` separately checks Git installation, valid/invalid +updates, dirty-checkout protection, clone restoration, built-in imports, +removal retention and snapshot identity. Current source and installation evidence +is tracked in [validation](validation.md). diff --git a/docs/operations.md b/docs/operations.md deleted file mode 100644 index e8ab05ca..00000000 --- a/docs/operations.md +++ /dev/null @@ -1,1045 +0,0 @@ -# Operating and diagnosing this configuration - -This is the authoritative operator contract for repository commands, Ansible -tags, source checks, update logs, and deliberate reboots. The short command -table in the README links here instead of duplicating these details. - -## Command behavior - -| Command | Behavior | -| --- | --- | -| `./install` | Detects machine defaults, asks first-run questions, saves `/etc/cybexos/config.yml`, installs `ansible-core` when needed, and applies `site.yml`. Later runs reuse the saved answers. | -| `./bootstrap` | Compatibility alias for `./install`. | -| `./tests/run` | Runs all required source-tree checks without inspecting or changing the live machine. | -| `./verify` | Runs complete source and non-destructive installed-system checks. Use `--source`, `--system`, or `--quick` for a narrower scope and `--json` for automation. | -| `./update` | Resolves and verifies the selected GitHub release channel without a GitHub login, applies a newer compatible release, then starts the durable Fedora/Flatpak worker. With no published release it only updates packages and succeeds; if a release cannot be checked, verified, or staged, it still updates packages and exits 69. | -| `./update --system-only` | Skips the project release check and updates Fedora packages and system Flatpaks only. | -| `cybex agent` | Launches the selected AI coding agent in the current directory; an unset interactive session opens the picker. | -| `cybex agent --pick` | Selects, persists, and launches an installed OpenCode, Claude Code, or Codex CLI. | -| `cybex dev enable PATH` | Selects a validated checkout for live desktop source without changing it. | -| `cybex dev status` | Reports the active development or vendor runtime. | -| `cybex dev disable` | Returns desktop components to the verified vendor runtime. | -| `cybex backup setup local PATH` / `setup sftp HOST [PATH]` | Sets up encrypted personal-file backups and prints the recovery key once; see [Personal-file backups](#personal-file-backups). | -| `cybex backup now` / `status` / `restore` | Backs up now, reports the last and next backup, or restores into a new folder under `~/Restored`. | -| `./uninstall` | Removes CybexOS-owned services and configuration, restores first-adoption backups, and retains installed applications. Pass `--keep-user-data` to retain backup/updater state after restoration. | -| `cybex factory-reset` | ISO installations only: after a typed confirmation and sudo, restores the factory recovery point; the next start removes every account, home folder and other recovery point and opens the new-owner setup. A checkout installation prints that it is unavailable. See [Factory reset](#factory-reset). | - -`./install` and `./uninstall` pass `--ask-become-pass` to Ansible unless sudo -works without a terminal. Ansible's workers detach from the terminal, so the -ticket that `sudo -v` caches there cannot authorize their privileged tasks. -Both take the durable updater's lock before changing the machine and exit -with status 75 while an update is running, so a playbook never interleaves -with an update's DNF transaction or configuration run. - -For repository development, run Ansible directly after the source gate. The -saved installer configuration is deliberately supplied explicitly: - -```bash -./tests/run -ansible-playbook site.yml -e @/etc/cybexos/config.yml --check --diff -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags desktop,dotfiles -``` - -`ansible.cfg` pipelines modules to the Python interpreter instead of writing -a temporary file for every task, and sets `force_handlers`. A handler notified -before a later task failed (an initramfs rebuild, a daemon-reload, a service -restart) therefore still runs; a retry would see its triggering task as -unchanged and never notify it again. - -The public release updater applies a candidate through the lower-level durable -worker with `--full --skip-tests --repo `. That internal path -always supplies `--skip-tags boot`; public updates therefore do not rebuild -Plymouth or initramfs unexpectedly. Apply reviewed boot-role changes directly -with Ansible, then allow its handler to finish before rebooting. - -New installations select the Cybex Plymouth theme, including the LUKS unlock -screen. To apply the boot theme and Fish defaults to an existing installation -after running the source gate: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags boot,shell-defaults -``` - -The `shell-defaults` tag requires the existing installation's Fish configuration -directory. New Fish shells launch `codex` with -`--dangerously-bypass-approvals-and-sandbox` (YOLO: no approval prompts or sandbox). -Use `command codex` to bypass that alias. This terminal default does not change -the `cybex agent` dispatcher or the user's Codex configuration. - -`./verify --help` is the authoritative verification interface. Scope flags are -mutually exclusive, unknown arguments fail before any check runs, and -`--require-hyprland` is accepted only when system checks are in scope. -The installed `cybex verify` and `doctor` commands default to -`--system`; pass `--source` explicitly when the developer lint toolchain is -installed. - -### Reporting a problem - -`cybex report` collects `cybex doctor --json`, versions, a hardware summary, -shell health, failed units and recent journal excerpts (Quickshell, Hyprland, -SDDM, the update worker, hardware setup and akmods) into -`cybexos-report-.tar.gz` in the download folder, readable only by -you. Before writing, it replaces user and real names, hostnames, home paths, -MAC and IP addresses, serial numbers and UUIDs, Wi-Fi network names, tokens, -keys, passwords and email addresses with placeholders such as `[user]` or -`[token]`, and prints what it collected and how much it removed. It then -offers to open a GitHub bug report with a short, prefilled environment -section; it never uploads anything, so attach the archive yourself after -looking through it. `--no-browser` skips the question, `--json` prints a -machine-readable summary without asking, and `--output PATH` chooses a -directory or a `.tar.gz`, `.tgz` or `.tar.zst` file. The default is gzip -because GitHub does not accept `.zst` attachments. Redaction is pattern-based: -review the files before sharing them. - -The default-agent dispatcher stores only an allowlisted command name under -`$XDG_CONFIG_HOME/cybexos/defaults/agent`, or -`~/.config/cybexos/defaults/agent` when `XDG_CONFIG_HOME` is unset. It -changes that file atomically only after confirming the selected command -exists. Agent login tokens, API keys, models, permissions, and configuration -are deliberately not copied into CybexOS. A desktop launch begins in -`~/Code`; an invocation from an existing terminal keeps its working directory. -Use `cybex agent unset` to clear the preference. - -The development-source switch stores one canonical path at -`~/.config/cybexos/dev-source`. The resolver accepts only a user-owned, -non-world-writable Git worktree with the required desktop sources. Enabling or -disabling it restarts the managed Quickshell service and reloads Hyprland when -one is active. Rendered machine-specific Hyprland modules still come from the -installed vendor runtime. See [the ownership architecture](architecture/ownership.md). - -## Supported Ansible tags - -The definitive list for the current checkout is generated by Ansible: - -```bash -ansible-playbook site.yml --list-tags -``` - -The stable role boundaries are `base`, `desktop`, `apps`, `xps-2026` and -`nvidia` (both also `hardware`), `dotfiles`, `private-hooks`, `boot`, and -`finalize`. Narrow tags -currently exist for `browser`, `onepassword`, `fonts`, `font-defaults`, `packages`, `quickshell`, -`quickshell-lint`, `shell-defaults`, `user-tools`, `camera`, `fingerprint`, -`speaker`, `touchpad`, and `display`. The `display` tag applies the measured -XPS panel self-refresh quirk; see [hardware notes](xps-2026-hardware.md#internal-panel-black-flashes). The -narrow tags are development tools, not independent installation profiles; -their prerequisites can live in an earlier role. - -The `quickshell` tag compares the deployed shell tree with the managed sources -in one read-only pass and writes only the files that differ. An unchanged -tree skips qmllint, and with an unchanged Quickshell unit also the live -snapshot and the verified restart; `--tags quickshell-lint` still runs qmllint -on its own. Bytecode caches written by the running shell's Python helpers do -not count as a change; the next real deployment clears them. Because it also -installs the Hyprland and Quickshell packages, it converges their -qualified-series versionlock first. - -The `font-defaults` tag installs Liberation Sans/Serif/Mono, the full Noto -collection, Noto CJK Sans/Serif/Mono, Noto Color Emoji, and Font Awesome -(desktop and web fonts), -then applies [Omarchy's font mappings](https://github.com/omacom/omarchy/blob/8f324c90b82790d31ab33565441e07cbdb8d2308/default/fontconfig/conf.avail/50-omarchy.conf) -in `/etc/fonts/conf.d/49-cybexos-defaults.conf`. Sans-serif and system UI -aliases use Liberation Sans, serif uses Liberation Serif, and monospace uses -JetBrainsMono Nerd Font. Strong UI aliases preserve these choices against -Fedora's generic rules, and the system UI rule replaces Fedora's pre-expanded -Cantarell preference. `ui-sans-serif` and `-apple-system-body` also map -to Liberation Sans, and `ui-monospace` maps directly to JetBrainsMono Nerd Font. -The latter must already be installed by the upstream -application tasks. Personal Fontconfig files remain separate and load afterward. -Apply just these defaults on an installed machine with: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags font-defaults -``` - -Font coverage mirrors [Omarchy's base manifest](https://github.com/omacom/omarchy/blob/8f324c90b82790d31ab33565441e07cbdb8d2308/install/omarchy-base.packages) -checked on 2026-09-21. Fedora splits Noto into many packages; the required -`google-noto-fonts-all` metapackage supplies its language, symbol and math -families, with CJK and emoji installed separately. Our full JetBrainsMono Nerd -Font includes the coverage of Omarchy's basic variant. The `fonts` tag also -installs all 16 iA Writer static faces (Mono, Duo, Quattro and legacy Duospace), -using Omarchy's upstream commits with SHA-256 pins and bundled OFL notices. -Omarchy's private branding icon font is shell artwork; CybexOS uses its own -bundled Tabler interface icons and product icons. Existing additional font choices remain. -Arabic and Urdu fallback ordering follows Omarchy, including Chromium requests -without a language hint. - -Restart existing browsers to clear their cached font selection. These system -defaults are separate from the Quickshell Appearance settings. - -The `onepassword` tag installs the Wayland launcher under the upstream -`com.onepassword.OnePassword.desktop` ID and removes the legacy user launcher -so the application appears only once. New image seeds use the same desktop ID. - -The proprietary application group installs Brave Origin (`brave-origin`) from -Brave's signed release repository. The `browser` tag updates the package, -keyboard shortcut, launcher, default URL handlers, and managed policies, and -removes standard Brave's package and old managed launcher. Browser profiles are -preserved: Origin uses `~/.config/BraveSoftware/Brave-Origin`, while standard -Brave uses `~/.config/BraveSoftware/Brave-Browser`. Existing profiles are not -moved automatically; close both browsers before migrating profile data. - -`user-tools` deploys and runs the CLI updater on an existing developer-tools -installation. Codex resolves npm's `latest` release each time this updater runs; -it validates the downloaded version before activating it and retains the working -installation if resolution or download fails. Claude Code's inventory pin is a -minimum: an older or missing Claude Code is installed at the pin, while a newer -one (Claude Code updates itself) is left alone rather than downgraded. OpenCode -retains its exact inventory pin. System verification checks Codex locally -without requiring the npm registry; freshness is checked by the updater. - -Every invocation still executes tasks tagged `always`. That includes fresh -fact gathering, the feature contract, Fedora/architecture/user validation, -and precise XPS-role detection. A partial tag run is therefore not a way to -bypass the Fedora 44 and architecture support contract. Do not add -`--skip-tags always`; it removes the checks that make a targeted run safe. - -## Agent skill lifecycle - -CybexOS ships one canonical skill in the active release at -`~/.local/share/cybexos/current/agent-skills/cybexos`. Provisioning -always creates these discovery links, independently of whether each agent is -installed: - -- `~/.agents/skills/cybexos` -- `~/.claude/skills/cybexos` -- `~/.codex/skills/cybexos` - -The internal `scripts/manage-agent-skills` helper adopts only those three -named slots. Before first replacement it records each existing file, -directory, symlink, or absence separately under -`~/.local/state/cybexos/backups/agent-skills/`. If any backup or -replacement fails, the invocation restores every affected slot. Later runs -treat an adopted slot as project-owned and converge it without rewriting the -first-adoption record; unrelated skills and parent directories are untouched. - -Release activation swaps `current` and then reconciles all three links through -the new canonical skill. A reconciliation failure rolls back the links, the -active-release symlink, and the migrated installer configuration. Uninstall -uses the same helper before removing project state and unconditionally -restores each recorded original. `--keep-user-data` retains those records only -after restoration; it does not leave the CybexOS links installed. - -## Migrating from fedora-config - -The project was called `fedora-config` before it became CybexOS. An -installation made under that name moves to the CybexOS names when `./install` -runs from a CybexOS checkout; releases published under the old name cannot -update it. `scripts/migrate-legacy-names` runs first in `install`, `uninstall`, -and every playbook, and is idempotent. It: - -- moves `/etc/fedora-config`, `/var/lib/fedora-config`, the - `fedora-config-upstream` cache and state, `/opt/fedora-config-apps`, and - `/opt/fedora-config-builds` to their `cybexos` paths, keeping install - markers and retargeting `/usr/local/bin` links so nothing is rebuilt; -- renames the `fedora-config` firewalld zone to `cybexos`, including the - default zone and NetworkManager profile bindings, and replaces the Btrfs - scrub timer; -- moves the `fedora-config` directories in `~/.config`, `~/.local/share`, - `~/.local/state`, and `~/.cache` to `cybexos`, plus the user-tool - directory, and retargets `~/.local/bin` links; -- restores the former `fedora-config` agent skill slots so that provisioning - adopts the `cybexos` slots afresh. - -The `legacy-names` role then removes the remaining `fedora-config` helpers, -units, fragments, and managed include blocks after their replacements are in -place, and restarts a running Quickshell and hypridle. Links at the three -former home paths keep the current desktop session working. The next Hyprland -login removes them. If both an old and a new path already exist, the -migration leaves the old one for manual review and says so. - -## Release updater lifecycle - -`cybex update` follows the channel saved in -`~/.local/share/cybexos/channel` (`stable` by default, or `beta` after -`--channel beta`). It reads `DigitalPals/CybexOS` releases through GitHub's -public API without credentials; no GitHub account, `gh auth login`, or token -is needed. A project release is accepted only when GitHub marks it immutable, -the downloaded SHA-256 matches GitHub metadata, its provenance attestation -verifies, and its manifest supports the current Fedora release, architecture, -configuration schema, and updater version. - -The release workflow publishes the Sigstore bundle of that attestation as -`cybexos-VERSION.tar.zst.sigstore.jsonl` beside the archive. The updater -downloads it and runs `gh attestation verify --bundle` offline, pinned to this -repository's `.github/workflows/release.yml`, the release tag's -`refs/tags/vVERSION`, and a GitHub-hosted runner. `gh` is always installed; -one without offline bundle verification is reported as too old before -anything is downloaded. - -While a channel has no published release (stable: GitHub reports no latest -release for an existing repository; beta: no published prerelease), that is -not an error. `--check` prints `No CybexOS release has been published on the -stable channel yet (installed: VERSION).` and exits 0; `--check --json` reports -`"status": "no-release"`, `"available": false`, and an empty `projectError`. -An update then runs only the package phase and exits with its status, and -the Updates panel shows the neutral note "No CybexOS release published yet". -A missing repository is an error, not an unpublished channel. - -Fedora and Flatpak updates never depend on GitHub: when a release cannot be -checked (offline, an API error, or the anonymous API rate limit), verified, or -staged, the updater says why, removes any partial stage, and runs the -package-only update. The terminal command then exits 69 after a successful -package run; `--start --json` returns the started run with a `projectError` -field, and `--check --json` reports an available release together with a -`projectError` when this machine cannot verify it yet. A failed check exits -nonzero with the reason as its last stderr line. A request with -`--no-packages` has nothing to fall back to and fails. - -The verified archive is extracted into a new versioned directory. A dedicated -durable system worker owns configuration migration, candidate application, -agent-skill reconciliation, rollback, and the atomic `current` symlink change. -Detaching the terminal cannot split those steps, and an unrelated active -update is never accepted as the candidate transaction. On the managed Btrfs -layout, the worker checkpoints root, `/boot`, and the exact vendor-owned home -paths before applying changes. It downloads RPMs first, applies the update, -waits for RPM desktop reconciliation where applicable, and validates system -and desktop health before committing. Failure or cancellation after application -selects the previous root and restores the vendor desktop; restart when status -reports `rollbackState: restart-required`. Personal preferences, themes, plugins -and application data under home are excluded from this restore. The active -release plus two recent release directories are retained, with pruning deferred -until the health check succeeds. - -On other filesystems `transactionProtection` is `unavailable`: release activation -still restores the prior `current` and saved configuration on failure, but -already-deployed files and package changes require manual recovery. Such a failed -apply reports `mixedState: true`; converge the active release again with -`~/.local/share/cybexos/current/install` after fixing the cause. See the recovery -limits below before relying on rollback. - -Useful release commands are: - -```bash -cybex update --check -cybex update --check --json -cybex update --channel beta -cybex update --channel stable -cybex update --no-packages -cybex update --system-only -``` - -## Durable updater lifecycle - -The package/configuration worker is a transient service. It has no terminal, -and sudo's default ticket (`timestamp_type=tty`) is tied to the terminal that -authenticated, so a terminal invocation uses a user service only when sudo -works without any terminal (for example passwordless sudo). Otherwise it -authenticates once and starts a system service. Quickshell explicitly requests -a system service; when authorization is needed, systemd's own Polkit action is -handled by the graphical session agent, so no terminal is opened and progress -remains in the Updates view. -Release transactions also use a system service because their configuration -migration and activation must outlive the client. Closing the view or pressing -Ctrl+C while attached only detaches the observer. At most one worker can own -the update lock. The worker runs at nice 10 with a CPU and I/O weight of 20, -so the desktop stays responsive while it works. - -Use the installed backend to inspect or control it: - -```bash -cybexos-update-run status -cybexos-update-run status --json -cybexos-update-run attach -cybexos-update-run log-dir -cybexos-update-run cancel -cybexos-update-run dismiss -cybexos-update-run history -cybexos-update-run history --json --limit 5 -``` - -Each command accepts a run ID where documented by `cybexos-update-run --help`. -`cancel` is explicit; Ctrl+C during `attach` never cancels. The stop request -reaches only the worker process (`KillMode=mixed`), because interrupting -DNF/RPM midway can leave duplicate or half-upgraded packages. A running -package transaction, firmware update, repository check, or Ansible play -therefore finishes first, and the worker records `cancelled` at the next step -boundary; `status --json` reports `cancelRequested: true` meanwhile. Once -Ansible has started, the run completes or fails instead, so installed files -and the active release stay coherent. A worker still running 30 minutes after -the request is killed. A package phase started by an updater without this -contract is not cancellable. `dismiss` only changes the completed status shown -by the UI and does not delete its logs; the record keeps its result as -`outcome`. - -`history` is the transaction history: the finished runs among the retained -records, newest first, each with its `outcome` (`done`, `failed` or -`cancelled`), timestamps, failing `phase`, `snapshotId`, -`transactionProtection`, `rollbackState` and message. A run that is still -active is not listed. The worker writes `snapshotId` into the run record the -moment the recovery point exists, before any package changes, so even a run -whose worker disappeared names the point to return to. - -Hyprland and Quickshell are held to the series the release qualified, with a -dnf versionlock (see [the dependency policy](dependency-policy.md)). Both -package passes run `dnf upgrade --no-best`, so a library update that only a -held-back compositor or shell release could use is skipped and the rest of the -update still completes; `dnf versionlock list` shows the active entries. - -After a successful package phase, the root worker performs one bounded -`daemon-reload` through systemd's private socket, then requires a valid manager -version reply through the public system bus before continuing. This synchronizes -the manager after RPM triggers; it does not retry packages or replay failed -provisioning service actions. Public manager readiness is checked again before -commit, without another reload. A failed or timed-out synchronization uses the -existing transaction recovery path and preserves the package logs. -`--no-packages` skips the post-package reload and retains the commit readiness -check. - -Installed account reconciliation and feature changes reload an existing user -manager as that account through its local runtime bus, with a 30-second deadline -and a five-second forced-termination grace for the whole client process group. -They do not restart the running desktop. A dormant account with no runtime bus -needs no reload; a failed reload on an existing bus fails configuration. This -avoids the transient machine bridge and its descriptor transfer through PID 1. - -`--firmware` (also accepted by `cybex update`) installs available fwupd device -firmware in the same worker after the reversible update has committed, through -`cybexos-firmware-update`. A firmware failure never fails the run; the final -message notes the helper's status, and a capsule staged for the next boot -recommends a restart for the rest of that boot. With `--no-packages`, -`cybexos-update-run --firmware` is a firmware-only run. - -Run state lives under `~/.local/state/cybexos/update/`. Each run has a private -directory under `logs//` containing: - -- `status.json`: atomic machine-readable phase, result, component exit codes, - timestamps, transient unit name, the pre-update `snapshotId` when one was - created, `transactionProtection` (`btrfs` or `unavailable`), `rollbackState`, - and `mixedState` when the running root still needs recovery or a restart; -- `run.log`: the complete combined stream with `dnf`, `flatpak`, `firmware`, - `tests`, and `ansible` prefixes; -- component logs such as `dnf.log`, `flatpak.log`, `firmware.log`, - `tests.log`, and `ansible.log`, plus their exit-code files when that phase - ran; -- `firmware-events.log`: one JSON object per line (`plan`, `device`, - `progress`, `request`, `installed`, `skipped`, `failed`, and a final - `summary`) that the Updates view renders. - -The twenty newest valid run directories are retained, and with them the -history. For a worker that looks -stuck, start with `cybexos-update-run status --json`, inspect `run.log`, then use -the `unit` field with `systemctl --user status ` and -`journalctl --user -u ` (without `--user` when `systemUnit` is true). If -the unit disappeared without final status, the next status read marks the run -failed with phase `abandoned` instead of blocking all future updates. - -## What an update will change - -The Updates view counts what is pending by source: **System** (Fedora -packages), **Apps** (Flatpaks), **Firmware** and **CybexOS**. On an ISO -installation the `cybexos-desktop` RPM from the enrolled channel counts as -CybexOS rather than as a system package. Before a run, the panel's **What will -change** disclosure lists: - -- Fedora's advisories for the pending packages, from - `dnf advisory info --updates --json`: how many are security fixes, bug fixes - and enhancements, then their titles, security first and most severe first; -- each package and app as installed → incoming version. The incoming side - comes from the check (`dnf check-update`, - `flatpak remote-ls --updates --app --columns=name,application,version`); the - installed side is read only while the disclosure is open, once per check - (`rpm -qa`, `flatpak list --app`); -- CybexOS's own changes: the release notes GitHub publishes with an available - release (`cybex update --check --json` carries them as `releaseNotes`), or, - on an ISO installation, the desktop RPM's changelog entries newer than the - installed one (`dnf --repo=cybexos-desktop changelog --upgrades - cybexos-desktop`). Each desktop RPM build adds an entry naming its release and - the address of its release notes. - -None of these reads needs root; each is a bounded subprocess, and all of the -text is shown as plain text. The advisory read replaces the security-only read -the check already made, so a check costs no more processes than before. - -## Background downloads - -**Download updates automatically** (Settings → Bar → Updates, beside -**Check every**) is off by default; nothing downloads in the background unless -it is turned on. The setting is the enablement of the system timer -`cybexos-update-download.timer`, so systemd keeps the choice and installs or -updates never change it. Turning it on or off runs -`systemctl enable --now` or `disable --now` for that timer in one transient -system unit, so systemd asks the desktop's Polkit agent once; there is no -passwordless path. - -Every four hours (spread by up to an hour, and once after a missed slot) the -timer starts `cybexos-update-download.service`. It runs only on AC power -(`ConditionACPower=true`) and outside the live session, and its helper skips -quietly unless NetworkManager reports a fully connected, unmetered network, -no update worker or other `dnf`/`rpm` process is running, and the system is -not a temporary recovery boot. It then runs `dnf upgrade --downloadonly` and -`flatpak update --system --no-deploy` at idle priority. It never installs or -deploys anything. The durable updater stops a running download before it -takes its recovery point and reuses what was downloaded. - -A finished download writes `/var/lib/cybexos/update-download/status.json` -(`state` `done` or `failed`, timestamps and both exit codes). A failed download -is recorded rather than failing the unit. While that record is newer than the -last change to the installed packages and updates are pending, the Updates -view and its bar tooltip say **Updates downloaded — ready to install**. - -```bash -systemctl status cybexos-update-download.timer cybexos-update-download.service -journalctl -u cybexos-update-download.service -cat /var/lib/cybexos/update-download/status.json -``` - -The desktop also accepts `cybexos-runtime ipc updates check` (check every -source now) and `cybexos-runtime ipc updates open` (open the Updates view). - -## Personal-file backups - -`cybexos-backup` keeps encrypted copies of the home folder with restic, on a -local folder or external drive, or on an SSH/SFTP server. `cybex backup` runs -it on both installation paths, and **Settings → Backup & recovery** drives the -same helper: Set up… walks through the destination, the recovery key and the -first backup on the page itself. Recovery points (next section) restore the -installed system; backups are the personal files they never touch. - -```bash -cybex backup setup local /run/media/$USER/T7 # a folder or external drive -cybex backup setup sftp nas backups/laptop # SSH alias or user@host, then a folder -cybex backup confirm-key # after saving the recovery key -cybex backup now # back up now, in the foreground -cybex backup status [--json] # last and next backup, destination -cybex backup snapshots [--json] # the backups available to restore -cybex backup restore [SNAPSHOT] [--path Documents/Taxes] [--open] -cybex backup automatic on|off -cybex backup recovery-key # replace the recovery key -cybex backup excludes # what backups leave out -``` - -**Destinations.** A local destination gets a `cybexos-backup` folder inside the -chosen folder, unless the folder already is a restic repository. Folders inside -the home folder on the same filesystem, and system folders, are refused. The -destination's mount point is recorded: while a removable drive is absent its -empty mount directory is never written to, which would fill the system disk. -An SSH destination uses restic's SFTP backend through `ssh` with the user's -keys and `~/.ssh/config`, like the Remote Server widget: `BatchMode=yes` (no -password prompts) and `StrictHostKeyChecking=yes` (no unknown host keys). -Connect once with `ssh HOST` so the server is trusted; put ports, IPv6 -addresses and jump hosts in `~/.ssh/config` under an alias. The relative -folder (default `cybexos-backup`) is in the remote account's home. - -**Encryption and the recovery key.** Repositories are always encrypted. Setup -generates a random 256-bit password and stores it in the GNOME Keyring with -`secret-tool` (libsecret; attributes `application cybexos-backup repository -ID`); it reaches restic through a pipe, never argv or the environment. A -second restic key, the recovery key (30 characters in six groups, without -I, L, O or U), is shown once and never stored; Settings copies it to the -clipboard marked sensitive, so clipboard history skips it. With it the -backups open on any computer. **If this computer's keyring and the recovery -key are both lost, the backups cannot be opened.** `cybex backup recovery-key` -adds a new recovery key and removes the previous one; Settings offers the same -as New key… while a key is not confirmed as saved. After reinstalling, setting -up the same destination reports the existing backups; `--existing` (or the -Recovery key field in Settings) reads their recovery key and stores a new -keyring password beside it. On a computer without CybexOS, restore with plain -restic: `restic -r REPOSITORY restore latest --target ~/restored` and the -recovery key as the password. - -**What is backed up.** The whole home folder, on its own filesystem -(`--one-file-system`, so mounted drives and network shares under it are -skipped), excluding directories tagged with `CACHEDIR.TAG` and: - -- caches and trash: `~/.cache`, `~/.local/share/Trash`, Flatpak application - caches (`~/.var/app/*/cache`), and the caches Chromium, Brave, Chrome and - Electron applications keep inside `~/.config` (`Cache`, `Code Cache`, - `GPUCache`, `DawnCache`, `GrShaderCache`, `ShaderCache`, - `Service Worker/CacheStorage`, `component_crx_cache`); -- downloaded Steam games (`steamapps/common`), downloads, shader caches and - client caches in the native and Flatpak Steam stores; `userdata` and - `steamapps/compatdata` (Proton prefixes and saves) are included; -- rootless Podman/Docker image/build stores (`overlay`, `overlay2`, `vfs`, - image metadata, BuildKit and containerd), including disposable writable - container layers; readable named volumes and home bind-mounted personal data are included; -- applications that are downloaded again: `~/.local/share/flatpak`, the Neovim plugin and - Mason stores, Voxtype models and `~/.ollama/models`; -- the CybexOS vendor runtime and release store - (`~/.local/share/cybexos/runtime`, `releases` and `current`); -- toolchains and package caches: `~/.cargo/registry`, `~/.cargo/git`, - `~/.rustup`, `~/.npm`, `~/.npm-global`, `~/.bun/install/cache`, - `~/go/pkg/mod`, `~/.m2/repository`, `~/.gradle/caches`, - `~/.gradle/wrapper/dists`, `~/Android/Sdk` and `~/.android/avd`; -- dependency trees and build output anywhere: `node_modules`, `__pycache__`, - `.tox`, `.nox`, `.next`, `.nuxt`, `.svelte-kit`, `.turbo`, `.parcel-cache`, - `.zig-cache` and `zig-cache` (Cargo's `target/` carries `CACHEDIR.TAG`). - -Add restic exclude patterns, one per line, to `~/.config/cybexos/backup-exclude`. -`cybex backup excludes` prints the effective list. - -**Application data.** Steam games that store saves inside their installation -directory (`steamapps/common`) need a separate export into a backed-up home -folder. Steam Cloud is not a substitute for that export. Keep persistent -container data in named volumes or home bind mounts, rather than writable -container layers. Root-owned stores under `/var/lib`, custom stores outside -home, symlink targets outside home and separately mounted filesystems are not -included. The regular cache/dependency exclusions also apply inside volumes. - -The backup runs as your desktop account. Private `_data` directories and files -owned by subordinate UIDs in rootless containers may be unreadable to that -account, even though the volume lives under home. Export that data with the -application/container's supported tools into a home folder readable by your -desktop account, and keep the export until a complete backup succeeds. -Stopping a database alone does not change file ownership or make private -volume data readable. The helper does not elevate privileges or change volume -permissions. - -These are file backups, not application-consistent database snapshots. Files -copied while PostgreSQL, MariaDB/MySQL, SQLite or another database is writing -may not restore consistently, even when the volume is included. Before backup, -write an application-supported export (for example `pg_dump`, `mysqldump`, or -SQLite's `.backup`) into a backed-up home directory and retain it until backup -finishes, or stop the database for the entire backup when its files are readable. -Restore and verify those exports with the application's tools. Setup and status describe this coverage -and consistency limit on both installation paths. - -**Incomplete backups.** Restic may save a usable snapshot of readable files -while omitting an unreadable directory and everything below it. Such a run is -reported as `lastResult: "partial"`, with the reason in `lastError`; a manual -`now` returns a nonzero exit status. Its snapshot remains available to restore, -but `lastSuccess` continues to identify the last complete backup. Incomplete -runs do not apply retention and remain due for the next scheduled retry. -`lastSummary.unreadable` is the number of read-error records restic reported, -not a count of all omitted files: a directory error can hide an unknown number -of children. It is `null` when restic reports an incomplete backup without -structured error records, and `0` on a complete run. `lastSummary.errors` -retains the latest 40 error messages, including affected paths when available; -the error count includes records beyond that diagnostic limit. - -**Schedule.** `cybexos-backup.timer` is a user timer started with the Hyprland -session (on the ISO by the packaged session target, on checkout installations -by the desktop role's `hyprland-session.target.wants` link); it needs the -session's keyring and notifications. It checks 10 minutes after login and then -hourly; a backup runs when automatic backups are on and none has succeeded in -the last 23 hours. A due backup waits quietly while the destination is -unreachable (drive not mounted, folder missing, SSH port not answering), on -battery below 30 % (peripheral batteries do not count), on a metered network -(SSH destinations only), or while the keyring is locked: a scheduled run never -raises an unlock prompt. It runs at `Nice=10` with idle I/O priority. Once a -week, after a backup, `restic forget --keep-daily 7 --keep-weekly 4 ---keep-monthly 6 --prune` applies to this computer's snapshots, followed by -`restic check`. - -**Notifications.** A failed or incomplete backup sends one notification with -the reason, at most once a day for the same reason, however often the hourly check retries. -After seven days without a successful backup, a reminder follows once a day, -naming the drive to connect when that is the cause; it also comes while -automatic backups are off. Clicking either opens Settings → Backup & recovery. - -**Restoring.** A restore never writes into the home folder: it goes into a new -folder `~/Restored/YYYY-MM-DD-HHMM` named by the backup's time (`-2`, `-3` when -taken), with `--overwrite never`. `--path` restores one file or folder, -relative to the home folder; without it everything is restored after a free -space check. Settings restores the chosen backup and path, then opens the -folder in Files. Back up now and Restore run as transient user units -(`systemd-run --user`), so closing Settings or restarting the shell does not -interrupt them; one backup or restore runs at a time. - -**Files.** `~/.config/cybexos/backup.json` holds the destination and keyring -item name (no secrets); `~/.local/state/cybexos/backup.json` the last results -and notification bookkeeping; `$XDG_RUNTIME_DIR/cybexos/backup-progress.json` -the progress of a running backup or restore. - -The earlier opt-in `app-backup` (and `browser-backup`) command still exports -browser profiles as an age-encrypted tarball for `AGE_RECIPIENT`; it is now a -mode of `cybexos-backup` and is installed on ISO installations too. Ordinary -backups already include the browser profiles. - -`tests/backup.py` covers the helper against stubbed restic, secret-tool, SSH, -NetworkManager and power-supply fixtures, and the image parity tests compare -the units and the `cybex backup` dispatchers of both installation paths. When -restic is available, it also snapshots and restores synthetic home data in a -temporary local repository, checking saves, readable volumes and cache exclusions. -It also runs a real unreadable-directory backup as an unprivileged account, -checking partial status, preserved complete snapshots, read errors and a -readable volume restore. These fixtures do not qualify private subordinate-UID -volumes as readable. A backup to real external media or an SSH server from an installed system, and -the Settings flow in a live session, still need qualification on hardware. - -## Update recovery points - -Before package work starts, the updater requires -`/usr/local/libexec/cybexos-system-snapshot` on the managed Btrfs layout. It saves a -read-only snapshot of the `root` subvolume and a matching archive of `/boot` -(including the mounted EFI tree), records the default kernel, then retains the -five newest points. `/home` is a separate subvolume and is intentionally -outside system rollback; on the standard layout `/var` (logs, containers, -system Flatpaks) is inside `root` and rolls back with it. A snapshot failure -stops the update before DNF changes anything. On a non-Btrfs root the step -records that no filesystem recovery point was required. - -The transaction journal and vendor checkpoint live in the Btrfs top-level -recovery store, outside the root that is restored. An active checkpoint pins -its recovery point against ordinary retention. `cybexos-update-recover.service` -resolves interrupted updates before login, retrying an interrupted restoration -without exchanging the root twice. A prepared transaction with no applied -changes is abandoned safely. A failed restoration enters emergency mode instead -of starting a desktop with incomplete recovery. - -The first update from an older installation creates an untouched recovery point, -installs a root-owned recovery bundle and login dependency, then creates a second -point containing that hook. Package changes begin only after the second point -and vendor checkpoint succeed. This keeps recovery available if power fails -between restoring root and restoring vendor files in home. Normal convergence -replaces the bootstrap hook; a package-only update can retain it until that -convergence. Starting the boot service during the current update never rolls -back that same-boot worker. - -Installed baseline and account provisioning runs in the fixed transient -`cybexos-provision-work.service` cgroup. The supervising helper holds -`provision.lock` until `cgroup.events` confirms every descendant has exited, -including workers that detach into another session. Runtime limits stop the -whole cgroup; cancellation cannot release serialization during teardown. A -killed supervisor is fenced by draining that named workload before another -configuration run or updater recovery. A kernel task that cannot be killed -keeps the fence held rather than permitting concurrent writes. Live provisioning -requires a working systemd cgroup v2 manager and fails if it is unavailable. -Only Anaconda's explicit `--offline --installer-target` invocation in a verified -chroot provisions without a running target manager; `--offline` alone retains -live containment. The updater probes the helper's advertised `--drain` interface -so the first update from an older baseline remains supported. - -Commit checks the RPM database and newly failed system services. When the -desktop was active, it also requires the managed Quickshell service to own the -sole shell process, complete read-only IPC initialization, remain stable, and -report no QML errors for that invocation. Safe mode is not successful update -validation. Headless updates validate system health; they cannot validate a -desktop session that is not running. - -These are recoverable filesystem transactions, not whole-machine snapshots. -Root selection takes effect after restart, and a new kernel that cannot boot -after an otherwise successful commit still needs the recovery boot menu. -User Flatpaks, independently updated application/tool stores in home, external -filesystems, remote effects and firmware are outside the checkpoint. Firmware -runs only after commit. Keep external backups; root rollback also reverts data -in `/var` on the standard layout. Guided Fedora release upgrades use the same -journal with separate offline-boot and desktop validation stages; see -[the major-upgrade workflow](fedora-major-upgrade.md). - -```bash -sudo cybexos-system-snapshot list # ID and description -sudo cybexos-system-snapshot list --json # also kernel and boot-menu status -cybexos-update-run status --json | jq -r .snapshotId -cybexos-update-run history --json | jq -r '.[] | "\(.id) \(.outcome) \(.snapshotId)"' -``` - -The Updates view shows the last update's recovery point as **Recovery point · -Today at 14:02** (local time) with **Manage**, which opens the Settings section -where recovery points are listed and restored. The line stays after the result -is dismissed and is replaced when the next update takes its own point. - -### Trying a recovery point from the boot menu - -GRUB shows a **CybexOS recovery points** submenu with one entry per retained -point whose kernel and initramfs are still in `/boot`. Fedora hides the menu -after a successful boot: hold **Shift** (BIOS) or press **Esc** (UEFI) while -the machine starts, or simply let a failed boot bring it up. An entry boots the -read-only snapshot with its own kernel; the `cybexos-recovery` dracut module -lays an in-memory overlay over it, so the system runs normally but nothing is -written to the snapshot and every change disappears at the next restart. -`/home` is the live one. The real `/boot` and EFI partitions are mounted -read-only, so nothing installed during a recovery boot can leave a kernel -behind without its modules. The desktop announces the recovery boot and offers -to restore it; new recovery points, and therefore updates, are refused while it -runs. - -Nothing here regenerates `grub.cfg` per snapshot. `/etc/grub.d/42_cybexos_recovery` -makes `grub.cfg` source `/boot/grub2/cybexos-recovery.cfg`, which the helper -rewrites atomically after every create, prune and restore, from a kernel-install -hook when kernels change, and at boot (`cybexos-recovery-refresh.service`, which -also publishes `/run/cybexos-snapshots/recovery-points.json` for the desktop). -Entry titles and kernel arguments are reduced to a safe character set, so a -description cannot inject GRUB commands. A kernel whose initramfs lacks the -overlay module gets no entry; Ansible rebuilds initramfs images when the module -or helper changes. - -### Restoring - -Restore from **Settings → Backup & recovery → Recovery points** (two presses; systemd asks -the desktop's Polkit agent, as for updates) or from a terminal, in a normal or -a recovery boot: - -```bash -sudo cybexos-system-snapshot restore ID -# From a recovery point older than this feature, use the helper the -# initramfs provides instead: -sudo /run/cybexos-recovery/cybexos-system-snapshot restore ID -``` - -Restore never reboots. It makes a writable copy of the point, puts back any of -the point's kernels that `/boot` no longer has (from its archive), exchanges the -copy with `root` in one atomic rename, and keeps the previous system as -`root.replaced-TIMESTAMP`. It then retires kernels that the restored root has no -modules for into the store and makes the point's kernel the GRUB default. The -bootloader itself (GRUB, shim and the EFI tree) is left at its current version. -Nested subvolumes such as `/var/lib/machines` are never part of a point and move -to the restored root. An interrupted restore is finished or undone by the next -`cybexos-system-snapshot refresh`, which runs at every boot. - -Restart to use the restored system, then review and reclaim space: - -```bash -sudo cybexos-system-snapshot replaced -sudo cybexos-system-snapshot discard root.replaced-TIMESTAMP -``` - -Discarding is refused while that root is still the running system. - -### Factory reset - -ISO installations made with the guided installer (interactive, unattended or -for a new owner) take one **factory recovery point** at their first start, -before anyone logs in: `cybexos-first-boot.service` runs -`cybexos-system-snapshot create --factory`. The point is marked -`metadata/ID.factory` in the store, is listed with `"factory": true`, does not -count towards the five retained points and is never pruned; only one can exist. -Checkout installations never create one. - -```bash -cybex factory-reset -``` - -The command runs `/usr/libexec/cybexos-factory-reset reset` through sudo. It -refuses during a recovery boot, while a restore waits for a restart, while an -update transaction is unfinished, and without a factory point. It explains -what is erased, suggests `cybex backup now`, and requires typing the computer -name or `erase`. On an encrypted disk it asks for the current passphrase and a -temporary passphrase for the next owner, adds that passphrase and removes every -other key slot and LUKS2 token. It then restores the factory point with the -normal restore and marks the restored root. **Settings → Recovery points → -Factory reset…** explains the same and opens the command in a terminal. - -At the next start `cybexos-first-boot` finishes the reset before any login: -every other recovery point, set-aside root and update checkpoint is deleted, -every desktop account and everything in `/home` (nested Btrfs subvolumes -included; any other mount below `/home` stops the reset instead) is removed, -along with SSH host keys and the passwordless-sudo rule. The installation's -feature choices move from `config.yml` to `/etc/cybexos/install-choices.json`, -`/etc/cybexos/oem.json` is written and `cybexos-new-owner.service` asks the new -owner for language, keyboard, account, password and time zone before the login -screen: in a graphical setup, or on the console when graphics are unavailable -(see [image/INSTALLER.md](../image/INSTALLER.md#new-owner-oem-installations)). If finishing fails, the reset marker remains, the -failure is in `journalctl -b -u cybexos-first-boot` and the next start retries. -The system returns to the factory point's package versions; the hostname and -machine ID are kept. See [image/INSTALLER.md](../image/INSTALLER.md#factory-reset). - -### Last resort: live media - -If neither the default entry nor a recovery entry boots, boot Fedora -rescue/live media, unlock the LUKS device, identify the root Btrfs filesystem -and the separate `/boot` and EFI partitions with `lsblk -f`, then use the -reviewed snapshot ID below. Device names are placeholders and must be replaced -with the values from `lsblk`: - -```bash -mount -o subvolid=5 /dev/mapper/ROOT_CRYPT /mnt -ID=20260903T120000Z-1234 -test -d "/mnt/cybexos-snapshots/root/$ID" -test -f "/mnt/cybexos-snapshots/boot/$ID.tar" -btrfs subvolume snapshot "/mnt/cybexos-snapshots/root/$ID" /mnt/root.recovered -mv /mnt/root "/mnt/root.failed-$ID" -mv /mnt/root.recovered /mnt/root -mount /dev/BOOT_PARTITION /mnt/root/boot -mount /dev/EFI_PARTITION /mnt/root/boot/efi -tar --acls --xattrs --selinux --numeric-owner \ - --extract --file "/mnt/cybexos-snapshots/boot/$ID.tar" --directory /mnt/root -sync -``` - -Reboot into the default entry and run `sudo restorecon -RF /boot` followed by -`./verify --system --require-hyprland`. Keep `root.failed-$ID` until the system -and user session are confirmed healthy; deleting it is a separate, explicit -space-reclamation decision. These recovery points do not replace backups: -they share the same physical Btrfs filesystem and cannot survive device loss. -Use [personal-file backups](#personal-file-backups) for the home folder. - -`tests/system-snapshot` covers the helper against fixtures (retention, the -factory point, menu generation and escaping, kernel selection, restore -ordering and interruption, the overlay hook); `image/test_new_owner.py` covers -the factory reset guard rails, the next-start cleanup and the new-owner setup -with the snapshot helper, cryptsetup and account tools stubbed. Booting a recovery entry and restoring from it on real -hardware have not yet been qualified end to end; test them in a disposable VM -before relying on them. - -Ansible's `cybexos` callback is intentionally compact: unchanged and skipped tasks -are quiet, changes are one line, and failures include a bounded diagnostic. -Pass `-v` to use the stock verbose callback behavior. A full updater run keeps -the callback's complete emitted stream in `ansible.log`; it does not recreate -details that the compact callback intentionally never emitted. Pass `-v` on a -diagnostic run when those details are needed. `tests/callback-smoke.py` checks -compatibility with callback event objects. - -## Shutdown and reboot expectations - -Do not shut down or reboot while `cybexos-update-run status` reports `queued` or -`running`. A durable transient service survives a terminal or shell restart, -not a machine power cycle. Wait for a terminal state (`done`, `failed`, or -`cancelled`), or cancel deliberately and confirm the terminal state first. - -While it runs, the worker holds a logind block inhibitor for shutdown and -sleep (`systemd-inhibit --list` shows `CybexOS`), so ordinary power-off, -reboot, and suspend requests, including idle suspend, are refused until it -finishes. `systemctl poweroff -i` overrides it deliberately. Closing a laptop -lid still suspends, because logind's default `LidSwitchIgnoreInhibited=yes` -ignores inhibitors; keep the lid open during an update. The protection is -complete for a system-service worker (Quickshell and release updates). logind -does not apply a user's own inhibitor to that user's requests, and Polkit may -refuse it to a user-service worker altogether; `run.log` then records an -`[inhibit]` line and the update continues unprotected. - -No repository command automatically reboots or powers off the machine. -Ordinary package updates can install a new kernel for the next boot. A direct -bootstrap can rebuild initramfs through the `boot` role. The IPU7 camera role -can also leave `/var/lib/xps-hardware/ipu7/reboot-required` when new signed -DKMS modules must be loaded in a clean boot. Let the play finish, inspect its -result, then perform one normal reboot. `./verify` reports this camera state. - -## Idle, power, and background services - -hypridle runs the idle timeline from **Settings → Power → Idle**, unless a -regular file at `~/.config/cybexos/hypr/hypridle.conf` replaces it. A new -installation locks after five idle minutes, turns the screen off after ten, -and suspends after 30 minutes only on battery, so a machine on mains power, -including every desktop, stays awake. A laptop that reaches the suspend -timeout on mains power checks again every minute until the next input, and -suspends if it is unplugged meanwhile. Existing installations keep their -stored values: the shell writes every key to `shell.json`, so a stored Never -cannot be told apart from a deliberate one. - -A locked screen turns off about a minute after the last input, whether it was -locked by hand or by the idle lock; a Screen off setting of Never keeps it lit. -Every suspend waits for the lock. For a lid close or any other sleep request, -hypridle's `inhibit_sleep = 3` holds the sleep until the compositor reports -the session locked, for as long as logind allows a delay. The shell's Suspend -and idle suspend call `systemctl suspend` only after `hyprctl locked` -confirms the lock. Without that confirmation within eight seconds the machine -stays awake, and a lock screen that is still starting is left running rather -than stopped. - -hypridle reads its configuration only when it starts. A converge that changes -its configuration, its unit, the runtime resolver, or the timeout renderer -restarts a running hypridle, so new timeouts apply without logging in again. -hypridle only tracks idle time, so the restart neither locks nor unlocks the -session. - -Docker is socket-activated: `docker.socket` starts dockerd, and with it -containerd, on the first client request instead of at boot. Containers with a -`restart=always` policy therefore wait for the first `docker` command after -boot. A converge leaves a daemon that is already running alone. - -Docker commands need `sudo` unless the saved `docker_sudoless` answer is -`true`. Membership in the `docker` group controls a root daemon, so it is -root-equivalent; the installer asks for it explicitly and defaults to no. -Earlier releases granted the group without asking: a converge revokes it from -a CybexOS-managed Docker installation whose saved configuration has not -accepted sudoless use. The revocation applies to new login sessions. Run -`cybex configure` to opt in again. `Super+D` starts lazydocker directly with -socket access and through `sudo` otherwise. - -The weekly Btrfs scrub runs only on AC power and reads at most 200 MiB/s per -device. A run skipped on battery is not caught up when the charger returns; it -waits for the next weekly trigger. - -Dictation requires the `developer_tools` feature, which also downloads its -model and binds its keys. Without it the Voxtype user unit is not installed, -and a converge stops and removes one left by an earlier run. The daemon loads -the model when a recording starts and releases it when idle, so the first -dictation after an idle period has a short load delay. - -Game mode (`Super+Shift+G`, or `game-mode on|off|toggle|status [--json]`) -turns the compositor's animations, blur, shadows, window rounding and gaps -off, including the margins around a lone window on an external display, and -holds idle locking and idle suspend off with a systemd idle inhibitor -(`cybexos-game-mode-inhibit.service`, part of `hyprland-session.target`). -An explicit suspend, a lid close and the lock key still work. Turning it off -restores exactly the values it replaced, including any set in `user.lua`; with -power saver also on, whichever ends last restores the original animations. -The helper records the state in `$XDG_RUNTIME_DIR/cybexos/game-mode.json` -(`{"active": true, "instance": ..., "since": ...}`), which the menubar -watches for its indicator. The file names the Hyprland instance, so a -configuration reload applies game mode again once the whole configuration -has loaded, and a new login always starts with it off. When it seems stuck, -compare `game-mode status --json` with -`systemctl --user status cybexos-game-mode-inhibit.service`, and check -`hyprctl repl 'return __cybexos_game_mode.error'`; `game-mode off` followed -by `hyprctl reload` restores the configured values in any case. - -mpv prefers hardware decoding: a managed block at the top of -`/etc/mpv/mpv.conf` sets `hwdec=auto-safe`, which uses only the decoders mpv -considers reliable and falls back to software otherwise. Lines below the block -and a personal `~/.config/mpv/mpv.conf` override it; uninstall removes only -the block. - -## The strict source gate - -`tests/run` executes every stage even after a failure and returns nonzero when -any stage failed. Missing Node, Python, Ansible, qmllint, or QML runtime tools -are failures rather than silent skips. The exact stage order and count come -from the executable itself: - -```bash -./tests/run --list -``` - -The stages cover whole-source/Ansible syntax, ShellCheck, ansible-lint, -yamllint, Ruff, Node unit tests, Hyprland workspace fixtures, QML static -analysis, offscreen helper contracts, real-Quickshell component lifecycle -coverage in CI, Quickshell deployment integration, callback and Python -fixtures, transactional agent-skill lifecycle coverage, XPS hardware -integration, Plymouth layout, the durable updater, screenshot/brightness -workflows, Btrfs snapshot retention, and the fedora-config name migration. -QML static analysis also lints the three real-engine test harnesses, each over -a scratch copy of the tree, so a harness that drifted from the components it -drives fails locally even while the managed shell keeps the real-engine run -itself CI-only. The deploy's own lint (`tests/qml-lint --shell-only`) checks -the shell alone. - -Independent stages run side by side, longest first, and their results print -in the fixed order with the verdict last. `--jobs N` bounds the concurrency -(`--jobs 1` runs serially) and `--only STAGE` runs a single stage. Every stage -has a time limit (300 seconds; 600 for static lint and the Python fixtures), -and a stage that exceeds it fails with whatever it printed rather than holding -the gate open. - -The GitHub workflow runs the same `./tests/run` command in a Fedora 44 -container. The lower-level worker stops before Ansible if this gate fails or -does not finish within 30 minutes. A separate `Fedora preview` workflow runs it -in the next Fedora release's container with the same dependencies. It is not a -required check, never runs for a release, and does not make that Fedora -release supported; see "Fedora 45 qualification" in -[the major-upgrade runbook](fedora-major-upgrade.md#fedora-45-qualification). -`--skip-tests` is reserved for an already verified release candidate and is -not part of the public command interface. - -The weekly and manually dispatchable `Fedora VM convergence` workflow adds a -slower system boundary: it checksum-verifies the pinned official Fedora 44 -Cloud image, boots it under QEMU, applies every role twice, requires a -zero-change second pass, checks preserved user collisions and firewall state, -and then verifies uninstall restoration. Run the same test locally with -`./tests/fedora-vm-convergence`; set `FEDORA_VM_TAGS` to a comma-separated role -subset only for a deliberately narrower development run. - -## Reduced motion - -Quickshell treats `QS_REDUCED_MOTION=1`, `true`, `yes`, or `on` -(case-insensitive) as a request to remove shared transitions, movement, -stagger, and press animation. Qt does not currently supply this preference to -the shell automatically. Make it durable with a user-service drop-in: - -```ini -# ~/.config/systemd/user/quickshell.service.d/reduced-motion.conf -[Service] -Environment=QS_REDUCED_MOTION=1 -``` - -Then run `systemctl --user daemon-reload` and restart the managed -`quickshell.service` at a safe time. Remove the drop-in and repeat those two -commands to restore motion. Repository runtime tests set the variable only in -their isolated offscreen process. - -The power saver profile is a reduced-motion request too, for as long as it is -on; it changes neither this variable nor **Settings → Appearance → Reduce -motion**. The compositor follows it as well: it uses one blur pass and -Hyprland animations turn off, and the values it replaced return when power -saver ends. diff --git a/docs/overview-and-dock.md b/docs/overview-and-dock.md index e418f53d..07126f0f 100644 --- a/docs/overview-and-dock.md +++ b/docs/overview-and-dock.md @@ -77,7 +77,7 @@ The Control Center's Overview tab lists the six busiest processes under the CPU and memory meters. **CPU** and **Memory** choose the ranking. CPU is each process's share of the whole machine since the previous reading, on the same scale as the CPU meter. The list is read every two seconds only while it is on -screen, by one short-lived helper (`scripts/processes`) that uses shell +screen, by one short-lived helper (`desktop/quickshell/scripts/processes`) that uses shell builtins only. Hide it under the Control Center widget's **Overview** settings (**Top processes**). @@ -98,7 +98,7 @@ Node tests cover the pure rules: `overview-helpers`, `dock-helpers`, and `window-commands`. `desktop-navigation.test.cjs` covers the QML lifetimes and wiring. The real-engine lifecycle harness builds the overview view on an empty desktop and runs two samples of the process list. -`image/test_desktop_payload.py` checks that both installation paths ship the +The shared NixOS module checks checks that both installation paths ship the same files and defaults. Live behaviour still needs checking on a Hyprland session: thumbnail capture, drag-to-move, multi-monitor tiles, dock auto-hide over a bottom bar and the touchpad gesture. diff --git a/docs/quickshell-notes.md b/docs/quickshell-notes.md deleted file mode 100644 index 8b8933b2..00000000 --- a/docs/quickshell-notes.md +++ /dev/null @@ -1,938 +0,0 @@ -# Working on the Quickshell shell - -Notes for anyone changing `roles/desktop/files/quickshell/` — how to test it, -the traps that cost real time to find, and the things already decided against. - -Distilled from `docs/qml-improvement-plan.md`, a 43-package refactor completed -2026-08-08 (42 done, 1 measured and declined). That plan is in git history if -you need the reasoning behind a particular change; `git log --oneline --- roles/desktop/files/quickshell` is usually faster. - -## Ground rules - -- The Ansible role is the vendor source of truth. Its deployed runtime is - `~/.local/share/cybexos/runtime/quickshell`; never edit that generated - copy expecting the change to survive. User settings and overrides live in - the paths documented by `docs/architecture/ownership.md`. -- Match the surrounding style. **Do not run qmlformat** (see "Already decided - against"). -- Theme values come from `Common/Theme.qml`. Add a token rather than a literal - when the value expresses a design role. -- New shared components: PascalCase file in the directory that owns the - concern, and **add it to that directory's `qmldir`** — a directory carrying a - `qmldir` is no longer implicitly scanned, so an unlisted type fails at - runtime as "X is not a type". `tests/quickshell/qmldir.test.cjs` enforces it. -- Pure logic goes in a `.js` module in `Common/` with a Node test in - `tests/quickshell/` — that suite runs in under a second without Qt. -- `tests/run` is the strict source gate: language-aware static analysis, Node - tests, QML static/runtime checks, integration contracts, and the - repository's other fixtures. `update --full` runs it before deploying, and - the Ansible role lints the tree before copying a changed one. See - `./tests/run --list` and - [the operations guide](operations.md#the-strict-source-gate). - -## Testing without a GUI - -The built-in [authentication dialog](authentication-dialog.md) uses Quickshell's -Polkit service. Its presentation and startup checks are covered below; its -authentication backend remains the system Polkit/PAM stack. - -Run `./tests/run` first; it needs no live shell. External widget tests require -`sway` for a disposable headless Wayland compositor (also installed by CI); -this is a test dependency, not a change to the desktop's compositor. -For a live deployment, keep -`quickshell.service` as the only `qs` process and use the shared safety harness -at both boundaries: - -```sh -set -euo pipefail -source tests/lib/quickshell-live -cleanup() { - rc=$? - trap - EXIT INT TERM - qs_live_end || rc=1 - exit "$rc" -} -trap cleanup EXIT -trap 'exit 130' INT -trap 'exit 143' TERM - -qs_live_begin -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags quickshell -qs_live_wait_ipc 20 popouts close >/dev/null -qs_live_wait_ipc 20 popouts toggle t3code # or: audio, control, wifi, notifications, … -qs_live_wait_ipc 20 settings open notifications -grim -g "1020,50 400x420" /tmp/shot.png -qs_live_end -trap - EXIT INT TERM -``` - -`qs_live_begin` compares the service MainPID with every `pgrep -x qs` result, -inspects command lines and cgroups, and only terminates a confirmed unmanaged -`qs -d`/`qs -p` developer process. `qs_live_end` requires the service to be -active, its MainPID to be the sole `qs`, and the current invocation journal to -be free of known QML/runtime errors. IPC readiness checks target the service PID -so runtime migration cannot send them to a dead default configuration. Never -replace this with `pkill qs`. - -The Ansible role is the supported deployment path. A test that temporarily -edits the deployed tree must be trap-protected, restore the exact managed -manifest (including destination-only file removal), wait for IPC readiness, -and then call `qs_live_end`; `tests/t3-contract-snapshot` is the working -example. Do not commit while a throwaway live copy is deployed. - -### Techniques that work here - -- **`console.log` never reaches stdout or the qslog**, under any - `QT_LOGGING_RULES`. `console.warn` *does* reach - `journalctl --user -u quickshell.service`. A harness that must report a value - writes a file: - `Quickshell.execDetached(["sh", "-c", "printf '%s' \"$1\" > \"$2\"", "sh", text, path])`. -- **Offscreen harness**: `tests/run` points HOME/XDG paths at a scratch - directory. Generic `qmltestrunner` covers helpers; Quickshell's plugin is - statically linked into `qs`, so the production-component lifecycle harness - runs through the real engine in CI and refuses to run beside an active local - `qs`. If another direct source-tree `qs -d`/`qs -p` probe is indispensable, - stop `quickshell.service` first and install a trap that terminates the exact - developer PID, restores the service, and finishes with the same sole-PID and - current-journal checks as `qs_live_end`. Never run the probe beside the - managed service. -- **Triggering internal code paths live**: add a throwaway `IpcHandler` target - to the *deployed* `shell.qml`. Cheaper than staging the real event, and it - exercises the shipped code rather than a copy. -- **Keyboard**: `wtype -k Tab` / `wtype -k space` sends real key events, which - is how focus order and activation get tested end to end. -- **Updates drawer states without updating anything**: write a run record - (`status.json`, `dnf.log`, `flatpak.log`, `firmware-events.log`) under - `~/.local/state/cybexos/update/logs//` and put the id in `../current`. - The id has the form `YYYYmmdd-HHMMSS-N-N`. Set the record's `unit` to a - disposable `systemd-run --user … sleep 900` unit, never a real service: - `status` treats an inactive unit as an abandoned run, and a stray Cancel - stops whatever unit the record names. Restart `quickshell.service` so it - attaches, rewrite the record with the same log prefixes to advance it, and - remove the run and `current` afterwards. -- **Pointer**: `hyprctl dispatch 'hl.dsp.cursor.move({ x = …, y = … })'` takes - absolute screen-logical coordinates. Call it in a loop until - `hyprctl cursorpos` agrees — the first call after the pointer has been - elsewhere can land short. With `grim -c` this makes cursor shape and hover - state observable. -- **Multi-monitor without hardware**: `hyprctl output create headless`, then - read the name back (`HEADLESS-1`). The shell keeps a bar mapped on every - output, so `hyprctl layers -j` can directly verify one `qs-bar` surface per - output and only one `qs-bar-popout` on the output whose bar was clicked. -- **This Hyprland speaks a Lua dispatch dialect** — - `hyprctl dispatch 'hl.dsp.focus({ monitor = "" })'`. Plain - `focusmonitor` / `movecursor` do not exist. -- **Prove the probe has teeth.** Reconstruct the pre-fix code in the throwaway - copy and confirm the probe fails. A check that cannot fail has not verified - anything. - -### Pixel diffs - -`compare -metric AE` is **not** a pixel count in this ImageMagick 7 — it -returned 6.7e7 for a 120×80 crop. Use: - -```sh -magick a.png b.png -compose difference -composite -colorspace Gray \ - -threshold 8% -format '%[fx:mean*w*h]' info: -``` - -and always check it against a deliberately broken variant before believing a -zero. Then subtract the noise floor: capture the *same* tree twice and diff -that too. Live values move constantly — the control centre differs from itself -by ~134k px eight seconds apart (CPU/RAM/temp), the battery popover by its time -estimate, several settings pages by their clock previews. A panel's outer edge -blends with whatever is behind it, so `-shave 12x0` before comparing. - -## Traps - -- **An unqualified reference to another singleton's property lints clean and - throws at runtime.** Inside a Quickshell `Singleton`, qmllint cannot resolve - the scope, so `threadMap` where `T3Threads.threadMap` was meant produces no - warning — and every call throws `ReferenceError`, invisibly except in the - journal. This shipped three times during the T3 split (WP5.1, WP5.2, and - settle/snooze after Phase 5). `tests/quickshell/t3-singleton-scope.test.cjs` - now enforces a watchlist; extend the list when a new cross-singleton name - appears. -- **A `Connections` handler that matches nothing on its target is silently - dead.** qmllint has no opinion, the configuration loads, and Quickshell logs - one WARN at reload and never calls it — so the code reads as wired and does - nothing. The T3 façade makes this easy: views only talk to `T3Code`, so a - handler for state that still lives on `T3Drafts`/`T3Detail` looks right at - both ends. Shipped three times (`threadDrafts`/`userInputDrafts`, - `newThreadConfirmed`, `detailThreadId`). Re-export on the façade — a - property binds, a signal needs its own declaration plus a relaying - `Connections`. `tests/quickshell/connections-handlers.test.cjs` now resolves - every handler against its target singleton's real surface. -- **In-place mutation never re-evaluates a binding.** Mutating an object or - array in place is invisible to QML; reassigning it notifies. Both behaviours - are useful — a memo cache wants the former, an invalidation wants the latter - — but mixing them up silently breaks either the update or the performance. -- **A counter written from inside a binding can feed back into the binding - graph.** Instrumenting `iconSource` with `property int` counters drove the - shell to 171% CPU and 6.6 GB and filled `/run/user/1000` with a 3.1 GB log, - after which the restarted instance could not create its IPC socket. Count in - a `.pragma library` script instead — module scope is not a QML property, so - nothing can capture it. Note such scripts are **cached past a hot reload**; - changing one needs `systemctl --user restart quickshell.service`. -- **A `.js` imported without `.pragma library` gets a separate copy per - importing component.** Fine for stateless helpers, useless for shared state. -- **`readonly property int` silently truncates.** Card heights are text metrics - plus padding and land on fractions; an `int` cost a pixel and shifted - everything below it. Use `real` for anything derived from text. -- **Quickshell does not watch `qmldir` files.** A qmldir edit needs a `.qml` - touch before it reloads. -- **`Loader.active` defaults to true**, so `onActiveChanged` fires only for - slots that evaluate false. Absence of `active=true` lines is a logging - artifact, not a gate that failed. -- **`signal-handler-parameters` cannot be satisfied** for - `Process.exited(int, QProcess::ExitStatus)` — the enum is not registered with - QML. The apparent fix (`function onExited(exitCode) {}`) silences qmllint - *and* stops Quickshell calling the handler. Disabled in `.qmllint.ini` with - that reason. -- **Quickshell emits no `exited` at all when a binary cannot be launched** — - only the falling edge of `running`. Anything reading exit status must handle - a never-started process; `Common/ProcHelpers.js` has the sentinel. -- **Popout lifetime**: `PopoutHost` latches the Control Panel, so it is - constructed once and never destroyed. Anything refcounted must key on - `visible` via `Common/Claim.qml`, not on construction or destruction. -- **Idle CPU is not measurable with `top` on a machine in use** — sampling the - live shell gave 0.12%–6.44% across windows of *identical* code. Instrument - both trees and count timer firings in the journal instead. -- **A pixel-identical bar is not a working bar.** WP4.3 shipped a module - registration regression that looked perfect in a screenshot. `tests/run` - carries a duplicate-handler check because qmllint has no opinion on that and - the failure mode is a shell silently running stale code. -- **`hyprctl cursor.move` warps the pointer without delivering hover to the - client.** No `MouseArea` under it sees `onEntered`, and `containsMouse` stays - false — verified by probe, with the bar's own `HoverHandler` reporting - `hovered: true` and a live position at the same moment. The cursor *shape* - still changes, so a screenshot looks like a real hover and is not one. A - uinput virtual pointer emitting `EV_REL` a pixel at a time does generate real - motion, but Qt's legacy hover path still did not pick it up here — so testing - anything gated on `containsMouse` needs a human hand on the mouse. What is - testable without one: pin the raw hover state to `true` in the deployed copy - and check what the bar-wide validation does with it. -- **Binding an item's visibility to a descendant's `visible` latches it at - false.** `visible` reads back *effective* visibility — the item's own flag - ANDed with its parents' — so a wrapper written as `visible: child.visible` - depends on itself and can never leave false. It looks correct as long as the - child starts visible, which is why it survived review: only modules that turn - on *later* (a track starts playing, updates appear, a tray icon registers) - stayed missing. Bind to the underlying condition instead — `Loader.active`, - not `Loader.visible`. -- **A defaulted property that a safety check depends on will eventually be - left unset.** `BarIcon.host` looked like panel wiring, so the idle module — - which owns no panel — never set it, and `BarTooltip` silently fell back to - the local `containsMouse` it exists to second-guess. A missed exit event - then stranded "Idle inhibit off" on screen with no path back to false. - `host` is `required` on `BarIcon`/`BarChip`/`BarTooltip` now and - `RequiredProperty` is an error in `.qmllint.ini`; the general lesson is that - a null-degrades default turns a loud failure into a silent one. -- **Lock state comes from `hyprctl locked`, never logind's `LockedHint`.** - Nothing in this session sets the hint, so it reads "no" behind a working - lock screen. A lock helper that times out must leave a running locker alone: - stopping it unlocks the session with nobody at the keyboard, and staying - locked is the only safe failure (`cybexos-session-action`). -- **`hyprctl` speaks the Lua config too.** `hyprctl dispatch X` runs - `hl.dispatch(X)`, so X must be an `hl.dsp.*` expression; a bare `exit` is - nil and does nothing. `hyprctl keyword` is refused under a Lua config and - still exits 0; set options with `hyprctl eval 'hl.config({ … })'`, which - fails loudly. `HyprlandToplevel.address` is bare hex, while the `address:` - selector needs the `0x` prefix. A window selector that does not resolve - falls back to the *active* window, so closing or moving a window that has - just gone away acts on whatever has focus: check the address is still in - `Hyprland.toplevels` right before dispatching, and build the string with - `Common/WindowCommands.js`, which refuses anything it cannot quote. -- **`HyprlandToplevel.lastIpcObject` comes only from a toplevel refresh.** - Quickshell follows titles, workspaces and focus from Hyprland's events, but - the `hyprctl clients` record (geometry, class) arrives with - `Hyprland.refreshToplevels()`; a window opened since the last refresh can - have `{}` there. The window overview refreshes when it opens and after - window events while it is up. For a live application id, read - `toplevel.wayland.appId` instead. -- **`ClippingRectangle` draws its content through a `ShaderEffectSource`.** - Around live content (a `ScreencopyView`) that is an extra offscreen pass on - every frame, and nesting multiplies it. Clip static layers only; the - overview clips each workspace's wallpaper, not its window captures. -- **A bare `qs ipc call` reaches nothing.** The shell runs by path - (`qs -p `), so there is no default configuration to find. Scripts, - bindings and docs use `cybexos-runtime ipc TARGET FUNCTION …`, which - resolves the path the service started with, development checkout included. -- **`luajit` reading a script from stdin without `-` exits 0 after a failed - assertion**, so a heredoc fixture can never fail. Write `luajit - <<'LUA'`. -- **FileView `setText` skips text it believes it already holds.** It compares - against the last bytes it read *or tried to write*, a failed attempt - included, and a match emits neither `saved` nor `saveFailed`, so a write - guard waiting for one never clears. Settings and Notes track that text - (`storeText`), settle without writing when the file already has the - content, and retry identical content with one extra trailing newline. - Quickshell logs a failed atomic commit (fsync or rename) and still emits - `saved`. Never call `setText` under a live async write; a `reload()` issued - under a write is dropped, which is why Settings' `reloadStore()` defers it. -- **Qt refuses to clear `activeFocusOnTab` on the focused item.** A roving tab - stop that follows the selection must `forceActiveFocus()` the new target - before committing it, or the old item keeps a second tab stop. -- **hypridle's `condition_cmd` runs synchronously in its loop** (0.1.8), so - keep it fast and bounded. A failing condition skips the listener's - `on-resume` as well as its `on-timeout`, and any input cancels pending - `condition_retry` attempts. -- **Python retries `poll()` and `sleep()` after a signal handler runs**, so a - handler alone never ends an unbounded wait: register the loop's pipe with - `signal.set_wakeup_fd` and poll it too (`xps-haptic-touchpad`). -- **`pactl` translates the headers of its long listings** ("Sink Input #" is - "Afvoer-invoer #" in Dutch). Parse them under `LC_ALL=C`. -- **An EDS connect wait of 0 means "wait forever".** `calendar-events.py` - connects its sources in parallel with a one-second wait, and at its deadline - returns the calendars that answered plus a `sourceErrors` entry for each - that did not. - -## Churn and lifetime invariants (2026-09 robustness pass) - -A review of efficiency and robustness left these contracts in the code. Keep -them when changing the surrounding files. - -- **Streaming transcripts update rows, not arrays.** `T3ThreadPage` and - `HermesTranscript` draw from a `ListModel` keyed by message id and apply - edit scripts (`T3CodeHelpers.historyRowOps`, `HermesHelpers.listSyncOps`); - a token is one `setProperty`. Per-row UI state (expanded, editing) lives on - the page keyed by id. T3 detail histories stay sorted, so `upsertHistory` - inserts by binary search instead of re-sorting. Hermes stores messages in - place: bind to `transcriptRevision`/`transcriptChanged`, not to - `messagesByConversation`. -- **Repeaters over derived lists take a structural key.** Bar clusters, - indicators, workspaces, the drawer network list, and the - GitHub Inbox and repository rows parse their model from a JSON string of - ids, and delegates look up live data by id, so a value change flows through - bindings instead of recreating delegates. GitHub Inbox sections and drawer - Bluetooth sections are fixed models; drawer Bluetooth and Sound rows go - through `ScriptModel`, which diffs them by device and node identity. -- **`Settings.applyLoaded` assigns only changed keys** (`assignChanged`) and - ignores a reload whose bytes equal the last write. Reassigning an unchanged - var key still notifies and rebuilds every bar module. A settings file from a - newer schema is applied read-only and never saved over. -- **Popout slots are visible only while fronted, fading or requested.** An - outgoing panel turns invisible when its fade ends, which releases its - `Claim`s and visible-gated timers. A presented slot is fronted before its - card turns visible; otherwise the latched drawer counts as visible for one - turn, and its tab's claims start and release their pollers on every - unrelated open. A spinner's `running` includes its own `visible`, since the - outgoing panel stays alive until it closes. -- **One `nmcli monitor`** lives in `NetworkStatus`; `EthernetState` listens to - its `monitorEvent`. Both 30 s safety polls run only while it is down, and - restarts back off 5 s → 60 s. Battery health follows the same rule with - `upower --monitor-detail`: a safety poll runs only while its event stream is - down. Tailscale polls every 120 s for plain `acquire()` claims and every - 30 s for `acquireLive()` views, neither while idle; a missing binary drops - it to an hourly probe, and a view that opens probes at once. -- **Tailscale sign-in** uses the Network overlay for first-use setup and the - shared `Tailscale` singleton for the pending session. Continue releases the - overlay's keyboard focus before browser handoff or a polkit prompt. Login - URLs stay in memory; closing the dialog keeps sign-in running. A two-second - status poll lasts up to five minutes, and only a successful status snapshot - with a Tailscale address reports connection success. `NeedsMachineAuth` - displays administrator approval separately. Stop connecting uses `down`, - preserving the account. Commands are bounded by `timeout`; permission - failures retry once through `pkexec`, without changing the machine's - operator or polkit policy. First sign-in consumes `up --json` incrementally; - reconnection uses bare `up` to preserve non-default preferences (even adding - `--json` changes Tailscale's preference checks). The existing-account path - also recognizes a standalone HTTPS authentication URL on stderr. -- **The Network panel's live figures cost no process per sample.** Throughput - reads `/sys/class/net//statistics` through FileView on the 1.5 s tick, - and `primary` carries those live counters; latency comes from two - long-running `ping -n -O` processes. The device/route/profile/scan snapshot - runs on `NetworkStatus.monitorEvent` (debounced), on a change in the scanned - SSID set, after actions, and on a 10 s safety poll. All of it runs only - while a view holds the panel. -- **Plugin discovery** watches `plugins.json`, which `plugin update` touches. - Package trees are polled every 5 min only while a plugin is enabled (2 s - while Settings is open), never while idle, and a package is rehashed only - when its stat signature in `/.revisions.json` changes. A - registry error keeps the last good plugins. A successful scan clears - discovery failures even when its output matches the last good scan. Scan - health and mutation failures are separate: a rescan cannot hide a failed - preference write. Registry lock acquisition waits at most five seconds, - then reports that preferences are busy and asks for a retry without - changing preferences or interrupting the holder; queued edits continue. - Git clone and fetch remain outside the registry lock. -- **Long-lived helpers are bounded.** `gh` reads, brightness reads/writes, - matugen, `calendar-events.py`, wallpaper thumbnails and the plugin scanner - each have a timeout or watchdog. Helpers settle on the falling - edge of `running`, so one that never starts cannot wedge its queue; that - includes the Reminders list, the plugin writer, the launcher's search - processes and the brightness re-read a mid-read refresh leaves pending. -- **Settings, Notes and launcher usage write asynchronously.** Settings and - Notes keep one write in flight (see the FileView trap); launcher usage is - written 2 s after a burst of launches, and synchronously if the shell goes - first. - -### Idle and energy (2026-09-23) - -- **`Common/Activity.qml` is the one idle and power signal.** `idle` comes - from an `IdleMonitor` (300 s, respecting inhibitors, off outside a Wayland - session); `onBattery` and `powerSaver` follow UPower and the power profile; - `resumed` fires on the first input after idle. Background pollers stop while - idle and refresh what went stale on `resumed`. A timer longer than an idle - spell (wallpaper rotation) keeps running, and work that falls due while idle - is owed to `resumed`. -- **GitHub gates scheduled timers on `scheduleActive`**: the module - is on, the session is not idle, and the network is not known to be down. - Manual refreshes are never gated. -- **Poll only for a consumer.** Updates checks in the background only for its - widget or its notifications; opening the Updates panel or the drawer - Overview refreshes a stale count. Weather fetches only for a set location - with its widget on or a Day sheet claim; the calendar polls only for a Day - sheet claim. -- **Updates retries only what failed.** The retry budget resets on the online - edge, startup, a manual refresh or an all-success check, never on a - scheduled poll, and each source has its own notification baseline. dnf's - answer is reused while the repomd/repo/rpmdb signature is unchanged; manual - and post-run checks force a real read, and one happens at least every 6 h. -- **Never reload a watched FileView on a timer.** Each reload rebuilds its - inotify watches, and the directory watch already sees creation and atomic - replacement. Poll only while the file is missing, and make sure its - directory exists (Recorder, Dictation). -- **Parsed commands set `LC_ALL` through the Process `environment`**, not an - `env` process per run. -- **Integration sockets exist only while wanted.** `T3Connection.enabled` and - `HermesConnection.enabled` follow their bar module, or their panel while it - is open, and `T3Connection.connect()` is the single gated entry. Naming a - connection singleton constructs it, so ShellHealth checks `Settings.mods` - first. Backoff starts over only once a link has proven healthy (T3: its - first shell snapshot via `markHealthy`, or 60 s connected; Hermes: 60 s), - never merely because a socket opened. T3 retries rest while the machine is - known offline (a loopback server is exempt) or idle, Hermes's only while - idle. An expired T3 Connect session is a state, not an error: the ticket - helper exits 3, the link goes signed-out without retrying, and the panel - offers Sign in. -- **Nothing ticks faster than it reads.** The recording mark steps with - `Recorder.elapsed` instead of an infinite animation; the mic meter samples - at ~15 Hz into whole pixels; reminder countdowns wake at - `CountdownHelpers.soonestChangeMs`; HH:mm captions use a minutes clock that - runs only while visible. -- **Power saver** is part of `Theme.reducedMotion` (`Activity.powerSaver`). - The compositor side is `cybexos_power_saver()` in `looknfeel.lua`, whose - state lives in `_G` so a config reload reapplies it. - -## Layout - -- `Common/` — singletons (services, settings, theme), pure `.js` helpers, and - the shared controls both other directories draw (`Toggle`, `HSlider`, - `NotifCard`, `NotifIcon`, `NotifActions`). -- `Bar/` — the menubar, the popout host, and 13 modules under `Bar/Modules/` - sharing a `BarModule` base. `Cluster.qml` is what turns a run of adjacent - modules into one shared pill (see `LayoutHelpers.groupModules`); `Bar.qml` - owns the furniture at either end and the fit pass. -- `Popovers/` — panel contents, all built on `PopoutPanel` / `Surface`. -- `Settings/` — the settings window; rows build on `Settings/SettingsRow.qml`. - Keyboard and Touchpad edits belong to the session-owned `Settings/InputDrafts` - singleton: search, page navigation and closing the window retain them until - Apply or Discard. Their original backend version stays with the draft, and - a completion settles only the submitted section, even after its page unloads. - Network checkpoints and display trials retain their own rollback lifetimes. - Search matches catalog translations and English aliases; non-preference - destinations use `searchAnchor`, separate from `shell.json` keys. -- `tests/quickshell/` — Node tests. `shell.cjs` locates the source tree; - `load("X.js")` pulls a helper out of `Common/`. - -Two conventions worth knowing: a settings row that names a `settingKey` gets -its value, dirty state, commit and undo from the base, and per-surface styling -travels as a single `var` object (`Theme.switchRow`, a card's `style`) rather -than as a dozen properties. - -### Launcher providers and actions - -`Common/LauncherProviders.qml` owns command-palette routing, results and side -effects; `LauncherView.qml` only renders its normalized rows. Every open and -every close resets to the Apps tab; Emoji, History (clipboard), and Actions -are discoverable tabs beside it in a compact 460px card. The strip is 34px -tall, the search field is 44px, and an up-to-eight-row viewport uses 42px rows -with 28px icons. The Apps tab keeps every visible desktop entry in its model -and scrolls inside that fixed viewport; keyboard selection keeps the active -row in view. Rows show one line only; action subtitles and keywords remain -searchable metadata but do not add visual bulk. - -The launcher's layer surface is the card's full-height envelope plus its -entry travel, anchored top-left at offsets from the output edge, not the whole -output: the compositor blurs every pixel of a blurred surface. Clicks outside -it clear the `HyprlandFocusGrab`, which closes the launcher. - -`Ctrl+Tab` and `Ctrl+Shift+Tab` cycle tabs. `Left`/`Right` also cycle tabs -while the input is empty. With text or an argument prompt, `Left`, `Right`, -`Home` and `End` retain normal caret, word and selection editing (including -Ctrl and Shift). With empty input, `Home`/`End` select the first/last result. `Up`, `Down`, -and result-navigation `Tab` wrap at the list ends; `PageUp`/`PageDown` jump six -rows and clamp to the first or last result. `Alt+1…8`, immediate -`Enter`, and clipboard `Shift+Delete` remain available. Escape clears a -non-empty query and returns to the active tab's full results; a second Escape -closes the launcher. The result highlight and its glyph color change -immediately without a transition. Switching tabs clears the search field so -results never carry across provider boundaries. - -Typed prefixes temporarily override the selected tab: `/` files, `>` command, -`=` calculator, `@` web, `$` windows, `;` clipboard, `:` emoji, and `!` actions. -Removing the prefix returns to the selected tab, so the compact tab strip does -not displace the existing keyboard-first routes. - -Clipboard history is collected by `cliphist`. The store helper skips offers a -password manager marks as secret (`x-kde-passwordManagerHint`, reported by -`wl-paste --watch` as `CLIPBOARD_STATE=sensitive`). In the History tab, -`Enter` copies the selected entry and, once the launcher has released keyboard -focus, pastes it into the previously active window; `Shift+Enter` only copies. -`Ctrl+P` pins or unpins the selected entry, and the search field names that -key. Pinned entries are listed first with a pin glyph. They are copies kept by -`scripts/clipboard-tool.py` under `~/.local/share/cybexos/clipboard/` (at most -50 entries and 64 MiB; text up to 256 KiB, images up to 10 MiB), so cliphist's -500-entry pruning and `cliphist wipe` do not remove them; a full store refuses -a new pin rather than dropping an old one. `Shift+Delete` or right-click -removes a history entry, or unpins a pinned one. Image entries show a -thumbnail, and the selected image grows its row to show a larger preview. -Previews are decoded into `$XDG_RUNTIME_DIR/cybexos/clipboard-previews/`, -which holds only the images the current history lists and is bounded to 64 -files and 48 MiB. `Super+Ctrl+V` opens the launcher on the History tab. - -Emoji names come from Fedora's `unicode-emoji` data. Activating an emoji -copies it and pastes it the same way; `Shift+Enter` only copies. Both -providers degrade to a readable empty-state error when their package is -unavailable. - -`cybexos-runtime ipc launcher open ` opens the launcher on a tab -(`apps`, `emoji`, `clipboard`, `actions`), or on Apps with a provider's prefix -typed (`files`, `command`, `calculator`, `web`, `windows`). An open launcher -switches to it rather than closing. - -#### Actions: the command palette - -The Actions tab is a curated catalogue in `Common/LauncherProviders.js`, -grouped by verb in this order: Install, Remove, Set up, Toggle, Update, -Capture and System. Each row shows its group on the right. Entries run a -command from `~/.local/bin` (where both installation paths put the CybexOS -helpers), open a Settings page, or call the shell directly. Captures start only -after the launcher has left the screen. Entries for a feature the installation -left out are hidden: the AI agent entries need developer tools. Factory reset -is deliberately not in the launcher; it stays in Settings. - -A typed Apps query also lists up to three actions after the app matches, each -marked "Action", when the query starts a word of the action's name or -keywords. The empty Apps query stays the plain application directory. - -Some actions need one value first. **Install web app…** asks for a web address -(a bare host gets `https://`), **Install theme from Git…** for an https or SSH -repository, and **Remove web app…** lists the installed web apps; the chosen -row asks for a second `Enter` before it removes anything. The search -field becomes that prompt, its chip names the action, and `Escape` returns to -the list it came from. `Enter` runs the command with the value as its own argv -element; no shell parses it. Commands without a value run under a fixed -wrapper that raises a notification when they fail. - -`launcher-actions.json` at the shell root adds the user's own actions; they -are listed with the tag Custom. The file is watched for changes. Each user -action must provide a display name and an argv-style command; a string shell -command is rejected deliberately. For example: - -```json -[ - { - "id": "notes", - "name": "Open notes", - "subtitle": "Open the notes folder in Nautilus", - "keywords": ["documents", "writing"], - "command": ["nautilus", "/home/alex/Documents/Notes"] - } -] -``` - -## Dialogs are the menubar unrolled - -Every surface in the shell — the settings workspace, T3 Code, the GitHub -workspace, the control centre, the network and audio panels, the notification -centre and its toasts, the launcher, the OSD, the shortcut sheet — used to be -a stack of filled, bordered cards on a lighter surface, and two of them were -in a face of their own. They all follow the menubar now. Four rules, and -`Common/Theme.qml` carries the tokens: - -- **One surface.** A dialog sits on `Theme.panelSurface` (the shell's deepest - surface, glass-aware) with no card stacked on it. `Theme.chip` / - `Theme.chipHover` are the only fills left inside: a text field, a row that is - current, a segment that is taken. -- **A section is a label plus a hairline.** `Settings/SectionHeader.qml` is the - shape — uppercase `fontMicro`, letter-spaced, then a rule to the edge. - T3's inbox groups and the GitHub workspace draw the same mark inline. - `SettingsGroup.qml` is a layout, not a Rectangle; there is nothing left to - paint. -- **One accent, four places.** The current workspace pill, a live status dot - and its working label, the current page's icon in the settings rail, and an - on-switch track or selected swatch ring. Never a nav-row background, never a - selected segment fill, never a wash behind a title, never a slab behind the - selected launcher result or a connected device. `typography.test.cjs` bans - `accentBg*` / `accentSoft` / `accentSubtle` / `accentContainer` as a `color:` - or `border.color:` shell-wide, with a short allow-list naming the four fills - that earn it: a slider's value readout, a switch or quick-toggle track, the - one primary action per panel, and the current-day / current-workspace pill. -- **One face.** `Theme.fontMenu`, the Typography setting, everywhere. - `T3Theme.fontUi` is the T3/GitHub indirection. `Theme.fontSans` is now only - what `fontMenu` falls back to; **naming it in a view is how a surface opts - out of the setting**, which is exactly the bug this closed, so - `typography.test.cjs` bans it outside Theme itself. - -Metrics live in Theme's `---- dialog metrics ----` block: `panelRadius` follows -`Settings.barRadius`, so squaring the menubar squares the panels under it; -`panelRowHeight` 28 is a settings row, `listRowHeight` 34 is one menubar-tall -list row, `panelTileHeight` 48 is the occasional two-line form, -`sectionHeaderHeight` 22 is the mark above them, and `panelHeaderHeight` / -`panelFooterHeight` are a panel's title block and its one-line footer. - -Two aliases changed meaning rather than value: `Theme.cardFill`, -`Theme.tile` and `Theme.insetSurface` now resolve to `Theme.chip`, and -`cardRadius` / `rowRadius` / `tileRadius` to `chipRadius`. There are no cards -left, so the names that meant "a container with a fill and a border" mean the -menubar's resting chip — which is why most panels needed no edit of their own. -`popRadius` follows `panelRadius`. `surfaceRadius` did **not** move: it is -Hyprland's window rounding (`roles/desktop/files/looknfeel.lua`) and the Hug -corners that must match it, and `bar-geometry.test.cjs` pins the pair. - -Two things this pass had to fix, both worth remembering: - -- **A fixed pixel lane beside a text label breaks when the face changes.** The - GitHub inbox positioned its Settled count at `leftMargin: 62`, which cleared - the word only in a proportional face; in JetBrains Mono the two overlapped. - Anchor a count to `label.right`, never to a measured constant. Lanes that - clear a fixed-size *icon* (the 30–32px ones) are fine. -- **Compact a row as a layout change, not a token change.** T3 first moved its - inbox to one line; GitHub later followed. GitHub's Inbox is deliberately only - a coloured status glyph and meaningful title; workflow rows prefer GitHub's - run display title over generic workflow names such as `CI`. Repositories and - commits keep their context in bounded lanes beside the title. Simply - shortening the old two-line card would draw its detail through the next - section header. - `github-inbox-structure.test.cjs` requires all three lists to use the shared - flat row and pins the Inbox's quieter status treatment separately. - -`SettingsHelpers.semanticPalette` also gained a real step at every level. It -built the ladder with `ensureContrast`, which only ever *raises* a colour, so a -Material palette whose `onSurfaceVariant` already cleared 7:1 returned the same -tone for all five steps — in wallpaper mode every label, value and piece of -metadata rendered identically. `paletteTone` folds the tone back toward the -background when it over-clears, so each step lands on its own floor. - -## The edge drawer and the Day sheet (2026-09 redesign) - -The 2026-09-03 redesign ("Quickshell Menubar", Claude Design project -`8cf85161`, direction 2) introduced the attached surface family: - -- **The Control Dashboard** (`Popovers/Drawer/`) is one edge-drawer surface - with six tabs — - Overview · Sound · Network · Bluetooth · Power · Notifications. Every - established popout name (`control`, `audio`, `wifi`, `bluetooth`, - `tailscale`, `battery`, `notifications`) still works from IPC and - the bar; each one presents its tab of - `DrawerPopover.qml`. The tab is derived from `Popouts.currentName` at - creation, and the drawer's own tab strip navigates by reopening the - canonical name for the wanted tab (`PanelRegistryData.nameForTab`), so the - bar's held states, hover-crossing and the module-ownership sweep all keep - working unchanged. -- **Updates is a dedicated edge drawer** (`Popovers/UpdatesPopover.qml`). The - widget and `updates` IPC name open its full pending, running, completed, and - failed views rather than deep-linking to Overview. It still carries the new - drawer template's width, attached geometry, Hug corners, palette, and type. - Every state uses one layout. A one-line header states what is happening. - Fixed System, Apps, Firmware and CybexOS rows move from a count to progress - to a result. There is one primary action (Update, Restart now or Try again). - A collapsed **Details** section holds package names, the live transaction, - dnf's own error and the recovery point. Keep backend vocabulary (dnf, - Flatpak, poll cadence, log paths) and negative results ("no restart - needed") out of the rows and header. -- **Firmware installs inside the update run, never in a terminal.** When - firmware is pending, `Updates.run()` passes `--firmware`. The durable worker - then runs `cybexos-firmware-update` (libfwupd over D-Bus, as root) after - the packages and streams JSON events to `firmware-events.log`, which the - panel reads like the dnf and Flatpak logs. fwupd's device requests (such as - replugging a dock) appear as a card under the Firmware row. Capsules staged - for the next boot turn the run's restart recommendation on. On battery, a - device that requires AC power is left out of the run, and the row asks for - power instead of letting fwupd fail the flash. A firmware failure never - fails the package update; the row explains it. -- **T3 Code is a separate attached panel**, not a seventh status tab. Its - existing `t3code` panel name, bar ownership, source, and IPC route are - unchanged; unlike the status drawer, it is not pinned to a screen edge, so - it follows the T3 widget when that widget is reordered or moved between bar - sections. It uses the wider `Theme.t3MaxWidth` measure and hugs the active - page's content until it reaches the host's usable-height cap, at which point - only the page's content viewport scrolls. - Inbox search and connection chrome stay fixed around a scrolling grouped - list; thread headers and response/composer controls stay fixed around the - transcript; New Thread scrolls its form below a fixed header. Below 360px of - effective content width, inbox rows become two-line tiles and composer - reasoning moves into Run settings. Picker geometry is clamped to the drawer - body and New Thread reserves popup room in its own scroller rather than - extending a transparent surface tail. -- **The Day sheet** (`Popovers/DaySheetPopover.qml`) hangs under the clock - (and the weather pill): big time, today's sky, a Monday-first week strip - with per-day forecast and calendar event dots, and the next three events. -- Clock and Weather options share `Settings/WeatherLocationPicker.qml`. - City search uses Open-Meteo geocoding (GeoNames), debounces typing, and - distinguishes matches by region, country, and coordinates. Typing previews; - Enter applies a sole match, while ambiguous results require selection. - A selection saves the name and both coordinates in one settings write. - Clearing or dismissing a search preserves the saved location; timeouts and - stale replies cannot overwrite it. Manual coordinates remain available. - This sets the forecast location, not the system clock's time zone. -- The registry gained two flags: `attached` (flush under the bar, squared - bar-side corners, Hug-corner bridges drawn by `Bar/PopoutHost.qml`) and - `edge: "right"` (pinned to the screen edge instead of centred on the - trigger). Both are read by the popout host; detached panels are untouched. -- The visual system moved with it: warm charcoal surfaces (`#1a1917` bar and - panel, chartreuse `#d3d283` accent), Figtree as the default UI face, and - `Theme.fontNumeric` (Geist Mono) for every instrument reading — the clock, - percentages, meters, resets. Clock+weather group into one filled `time` - pill; notifications joins the filled vol/wifi/bt/batt `status` pill by - default and can be separated under Widgets → Notifications. Grouping still - follows adjacency (`SettingsHelpers.MODULE_GROUPS` / `FILLED_GROUP_KINDS`); - the usage chips - carry a 2px remaining meter; the bell wears an unread dot instead of a - count. -- The pre-drawer popovers (`AudioPopover`, `WifiPopover`, - `ControlCenterPopover`, …) are no longer reachable from the registry but - remain in the tree with their tests until a deliberate removal pass. - -## Layered Hug, glass, and the wallpaper palette - -The 2026-08-15 redesign ("QuickShell Menubar", Claude Design project -`facd7f56`) replaced an opaque bar and its bar-fused popouts with translucent -glass and detached panels. What that added, and what it needs: - -- **Pinned shell fonts.** The UI typeface remains configurable. Generic - interface icons use bundled Tabler 3.48.0, loaded once by - `Common/TablerIcons.qml`; no icon font installation or font-cache refresh is - needed. Product marks still use `BrandIcon`, and application icons use the - desktop theme. -- **Icons use an explicit registry.** `Sym { name: "wifi" }` resolves through - `Common/TablerGlyphs.js`. Existing semantic names remain as compatibility - aliases for built-ins and plugins; canonical bundled Tabler names also work. - Unknown names show help-circle and empty names draw nothing. `Sym` reserves - a square slot and always draws the outline variant, including active states - and playback controls. Selection uses colour, backgrounds, borders, labels - and checkmarks; pinned thread actions use accent ink and favourite stars - use amber. Icon colour fades, press feedback and spinners remain. Legacy - `fill`/`animateFill`/`glyphFill`/`symbolFill` inputs are accepted but inert, - as are `symWeight`/`grade`. The filled font is no longer shipped. - `tests/quickshell/tabler-icons.test.cjs` checks names and actual codepoint - coverage in the bundled font, without system dependencies. Brand/application - artwork and functional shapes such as switch tracks and progress meters - retain their own presentation. - Update assets with `scripts/update-tabler-icons`; see `assets/tabler/README.md`. -- **Blur is the compositor's.** `roles/desktop/templates/looknfeel.lua.j2` - defaults to one blur pass to reduce desktop GPU work while keeping - animations and display refresh rates unchanged. Both Ansible deployment - and the ISO desktop package render this template. - It exports the named `quickshell_blur_rule` matching the `qs-*` namespaces. - The Appearance switch calls that handle through `hyprctl eval`; its initial - `enabled` value is read from the persisted JSON so compositor reloads retain - the choice. - Layer namespaces stay fixed because changing one after a Wayland surface is - connected does not update the compositor rule safely. -- **Nothing that floats over the desktop may draw a drop shadow.** Blur is - applied per pixel of the *surface*, and every one of these layers is larger - than the shape it draws — the menubar's runs past the slab to leave room for - tooltips, a panel's runs past the card. Anything painted into that margin is - blurred with the shape, at the full size of the layer, so a shadow does not - read as a shadow: it reads as a haze band the height of the whole surface. - Both the design's `0 20px 50px` shadows shipped that way and both were - reported. Raising `ignore_alpha` only trims the falloff — the shadow is at - full strength directly under the shape, which is exactly the band you can - see. Glass over a real blur already reads as floating; the hairline border - and the rim highlight do the rest. Glows *inside* a surface (the focused - workspace pip, the T3 running dot) are fine — they composite over the glass, - not into the margin. -- **Render semantic surfaces, never raw variants.** `Theme.barSurface`, - `surfaceStrong`, and `surfaceMenu` select translucent glass or their opaque - references from `Settings.glassEnabled`. Directly painting `Theme.glass*`, - `popBg`, or `barBg` bypasses that switch. Modal scrims are deliberately - separate: they remain translucent safety layers when glass is off. -- **Wallpaper mode is one validated Material palette.** `Common/Palette.qml` - runs Matugen's tonal-spot scheme for the selected wallpaper, whitelists the - semantic roles in `PaletteHelpers.js`, and atomically caches both light and - dark variants at `~/.local/state/cybexos/shell/wallpaper-palette.json`. Theme - changes select the cached variant. The menubar background remains the user's - independent bar-color choice while its accents follow this palette. Missing - or malformed Matugen output leaves the user's mode unchanged and renders the - stored fixed colors as fallback. Copy-bearing tones are still forced to a - 4.5:1 floor against their opaque reference surface. -- **Bar style is explicit.** `hug` is the default edge-attached slab with local - `QtQuick.Shapes` concave corners; `floating` alone uses the stored gap and - radius; `attached` is full-width and square. Hug/attached reserve exactly the - bar height, and the decorators travel with auto-hide without joining its - input mask. -- **State layers are shared.** `Common/StateLayer.qml` supplies the 8% hover - and 12% pressed/focused overlay used by bar primitives, workspace targets, - shared actions, toggles, and settings controls. Controls retain their press - scale and accessibility behavior. -- **One spring for continuous motion.** `Theme.springCurve` drives controls and - in-place movement. Bar popouts use a faster directional enter/exit and a - lower-overshoot morph between triggers. Colour and opacity never spring — an - overshooting fade reads as a flicker — so they use the ease curves. -- **The launcher is always keyboard-ready.** Its view and first eight - alphabetically sorted apps are constructed at shell startup, while - `Super+Space` reaches it through - Hyprland's global-shortcut protocol instead of spawning an IPC client. It - takes exclusive keyboard focus while mapped, forwards an early character or - Enter across the mapping frame, and never stages result rows behind an - animation. Launcher-only motion is brief and purely visual. -- **Schema 7 adopts the softer type and density pass.** A stored `Urbanist` - value from an older schema follows the new `Google Sans Flex` default; - OPPO Sans, IBM Plex Sans, and JetBrains Mono remain explicit choices. Shared - popovers gain modest width and padding, metadata floors at 11px, and soft - inner hairlines recede while outer surface boundaries remain intact. -- **Schema 6 adds bar style and palette mode.** A v5 attached bar remains - attached. A v5 floating bar adopts Hug only when height, radius, and gap are - pristine; custom geometry remains floating. Old wallpaper-accent users and - untouched colors adopt wallpaper mode, while active custom colors select - fixed mode without discarding either stored choice. Module order is never - part of this migration. `SettingsHelpers.adoptRedesign` still gives a v3 - file the schema-4 geometry only where the user never moved it. - -## Layout and foreground roles - -- Settings use `settingsRowSpacing` for ordinary rows, `settingsContentSpacing` - for related rich content, `settingsSubsectionSpacing` before a subsection, - and `settingsGroupSpacing` between groups. Do not use a page's group gap - inside a compact run of controls. -- `SettingsSubsection` owns its heading and leading separation. Put it **inside** - a `Revealer` so the separation collapses with the subsection. Keep full-width - content for `SettingsRow` children; set `insetContent` only for rich content - that does not already reserve `settingsMarkInset`. -- Standard rows keep the modified-state gutter in wide and stacked layouts. - Picker height follows its wrapped pills at either width; narrow captions - sit below the control. Reset lanes remain reserved. Section headings reserve - reset geometry even when clean; non-resettable subsections opt out of the - trailing lane. -- Use `SettingsField` for native settings text inputs, including standalone - forms. `SettingsTextRow` retains ownership of commit/reset/persistence wiring. - Plugin-kit fields continue using their configurable `Ui`/`Commons` styling. -- `SectionLabel` owns the bounded label/count/rule layout used by ordinary - popovers and the T3/GitHub group headers. Product-specific colors remain - overrides; their list row implementations remain separate. -- `Theme.accent` is the chosen fill/swatch color. `Theme.accentText` is its - contrast-adjusted foreground counterpart for native copy, icons and focus - outlines. Do not darken the stored accent to make a light-mode label legible. - T3 and Hermes retain their existing independently adjusted accent roles. - -## Already decided against — do not pick these up - -- **qmlformat one-shot reformat**: most files would churn and the tool fights - the deliberate hand-wrapped style. Revisit only as a dedicated commit with a - tuned `.qmlformat.ini`. -- **Restarting the shell on every converge**: Quickshell hot-reloads, and a - restart is more disruptive than the problem it solves. A converge that - changes the deployed tree or unit restarts once from the complete tree, - because sequential copies can trip rejected intermediate hot reloads, and - verifies it; an unchanged converge leaves the shell alone. Installing a new - shell font is the other exception: Qt does not add a newly cached face to an - already-running process, so the apps role `try-restart`s Quickshell after a - font install and leaves an inactive service alone. -- **The remaining perf items** (toast countdown timer, memoising - `Notifs.iconSource`, a `Clock` singleton, a launcher token index): measured - 2026-08-08 and declined. Two premises were already false in the code, and the - third does not reproduce — with eight notifications the icon lookups go - 8 → 24 → 96 and then flat, identically with and without a memo. Do not reopen - without new numbers. -- **Merging the remaining list rows into one `ListRow`**: they differ more than - the shared action buttons did. Reopen only if a fifth consumer appears. -- **`Theme.fontSans` → `Theme.fontMenu` in the launcher and toasts**: those are - overlay surfaces, not menubar chrome, so they follow the general UI face and - do not track the menu font setting. `typography.test.cjs` enforces the split. - -Still open: **deeper QML state-machine coverage**. The mandatory runtime stage -now exercises helpers under `qmltestrunner-qt6` and constructs, mutates, -signals, and destroys production controls under the real Quickshell engine in -CI. The next targets are the Settings load/merge/save cycle and the -`T3Connection` process/socket lifecycle against controlled test doubles. - -## Bluetooth drawer - -Bluetooth has its own tab immediately after Network by default. Existing drawer -settings gain the new tab beside Network while retaining their saved order and -visibility; it can then be hidden or reordered in Drawer settings. The Bluetooth -bar button and `popouts open bluetooth` open this same tab. - -The tab lists connected and paired devices for the default adapter, with battery -levels when available. Opening the tab while Bluetooth is on automatically starts -a 60-second discovery session and shows named nearby devices. Unpaired discoveries -with empty, address-only, UUID-like or hexadecimal identifier names are hidden; -paired and connected devices remain visible even without a readable name. -Turning Bluetooth on -while the tab is open also starts discovery. **Stop scan** ends it early, and -**Scan again** restarts it after it stops; revisiting the tab starts a fresh scan. -Pairing supports PIN entry, passkey entry/display and code -confirmation inline; successful pairing trusts and connects the selected device. -Errors remain visible for retry. Closing the drawer, switching tabs or powering -off Bluetooth terminates the tab's helper, rejects pending prompts, cancels its -pairing attempt and releases its discovery session. Other applications' scan -sessions and default pairing agent are left alone. - -`bluetooth-tool.py` uses the existing `python3-gobject` dependency and BlueZ's -application-scoped agent API. `tests/bluetooth-tool.py` checks input validation, -authorization, cancellation, action failures and discovery lifetime without -operating the host radio. - -## Widget editor - -Settings → Bar opens with a live preview of the bar pinned above the page: -the wallpaper, the bar at its position, style, height, gap, radius and -background, and each section's enabled widgets as their icons. Clicking a -widget there opens its options. Below it, the **Widgets** group lists Left, -Center, and Right as rows of compact pills with leading icons (no cards). -This includes widgets whose runtime conditions currently hide them from the -bar. Plugins without a declared catalog icon use the extension symbol. -Clicking a pill opens built-in or plugin settings in an embedded, scrollable -dialog; closing it returns focus to the pill. Each pill's ⋯ menu offers -Widget settings…, Move earlier/later, Move to Left/Center/Right, and Remove -from bar. The bar's Layout, Background, and Behavior rows follow on the same -page. This interaction design follows Noctalia's legacy QML bar editor; our -implementation uses Cybex components and storage. - -Disabled widgets sit in the **Add widgets** tray below the sections. A click -adds one to its own section; its ⋯ menu picks another. Control Center is a -normal widget: the Fedora button can move, be removed, and be restored. Its -options contain the tab, overview, and behavior controls. Settings search -opens this widget dialog too; there is no separate Control Center sidebar -entry. - -Schema 24 adds the Fedora widget at the right edge of older layouts and keeps -all other widget placements. Schema 25 retires the first built-in Model usage -widget (`usage`), its drawer tab and its `modOpts.usage`/`pollMax` settings. -Schema 26 adds `modelusage`, the Model Usage widget vendored from the -`digitalpals.model-usage` Omarchy plugin (see `ModelUsage/README.md`), at the -start of the right section of older layouts. It starts on when the install -has connected widgets. Its settings are `modOpts.modelusage`; sources, -credentials and cost servers stay in the panel's own forms, which Settings -opens. - -Drag a pill to reorder or move it between sections. The drag ghost and insertion -marker follow wrapped grid positions and the arrangement scrolls near its edges. -Alt+arrow keys reorder; right-click, Menu, or Shift+F10 exposes placement, -earlier/later, settings, and removal actions. Plugin blocks match the native bar: -before built-ins on the left and right, after them in the center. Drop indicators -snap to those supported boundaries. `Common/WidgetEditor.js` translates visible -gaps to stored indices without changing disabled entries or compaction preferences. - -Removal retains placement and settings and offers an eight-second Undo that -restores only that widget. Plugin success is confirmed by both the write result -and the refreshed membership/section before showing success. Section-specific -plugin adds enable and place the instance in one atomic registry write. The settings -writer skips already-persisted bytes before taking its in-flight guard: FileView -does not emit `saved` for an identical `setText`, which would otherwise block -subsequent edits after opening a form that re-applies an unchanged value. - -The layout actions menu contains presets, plugin management, built-in layout -reset, and layout Undo. Presets preview their enabled built-ins before applying, -preserve placement and plugin preferences, and use the existing eight-second undo. - -Plugin widget details expose width, saved/default setting values, and an -advanced key/JSON-value field. `configure-widget` changes widget enablement, -width, or the destination of a newly added widget atomically. Removing a widget -does not disable its package services or sibling instances. Settings are merged -through the plugin registry, never written into `shell.json`. The Omarchy plugins -page retains package installation, updates, cloning and removal; shared plugin -appearance controls live under Appearance. diff --git a/docs/recovery.md b/docs/recovery.md new file mode 100644 index 00000000..24fd2392 --- /dev/null +++ b/docs/recovery.md @@ -0,0 +1,81 @@ +# Recovery, persistence and garbage collection + +The updater serializes operations with a machine lock and publishes status and +pending records with fsync and atomic rename. Downloads use atomic cached files. +Evaluation/build happens in a private staged configuration; neither changes the +working system. Each transaction retains its full configuration, lock file, +input archive identities, candidate and previous system, and actual diff. +Indirect Nix GC roots protect these inputs and known-good systems. + +Activation tests the candidate before selecting its boot configuration. It saves +a durable pending record first. Failure restores the previous system and its +configuration. Before rollback, working personal edits are copied into the +transaction's `personal-before-rollback` directory; they are not discarded. +NixOS service-warning exit 4 is checked through the declared health services; +unrelated failed units do not prevent restoration. Genuine activation errors +still fail closed. A busy recovery unit defers to the supervised operation that +holds the lock, and rollback disarms a rejected trial before updating boot files. +An interruption at application boundaries is recovered by `cybexos-recover`, +which stays completed for the rest of that boot so activation cannot start a +second recovery worker. Interrupted staging is reported and can be staged again. + +`cybex update status` reports `recovery-failed` if restoration itself fails. +The durable pending record remains, and further previews are blocked. Inspect +`cybex update log ID` for private build, activation and restoration output; +correct the reported cause, then run `cybex update rollback ID` to retry the +retained restoration. The desktop keeps its Rollback and private-log controls +available. Rebooting into the retained known-good boot entry also resumes +recovery. A failed restoration never reports the previous generation as +restored, and its transaction retains both the original activation error and +the restoration error. These steps preserve personal configuration and state; +they do not promise application-data rollback. + +The previous generation stays the persistent systemd-boot default. The candidate +is a one-shot trial, selected once. A failed boot requires a user-selected reset +or reboot; the next boot returns to the previous default. There is no unattended +reboot or hardware watchdog reset. A successful fresh boot and authenticated +Hyprland/Quickshell login must pass `cybex update confirm` before the candidate +becomes the persistent default. Failed systems are quarantined against normal +reapplication. `cybex update history` and `cybex update rollback [ID]` expose the +retained identities and restore coherent system/updater configuration. + +System health requires the boot target, NetworkManager and login manager to be +active. When Libreswan is enabled, its managed `ipsec.service` must also be +active so Cisco IPsec connections have their required daemon. Add critical +personal services in `/etc/nixos/local/*.nix`, for example +`cybexos.updates.additionalHealthServices = [ "personal.service" ];`. +The shared modules generate `/etc/cybexos/health-services.json` for the updater; +disabled optional services are not required. Failed checks identify the unit +and retain the pending recovery record instead of accepting the deployment. +Selected XPS hardware also requires its haptic daemon and, in an authenticated +desktop, speaker tuning. The selected processed camera requires a separate +booted-kernel ABI check and the relay; an ABI failure blocks relay startup and +trial acceptance. These service checks do not prove processed frames, privacy, +image quality or suspend/resume; those still require physical qualification. + +From live media, unlock/mount the target filesystem and ESP, inspect +`/var/lib/cybexos/transactions` and its deployment records, and select the +retained previous system using its `switch-to-configuration boot` inside +`nixos-enter`. Restore `/etc/nixos` to that record's retained configuration as +well. This is an offline repair requiring explicit administrator intent, not a +routine update shortcut. Keep backups of ESP contents and enrolled Secure Boot +public certificates. + +Regular configuration Git metadata remains persistent outside source snapshots; +selected writable trees reference the retained metadata directory. Follow the +`.git` target before deleting any old working directory. Repeated rollback makes +a fresh working selection and preserves edits in earlier selections instead of +reusing them as the retained deployment configuration. + +System generations are not filesystem snapshots. Home files, credentials, +NetworkManager profiles, VPN keys, plugin data and service databases remain +persistent through activation and rollback. Personal backups remain separate. +Future data/schema migrations require an application-aware backup, explicit +migration metadata, tested rollback policy and compatible old readers. Never +silently downgrade a newer database or unknown preference schema. + +Automatic garbage collection is disabled by default. Ordinary Nix GC cannot +remove the updater's indirect roots. No automatic retention-pruning policy ships +yet; an administrator must inspect history and backups before explicitly deleting +superseded transaction roots. Keep at least the last confirmed system, previous +system, all pending trial identities and required configuration/input archives. diff --git a/docs/releasing.md b/docs/releasing.md index 5bdae14f..2ea07584 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,207 +1,147 @@ -# Publishing CybexOS releases - -CybexOS has two release surfaces. Source-checkout releases publish a -versioned source archive; ISO releases publish signed desktop and offline-apps RPMs, a bootable -ISO, and RPM update metadata. Both use a semantic-version Git tag and share -the repository source contract and generic Fedora VM gate. The release also -runs the image-source contract, then requires trusted PXE-host qualification -before publication. See [ISO and desktop RPM releases](iso-releases.md) for -runner setup, signing, the installer matrix, reconstruction, and channel -enrollment. - -The large offline application seed has its own version in -`image/offline-payload.json`. Run `python3 image/offline_payload.py --version X.Y.Z` -with a newer version after changing its reviewed application inputs. Packaging -refuses a stale input digest. Shell-only changes keep that version and RPM -release unchanged; desktop RPMs retain their source/build identity. The desktop -also requires the exact application input digest, preventing an unrelated -newer offline package from satisfying its installation contract. - -Treat a published offline NEVRA as immutable. Reuse its artifact for unchanged -inputs; if a moving upstream tool resolves differently, review it and advance -the offline version before publishing different payload bytes. Sign and ship -both RPMs together. The upgrade qualification installs both in one offline DNF -transaction and binds their checksums in the release report and SPDX SBOM. -The signing workflow authenticates the previous channel manifest and rejects -changed file contents or ownership under an existing offline NEVRA. Manual -repository signing must likewise pass `--previous-repository` with the previous -signed directory or its HTTPS URL. A missing public manifest (HTTP 404) allows -the first release; invalid signatures and other download failures stop signing. - -An older installed baseline can need additional Fedora RPMs for a candidate's -SDK dependencies. Supply those only as an optional qualification fixture with -`image/qualify ... --candidate-dependencies DIR`. Do not publish them as -first-party release artifacts. -Keep a flat directory of signed, verified candidate-build downloads and a -separate provenance JSON mapping each RPM filename to -`{"id": "fedora", "url": "https://.../original-repository/"}`. On a host with -`rpm`, create and verify its manifest with: - -```bash -python3 image/offline_upgrade_inputs.py create DIR --provenance provenance.json \ - --baseline-iso BASELINE.iso --candidate-rpm DESKTOP.rpm --candidate-offline-rpm OFFLINE.rpm -python3 image/offline_upgrade_inputs.py verify DIR \ - --baseline-iso BASELINE.iso --candidate-rpm DESKTOP.rpm --candidate-offline-rpm OFFLINE.rpm -``` - -Manual generators may write `DIR/manifest.json`: the exact top-level keys are -`schema` (integer `1`), `baseline_iso_sha256`, `candidate_rpm_sha256`, -`candidate_offline_rpm_sha256`, and `rpms`. Each RPM record has exactly -`filename`, `name`, `nevra`, `architecture`, `sha256`, `size` (bytes), -`signature` (`{"tag": "RSAHEADER", "value": ""}`), and -`repository` (`id` and HTTPS `url`). NEVRA includes the numeric epoch, including -zero: `name-0:version-release.x86_64`. Signature values come from -`LC_ALL=C TZ=UTC rpm -qp --qf '%{RSAHEADER:pgpsig}'`; `DSAHEADER`, `SIGPGP`, and `SIGGPG` are also -supported. The JSON file's SHA-256 is the bundle identity, binding every RPM's -identity, bytes and provenance to all three input artifacts. - -Admission rejects symlinks, traversal, duplicate identities, unknown fields, -extra/missing files, non-x86_64/noarch RPMs and mismatches. Limits are 256 RPMs, -512 MiB per RPM, 2 GiB total and a 1 MiB manifest. Root copies both candidates -and all dependencies into fresh private staging files using bounded, -no-symlink reads, then verifies the copied hashes, NEVRAs and signature headers. -Only those root-owned copies are checked and installed; changing the original -account-owned transfers cannot change the transaction. Dependencies pass -`rpm --checksig --verbose` using installed trust keys; no keys are imported. -Root staging is removed on success or failure. Qualification creates a bootable recovery -point before installing both candidates and all dependencies in **one** DNF -transaction with every repository disabled. `--recovery-check` additionally -boots and restores that point. `qualification.json` records the bundle digest, -identities and verified signatures. This input targets exact prebuilt upgrade -artifacts. Omitting the option retains the existing upgrade path. - -For `image/release-gate`, which always builds new candidates, supply the reviewed -raw RPMs and the same separate filename-to-`{id, url}` provenance JSON as a pair: - -```bash -image/release-gate --output NEW_OUTPUT --baseline-iso BASELINE.iso --tag vX.Y.Z --execute \ - --upgrade-dependency-rpms RAW_RPM_DIR --upgrade-dependency-provenance provenance.json -``` - -This optional path requires host `rpm`. The raw directory contains only the -reviewed, signed third-party RPMs, without a prebound manifest. An incomplete flag -pair, malformed inputs or RPM metadata stop the gate before building or starting VMs. -The gate copies bounded inputs into private staging before the build, records -their hashes, identities and provenance, then binds those copies to the actual -baseline ISO and both freshly built RPM hashes. Caller inputs are never changed; -later caller changes cannot affect the staged fixture. The gate forwards the -bound bundle only to the original upgrade qualifier, retaining recovery and all -signature, checksum and single-transaction checks. It also checks that the -returned qualification evidence identifies that exact bundle and artifacts. -`release-gate.json` records the raw fixture content digest, provenance digest, -bound manifest digest and upgrade report digest; `upgrade-dependency-manifest.json` -retains the exact bound manifest. Private dependency RPM copies are removed on -success or failure and are never included in release artifacts. Omitting both -flags preserves the existing release-gate behavior. - -The source archive is published by `.github/workflows/release.yml` after all -required jobs pass. Its prerequisite job checks the tag against `VERSION`, -requires a non-empty `CYBEXOS_BASELINE_ISO` repository variable and a root -license file, and validates semantic versioning. The ISO job additionally -validates that the baseline file and checksum sidecar exist on the PXE host. - -## Source archive setup - -1. Immutable GitHub Releases are enabled for the repository. Keep Actions - permitted to create attestations and write release contents; the workflow - grants only those job-level permissions. -2. The `desktop-release` environment and its `CYBEXOS_RPM_SIGNING_KEY` secret - provide signing access only to the GitHub-hosted signing job. The PXE - qualification runner never receives that secret. The `github-pages` - environment deploys stable repository metadata after release publication. -3. Keep `CYBEXOS_BASELINE_ISO` set to the verified older ISO and configure the - on-demand trusted self-hosted PXE runner as described in - [the ISO release guide](iso-releases.md). -4. Branch protection requires pull requests, up-to-date branches, and the - GitHub Actions checks `Fedora 44 source contract` and `Image tooling, - installer fixtures and account isolation`. Admins are subject to the rule; - force pushes and branch deletion are disabled. The generic Fedora VM is a - release-tag gate, not a required branch check. - -The updater refuses a release when GitHub reports `immutable: false`, even if -the archive checksum is otherwise correct. - -GitHub Pages is configured at the desktop update URL but is currently empty; -the first stable ISO release workflow will deploy signed metadata there. -Prerelease tags publish downloadable assets but do not update the stable Pages -repository. - -## Source archive release checklist - -1. Review `release-manifest.json`, `VERSION`, the Fedora release, - configuration schema, minimum updater version, and all dependency pins. -2. Run `./tests/run` and `./tests/fedora-vm-convergence` locally when practical. - Run `python3 -B image/check-source` as well. Both source suites enforce the - [ISO-leading installation contract](installation-parity.md); changes to - applications, defaults or policy must cover both installation paths. Build - and qualify ISO/RPM artifacts from the same release revision rather than - reusing an older image as parity evidence. - The source gate includes an N to N+1 ownership test that advances vendor - runtime while requiring every user customization sentinel to remain - byte-identical. - The user-widget fixture also loads a fixed API 1 package outside the - runtime through simulated replacement and rollback. Retain supported API - adapters when changing the shell; see [the widget contract](architecture/user-widgets.md). -3. Commit the intended source and create a signed semantic-version tag, such - as `git tag -s v1.0.0 -m 'CybexOS 1.0.0'`. -4. Push the commit and tag. A version containing a hyphen, such as - `v1.1.0-beta.1`, is published as a prerelease for the beta channel. -5. Wait for the workflow to build, attest, verify the provenance bundle - offline, upload, publish, and confirm the immutable release before - announcing it. -6. On a clean supported machine, run `cybex update --check --json`, - apply the release, and run `cybex verify --system`. - -Do not edit an existing release. Immutability makes correction explicit: fix -forward, increment the version, and publish a new tag. If rollout must stop, -remove the bad release from channel discovery and publish a corrected release. -On the managed Btrfs layout, failed application or health validation selects -the previous root and restores the vendor desktop checkpoint while preserving -personal settings; users must restart to enter the restored root. Interrupted -application is recovered before login. On other filesystems the updater reports -`transactionProtection: unavailable`: failed activation restores the previous -`current` and saved configuration, but partially deployed files can remain -(`mixedState: true`). Converge the active release with -`~/.local/share/cybexos/current/install` or apply a corrected release. -See [update recovery and its limits](operations.md#update-recovery-points). - -## What the source workflow publishes - -The release contains a versioned source archive, its provenance bundle -`cybexos-VERSION.tar.zst.sigstore.jsonl`, and `SHA256SUMS`. The checksum -entries use the asset basenames, so they can be verified directly after the -files are downloaded into one directory. The archive -is reconstructed from the tagged Git tree, its `VERSION` and manifest version -are set to the tag, its installer is checked, and Ansible syntax is validated. - -The bundle is the Sigstore bundle of the `actions/attest` provenance -attestation. Before publishing, the workflow verifies it offline without a -token, exactly as installed systems do: - -```bash -gh attestation verify cybexos-VERSION.tar.zst \ - --bundle cybexos-VERSION.tar.zst.sigstore.jsonl \ - --repo DigitalPals/CybexOS \ - --signer-workflow DigitalPals/CybexOS/.github/workflows/release.yml \ - --source-ref refs/tags/vVERSION --deny-self-hosted-runners -``` - -The same attestation has a second subject: the ISO's SPDX SBOM, -`CybexOS-Live-44-BUILD-ID.spdx.json`, which the workflow also verifies offline -with this bundle. A separate SPDX attestation binds the SBOM to the ISO digest -and is published as `CybexOS-Live-44-BUILD-ID.iso.sbom.sigstore.jsonl`; -`SHA256SUMS` lists that bundle. See [the SBOM section of the ISO release -guide](iso-releases.md#software-bill-of-materials) for its contents and -verification. - -That offline attestation check, the immutable-release flag, and the API -SHA-256 digest form the updater trust boundary. Updating needs no GitHub -login, and a release without its bundle is refused. Files copied from an -arbitrary branch or mutable URL are not accepted as updates. - -Until a source archive release is published, source-checkout -`cybex update --check` reports that no release exists on its channel. On ISO -installations, use `cybex update-channel status --json`; that command's channel -status is separate from source archive discovery. See [ISO and desktop RPM -releases](iso-releases.md) for channel enrollment after the first stable -desktop release has been published. +# Release delivery and publisher trust + +CybexOS uses semantic tags (`v1.2.3`, `v1.2.3-beta.1`), immutable GitHub +Releases, and small signed GitHub Pages documents under `channels/stable.json` +and `channels/beta.json`. Normal clients use public HTTPS without GitHub +credentials. A release is always selected by exact commit and NAR content +identity, never by checking out a moving branch after discovery. + +The installed `release/keys/trust.json` is deliberately empty. An owner must +supply real Ed25519 publisher public keys before production installation or +CybexOS channel updates can work. Upstream-only updates do not use this +channel. Do not ship a generated development key as publisher trust. + +Keep private signing keys outside the repository and Nix store. The protected +`cybexos-release` and `cybexos-channel` GitHub environments hold separate +`RELEASE_ED25519_PRIVATE_KEY` and `CHANNEL_ED25519_PRIVATE_KEY` secrets, with +`RELEASE_KEY_ID` and `CHANNEL_KEY_ID` environment variables. Require owner +review, protected tags, and restricted workflow changes. Enable immutable +releases in GitHub settings before publication. Enable GitHub Pages with +Actions delivery and prepare its `gh-pages` branch; promotion preserves the +existing site. These settings and trust provisioning are release prerequisites. + +The release environment also needs `IMMUTABILITY_CHECK_TOKEN`: a repository- +scoped GitHub App or fine-grained credential with **Administration: read**. +The ordinary Actions publishing token cannot read this policy. The workflow +checks GitHub's immutable-release setting before draft creation and again before +publication, and fails closed on a disabled setting or an unavailable API. +This separate credential cannot change the setting; publication still uses the +Actions contents token. Keep repository policy changes protected during release +publication. Installed users need neither credential nor a GitHub account. + +Full Nix/product/image jobs require a `cybexos-isolated` self-hosted runner; +standard hosted runners do not provide enough space for the complete desktop, +build dependencies, ISO copies and installation VMs. Provision a fresh, +single-job disposable x86_64 Linux VM with at least 256 GiB of disk, 32 GiB +RAM, four CPU cores and accessible nested KVM. The preflight requires 160 GiB +free for the Nix store plus 32 GiB for work (192 GiB when they share a +filesystem), at least 24 GiB available memory, and KVM for VM/image jobs. +Build concurrency is bounded to two builds and four cores per build. +Discard the runner, workspace and attached disks after each job. It must have +no workstation data, persistent credentials, production network access or +production PXE mount. Untrusted pull-request checks receive no signing secrets; +only the protected publisher step receives its scoped signing secrets. +Runner provisioning and isolation are owner-managed release prerequisites, +not an instruction to enroll the running workstation as a runner. + +1. Commit the final source, set VERSION and release notes, and create a semantic + tag. Never reuse a tag. Record `nix flake metadata --json` revision/NAR. +2. Run source checks, real ISO installation and updater tests, plus representative + physical hardware/security qualification on that exact revision. A complete + report must use `installation-update-and-physical-qualification`, include + `revision`, `narHash`, and every check in `release/manage.py:REQUIRED`, with + passed results and reviewable evidence. A fixture report cannot pass the gate. +3. Run the protected release workflow with that tag and the reviewed report URL. + It repeats checks/builds, prepares the ISO, versioned source, notes, checksums, + qualification report and signed manifest, authenticates the local signature + and every asset, then uploads all assets to a draft. + Publication must produce an immutable release. The report URL is evidence + supplied for owner review; it is not an independent hardware attestation. +4. Verify public assets against the authenticated manifest. Only then run the + separate promotion workflow with a strictly increasing channel sequence. + Stable rejects prereleases. Promotion authenticates the previous signed + channel/release, rejects version downgrades and nonadvancing sequences, and + verifies the selected release and every public asset before changing Pages. + The signed version, exact commit and NAR must match the requested tag's + checked-out source; a valid manifest copied from another release is rejected + before its asset URLs are fetched. + Expired channel documents can be renewed for the same release. An expired + signing key requires the documented trust/recovery procedure. + +Ed25519 signatures authenticate the exact JSON bytes using OpenSSL; detached +signature envelopes identify a trusted key and its purpose. The signed manifest +contains version, exact source revision/NAR, supported architectures and NixOS +releases, configuration/plugin APIs, minimum updater API, declared vendor inputs, +notes, and each asset's name, URL, size and SHA-256. The channel authenticates +the manifest hash, expiry and sequence. Unknown/expired keys, malformed metadata, +unsupported APIs/platforms, mismatched identities, replays and normal downgrades +are rejected before selected code evaluation. The machine records successful +channel sequence high-water marks outside user caches. Explicit retained-generation +rollback is separate from channel selection. + +Clients use bounded downloads, atomic cached responses, ETags, and actionable +unavailable/rate-limit errors. An endpoint failure means unknown availability; +it never means current. Interrupted downloads do not change the working lock. +Expired channel documents must be re-signed and promoted with a greater sequence, +including when the release itself remains unchanged. + +For rotation, first publish a release signed by an existing trusted release key +that adds the new public keys, validity dates and purpose restrictions. Keep +old and new keys overlapping until supported machines have installed that trust +update. Promote using the old channel key during the overlap, then switch to +the new channel key. Do not expire the only key usable by supported updaters. +Revocation of a compromised sole key requires an independently authenticated +recovery/trust update; signatures by that compromised key cannot establish new +trust. Increasing updater/schema requirements requires an overlap release that +old updaters can authenticate and install. + +Nixpkgs supplies Quickshell, Qt, Hyprland, applications and fonts through the +machine's stable Nixpkgs input and upstream binary cache. CybexOS supplies the +desktop interface, assets, native notification-image module and extensions. +Narrow Intel camera/OMaWrite/font source pins and the temporary Hyprland/native +library compatibility overlay are source derivations, not another Nixpkgs +universe; requalify their ABI/license constraints when updating them. +T3 Code is an exact hashed upstream AppImage wrapped with Nixpkgs because the +selected stable package set does not provide the application. Replace that +source recipe with the maintained Nixpkgs package when available, after product +runtime qualification; its successful wrapper build is not desktop validation. +The selected stable Nixpkgs ChatGPT recipe is macOS-only. The Linux recipe pins +OpenAI's versioned Debian pool object and SHA-256, reviewed against the signed +OpenAI repository metadata; it executes no distribution maintainer scripts or +repository enrollment. Preserve the Chromium sandbox and exact-executable +AppArmor user-namespace allowance when updating it. Replace the recipe with a +maintained native Nixpkgs Linux package after runtime qualification. + +Brave Origin reuses the selected Nixpkgs Chromium wrapper/libraries and pins the +official Origin Debian asset because stable Nixpkgs does not yet select that +edition. Review both exact proprietary package pins on every vendor release; +replace the Origin override when a maintained equivalent enters Nixpkgs. +They remain part of the explicit proprietary-application choice and require +installed runtime, security and redistribution qualification. + +The maintained NetworkManager Libreswan plugin uses Nixpkgs sources and a +scoped helper-path correction: the selected recipe points to `libexec/`, while +Libreswan 5 installs its daemon/controller helpers in `libexec/ipsec/`. +Build-time assertions check each replacement target. CybexOS uses the native +NixOS `ipsec.service` to manage the backend; the plugin's legacy standalone +startup expects a removed `_stackmanager` helper. A working managed service is +therefore required and must pass installed health qualification. +Remove the override after the upstream recipe is +corrected and the installed VPN path qualifies; it does not suppress or patch +the archived vpnc plugin's vulnerabilities. +The reviewed desktop runtime series live in `desktop/runtime-compatibility.json`; +a candidate outside that matrix fails staging until qualified. + +No durable CybexOS binary cache is configured. Before advertising fast public +updates, provision a public Nix cache (for example an object-store-backed Nix +cache or Cachix), publish its independent Nix cache signing public key and +substituter URL, and prebuild each release/architecture. Add cache trust through +the shared module after reviewing that key. GitHub Actions artifacts and GitHub +Release download archives are not a public Nix binary cache. Source builds remain +possible; publisher metadata signatures and Nix cache signatures have different +purposes. Redistribution/license review remains required for the actual payload. + +Physical/security qualification must include `mandatory-access-control` and +`firewall` evidence. Record enforced profile/process coverage, negative access +probes and packet-level policy behavior. AppArmor being enabled alone cannot +satisfy the mandatory access control check. diff --git a/docs/remote-server-widget.md b/docs/remote-server-widget.md index 3985f34c..d22376fe 100644 --- a/docs/remote-server-widget.md +++ b/docs/remote-server-widget.md @@ -67,7 +67,7 @@ overlay mounts are excluded from capacity collection. ## Implementation and lifecycle `Common/RemoteServer.qml` owns a single `Process` for the whole shell, independent -of monitor count. `scripts/remote-server.py` validates the destination and execs +of monitor count. `desktop/quickshell/scripts/remote-server.py` validates the destination and execs SSH, sending the self-contained `remote_server_probe.py` as a quoted Python program. One persistent SSH channel streams newline-delimited JSON. Its stdin accepts cadence/refresh messages, and EOF terminates the probe. The connection @@ -96,8 +96,8 @@ cybexos-runtime ipc remoteServer configure cybexos-runtime ipc popouts open remote ``` -Tests cover counter resets, memory semantics, cache lifetime, transport quoting, -SSH restrictions, stream cadence/EOF, malformed samples, interface selection, -metric formatting, warning levels, chart windows and scales, filesystem and -sensor presentation, history bounds and settings migration. Run `./tests/run`. +`tests/quickshell/remote-server.test.cjs` covers interface selection, metric +formatting, warning levels, chart windows, presentation and settings. SSH +transport restrictions, stream cadence/EOF and real remote hosts still require +isolated runtime qualification. Run `./tests/run`. For live checks, use `tests/lib/quickshell-live` begin/end and the managed service. diff --git a/docs/security.md b/docs/security.md deleted file mode 100644 index c9f5e45b..00000000 --- a/docs/security.md +++ /dev/null @@ -1,207 +0,0 @@ -# Fingerprints and security keys - -CybexOS accepts a fingerprint or a FIDO2/U2F security key as an alternative to -the password for the **lock screen**, **sudo** and **system prompts** (the -shell's Polkit dialog). The password always keeps working. Signing in at the -login screen after a restart always asks for the password. - -Manage both in **Settings > Security** (`cybexos-runtime ipc settings open -security`). The page has two groups: - -- **Fingerprint**: the reader, the enrolled fingers, **Add fingerprint** (choose - a finger, then scan it until the bar fills), **Remove…** with a confirmation, - and **Use fingerprint to unlock and authorise** once a finger is enrolled. -- **Security keys**: the registered keys, **Add security key** (touch the key - when it flashes) and **Remove…** with a confirmation. - -Without a reader, or without key support, each group shows one short line -instead of controls. - -## Installation - -Every installation, on any hardware, gets the same packages and policy; the -checkout (`./install`, `./bootstrap`) and the ISO apply them identically. - -| Part | Source | Checkout | ISO | -| --- | --- | --- | --- | -| `fprintd`, `fprintd-pam`, `libfprint`, `pam-u2f`, `pamu2fcfg`, `libfido2` | `base_required_packages` | base role | compose; `cybexos-desktop` `Requires` for upgrades | -| `authselect` feature `with-fingerprint` | `cybexos-auth-policy apply` | base role | kickstart selection, then the same helper during installation, repair and reconciliation | -| Security-key steps and `/etc/pam.d/cybexos-lock` | `cybexos-auth-policy apply` | base role | the same helper | -| Settings switch helper | `roles/base/files/cybexos-auth-policy` | `/usr/local/libexec/` | `/usr/libexec/` | - -`roles/base/tasks/authentication.yml` runs the helper from the role in -workstation convergence (`main.yml`), ISO installation and repair -(`image.yml`) and upgrade reconciliation (`reconcile.yml`). A failure is -reported and leaves the password unchanged; it does not stop the install. - -The XPS 2026 role keeps only its hardware-specific part: the detector that -rejects the IPU7 camera's USB bridge, the `xps-fingerprint-check` diagnostic, -and a report when the detected reader is not one libfprint supports. See -[Dell XPS 2026 hardware support](xps-2026-hardware.md). - -## PAM design - -The rule throughout: every addition is `sufficient` and sits in front of an -otherwise unchanged stack, so a missing reader, finger, key, module or daemon -falls through to the password. Nothing removes or reorders `pam_unix`. - -### Fingerprints - -Fedora's managed `authselect` feature `with-fingerprint` adds -`auth sufficient pam_fprintd.so` to `system-auth`. That stack serves sudo, -Polkit, `su` and console login. It is not added to `password-auth`, which -serves SDDM. `pam_fprintd` returns at once when fprintd has no device or the -person has no enrolled finger, so they are asked for the password as before. - -CybexOS enables the feature once (recorded in -`/var/lib/cybexos/authentication/fingerprint-adopted`). After that, -`authselect`'s own state is the person's choice: the Settings switch runs -`pkexec cybexos-auth-policy fingerprint on|off`, which calls -`authselect enable-feature`/`disable-feature`, and no later install, repair or -upgrade turns it back on. The switch applies to every account on the computer. -If authselect does not manage the system, CybexOS leaves fingerprint sign-in -alone and the switch reads *Managed outside CybexOS*. - -### Security keys - -pam_u2f is added as the first `auth` step of exactly three stacks: -`/etc/pam.d/sudo` (and therefore `sudo-i`), `/etc/pam.d/polkit-1` (Polkit's -vendor stack from `/usr/lib/pam.d/polkit-1`, regenerated on each run so a Polkit -update is followed) and the lock screen's `/etc/pam.d/cybexos-lock`: - -```text --auth sufficient pam_u2f.so cue nouserok userpresence=1 origin=pam://cybexos appid=pam://cybexos authfile=.config/Yubico/u2f_keys [cue_prompt=Touch your security key] -``` - -- `nouserok`: an account without registered keys is unaffected (pam-u2f 1.4 - returns `PAM_IGNORE` for it). -- `userpresence=1`: a touch is always required. -- `origin`/`appid` `pam://cybexos`: fixed, so renaming the computer does not - invalidate keys (pam_u2f's default is `pam://$HOSTNAME`). -- `authfile=.config/Yubico/u2f_keys`: relative to the account's home and opened - as that account, never by root through a path the person controls. -- `-auth`: if the module is missing the line is skipped, not an error. - -authselect's own `with-pam-u2f` feature is deliberately not used: it would put -the key step into `password-auth` as well, where a touch at the SDDM login -would sign in without the password that unlocks GNOME Keyring. - -The helper leaves an administrator's own configuration alone. A file that -already contains a `pam_u2f` line (for example a required second factor) gains -no CybexOS step, an administrator's own `/etc/pam.d/polkit-1` gains only the -key line, and a `cybexos-lock` without the CybexOS header is never rewritten. If authselect's `with-pam-u2f` or -`with-pam-u2f-2fa` is enabled, CybexOS removes its own key steps and Settings -reports that keys are managed with authselect. - -Registrations live in `~/.config/Yubico/u2f_keys` (folder `0700`, file `0600`, -SELinux label restored after each change). Settings runs -`pamu2fcfg -n -o pam://cybexos -i pam://cybexos` and appends the result. A key -that asks for its PIN to register cannot be added from Settings, which has no -terminal for the PIN prompt; register it in a terminal instead: - -```bash -mkdir -p -m 0700 ~/.config/Yubico -pamu2fcfg -o pam://cybexos -i pam://cybexos >>~/.config/Yubico/u2f_keys # first key -pamu2fcfg -n -o pam://cybexos -i pam://cybexos >>~/.config/Yubico/u2f_keys # further keys -chmod 0600 ~/.config/Yubico/u2f_keys -``` - -Keys registered with another origin (for example pamu2fcfg's default) do not -verify with the CybexOS steps. - -### Login (SDDM): password only - -SDDM's stack includes `password-auth`, which has neither `pam_fprintd` nor -`pam_u2f`. This is intentional. The login password is what unlocks GNOME -Keyring: on an encrypted installation with automatic login, the LUKS -passphrase cached by `pam_systemd_loadkey` does it (the `sddm-autologin` stack -is unchanged); otherwise `pam_gnome_keyring` uses the password typed into SDDM. -A fingerprint or key login would leave the keyring locked until the first -application asked for it. After the first login, the lock screen, sudo and -Polkit accept fingerprints and keys. - -### Lock screen (hyprlock) - -`pam_fprintd` cannot run beside the password in one PAM conversation: the stack -is sequential, and while it waits for a finger the password cannot be checked. -hyprlock therefore uses its own fprintd D-Bus check, which runs in parallel -with PAM, and a PAM service without `pam_fprintd`: - -```text -# /etc/pam.d/cybexos-lock --auth sufficient pam_u2f.so … -auth include password-auth -``` - -`cybexos-runtime exec hyprlock` adds the authentication to the vendor or themed -lock screen at each lock (written to `$XDG_RUNTIME_DIR/cybexos/hyprlock.conf`): - -- `auth:pam:module = cybexos-lock`, only when that file exists. Without it the - configuration is used as is: naming a missing PAM service would refuse every - password. -- `auth:fingerprint:enabled` follows `with-fingerprint` in - `/etc/authselect/authselect.conf`, with the prompt *Touch the fingerprint - reader or enter your password* shown under the field (`$FPRINTPROMPT`). With - no reader or no enrolled finger the prompt stays empty. -- When the account has a registered key, the field reads *Password or security - key*, an empty Enter is passed to PAM, and while PAM waits for the touch the - field shows *Touch your security key if it flashes*. hyprlock starts PAM only - when the field is submitted and does not display PAM's own cue. - -A person's own `~/.config/cybexos/hypr/hyprlock.conf` is used unchanged; add an -`auth` section to it to use these factors. - -### Sudo and Polkit prompts - -These stacks are sequential: with a registered key plugged in, pam_u2f waits -for its touch first; with an enrolled finger, `pam_fprintd` waits for a finger -(up to 30 seconds and three attempts); then the password is asked. The shell's -Polkit dialog recognises both steps from PAM's messages and says *Touch the -fingerprint reader, or wait to enter your password.* or *Touch your security -key, or wait to enter your password.* instead of showing a password field that -cannot take input yet. A mismatched scan adds *Not recognised.* In a terminal, -sudo prints PAM's own messages. - -## Recovery - -Nothing here can lock anyone out: the password is accepted by every stack. -If a reader misbehaves, turn the Settings switch off, or run -`sudo authselect disable-feature with-fingerprint`. Remove a misbehaving key's -line from `~/.config/Yubico/u2f_keys`, or delete the file. `cybex uninstall` -runs `cybexos-auth-policy remove`, which removes the CybexOS key steps and lock -service and leaves authselect's fingerprint feature as chosen. - -## Manual verification on real hardware - -The repository tests stub authselect, fprintd and pamu2fcfg, and a Fedora 44 -container checked the generated stacks with `pamtester` (password accepted and -rejected correctly for sudo, `sudo-i`, Polkit and `cybexos-lock`, with and -without a registered but absent key, and with fprintd unavailable). The -following still needs a person, a reader and a key: - -1. A machine without a reader: Settings > Security shows *No fingerprint reader - was found.*; sudo and Polkit ask for the password with no delay. -2. Enrol a finger from Settings: the Polkit dialog asks for the password first - (fprintd's enrol action), the counter and bar advance with each scan, retry - messages appear for a short or off-centre scan, and the finger is listed. - Cancel an enrolment midway and confirm the reader is released (a second - enrolment starts). -3. Remove a finger; Polkit asks again; the list updates. -4. Lock the screen: the prompt appears under the field, a finger unlocks, a - wrong finger shows the failure, and typing the password still unlocks at - once while the reader is idle. Suspend and resume while locked. -5. sudo in a terminal and a Polkit action (for example a timezone change): the - finger works; ignoring the reader leads to the password prompt after the - timeout; the dialog's wording is as above. -6. Turn the switch off: the lock screen shows no prompt, sudo asks for the - password at once; `cybex update` does not turn it back on. -7. Register a key from Settings (with and without the key inserted beforehand; - a key with a PIN shows the terminal hint). Then: lock screen (Enter, then - touch), sudo, Polkit, and the same with the key unplugged (password only, no - delay). Rename the computer and confirm the key still works. -8. Restart: SDDM asks for the password; the keyring is unlocked (for example - in a browser that stores passwords). With encrypted automatic login, the - desktop starts and the keyring is unlocked as before. -9. On an XPS 2026 with a supported reader, `xps-fingerprint-check "$USER"` - passes; on the tested SKU `0DB9`, the SVP7500 camera bridge is still not - reported as a reader. diff --git a/docs/shell-appearance.md b/docs/shell-appearance.md deleted file mode 100644 index c27ec281..00000000 --- a/docs/shell-appearance.md +++ /dev/null @@ -1,196 +0,0 @@ -# Shared shell appearance - -Settings → Appearance owns the font family, text size, interface scale -(75–200%), density and panel borders/corners; the base font size (10–24 -logical pixels) sits under **Advanced text options**. These feed native panels, notifications, launcher and the -Omarchy compatibility components. Bar corner geometry remains separately -configurable; panel corners no longer inherit the bar radius. Attached drawers -and sheets use the same radius on all four corners, without square edge -overrides or concave bridges to the bar. - -The **Text & size → Interface font** dropdown is near the top of Appearance, -draws each family in its own face, and can also be reached by searching for -“font”. A preview above it shows the size text renders at. It includes the current default, -JetBrainsMono Nerd Font, and applies immediately across the shell. The installer -ships every offered family: JetBrainsMono Nerd Font, Figtree, Google Sans Flex, -Urbanist, OPPO Sans 4.0 and IBM Plex Sans. System/browser fallbacks include -Liberation Sans, Serif and Mono, the full Noto family, CJK, emoji, Font Awesome -and iA Writer (Mono, Duo, Quattro and Duospace), matching Omarchy’s base font -coverage. These use the managed -Fontconfig defaults independently of the shell font choice. - -Dark or light mode, the palette, the accent, the font and the text size also -reach the desktop outside the shell: the terminal, window borders, GTK -applications and the lock screen follow them (see -[System theme](system-theme.md)). - -## Themes - -A theme is a named set of appearance choices applied in one step: dark or -light, the accent and where it comes from, the bar colour, optionally a font -and a wallpaper, and optionally its own palette — background, panel and menu -surfaces, three text tones, and red, amber and green. **Theme** is the first -row of Settings → Appearance. Its dropdown previews every theme in three dots -(base, accent, text); picking one applies it at once, with the footer's Undo -for eight seconds. Changing anything a theme set afterwards (the mode, the -accent, the bar colour…) shows it as “Nord (modified)”; settings that match no -theme show “Custom”. A fresh installation shows **CybexOS**, the default look, -and existing settings are untouched until a theme is picked. The row's reset -returns every value a theme sets to that default; the wallpaper stays. - -The save button beside the dropdown opens **Theme name**: **Save** stores the -current mode, colours, bar and font as a user theme and selects it. The -wallpaper is not included. - -Built-in themes ship with the shell runtime (`themes/` beside `scripts/`): -CybexOS, Catppuccin Latte and Mocha, Everforest, Gruvbox, Kanagawa, Nord, -Rosé Pine and Tokyo Night, from their published MIT-licensed palettes (see -[Licensing](licensing.md#built-in-theme-palettes)). User themes live in -`~/.local/share/cybexos/themes//`, a user-owned root that updates never -prune ([ownership](architecture/ownership.md)); a theme folder added or -removed there by hand appears in the dropdown without a restart. - -### Palettes and readability - -Applying a theme copies its palette into the settings (`themePalette`), so the -look loads with the shell and survives the theme's removal. `Common/Theme.qml` -draws it only while its mode is the shell's mode and the accent source is -Fixed; switching to the wallpaper palette or the other mode falls back to the -shell's own references. Colours a theme leaves out are derived from the ones -it sets. The shell's floors still apply to every theme: panel surfaces are -held to their mode (7.5:1 against white in dark mode, 9:1 against black in -light mode), primary text to 7:1 and every quieter tone, status colour and -the accent's text to 4.5:1 against the theme's least favourable backdrop. A -theme's own colours are kept wherever they already read; the floors only lift -the others (`SettingsHelpers.themeTextPalette`, tested against every -built-in theme and a sweep of random palettes). - -### Sharing themes - -```bash -cybex theme list [--json] # built-in and user themes -cybex theme apply nord # apply now, or at the next shell start -cybex theme install ~/Downloads/harbour # a folder, .tar.gz/.zip, or https:// Git URL -cybex theme install https://github.com/someone/harbour-theme -cybex theme export nord ~/Desktop # writes ~/Desktop/nord/ -cybex theme export CURRENT ~/share.tar.gz --name "Night shift" -cybex theme remove harbour # user themes only -``` - -`apply` hands the running shell the theme over its `theme` IPC target -(`cybexos-runtime ipc theme use …`), where it is checked against the settings -schema again and applied with Undo. Without a running shell it writes the same -values to `~/.config/cybexos/shell.json` through the shell's own transactional -writer, keeping every other value and refusing a file from a newer release. -`export CURRENT` describes the current appearance and includes the current -wallpaper; exporting a theme includes its own wallpaper, if it has one. - -A theme is data only: - -```json -{ - "schema": 1, - "name": "Harbour", - "mode": "dark", - "accent": "#5e81ac", - "bar": "#202830", - "font": "plex", - "palette": {"background": "#202830", "surface": "#28323c", "text": "#e6ecf2"}, - "wallpaper": "harbour.jpg", - "author": "Someone", "license": "CC-BY-4.0", "source": "https://example.com" -} -``` - -`name`, `mode` and `accent` are required. `accentSource` is `fixed` (the -default) or `wallpaper`, which takes no palette. `bar` is a Bar page preset -(`default`, `macos`, `black`, `graphite`, `slate`, `white`), a `#rrggbb` colour -or `{"hue", "saturation", "lightness"}`; `font` is a shell font id (`figtree`, -`google`, `urbanist`, `oppo`, `plex`, `mono`). `palette` takes `background`, -`surface`, `surfaceRaised`, `text`, `textMuted`, `textDim`, `red`, `amber` and -`green`. `id` defaults to one made from the name. Unknown keys are refused. - -`install` is deliberately strict. A theme holds `theme.json` and the one PNG, -JPEG or WebP image it names, checked by content; a README, LICENSE or -`.gitignore` beside them is tolerated and not copied. Anything else — scripts, -QML, other dotfiles, folders, symbolic or hard links, device files, paths that -leave the theme, submodules — refuses the whole source, as do files over the -size limits (64 KiB for `theme.json`, 16 MiB for an image, 64 entries). -Archives are read member by member without extracting them. Git sources must -be `https://` URLs without credentials; the helper makes a shallow, -blob-less clone with hooks disabled and only https allowed, checks the whole -tree listing, and then reads the two files it needs with `git cat-file`, so -nothing from the repository is checked out. The installed folder records its -source in `.source.json` (the URL and commit for Git). An existing user theme -is replaced only with `--replace`; a built-in id cannot be taken. - -The command is `assets/scripts/cybexos-theme` on both installation paths -(`~/.local/bin` on a checkout, `/usr/share/cybexos/bin` from the RPM). It -runs the active runtime's `scripts/theme-packs.py`, which Settings uses too, -so the schema and the theme-to-settings mapping (`theme_packs.settings_patch`) -exist once. - -Fresh installations leave **System tray** disabled. Add it from Settings → -Bar when wanted; existing saved widget choices are preserved. - -`Common/ShellMetrics.js` calculates a rounded effective font size once from -base size × interface scale × accessibility (text size) scale. Typography uses this size relative -to a 12px reference. Geometry and padding additionally multiply by density -(Compact 0.92, Default 1, Comfortable 1.16). Compact changes geometry without -shrinking text. Panel widths track that shared geometry scale without the old 115% cap and -clamp to output bounds. The launcher reduces its scrolling results viewport -when vertical space is limited. Settings fields and narrow rows grow with text. -Qt/Wayland alone applies output device scaling; these are logical pixels. - -Omarchy Style receives the same effective font and density, plus the optional -plugin scale. Advanced Omarchy tokens can intentionally diverge from the shared -settings. Existing explicit plugin border overrides remain valid; choose -**Shell** under Settings → Appearance → Omarchy plugins to inherit shared -surface borders. - -The defaults use Dark mode, a Hug bar, wallpaper colors, opaque surfaces and -numbered workspaces. Typography uses JetBrainsMono Nerd Font at 14px, 100% UI -scale and standard spacing. Panels have 16px corners and no border (width 0); -plugins inherit the shared appearance. Appearance has no preset actions. -Existing saved preferences remain in effect; section resets use these defaults. -New ISO-installed accounts explicitly seed the same JetBrainsMono font choice. -Empty or older settings without a font also inherit this default; the historical -Google Sans migration applies only to a stored pre-schema-7 Urbanist value. - -The shared library and usage rules are documented in [Shell typography](shell-typography.md). - -Typography derives from [Omarchy's scale](https://github.com/omacom/omarchy/blob/quattro/default/themed/shell.toml.tpl) -and [default monospace family](https://github.com/omacom/omarchy/blob/quattro/default/fontconfig/conf.avail/50-omarchy.conf), -with a larger default base: 12px captions, 13px secondary copy, -14px body/control/bar text, 16px titles, 18px headings, and 28/33px display -values. The heading multiplier is tuned to 18px at the new base. -Settings labels, inputs, pickers and actions use the same body role as plugin -controls. Regular copy uses -weight 400; headings can use medium, semibold or bold. - -Native body/caption and plugin body/caption share the same reference sizes. -Other semantic roles (headings and hero values) can have distinct sizes -while deriving from that common scale. Fixed-format numerical content follows -the monospace family when the monospace font is selected. - -## Verification - -The appearance unit matrix covers all density/accessibility combinations, -base-font and UI-scale boundaries, output width clamping, settings persistence, -shared/plugin border precedence and one-time opacity application. Existing -launcher, Settings and notification contracts check their scalable controls -and bounds. Live checks use `tests/lib/quickshell-live` around the managed -service and compare `shell debugPluginTheme` with screenshots of the same -Model Usage tab/data, native Settings, popovers and a local test notification. -The diagnostic reports native and plugin body, caption, title, heading and -display sizes so their equality can be checked directly. No second live -Quickshell process is needed. - -Validated on 2026-09-21: the full repository gate passed, including 744 unit -tests. Live checks passed all nine density/accessibility combinations, shared -custom border color/opacity, light/dark updates and the then-available presets against the -same Model Usage data. Launcher, Settings, audio and notification surfaces -were checked live, including enlarged text with comfortable spacing. Both -connected outputs used 2× device scaling; fractional output scaling was not -tested live. The isolated QML lifecycle test was skipped while the managed -desktop was active; managed-service checks confirmed a single Quickshell -process and no QML errors in its current invocation. diff --git a/docs/shell-recovery.md b/docs/shell-recovery.md index ae1d81fd..d6461ffc 100644 --- a/docs/shell-recovery.md +++ b/docs/shell-recovery.md @@ -7,7 +7,7 @@ select the standalone recovery configuration on the next start. Successful service stops, deliberate restarts, a long healthy run, and a new boot reset the failure counter. A late stop notification cannot override a recovery choice. -Recovery uses `quickshell/safe-mode/shell.qml`. It provides a bar on every output, +Recovery uses `/etc/cybexos/runtime/quickshell/safe-mode/shell.qml`. It provides a bar on every output, a clock, a terminal button, and **Retry desktop**. Super+Space opens a terminal. It imports no normal settings, theme, plugins or connected integrations, so a broken widget or personal setting cannot prevent the recovery UI from loading. @@ -23,18 +23,17 @@ cybex shell recover # clear the crash counter and retry the full shel ``` **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 +problem persists, the next three failures return to recovery. If a development checkout was explicitly +selected, `cybexos-runtime dev disable` restores the vendor runtime. Recovery +does not guess which plugin caused an exit: the status record and current service journal support diagnosis without discarding working settings. The private, atomic state record is `$XDG_STATE_HOME/cybexos/shell-recovery.json` (normally `~/.local/state/cybexos/shell-recovery.json`). It stores lifecycle metadata only. Malformed state chooses recovery. Explicit recovery remains active across -reboots until the user retries; stale failure counts do not cross boots. Older -runtimes without this mechanism retain their normal launch behavior on rollback. +reboots until the user retries; stale failure counts do not cross boots. -`tests/shell-recovery.py` exercises crash accounting, stale stop callbacks, +`tests/nixos/test_shell_recovery.py` exercises crash accounting, stale stop callbacks, normal restarts, boot boundaries, recovery commands, malformed state and exact preservation of shell/plugin configuration. It does not launch a live shell. diff --git a/docs/shell-settings-manual-verification.md b/docs/shell-settings-manual-verification.md deleted file mode 100644 index 83a264b4..00000000 --- a/docs/shell-settings-manual-verification.md +++ /dev/null @@ -1,633 +0,0 @@ -# CybexOS Settings — manual verification - -The CybexOS Settings workspace makes bar geometry, appearance, -widgets, wallpaper, and system behavior live-configurable, persisted to -`~/.config/cybexos/shell.json`. Automated coverage: -`tests/run` — the Node suite (store merge/clamp rules, schema/property -agreement, qmldir completeness, IPC single-declaration, typography lint) plus -`tests/qml-lint`, a qmllint sweep over every shell QML file — and -`tests/verify-system` (settings IPC liveness). Everything pointer-driven below -is manual. - -## Opening and closing - -- [ ] `cybexos-runtime ipc settings toggle` opens the centered window; again - closes it. -- [ ] On an output with at least 900×664 logical pixels available, the card - opens at 900×664 with the labeled sidebar: Personalize (Appearance, - Wallpaper, Bar, Notifications), Devices (Displays, Sound, Network, - Touchpad) and System (Power, Region & formats, Online accounts, - Plugins, Backup & recovery, About), all visible without scrolling and with no dots on - changed pages. Below 860px available width, the same navigation becomes - an icon rail with hairlines between the groups, themed tooltips and - 42px targets. -- [ ] `cybexos-runtime ipc settings open bar` lands on the Bar page. The - retired ids still work: `open modules` lands on Bar, with its widgets - at the top, and `open system` on Power. The recovery-boot notification's action lands on Backup & - recovery's recovery points, and the power drawer's Idle settings on Power. -- [ ] The header reads " · " with the description in its - own capitalization (Network shows "IP addresses and DNS"). It carries - only Close, whose tip appears on hover (panel-colored, not a yellow - system box) and never merely because it has focus. -- [ ] With the window freshly opened, the first Tab lands in the search - field, then the current page in the rail, then the page; Close comes - last. No Tab-then-Enter sequence from the start resets anything. -- [ ] Gear in the Control Panel footer opens it (and closes the popout). -- [ ] Right-click anywhere on the bar slab opens it; left-clicks on modules - still open their popouts. -- [ ] `Super+,` opens Settings directly without making the menubar focusable. -- [ ] Drag the header to move the floating window; Super+right-drag resizes it. - The sidebar responds to the window width, and page contents scroll. -- [ ] Esc first clears search or closes a widget subpage, then closes Settings. - The close button and the normal compositor close shortcut also work. -- [ ] Clicking another application, opening the launcher, or opening a bar - popout leaves Settings open. It participates in normal window switching. -- [ ] Repeated `settings open` calls focus the existing window without creating - duplicates. Closing and reopening works through every entry point. -- [ ] Opening settings while a popout is open closes the popout first. - -## Launcher keyboard path - -- [ ] Restart Quickshell, then immediately press `Super+Space` and type. The - first character appears in the search field; no click or second key is - needed to establish focus. -- [ ] With the empty query and its first result selected (for example, - 1Password), press Enter immediately after `Super+Space`. It launches - even while the card is still completing its short entrance animation. -- [ ] Down/Up, Tab, Home/End, Alt+1…8, Enter, and Esc all work without moving - focus out of the search field. Closing restores focus to the prior app. -- [ ] Reopen repeatedly: results appear together without a row-by-row delay, - and the first open after shell startup feels the same as later opens. - -## Persistence - -- [ ] First slider drag creates the JSON; `watch -n1 stat -c %y` on it shows - at most ~2 writes/s during a continuous drag. -- [ ] Editing the JSON externally applies live (no restart); junk values are - clamped or reverted to defaults on the next save. -- [ ] A schema-5 pristine floating bar migrates to Hug; a customized height, - radius, or gap remains Floating; an old non-floating bar becomes Attached. - Module order and centered Clock/Weather remain unchanged. -- [ ] Deleting the file live restores defaults; restart keeps them. -- [ ] A changed row wears the accent mark and, on hover or focus, its reset - chip. A page with changed values ends in "Reset to defaults" - (Displays: "Reset night light to defaults"); pages with nothing changed, - and pages whose values live outside shell.json, show none. -- [ ] Reset controls reset exactly their row or page and show an eight-second - `… reset · Undo` line in the rail footer. Undo restores the snapshot; a - new reset replaces it; any manual edit clears it. A forced save failure - exposes Retry. -- [ ] The rail footer is empty at rest. A change shows "Saving changes…", - then "Saved" for about two seconds, then nothing; errors and the newer- - schema warning stay until resolved. -- [ ] Every page draws one bounded column centered in the pane: labels on - the left, every control ending on the same right-hand edge, a hairline - between rows, segmented choices on one track, sliders of bounded length, - and long choice lists as dropdowns. - -## Wallpaper page - -- [ ] The page keeps the bounded, centered column of the other pages: on a - wide window its rows end on the same right-hand edge as Appearance's, - and moving between the two does not shift the column sideways. -- [ ] A Browse row at the top chooses Library or Online on one segmented - track, with no changed mark or reset chip. -- [ ] Grid lists the configured wallpaper folder inside the row grid (from - the label lane to the controls' edge); clicking a thumb swaps the - wallpaper live, moves the accent ring + ✓, and the Current row under the - grid names the new file. -- [ ] "Shuffle now" on the Current row picks a different wallpaper each press - and is disabled while the folder holds fewer than two images. -- [ ] `Super+K` opens the shortcut sheet at up to 1180 logical pixels wide with - three balanced columns on a wide screen (fewer on a narrow one). There is - no Hardware keys group, and a long row such as Resize window wraps its keys - under the label without clipping. -- [ ] The page opens on Library and makes no network request. Choosing Online - (or `cybexos-runtime ipc wallpaper browse`) shows popular Wallhaven - results sized for the connected displays; Sort, Category and Size search - again, and their reset chips restore Popular / General / Fits my displays. - The Search row runs on Enter or its search button, never on leaving the - field, and Down moves from the field to the results. - Scrolling to the end, or pressing Down on the last row, appends the next - 24 results without jumping back to the top. -- [ ] Picking an online result shows download progress on the tile, then saves - `wallhaven-.` into the wallpaper folder and applies it (palette - follows). Picking another result mid-download cancels the first and leaves - no `.part` file. A saved result shows its download mark and applies - without a new download. Offline, the view reports a connection error. -- [ ] Rotate 15 min / 1 hour / Daily arms the timer ("Off" disarms). -- [ ] The Folder row shows the folder path as its label in the mono face, - with the image count on its hint line; an unusable folder shows its error - there instead. A long path elides in the middle before it reaches - Choose… and Open. -- [ ] "Choose…" stays inside the settings surface. Valid folders, - including paths with spaces, preserve the current basename or choose the - first alphabetic supported image. Empty/unreadable folders change nothing. -- [ ] "Open" opens the selected directory in the file manager. Large folders - scroll smoothly without constructing every thumbnail at once. -- [ ] Narrow the content below 520px: the gallery switches to one column, the - rows stack their controls under their labels, and the folder actions drop - under the path without clipping or covering each other. - -## Appearance page - -- [ ] Dark / Light changes the shell palette; Glass effect switches the bar, - popouts, launcher, notifications, OSD, shortcuts, tooltips, - and floating menus between blurred translucent and opaque surfaces. - The full-screen shortcut scrim stays translucent in both modes. -- [ ] Glass effect applies without closing Settings or remapping/flickering the - bar. Toggle it twice quickly, then reload Hyprland and restart Quickshell; - the final persisted state wins each time. -- [ ] Text & size opens with a live preview — a bar strip (workspaces, date - and clock, status icons) and a popover with two rows — captioned - "Preview · text renders at N px". Text size, Interface scale and Density - resize it along with the rest of the shell, and N matches the rendered - base size (12 at defaults, 14 at Large with 100%). -- [ ] Text size, Interface scale and Density are in view; Base font size is - under "Advanced text options", which stays closed on opening. Searching - "Base font size" opens the disclosure and highlights the row. With a - changed base size and the disclosure closed, the page foot still offers - "Reset Appearance to defaults". -- [ ] Interface font is a dropdown at the controls' edge. The closed button - and every option draw in their own face; picking one reflows the bar and - popovers instantly. Test every menu font: option names stay within the - list without clipping. -- [ ] Accent source Wallpaper shows a Wallpaper palette row: six swatches at - the controls' edge (hover names each) and a hint that reads "Generated - from " or the generation status. Switching Dark / Light selects the - cached variant without a new Matugen process; changing wallpaper - regenerates once after the debounce. -- [ ] Change wallpapers rapidly: no stale palette flashes. Temporarily hide - `matugen` or feed malformed output: the selector remains Wallpaper, - the fallback error appears on the palette row, and the stored fixed - palette renders. -- [ ] Accent source Fixed reveals an Accent color row (six presets, right - aligned, their name and hex on the hint line) and an Accent hue row - directly under Accent source, and scrolls them into view. The presets - are one Tab stop; arrow keys, Home and End move and pick. Presets and - hue recolor the whole shell, and in Wallpaper mode they are neither - focusable nor exposed. -- [ ] Resetting Accent source returns to Wallpaper and restores the fixed - accent with it; Undo brings both back. -- [ ] On the Bar page (moved there from Appearance), Background offers Shell - Default, macOS, Black, Graphite, Slate, White, and Custom in both - Wallpaper and Fixed accent modes, and the accent choice leaves it - unchanged. -- [ ] A Black menubar changes its text/icons to light tones; White changes - them to dark tones. Accent, warning, error, workspace, weather, and T3 - marks remain legible, with no change to popover colors. -- [ ] Custom reveals Hue, Saturation, and Lightness sliders. Their tracks and - the real bar update live, the chosen HSL survives a preset round-trip, - and the Bar Background reset restores the adaptive Shell Default. -- [ ] Panels: Border color appears directly under Border only for Custom, and - resetting Border also restores the custom color. Border opacity is - disabled and says why at width 0. -- [ ] Plugins: with Match shell style off, Interface scale, Border and Corners - appear; Border color only for Custom, Border width and opacity for every - mode except Shell. -- [ ] Appearance has no preset actions. Fresh settings use Dark mode, Hug, - wallpaper colors, opaque surfaces, numbered workspaces, JetBrains Mono - at size 12 and 100% scale, and no panel borders. - -## Bar page - -- [ ] A preview of the bar stays pinned at the top of the page while the rows - below it scroll. Over the current wallpaper it draws the bar at its edge, - in its style (Hug with its inverted corners, Floating inset by the edge - gap with its corner radius, Attached square), at its height and in its - background colour, with each section's enabled widgets as their icons, - the workspace strip and the clock's time. Every Layout, Background and - Behavior row changes it as it changes the real bar; Auto-hide fades it, - and Reserve space off lets its window slide under the bar. -- [ ] Clicking a widget in the preview opens that widget's options; closing - the dialog leaves the page scrolled where it was. At a short window - height the preview shrinks before the rows lose their room. -- [ ] Position Bottom moves the bar; every popout opens above it with its - directional motion mirrored, content upright; tooltips flip - above modules; toasts hug the top edge; Esc/hover-switching still work. -- [ ] Style picker renders Hug as full-width with 16px concave corners, - Floating as the existing detached rounded slab, and Attached full-width - and square. Top/Bottom mirrors Hug's corners without mirroring content. -- [ ] Edge gap and Corner radius appear directly under Style only for - Floating and leave keyboard and accessibility traversal immediately when - hidden. Their stored values survive a round trip through Hug and - Attached; resetting Style restores them along with it. -- [ ] Height offers Compact (30), Classic (34), Default (36), Roomy (42) and - Custom, with the value beside them. A stored height no preset names - shows Custom. Custom reveals a Custom height slider (28–60 px) that keeps - the current value, and dragging it across 34 or 42 does not fold it away. - Resetting Height, the page, or everything returns the picker to its - preset. Where the bar cannot be that short at the current text size, the - row names the height it is drawn at. A search for Height lands on the row. -- [ ] Bar background is one row: the label on the left, the seven swatches on - the control edge. Arrow keys, Home and End move and pick; the hint names - the colour, its hex value, and whether it adapts to the theme. Custom - reveals Hue, Saturation and Lightness as attached rows; the row's reset - restores Shell Default together with the custom values. -- [ ] Changing Height or Corner radius dirties/reset-enables Bar only; - Appearance remains clean. Reset Bar owns and restores both values. -- [ ] Auto-hide: bar slides away after ~1.6 s without hover; hovering the - screen edge reveals it; it stays out while a popout or the settings - window is open; clicks pass through the vacated strip. -- [ ] Reserve space off lets tiled windows extend under the bar - (exclusive zone released; Hyprland re-tiles once per toggle). -- [ ] Every connected output keeps its own bar while focus moves between - monitors; hotplug creates/removes only that output's bar. -- [ ] Opening a module or CybexOS Settings from either bar shows exactly one - panel, attached to the bar that was clicked. - -## Bar page — widgets - -- [ ] Left, Center and Right are rows of the Widgets group, separated by - hairlines rather than cards: the section's name in the label column and - its enabled widgets as chips in bar order, including widgets currently - hidden by runtime conditions (drawn quieter). An empty section says so - and still takes a drop. There is no per-section widget picker. -- [ ] Clicking a chip opens its options in a dialog sized to its settings, with - scrolling for longer forms. Close or Esc returns focus to the chip - without closing Settings. -- [ ] Every chip has a ⋯ button with Widget settings…, Move earlier/later, - Move to Left/Center/Right and Remove from bar; right-click, Menu and - Shift+F10 open the same menu. Remove retains settings; Undo restores - only that widget and its placement. -- [ ] Add widgets lists every disabled built-in and every installed plugin - widget instance as an outlined chip. Clicking one adds it to the section - it last lived in, settings retained, and keeps focus in the tray; its ⋯ - offers the other sections and its options. With every widget on the bar, - the tray says so. -- [ ] Drag within and between sections; the section under the pointer lights - up, the ghost and insertion marker track both axes, wrapped rows, empty - sections and edge scrolling, and the preview dims the dragged widget. - Escape cancels without writing. Alt+arrow keys and menu Earlier/Later - also reorder. -- [ ] Plugin adds are atomic, and reported success waits for the saved registry. - Plugin ordering matches the bar's separate plugin block in each section. -- [ ] At 480px window width, each section stacks its name above its chips, - chips use fewer columns, and menus/dialogs remain inside the window with - accessible controls. -- [ ] The ⋯ beside the caption under the preview offers Presets…, Manage - plugins…, Restore default built-in layout and Undo. Presets show the - chosen preset on the preview before it is applied; applying preserves - placement and all plugin preferences. -- [ ] Detail policy (Auto / Prefer detail / Always compact) is picked in the - dialog; Prefer detail compacts only after Auto widgets. -- [ ] Notifications → Grouping switches live between Separate and Status - group. It joins only adjacent Volume, Network, Bluetooth, or Battery - widgets, and every glyph keeps its own click target inside the pill. -- [ ] Per-module options apply live: clock seconds/date format, battery and - volume percentage toggles and thresholds, media title format and width, - T3 label and pulse, - workspaces min slots / hide empty / dots, notification grouping. -- [ ] Option rows in the dialog follow the page grammar: controls end on one - edge, rows are separated by hairlines, and explanatory copy (notes title - privacy, notification grouping, the sign-in policy) sits on the row's - own hint line. -- [ ] Indicators expands inline with Clock hover / Always show / Active only, - per-action switches, and drag/keyboard ordering. A hidden Dictation or - Screen recording action returns while running so it can always be stopped. -- [ ] The OCR clock-side action starts region selection, copies recognized text - to the clipboard, and remains hidden in Active only mode. A region with - a QR code copies the code's content instead; a link is not opened and a - Wi-Fi code's notification names the network without its password. -- [ ] `Print` with a click on a window (tiled, floating, on a second output, - with fractional scaling) captures exactly that window; a drag still - captures a region; a window hidden behind a fullscreen one is not offered. -- [ ] `Alt+Print`: the hint shows while selecting and is gone before capture; - the menubar's red recording indicator stays lit while scrolling (its - tooltip offers to finish), Enter, Escape, the shortcut again, or a - click on the indicator each finish, and Enter/Escape reach applications - normally afterwards. A page with a sticky header joins without repeats. -- [ ] `` Super+Ctrl+Shift+` ``: the round camera bubble appears in the recorded - area's corner, is in the saved video, can be dragged to another corner, - and the camera light goes off when the recording stops. Without a camera - one notification says so and the recording still runs. -- [ ] `screen-record --encoder` names VA-API on Intel/AMD with H.264 encode - support, and recordings play; on NVIDIA it names NVENC or an installed - gpu-screen-recorder, and the menubar indicator still tracks and stops it. -- [ ] `Super+Print` / `Super+Shift+C`: clicking a pixel copies its hex value and - the notification shows a swatch of that colour; Escape is silent. -- [ ] In Clock hover mode, reveal the indicators and click the clock without - moving the pointer. They stay expanded; moving into Calendar or switching - to another bar view causes no collapse or flicker. Close the view and - leave the bar: indicators collapse normally. A view on another output - does not hold them open, and opening an unrelated view with indicators - already collapsed does not expand them. -- [ ] Indicator action options apply live: dictation languages/model, recording - region/window/screen and elapsed label, reminder icon/count and quick-add - duration, DND click lifetime, and Stay awake click duration/countdown. -- [ ] Night light, DND, and Stay awake each honor Remember / Off / On after a - new desktop login. Restarting only `quickshell.service` preserves the - live state; timed DND/idle requests keep their original absolute deadline. -- [ ] Weather place/latitude/longitude edits commit on Enter or focus loss and - refetch; Esc inside a text field restores the value without closing - anything; junk input snaps back to the stored value. -- [ ] GitHub's account and watched repositories sit on the row grid without - cards: add a repository with Enter or Add, remove one with its ×, and a - watch error shows under that repository. -- [ ] Reset Bar resets layout, detail policies, and all module options (with - Undo); per-row undo chips reset one option. -- [ ] Toggles apply to the bar instantly; auto-rules keep working (Media - only while playing, Bluetooth only when connected, Battery on - laptops). -- [ ] Disabling a module whose popout is open closes that popout. -- [ ] T3 Code and Model Usage can each be toggled, reordered, and moved - across columns, including between built-in widgets; a fresh install - with connected widgets shows Model Usage first in the right section. -- [ ] Model Usage's options (bar display, bar providers, thresholds, refresh, - account privacy, square cards) apply live, and "Source and accounts" and - "Cost sources" open the panel's own settings. -- [ ] Disabling T3 Code or Model Usage while its popout is open closes only - that popout; the other widget still opens normally. -- [ ] Volume, Network, Bluetooth, and Battery can be reordered within or - across columns; each dedicated popout follows its widget. The fixed - Fedora Control Panel button remains at the right edge. -- [ ] In a narrow/stacked settings panel, pointer and keyboard drops use the - correct column-relative index, edge dragging scrolls, and focus returns - to the dropped row. -- [ ] With the Everything preset, the preview's center section moves aside - rather than painting over a crowded left or right section. - -## Notifications page - -- [ ] The sample toast heads the Style group, above Density, in the page's - one column (no second column beside the rows at any width). It updates - live for density, icons, body lines and timeout progress; the line under - it reads position · duration · density. -- [ ] Body preview is a segmented choice of Off / 1 line / 2 lines / 3 lines, - and the sample and real toasts follow it. -- [ ] Quiet hours Off/Nights hides the Quiet from / Quiet until rows and - removes them from Tab/Orca traversal; Custom reveals both directly under - Quiet hours, preserving the stored range. Nights and Custom show the - range beside the choices. -- [ ] "Send test" sits at the sample's foot and sends a real toast using the - current settings. With Do Not Disturb or quiet hours active, a line - under it says the test only collects in the center. -- [ ] On-screen display → Placement Top shows volume/brightness pills - top-center, clearing the bar; Bottom returns them; slide-in direction - matches the edge. - -## Displays page - -- [ ] The arrangement is the display picker. Clicking a tile, or tabbing to it - and pressing Enter or Space, selects it: the tile gains a check and an - accent outline, and the group below is titled with its kind and maker - (for example BUILT-IN DISPLAY · BOE) over a connector · model note. - Arrow keys on a focused tile place it beside the others and keep focus - on it. -- [ ] Turning a display off, or setting it to mirror another, moves it out of - the layout to a chip under it ("DP-1 · off", "HDMI-A-1 · mirrors - eDP-1"). Selecting the chip opens its rows; turning it on returns it to - the layout. -- [ ] Every property is one row with its control on the right edge: - Resolution, Refresh rate, Scale, Mirror and Adaptive sync as dropdowns, - Rotation as one segmented control, Flipped and Use this display as - switches. Rotation and Flipped together round-trip all eight transforms. -- [ ] Any edit raises the Apply bar at the page foot: "N changes not applied - yet" and which ones. Discard restores the live values. An overlap or - other problem replaces the list with the reason, and Apply does - nothing. The last row scrolls clear of the bar. -- [ ] Apply turns the bar into "Keep these display settings?" with a - 15-second countdown and a draining line. Keep changes saves; Revert now, - Escape, closing Settings or the countdown restore the previous - settings, and a countdown that ran out leaves a note at the top. -- [ ] There is no standing Refresh. Loading and errors appear as a line at - the top, with Refresh only after an error. Night light and Warmth still - apply as they change. -- [ ] Warmth drag with Night light on retints smoothly (single hyprsunset - restart per pause, not per step). - -## Network page - -- [ ] Saved Wi-Fi and wired connections are a list with their type and a - Connected status; the physical adapters are a quiet line under it. - Selecting one opens its settings below, under its name. -- [ ] Connect to Wi-Fi… and Advanced connection editor share one row with the - certificates, bridges and routing hint. -- [ ] VPN lists each NetworkManager VPN and WireGuard profile with its type and - state and a switch. Connecting a password VPN opens the VPN plugin's own - sign-in dialog; Cancel there reads "The VPN sign-in was canceled" on the - row. Remove… asks on a second row before anything is deleted. With no - VPN the group says so; Import VPN… opens a file picker in Downloads that - lists only .ovpn and .conf files, and the import does not connect. -- [ ] Hotspot appears only with a Wi-Fi adapter. While Wi-Fi is connected on - the only adapter, a warning says that network disconnects. Turning it on - shows Show password and QR code, which opens the join card with the - password spelled out; a phone joins by scanning it and gets an address - and internet. After a reboot the hotspot is off. -- [ ] The drawer's Network tab shows a VPN switch under Tailscale only when a - VPN profile exists: the connected one, else the one used last. -- [ ] Autoconnect and Metered connection read as ordinary rows; the metered - label no longer runs into its choices. -- [ ] IPv4 or IPv6 Manual reveals labelled Address and Gateway fields. A - malformed entry outlines its field, names the problem under it and in - the Apply bar. The DNS field reads Additional DNS while Automatic DNS - is on and DNS servers while it is off. -- [ ] Typing in a field raises the Apply bar at once, and the other - connections dim until the edits are applied or discarded. Applying an - active connection starts a trial in the bar ("Keep these network - settings?"): Keep saves; Revert now, closing Settings or the countdown - restore. An inactive connection saves directly and says so. - -## Sound page - -- [ ] The page is settings rows only: no drawer header, no MICROPHONE 100 - heading, no standing Refresh. -- [ ] Output: Volume with a mute button and a percent readout (Muted while - muted); one row per output device with a check on the default, and - AirPlay/network outputs folded under "N network outputs". Port appears - for a device with several ports; Balance (Center, L n, R n) follows a - drag without snapping back and is greyed out with a reason on a - non-stereo output. -- [ ] Input: the device list, Input volume with mute, and a live Input level - meter lined up under the volume track. -- [ ] Hardware profiles are dropdown rows. Each application is one row with - mute, its level and its output or input device. Advanced audio - controls opens Volume Control. - -## Touchpad page - -- [ ] Scroll speed reads 1.0× at the default, snaps to its 1.0 tick, and - applies to touchpad scrolling as it changes. - -## Power page - -- [ ] The Idle timeline marks Screen off, Lock and Suspend at their current - delays on a 1m–2h axis, with Never as a dashed zone at its end. Delays - that share a value share one dot; neighbouring labels rise onto a longer - stem instead of touching, at 900px and 480px window widths. -- [ ] Set Lock screen to 30 min with Screen off at 10 min: the span between - them is shaded amber and the line under the timeline warns that the - screen turns off 20 minutes before it locks. Lock at Never warns that it - never locks. Otherwise the line reads the order back in words. -- [ ] Lock screen, Screen off and Suspend are dropdowns that save and restart - hypridle as before; the power drawer's idle link lands on Lock screen. -- [ ] With `~/.config/cybexos/hypr/hypridle.conf` present, the timeline dims - and says it is shown for reference; the three dropdowns and Only on - battery are disabled with "Set in your hypridle.conf". -- [ ] Stay awake Duration starts, retimes and stops the inhibitor; its hint - shows the time left. Bar indicator → Indicator settings opens the - Indicators widget on the Bar page. - -## Region & formats page - -- [ ] 12 h clock reformats the bar clock and the "Now …" caption. -- [ ] °F refetches weather in Fahrenheit (bar chip + popover + forecast); - the caption reads "… outside" in the new unit. - -## Online accounts page - -- [ ] With no accounts the group shows "No accounts connected" and an - accent-filled Add account, which opens the account window. There is no - standing Refresh. -- [ ] Each connected account is a row (address, provider and status) with a - Use calendars switch under it. An account needing attention says so in - amber and offers Reconnect. Remove… asks first; Cancel leaves it. -- [ ] Stop the accounts helper (or break GOA): an error row with Refresh - appears; Refresh clears it once the helper answers again. - -## Omarchy plugins page - -- [ ] The rail and header say "Omarchy plugins". Browse plugins opens - https://plugins.omarchy.org/ in the browser, as does the link in step 1. - Below 520 px the button drops under the intro copy. -- [ ] Three numbered steps explain finding, pasting and enabling a plugin. -- [ ] Install is disabled until Source holds text; Enter in the field installs. - Pasting `omarchy plugin add --enable` installs `` (still - disabled). The result or error reads under the Source row. -- [ ] With nothing installed, Installed shows the "No plugins yet" state; - otherwise its heading carries the count. -- [ ] Each installed plugin is one row: name, "id · version · adds …", an - Enabled switch and a ⋯ menu with Update, Preview update, Clone as a - custom copy… and Remove…. Clone opens a New ID row under the plugin; - Remove… opens a confirmation row. A command's output reads under the - plugin it ran on. -- [ ] There is no "Configure bar widgets" link; plugin widgets appear in the - Bar page's tray. - -## About page - -- [ ] Status shows a green dot for Healthy, amber for issues or a failed or - rolled-back deployment, red when the service is down. -- [ ] Last deploy check reads "Today at 19:16", "Yesterday at …", a weekday or - a date in local time, in the Region clock format; Refresh re-reads it. -- [ ] The settings file path elides in the middle; Open saves then opens it. - Reset all settings is red, resets every page and offers the rail's - eight-second Undo. -- [ ] About has no recovery points; they are on Backup & recovery. - -## Backup & recovery page - -- [ ] Without backups, the Backups group is one row, "Personal files · Not - backed up", with Set up…. -- [ ] Set up… shows Step 1 of 3 in the page: Drive or folder / SSH server, - Choose… opening at the mounted drives, Cancel and Continue. A folder in - the home folder is refused with a reason; an SSH host that is not yet - trusted fails with "does not trust … yet". -- [ ] Step 2 of 3 shows the recovery key once in large mono text with Copy; - the copied key pastes, and does not appear in clipboard history. - "I saved it, back up now" starts the first backup (Step 3 of 3) with a - percentage that keeps updating after closing and reopening Settings. -- [ ] Choosing a drive that already holds CybexOS backups asks for their - recovery key and connects them; a wrong key says so. -- [ ] Last backup reads "Today at …" with a green dot and "Next backup …"; - unplugging the drive turns Destination's hint into "… is not - connected." and Back up now fails with that reason in a notification. -- [ ] Automatic backups switches off and on; the hint then reads "Automatic - backups are off." -- [ ] Restore… lists the backups by local time; restoring "Documents" opens - Files at ~/Restored//Documents and leaves ~/Documents unchanged. -- [ ] Recovery points list as rows named by local time, with the UTC stamp the - boot menu shows for bootable points. Restore… turns the row into a - warning with Confirm restore and Cancel. With none, the row says - "None yet". -- [ ] Search for "backup", "restore" or "rollback" lands on this page. - -## Regression sweep - -- [ ] Volume, Network, Bluetooth, and Battery are distinct transparent-resting - buttons. Each opens its own Audio, Network, Bluetooth, or Battery view; - with one open, crossing another button switches the panel in place. -- [ ] The rightmost Fedora logo opens the Control Panel. Its SESSION row shows - five equal controls in order: Lock, Suspend, Log out, Restart, and red - Shut down. Each closes the panel before running its established action. -- [ ] The launcher's Power action and `cybexos-runtime ipc session power` - open/toggle the Control Panel on the focused output; - `cybexos-runtime ipc session lock` remains a direct lock action. -- [ ] All popouts open/close/hover-switch as before at default settings; - Calendar → Weather and other adjacent-module switches work without a - second click; with Settings open, hovering a module also switches. -- [ ] Resize/hotplug from a wide output down to 800 logical px: detail compacts - Media → Weather → Clock date → T3 → Volume → Battery, every - enabled module remains, clusters retain an 8px gutter, and the center - shifts only after all eligible detail is compact. -- [ ] Fine-grained touchpad scrolling over Volume changes it once per - accumulated wheel step, not once per raw event. -- [ ] Workspace cells keep a 22px width and full 30px target. Moving right - sends the lozenge's right edge in 120ms and left edge in 300ms; moving - left reverses those assignments. Hide-empty/model/settings changes snap, - while urgency, numbered mode, tooltips, and accessible actions persist. -- [ ] Tab/arrow traversal, automatic focus scrolling, roles/states/actions, - and Orca announcements work for navigation, custom controls, resets, - drag/drop, module cogs and their sub-pages, and save errors. -- [ ] Capture comparison screenshots for default Hug/Wallpaper, Floating/Fixed, - and a narrow panel. In each, check header title/description, preset/test - action copy, font samples, notification labels, config/folder paths, - module names, and preview chips for clipping or overlap. -- [ ] `journalctl --user -u quickshell.service` free of QML errors and - binding loops after exercising every page. -- [ ] `tests/verify-system` passes. - -## Consistency regression checks (September 2026) - -- [ ] At 900px and 480px window widths, switch Wallpaper/Fixed and Custom bar - color. Subsection headings, swatches and status text follow the label - gutter; closed revealers leave no subsection spacer. Long palette errors - wrap rather than disappearing after two lines. -- [ ] Picker captions stack below narrow controls. Long options can wrap in - wide rows too, without painting over the next row. Tab to a reset chip: - its announcement includes the setting name, and focus does not shift the - control or scroll the page when it moves into navigation/search. -- [ ] Dirty/clean group headers keep their height and label width. Long titles - wrap within the reserved reset lane. Plugin install/clone fields follow - the shell palette and show a focus outline in light, dark and contrast - modes. Test Enter, blur, Escape and normalized/rejected text edits. -- [ ] In the drawer, Tab reaches the current tab; Left/Right and Home/End select - adjacent/edge tabs. Labels elide before tabs overflow, and a single-tab - configuration fills the lane. -- [ ] Pale fixed accents remain readable as text/icons/outlines in Light and - High contrast. The actual swatch/fill is unchanged. Test default, larger - text/comfortable density, and 80% UI scale/compact density. -- [ ] Notification actions wrap inside their card. Shortcuts become one column - at narrow widths and scroll with arrows, Page Up/Down and Home/End. - Wallpaper filename overlays retain white copy over their dark scrim. - -### Scope and verification record - -The pass covers the Settings workspace and all eight pages, widget detail -controls, wallpaper folder dialog, Bar and tooltip/menu components, launcher, -notification cards/actions, OSD, shortcuts and network overlays, all six Drawer -tabs, Day sheet, updates, notes, reminders, media, GitHub, T3 and Hermes. Legacy -popover entry points and the configurable `Ui`/`Commons` plugin kit were -reviewed through their shared controls and foreground roles. The standalone -welcome UI was inspected separately: its installer typography, palette and -minimum window size are deliberate and remain independent of the live shell. - -Rendered checks use the managed service and `tests/lib/quickshell-live` at -both boundaries. Captures exercise each Settings page, the available popout -entry points, launcher and shortcuts, plus wide/narrow appearance combinations. -Runtime component tests additionally cover wrapping picker bounds, subsection -sizing, hidden trailing group content, stable dirty headings, constrained drawer tabs, and text-field commit -normalization. See `tests/qml-lifecycle/shell.qml` and -`tests/quickshell/settings-layout.test.cjs`. - -Service-dependent states need an appropriate account/device: this workstation -presents T3/Hermes authentication screens and an -updater endpoint error. Those data sources and commands were not changed for -visual verification. Existing compatibility surfaces keep their separate -cursor-navigation and theme APIs; dense transaction/transcript/list layouts -were not forced into the settings row geometry. - -Generated wallpaper palettes were also checked in dark, light and high-contrast -modes, including gallery captions and keyboard selection in Drawer tabs. The -Widgets catalog switches to one column at the settings breakpoint, with inline -settings beneath their owning row and secondary tags yielding to widget names. diff --git a/docs/system-theme.md b/docs/system-theme.md deleted file mode 100644 index f82d3f08..00000000 --- a/docs/system-theme.md +++ /dev/null @@ -1,271 +0,0 @@ -# System theme - -The shell's appearance settings — dark or light, the fixed or wallpaper -palette (or a [named theme's](shell-appearance.md#themes)), the accent, the -bar colour, the font and the text size — reach the rest of the desktop, not -just the Quickshell surfaces. Changing any of them in Settings → -Appearance, or a wallpaper change in wallpaper mode, recolours the terminal, -the compositor, GTK applications and the lock screen within about a second. - -## How it works - -1. `Common/SystemTheme.qml` exports the colours `Theme.qml` has already - resolved as one JSON object (the *tokens*). Nothing is recomputed: the - wallpaper palette, the contrast floors and the bar colour are exactly the - ones the shell draws. Translucent tokens are flattened over the base, so - every exported colour is an opaque `#rrggbb`. -2. When the tokens change (debounced by 400 ms, and never while a wallpaper - palette is still being generated), the shell pipes them to - `scripts/theme-apply.py apply`. -3. The renderer validates them (`scripts/theme_tokens.py`), saves them to - `~/.local/state/cybexos/theme/tokens.json`, and lets each target module - render its files into the same directory. A target reloads its application - only when one of its files changed. - -| Token | Source in `Theme.qml` | -| --- | --- | -| `mode` | `dark` or `light` | -| `source` | `wallpaper` when the wallpaper palette is active, else `fixed` | -| `colors.background`, `surface`, `surfaceRaised`, `bar` | `background`, `popBg`, `menuBg`, `barBg` | -| `colors.text`, `textMuted`, `textDim` | `textHi`, `textMid`, `textDim` | -| `colors.accent`, `onAccent`, `accentText`, `accentContainer` | `accent`, `accentFg`, `accentText`, `accentContainer` | -| `colors.stroke` | `stroke` flattened over `background` | -| `colors.red`, `amber`, `green` | `redText`, `amber`, `ok` | -| `font.ui`, `font.mono` | `fontMenu`, `fontMono` | -| `font.size` | `fontBaseSize`: base size × interface scale × text size, in logical pixels (14 on a fresh install) | -| `radius` | `surfaceRadius` (the compositor's window rounding) | -| `glass` | `glassActive` | -| `wallpaper` | absolute path of the current wallpaper, or empty | - -The sixteen terminal colours are derived by `theme_tokens.ansi_palette()`: -fixed hues per mode, pushed toward the ink colour only where a hue would fall -below 4.5:1 on the base, with black and white taken from the shell's text -ladder. - -## Text size - -`font.size` is the size the shell itself draws body text at -(`Common/ShellMetrics.js`), so Settings → Appearance → Text size, Interface -scale and the base font size reach the terminal, GTK applications and the -lock screen too. The targets do not share a unit — kitty and hyprlock size -fonts in points, GTK takes a scaling factor — nor a default: kitty's is -12 pt, GTK's 11 pt, the lock screen's clock 84. Converting the shell's 14 px -to 10.5 pt would shrink all of them, so each keeps its own default at the -shell's default size and scales it by the same ratio, -`theme_tokens.text_scale()` = `font.size` / 14, bounded to GNOME's 0.5–3.0: - -| Shell text | `font.size` | Scale | kitty `font_size` | GTK `text-scaling-factor` | Lock clock | -| --- | --- | --- | --- | --- | --- | -| Default | 14 | 1.00 | 12.0 | 1.00 | 84 | -| Large | 16 | 1.14 | 13.7 | 1.14 | 96 | -| Larger | 18 | 1.29 | 15.4 | 1.29 | 108 | -| 150% interface scale | 21 | 1.50 | 18.0 | 1.50 | 126 | - -Tokens saved by an older shell have no `font.size` and render as before. -User overrides keep winning exactly as for colours; see each target. - -## Diagnostics - -```bash -cybexos-runtime ipc theme status # last report per target, and any error -cybexos-runtime ipc theme apply # re-render and reload every target -``` - -`theme-apply.py render` rewrites the files from the saved tokens without -reloading anything. - -## Ownership - -Everything the renderer writes lives in `~/.local/state/cybexos/theme/` -(state, not configuration). User overrides keep winning: a user -`~/.config/cybexos/hypr/hyprlock.conf` replaces the generated lock screen, and -`~/.config/cybexos/hypr/user.lua` loads after the generated compositor -colours. - -## Targets - -### Kitty - -`scripts/theme_kitty.py` renders `kitty.conf`: `font_size` (12 pt scaled by -the shell's text size), foreground and background -(`text`, `background`), the selection (`accentContainer` with the ink the shell -draws on it), cursor and URLs (`accentText`, kept at 4.5:1 on the base), tabs -(the active one filled `accent`/`onAccent`, inactive ones `surface` with -`textMuted`), window borders, the three marker colours and `color0`–`color15` -from `ansi_palette()`. The font family and opacity stay in the vendor fragment. - -The vendor fragment, `roles/dotfiles/files/kitty.conf` (installed as -`~/.config/kitty/cybexos.conf` and included from the user's `kitty.conf`), -carries the same settings for the default dark theme, and `font_size 12.0`, -as a fallback, and a test keeps it equal to that render. Its last line, -`include ~/.local/state/cybexos/theme/kitty.conf`, loads the generated file -over it. kitty expands `~` in `include` and only logs a missing file, so a -terminal started before the shell's first export still opens with the -fallback. `globinclude` is not usable here: it globs relative to the including -file's directory, and kitty 0.47's Python refuses an absolute pattern, which -aborts loading the configuration. - -Reload sends `SIGUSR1`, which makes kitty re-read its configuration and every -include, to the user's GUI kitty processes: -`pkill --signal USR1 --uid --full --exact '([^ ]*/)?kitty( \+open)?( [^+@].*)?'`. -The command-line match mirrors kitty's own `is_kitty_gui_cmdline()`; a plain -`pkill -x kitty` would also hit Python kittens (`kitty +runpy …`), which -SIGUSR1 terminates. No running kitty (pkill status 1) is not an error. kitty's -`auto_reload_config` watcher does not help: it watches only the top-level -`kitty.conf`. - -User colours and a user `font_size` win when they come later: put them in -`~/.config/kitty/kitty.conf` after the `CYBEXOS MANAGED INCLUDE` block. kitty -applies a changed `font_size` to open windows on the same `SIGUSR1` reload. - -### Hyprland - -`theme_hyprland.py` renders `hyprland.lua`: a data-only `return { ... }` of -`hl.config` colours. - -| Key | Colour | -| --- | --- | -| `general.col.active_border`, `inactive_border` | `accent`, `stroke` | -| `group.col.border_active`, `border_inactive` | `accent`, `stroke` | -| `group.col.border_locked_active`, `border_locked_inactive` | `red`, `stroke` | -| `group.groupbar.col.active`, `inactive`, `locked_active`, `locked_inactive` | `accent`, `stroke`, `red`, `stroke` | -| `group.groupbar.text_color`, `text_color_locked_active` | `text` | -| `group.groupbar.text_color_inactive`, `text_color_locked_inactive` | `textMuted` | -| `decoration.shadow.color` | dark: `background` at 0xee; light: `text` at 0x30 | - -Window borders are 0 px in the vendor look, so their colours only show once -`user.lua` sets a `border_size`. Rounding is not a theme value: it stays 16 px -to match `Theme.surfaceRadius`, whatever the panel corner setting. Blur, the -glass layer rule and power saver are untouched. - -`looknfeel.lua` applies the file right after its vendor `hl.config`, whose -colours are this renderer's output for the default dark tokens (a test keeps -them equal). A missing file keeps those colours. The file is read as text, -loaded in an empty environment and checked key by key: a syntax error, a call, -a value that is not `rgb(rrggbb)`/`rgba(rrggbbaa)` or an oversized file leaves -the vendor colours in place and is reported by - -```bash -hyprctl repl 'return __cybexos_system_theme.error' -``` - -Keys the compositor config does not know are ignored, so a newer renderer never -costs an older config the whole theme. - -A theme change reaches the running compositor through -`hyprctl eval 'cybexos_system_theme("/hyprland.lua")'`, which -applies the same checks and raises instead of half-applying. Without -`HYPRLAND_INSTANCE_SIGNATURE` reload does nothing and the next compositor start -reads the file. A full `hyprctl reload` was rejected: it drops an unconfirmed -display arrangement and would re-run the whole configuration on every -wallpaper change in wallpaper mode. - -**`user.lua` keeps winning.** It loads after `looknfeel.lua`, so at config load -its colours override the theme's. For the live path, `looknfeel.lua` records -what each theme key holds (`hl.get_config`) straight after it applies the -theme, before `user.lua` runs. A live change applies a key only if it still -holds that value; a key that `user.lua` — or a manual `hyprctl eval` — has set -since is left alone until the next config reload, which takes a fresh -baseline. A user value identical to the theme's is indistinguishable from it -and follows the theme. - -### GTK - -`scripts/theme_gtk.py` sets four `org.gnome.desktop.interface` keys with -`gsettings`, and records them in `gtk.json`: - -| Key | Value | -| --- | --- | -| `color-scheme` | `prefer-dark` or `prefer-light`, from `mode` | -| `gtk-theme` | `adw-gtk3-dark` or `adw-gtk3` (package `adw-gtk3-theme`), so GTK 3 applications match libadwaita ones | -| `accent-color` | the libadwaita accent (GNOME 47) nearest to `colors.accent` by hue; an accent with too little colour for a hue becomes `slate`. Skipped when the installed schema has no such key | -| `text-scaling-factor` | `theme_tokens.text_scale()` to two decimals: `1.00` at the shell's default text size (see [Text size](#text-size)) | - -Running GTK 3 and libadwaita applications follow the change, and so do -Firefox, Chromium and Electron applications, which read the same keys through -the settings portal (xdg-desktop-portal-gtk). `gtk.json` is only the record of -what was last applied: the keys are set again only when it changes, so after -changing one by hand run `cybexos-runtime ipc theme apply` to restore the -shell's values. - -`text-scaling-factor` is the one key people also set elsewhere (GNOME Tweaks, -an accessibility guide), so the shell only replaces a value it set itself -(recorded in `gtk-text-scale`) or GNOME's default 1.0. A value of the user's -own wins, including over `theme apply` and on the first export after an -upgrade, until `gsettings reset org.gnome.desktop.interface -text-scaling-factor` hands the key back. - -The shell owns the other keys. The converge only gives a key still at its schema -default (`color-scheme` `default`, `gtk-theme` `Adwaita`) the dark default a -fresh install's first session shows until the shell has applied its tokens, -so re-running Ansible never undoes a choice made in Settings. - -`GTK_THEME` is no longer set in `~/.config/environment.d`: it overrides -`gtk-theme` for GTK 3 and forces libadwaita's stylesheet, so no mode change -could reach either. The user manager lingers and read that file when it -started, so the converge also removes the old `GTK_THEME=adw-gtk3-dark` from -it (`unset-environment`, then a `daemon-reload`, because a value that came -from environment.d survives the unset alone) and restarts the GTK portal, which -draws every application's file chooser; the shell and the applications -it starts lose the variable at the next login (or -`systemctl --user restart quickshell.service`). Until then those applications -stay dark whatever the mode. The session bus copied the old environment when it -started, and it lingers too, so the few D-Bus-activated applications that are -not started through systemd keep the pin until the next reboot. - -### Lock screen (hyprlock) - -`scripts/theme_hyprlock.py` renders `hyprlock.conf`: the vendor layout (clock, -date, password field and the fingerprint prompt under it) in `font.ui`, sized -by the shell's text size (the clock's 84, the date's 24, the 300×54 field and -the gaps between them all scale together, so larger text never overlaps), with -the clock in `text`, the date, the field's placeholder and the fingerprint -prompt in `textMuted`, the field filled with `surface` at 85% and ringed with -`stroke`, turning `accent` while checking and `red` on a failure. - -With a wallpaper, the background is that image, blurred (3 passes of size 8, -enough to reduce it to soft fields of colour) under a full-screen scrim of -`background`, with `background` as the colour hyprlock falls back to if the -image cannot be loaded. The scrim does all of the dimming: hyprlock's -`brightness` only darkens, which would sink the light mode's dark text, and -its `contrast` curve never moves pure black or white, so both are set to 1.0. -The scrim's opacity is the least step from 50% to 90% that keeps `text` at -4.5:1 over a pure black *and* a pure white wallpaper (65% for the fixed dark -palette, 60% for the light one), and every text colour is then moved toward -black or white where needed to reach 4.5:1 on the worse of those two -backdrops, and on the field's fill over them. In the dark mode that lifts the -date nearly to `text`; the size difference carries the hierarchy. Without a -wallpaper, or with a file name hyprlang would misread (`#`, `$`, braces or -edge spaces), the background is plain `background`. - -`reload` does nothing: hyprlock runs only while the session is locked and reads -its configuration at every lock, so the next lock shows the new look and one -already on screen keeps the look it started with. - -`cybexos-runtime exec hyprlock` (the `cybexos-session-lock.service` command) -picks the configuration in this order: - -1. `~/.config/cybexos/hypr/hyprlock.conf`, when it is a regular file; -2. the rendered `$XDG_STATE_HOME/cybexos/theme/hyprlock.conf` (default - `~/.local/state/…`), when it is a non-empty regular file, not a link, whose - first line is the renderer's header; -3. the vendor `~/.local/share/cybexos/runtime/hypr/hyprlock.conf`. - -For the second and third, it then adds the sign-in factors (fingerprint and -security keys) at lock time; see [Fingerprints and security keys](security.md). - -The vendor file is the renderer's output for the fixed dark palette without a -wallpaper, so the lock screen looks the same before the shell's first export; -`tests/quickshell/system-theme-hyprlock.test.cjs` keeps the two equal. A -damaged rendered file cannot prevent locking: the renderer replaces it -atomically, and hyprlock ignores entries it cannot parse rather than refusing -to lock. - -## Adding a target - -A target is a `scripts/theme_.py` module listed in `TARGETS` in -`theme-apply.py`, with `NAME`, a deterministic `render(tokens)` returning -`{file name: content}` (at least one file), and `reload(tokens, directory)`, -which raises with a short, readable message on failure. Reload commands must be -resolved through `PATH`: the tests run every target with a PATH of stub -commands, so they can never signal or reconfigure the real desktop. diff --git a/docs/t3-composer-manual-verification.md b/docs/t3-composer-manual-verification.md deleted file mode 100644 index 19c41b0b..00000000 --- a/docs/t3-composer-manual-verification.md +++ /dev/null @@ -1,65 +0,0 @@ -# T3 composer manual verification - -Use a connected T3 Code server with at least one provider that advertises a -select trait and a boolean trait. - -## Disclosure and layout - -- [ ] Open an existing thread and New Thread. Confirm each uses one rounded - glass composer: prompt above, compact model/reasoning/access controls below, - and a circular send arrow at the lower right. -- [ ] Open New Thread and confirm the prompt starts focused with its placeholder - still visible, typing lands immediately, and `Escape` still returns to the - inbox. -- [ ] Activate the tune/summary row and confirm the Run settings drawer attaches - immediately above the composer. Leave the page, reopen it, and confirm the - drawer resets to collapsed instead of persisting the previous choice. -- [ ] Confirm the toolbar stays one row: model text elides before access text, - while the tune and send/stop actions never clip. Run settings must have a - Tabler close control rather than a typographic chevron. -- [ ] Confirm `Full access` is amber in both the toolbar chip and expanded Access - picker. Below 360px of effective composer width, confirm reasoning moves into - Run settings while model and access remain available in the toolbar and its - accessible description. -- [ ] On New Thread, confirm Project appears as a compact shoulder directly - above the composer rather than as a separate full-page form section. -- [ ] Confirm expanded controls use the wider T3 panel's available columns and - stack without horizontal clipping on an output-constrained width. On a short - screen, confirm the content-sized panel stops at the output cap and its form, - transcript, attachment, and picker scroll areas remain usable. - -## Input and state - -- [ ] Toggle the settings row with the mouse, then use `Tab`, `Enter`, and - `Space`; confirm keyboard focus has a visible ring and the drawer follows the - state. -- [ ] Repeat while the composer is read-only, running, or sending; the header - must still open for inspection while each setting remains disabled. -- [ ] Tab through provider, model, access, mode, select traits, boolean traits, - prompt, and Send. Exercise picker open/close, arrow-key selection, and - `Escape`. -- [ ] Open every picker in both New Thread and an existing thread. Confirm its - menu is opaque and stays above the other settings, prompt, buttons, and error - text; opening a second picker must close the first. Reopen it and click - elsewhere in the drawer without selecting an option; the menu must close and - the underlying control must still receive that click. Selected options should - show a check and the first nine choices should show shortcut numbers. -- [ ] Test idle, running, read-only, sending, plan-ready, and provider-locked - threads. Confirm the prior enablement, model-locking, prompt, and send behavior - is unchanged. -- [ ] Change provider/model and confirm the traits and summary chips update. - Confirm Mode only appears when supported, and exercise every advertised - select and boolean trait. -- [ ] Select Ultrathink and confirm prompt injection and the prompt highlight - still work. Trigger a trait validation error and confirm it remains visible in - the open settings drawer. -- [ ] On a thread with an approval or structured question, confirm the request - is in a bounded drawer immediately above the composer—not in the transcript— - and that overflow remains scrollable. Repeat with a ready plan. - -## Automated and runtime checks - -- [ ] Run `tests/run` (Node tests plus qmllint over the whole shell). -- [ ] Deploy through the Quickshell Ansible tag with `qs_live_begin` / - `qs_live_end`; inspect that service invocation's journal for QML load - failures, syntax errors, binding loops, invalid sizing, and oversized buffers. diff --git a/docs/t3-git-actions-manual-verification.md b/docs/t3-git-actions-manual-verification.md deleted file mode 100644 index 6761d07c..00000000 --- a/docs/t3-git-actions-manual-verification.md +++ /dev/null @@ -1,94 +0,0 @@ -# T3 git actions manual verification - -Use a connected T3 Code server whose token has `orchestration:operate`, plus -threads whose project checkouts can be put into each git state below. - -## Dedicated panel layout and navigation - -- [ ] Confirm T3 opens as its own 520px attached panel under the T3 widget, - separate from the six status tabs and bounded by the output on a narrow - screen. -- [ ] Test both short and long inboxes: a short inbox hugs its visible rows and - footer, while a long inbox stops at the usable-height cap. Search remains - fixed whenever threads exist, the grouped list alone scrolls, and the - connection/status footer stays at the bottom. -- [ ] Open short and long conversations. The contextual header stays fixed, - the transcript takes the remaining height, and approvals, ready plans, task - progress, and the composer remain docked at the bottom. -- [ ] Exercise New Thread, Project, model, access, and Run settings. Menus stay - within the panel; right-side menus open inward; New - Thread scrolls to reserved popup room without growing a detached tail. At - 320px reasoning lives in Run settings, model text elides before access text, - and the tune and send/stop actions remain reachable. -- [ ] Check ready plans, approvals, task progress, signed-out, disconnected, - and read-only states at normal and output-constrained widths. Drafts, lifecycle - actions, authentication, and Git behavior must remain unchanged. -- [ ] Verify pointer and keyboard focus, then press Escape repeatedly: close - the active connection/picker/menu layer first, return from Thread or New - Thread to Inbox second, and close the drawer through the host last. -- [ ] Open with `cybexos-runtime ipc popouts toggle t3code`, then hover - between T3 and status-drawer glyphs. T3 keeps its own source and navigation - while the existing hover transition and held-state behavior remain intact. - -## Visibility (options must only appear when they apply) - -- [ ] Open a thread whose checkout has uncommitted changes: the ellipsis menu - shows only `Commit & Push` (plus `View PR ↗` if a PR exists for the branch). -- [ ] Open a thread whose checkout is clean but ahead of upstream: the ellipsis - menu shows `Push` and not `Commit & Push`. -- [ ] Open a thread whose checkout is clean and up to date with no PR: no Git - status, action, card, or tile is visible. -- [ ] Open a thread whose branch has an open PR: `View PR ↗` appears in the - ellipsis menu; it opens the PR in the browser and closes the drawer. -- [ ] Open a thread whose project folder is not a git repository: no Git UI is - shown. -- [ ] With a read-only pairing (token without `orchestration:operate`), confirm - `Commit & Push`/`Push` are hidden while `View PR ↗` still works. - -## Commit & Push (defaults to the current branch — main) - -- [ ] Trigger `Commit & Push` on a dirty checkout on `main`: the menu closes and - the compact line below the header follows the live phase (`Generating commit - message…`, then the push phase); generation longer than 15 s must NOT time - out (budget is 120 s, sliding). -- [ ] On success the compact line shows `Committed · — pushed - to main` for about six seconds, then disappears. Reopen the menu and confirm - `Commit & Push` is gone. Verify on the server that the commit landed on - `main` (no feature branch was created). -- [ ] Trigger `Push` on a clean-but-ahead checkout: the success line appears, - then `Push` hides after the status refresh. -- [ ] Repo with a pre-commit hook: the compact line shows `Running …` - while it runs; a failing hook leaves its message visible in red. -- [ ] Navigate to the inbox mid-action and back: the action completes (it is - not interrupted) and the refreshed status reflects the result. -- [ ] Disconnect (stop the server or drop the network) mid-action: the compact - line shows `Disconnected before confirmation`, the pending state clears, and - the server-side push still completes; reopening the thread shows clean status. - -## Status refresh and errors - -- [ ] The status refreshes when a turn finishes with a ready checkpoint and - after every git action; reopening a thread within 10 s reuses the last fetch. -- [ ] Point a project at a deleted folder: the compact error line shows the - fetch error with a `Retry` action that re-requests the status. - -## Activity and changed files - -- [ ] Confirm routine latest-activity summaries no longer produce a card or - tile; messages and the existing Working row remain unchanged. -- [ ] Trigger a session/activity failure and confirm only a compact red inline - error appears in the timeline. -- [ ] Confirm a ready checkpoint with no changed files produces no UI. -- [ ] Confirm a checkpoint with changes produces one collapsed - `N changed file(s) · +A −D` row. Expand it, verify the diff loads lazily, then - exercise collapse, copy, retry, truncated-preview, and full-client actions. -- [ ] Switch threads or receive a newer checkpoint and confirm the expanded - diff state resets. - -## Automated and runtime checks - -- [ ] Run `tests/run` (Node tests plus qmllint over the whole shell). -- [ ] Deploy through the Quickshell Ansible tag, wrapping the entire live check - with `qs_live_begin` / `qs_live_end`. Inspect that service invocation's - journal for QML load failures, syntax errors, and binding loops; open the - drawer with `cybexos-runtime ipc popouts toggle t3code`. diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 00000000..1118fa07 --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,82 @@ +# Independent updates + +The machine owns `/etc/nixos/flake.nix` and `flake.lock`: + +```nix +inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; +inputs.cybexos.url = "github:DigitalPals/CybexOS/EXACT_AUTHENTICATED_REVISION"; +inputs.cybexos.inputs.nixpkgs.follows = "nixpkgs"; +``` + +Add personal inputs explicitly and make their Nixpkgs follow the same root. +The updater rejects duplicate Nixpkgs universes and unrelated lock changes. +Upstream binaries use `cache.nixos.org`; no extra cache key is implicitly trusted. +The 26.05 package set needs a narrowly scoped Hyprland 0.56.2/hyprutils 0.14 +compatibility overlay for the existing Lua desktop and native titlebars, plus a +scoped build-time Wayland protocol pin and the required Aquamarine source fix. +It uses Nixpkgs recipes and the single machine package universe. `nix/desktop-compatibility.nix` +records exact source identities; remove it after stable catches up and the +runtime qualifies. Other upstream inputs continue to advance independently. + +`cybex update check` checks upstream and CybexOS independently. An unreachable +source is unknown/unavailable, never reported current. Nixpkgs checks identify +available revisions; preview builds expose the actual package closure changes. +The Update drawer has separate Upstream, CybexOS and Both preview actions, +installed/available versions, release notes, durable progress, errors, built +changes, restart requirements and recovery status. + +An upstream discovery failure keeps `upstream.state` at `unreachable` and +explains that the Nixpkgs package-source status is unknown. A confirmed GitHub +API rate-limit response or HTTP 429 suggests retrying later; HTTP 403 without +rate-limit evidence is reported as access denied. Recognized server, DNS, TLS, +connection and timeout errors have separate guidance. Other Nix failures leave +the cause unknown and suggest checking network access and Nix configuration. +Malformed metadata also leaves the status unknown. CybexOS discovery still runs +after an upstream failure, so each source keeps its own result. + +Discovery creates no deployment or transaction log. Its public error includes +this read-only diagnostic command, which can be run locally without elevation: + +```sh +nix flake metadata --refresh --json github:NixOS/nixpkgs/nixos-26.05 +``` + +Checks classify command diagnostics internally and publish only static guidance; +they do not log the raw discovery output. The command above may show private +URLs or configuration details: inspect its output locally and redact secrets +before sharing it. Checks do not retry automatically or require an account or +authentication token. + +The separate **Device firmware…** action opens a terminal, refreshes fwupd's +signed metadata, shows the selected devices and versions, and asks before +installation. The launcher also provides **Review device firmware…**; the CLI +is `cybexos-firmware-update interactive`. fwupd supplies authentication, +signature checks and device progress. Keep external power connected and follow +device-specific instructions. Firmware changes are outside the Nix transaction +and are not reverted by a generation rollback. Reboots remain explicit. + +`cybex update stage upstream|cybexos|both|local` authenticates selected release +metadata before evaluation, snapshots the entire personal configuration and +updates only the chosen inputs. CybexOS-only updates include its source-declared +vendor dependency graph while preserving root upstream/personal inputs. They +never select code from a moving branch. Failed combined previews offer the +explicit `stage upstream` retry retaining installed CybexOS; that combination +must build and pass candidate checks too. + +`cybex update status` returns the built diff, transaction and review token. +`cybex update apply ID TOKEN` runs under a supervised systemd worker and rejects +stale review tokens or intervening personal edits. No widget/module is removed +to make evaluation or runtime checks pass. Actual build progress is phase-based; +private transaction logs hold build diagnostics. After a deliberate reboot and +authenticated desktop login, `cybex update confirm` validates system services, +Hyprland, exactly one managed Quickshell and its current journal. + +A failed deployment is quarantined by complete system identity. Inspect the +cause before using `cybex update retry-failed ID` to explicitly allow another +preview of it. Retry permission covers prior failures of that exact system and +is consumed only after a successful preview. Recovery is separate from authenticated normal updates; a channel +cannot use a lower semantic version as a normal update. + +Advancing 26.05 is routine. Changing the root stable branch is a deliberate major +upgrade and currently requires new compatibility qualification. Never change +`system.stateVersion` as part of a routine update. diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 00000000..6ba0271e --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,107 @@ +# Validation status and release blockers + +The NixOS implementation is unreleased and incomplete. There is no qualified +production release, authenticated public NixOS channel or completed physical +hardware/security qualification. This record describes development work on +2026-10-22; results on earlier source snapshots do not qualify later edits. + +## Evidence boundaries + +| Area | Actually exercised | Remaining work | +| --- | --- | --- | +| Source lifecycle and trust | Python command-boundary fixtures; real isolated Ed25519 signing/verification; input ownership, failed evaluation/build and durable recovery cases | Real signed-delivery/updater matrix on installed machines | +| Desktop source | JavaScript source suites, QML lint, ownership/theme/package tests and C++ notification-image tests passed on development snapshots | Repeat required checks on the final source identity | +| Public plugins | Actual Quickshell engine under isolated headless Sway: API 1 replacement, N/N+1/rollback and all six supported Omarchy kinds | Installed Hyprland session, real player/audio/input and third-party dependency coverage | +| System boot | Real isolated NixOS VM boot; installed password/sudo and SDDM login; Hyprland/titlebars and Quickshell rendering; sole managed shell/current journal health; API 1 personal widget using a machine-added executable; candidate namespace/compositor/QML validation | Repeat on the final source identity; suspend, peripherals and physical authentication | +| Fresh ISO installation | Complete unencrypted and encrypted UEFI ISO installations, LUKS unlock, password login and encryption-gated automatic login in disposable QEMU on development snapshots; exact installed source identity checked | Both cases must pass on the final source identity; actual testing corrected filesystem-type, public mount-ancestor permission and Quickshell IPC path bugs | +| Updates and recovery | Actual local stage/build/apply/trial boot/confirmation; injected activation failure and coherent rollback; real initrd root failure and fallback to the previous EFI default; personal hashes preserved; managed-shell crash safe mode and explicit recovery, on development snapshots | Final same-source runs including upstream-only, CybexOS-only, combined, unavailable/malicious delivery, failed evaluation/build, interrupted staging/application and explicit rollback | +| Hardware | Pinned Intel camera HAL/GStreamer and PSYS packages built; private-ABI parser self-test passed | Actual kernel/private ABI and processed camera frames, both XPS SKUs, NVIDIA, fingerprint/FIDO2, Secure Boot and peripheral/power matrix | +| Release delivery | Publisher-side signature, asset, qualification, sequence and downgrade regression checks | Protected workflow execution, owner keys, immutable publication, public asset verification, Pages promotion and durable custom cache | + +Fixtures simulate command boundaries. They are regression coverage and cannot +establish a successful installation, an applied update or recovery from a real +failed boot. A build proves that a derivation builds; it does not prove that the +resulting compositor, camera, authentication or desktop works. + +Development diagnostics are not substitutes for the final qualification report. +The handoff records the exact source NAR identity, image checksum, completed +tests and failures; inspect that evidence before using a particular image. + +## Implementation and operational blockers + +- Signed installation media, safe Secure Boot enrollment and out-of-tree module + signing remain unfinished. This includes enforcing kernel module trust and + authenticating the external installation payload; signing a boot executable + alone is insufficient. Secure Boot installation refuses before erasure; + disabling firmware verification is not an accepted workaround. +- The desktop requires Hyprland 0.56.2 and native titlebars. The stable Nixpkgs + compositor is older; the narrow compatibility overlay and its dependent native + libraries and titlebars build. Real native configuration verification accepts + the product configuration and rejects an invalid declaration. It also exposed + an upstream verifier/event-loop interaction, now handled without scheduling + runtime timers during parsing. Installed startup/rendering and runtime health + passed on a development snapshot; repeat against the final source identity. +- Mandatory access control coverage remains incomplete. AppArmor enablement + and the exact Chromium namespace exception do not reproduce the prior + enforcing policy. Qualify enforced native service/application confinement + and negative access tests, alongside packet-level firewall tests. +- Production publisher trust is deliberately empty. An owner must provision + real public keys, publish an authenticated exact revision and configure + protected GitHub environments and immutable Releases. Do not substitute a + development key or a moving branch. +- T3 Code's native wrapped AppImage builds but remains runtime-unqualified. + Cisco IKEv1/IPsec uses the maintained Nixpkgs Libreswan plugin rather than the + archived, vulnerable vpnc plugin. Real gateway authentication/routing/DNS and + all declared product/hardware features still require qualification. +- Type 1 systemd-boot entries and Type 2 UKI/Lanzaboote entries have native boot + selection support with source regression coverage. Actual UKI trial boots, + signing, failed-boot recovery and rollback remain unqualified. +- No application-data migration engine or automatic transaction-root pruning + ships. Persistent state requires backups; retained Nix generations do not + roll back documents, VPN credentials, plugin data or service databases. +- No durable public CybexOS binary cache is configured. Public cache hosting, + explicit cache trust and redistribution review remain release work. +- PXE delivery uses the Beast (`10.10.0.7`), independently of the development + host. A copied ISO is not advertised as PXE-ready until checksum verification, + refresh, running service and image-list checks succeed there. + +1Password CLI/Polkit integration, connected-account services, the Hermes bridge, encrypted-backup timer, +external-monitor lid handling, Voxtype and the required offline dictation model +have native declarations. Their installed interactions still require testing. + +The native Linux ChatGPT derivation builds with strict ELF dependency checks; +its version command and bundled Codex/Node commands ran in the isolated build +container. This does not qualify interactive sign-in, Chromium sandbox startup, +remote control or application use on NixOS. The official upstream distributions +do not include NixOS in their formal preview support list. + +The desktop package also checks its actual wrapped Python and GI dependencies +under an empty environment: network/account/calendar/firmware libraries, schemas, +GIO TLS, Pillow, D-Bus and WebSockets. This caught and corrected plain-Python +shadowing and missing typelibs before installation qualification. + +## Qualification procedure + +Run `nix develop -c tests/run`, `nix develop -c tests/qml-lint` and +`nix develop -c python3 -B tests/user-plugins.py` in an isolated environment, +then evaluate/build the relevant flake checks and ISO. Run desktop engines +sequentially and obey [the Quickshell safeguards](../AGENTS.md). + +`scripts/qualify-installations` boots the built ISO and exercises the actual +fresh installer, installed boot/login and local staged updater in disposable +UEFI VMs. Its evidence is intentionally labelled +`isolated-installation-evidence`; it cannot satisfy complete release +qualification. Exercise both `--case plain` and `--case encrypted`, retain source +revision/NAR and image hash, and report a failure as a failure. + +Extend real installed-system testing through all independent update scopes, +unavailable and malicious delivery, failed evaluation/build, interrupted +application, trial boot, explicit confirmation and coherent rollback. Keep +sentinels for personal packages, VPNs, services, widget code/placement/preferences, +plugin state and service data, and check them after updates and rollback. +Hardware/security checks require representative physical machines. + +Only a complete same-revision `installation-update-and-physical-qualification` +report containing every check required by `release/manage.py` passes the release +gate. See [release operations](releasing.md) for trust and publication. Do not +promote the development evidence in this document to a production-readiness claim. diff --git a/docs/xps-2026-hardware.md b/docs/xps-2026-hardware.md deleted file mode 100644 index 46f1a7f5..00000000 --- a/docs/xps-2026-hardware.md +++ /dev/null @@ -1,346 +0,0 @@ -# Dell XPS 2026 / Panther Lake hardware support - -The `xps-2026` role is intentionally narrow. It runs only when DMI reports -Dell, XPS 14/16 or DA14260/DA16260, SKU `0DB9` or `0DBA`, and a Panther Lake -Core Ultra CPU. Camera and touchpad work have additional ACPI, PCI, and I2C -gates. It does not apply these settings to other Dell laptops. - -Fedora's stock kernel remains the only kernel. This role does not build or -install a kernel, `linux-ptl`, an i915/xe/DRM patch, `fred=on`, or a global -Wi-Fi 7/EHT workaround. - -## Internal OLED backlight - -The LG 2880x1800 OLED panel with EDID product `30e4` accepts brightness writes -through the Xe driver's default interface without changing its luminance; only -the separate zero/off operation has an effect. The same XPS chassis can contain -an IPS panel, so the role reads the internal eDP EDID and limits the workaround -to that exact OLED product. - -For the affected panel, every installed Fedora boot entry receives -`xe.enable_dpcd_backlight=1` through `grubby`. This makes Xe probe the standard -VESA DPCD path, which the panel implements correctly. The kernel log's generic -suggestion of `i915.enable_dpcd_backlight=3` is not used: Panther Lake runs the -`xe` module here, and forcing the Intel HDR path leaves this panel frozen. -`grubby --update-kernel=ALL` also updates `/etc/default/grub` and -`/etc/kernel/cmdline`, so later Fedora kernel entries inherit the argument. - -The change takes effect only when Xe next loads. After applying the role, -reboot normally and verify: - -```bash -grep -o 'xe.enable_dpcd_backlight=[^ ]*' /proc/cmdline -sudo cat /sys/module/xe/parameters/enable_dpcd_backlight -brightnessctl set 40% -``` - -The first two commands should report `xe.enable_dpcd_backlight=1` and `1`; the -last should visibly change panel luminance. - -## Internal-panel black flashes - -On the XPS 14 DA14260 (SKU `0DB9`) with LGD product `0x07c6`, Fedora kernel -`7.2.7-200.fc44` exhibited repeated full-screen black flashes at 120 Hz. -The panel reported a PSR link CRC error, PSR2 selective fetch was enabled, -and the boot journal recorded `Selective fetch area calculation failed in -pipe A`. Disabling PSR live stopped the flashes without changing brightness, -resolution or refresh rate. This is separate from an absent Quickshell -wallpaper when the desktop session target has not started. - -The shared hardware role limits `xe.enable_psr=0` to this SKU and the full -EDID manufacturer/product bytes `30e4c607`. Both checkout provisioning and -ISO hardware setup use this task. `grubby --update-kernel=ALL` applies it to -installed kernels and the defaults for future kernels. Disabling PSR costs -some idle display power; it retains 120 Hz. The `display` tag applies only -this quirk, after the role's read-only hardware detection: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags display --check --diff -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags display -``` - -The boot argument takes effect on the next normal boot. On a running system, -`/sys/kernel/debug/dri/0/i915_edp_psr_status` reports the active PSR mode -(the debugfs name is retained by the Xe driver). During diagnosis, writing -`1` to `i915_edp_psr_debug` disables PSR for the current boot; `0` restores -the driver's default. Verify the DRM device before using either path. - -Keep `xps_2026_psr_disabled_panels: []` in the saved Ansible configuration to -opt out of further enforcement when evaluating a driver fix, then remove -`xe.enable_psr` with `grubby --update-kernel=ALL --remove-args=xe.enable_psr`. -No other panel or machine receives the workaround. - -## Internal speakers - -SKU `0DB9` (plus Quattro-listed XPS 16 SKU `0DBA`) gets Omarchy Quattro's current -13-biquad-per-channel profile and stereo lookahead limiter. A separate user -PipeWire process hosts the graph, so a broken optional profile cannot prevent -the normal PipeWire daemon from starting. Its output is pinned to the exact -`sof_sdw` internal-speaker sink. Headphones, Bluetooth, HDMI/DP, USB, and -network sinks bypass it. At login the manager waits for four consecutive -observations of the physical speaker, avoiding WirePlumber's initial device -enumeration churn without masking a genuinely absent sink. - -The XPS 14 profile was measured on `0DB9`; `0DBA` is included on the same basis -as Quattro and has not been independently measured here. Fedora provides the -filter-chain LV2 loader and `lsp-plugins-lv2` limiter. Desktop volume keys and -the Quickshell audio UI control the physical speaker gain while the virtual -filter master stays at unity. - -Useful checks: - -```bash -systemctl --user status xps-speaker-tuning -/usr/local/libexec/xps-speaker-tuning check -pactl list sinks short -journalctl --user -u xps-speaker-tuning -b -``` - -## Optional fingerprint reader - -Fingerprint support itself is no longer XPS-specific: every installation gets -Fedora's `fprintd`/`fprintd-pam` and `authselect`'s `with-fingerprint`, and -Settings > Security manages enrolment (see [Fingerprints and security -keys](security.md)). Without a reader or an enrolled finger, every prompt asks -for the password exactly as before. - -What remains in this role is the hardware gate. The detector trusts explicit -fingerprint/biometric product descriptors and uses a conservative, unbound-USB -vendor fallback. It explicitly rejects Synaptics `06cb:0701` (SVP7500), which -is the IPU7 camera's USBIO bridge on the tested XPS 14 rather than a fingerprint -reader. The tested SKU `0DB9` exposes that bridge but no fingerprint device to -current `libfprint`, so Settings > Security reports that no reader was found. - -When a precisely matched XPS build has a possible reader, the role installs the -`xps-fingerprint-check` diagnostic and asks fprintd over D-Bus whether the -current libfprint release supports the device; if not, it reports that -password authentication is unchanged. It never edits PAM files and enrols -nothing. Existing installations keep their `with-fingerprint` state; the -shared policy only adds the feature where it was absent, once. - -Enrol in Settings > Security, or interactively: - -```bash -fprintd-enroll -fprintd-list "$USER" -fprintd-verify -authselect current --raw -/usr/local/libexec/xps-fingerprint-check "$USER" -``` - -SDDM login stays password-only, so the login keyring is unlocked as before. -The lock screen uses hyprlock's own fprintd check beside a fingerprint-free PAM -service, and sudo and Polkit use Fedora's `system-auth` with password fallback. -Fedora has no stock `authselect` feature for inserting a lid-state command into -PAM, so the role does not copy Omarchy's custom clamshell helper; with the lid -closed, the reader times out and the password is asked. - -## OVTI08F4 / IPU7 camera - -The camera role additionally requires the `OVTI08F4` ACPI device and Intel PCI -ID `8086:b05d`. Fedora 44 supplies the stock IPU7 base/ISYS modules, IPU bridge, -OV08X40 sensor driver, and `intel/ipu/ipu7ptl_fw.bin`. Those remain owned by the -normal Fedora kernel and `intel-vsc-firmware` packages. Verification rejects an -out-of-tree replacement for either `intel_ipu7` or `intel_ipu7_isys`. - -Missing hardware companions use DKMS: - -- Intel IPU7 PSYS, compiled in PSYS-only mode against Fedora's stock core ABI. -- Intel CVS only on kernels which do not supply it. Fedora's native CVS is - retained when present; its runtime PM and media bridge manage camera - ownership. The legacy CVS DKMS build refuses to shadow a native module. - -RPM Fusion's `akmod-v4l2loopback` supplies the third out-of-tree module. Intel's -HAL, redistributable IPU75XA libraries, and `icamerasrc` are built into the -local `xps-ipu7-camera-stack` RPM. `v4l2-relayd` publishes their processed NV12 -stream as `/dev/video50`; WirePlumber hides raw Bayer ISYS nodes and the -duplicate generic libcamera endpoint, then exposes only **Hardware ISP -Camera** to PipeWire. Brave is launched with `PipeWireCamera` enabled. - -The source inputs are immutable archives with SHA-256 checksums in -[`roles/xps-2026/defaults/main.yml`](../roles/xps-2026/defaults/main.yml): - -| Component | Pinned commit | Why it is needed | -| --- | --- | --- | -| [intel/ipu7-drivers](https://github.com/intel/ipu7-drivers) | `a88b19096a738d0708742a78d6540d6d4a3021ff` | PSYS companion absent from Fedora's stock module set | -| [intel/vision-drivers](https://github.com/intel/vision-drivers) | `a8d772f261bc90376944956b7bfd49b325ffa2f2` | Panther Lake CVS ownership driver | -| [intel/ipu7-camera-bins](https://github.com/intel/ipu7-camera-bins) | `403c67db6b279dd02752f11db6a34552f31a3ac5` | Intel's redistributable IPU75XA userspace interface | -| [intel/ipu7-camera-hal](https://github.com/intel/ipu7-camera-hal) | `b1f6ebef12111fb5da0133b144d69dd9b001836c` | Hardware ISP HAL and sensor configuration | -| [intel/icamerasrc](https://github.com/intel/icamerasrc) | `4fb31db76b618aae72184c59314b839dedb42689` | GStreamer source consumed by `v4l2-relayd` | - -The PSYS patch is deliberately limited to the companion module: it removes an -out-of-tree debugfs structure member that is not present in Linux 7.1's stock -IPU7 core and prevents DKMS from compiling or installing base/ISYS. Before each -DKMS build and camera start, `xps-ipu7-abi-check` reads Fedora's in-tree module -BTF with `pahole` and compares the private `ipu7_device`, `ipu7_bus_device`, and -`ipu7_psys_pdata` layouts that PSYS dereferences. This structural guard is not -tied to an exact kernel version. If Fedora changes that internal ABI, it emits -`IPU7_STOCK_ABI_CHANGED` and refuses the optional camera build/start while -leaving the stock modules and normal kernel update path untouched. -The guard also covers callback, shared queue, and firmware boot structures. -The reviewed Fedora 7.2 layout accounts for the public `auxiliary_device` -growing by eight bytes: the PSYS build uses the target kernel headers and the -private structure offsets are checked as a complete signature. Unrecognized -layouts still fail closed. It refuses PSYS DKMS if Fedora starts shipping -PSYS itself. Native CVS does not require replacement or an extra reboot when -installing PSYS for the first time. - -With native CVS the graph is `OV08X40 -> Intel CVS -> IPU7 CSI2`. A narrow HAL -patch resolves the sensor's actual I2C address across this bridge and programs -its sink/source formats and links. Older direct sensor-to-CSI configurations -remain supported. Merely registering the sensor does not prove frame capture; -validate processed frames after installation. - -The complete build-input hash is embedded in the RPM release, its installed -manifest, and both DKMS package versions. Consequently, changing a source pin, -patch, ABI guard, spec, or build recipe while retaining the human bundle -version still creates fresh PSYS/CVS registrations and forces real module -builds. Before replacing a healthy stack, the builder retains one checksum- -verified copy of the exact installed RPM. Immediately before any DNF operation -that would change NEVRA, the role re-verifies that snapshot against the healthy -installed identity. This is a hard precondition: an interrupted rerun with a new -cached RPM and an old installed RPM may use the already durable snapshot, but it -cannot proceed when that snapshot is absent or mismatched. - -The role then writes and syncs `/var/lib/xps-hardware/ipu7/transaction.in-progress` -before removing the relay trigger and stopping/disabling camera exposure. Both -the init unit and relay refuse to run while that marker exists. A successful new -stack is validated against its exact manifest before the committed identity is -written and synced; only then is the in-progress marker removed. If older IPU -modules are already loaded, the target remains uncommitted, hidden, and disabled -through one normal reboot. Rerunning the camera tag after that reboot proves the -new on-disk modules are now the boot boundary, then commits and exposes them. A -failed userspace, akmods, or DKMS refresh reinstalls the snapshot, force-builds -both prior DKMS modules, and performs the same exact validation before clearing -the marker. The init helper also compares the installed identity with -`committed-identity`, so interruption, marker corruption, or an unprovable -rollback leaves the relay hidden and camera service stopped rather than exposing -a mixed stack. - -On a later Ansible invocation, the role recovers the prior identity, target -identity, target NEVRA, and exact prior NEVRA from the checksummed marker and -rollback snapshot; it does not infer them from whether systemd remains enabled. -The builder refuses changed input hashes while that transaction is unresolved -and never rotates its recorded rollback RPM. This permits an exact rollback -after interruption at the exposure, DNF, or DKMS boundary without letting a -missing snapshot reopen package installation. Historic target NEVRAs are -validated against the role-owned package/release shape and their recorded -manifest prefix, not the repository's current bundle version, so a legitimate -bundle bump cannot make an interrupted prior transaction unrecoverable. - -The local RPM is also validated before packaging. Every staged `libcamhal`, HAL -plugin, `libgsticamerasrc`, private icamerasrc interface, and included IPU75XA -binary library is inspected with `readelf` and `ldd`. Missing dependencies fail -the build, and private dependencies must resolve inside the new stage rather -than through an older installed camera RPM. The validator also requires the -OV08X40 AIQB, graph binary, sensor JSON, and common HAL configuration under -`/etc/camera/ipu75xa`. Runtime checks repeat the closure validation. Camera -source, ABI, dependency, or build failures remain non-fatal to the rest of -Ansible and are recorded in `/var/lib/xps-hardware/ipu7/build-failed.log` or -`/var/lib/xps-hardware/ipu7/abi-failed.log` or -`/var/lib/xps-hardware/ipu7/dkms-failed.log`. The ABI preflight runs before the -role prunes or upgrades any previously installed camera DKMS bundle. - -### Secure Boot and kernel updates - -DKMS and RPM Fusion akmods share `/etc/pki/akmods`. On a machine with an -NVIDIA GPU, the [NVIDIA role](nvidia.md) queues this key's enrollment for you; -one enrollment covers both. Otherwise, with Secure Boot enabled, enroll that -public key once: - -```bash -sudo mokutil --import /etc/pki/akmods/certs/public_key.der -``` - -Choose a temporary password, reboot, and use the firmware MOK manager to enroll -the key. Then rerun the role from the active release: - -```bash -ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags xps-2026 -``` - -The role does not disable Secure Boot. A DKMS update made while old camera -modules are loaded is not force-unloaded: the camera and relay stay disabled -behind the transaction marker. Reboot normally, then rerun the same command; -that post-reboot pass validates, commits, and starts the new signed modules. - -`kernel-devel-matched` keeps builds tied to Fedora's installed kernel. Diagnose -the complete path with: - -```bash -dkms status -modinfo -n intel_ipu7 intel_ipu7_isys intel_ipu7_psys intel_cvs -systemctl status xps-ipu7-camera xps-ipu7-camera-init v4l2-relayd@ipu7 -journalctl -k -b | grep -Ei 'ipu7|intel_cvs|ov08x40' -/usr/local/libexec/xps-ipu7-abi-check "$(uname -r)" -/usr/local/libexec/xps-ipu7-userspace-check -/usr/local/libexec/xps-ipu7-camera-check -``` - -The normal check does not turn on the camera. To request a short three-frame -smoke test explicitly: - -```bash -XPS_CAMERA_FRAME_TEST=1 /usr/local/libexec/xps-ipu7-camera-check -``` - -The frame check skips the relay's initial splash buffers and verifies three -complete, changing images in memory; it saves no images. An identical/covered -scene can make this proof inconclusive. The relay disables its producer's -last-sample retention so GStreamer mmap consumers copy and immediately requeue -loopback buffers across the splash-to-camera transition. - -The Fedora package's unused generic `icamerasrc` generator trigger is removed -on this hardware; the role's `ipu7` relay is the only camera relay instance. -The suspend hook stops only the userspace relay before sleep and restarts it -through a managed, restartable oneshot service after resume. A pending resume -job is cancelled before another sleep transition, so rapid suspend cycles and -suspend-then-hibernate cannot collide on a transient unit name. The hook avoids -fragile module unload/reprobe operations. - -## Haptic touchpad - -The existing Fedora controller power rules are retained. The hardware-specific -daemon now pairs the matching 06CB input and hidraw devices, applies both the -surface/physical-button feature and a configurable intensity, retries across -device disappearance/re-enumeration, and reapplies after physical clicks. It -rereads its configuration on SIGHUP (`systemctl reload xps-haptic-touchpad`) -or a restart; a converge that changes the configuration restarts it. It reads -HID features back when the transport supports that operation; this XPS -firmware may expose them as write-only. - -Set `xps_2026_haptic_intensity` to `low`, `mid`, or `high` in inventory. These -map to the controller's values 10, 50, and 100. Check it with: - -```bash -systemctl status xps-haptic-touchpad -sudo /usr/local/libexec/xps-haptic-touchpad --check -journalctl -u xps-haptic-touchpad -b -``` - -## Media, firmware, and wireless regulation - -The role explicitly installs SOF/audio, Intel GPU/VSC/Wi-Fi firmware, the Intel -media driver, oneVPL's `libvpl` dispatcher and `intel-vpl-gpu-rt`, and -`wireless-regdb`. It does not add `libvpl-devel` solely for runtime support; -source-build dependencies for the hardware-gated camera RPM are installed only -when that camera is detected. - -Fedora's `setregdomain` (resolved through `PATH`, currently packaged under -`/usr/bin`) derives the regulatory country from the configured -`Europe/Amsterdam` timezone; no Arch-style override is installed. -`./verify` requires the effective global and self-managed phy domains to be -`NL`. Wi-Fi 7/EHT remains enabled. - -```bash -command -v setregdomain -iw reg get -``` - -## Intentional stock-kernel gap - -Omarchy Quattro's Panther Lake kernel and its three display patches are not -ported. Until equivalent code reaches Fedora's normal kernel, Panel Replay and -VRR behavior may lag Quattro. Closing that display-only gap would require the -custom kernel work explicitly excluded by this repository; speaker, camera, -touchpad, media firmware, and regulatory support do not require it. diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..e7f6e1e3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,175 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1754269165, + "narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=", + "owner": "ipetkov", + "repo": "crane", + "rev": "444e81206df3f7d92780680e45858e31d2f07a08", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "lanzaboote", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1754091436, + "narHash": "sha256-XKqDMN1/Qj1DKivQvscI4vmHfDfvYR2pfuFOJiCeewM=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "67df8c627c2c39c41dbec76a1f201929929ab0bd", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "lanzaboote", + "pre-commit-hooks-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "lanzaboote": { + "inputs": { + "crane": "crane", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "nixpkgs": [ + "nixpkgs" + ], + "pre-commit-hooks-nix": "pre-commit-hooks-nix", + "rust-overlay": "rust-overlay" + }, + "locked": { + "lastModified": 1762205063, + "narHash": "sha256-If6vQ+KvtKs3ARBO9G3l+4wFSCYtRBrwX1z+I+B61wQ=", + "owner": "nix-community", + "repo": "lanzaboote", + "rev": "88b8a563ff5704f4e8d8e5118fb911fa2110ca05", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "v0.4.3", + "repo": "lanzaboote", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1792615934, + "narHash": "sha256-MlvRfa1O5Cqc2/a+gG+l9TSuowEoCKCmrJ1d9oAVm0I=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "da6dc0e7b26060f2f71ce3ef7088fb56ee44fa36", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks-nix": { + "inputs": { + "flake-compat": [ + "lanzaboote", + "flake-compat" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "lanzaboote", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1750779888, + "narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "lanzaboote": "lanzaboote", + "nixpkgs": "nixpkgs" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "lanzaboote", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1761791894, + "narHash": "sha256-myRIDh+PxaREz+z9LzbqBJF+SnTFJwkthKDX9zMyddY=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "59c45eb69d9222a4362673141e00ff77842cd219", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..51759f6e --- /dev/null +++ b/flake.nix @@ -0,0 +1,135 @@ +{ + description = "CybexOS: a personal, independently updatable NixOS desktop"; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + lanzaboote = { + url = "github:nix-community/lanzaboote/v0.4.3"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + outputs = inputs@{ self, nixpkgs, lanzaboote, ... }: + let + systems = [ "x86_64-linux" ]; + each = nixpkgs.lib.genAttrs systems; + # Carry the tested input sources in the live store. A fresh machine starts + # from this lock, without resolving the moving stable branch during install. + inputNodes = builtins.genericClosure { + startSet = map (value: { key = value.outPath; inherit value; }) + (builtins.attrValues (builtins.removeAttrs inputs [ "self" ])); + operator = node: map (value: { key = value.outPath; inherit value; }) + (builtins.attrValues (node.value.inputs or {})); + }; + inputSources = map (node: node.key) inputNodes; + inputNarHashes = map (node: node.value.narHash) inputNodes; + compatibility = import ./nix/desktop-compatibility.nix; + module = { options, lib, ... }: { + imports = [ ./nix/modules lanzaboote.nixosModules.lanzaboote ]; + # NixOS VM tests can supply an already-configured, read-only pkgs. + # Normal installations must receive the same exported compatibility overlay. + nixpkgs.overlays = lib.mkIf (options.nixpkgs.overlays.type.name != "unique") [ compatibility ]; + cybexos.release = { + version = nixpkgs.lib.mkDefault (nixpkgs.lib.trim (builtins.readFile ./VERSION)); + revision = nixpkgs.lib.mkDefault (self.rev or "uncommitted"); + narHash = nixpkgs.lib.mkDefault (self.narHash or "unavailable"); + }; + }; + in { + overlays.default = compatibility; + nixosModules = { default = module; cybexos = module; installer = ./nix/modules/installer.nix; }; + packages = each (system: let pkgs = import nixpkgs { inherit system; overlays = [ compatibility ]; config.allowUnfree = true; }; in rec { + desktop = pkgs.callPackage ./nix/packages/desktop.nix { source = self; }; + updater = pkgs.callPackage ./nix/packages/updater.nix { source = self; }; + xps-camera = (pkgs.callPackage ./nix/packages/ipu7.nix { kernel = pkgs.linuxPackages_latest.kernel; }).sensor; + xps-psys = (pkgs.callPackage ./nix/packages/ipu7.nix { kernel = pkgs.linuxPackages_latest.kernel; }).psys; + t3-code = pkgs.callPackage ./nix/packages/t3-code.nix {}; + titlebars = pkgs.callPackage ./nix/packages/titlebars.nix {}; + iso = self.nixosConfigurations.installer.config.system.build.isoImage; + default = desktop; + }); + nixosConfigurations.installer = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ module ./nix/modules/installer.nix { + system.extraDependencies = inputSources; + environment.etc."cybexos/installer-inputs.json".text = builtins.toJSON + (map builtins.unsafeDiscardStringContext inputSources); + } ]; + }; + checks = each (system: let pkgs = import nixpkgs { inherit system; overlays = [ compatibility ]; }; in { + parity = import ./nix/tests/parity.nix { + inherit nixpkgs pkgs module system inputSources inputNarHashes; + installer = self.nixosConfigurations.installer.config; + }; + lifecycle = pkgs.runCommand "cybexos-lifecycle-tests" { + nativeBuildInputs = [ + (pkgs.python3.withPackages (p: [ p.websockets p.pygobject3 p.pillow p.dbus-python ])) + pkgs.openssl pkgs.git pkgs.dbus pkgs.restic pkgs.quickshell pkgs.systemd + ]; + GI_TYPELIB_PATH = pkgs.lib.makeSearchPath "lib/girepository-1.0" [ pkgs.networkmanager pkgs.glib ]; + CYBEXOS_DBUS_SESSION_CONFIG = "${pkgs.dbus}/share/dbus-1/session.conf"; + } '' + cp -r ${self} source; chmod -R u+w source + # Tests execute the same portable helper scripts as the packages. + # /usr/bin/env is not available inside a normal Nix build sandbox. + patchShebangs source + cd source + export PYTHONDONTWRITEBYTECODE=1 + python3 -m unittest discover -s tests/nixos -v + python3 -B tests/nixos/hermes-bridge.py + python3 -B tests/nixos/hermes-remote-runtime.py + python3 -B tests/nixos/hermes-webui-auth.py + python3 -B tests/nixos/default-agent.py + python3 -B tests/nixos/camera-frame.py + python3 -B tests/system-settings.py + touch $out + ''; + system = (nixpkgs.lib.nixosSystem { + inherit system; + modules = [ module ./nix/tests/machine.nix ]; + }).config.system.build.toplevel; + # Qualify the complete default application selection too. The small + # VM/installer choices must not hide unsupported proprietary packages. + product = (nixpkgs.lib.nixosSystem { + inherit system; + modules = [ module ./nix/tests/machine.nix ({ lib, ... }: { + cybexos.features = { + proprietary = lib.mkForce true; + steam = lib.mkForce true; + docker = lib.mkForce true; + }; + }) ]; + }).config.system.build.toplevel; + nvidia-evaluation = pkgs.writeText "cybexos-nvidia-application-independence" (builtins.unsafeDiscardStringContext ( + (nixpkgs.lib.nixosSystem { + inherit system; + modules = [ module ./nix/tests/machine.nix { + cybexos.hardware.nvidia = true; + } ]; + }).config.system.build.toplevel.drvPath)); + # Build the complete matched hardware profile: generic product builds + # cannot detect camera-module, LPMd or hardware-unit packaging failures. + # This remains a source/build check, never physical qualification. + xps-system = (nixpkgs.lib.nixosSystem { + inherit system; + modules = [ module ./nix/tests/machine.nix { + cybexos.hardware.xps = { enable = true; sku = "0DB9"; camera = true; }; + } ]; + }).config.system.build.toplevel; + vm = import ./nix/tests/lifecycle.nix { inherit pkgs module; }; + }); + devShells = each (system: let pkgs = import nixpkgs { inherit system; overlays = [ compatibility ]; }; in { + default = pkgs.mkShell { + packages = with pkgs; [ openssl nixfmt git nodejs quickshell qt6.qtdeclarative restic systemd + qt6.qttools cmake ninja pkg-config qt6.qtbase sway qemu OVMF dbus util-linux lua5_4 jq procps hyprland xorriso squashfsTools + (python3.withPackages (p: [ p.pexpect p.pillow p.pygobject3 p.dbus-python p.websockets ])) ]; + CYBEXOS_OVMF = "${pkgs.OVMF.fd}/FV"; + GI_TYPELIB_PATH = pkgs.lib.makeSearchPath "lib/girepository-1.0" [ pkgs.networkmanager pkgs.glib ]; + CYBEXOS_DBUS_SESSION_CONFIG = "${pkgs.dbus}/share/dbus-1/session.conf"; + CYBEXOS_SWAY_BINARY = "${pkgs.sway-unwrapped}/bin/sway"; + CYBEXOS_QML_IMPORT_PATH = "${pkgs.qt6.qtdeclarative}/lib/qt-6/qml:${pkgs.qt6.qtmultimedia}/lib/qt-6/qml:${pkgs.qt6.qtwebsockets}/lib/qt-6/qml:${pkgs.qt6.qt5compat}/lib/qt-6/qml:${pkgs.quickshell}/lib/qt-6/qml"; + QT_QPA_PLATFORM = "offscreen"; + QT_QUICK_BACKEND = "software"; + }; + }); + templates.default = { path = ./templates/machine; description = "Machine-owned CybexOS configuration"; }; + }; +} diff --git a/roles/xps-2026/files/camera/71-xps-ipu7-hide-isys.rules b/hardware/xps/camera/71-xps-ipu7-hide-isys.rules similarity index 100% rename from roles/xps-2026/files/camera/71-xps-ipu7-hide-isys.rules rename to hardware/xps/camera/71-xps-ipu7-hide-isys.rules diff --git a/roles/xps-2026/files/camera/90-xps-ipu7-psys.rules b/hardware/xps/camera/90-xps-ipu7-psys.rules similarity index 100% rename from roles/xps-2026/files/camera/90-xps-ipu7-psys.rules rename to hardware/xps/camera/90-xps-ipu7-psys.rules diff --git a/roles/xps-2026/files/camera/patches/0003-vision-ptl-cvs-owns-rgb-power.patch b/hardware/xps/camera/patches/0003-vision-ptl-cvs-owns-rgb-power.patch similarity index 100% rename from roles/xps-2026/files/camera/patches/0003-vision-ptl-cvs-owns-rgb-power.patch rename to hardware/xps/camera/patches/0003-vision-ptl-cvs-owns-rgb-power.patch diff --git a/roles/xps-2026/files/camera/patches/0004-ipu7-psys-register-bus.patch b/hardware/xps/camera/patches/0004-ipu7-psys-register-bus.patch similarity index 100% rename from roles/xps-2026/files/camera/patches/0004-ipu7-psys-register-bus.patch rename to hardware/xps/camera/patches/0004-ipu7-psys-register-bus.patch diff --git a/roles/xps-2026/files/camera/patches/0005-ipu7-psys-stock-core-layout.patch b/hardware/xps/camera/patches/0005-ipu7-psys-stock-core-layout.patch similarity index 67% rename from roles/xps-2026/files/camera/patches/0005-ipu7-psys-stock-core-layout.patch rename to hardware/xps/camera/patches/0005-ipu7-psys-stock-core-layout.patch index 055e820e..1b2f4c00 100644 --- a/roles/xps-2026/files/camera/patches/0005-ipu7-psys-stock-core-layout.patch +++ b/hardware/xps/camera/patches/0005-ipu7-psys-stock-core-layout.patch @@ -11,36 +11,9 @@ PSYS to defer forever. For kernels which already provide base and ISYS, compile only PSYS, match the in-tree structure layout, and give PSYS an independent debugfs root. -Fedora's base and ISYS modules remain unmodified and are not installed by -this DKMS package. +NixOS's base and ISYS modules remain unmodified and are not installed by +this native module package. --- - dkms.conf | 10 ++++++++++ - drivers/media/pci/intel/ipu7/Makefile | 2 ++ - drivers/media/pci/intel/ipu7/ipu7.h | 3 --- - drivers/media/pci/intel/ipu7/psys/ipu-psys.c | 6 ++---- - 4 files changed, 15 insertions(+), 8 deletions(-) - -diff --git a/dkms.conf b/dkms.conf -index e3db2d4..8de8c52 100644 ---- a/dkms.conf -+++ b/dkms.conf -@@ -37,6 +37,16 @@ KERNEL_VERSION=$(echo ${kernelver} | sed 's/[^0-9.]*\([0-9.]*\).*/\1/') - KV_IPU7_ISYS=6.17.0 - -+# The upstream kernel contains base and ISYS from 6.17 onward. Build only -+# their missing PSYS companion so DKMS cannot replace the in-tree modules. -+if ! version_lt ${KERNEL_VERSION} ${KV_IPU7_ISYS}; then -+ if [ -n "$BUILD_INTEL_IPU_ACPI" ]; then -+ MAKE="./xps-ipu7-abi-check $kernelver && make IPU7_PSYS_ONLY=1 BUILD_INTEL_IPU_ACPI=1 all-acpi KERNELRELEASE=$kernelver KERNEL_SRC=$kernel_source_dir" -+ else -+ MAKE="./xps-ipu7-abi-check $kernelver && make IPU7_PSYS_ONLY=1 KERNELRELEASE=$kernelver KERNEL_SRC=$kernel_source_dir" -+ fi -+fi -+ - MODULE_INDEX=0 - - # Default modules: intel-ipu7 family - if version_lt ${KERNEL_VERSION} ${KV_IPU7_ISYS}; then diff --git a/drivers/media/pci/intel/ipu7/Makefile b/drivers/media/pci/intel/ipu7/Makefile index 0750ef9..95a6c32 100644 --- a/drivers/media/pci/intel/ipu7/Makefile diff --git a/roles/xps-2026/files/camera/patches/0006-camera-hal-native-cvs-bridge.patch b/hardware/xps/camera/patches/0006-camera-hal-native-cvs-bridge.patch similarity index 97% rename from roles/xps-2026/files/camera/patches/0006-camera-hal-native-cvs-bridge.patch rename to hardware/xps/camera/patches/0006-camera-hal-native-cvs-bridge.patch index 181be49f..28d7b8dc 100644 --- a/roles/xps-2026/files/camera/patches/0006-camera-hal-native-cvs-bridge.patch +++ b/hardware/xps/camera/patches/0006-camera-hal-native-cvs-bridge.patch @@ -1,7 +1,7 @@ From: CybexOS Subject: [PATCH] camera-hal: follow the native CVS bridge on OV08X40 -Fedora's native CVS exposes sensor -> CVS -> IPU7 CSI2. Resolve the actual +NixOS's native CVS exposes sensor -> CVS -> IPU7 CSI2. Resolve the actual sensor I2C address through that bridge and configure its two pads and links. Retain the direct sensor topology used with the legacy CVS driver. diff --git a/roles/xps-2026/files/camera/xps-ipu7-abi-check b/hardware/xps/camera/xps-ipu7-abi-check similarity index 69% rename from roles/xps-2026/files/camera/xps-ipu7-abi-check rename to hardware/xps/camera/xps-ipu7-abi-check index 96c35db8..88635b15 100755 --- a/roles/xps-2026/files/camera/xps-ipu7-abi-check +++ b/hardware/xps/camera/xps-ipu7-abi-check @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Refuse PSYS use when Fedora's in-tree IPU7 private ABI has changed.""" +"""Refuse PSYS use when the selected NixOS kernel's in-tree IPU7 private ABI has changed.""" from __future__ import annotations @@ -18,7 +18,7 @@ class AbiChanged(RuntimeError): # PSYS dereferences these private structures allocated/owned by the in-tree -# base driver. This signature was recorded from Fedora 44's stock IPU7 BTF; +# base driver. This signature was recorded from the previously reviewed upstream IPU7 BTF; # checking offsets (rather than a kernel version) permits harmless kernel # updates while refusing a layout that this compatibility patch has not seen. EXPECTED = { @@ -92,7 +92,7 @@ EXPECTED = { ), } -# Fedora 7.2 grows the public auxiliary_device by eight bytes. PSYS uses the +# Upstream Linux 7.2 grows the public auxiliary_device by eight bytes. PSYS uses the # matching kernel headers, so its following fields move with that embedded # object. This second complete signature was reviewed against stock 7.2.7 BTF; # continue rejecting any other private layout rather than accepting arbitrary @@ -175,14 +175,12 @@ def unpack_module(source: Path, destination: Path) -> None: def stock_module(kernel: str, name: str) -> Path: - path = Path(command_output(["modinfo", "-k", kernel, "-n", name])) - intree = command_output(["modinfo", "-k", kernel, "-F", "intree", name]) - try: - relative = path.resolve().relative_to(Path(f"/usr/lib/modules/{kernel}/kernel")) - except ValueError as error: - raise AbiChanged(f"{name} is not Fedora's in-tree module: {path}") from error - if not relative.parts or intree != "Y": - raise AbiChanged(f"{name} does not report intree=Y: {path}") + prefix = ["modinfo", "-b", "/run/booted-system/kernel-modules", "-k", kernel] + path = Path(command_output([*prefix, "-n", name])) + if command_output([*prefix, "-F", "intree", name]) != "Y" or "/kernel/" not in str(path): + raise AbiChanged(f"{name} is not the selected in-tree module: {path}") + if not str(path.resolve()).startswith("/nix/store/"): + raise AbiChanged("Kernel module is outside the immutable selected closure") return path @@ -190,73 +188,27 @@ def layout_reviewed(structure: str, actual: tuple) -> bool: return actual == EXPECTED[structure] or actual == REVIEWED_ALTERNATES.get(structure) -def check_kernel(kernel: str, *, external_cvs: bool = False) -> None: +def check_kernel(kernel: str) -> None: + if kernel != command_output(["uname", "-r"]): + raise AbiChanged("Runtime BTF can only validate the booted kernel") if not shutil.which("pahole"): - raise AbiChanged("pahole is missing (install Fedora's dwarves package)") - - kernel_modules = Path(f"/usr/lib/modules/{kernel}/kernel") - for companion in ("intel-ipu7-psys.ko*", "intel_ipu7_psys.ko*"): - native = next(kernel_modules.rglob(companion), None) - if native is not None: - raise AbiChanged( - f"Fedora now provides the companion {native.name} in-tree; " - "refusing to shadow it with this DKMS bundle" - ) - - native_cvs = next(kernel_modules.rglob("intel_cvs.ko*"), None) - if native_cvs is not None: - stock_module(kernel, "intel_cvs") - if external_cvs: - raise AbiChanged("native Intel CVS is present; refusing the external CVS build") - + raise AbiChanged("The native dwarves package is missing") base_module = stock_module(kernel, "intel_ipu7") stock_module(kernel, "intel_ipu7_isys") - kernel_tree = Path(f"/usr/src/kernels/{kernel}") - extractor = kernel_tree / "scripts/extract-vmlinux" - vmlinuz_candidates = ( - Path(f"/usr/lib/modules/{kernel}/vmlinuz"), - Path(f"/boot/vmlinuz-{kernel}"), - ) - vmlinuz = next((path for path in vmlinuz_candidates if path.is_file()), None) - if not extractor.is_file() or vmlinuz is None: - raise AbiChanged(f"kernel-devel/vmlinuz inputs are missing for {kernel}") - - with tempfile.TemporaryDirectory(prefix="xps-ipu7-abi.") as work_text: - work = Path(work_text) - vmlinux = work / "vmlinux" - module = work / "intel-ipu7.ko" - with vmlinux.open("wb") as output: - result = subprocess.run( - [str(extractor), str(vmlinuz)], - check=False, - stdout=output, - stderr=subprocess.PIPE, - ) - if result.returncode or not vmlinux.stat().st_size: - raise AbiChanged( - f"could not extract Fedora vmlinux BTF: {result.stderr.decode().strip()}" - ) + stock_module(kernel, "intel_cvs") + modules = Path(f"/run/booted-system/kernel-modules/lib/modules/{kernel}/kernel") + if any(modules.rglob("*ipu7*psys.ko*")): + raise AbiChanged("Upstream now supplies PSYS; remove the external companion before proceeding") + base = Path("/sys/kernel/btf/vmlinux") + if not base.is_file(): + raise AbiChanged("Booted kernel does not expose BTF; private ABI is unproved") + with tempfile.TemporaryDirectory(prefix="xps-ipu7-abi.") as work: + module = Path(work) / "intel-ipu7.ko" unpack_module(base_module, module) - for structure, expected in EXPECTED.items(): - result = run( - [ - "pahole", - f"--btf_base={vmlinux}", - "-F", - "btf", - "-C", - structure, - str(module), - ] - ) - if result.returncode: - raise AbiChanged(f"stock module lacks BTF for struct {structure}") - actual = parse_layout(result.stdout) - if not layout_reviewed(structure, actual): - raise AbiChanged( - f"struct {structure} layout differs: expected {expected}, got {actual}" - ) + result = run(["pahole", f"--btf_base={base}", "-F", "btf", "-C", structure, str(module)]) + if result.returncode or not layout_reviewed(structure, parse_layout(result.stdout)): + raise AbiChanged(f"Unreviewed private layout: {structure}; expected {expected}") def self_test() -> None: @@ -295,20 +247,17 @@ def main() -> int: self_test() return 0 args = sys.argv[1:] - external_cvs = bool(args and args[0] == "--external-cvs") - if external_cvs: - args = args[1:] if len(args) > 1 or (args and args[0].startswith("--")): - print("usage: xps-ipu7-abi-check [--external-cvs] [kernel]", file=sys.stderr) + print("usage: xps-ipu7-abi-check [kernel]", file=sys.stderr) return 2 kernel = args[0] if args else command_output(["uname", "-r"]) try: - check_kernel(kernel, external_cvs=external_cvs) + check_kernel(kernel) except AbiChanged as error: print( - "IPU7_STOCK_ABI_CHANGED: Fedora's stock IPU7 ABI no longer matches " + "IPU7_STOCK_ABI_CHANGED: the selected NixOS kernel's stock IPU7 ABI no longer matches " f"the reviewed PSYS compatibility layer for {kernel}: {error}. " - "The optional camera stack will not build or start; Fedora's stock " + "The camera stack will not start; the selected NixOS kernel's stock " "intel_ipu7 and intel_ipu7_isys modules remain untouched.", file=sys.stderr, ) diff --git a/roles/xps-2026/files/camera/xps-ipu7-camera-frame-check b/hardware/xps/camera/xps-ipu7-camera-frame-check similarity index 82% rename from roles/xps-2026/files/camera/xps-ipu7-camera-frame-check rename to hardware/xps/camera/xps-ipu7-camera-frame-check index d8b65b75..d0373480 100644 --- a/roles/xps-2026/files/camera/xps-ipu7-camera-frame-check +++ b/hardware/xps/camera/xps-ipu7-camera-frame-check @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Verify fresh relay frames in memory without saving camera images.""" +import argparse import hashlib import os import re @@ -9,9 +10,12 @@ import sys def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--device", required=True, help="Processed relay output, for example /dev/video50") + device = parser.parse_args().device environment = dict(os.environ, LC_ALL="C") details = subprocess.run( - ["v4l2-ctl", "--device=/dev/video50", "--get-fmt-video"], + ["v4l2-ctl", f"--device={device}", "--get-fmt-video"], capture_output=True, text=True, check=True, timeout=5, env=environment, ).stdout match = re.search(r"Size Image\s*:\s*(\d+)", details) @@ -22,7 +26,7 @@ def main(): # warm-up interval and require different complete images, not merely three # successful dequeues. stdout is an anonymous pipe; pixels never reach disk. captured = subprocess.run( - ["v4l2-ctl", "--device=/dev/video50", "--stream-mmap", + ["v4l2-ctl", f"--device={device}", "--stream-mmap", "--stream-skip=30", "--stream-count=3", "--stream-to=-"], capture_output=True, check=True, timeout=15, env=environment, ).stdout diff --git a/hardware/xps/camera/xps-ipu7-camera-init b/hardware/xps/camera/xps-ipu7-camera-init new file mode 100644 index 00000000..bcc274e9 --- /dev/null +++ b/hardware/xps/camera/xps-ipu7-camera-init @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Loaded only by the matched NixOS XPS camera unit. Core/ISYS/CVS modules are +# owned by the selected kernel and must never be unloaded by this initializer. +set -euo pipefail + +die() { echo "xps-ipu7-camera-init: $*" >&2; exit 1; } +[[ $(cat /sys/class/dmi/id/sys_vendor) == 'Dell Inc.' ]] || die 'not a Dell system' +case "$(cat /sys/class/dmi/id/product_sku)" in + 0DB9|0DBA) ;; + *) die 'DMI SKU is not a supported XPS 2026 model' ;; +esac +[[ -e /sys/bus/acpi/devices/OVTI08F4:00 || -e /sys/bus/acpi/devices/OVTI08F4:01 ]] \ + || die 'OVTI08F4 ACPI camera is absent' +matched=false +for device in /sys/bus/pci/devices/*; do + if [[ $(cat "$device/vendor") == 0x8086 && $(cat "$device/device") == 0xb05d ]]; then + matched=true + break + fi +done +"$matched" || die 'Panther Lake IPU7 PCI function (8086:b05d) is absent' +kernel_log=$(journalctl -k -b --no-pager) || die 'cannot inspect this boot for camera kernel faults' +if grep -Eq 'RIP: .*ipu7|BUG:.*ipu7' <<< "$kernel_log"; then + die 'this boot recorded an IPU7 kernel fault; reboot before retrying the camera' +fi +if grep -q '^ov08x40 ' /proc/modules && ! grep -q '^intel_cvs ' /proc/modules; then + modprobe -r ov08x40 || die 'sensor is busy; reboot to apply CVS-first ordering' +fi +modprobe intel_cvs +sleep 2 +modprobe ov08x40 +modprobe intel_ipu7_psys +udevadm settle --timeout=10 +for _ in {1..30}; do + [[ -e /dev/ipu7-psys0 || -e /dev/ipu-psys0 ]] && exit 0 + sleep 0.2 +done +die 'IPU7 PSYS device did not appear' diff --git a/hardware/xps/xps-fingerprint-check b/hardware/xps/xps-fingerprint-check new file mode 100755 index 00000000..77c5aead --- /dev/null +++ b/hardware/xps/xps-fingerprint-check @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +fprintd-list "${1:-$USER}" +grep -q pam_unix /etc/pam.d/sudo +grep -q pam_fprintd /etc/pam.d/sudo diff --git a/roles/xps-2026/files/xps-fingerprint-detect b/hardware/xps/xps-fingerprint-detect similarity index 100% rename from roles/xps-2026/files/xps-fingerprint-detect rename to hardware/xps/xps-fingerprint-detect diff --git a/roles/xps-2026/files/xps-haptic-touchpad b/hardware/xps/xps-haptic-touchpad similarity index 98% rename from roles/xps-2026/files/xps-haptic-touchpad rename to hardware/xps/xps-haptic-touchpad index bf7b4df2..6a67e17d 100644 --- a/roles/xps-2026/files/xps-haptic-touchpad +++ b/hardware/xps/xps-haptic-touchpad @@ -15,7 +15,7 @@ import time VENDOR = "06CB" -CONFIG_PATH = "/etc/xps-haptic-touchpad.conf" +CONFIG_PATH = "/etc/cybexos/xps-haptic.conf" BUTTON_SWITCH_REPORT = 0x06 HAPTIC_INTENSITY_REPORT = 0x37 BUTTON_SWITCHES = 0x03 # Surface switch and physical button switch. @@ -273,7 +273,7 @@ def monitor_pair(event, hidraw, phys, wakeup_fd): ) while not stop_requested: - # Config changes arrive as SIGHUP (Ansible restarts the unit when + # Config changes arrive as SIGHUP (the NixOS module restarts the unit when # it templates a new file), so there is nothing to time out for. events = poller.poll(-1) if reload_requested: diff --git a/roles/xps-2026/files/xps-speaker-filter-chain.conf b/hardware/xps/xps-speaker-filter-chain.conf similarity index 100% rename from roles/xps-2026/files/xps-speaker-filter-chain.conf rename to hardware/xps/xps-speaker-filter-chain.conf diff --git a/roles/xps-2026/files/xps-speaker-host.conf b/hardware/xps/xps-speaker-host.conf similarity index 100% rename from roles/xps-2026/files/xps-speaker-host.conf rename to hardware/xps/xps-speaker-host.conf diff --git a/roles/xps-2026/files/xps-speaker-tuning b/hardware/xps/xps-speaker-tuning similarity index 94% rename from roles/xps-2026/files/xps-speaker-tuning rename to hardware/xps/xps-speaker-tuning index dda2f61c..836dd31a 100644 --- a/roles/xps-2026/files/xps-speaker-tuning +++ b/hardware/xps/xps-speaker-tuning @@ -4,8 +4,8 @@ set -uo pipefail readonly virtual_sink=xps_speaker_tuning readonly output_node=xps_speaker_tuning_output readonly speaker_pattern='^alsa_output.*sof_sdw.*HiFi__Speaker__sink$' -readonly profile=/usr/local/share/xps-2026/audio/filter-chain.conf -readonly host=/usr/local/share/xps-2026/audio/host.conf +readonly profile=/etc/cybexos/xps-audio/filter-chain.conf +readonly host=/etc/cybexos/xps-audio/host.conf readonly speaker_wait_attempts=60 readonly speaker_stable_samples=4 readonly speaker_wait_interval=0.25 @@ -84,9 +84,8 @@ move_apps_to() { } plugin_present() { - [[ -r /usr/lib64/spa-0.2/filter-graph/libspa-filter-graph-plugin-lv2.so ]] && - grep -Rqs 'http://lsp-plug.in/plugins/lv2/limiter_stereo' \ - /usr/lib/lv2 /usr/lib64/lv2 2>/dev/null + [[ -n ${CYBEXOS_LSP_LV2:-} && -d $CYBEXOS_LSP_LV2 ]] && + grep -Rqs 'http://lsp-plug.in/plugins/lv2/limiter_stereo' "$CYBEXOS_LSP_LV2" } prepare_runtime_config() { @@ -210,7 +209,7 @@ case "$action" in } runtime=$(prepare_runtime_config "$speaker") || exit 1 export PIPEWIRE_CONFIG_DIR="$runtime" - exec /usr/bin/pipewire -c xps-speaker-tuning.conf + exec pipewire -c xps-speaker-tuning.conf ;; activate) activate ;; deactivate) deactivate ;; diff --git a/image/AUDIT-2026-09-23.md b/image/AUDIT-2026-09-23.md deleted file mode 100644 index 532e3a93..00000000 --- a/image/AUDIT-2026-09-23.md +++ /dev/null @@ -1,175 +0,0 @@ -# ISO and installer audit — 2026-09-23 - -Audited checkout: `3c585cd`, followed by the local fixes described below. -Host: a Fedora 44 workstation. This is a source/configuration audit with automated -tests, not validation of a newly built or booted ISO. - -The subsequent [implementation record](IMPLEMENTATION-2026-09-23.md) tracks -which recommendations were implemented in the isolated worktree. Findings -below describe the audited baseline; current commands are in [README.md](README.md). - -## Result and verification - -The project has a complete VM-based ISO builder, offline application payload, -Hyprland/Quickshell live session, welcome application, and Anaconda Web UI. -The earlier full-application ISO passed offline UEFI installation and an -installed-disk boot; [VALIDATION.md](VALIDATION.md) identifies that exact -September 6 image. Those results do not qualify current source. - -Checks performed in this audit: - -- `./tests/run`: passed, including 895 JavaScript tests and repository source, - integration, helper, and policy checks. The real-engine Quickshell lifecycle - smoke test was explicitly skipped because a managed desktop was active. -- `image/tests`: all 11 existing tests passed; all 13 passed after adding the - repository-policy regressions below. Qt welcome loading used an isolated - PySide6 6.11.2 environment with `QT_QPA_PLATFORM=offscreen`, not the exact - Fedora image package environment. -- Fedora 44 Kickstart parsing passed for the expanded compose configuration - (139 native selections, before pinned application RPM additions, and three - Flatpaks) and the interactive installation cleanup script. -- All six packaged Hyprland templates, three Distrobox launcher templates, - and the Hermes service rendered with the packager's actual Jinja context. -- Static analysis passed again after the code changes; `git diff --check` - passed. - -An actual `image/build` invocation stopped at its prerequisite check because -this host lacks `cloud-localds`. No builder VM or disk was created. The -live-test harness also assumes `/usr/share/OVMF/OVMF_{CODE,VARS}_4M.fd`, which -this Fedora host does not provide; its firmware uses other paths/formats. -The local `/data/pxe/iso` tree and previous image-output directories are absent. -The PXE host's name did not resolve; an SSH attempt at its address stopped -at the unknown-host-key check. Existing PXE image contents were not inspected. - -Fresh compose, live boot, encrypted installation, installed boot, physical -hardware, Secure Boot, and current iVentoy behavior remain unverified. - -## Small fixes made during the audit - -1. [applications](applications) requested `brave-origin.repo`, but the reviewed - policy is `roles/apps/files/brave-browser.repo`. Python silently ignored - the missing file, omitting the Brave repository while the package contract - selected `brave-origin`. Corrected the filename and made missing policy - files fail explicitly. Added tests for the actual Brave repository contract - and missing-file behavior. -2. [Live image CI](../.github/workflows/live-image.yml) did not trigger on - desktop, base-role, or helper/asset changes that affect the image. Expanded - its path filters to cover these inputs and `VERSION`. - -No installed desktop configuration was changed. No ISO was published, so no -iVentoy refresh was required. Temporary test dependencies and the task-created -Python bytecode were removed; no VM, disk, screenshot, or build log is retained. - -## Comparison with the current desktop - -| Area | Finding | -| --- | --- | -| Quickshell | All 352 source files match the deployed runtime byte-for-byte; the image packager embeds that source. This establishes source parity, not the contents of an existing ISO. | -| Hyprland | Core files and rendered defaults match the deployed runtime, aside from irrelevant whitespace. The active `releases/bootstrap` source is older than the runtime and is not a reliable parity baseline. | -| Appearance | Theme, fonts, bar geometry, spacing, borders, and module layout largely match. The current Bluetooth widget preference is not seeded. | -| Wallpaper | The current mountain wallpaper and bundled wallpaper collection are absent from the build allowlist. No default wallpaper is selected in the image. Wallpaper-derived colors can therefore differ too. | -| Omarchy plugin | Personal plugin packages, including the installed `digitalpals.model-usage` widget, are not copied. Omarchy compatibility code remains part of the shell. | -| Personal monitor rules | The personal AORUS mode and single-window width override are not exported. Generic display defaults remain appropriate for a distributable ISO. | -| Boot theme | The workstation selects the custom `cybex` Plymouth theme; the image includes generic Plymouth and omits the boot role. | -| Installed login | The current workstation enables autologin. The image post-install hook explicitly writes a GDM configuration without it. | -| Encryption | Anaconda offers encryption, but the image does not enable it by default or share its passphrase with the account password. | -| System policy | The image invokes the apps role and manually assembles desktop integration. It does not apply all base, firewall, hardware, and dotfile policies from the workstation installer. | - -Evidence: [source allowlist](build), [payload assembly](package), -[application preparation](applications.yml), [wallpaper role](../roles/dotfiles/tasks/wallpapers.yml), -[boot role](../roles/boot/tasks/main.yml), [compose Kickstart](live.ks), -[Anaconda defaults](live-rootfs/etc/anaconda/conf.d/20-cybexos.conf), and -[installed-system cleanup](live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks). - -Two smaller integration gaps also need attention: the T3 launcher still looks -for its OAuth callback helper under `~/.config/quickshell`, while image -initialization only creates the current runtime link; Super+P references a -private `portal-launcher` helper that the image does not include. Resolve or -gate these through the shared desktop contract. - -## Proposed minimal installation - -Omarchy v4.0.4's [shared setup form](https://github.com/omacom/omarchy/blob/v4.0.4/install/provisioning/setup-form.sh) -collects keyboard, account/password, optional Git identity, hostname, and -timezone. Its [ISO configurator](https://github.com/omacom/omarchy-iso/blob/7cfb7111a06873d61c45d37034577d4ba08d3f4f/configs/airootfs/root/configurator) -then asks about the disk and confirms changes, with encryption enabled by -default. The [installation implementation](https://github.com/omacom/omarchy-iso/blob/7cfb7111a06873d61c45d37034577d4ba08d3f4f/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py) -enables autologin on encrypted normal installations. The pinned ISO source -is the current development branch inspected during this audit; it may be -newer than the released ISO. Older Omarchy 3 manual limitations should not -be assumed to describe this source. - -Recommended CybexOS flow: - -| Screen | Required choices | Defaults / secondary choices | -| --- | --- | --- | -| Your setup | Username, password and confirmation, keyboard layout | Language, timezone, and hostname prefilled where possible, editable under More options; keyboard test field available. | -| Install location | Target disk, shown by model and capacity | Automatic LUKS2-encrypted Btrfs root/home; boot partitions managed by Anaconda; autologin enabled. Advanced storage remains available. | -| Review and install | Confirm the exact disk changes | Show encryption, account, keyboard, and erase summary, then installation progress and reboot. | - -Collect the password once, use it for LUKS and the account, and leave root -locked. Keep the password out of persistent logs and generated artifacts; -use the installer's supported secret handling and store the account hash. -After reboot the intended flow is **Cybex disk-unlock prompt → desktop**. -Move optional Git identity and application choices into later personalization. -When encryption is disabled in Advanced, default back to a graphical login. - -Keep Anaconda's storage backend and validation. Fedora 44's Web UI tag 68 -supports branding, CSS, and hiding selected pages, but its -[customization documentation](https://github.com/rhinstaller/anaconda-webui/blob/68/docs/customization.rst) -says Web UI add-ons are not implemented. The existing -[encryption](https://github.com/rhinstaller/anaconda-webui/blob/68/src/components/storage/storage-configuration/DiskEncryption.jsx) -and [account](https://github.com/rhinstaller/anaconda-webui/blob/68/src/components/users/Accounts.jsx) -components use separate password state. Implementing a combined screen -requires a maintained Web UI change or a custom frontend using Anaconda's -interfaces. Begin with a small Web UI change and measure its maintenance cost. - -Equal initial passwords do not automatically stay synchronized. Omarchy has a -[separate disk-password command](https://github.com/omacom/omarchy/blob/v4.0.4/bin/omarchy-drive-password). -A later CybexOS password-settings screen should coordinate both changes or -clearly identify which password it changes. Also test GNOME Keyring access -under autologin: disk unlock alone does not establish the normal login PAM -password handoff, so applications may otherwise prompt again. - -## Prioritized improvements - -1. **Make one shared desktop/image contract.** Package the same wallpaper, - appearance defaults, helpers, system policies, and boot theme from both - install paths. Keep personal plugins and machine-specific overrides out - of that contract. Add fresh-account parity assertions. -2. **Complete the branded boot path.** Reuse the current Cybex artwork and - colors for a short boot menu, Plymouth animation and encryption entry, - welcome screen, and installer. Keep readable error output and a recovery - menu. A consistent restrained animation will contribute more than adding - extra splash screens. -3. **Remove the blank first-login wait.** The session launcher synchronously - copies the whole offline seed before Hyprland starts; the existing README - documents about a minute on live media. Prepare installed accounts before - reboot; reduce live-user copying and expose progress for unavoidable work. - Share immutable toolchain assets where their update/ownership behavior - permits it. Preserve per-user writable state and offline functionality. -4. **Implement the three-screen installer and encrypted autologin.** Test - international keyboards at both password entry and early boot, installer - failure/retry, screen lock, sudo, and application credential storage. -5. **Improve build feedback and recovery.** Add disk/RAM/CPU preflight and - Fedora dependency guidance; use unique build IDs; show named phases, - timings, and actionable failure summaries. Record source revision, local - modifications, configuration, tool versions, and payload versions. - Cache signature/checksum-verified downloads independently of disposable - VMs, and benchmark zstd against the current xz ISO compression before - choosing a new default. Verify copied output checksums before success. -6. **Automate release qualification and PXE publishing.** Detect Fedora and - Debian OVMF paths; replace fixed boot delays with bounded readiness checks. - Exercise live boot → encrypted installation → installed boot → desktop - parity, with outbound networking disabled. Publish a uniquely named ISO - and checksum to `/data/pxe/iso` only after verification, then perform the - required iVentoy refresh/status/list checks. Keep physical GPU and Secure - Boot coverage as explicit additional tests. -7. **Provide desktop RPM updates.** The image RPM version is currently fixed - and a signed desktop repository is not yet established. Application updates - alone cannot deliver the next CybexOS desktop release. - -Start with parity and a newly qualified build, then implement the boot/installer -experience. Keep the full offline application image as the primary profile -while measuring size and startup time; a smaller optional development profile -can be considered afterward if the measurements justify another artifact. diff --git a/image/Containerfile.tests b/image/Containerfile.tests deleted file mode 100644 index 67c4251c..00000000 --- a/image/Containerfile.tests +++ /dev/null @@ -1,15 +0,0 @@ -# CI passes inventory fedora_release (image/fedora_target.py); this default -# serves a plain local build and is kept equal to it by test_fedora_release.py. -ARG FEDORA_RELEASE=44 -FROM fedora:${FEDORA_RELEASE} -RUN dnf install -y python3-pyside6 python3-jinja2 python3-pyyaml python3-gobject-base glib2 gvfs \ - ShellCheck pykickstart qt6-qtdeclarative-devel desktop-file-utils \ - nodejs24 gnupg2 git ripgrep luajit ansible-core rpm-build rpm-sign createrepo_c \ - && dnf clean all -# CI mounts the host-owned checkout read-only while this test image runs as -# root. Trust only that explicit mount for source archive enumeration. -RUN git config --system --add safe.directory /source -ENV QT_QPA_PLATFORM=offscreen -WORKDIR /source -ENV PYTHONDONTWRITEBYTECODE=1 -CMD ["python3", "image/check-source"] diff --git a/image/IMPLEMENTATION-2026-09-23.md b/image/IMPLEMENTATION-2026-09-23.md deleted file mode 100644 index cd56f1f6..00000000 --- a/image/IMPLEMENTATION-2026-09-23.md +++ /dev/null @@ -1,91 +0,0 @@ -# ISO experience implementation — 2026-09-23 - -Worktree: `CybexOS-wt/iso-experience-20260923`, beside the main checkout - -Branch: `feat/iso-experience-20260923` - -Base: `3c585cd` - -This implements the source changes from the [ISO audit](AUDIT-2026-09-23.md). -The implementation stayed in this worktree while another agent used `main`. -At the user's request, no ISO was created or tested. No build VM, installer, -PXE operation, signed release or host desktop deployment was run. - -## Delivered - -| Audit suggestion | Implementation | -| --- | --- | -| Share desktop defaults | `assets/desktop-contract.json` supplies wallpaper, helper and seeding contracts. Workstation and image share wallpaper assets, Bluetooth defaults, boot artwork, sysctl and firewall sources. Private plugins/monitor rules/credentials are excluded. T3's runtime callback path and optional private portal binding are corrected. | -| Brand boot and installer | GRUB/Syslinux appearance, existing Cybex Plymouth animation/unlock artwork, welcome and Cockpit installer share the dark/warm palette. Boot/media-check/recovery commands remain available. Target initramfs is regenerated after live configuration removal. | -| Improve first-login time | Essential desktop files precede Hyprland; large offline applications seed in the background with progress/retry. Installed users seed before reboot. Atomic copies preserve existing personal data and defer editor/tool activation until dependencies exist. | -| Minimal encrypted installation | Three pages: setup, disk, review. Automatic encrypted Btrfs is default; one initial account/LUKS password, locked root, encrypted-root verification before autologin, normal login when encryption is disabled. Keyboard is applied before password entry. Anaconda handles validation and disk writes; advanced storage retains the stock UI. | -| Build feedback/recovery | Resource/dependency preflight, Fedora-compatible seed tools, unique build IDs, phases/timings, revision/configuration provenance, verified download cache, xz/zstd switch, checksummed atomic artifact delivery and interruption cleanup. Brave repository omission fixed. | -| Qualification/PXE tools | Explicit opt-in live/install harnesses, portable OVMF discovery, isolated disk identity, offline guest networking, live and installed checks, shared Quickshell lifecycle helper, default artifact cleanup. PXE publication preserves existing files and verifies checksum, refresh, image list, PXE and service status. | -| Desktop RPM updates | Source/build-derived version, migration epoch, fingerprint-pinned public update channel, local signed-repository preparation, independent signature verification and rejection of packages containing a different/disabled channel. Actual URL, signing key and hosting remain operator inputs. | - -See [README.md](README.md) for commands and [INSTALLER.md](INSTALLER.md) for -the Fedora 44 API contract, state handling and target finalization. - -## Review findings fixed - -- Correct `brave-browser.repo` policy selection and explicit missing-file failure. -- Wait for Anaconda startup; use its supplied browser URL/port and correct - Fedora 44 password-hashing API. -- Serialize confirmation and installation, use a durable worker, avoid - status/worker state races, invalidate old plans, and reject changed disks. -- Restore the shared firewall zone after Anaconda's default SSH exception; - regenerate installed initramfs after removing live-only settings. -- Keep boot-keyboard conversion and live keyboard application ahead of - password entry. Changing the layout clears password fields and review state. -- Defer LazyVim/toolchain activation during background copying, and keep - incomplete files from becoming live defaults. -- Accept prerelease RPM filenames, reject symlinked checksum manifests, - and atomically refuse even a concurrently created empty output directory. -- Verify actual RPM signatures rather than accepting digest-only success; - check embedded update-channel digests before signing a desktop release. -- Require checksum sidecars for VM input, use the actual shared Quickshell - audit helper, and register signal cleanup in imported entry points. - -## Source-only verification - -- `image/check-source`: **13 existing image/Qt tests + 61 Python image-tooling, - parity, installer and release tests + 4 installer state tests passed**. -- Headless browser fixture: real frontend with fake Cockpit transport; three - pages, keyboard changes, encryption default, explicit erase confirmation, - password clearing, progress/reload recovery and 390px layout passed. -- `tests/run`: repository syntax/lint, **895 JavaScript tests**, QML helpers, - configuration integration, policy, application/helper and existing recovery - fixtures. The live Quickshell lifecycle test is skipped when a managed - desktop is active; no second shell instance was launched. -- Fedora 44 Kickstart parsing: expanded compose (139 native application - selections plus three Flatpaks) and target post-install scripts passed. -- Independent source review checked Anaconda 44.30/Web UI 68 interfaces and - installation sequence. iVentoy 1.0.41 response shapes were inspected - statically from its official release; no iVentoy service was contacted. -- Whitespace checks passed. Optional `rpmspec --parse` could not run because - this host lacks `rpmspec`; no package tools were installed on the host. - -Qt fixtures used an isolated PySide6 6.11.2 environment, not a composed Fedora -image. The browser test used installed Chromium/Brave with temporary Playwright -Core 1.63.0. Task-created dependencies, screenshots and scratch files are -removed after verification; no ISO, VM disk or large diagnostic output is kept. - -## What these checks do not establish - -A newly built ISO still needs real Fedora/Cockpit authorization, actual DBus -tasks, BIOS/UEFI boot, offline installation, encrypted/unencrypted target boot, -autologin, screen lock, sudo, non-US/dead-key input and keyring behavior verified. -Physical GPU, Secure Boot, suspend/resume and network-zone behavior need their -own hardware coverage. No performance numbers or successful current-ISO boot -claim can be inferred from the fixtures. - -The qualification harness remains unqualified too: initial SSH access and LUKS -unlock use bounded graphical key injection without prompt recognition. A real -VM run is needed to establish timing reliability. Its failures cannot count as -a successful qualification report. - -The signed repository tool is implemented but no production channel exists -without the operator's key and URL. Default builds keep desktop updates disabled. -The shared password is initial only; later account and disk password changes -are independent. The keyring remains protected and may request unlock after -automatic login. xz stays default until an authorized zstd size/startup comparison. diff --git a/image/INSTALL-AUDIT-2026-09-25.md b/image/INSTALL-AUDIT-2026-09-25.md deleted file mode 100644 index 387ee843..00000000 --- a/image/INSTALL-AUDIT-2026-09-25.md +++ /dev/null @@ -1,336 +0,0 @@ -# Installed XPS audit — 2026-09-25 - -The application payload was installed, but the ISO does not reproduce the -workstation's complete baseline, account policy, or hardware configuration. -There is also a session dependency cycle that prevents three desktop services -from starting. These are image/configuration defects, rather than evidence of -an interrupted application seed. - -This records the initial read-only investigation followed by the authorized -repair and validation. The findings below describe the original installation; -the applied-repair section records the resulting state. - -## Build and checks - -- Computer: Dell XPS 14 DA14260, SKU 0DB9, Fedora 44, kernel - `7.2.7-200.fc44.x86_64`. -- Original installed build: `20260925T140839Z-5f33cb32`, clean source revision - `a2d31c2c13b5ab92491a8650991c519a223d10bb`. -- Read-only SSH inspection of `john@10.10.0.7` matched that build to - `/data/pxe/iso/CybexOS-Live-44-20260925T140839Z-5f33cb32.iso` and the - builder's provenance. That original ISO remains unchanged. -- All **166 explicit RPM selections** in the installed `applications.json` - are present, along with all **three declared Flatpak applications**. -- Offline application seeding reports `ready`. No broken symlinks were found - in the user's `.local/bin`, `.npm-global/bin`, or `.cargo/bin`. -- Claude, OpenCode, Cargo, Node, Bun, awww, wayfreeze and voxtype version - commands succeed. This does not qualify every application's interactive use. -- Quickshell is active with its MainPID as the sole `qs` process. Hyprland - reports no configuration errors. Its current journal confirms the missing - PowerProfiles service and contains existing portal/property warnings. -- System and user `--failed` lists are empty; that check misses the startup - jobs systemd discarded to resolve the dependency cycle below. - -## Reported problems - -| Problem | Evidence and cause | Required correction | -| --- | --- | --- | -| Nano missing after installation | Neither the required application list nor the image kickstart selects `nano`. The local RPM was installed at 17:22 CEST by a later `dnf install nano`, confirmed in the journal and DNF log. | Add `nano` to the shared required package contract so both installation paths include it. | -| Updates widget error | The original Quickshell warning at 17:01 CEST is `Cache-only enabled but no cache` for the Hyprland COPR. The widget only runs `dnf --quiet --cacheonly check-update`; the image runs `dnf clean all`. The makecache timer starts after 10 minutes plus up to 60 minutes of randomized delay, and its service requires AC power. | Bootstrap verified metadata on the first online boot and make the widget handle an uninitialized cache explicitly. | -| Signing-key prompts and incomplete update checks | The manual root DNF run encountered missing repository metadata keys, then verified signatures after acceptance. The unprivileged check still logs missing keys and skips `openai-chatgpt` and `tailscale-stable`, even while returning exit status 0. | Initialize trust in the context used by update checks, using the reviewed pinned keys. Treat skipped enabled repositories as an incomplete check. Keep signature verification enabled. | -| LocalSend cannot communicate normally | Flatpak 1.18.2 is installed and starts; it binds TCP and UDP port 53317. The active network interfaces use the `cybexos` zone. Its shipped definition has target `DROP` and no allowed ports because `features.local_network_services` defaults to false. A connection from the PXE host to the laptop's listening port timed out. | Include the intended LocalSend TCP/UDP rules in the active and permanent policy. Test discovery and a transfer in both directions. | -| Codex/Claude do not default to YOLO | The user's login shell is `/bin/bash`. The Codex alias exists and resolves correctly in interactive Fish, but Bash does not source it. No Claude alias exists in the shared Fish configuration. The image seeds Fish files but never sets the installed account's shell. | Apply the intended Fish login-shell policy during installed-account setup and add the Claude alias. Verify the aliases in a newly opened terminal. | -| Passwordless sudo missing | `sudo -n true` requires a password. The repository default is explicitly `passwordless_wheel: false`; its sudoers task belongs to the base role, which the image application playbook does not run. | Record the requested enabled policy and apply a validated, mode-0440 sudoers entry during image installation. Updating only the Ansible default would not fix the image path. | -| Tailscale click does not open login | The UI first opens a setup view; `Continue in browser` calls `signIn()`. When unprivileged Tailscale needs permission, it retries through `pkexec`. The Polkit agent was never started because of the session cycle. Tailscale is now logged in following the user's manual `sudo tailscale up`. | Repair the Polkit agent startup. If the desired initial click must open the browser directly, route that action to `signIn()` rather than only `showSetup()`. Verify with a fresh unauthenticated test account/device. | - -The original Tailscale interaction was not replayed by logging out the user's -working connection. The missing agent and extra setup step are confirmed; -the exact button sequence used in the failed attempt is unknown. - -LocalSend was launched briefly with `--hidden` and the task-owned Flatpak -instance was stopped afterward. No files were transferred. The inbound timeout -is consistent with the shipped host firewall; routed-network filtering could -also contribute. The permanent `/etc/firewalld` configuration and live ruleset -could not be fully read without authentication. A loopback HTTPS probe did -not return a successful response, so complete protocol/transfer functionality -has not been established. - -[LocalSend's upstream requirements](https://github.com/localsend/localsend/blob/main/README.md#setup) -specify incoming TCP and UDP 53317. -[DNF5 documents](https://dnf5.readthedocs.io/en/latest/dnf5.conf.5.html#repo-gpgcheck) -that repository metadata keys are separate from package keys and are stored -separately for each repository. Importing an RPM key alone is insufficient. - -The requested alias arguments are supported by the installed CLIs and their -official references: Codex -[`--dangerously-bypass-approvals-and-sandbox`](https://learn.chatgpt.com/docs/developer-commands?surface=cli) -and Claude -[`--dangerously-skip-permissions`](https://code.claude.com/docs/en/cli-reference). - -## Additional defects to fix first - -### 1. Session startup: authentication, automatic locking, dictation - -The current boot journal explicitly records an ordering cycle for each of -`hyprpolkitagent.service`, `hypridle.service` and `voxtype.service`, followed by -deletion of its start job. All three are inactive, with no corresponding process. - -`image/rootfs/usr/lib/systemd/user/hyprland-session.target` wants these units -and orders itself before `graphical-session.target`. Their vendor units order -themselves after `graphical-session.target`. Systemd's target dependency -ordering closes the cycle. The image's drop-ins do not reconcile that ordering. -The workstation uses its own service templates instead. - -Repair the image session ordering and verify the installed vendor units and -drop-ins together. A successful login and a running menubar do not verify that -authorization prompts, automatic idle locking, or dictation are operational. -The inactive idle daemon makes this the first repair priority. - -### 2. Missing baseline packages and services - -`image/applications` reads application packages and selected desktop/Docker -tasks. It omits the `Install Fedora baseline packages` task. Compared with that -task, this computer is missing: - -| Missing package | Affected feature | -| --- | --- | -| `tuned-ppd` | Desktop power-profile backend; Quickshell explicitly reports PowerProfiles unavailable. Neither this nor `power-profiles-daemon` is installed. | -| `cups` | Printing service; the printer settings application is installed without its intended local print server. | -| `avahi` | Local service discovery. | -| `bolt` | Thunderbolt authorization management. | -| `gvfs-fuse` | Filesystem access to GVfs mounts. | -| `acl` | ACL administration tools. | -| `bash-completion` | Bash command completion. | -| `powertop` | Power diagnostics. | - -The separately selected workstation desktop package `fuzzel` is also absent. -The firmware refresh timer is disabled, while the base role enables it. -Baseline services must be installed and activated together, rather than merely -adding their client applications to the manifest. - -### 3. Update delivery and omitted integration files - -- `1password` and `openai-chatgpt` are each defined twice: once by their vendor - files and once in `cybex-applications.repo`. DNF logs duplicate-ID errors. - `image/compose` writes the combined file after vendor packages have created - their own files. Reconcile the definitions and preserve the reviewed policy - across vendor RPM scriptlets; use DNF overrides where appropriate. -- `cybexos-firmware-update` is omitted from `image/package`'s helper list. - The installed update runner expects it beside itself and records firmware - status 127 when it is absent. `fwupdmgr` itself is installed. -- `/usr/share/cybexos/update-channel.json` contains `enabled: false`. - Fedora/vendor updates can work, but there is no enabled repository delivering - new CybexOS desktop RPMs. A signed desktop update channel remains a separate - release requirement. -- The image omits the workstation's LocalSend Nautilus extension and three - share launcher desktop files. The basic LocalSend command is installed. - Its file/clipboard share helpers also need actual Flatpak transfer testing; - command existence alone does not establish that those integrations work. - -### 4. The XPS hardware role is not applied - -The image package renders desktop configuration with `cybexos_xps_2026=False` -and does not run the detected-hardware role for the installed computer. -This machine has the matching XPS SKU, Panther Lake IPU7 PCI function and a -Synaptics `06cb:0701` SVP7500 camera USBIO bridge. This is not a fingerprint -reader; the hardware role explicitly excludes it from fingerprint detection. - -Compared with the repository's XPS package tasks, missing packages include: - -- `alsa-sof-firmware`, `cirrus-audio-firmware`, `intel-audio-firmware` and - `intel-vsc-firmware`; -- `libva-intel-media-driver` and `intel-vpl-gpu-rt`; -- `pipewire-utils`, `pipewire-module-filter-chain-lv2` and `lsp-plugins-lv2`; -- Fingerprint packages are conditional on detecting a real supported reader; - their absence on this laptop is not an established installation defect. - -The intended camera setup, audio processing, touchpad/backlight configuration -and thermal/power configuration are therefore not established by this ISO. -The initial `/dev/video*`/sysfs inventory exposed only the external Studio -Display camera, not the laptop's internal camera. No supported fingerprint -reader was detected; the existing role probes support before enabling -fingerprint authentication. - -Hardware repair needs the role's model gates and physical testing. Do not -blindly install an IPU camera stack or apply laptop-specific tuning to every ISO -installation. - -## Repair and qualification order - -1. Correct the session dependency cycle; verify Polkit, idle locking and - dictation after a fresh login, with exactly one service-owned Quickshell. -2. Bring the image's baseline packages/services into the shared contract, - including nano and the firmware-update helper. -3. Reconcile repository definitions, bootstrap trusted metadata, and require - complete update-check coverage for every enabled repository. -4. Apply the requested installed-account defaults: Fish, both CLI aliases and - passwordless sudo. Test from a new terminal and with `sudo -n true`. -5. Correct LocalSend firewall/integration defaults and exercise bidirectional - transfers. Exercise Tailscale browser login from an unauthenticated state. -6. Apply and validate the detected XPS hardware configuration; qualify internal - camera, audio, power profiles and fingerprint support independently. -7. Run first-boot tests with empty DNF caches and no accepted user-side keys - before building/publishing another testing ISO. Add service and behavior - checks; repeating the current manifest-presence test will miss these defects. - -The initial audit was read-only apart from this document and a temporary -LocalSend launch, which was stopped. The authorized repair below followed it. - -## Applied repair and physical-machine validation - -The repair uses shared package, account, service, firewall, Fish and hardware -sources. Anaconda runs the offline account configuration; a first-boot service -runs model-gated hardware setup. Firmware and ALSA UCM profiles are included -in the ISO before the first physical boot. The latter were also missing: -without `alsa-ucm`, the newly detected internal sound card exposed only a -generic stereo fallback instead of its speaker, headphone and microphone paths. - -Validated on the installed laptop: - -- The package-contract preview reports no missing packages. Nano was already - manually installed; it is now also in the source package contract. -- `john` has `/usr/bin/fish`; an interactive Fish session defines both requested - YOLO aliases. `sudo -k -n true` succeeds using the validated wheel policy. -- Polkit, hypridle, voxtype, TuneD PPD, thermal management and the haptic service - run. Exactly one Quickshell is owned by its service; its current invocation - passes the repository's live-shell journal check. -- Root and user DNF checks succeed with unavailable-repository failures enabled. - There are no duplicate repository IDs. Repository reconciliation is idempotent. - A separate empty-cache user check also succeeds: DNF automatically imports - the bundled Codex, Tailscale and 1Password metadata keys without prompting. -- LocalSend's TCP/UDP 53317 ports are open. A protocol-compatible HTTPS request - with a disposable client certificate from `10.10.0.7` successfully retrieves - the laptop's device information. Plain curl without a client certificate is - rejected by LocalSend's mutual TLS; that is not a firewall failure. The - file wrapper now uses Flatpak document forwarding and the installed version's - supported file/text arguments. John subsequently confirmed that a real - LocalSend transfer with another device works. -- Tailscale remains connected to the user's existing account. Login routing, - duplicate-click prevention and browser handoff pass the behavioral fixtures; - the active account was not logged out for testing. -- Loading the newly installed audio firmware exposed the internal sound card. - With ALSA UCM installed, the internal speaker/headphone/microphone nodes appear. - The speaker-tuning check verifies its 26 biquads, limiter and physical output. - John subsequently confirmed audible output through the internal speakers. - Internal microphone capture has not been physically verified. -- A repeat baseline/account configuration finishes with `changed=0`, `failed=0`. - -The camera setup exposed an additional Fedora-specific bug: its generic relay -configuration is a dangling symlink into `/run`. Backup and restoration now -preserve the symlink rather than dereferencing it, with a regression fixture. -The subsequent hardware check reaches the existing ABI guard and refuses the -optional camera bundle on `7.2.7-200.fc44.x86_64`: Fedora now ships `intel_cvs` -in-tree. No conflicting DKMS module was installed. The diagnostic remains at -`/var/lib/xps-hardware/ipu7/abi-failed.log`; `cybex doctor` reports it. Automatic -retry avoids rebuilding the same incompatible kernel/payload combination. -This needs a separate camera compatibility change, not a reinstall. - -The signed CybexOS desktop RPM update channel is still unconfigured. This is -separate from the now-working Fedora/vendor/Flatpak update checks. - -Repair backups are retained under `/var/lib/cybexos/backups/`: -`install-repair-20260925T160403Z` (212 KiB) and -`install-repair-20260925T160524Z` (24 KiB). They preserve replaced system files -and account/Fish configuration for review or rollback. - -## Rebuilt delivery and regression verification - -Build `20260925T162842Z-f71bbab0` completed successfully from a task-specific -worktree. Its live audit verifies all 209 explicit RPM selections, all three -Flatpaks and the bundled user toolchains. The laptop was upgraded to its matching desktop RPM, -`1:0.0.0~dev-1.20260925162842.gfe1179ea114a.dirty.fc44.x86_64`. -Repeating shared configuration after the RPM upgrade reports `changed=0`, -`failed=0`. The post-upgrade package audit reports no missing packages, and the -live Quickshell check confirms one service-owned process with no matching QML -errors in the current invocation. - -The ISO is published on `john@10.10.0.7` at -`/data/pxe/iso/CybexOS-Live-44-20260925T162842Z-f71bbab0.iso` -(6,927,169,536 bytes), with a companion `.iso.sha256` file. SHA-256: -`412509d7013d636e9a62ddae7a46a233f4434de60cc679aff244ec75ff199b63`. -The publisher verified the copied checksum, successful iVentoy refresh, image -listing, running PXE status and active `iventoy.service`. Existing ISOs remain. - -The full repository test suite passed all 17 stages, including 1,146 JavaScript -tests across 122 files. Source checks, shared Ansible policy fixtures and -static analysis also passed. A disposable Fedora 44 container applied the -account/firewall policy successfully; its second application made no changes. -The final image source check passes 20 image fixtures, 102 additional Python -fixtures and four installer JavaScript fixtures. - -The installation VM runner initially failed before boot because it supplied a -disk serial number as a block-backend option. The corrected runner attaches -the serial number to `virtio-blk-pci`, preserving the disposable-disk identity -check. QEMU documents the separate -[drive and device arguments](https://github.com/qemu/qemu/blob/master/docs/qdev-device-use.txt). -The original 21-byte serial was also truncated by Virtio's -[20-byte identifier limit](https://github.com/torvalds/linux/blob/master/include/uapi/linux/virtio_blk.h). -The runner and installer guard now share a shorter constant, with a protocol -length regression check; the guard continues to require an exact match. -This host-side runner change does not alter the built ISO payload. - -The bootstrap now waits for the first-run desktop before sending terminal -shortcuts or commands, uses a separate workspace, and closes failed terminal -attempts. It still requires a freshly computed shell-output marker before -typing private fixture input. This prevents boot-menu editing and unreadable -stacks of tiled terminals during automated qualification; regression fixtures -cover both conditions that gate private input. - -The first completed encrypted-installation run booted without the ISO and -passed the new account/service checks, then exposed a test-only permissions -mistake: Fedora restricts traversal of `/etc/firewalld`. The installed audit -now queries both runtime and permanent LocalSend ports through -`sudo -n firewall-cmd`, instead of reading that directory as an ordinary user. - -Live qualification also exposed a process-audit bug: `qs ipc -p ...` clients -were classified as developer menubars because they contain `-p`. The shared -test helper now waits for IPC clients to finish naturally and only uses the -existing termination path for actual developer instances. A regression fixture -verifies that IPC clients receive no termination signal. - -The final UEFI qualification **passed** with guest outbound networking blocked. -It verified the live applications, a fresh encrypted installation, boot without -the ISO, Btrfs encryption, SELinux enforcing, desktop settings and removal of -live-only access. The installed account passed Fish, both YOLO aliases, -passwordless sudo, session services, enabled baseline timers and both runtime -and permanent LocalSend firewall checks. - -All 21 named qualification stages passed, including cold reboot with an -encrypted keyring, logout/compositor-crash/display-manager-restart recovery, -missing boot-password-cache fallback and a deliberately failed first autologin. -These are VM results, not physical GPU, Secure Boot or camera certification. - -## Retained artifacts and cleanup - -On `john@10.10.0.7`: - -| Path | Size | Reason retained | -| --- | --- | --- | -| `/data/pxe/iso/CybexOS-Live-44-20260925T162842Z-f71bbab0.iso` | 6,927,169,536 bytes (6.45 GiB) | Completed and qualified testing ISO, visible in iVentoy. | -| Same ISO path with `.sha256` appended | 112 bytes | Verified ISO checksum. | -| `~/.local/share/cybexos/images/install-policy-20260925-2200/deliverables/cybexos-desktop-0.0.0~dev-1.20260925162842.gfe1179ea114a.dirty.fc44.x86_64.rpm` | 1,720,229,107 bytes (1.60 GiB) | Matching repair package already installed on the laptop; retained for review/reuse. | -| Same `deliverables/` directory: `applications.json`, `packages.txt`, `build-provenance.json`, `qualification.json`, `SHA256SUMS` | Under 70 KiB combined | Package selections, exact RPM inventory, provenance, passed qualification report and checksums. | - -The task's failed/superseded VM runs, all disposable disks, credentials, -screenshots, duplicate build ISO, intermediate RPMs, temporary logs and build -worktree were removed. Only `deliverables/` remains in the task staging root. -The original installation ISO and unrelated images were preserved. Small -laptop rollback backups and the camera diagnostic described above remain -intentionally. Repository changes remain uncommitted in both checkouts. -Final cleanup checks found no task-owned VM processes or mounts. The remote -staging root uses 1.6 GiB for the retained deliverables; local temporary repair -files, package staging and task bytecode caches are gone. All retained package -checksums pass, and iVentoy still reports the expected ISO and running PXE. - -## Recommendation - -Keep and repair the current installation. Its original application payload -was complete for the old manifest; the problems came from missing shared -configuration and several independent image defaults. Reinstalling the old -image would repeat those defects, and reinstalling the new image would not -resolve the camera's current kernel incompatibility. - -Reboot once after this repair to load the firmware, hardware environment and -new login-shell defaults cleanly. John has confirmed a real LocalSend transfer -and internal speaker output. Internal microphone capture remains unverified; -service and device-node checks alone do not establish its physical operation. diff --git a/image/INSTALLER.md b/image/INSTALLER.md deleted file mode 100644 index 0a2b8f09..00000000 --- a/image/INSTALLER.md +++ /dev/null @@ -1,644 +0,0 @@ -# CybexOS installation experience - -The live image opens a small CybexOS page in Anaconda's existing local Cockpit -service. It uses Fedora's Anaconda storage and installation backend. It does not -format devices itself, generate an unattended live Kickstart, or replace -Anaconda's partition validation. - -The complete installation has **not been qualified by an ISO boot**. The SDDM -implementation has source, fixture and disposable desktop-RPM build checks; -ISO creation and publication were deferred at the user's request. No real -installation or host login-manager switch was performed. Do not label an image -qualified until the opt-in installation tests pass. - -## Boot entries - -The ISO menu (UEFI GRUB and BIOS isolinux) offers each generated live entry -twice. **Install CybexOS** is first and the default; it adds `cybexos.install` -to the kernel command line. **Try CybexOS live** is the unchanged live desktop. -The basic graphics troubleshooting entry has the same pair. - -With `cybexos.install`, `cybexos-live-setup` creates `/run/cybexos-install-mode` -and the session launcher starts `cybexos-install-session.target` instead of -`hyprland-session.target`. That target starts only the Polkit agent and the -welcome window in `--install-mode`, which opens the installer immediately. -There is no menubar, dictation, idle daemon, Hermes bridge, input method or -background offline-application seeding. The window cannot be closed; it can -reopen the installer, restart, power off, or start the full live desktop. -Both entries boot the same image, so this saves startup time and memory, not -download size or installation time: Anaconda installs by copying the live -root filesystem, which must therefore contain every installed application. - -## User flow - -Usernames are lowercase. Capital letters are lowercased while typing, so -"John" creates the account `john`; the backend still rejects anything else. - -1. **Your setup:** select a keyboard, test it, and enter username/password twice. - Language, timezone, hostname and **Prepare this computer for a new owner** - are under **More options**. The timezone is a dropdown of Anaconda's valid - timezones, preselected from Anaconda's geolocation (Fedora's GeoIP service) - unless the user chose one. -2. **Install location:** choose a disk. The default is automatic Btrfs with - LUKS2 encryption. When the disk already holds another system, the step - offers **Install alongside** (the default) or **Erase disk**; see - [Installing alongside another system](#installing-alongside-another-system). - The advanced section can disable encryption or open the stock Anaconda Web - UI for several disks, reusing existing partitions or a custom layout. -3. **Review and install:** show the target model/path, account, encryption, - startup behavior, boot keyboard, language/timezone, exactly what is erased, - resized, created and kept, actual Anaconda disk actions, and its validation - warnings. An explicit checkbox is required: erase the disk, or (alongside) - resize the named partition. Progress thereafter comes from Anaconda's real - task signals. - -An attached seed volume replaces these questions with an -[unattended installation](#unattended-installation) and its countdown. - -The password is applied to the LUKS slot and hashed separately for the created -administrator account. Root is locked. The post-install helper verifies that -the target root filesystem has an encrypted block-device ancestor before -requesting SDDM autologin. The shared login helper rechecks root encryption at -boot and permits automatic login only on the first SDDM start in that boot. -Unencrypted and stock Advanced installations retain the -normal login screen. EFI and boot partitions follow Anaconda's platform rules; -“encrypted installation” does not mean the firmware boot partition is encrypted. - -The app keyring remains encrypted. SDDM's autologin PAM stack uses systemd's -`pam_systemd_loadkey` and GNOME Keyring's normal PAM integration to unlock it -with the briefly cached boot passphrase when the passwords match. The cache -retention is not extended. If the cache is absent or expired, or the keyring -password differs, an application requests the keyring password normally. The -installer never writes the passphrase to a file or creates a passwordless -keyring. Disk, account and keyring passwords are not kept synchronized after -installation; changing one later can require a separate keyring unlock. - -The RPM packages the same login preparation helper, PAM template and SDDM -service drop-in as workstation deployment. Its post-transaction step installs -the PAM template with an initial backup; the SDDM package retains ownership of -`/etc/pam.d/sddm-autologin`. The helper prepares `/etc/sddm.conf` before SDDM -starts. There is no permanently enabled autologin stanza in the image. - -## Installing alongside another system - -When the selected disk has partitions whose file system suggests another -operating system (NTFS or BitLocker for Windows; ext4, XFS, Btrfs, LUKS or LVM -for Linux; APFS or HFS+ for macOS), the location step offers **Install -alongside** it. The backend's `alongside` command asks Anaconda, never the -browser, how much space is available: - -- **Enough free space** (at least the larger of 64 GiB and Anaconda's payload - requirement): CybexOS uses it. Nothing is resized. -- **Not enough free space:** only the **largest partition Anaconda can shrink** - is offered. A slider chooses the space for CybexOS, from the minimum up to - what the partition can give while keeping its file system minimum plus - 5 GiB of working space. Anything Anaconda cannot shrink (for example - BitLocker) is never offered; choose **Erase disk** or the full installer. - -The sequence mirrors anaconda-webui 68's reclaim dialog: the disk is selected, -`InitializationMode` is `CLEAR_PARTITIONS_NONE` with no drives to clear and no -new disk label, an `AUTOMATIC` partitioning is created, its resizable device -tree (`GetDeviceTree()`, interface `DeviceTree.Resizable`) receives one -`ShrinkDevice(partition, size)` call, and then the normal request, passphrase, -configure, apply and validate steps run. NTFS resizing uses `ntfsresize` -from `ntfsprogs`, a mandatory package of the image's `@anaconda-tools` group. - -Before review, the backend refuses any plan that touches an existing device -other than the shrunk partition: a `destroy` action, a resize of anything -else, or a format or label created on an existing partition or the disk. The -existing EFI system partition is reused and shared; Anaconda's automatic -partitioning assigns it `/boot/efi` without formatting it. The review states -the resized partition with its old and new size, the space CybexOS receives -and the partitions kept unchanged, and the confirmation names the partition. -The installation confirmation is `alongside_confirmed`, not `erase_confirmed`. - -**Boot menu.** Fedora 44's `grub2-tools` (2.12) requires `os-prober`, and its -`/etc/grub.d/30_os-prober` runs unless `GRUB_DISABLE_OS_PROBER=true`. -Anaconda 44.30 does not set that variable on x86 and runs `grub2-mkconfig` in -the target, so the generated `grub.cfg` lists Windows Boot Manager (UEFI) and -other Linux installations. BLS kernel updates do not regenerate `grub.cfg`; -the os-prober entries remain. The target hook clears `menu_auto_hide` in the -installed grubenv so the menu is visible at every start, and records in -`/etc/cybexos/installation.json` whether `grub.cfg` contains an os-prober -entry (`otherSystemsInBootMenu`). The firmware boot menu remains a fallback. -Another Fedora-based system on the same EFI system partition shares -`EFI/fedora`; CybexOS's boot loader then manages both. - -## Unattended installation - -A volume labelled `CYBEXOS-SEED` (or cloud-init's `CIDATA`/`cidata`) with a -`cybexos-seed.yml` at its top installs without questions. The live welcome -opens the installer when such a volume is attached (it checks for 30 seconds -after login). The installer page asks the backend's `seed` command, which -mounts the volume read-only (`ro,nosuid,nodev,noexec`), reads only the YAML -and the files it names (regular files, no symlinks, size-limited), unmounts it -and validates everything strictly. Every problem is listed on screen; nothing -is planned until the seed is valid. A volume without `cybexos-seed.yml` is not -a seed. - -A valid seed is planned with Anaconda exactly like an interactive installation -(`plan` with the seed's digest; a changed seed is refused). The page then shows -the matched disk's identity, the account, storage and access, and a visible -countdown (`countdown`, 10–600 seconds) with **Cancel installation**. The -backend also refuses to start before the countdown has ended, so a page that -skips it cannot shorten it. Cancelling resets Anaconda's plan; nothing was -written, and a reloaded page does not offer the same seed again until it -changes or the live system restarts. The outcome is that of an interactive installation: the same -Anaconda storage, account, keyboard, language, timezone and hostname calls, -target hooks, `/etc/cybexos/config.yml`, login policy and factory point. - -```yaml -# cybexos-seed.yml at the top of a volume labelled CYBEXOS-SEED. -version: 1 -disk: - match: - serial: S6B0NL0W123456 # `lsblk -o NAME,MODEL,SERIAL,SIZE` shows it - erase: true # required: the matched disk is erased -encryption: - enabled: true - passphrase_file: disk-passphrase # a file beside this one; see below -account: - username: alice - full_name: Alice Example - # mkpasswd -m yescrypt (or openssl passwd -6). Never a plain password. - password_hash: "$y$j9T$SKre8868v2XV1NXxxycxM/$klea7p1t8995H5ER2f8JnqFUUOtu1t24e8W3qRBPIU3" - passwordless_sudo: false -system: - keyboard: us # an Anaconda layout, e.g. "de (nodeadkeys)" - locale: en_US.UTF-8 - timezone: Europe/Amsterdam - hostname: alice-laptop -ssh: - authorized_keys: - - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyOnlyReplaceWithYourOwnPublicKey0 alice@desktop - server: false # true enables sshd; the firewall still applies -tailscale: - auth_key_file: tailscale-auth-key # joins once at first start, then deleted -config: # /etc/cybexos/config.yml choices - manage_personal_dotfiles: true - features: - steam: false - local_network_services: false -countdown: 30 -``` - -| Setting | Rules | -| --- | --- | -| `version` | Must be `1`. Unknown settings anywhere are errors. | -| `disk.match` | One or more of `serial`, `wwn` (exact; WWN case-insensitive), `model` (case- and space-insensitive), `path` (a `/dev` path; `/dev/disk/by-id/…` links are resolved), `min_size`/`max_size` (`500GB`, `465GiB`). All given keys must match. A size alone is refused. Exactly one disk must match; zero or several matches are errors listing the available disks. The seed volume's own disk and the live medium are never candidates. | -| `disk.erase` | Must be `true`. Unattended installation always erases the matched disk; install alongside interactively. | -| `encryption` | `enabled` defaults to `true` and then requires `passphrase_file`. | -| `account` | Required unless `oem: true`, which forbids it. `username` follows the interactive rules, `full_name` is optional (no colons), `password_hash` must be a yescrypt (`$y$`) or SHA-512 (`$6$`) crypt hash; a `password` key is an error. `passwordless_sudo` defaults to `false`. | -| `oem` | `true` installs for a new owner; see below. | -| `system` | `keyboard`, `locale` and `timezone` must be in Anaconda's lists; defaults `us`, `en_US.UTF-8`, `UTC`. `hostname` defaults to `cybexos`. | -| `ssh` | Up to 32 OpenSSH public keys for the account's `~/.ssh/authorized_keys`, and `server` (default `false`) to enable `sshd`. The CybexOS firewall zone still drops SSH except on interfaces it trusts (Tailscale when that feature is on). Not allowed with `oem: true`. | -| `tailscale.auth_key_file` | A file holding a `tskey-…` auth key. Requires the Tailscale feature. | -| `config` | Only `manage_personal_dotfiles`, `passwordless_local_polkit`, `docker_sudoless` and `features.*` booleans, validated by the shared `installation_policy.choices`. Absent keys keep the ISO defaults. Automatic login, identity and passwordless sudo are decided by the installer as in an interactive installation. | -| `countdown` | Seconds before the disk is erased, 10–600, default 30. | - -Check a prepared seed folder before writing it to a volume (everything except -the disk match, which needs the target machine): - -```bash -python3 image/live-rootfs/usr/libexec/cybexos-installer-seed check /path/to/seed-folder -``` - -**Secrets and their trade-off.** The account takes only a password hash. LUKS, -however, needs the passphrase itself, so a fully unattended encrypted -installation must read it from the seed volume (`passphrase_file`). Anyone -who can read the volume can unlock the installed disk until the passphrase is -changed (`sudo cryptsetup luksChangeKey` on the LUKS partition, or the -interactive Settings for the account password). Keep the volume offline and -physically controlled, write it shortly before use, remove it after -installation and wipe it. The passphrase is never copied to the target, logged -or written to `/run`; it is passed to Anaconda's `SetPassphrase` in memory. -The account and disk start with different secrets unless you use the same -one; with different secrets, automatic login still works but the keyring asks -for its password once. The Tailscale key is staged in root-only, memory-backed -`/run/cybexos-installer/staged`, copied by the target hook to a root-only -`/var/lib/cybexos/first-boot/` file, moved to `/run` at the first start before -the factory recovery point is taken, used once with -`tailscale up --auth-key=file:…` (never on a command line) and deleted whether -or not it worked. On an unencrypted disk the deleted bytes can remain on the -device until overwritten; use a pre-approved, short-lived or ephemeral key. - -## New-owner (OEM) installations - -**Prepare this computer for a new owner** (or `oem: true` in a seed) installs -without an account. Anaconda creates no user; root stays locked. The target -hook verifies that no desktop account exists, writes a disabled login policy -and `/etc/cybexos/oem.json` (`{"version": 1, "passwordless_wheel": …}`), and -keeps any seed `config` choices in `/etc/cybexos/install-choices.json`. - -At the first start, `cybexos-new-owner.service` runs on the first console -before the login screen (`Before=display-manager.service`). It shows a -graphical setup in [cage](https://github.com/cage-kiosk/cage) (the RPM -requires the `cage` package) with four steps, one primary action each: - -1. **Welcome:** language (the installed UTF-8 locales, the installer's list, - labelled in their own language; the setup's own text is Dutch for `nl_*` - and English otherwise) and keyboard layout (xkeyboard-config's layouts and - variants), each a searchable list, with a field to try the keyboard; -2. **Your account:** full name, username (suggested from the first name and - editable), password and confirmation with the console's rules; on an - encrypted disk it explains that the password also unlocks the disk and asks - for the temporary passphrase used at this start; -3. **Time zone:** a searchable list, preselected with the installation's time - zone; -4. **Finishing:** the provisioning stages while they run, then **Ready** and a - restart (automatic after 15 seconds). - -Everything works from the keyboard alone, with a visible focus ring, and -sizes scale with the screen. - -**Privilege separation.** The root service never runs the window. It starts -`cage -d -- /usr/bin/python3 -I /usr/libexec/cybexos-new-owner-gui` with -`systemd-run --pipe --wait` as the transient unit -`cybexos-new-owner-gui.service` with `DynamicUser=yes` (a fresh unprivileged -user: read-only system, private `/tmp`, no new privileges, Unix and netlink -sockets only), the `video`, `input` and `render` groups and a private 0700 -runtime directory. There is no login session yet, so libseat's `noop` backend -opens the screen and input devices; the root service switches the first -console to graphics mode with its keyboard off for the duration (so nothing -typed reaches the console's input buffer) and restores it afterwards. The -window gets every list in one `init` message on standard input and returns -JSON lines on standard output: `ready`, `active` (first key press), -`keyboard`, `submit` and `restart`. The root service accepts only those -types with exactly their fields as strings of bounded length and lines up to -32 KiB; anything else ends the window. Every answer is checked again as root -(the same rules module the window imports), and the password travels only on -that pipe: never on a command line, in the environment, a file or a log. - -**Keyboard.** cage reads `XKB_DEFAULT_LAYOUT`/`XKB_DEFAULT_VARIANT` when it -starts. Choosing a layout applies it to the system with `localectl -set-x11-keymap` (localed converts it to the console keymap) and restarts cage -with it, back on the first step with the test field focused, so the password -is always typed with the chosen layout. On an encrypted disk a layout without -a console keymap is refused, as in the installer. Before the re-key, the -running kernel's initramfs keymap (`lsinitrd`) is compared with -`/etc/vconsole.conf` and `dracut --regenerate-all --force` runs when they -differ, so the unlock prompt reads the new password with the same layout. - -**Fallback.** The text-mode questions on the same console (keyboard, language, -account, password, time zone, then a confirmation) take over when -`cybexos.new-owner=console` is on the kernel command line; when cage, -systemd-run, the window, its QML or PySide6 is missing; when there is no -`/dev/dri/card*` or `/dev/input/event*`; when the console is not a virtual -terminal; when the window does not draw within 60 seconds (after one more -attempt); when no key press reaches it within five minutes; when it breaks -the protocol; or when it closes or crashes before the account exists. - -Either way the service then: - -1. creates the account in `wheel` and sets its password with `chpasswd`; -2. runs `cybexos-configure-installed --offline --user NAME` and - `cybexos-seed-installed-users`, the same fresh-account provisioning as - Anaconda's in-target hook, so `config.yml` is recorded from the saved - choices through the shared installation policy; -3. applies the saved sudo choice; -4. on an encrypted disk, refreshes the initramfs keymap when needed and - re-keys every LUKS device of the root file system (see below), then enables - automatic login only on a verified encrypted root, exactly as the - installer does; -5. updates `installation.json`, removes `oem.json` and restarts. - -Any failure removes the half-created account and restores the saved choices; -the window says what failed and offers to try again, and the console starts -again. Before creating the account the service records it in -`/etc/cybexos/new-owner-pending.json` (no secrets); a crash or power loss in -the middle is undone at the next start, and the unit runs again on every -start until `oem.json` is gone. - -**Encryption.** An encrypted OEM installation uses a temporary passphrase -chosen in the installer (or the seed's `passphrase_file`). The person handing -over the computer gives it to the new owner, who types it at the first unlock -and once more in the setup. The setup adds the new password as a passphrase -(`cryptsetup luksAddKey`, both secrets through stdin and a pipe, never argv), -removes every other key slot (`luksKillSlot`) and every LUKS2 token, and -verifies that the temporary passphrase no longer opens the disk. Re-running -after an interruption is safe. The restart then unlocks with the owner's -password, logs in automatically and lets the keyring unlock with the same -password. Changing a passphrase does not change the LUKS volume key: someone -who saved a copy of the header with the old key slot, or the volume key while -the disk was unlocked, could still decrypt it. A new owner who does not trust -the previous holder should run `sudo cryptsetup reencrypt` on each LUKS device -(online LUKS2 re-encryption; slow, keep the computer on mains power). - -## Factory reset - -Every guided installation leaves `/var/lib/cybexos/factory-point.pending`. -`cybexos-first-boot.service` runs at the first start, before any login, and -takes one **factory recovery point** with -`cybexos-system-snapshot create --factory`: the normal read-only root snapshot -and `/boot` archive, marked by `metadata/ID.factory`. It is outside the -five-point rotation and never pruned, and only one can exist. - -`cybex factory-reset` (ISO installations; a checkout installation explains -that it is unavailable) runs `/usr/libexec/cybexos-factory-reset reset` with -sudo, so it needs administrator authentication. It refuses in a recovery boot, -with a restore waiting for a restart, with an unfinished update transaction, -or without a factory point. It shows what is erased, suggests -`cybex backup now`, and requires typing the computer name or `erase`. On an -encrypted disk it asks for the current passphrase and a temporary passphrase -for the next owner, and re-keys the disk to that passphrase alone (as above). -It then restores the factory point with the normal restore and records the -reset inside the restored root. **Settings → Recovery points → Factory -reset…** explains the same and opens it in a terminal. - -At the next start, `cybexos-first-boot` completes the reset before any login: -it deletes every other recovery point, every set-aside root and the update -checkpoints (they contain the previous owner's system and files), removes every -desktop account and everything in `/home` (nested Btrfs subvolumes included; -other mounts are refused), SSH host keys and the sudo rule, converts the -installation's `config.yml` choices into `install-choices.json`, writes -`oem.json`, and the new-owner setup follows. The system is at the factory -point's package versions; the new owner updates as usual. Hostname and machine -ID are kept. -## Installation details and logs - -An **Installation details** panel at the bottom of every page follows one -allow-listed log, refreshed every two seconds while open: - -| Choice | Files | -| --- | --- | -| Installation steps | `/run/cybexos-installer/worker.log`: timestamped Anaconda steps and the error that stopped them | -| Anaconda | `/tmp/anaconda.log` | -| Commands | `/tmp/program.log` | -| Storage | `/tmp/storage.log` | -| Software | `/tmp/packaging.log` | -| Post-installation scripts | `/mnt/sysroot/var/log/anaconda/cybexos-cleanup.log` (chroot `%post`) and `/var/log/anaconda/cybexos-target-policy.log` (`--nochroot`) | - -The page shows at most the last 96 KiB of each. A failed installation names the -step it stopped in and opens the log most likely to explain it: the -post-installation scripts for "Running post-installation scripts", otherwise -the step log. Public status and saved state use only fixed step labels. The -log panel records Anaconda's free-form task and error text, which can include -paths and personal data; review exported logs before sharing them. Anaconda's -`/tmp/sensitive-info.log` is never read. - -**Save logs** writes `cybexos-install-logs-DATE-TIME.tar.gz`, with all of these -logs, the secret-free state file and this boot's journal, to a writable -filesystem (FAT, exFAT, ext2/3/4, Btrfs or XFS) on a removable drive. The live -medium, the selected target disk and fixed disks are never offered. An -unmounted drive is mounted `nosuid,nodev,noexec` only for the copy. - -## Keyboard behavior - -Before password entry is enabled, the backend validates the selected layout -against Anaconda's available layouts, obtains its virtual-console equivalent, -and applies the XKB layout/variant to the live user's single Hyprland session. -Changing layout clears the password fields and invalidates an earlier review. -The test input is for ordinary characters, not the password. - -The adapter accepts ordinary `layout` and `layout (variant)` identifiers. -Layouts it cannot map to a boot keymap fail before password entry and can use -the full installer. Anaconda writes the installed keyboard configuration; the -desktop launcher reads locale1 on login. Physical non-US keyboards, dead keys, -non-ASCII passwords, and accessibility input require later actual boot tests. -Early-boot Bluetooth input support is not promised. - -## Integration and source contract - -The interface was checked against **Anaconda 44.30** (tag `anaconda-44.30`, -commit `2353f519077c7fc9c8d6fe7712adc141cad2b649`) and **anaconda-webui 68**. -These are the Fedora 44 versions used as the source reference. - -- [Anaconda's live launcher](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/data/liveinst/liveinst) - owns backend startup, authorization, required modules, and the live payload. - It explicitly rejects Kickstart-based live installations. -- [Web UI customization](https://github.com/rhinstaller/anaconda-webui/blob/68/docs/customization.rst) - documents the `webui_web_engine` hook. Our browser wrapper accepts only the - loopback Anaconda URL, preserves its port, and substitutes the CybexOS page. -- [Anaconda's web launcher](https://github.com/rhinstaller/anaconda-webui/blob/68/webui-desktop) - starts its Cockpit service and passes the local page URL to that browser. -- [Storage API sequence](https://github.com/rhinstaller/anaconda-webui/blob/68/src/apis/storage_partitioning.js): - configure automatic partitioning, apply the in-memory layout, run its - validation task, then review. CybexOS sets both selected disks and - `DrivesToClear` to the one chosen disk. It obtains candidate disks from - `GetUsableDisks` and excludes protected/non-disk devices. -- [Storage scan interface](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/storage/storage_interface.py) - provides `ScanDevicesWithTask` for an explicit rescan. A rescan invalidates - the prior review token. Existing partition details come from read-only - `lsblk`; Anaconda remains the authority for disk selection. -- [Disk initialization interface](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/storage/disk_initialization/initialization_interface.py) - defines the clearing scope. [PartitioningRequest](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/common/structures/partitioning.py) - supplies Btrfs scheme `1`, LUKS2, and encryption policy. -- [Task interface](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/common/task/task_interface.py) - defines `Start`, `Stopped`, `Finish`, `GetResult`, and progress. `Finish` is - checked after stopping; stopping alone does not count as success. -- [Upstream installation progress](https://github.com/rhinstaller/anaconda-webui/blob/68/src/components/installation/InstallationProgress.jsx) - starts the same Boss `InstallWithTasks` task. The CybexOS worker runs under - systemd so a browser or SSH disconnect does not terminate its monitor. -- [UserData](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/common/structures/user.py) - provides the account record; [localization](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/localization/localization_interface.py) - provides layout selection and virtual-console conversion. - -There is no claim of a stable third-party installer-plugin API. A Fedora or -Anaconda version update requires reviewing this adapter and re-running the -installation qualification, alongside normal source tests. - -## Backend protocol - -`/usr/libexec/cybexos-installer-backend COMMAND` reads one JSON object on stdin -and emits newline-delimited JSON. Input is capped at 16 KiB. Requests require -root, `/run/cybexos-live`, and `rd.live.image` on the running kernel command -line. Operations that need Anaconda also require its `backend_ready` marker. -Fixture tests import the controller/adapter; they do not bypass these runtime -guards or invoke a real installation. - -| Command | Input | Result | -| --- | --- | --- | -| `inventory` | `{}` | Available disks with model, capacity, serial, WWN and existing partitions; layouts, locales, timezones and payload space requirement | -| `rescan` | `{}` | Invalidates any review, asks Anaconda to scan again, and returns a fresh inventory | -| `diagnostics` | `{}` | Allowlisted installer phase, progress, worker service state and backend readiness; no raw logs or personal data | -| `geolocate` | `{}` | Timezone from Anaconda's geolocation task, or empty; changes no selection | -| `keyboard` | `{"keyboard":"us"}` | Applied live keyboard and boot keymap | -| `seed` | `{}` | `{"found": false}`, or a display-safe seed summary with the matched disk, or every validation error | -| `cancel-seed` | `{"seed":"DIGEST"}` | Stops an unattended installation and resets the plan; that seed is not offered again in this live session | -| `alongside` | `{"disk":"vda"}` | Free space, or the one shrinkable partition with its floor and the CybexOS size range | -| `plan` | Account fields below, or `{"seed":"DIGEST"}` | Review token, disk identity, account policy, layout (`erase`/`alongside` with resize, space and kept partitions), disk actions, warnings; a seed plan adds its countdown | -| `install` | Token, disk, explicit erase or alongside confirmation | Starts the independent worker and returns `phase: installing` | -| `status` | No input required | Secret-free state and actual task progress | -| `reset` / `advanced` | `{}` | Clears the uncommitted plan and account state | -| `reboot` | `{}` | Reboots only when installation state is complete | -| `log` | `{"source":"steps"}` | The end of one allow-listed log (see above) | -| `log-destinations` | `{}` | Writable filesystems on removable drives | -| `export-logs` | `{"device":"sdb1"}` | Saves the log archive to one listed destination | - -`plan` fields are `username`, `password`, `confirm`, `keyboard`, `locale`, -`timezone`, `hostname`, `disk`, optional boolean `encrypted` (default true), -optional boolean `passwordless_wheel` (default false), optional boolean `oem` -(default false; no `username`, and `password` is the temporary disk -passphrase), optional `layout` (`erase` or `alongside`) and, for `alongside`, -an optional `shrink` of `{"device": ID, "size": BYTES}` that must name the -partition `alongside` offered. -`disk` is an Anaconda disk name such as `vda`, not an arbitrary path. -`install` input is `{"token":"…","confirmed_disk":"vda","erase_confirmed":true}`, -or `"alongside_confirmed":true` for an alongside plan. A seed plan is refused -before its countdown has ended. -The final record is `{"event":"result","ok":true,"data":{…}}`, or -`{"event":"result","ok":false,"error":"safe display text"}`. -`worker` is an internal systemd entry point and does not accept user settings. - -The review token expires after 30 minutes. Before installation, the controller -rechecks disk identity and existing partitions, selected disks, applied partitioning, the exact set of -pending actions, and storage validation. The set is compared without order: -`GetActions()` re-sorts blivet's list on every call, and its topological sort -reverses independent actions each time. A rejected confirmation returns to disk -selection. It never retries a started install. A failed -worker or interrupted task requires diagnosis, and the state continues to -block a replacement storage transaction. Status reads never overwrite worker -progress or completion. The startup grace prevents a queued service from being -mistaken for a lost monitor. - -## Credentials, state, and target finalization - -The UI passes the password through Cockpit stdin; it is never a command-line -argument. The helper uses Anaconda's own password-hashing function, provides the -plaintext only to its LUKS method, and drops its request copies. Python and -JavaScript do not guarantee immediate memory erasure. No disk key file or -plaintext password is written to the live image or target. A seed's account -arrives as a hash only; its disk passphrase is read from the seed volume into -memory for `SetPassphrase`. The only credential the installer moves to the -target is a seed's one-time Tailscale key (see above). - -`/run/cybexos-installer/state.json` is root-only, contains no password/hash, and -is replaced atomically. It holds disk identity, user-visible choices, the review -token before commit, task path, and progress. The browser uses a disposable -runtime profile with password saving, form history, and crash-session restore -disabled. Exceptions returned to the UI exclude raw DBus parameters. The welcome -launcher requests `--nosave=all_ks` to avoid saving generated account metadata. -Status and exported diagnostics expose only allowlisted phase, progress and -service fields. Anaconda's free-form task messages and journals are never -copied into the browser export because they may contain personal data. A failed -installation stays blocked pending diagnosis; the page never retries disk writes. - -The existing post-install hook removes the live account, temporary permissions, -live installer page/helpers/browser configuration, and live services. It calls -`cybexos-seed-installed-users` in the target before declaring success. A final -initramfs regeneration happens after live-only dracut configuration is removed. -The shipped CybexOS firewall zone is restored after Anaconda's firewall task, -which otherwise adds an SSH exception by default. A final -non-chroot post hook invokes `cybexos-installer-target`, which checks the actual -`/mnt/sysroot` mount, root encryption ancestry, created user, and locked root -before writing `/etc/cybexos/login.json`. This versioned nonsecret policy names -the installed user, requested autologin and `live: false`. -`/etc/cybexos/installation.json` records installation and keyring policy metadata. -The target hook removes any wheel sudo rule inherited from the live image. -Fresh offline configuration also defaults `passwordless_wheel` to false, so a -stock Advanced installation requires an administrator password. The guided -installer changes that policy and installs a mode-0440 sudoers rule only after -the user explicitly selects passwordless sudo and confirms the review. - -Before that, the first post hook (`cybexos-installer-target --stage`, ordered -before the in-target hook) writes a seed's explicit choices to -`/etc/cybexos/install-choices.json`; `cybexos-config` applies them through the -shared `installation_policy.apply_choices` when it records `config.yml` for a -fresh account and then removes the file. The final hook also handles the other -guided paths: for a new-owner installation it requires that no desktop account -exists and writes `oem.json` and a disabled login policy; for a seed it adds the -authorized keys (owned by the account, `0700`/`0600`), enables `sshd` only when -asked, and moves the staged Tailscale key; for an alongside installation it -clears `menu_auto_hide` and records whether os-prober added another system. -Every guided installation gets `/var/lib/cybexos/factory-point.pending`. - -The temporary live account has a separate policy with `live: true` and a -root-owned `/run/cybexos-live-session` marker. The login helper accepts this -exception only with the live kernel command-line flag. Target cleanup removes -the marker and prepared SDDM configuration and writes a disabled installed -policy before the final target helper runs. Stock Advanced installations keep -that disabled policy. Boot-local autologin state lives in -`/run/cybexos-login/autologin-used`; it cannot authorize a later boot. - -## Tests and remaining qualification - -`image/test_installer_seed.py` validates the documented seed example and every -seed rule, disk matching (zero/several matches, the seed's own disk), read-only -discovery with symlink refusal and unmounting, secret separation, the -backend-enforced countdown and the target hook's keys, SSH server, one-time -Tailscale key, OEM and alongside records. `image/test_installer.py` checks the -alongside, OEM and seed storage/account sequences against strict anaconda-44.30 -proxies (`CLEAR_PARTITIONS_NONE`, one `ShrinkDevice` on the partitioning's tree -before configuration, no account and a locked root for OEM, a seed hash used -as-is) and the controller's refusal of plans that touch other partitions. -`image/test_new_owner.py` covers LUKS re-keying against a fake LUKS2 header, -the new-owner questions and provisioning order with rollback and crash -recovery, the initramfs keymap refresh, the graphical setup's root side (the -window protocol with malformed, unknown and oversized messages, the -unprivileged `systemd-run` command, console handover, fallback selection, a -scripted end-to-end conversation with keyboard restart, wrong passphrase and -failed provisioning, and that no secret reaches a log or command line), the -factory reset guard rails and next-start cleanup, and the first-start -factory point. `image/test_new_owner_window.py` loads the window offscreen and -drives its pages, validation, live Dutch/English switching, translation -completeness, finishing states, and checks that every message it sends passes -the root service's parser. -`image/test_installation_parity.py` compares seed-style explicit choices on -the ISO and checkout paths. The browser fixture (`image/browser-smoke.cjs`) -drives the alongside slider and review, new-owner mode, and the seed -countdown, cancel and error screens with a mocked backend. - -None of these establishes a real installation. The following still need ISO -and VM (and some real hardware) qualification before they can be called -working: Anaconda 44.30 accepting an installation without any user; -`GetDeviceTree`/`ShrinkDevice` and free-space automatic partitioning on real -NTFS (including `ntfsresize` and a Windows that boots afterwards), ext4 and -already-full disks; the ESP being reused unformatted; os-prober entries in the -generated `grub.cfg` on UEFI and BIOS and the visible menu; seed discovery for -vfat, ISO9660 `cidata` and ext4 volumes and the welcome auto-start; the -countdown, cancel and unattended end-to-end result, including SSH and one-time -Tailscale enrollment at the first start; the graphical new-owner setup on tty1 -before SDDM (Plymouth hand-off, cage with the `noop` seat backend as a -`DynamicUser` on real Intel, AMD and NVIDIA graphics, input devices, the -console mode handover, cage restarting with a new layout, HiDPI scaling) and -its console fallback (localed keyboard, `chpasswd`, initramfs keymap refresh, -LUKS re-key on a real LUKS2 header, restart into autologin and keyring); -taking the factory point -at the first start; and a complete factory reset of an encrypted installation -through to the new owner's first login. -Every image build runs the chroot `%post` of `90-cybexos.ks` against the -composed root filesystem before packaging (`image/post_install_check.py`). It -uses a throwaway overlay with an Anaconda-like administrator account, the live -`/run` layout Anaconda binds into the target, and no network, then applies the -target helper and checks the resulting policy. A failure stops the build and -prints the script's output. This is how the 2026-09-26 failure (a missing -`/var/lib/systemd/linger` directory aborting every installation after the boot -loader step) would have been caught. - -`image/test_installer.py` covers input/locale validation, disk identity changes, -confirmation/replay, plan failures and retries, durable worker outcomes, monitor -loss, strict Anaconda proxy members and ordering, password separation, keyboard -commands, target autologin conditions, rejection of mixed encrypted/plaintext -backing devices, live-policy cleanup, executable permissions, and browser URL -validation. Node fixtures cover UI state, confirmation, navigation locks, and -failure recovery. Image tests load the welcome QML offscreen in both modes and -drive its wallpaper row against a scripted shell. The -headless browser fixture exercises the three pages with a mocked backend. -`image/test_qualification.py` checks URL and disk identity guards without -starting a VM. - -The opt-in `image/qualify` harness drives the guest's actual Cockpit page -through a local SSH tunnel and host Playwright. It discovers the guest URL from -the live browser command line, selects only the disk with the fixed disposable -serial, and verifies that a second attached disk's image hash is unchanged. -`--scenario` selects encrypted/plain storage and US/NL (or DE) keyboard and -locale. A `--candidate-rpm` must have a newer installed RPM version; the -optional `--recovery-check` creates a point before upgrade, boots its GRUB -entry, restores it and verifies the baseline RPM returned. The report at -`OUTPUT/qualification.json` records the exact ISO and candidate RPM SHA-256, -scenario, checks and final status. Credentials are passed to the browser driver -on stdin and excluded from its output. Source tests do not establish a -successful VM installation. - -Still required before an ISO can be called working: execution and observation -of actual Cockpit loading and -authorization, Fedora DBus behavior, UEFI/BIOS boot, encrypted and unencrypted -installation, keyboard input at LUKS unlock, autologin, post-script ordering, -offline payload completeness, driver/hardware behavior, and clean installed -services. The SDDM migration additionally needs real boot-cache/keyring handoff, -logout/relogin, compositor-crash and SDDM restart checks. The guest qualification -harness also injects an early first-autologin launcher failure, verifies that -the boot attempt remains consumed, and tests password-login recovery after a -manager restart. It restores the original launcher in cleanup. These checks -must run on isolated test disks with explicit opt-in; their presence in the -harness and passing source fixtures do not establish a boot result. diff --git a/image/QUALIFICATION-2026-09-26-LIFECYCLE.md b/image/QUALIFICATION-2026-09-26-LIFECYCLE.md deleted file mode 100644 index b66a7288..00000000 --- a/image/QUALIFICATION-2026-09-26-LIFECYCLE.md +++ /dev/null @@ -1,222 +0,0 @@ -# ISO lifecycle qualification, 26 September 2026 - -Status: the corrected replacement ISO passed all five UEFI QEMU scenarios -(88 checks), is checksum-verified, and is listed in iVentoy. This report does -not qualify a public release. - -## Candidate and evidence - -The candidate was built from clean source -`a991a97d099936298edfa7ad4c1747a3d0261f74` (`source_dirty=false`). Its source -archive SHA-256 is -`a0f0462f828f637c68c5dcb8233a78e5f32541223aef1b76f60cdc0d040bb1cc`. -The [build manifest](qualification-results/2026-09-26/build.json) binds the -source and artifacts. Each final report's `source_revision` records its -qualification harness revision. -The later `bd04954` change only fixes qualification-tool socket paths; it does -not change the installed ISO runtime. - -Artifacts retained on `john@10.10.0.7` for private installation and upgrade review: - -| Artifact | Path | Bytes | -| --- | --- | ---: | -| Private testing ISO | `/data/pxe/iso/CybexOS-Live-44-20260926T150946Z-99900b99.iso` | 7,633,059,840 | -| Matching unsigned testing RPM | `/data/cybexos-candidate-rpm-20260926-a991a97/cybexos-desktop-0.0.0~dev-1.20260926150946.ga991a97d0999.fc44.x86_64.rpm` | 1,720,330,844 | - -Each has an adjacent SHA-256 sidecar (112 bytes for the ISO; 139 bytes for -the RPM). Six compact JSON files in `image/qualification-results/2026-09-26/` -retain 8,929 bytes of build provenance and final qualification evidence. Digests: - -```text -ISO 7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635 -RPM e42fa8fdab34eab40d32e93fea9b8c9399abdff1c5be40d8f60207984c41ed8b -``` - -Build and served-copy checksums passed. iVentoy refresh returned success, -the new filename was listed, PXE reported running, and the service was active. - -| Final UEFI scenario | Status | -| --- | --- | -| Unencrypted US | Passed: [12 checks](qualification-results/2026-09-26/plain-us.json), harness `bd04954` | -| Encrypted Dutch | Passed: [25 checks](qualification-results/2026-09-26/encrypted-nl.json), harness `bd04954` | -| Encrypted US | Passed: [25 checks](qualification-results/2026-09-26/encrypted-us.json), harness `a991a97` | -| Unencrypted Dutch | Passed: [12 checks](qualification-results/2026-09-26/plain-nl.json), harness `bd04954` | -| Older-ISO RPM upgrade and GRUB recovery | Passed: [14 checks](qualification-results/2026-09-26/upgrade-recovery.json), harness `bd04954` | - -Every scenario uses disposable serial-identified installation and guard disks, -with outbound guest networking blocked. The guard disk must remain unchanged. -A pass requires installed boot without the ISO, desktop/application checks, -selected locale/timezone/keyboard, enforcing SELinux, and removal of live-only -privileges. Fresh installs must require a sudo password. Fresh encrypted scenarios -also exercise login/keyring recovery. Upgrade/recovery checks must preserve -user Kitty edits, shell preferences and a home-directory marker. - -The prior-image baseline is -`/data/pxe/iso/CybexOS-Live-44-20260926T055804Z-dbdd33d6.iso`. -It deliberately uses an installer-only session target. Its explicit legacy -qualification path starts the full desktop only after checking that target -and its marker; fresh candidates retain strict normal-startup requirements. - -Physical PXE client boot, Secure Boot, and this laptop's post-upgrade hardware -behavior are outside the QEMU qualification scope. - -## Reference workstation - -The reference is a Dell XPS 14 DA14260 running Fedora 44 from the ISO/RPM path. -Its runtime is under `/usr/share/cybexos`, without a source-checkout `current` -symlink. It uses encrypted Btrfs and enforcing SELinux; Secure Boot is disabled. -The battery was plugged in and paused at its 75–80% preservation thresholds. - -These observations informed the RPM channel, account migrations, hardware -continuation, diagnostics, and charge-limit status changes. The workstation -was inspected but was not upgraded, reconfigured, or rebooted for these tests. - -## Other verification - -| Check | Result | -| --- | --- | -| Initial local repository suite (historical) | All 17 stages passed, including 1,157 JavaScript tests. The opt-in live Quickshell stage was skipped. Later GTK and harness corrections have focused regression checks and current hosted CI coverage. | -| GitHub checks | Both required Fedora source/image checks passed at runtime/harness head `bd04954`. Checks for the final documentation and evidence commit are attached to [PR #1](https://github.com/DigitalPals/CybexOS/pull/1/checks). | -| Real RPM signing integration | Disposable RPM signed using a signing-subkey-only keyring; independent RPM/repository signatures, metadata binding, and tamper rejection passed. Nothing was installed or published. | -| Generic Fedora 44 VM at `86f185f` | First convergence: 135 changes. Second convergence: zero changes (`ok 220`). Uninstall: 22 changes; adopted files restored and project state removed. | -| Ephemeral PXE runner service | Actual transient user-service startup and cleanup passed. No GitHub runner was registered. | -| Reference workstation shell | Final guard passed its start/end checks: active managed MainPID 41596 was the sole Quickshell process, with a clean current-invocation journal. | -| Harness regressions | Sequential Quickshell IPC clients, unknown/persistent extras, localized console prompts, browser preflight, and bounded redacted audit failures passed focused tests. | -| GTK provisioning at `a991a97` | 26 focused tests passed, including real Ansible offline skipping and inherited-descriptor regressions. The actual task also passed against installed gsettings/dconf in private HOME/XDG directories and a private dconf profile: first run applied dark defaults, second made no changes, and explicit light/custom-theme choices survived. Temporary files and private processes were removed; workstation settings were untouched. | - -The generic VM predates later archive, installer and harness corrections; -it does not substitute for final ISO qualification. Its 1.2 GiB staging was -removed. - -## Findings and qualification corrections - -- Source archives omitted the bundled agent skill. The archive and an - extracted-tree packaging regression now include it. -- Anaconda's common-locales shortlist omitted Dutch. The installer now - enumerates its full API inventory: 180 available locales across 85 languages. -- Offline provisioning assumed `/var/lib/systemd/linger` existed. It now - creates the root-owned directory before the account marker; a real Ansible - regression covers a missing parent and a second idempotent run. -- A controlled backend lock reproduced the visible initialization-busy error. - The browser driver retries only that initial state through the UI, within - the original deadline and before any disk action. Other failures stop. -- Console bootstrapping now distinguishes echoed commands from output, - confirms Bash before Bash-specific setup, and recognizes the observed Dutch - sudo OCR error only with the sudo prefix and disposable account name. -- Welcome can start another Quickshell IPC client while the audit waits for - an earlier one. The guard now rescans and inspects every observed extra PID, - with a bound and the same final sole-managed-PID requirement. The historical - extra PID from the failed US run could not be classified retrospectively. -- Missing browser dependencies now fail before a VM/output directory is - created. Installed-audit failures retain bounded, password-redacted details - without echoing whole Python heredocs over the useful traceback. -- The US installed audit reported `Installed timezone differs` even though - Anaconda had selected UTC correctly. This Fedora workstation uses hardlinks - for `UTC` and `Etc/UTC`; the audit now compares file identity rather than - resolved path names. A generated-code regression covers hardlink and symlink - aliases and rejects a different zone. -- Recovery verification now requires the exact requested snapshot ID, matching - the snapshot tool's `recoveryBoot` string. Its regression uses the actual - index producer. The user-preference fixture also handles an omitted bar - position as the desktop's effective `top` default. -- Recovery uses an in-memory root overlay, whose encryption ancestry cannot be - verified by normal login policy. The real older-ISO run upgraded successfully - and preserved user choices, then its harness incorrectly expected autologin - on recovery boot. Recovery qualification now unlocks the encrypted disk, - waits for the SDDM greeter, signs in with the fixture password, and requires a - working desktop before restore. Session readiness precedes VT discovery, - because SSH can become available before SDDM has created a login session. -- A later recovery run verified the exact snapshot and completed its restore - command, then SSH disconnected during poweroff. The harness failed before - waiting for QEMU to exit. Shutdown now accepts SSH exit 255 only after a - guest marker confirms successful preparation and QEMU exits with status 0 - within the existing deadline. Sync, temporary-access cleanup, authentication, - timeout and abnormal-exit failures remain fatal. -- An earlier encrypted-US run timed out waiting for SSH after its first cold - reboot. The retained logs did not establish a cause. Readiness failures now - record bounded, password-redacted SSH and screen diagnostics and remove the - temporary screenshot. A standalone retry passed all 25 checks, - including SSH readiness and keyring recovery after that cold reboot. -- A replacement test launch failed before boot because its QMP socket path - exceeded Linux's Unix-socket pathname limit; normal nested release-runner - paths could do the same. The harness now allocates a short private socket - directory, records its actual location in `vm.json`, and removes it after - confirmed guest exit. Real socket-bind regressions cover long output paths, - repeated boots, retained diagnostic logs, and startup failures. The ISO - payload is unaffected. - -Modified diagnostic guests and superseded images cannot count as final proof. -Their useful findings are recorded here instead of retaining large artifacts. - -A later baseline run established a runtime provisioning defect: the GTK-default -task used `dbus-run-session -- gsettings` inside Bash command substitution. -An activated `gvfsd-fuse` inherited the output pipe, keeping Bash and offline -Ansible provisioning blocked after the settings command returned. The guest's -pipe holders were verified before terminating that one daemon to continue -diagnosis. That modified guest is excluded from final qualification. The first -`a1713ff` candidate had passed four fresh scenarios but packaged the same -defective task; it was superseded by the candidate above, which passed a new -complete qualification matrix. The fix skips GTK bus initialization -offline, leaving appearance defaults to the first desktop session. Live -provisioning uses a bounded private bus and separate regular-file captures for -each call, so a surviving service cannot hold Ansible's pipes open or corrupt -the next settings read. The modified diagnostic guest subsequently completed -the RPM upgrade, exact recovery boot and restore, baseline-version check, and -user-choice preservation; it remains excluded from final qualification. - -## Release configuration and limits - -The signed repository destination, public key, protected branch checks, -immutable-release setting, Pages configuration, signing environment, and -baseline variable were independently verified through the GitHub API. No -repository runners are registered. Signing runs on a separate hosted Fedora -container; the PXE runner receives no private signing key. See -[the release guide](../docs/iso-releases.md) for on-demand runners and gates. - -No production desktop RPM or public repository metadata has been published. -Project code is MIT; bundled third-party software and artwork retain their own -terms. Their redistribution/provenance review remains a public-release gate. - -This private qualification build explicitly includes the pinned stable channel -with its repository enabled. Because Pages has no metadata yet, ordinary DNF -operations on an installation of this candidate can fail on that repository. -For private testing before publication, disable only this repository: - -```bash -sudo sed -i 's/^enabled=1$/enabled=0/' /etc/yum.repos.d/cybexos-desktop.repo -``` - -This preserves signature checks and the pinned key. After publication, verified -channel enrollment re-enables it. `cybex update-channel status` checks local -configuration, not remote availability. Ordinary builds without -`--update-channel` ship a disabled desktop channel. - -The protected primary signing keyring is retained locally at -`/home/john/.local/state/cybexos/release-signing/DigitalPals-CybexOS` -(76 KiB, owner-only directory). Only its signing subkey was uploaded to the -GitHub environment secret. The public fingerprint is -`16C60642B7278AECE3A933C354220839FDF7099E`. - -## Cleanup - -All task build/cache/dependency staging (29 GiB), disposable VM disks, logs, -screenshots, temporary harness worktrees, and QMP socket directories were -removed after the five scenarios finished. The superseded task ISOs -`CybexOS-Live-44-20260926T114433Z-a107c90a.iso` and -`CybexOS-Live-44-20260926T123305Z-901ef7db.iso`, their checksums, and old RPM -directories `/data/cybexos-candidate-rpm-20260926` and -`/data/cybexos-candidate-rpm-20260926-a1713ff` were removed. Superseded local -results and diagnostic traces were removed after recording their findings. - -The final retained artifacts and signing keyring are listed above. The original -baseline ISO/checksum, Alpine ISO, unrelated remote checkout, and pre-existing -remote image outputs were preserved. The local image-output directory is -absent. Final process, mount, temporary-directory and worktree checks found no -task leftovers; directory inventory and disk usage were verified. - -After removing the last superseded ISO, iVentoy refresh returned -`result: success`. PXE reported `running`, `iventoy.service` was `active`, and -the image tree contained exactly the qualified candidate, original baseline, -and Alpine. The removed image was absent. Candidate ISO, baseline ISO, and -matching RPM checksum verification all passed again. No service restart was -needed. diff --git a/image/README.md b/image/README.md deleted file mode 100644 index 8a1aa248..00000000 --- a/image/README.md +++ /dev/null @@ -1,532 +0,0 @@ -# CybexOS live image - -The image packages the shared Hyprland/Quickshell desktop and offline -application set. The proposed boot path is **Cybex firmware menu → Cybex -Plymouth → live desktop/welcome → three-screen installer**. After an encrypted -installation, it is **disk unlock → automatic login → desktop**. - -Current ISO installation, desktop RPM upgrade, and recovery results are in the -[September 26 qualification report](QUALIFICATION-2026-09-26-LIFECYCLE.md), -including exact image hashes, harness revisions, and remaining release limits. -The earlier encrypted-login results are recorded in the -[September 25 installation audit](INSTALL-AUDIT-2026-09-25.md). -Earlier implementation and audit notes are historical: the -[September 23 implementation](IMPLEMENTATION-2026-09-23.md) and -[September 23 audit](AUDIT-2026-09-23.md). - -## Installation experience - -| Screen | Required choices | Defaults | -| --- | --- | --- | -| Your setup | Username, password/confirmation, keyboard | Language, detected timezone (dropdown), hostname and "Prepare this computer for a new owner" under More options; live keyboard applied before password entry, with a test field | -| Install location | Disk, identified by model and capacity; on a disk with another system, Install alongside (default) or Erase disk | LUKS2-encrypted Btrfs, shared initial disk/account password, automatic login | -| Review and install | Explicit confirmation to erase the disk, or to resize the named partition | Anaconda's actual partition plan, what is resized/created/kept, progress, completion and reboot | - -The custom Cockpit page uses Fedora 44 Anaconda's DBus storage/account/task -interfaces. Anaconda performs validation, partitioning and installation. -The full Anaconda Web UI remains available for advanced storage. The simple -flow needs at least 64 GiB for CybexOS. It erases the selected disk or, on a -disk with Windows or another system, installs alongside it: in free space, or -by shrinking only the largest partition Anaconda can shrink (a size slider; -the other system keeps its minimum plus 5 GiB). The EFI system partition is -shared and GRUB's os-prober lists the other system. Encryption can be disabled -explicitly; that path uses a normal login screen. Root stays locked, and the -new user has administrator privileges. - -Three further paths reuse the same backend and target hooks; see -[INSTALLER.md](INSTALLER.md): - -- **Unattended:** a volume labelled `CYBEXOS-SEED` (or `CIDATA`) with - `cybexos-seed.yml` installs without questions after a visible countdown that - can be cancelled. The seed names the disk by serial/WWN/model/path, must say - `erase: true`, takes only a password hash for the account, and reads the - disk passphrase and an optional one-time Tailscale key from separate files. - Remove and wipe the seed volume after use. -- **New owner (OEM):** installs without an account. The first start shows a - short graphical setup in cage before the login screen (language and - keyboard, account and password, time zone, finishing); it runs as an - unprivileged transient user and only returns answers to the root service, - which checks and applies them. Without usable graphics, or with - `cybexos.new-owner=console` on the kernel command line, the same questions - are asked on the console. On an encrypted disk the new password replaces - the temporary passphrase in every key slot. -- **Factory reset:** the first start takes a pinned factory recovery point. - `cybex factory-reset` (or Settings → Recovery points → Factory reset…) - restores it after a typed confirmation and sudo, and the next start removes - every account, home folder and other recovery point before the new-owner - setup. - -Passwords are passed through the privileged local transport and Anaconda's -in-memory interfaces. The account is stored as a password hash. CybexOS's -confirmation/progress files contain neither the password nor its hash. -Installation runs in a durable systemd worker and progress survives a browser -reload. A failed or lost installation worker requires inspection/restart; -the UI does not automatically retry disk writes. - -The account and encryption passwords start equal; changing one later does -not change the other. GNOME Keyring remains encrypted and can unlock through -SDDM's PAM stack using the briefly cached boot passphrase. An absent/expired -cache or different keyring password falls back to the application's unlock -prompt. No passwordless keyring or longer-lived password cache is created. See -[INSTALLER.md](INSTALLER.md) for interfaces, keyboard constraints and upstream -version references. - -## Desktop and first boot - -Both deployment paths consume [the shared desktop contract](../assets/desktop-contract.json), -wallpaper collection, existing Cybex Plymouth artwork, helpers, firewall zone -and sysctl policy. Bluetooth visibility matches the workstation default. -The ISO is authoritative for both paths' installation defaults; the checkout -installer consumes the same [installation policy](installation_policy.py). -The [parity contract](../docs/installation-parity.md) describes its checks. -The image contains the repository desktop and default applications; it does -not export personal plugins (including the Omarchy plugin), credentials, -monitor overrides or private launchers from the build machine. The private -portal keybinding is enabled only when its helper exists. - -The firmware menu uses the same dark background and warm accent as Plymouth -and the installer. Media checks, basic graphics and recovery entries are -preserved. Installed accounts are seeded before reboot. Live login copies -only desktop essentials before starting Hyprland; a background service copies -the large offline application seed and reports progress in the welcome -window. Reflinks are used where supported. Partially copied files never -become active, personal files/symlinks are preserved, and interrupted seeding -can be resumed with `cybex prepare-apps`. Developer tool links and editor -configuration activate after their dependencies are copied. - -On an installed system the first login opens the welcome window: the Cybex -wordmark, a scrollable row of popular Wallhaven wallpapers that apply with one -click (the shell searches, downloads and applies them over -`cybexos-runtime ipc wallpaper …`), and a Settings button. Offline, the row -shows the wallpaper folder instead. Closing the window finishes the welcome; -`cybex welcome` opens it again. The live session keeps its install-or-explore -choice. The window finds its QML beside the program, so -`python3 image/rootfs/usr/bin/cybexos-welcome` runs it from a checkout. - -The default application payload includes the desktop/media tools, Steam, -Docker, Podman/Distrobox, Tailscale, Brave, 1Password, ChatGPT, LocalSend, -Spotify, Android SDK, Rust, Claude Code, OpenCode, Codex CLI and T3 Code. -The payload is prepared for offline installation and first use. Online -services still need their accounts/network access. T3 waits for its offline -seed rather than downloading a fallback during setup. - -The temporary live account has administrative access and autologin; live -locking is disabled. The destination cleanup removes the account, its -privileges and live-only services/installer files, sets the shared firewall -policy, and rebuilds initramfs after removing live configuration. Installed -autologin requires the requested policy and verified root encryption, is -rechecked at boot, and is attempted only on the first SDDM start in each boot. -Logout and later SDDM starts return to the login screen. - -## Shared installation policy and repair - -The image package includes the reviewed Ansible baseline, account, Fish and -XPS hardware tasks under `/usr/share/cybexos/provision`. Package selection -includes the shared baseline and hardware firmware lists. Anaconda applies -account settings, service enablement, the firewall and hardware detection in -its offline target. The installed account starts with Fish and password-required -sudo. The installer offers an explicit opt-in to passwordless sudo; existing -saved choices are preserved on later configuration runs. -Both Codex and Claude use the interactive Fish aliases in the shared config. - -The installer records its choices in `/etc/cybexos/config.yml`, in the same -schema as the checkout installer: the account, the identity Anaconda set, the -inventory's feature defaults, personal defaults enabled for the new account -and the autologin decision made after the encryption check. The identity stays -Anaconda's (`manage_system_identity: false`). `cybex configure` asks the policy -questions again (connected widgets, local-network ports, passwordless sudo and -Polkit, sudoless Docker, autologin, personal defaults, Docker and Tailscale), -saves the answers and reapplies them; `cybex configure --check` prints them. -Autologin is saved only when the root filesystem is verifiably encrypted. -`cybex repair` creates a missing file for older installations, without opting -their account into personal defaults. `cybex uninstall` is not available: the -desktop is the operating system of an ISO installation. - -Installed accounts receive the same user configuration as a checkout -deployment, from shared task files: the session environment -(`environment.d`), portal preferences, Git/SSH/XDG directory/Brave/Firefox -defaults, the agent skill links and the Voxtype GPU backend (the CPU build -remains where no GPU is usable). Kitty settings live in the managed -`~/.config/kitty/cybexos.conf` fragment that repair keeps current; the user's -`kitty.conf` only includes it. The shell unit carries the inventory's feature -defaults, and `/etc/systemd/user/quickshell.service.d/50-cybexos-features.conf` -applies the saved choice; deselected connected widgets also mask the Hermes -bridge. Users are lingering, Docker is socket-activated rather than started -at boot, and deselected Docker/Tailscale units are turned off. On detected XPS -hardware, the session starts the external-monitor watcher. - -The image composes without weak dependencies, so packages Fedora normally -pulls in that way are explicit shared selections: the GnuPG pinentry, -`fwupd-efi`, the libcamera PipeWire camera path, `nss-mdns` (with authselect's -`with-mdns4`), `hunspell-en`, `docker-buildx`, `ibus-setup` and common tools. - -Fingerprint and security-key sign-in is the same on every installation: the -kickstart selects authselect's `with-fingerprint`, and installation, repair and -upgrade reconciliation run the shared `cybexos-auth-policy` helper, which adds -the sudo, Polkit and lock-screen key steps ([details](../docs/security.md)). - -After boot, `cybexos-hardware-setup.timer` applies the detected hardware role -when network access is available. Failed setup remains visible in the service -journal. A known camera ABI mismatch is cached for that kernel and provisioning -payload, avoiding repeated builds; a new kernel or changed payload retries it. -`cybex doctor --json` reports installed-system diagnostics. Hardware setup -status lives in `/var/lib/cybexos/hardware-status.json` and is shown in the -welcome window; a pending camera setup resumes after rebooting into the same -kernel. Successful hardware setup is also keyed to the kernel. - -The same setup runs the shared NVIDIA role on machines with an NVIDIA GPU: it -installs RPM Fusion's driver, builds it for the installed kernel, queues -Secure Boot key enrollment with a one-time password shown in the welcome -window and a notification, and records `/var/lib/cybexos/nvidia-status.json`. -A pending restart or enrollment keeps the hardware state pending until the -next boot confirms the driver; a failure keeps `nouveau` running and offers -`cybex repair --hardware`. See [NVIDIA graphics](../docs/nvidia.md). - -ISO installations use RPM upgrades for desktop changes. The default image has -no enabled desktop RPM update channel; inspect channel state with -`cybex update-channel status --json`. Enrolling a channel requires its reviewed -public configuration and complete signing-key fingerprint. RPM upgrades -schedule versioned account and machine policy through -`cybexos-reconcile.service` and its timer. Inspect or retry that work with -`sudo /usr/libexec/cybexos-reconcile --status` or `--retry`. `cybex repair` -reapplies the policy bundled with the installed RPM; it does not deploy a -source checkout. - -For installations made before this integration, preview and apply the migration -from a reviewed checkout: - -```bash -./image/repair-installed --user "$USER" -sudo ./image/repair-installed --apply --user "$USER" -``` - -This installs missing packages, reconciles signed vendor repositories, applies -shared machine/account settings, repairs session ordering and updates the -affected desktop helpers, `cybex configure`, the shell unit, the agent skill and -the XPS monitor watcher. It backs up replaced files under -`/var/lib/cybexos/backups/install-repair-*`. Home data and independent user -overrides remain in place; the managed Fish configuration is updated. Open a -new terminal for Fish, and log out/in to load the detected hardware flag into -Hyprland. The desktop shell restarts once during repair. - -DNF checks initialize the user's missing metadata and signing-key cache while -keeping signature verification enabled. Failed repositories fail the check. -Vendor repository IDs are reconciled and DNF5 overrides preserve the reviewed -key policy across vendor RPM updates. LocalSend gets TCP/UDP 53317 independently -of optional Steam/network services, and selected files use the Flatpak document -portal. The session target avoids the implicit ordering cycle with vendor -services that start after `graphical-session.target`. - -The release tooling prepares immutable-tagged desktop RPM releases and signed -repository metadata for -[`https://digitalpals.github.io/CybexOS/44/x86_64`](https://digitalpals.github.io/CybexOS/44/x86_64). -The public channel configuration is `image/channels/stable.json`. The signing -environment and baseline ISO are configured; Pages remains empty until a -reviewed release passes its gates using an on-demand PXE runner. See the -[release instructions](../docs/releasing.md). The repository code is MIT -licensed; a separate third-party software and asset redistribution audit is -still required before public distribution. Until publication and channel -enrollment, Fedora, vendor and Flatpak updates work independently. - -## Source checks: no ISO or VM - -```bash -# Fedora dependencies: python3-pyside6 python3-jinja2 python3-pyyaml python3-gobject-base glib2 -# pykickstart nodejs24 gnupg2 git ripgrep -PYTHONDONTWRITEBYTECODE=1 python3 image/check-source -./tests/run - -# Alternatively, the image fixture environment: -docker build -f image/Containerfile.tests -t cybexos-image-tests:44 image -docker run --rm -v "$PWD:/source:ro" cybexos-image-tests:44 -``` - -`image/check-source` exercises Qt welcome loading, user isolation, interrupted -seeding, branding, installer confirmation/worker/Anaconda adapter contracts, -artifact delivery/cache, firmware discovery, iVentoy responses and release -metadata. CI runs these fixtures without an image build or VM. - -`image/browser-smoke.cjs` optionally exercises the real HTML/CSS/JavaScript -against a fake Cockpit transport in a headless Chromium browser. It contacts -only its temporary loopback fixture server. Install `playwright-core@1.63.0` -in a disposable dependency directory and set `NODE_PATH` to its `node_modules`: - -```bash -CYBEXOS_BROWSER=/usr/bin/brave-origin node image/browser-smoke.cjs -``` - -It checks the minimal flow, encryption default, erase confirmation, password -clearing, progress/reload recovery, a narrow viewport, installing alongside -with a resize, new-owner mode, and the seed countdown, cancel and error screens. Screenshots are -only written when `CYBEXOS_UI_SCREENSHOTS` specifies a directory. - -## Builder - -Python 3.11+, PyYAML and writable `/dev/kvm` are required. Host packages: - -- Fedora: `qemu-kvm qemu-img xorriso openssh-clients python3-pyyaml edk2-ovmf`. -- Debian: `qemu-system-x86 qemu-utils cloud-image-utils openssh-client python3-yaml ovmf`. - -The VM smoke and installation tests also need Tesseract with English data: -`tesseract tesseract-langpack-eng` on Fedora, or `tesseract-ocr tesseract-ocr-eng` -on Debian. It recognizes the disk-unlock prompt and a terminal execution -marker before the harness types private fixture input. The image builder -itself does not require OCR. - -QEMU runs with `-no-user-config`, so host `/etc/qemu` configuration neither -shapes nor blocks the builder or test VMs. - -Allow approximately 180 GiB free staging space and 24 GiB available RAM for -the complete application build. OVMF is needed for later UEFI qualification. -`cloud-localds`, `xorriso`, `genisoimage` or `mkisofs` can create the builder's -cloud-init seed. Preflight checks resources and tools without creating files, -downloading or starting a VM: - -```bash -./image/build --preflight -``` - -When a build is authorized: - -```bash -./image/build -# Optional: --output /path/to/new-task-directory --memory 24576 --cpus 8 -# Optional: --compression zstd (xz remains the established default) -# Optional: --update-channel /path/to/public-update-channel.json -``` - -Default output is `~/.local/share/cybexos/images/BUILD-ID/`; each ID combines -UTC time and a random suffix. Output must be empty and outside `/data/pxe/iso`. -The builder: - -1. Verifies the pinned Fedora Cloud 44 base and starts a disposable KVM guest. -2. Transfers an explicit source allowlist, build provenance and, if supplied, - a validated public update key/channel. Personal runtime directories and - private signing keys are not inputs. -3. Installs applications, assembles the desktop RPM, composes and compresses - the image, with named phases, elapsed times and failure summaries. -4. Reuses only checksum-pinned cached upstream archives/fonts/COPR keys. - Moving npm, Flatpak and repository content is not treated as verified cache. -5. Verifies all copied checksums and atomically publishes `artifacts/`, with - a uniquely named ISO, desktop RPM, manifests and `SHA256SUMS`. -6. Stops the guest and removes temporary disks/keys on success, failure or - normal interruption. Logs and small provenance records stay with output. - -The persistent cache defaults to `~/.cache/cybexos/image`; `--cache` changes -it. `build.json` records revision, dirty state, source archive/configuration -hashes, host tools and timings; installed `/usr/share/cybexos/build.json` -identifies the source build. `applications.json`, `packages.txt` and -`package-manifest.json` record the application contract, RPM versions and -staged payload. The latter is not an extracted-RPM audit. -`rpm-inventory.tsv` and `flatpak-inventory.tsv` are raw inventories of the -image root; the host turns them into the ISO's SPDX 2.3 SBOM, -`CybexOS-Live-44-BUILD-ID.spdx.json` (see -[the ISO release guide](../docs/iso-releases.md#software-bill-of-materials)). - -Moving Fedora/vendor updates mean builds are not byte-for-byte reproducible. -The compose adapter verifies upstream RPM signatures before installation; -local upstream application RPMs are inventory-checksummed at the transaction -boundary. The locally built desktop RPM becomes signed through the release -step below. zstd is available for measurement, but compression size/startup -benchmarks have not been run; xz remains the default. - -`--debug-on-failure` retains a failed builder only while the command remains -running; `builder.json` contains its SSH command. Interrupt when inspection -is finished to trigger cleanup. Do not retain superseded large test artifacts. - -## Desktop updates and signing - -Image installations use `cybex update` for DNF/Flatpak updates. Desktop package -versions come from `VERSION`, with a timestamp/revision release suffix and -installed provenance. Epoch 1 permits upgrading the older hardcoded alpha -version. Existing checkout installations retain their source updater. - -A build without `--update-channel` ships a **disabled** desktop update channel. -The release gate explicitly enables `image/channels/stable.json`; a custom or -private channel can also be supplied with `--update-channel` at build time. -Local `cybex update-channel status` output does not prove that remote metadata -is available. For a custom channel, prepare a public configuration, for example: - -```json -{ - "baseurl": "https://YOUR-HOST/cybexos/44/x86_64", - "fingerprint": "YOUR-COMPLETE-OPENPGP-PRIMARY-FINGERPRINT", - "key_file": "CYBEXOS-desktop.asc" -} -``` - -`key_file` is public ASCII armor, relative to this JSON file or an absolute -path. The builder checks its full fingerprint, rejects private/revoked/expired -key material and transfers only the normalized public channel. Both package -and repository metadata signature checks are enabled. Keep the URL/key stable -across updates; key rotation needs an explicit migration. - -After an authorized build, create a signed local repository from existing -RPMs on a Fedora signing host (`rpm-sign`, `createrepo_c`, `gnupg2`): - -```bash -./image/release-repository /path/to/artifacts/cybexos-desktop-*.rpm \ - --output /path/to/new-release-directory \ - --public-key /path/to/CYBEXOS-desktop.asc \ - --key YOUR-COMPLETE-OPENPGP-PRIMARY-FINGERPRINT \ - --baseurl https://YOUR-HOST/cybexos/44/x86_64 -``` - -The RPM must have been built with that same enabled channel; a disabled or -different bundled channel is rejected. The tool signs copies, independently -verifies the RPM signatures using only the public key, signs/verifies metadata -and the release manifest, writes checksums, and atomically publishes a new -local directory. Original RPMs and the system RPM keyring remain untouched. -`--gnupghome` can select an existing signing keyring. Hosting/deployment is a -separate action; the tool does not upload anything or create signing keys. The -public GitHub Pages/RPM release tooling, baseline ISO variable, and signing -environment are configured. First publication still requires a reviewed -queued release, an operator-started ephemeral PXE runner, and passing release -gates. The repository-code MIT license does not resolve the separate -third-party software and asset redistribution audit. See -[the release instructions](../docs/releasing.md). - -## ISO qualification and PXE publication - -These commands are opt-in operations, **not part of source checks**. The -qualification report above identifies the exact tested runtime source and -artifacts; later runtime changes require a new build and qualification. -Completed testing ISOs belong in `/data/pxe/iso`; keep incomplete builds outside -that tree. - -On the iVentoy host, `image/publish-pxe /path/to/artifacts` verifies the artifact -set and prints a plan. Adding `--execute` copies the ISO/checksum through -staging, verifies them before publication, preserves existing images, then -requires iVentoy refresh success, completed refresh, running PXE, filename -presence and an active service. Identical-file retries are supported. -Staging uses the nearest writable ancestor of the served directory on the same -filesystem (for example `/data` when `/data/pxe` is root-owned), because -completed files are hardlinked into place; `--staging` selects another one. The -default API contract is iVentoy 1.0.41; review its installed UI after upgrades -and provide `--api-contract` for a changed schema. A failed refresh retains -the verified ISO for inspection and does not automatically restart PXE. - -When boot tests are separately authorized: - -```bash -./image/test-live /data/pxe/iso/CybexOS-Live-44-BUILD-ID.iso \ - --output /path/to/new-live-test --execute-vm - -./image/qualify /data/pxe/iso/CybexOS-Live-44-BUILD-ID.iso \ - --output /path/to/new-install-test --execute-vm --erase-disposable-disk -``` - -Qualification boots the ISO's default **Install CybexOS** entry, checks that -only the installation session runs, and drives the installer that session -opened. `--boot-entry live` instead selects **Try CybexOS live** with arrow keys, -audits the live desktop and offline applications, and starts the installer -itself. The smoke test (`image/test-live`) always selects the live entry. - -The harness detects Fedora/Debian UEFI firmware (raw or qcow2), uses bounded -readiness checks and blocks guest outbound networking. `--firmware bios` -selects BIOS. An adjacent `ISO-FILENAME.iso.sha256` must verify before a VM -can start. The smoke test checks the live desktop/applications; qualification -also installs to its newly created, serial-identified virtual disk, checks that -a second guard disk is unchanged, and reboots without the ISO. It verifies the -selected locale, timezone and keyboard, desktop defaults, SELinux and -live-account cleanup. Plain scenarios require password login; encrypted -scenarios check disk unlock and autologin, then verify an encrypted GNOME login -keyring without requesting an unlock, store a synthetic secret, and confirm -that secret is available after another cold boot. Logout, compositor crash and -SDDM restart must return to a greeter; password login must restore both the -desktop and keyring. Another cold boot temporarily disables the cached-password -PAM module in the disposable guest, checks that the vault stays locked, then -verifies recovery through normal password login. The final cold boot injects -a single early launcher failure in the guest. It requires a working greeter, -a consumed autologin attempt, disabled autologin after restarting SDDM, and -successful password-login recovery; the original launcher is then restored. -Chromium and Playwright operate the actual guest Cockpit installer through a -restricted SSH tunnel. The separate browser smoke fixture provides source -checks; it does not replace the graphical installation and boot results. - -Qualification reports use the versioned scenario evidence inventory in -`image/qualification_contract.py`, shared by the producer and GitHub release -verifier. Missing encrypted login/keyring checks, unknown execution modes, -stale artifact/source digests and incomplete public updater recovery evidence -fail admission. Legacy reports must be regenerated, not relabelled. - -The existing encrypted-US upgrade guest also qualifies the public updater; -no extra locale matrix or guest is added. Invoke it with the existing -`--candidate-rpm DESKTOP.rpm --candidate-offline-rpm OFFLINE.rpm --recovery-check` -options (and `--candidate-dependencies DIR` when needed). Both candidates and -`--recovery-check` are required for candidate qualification. After the existing -RPM/manual-recovery check, the harness authenticates normally and starts the -public durable updater. A root-owned guest-only DNF adapter selects the same -sealed offline RPM transaction and runs real DNF with repositories disabled; -it changes no signature checks, sudo rules, health checks or recovery code. -The harness kills the updater's complete cgroup after DNF applied the candidate -while the durable journal still says `applying`. A detached harmless writer -must stop before reboot. The installed recovery service then restores root and -vendor files before login and automatically restarts. An observer saves that -first boot's ordering evidence outside root. Another cold boot must retain the -restored root UUID and terminal journal, with baseline package identities and -post-checkpoint personal edits intact. Finally the public updater must commit -the exact candidate RPMs successfully, pass desktop health and leave no workers. -SSH access is bootstrapped again only after the automatic recovery restart, -through the normal authenticated console and login barrier. This supports a -restored baseline with SSH disabled and its firewall closed without enabling -SSH or opening the firewall permanently; the next cold boot uses the same -normal bootstrap fallback. -The same guest runs a harmless detached writer through the installed -provisioning helper with a short real systemd timeout, then proves the cgroup is -empty, its writes stopped and the next locked run cannot overlap it. -Provisioning uses `systemd-run --wait` with journal output: Fedora 44's systemd -259 rejects `--pipe` for this service. Each launch has a unique journal identifier -so diagnostics survive unit collection and exclude earlier launches of the -fixed containment unit. The caller prints progress and finite journal snapshots -every 15 seconds and a final snapshot after draining the complete cgroup. -Each journal read is limited to five seconds and 100 entries; displayed batches -are limited to 32768 characters. No journal follower is left running, and a -journal read failure does not replace the worker's failure status. -`public_updater` records run IDs, source updater digest and recovery root/boot -identities; fixture tests exercise failure transitions but do not qualify a VM. -The release gate's existing upgrade invocation runs this scenario automatically. - -Installation qualification uses four virtual CPUs, 16 GiB RAM and two new -100 GiB sparse disks: an installation disk and an untouched guard. Fresh encrypted -scenarios perform one live boot and four installed cold boots; the three -logout/crash/restart cases reuse the running installation. Plain scenarios -perform one live boot and one installed boot. The installer deadline defaults -to 30 minutes (`--install-timeout 1800`); boot, -application seeding and recovery have separate bounded waits. Runtime has not -yet been benchmarked. - -The ISO path and checksum are mandatory even when QEMU runs locally. The -harness resolves the path and rejects a symlink to an image outside -`/data/pxe/iso`. `image/publish-pxe` runs on the actual iVentoy host and requires -its local service and API; it cannot publish remotely using only an HTTP URL. -A build workstation needs filesystem access to that verified publication or -must run qualification on the PXE host. A local directory with the same name -does not establish that PXE publication and refresh succeeded. - -`--hold` on the smoke test allows inspection using `image/vm-control` and -`vm.json`. By default cleanup removes disks, credentials, screenshots and VM -logs; small JSON reports remain. `--keep-artifacts` is only for unresolved -diagnostics, and retained paths/sizes must be reported and later cleaned. -The graphical bootstrap waits for the desktop and a terminal execution marker -before private input; it stops instead of blindly retrying passwords. -Physical GPUs, Secure Boot, international early-boot password entry, screen -lock, suspend/resume and real application/account credential behavior need -separate checks. - -## Graphics and release boundary - -The payload includes Fedora kernel drivers, Mesa OpenGL/EGL/Vulkan and explicit -AMD, Intel and NVIDIA firmware, plus DRM in the generic live initramfs. It does -not force a GPU vendor. Nouveau/NVK support depends on the GPU; proprietary -NVIDIA drivers are not bundled: installed systems with an NVIDIA GPU build -RPM Fusion's driver after the first boot. Investigate graphics with `lspci -nnk`, -`hyprctl monitors all` and `journalctl -b -k`; basic-graphics recovery intentionally -disables normal modesetting. - -The repository's original code and configuration are MIT-licensed. That does -not grant redistribution rights for third-party packages, artwork, fonts, -trademarks, or bundled images. The ISO and its application payload still need -a documented redistribution audit before public release; see -[licensing and asset provenance](../docs/licensing.md). A passing VM gate -also does not qualify physical hardware or Secure Boot. diff --git a/image/VALIDATION.md b/image/VALIDATION.md deleted file mode 100644 index 64fca0a4..00000000 --- a/image/VALIDATION.md +++ /dev/null @@ -1,195 +0,0 @@ -# Private alpha validation — 2026-09-05 - -This record predates the rename to CybexOS and keeps the former -`fedora-config` names of the artifacts that were validated. - -Validated ISO: `FC-LIVE-44.iso`, Fedora 44, desktop RPM -`fedora-config-desktop-0.1.0-0.1.alpha.fc44.noarch`. - -SHA-256: - -```text -3358a9dc418154b1ccfa809d00ccf8624d9c706b3571a34c6ddcfc401a2172e1 -``` - -The final artifacts are in `~/.local/share/fedora-config/images/alpha-01/`. -This record applies to this ISO; rebuilds use moving upstream repositories. - -| Check | Result | -| --- | --- | -| Six account-isolation and welcome/QML tests | Passed | -| Kickstart validation, including the interactive cleanup hook | Passed | -| ShellCheck and application desktop-file validation | Passed | -| Upstream RPM signature checks during composition | Passed | -| BIOS live boot, welcome and Quickshell audit | Passed | -| UEFI live boot, welcome and Quickshell audit | Passed | -| Graphical Anaconda installation, internet route removed before confirmation | Passed | -| Whole-disk installation with encrypted Btrfs root/home | Passed | -| Installed target cleanup and account/session configuration | Passed | -| UEFI boot from the installed disk without an ISO | Passed | -| Disk unlock, GDM login and installed Hyprland welcome | Passed | -| Installed personalization button and Quickshell audit | Passed | -| ISO/RPM checksums after copying to the delivery directory | Passed | - -The installation test used QEMU/KVM with a q35 machine, four virtual CPUs, -8 GiB RAM, virtio graphics, a blank 48 GiB virtual disk, and OVMF without -Secure Boot. The actual Anaconda Web UI was driven through a private SSH -tunnel. Temporary SSH access was added for inspection after boot; the ISO -and installer were not patched during the final test. - -Before the first installed boot, the target had no live account, live sudo -or Polkit grants, live startup unit, live dracut configuration, autologin, or -test SSH key. Root was locked and SSH disabled. The installed account had -Hyprland selected in AccountsService. The installed system booted with -SELinux enforcing and encrypted Btrfs mounted at `/` and `/home`. - -Each live shell test checked the service MainPID against every `qs` PID and -checked the current invocation's journal for QML errors. The final installed -test began and ended with one healthy service-owned Quickshell process. -Hyprland reported no configuration errors. Qt emitted software-rendering and -portal-registration warnings in the VM; the tested welcome and settings -interfaces rendered and worked. - -Diagnostic logs and screenshots are retained under -`~/.local/state/fedora-config/iso-research/`. The delivery directory includes -selected evidence in `validation/`. Test VMs are stopped; temporary host SSH -keys are removed. The final installed test disk is retained separately under -`images/live-test-08/installed.qcow2` for inspection. - -Not qualified by this test: physical hardware, Secure Boot, BIOS installation -to disk, dual boot, manual partitioning, recovery, and hardware-specific -graphics or networking. Public distribution also needs a repository license, -redistribution/branding review, and signed desktop RPM update hosting, as -described in [README.md](README.md). - -## Testing image rebuild — 2026-09-06 - -Built the current source with `image/build` and placed the requested testing -image at `/data/pxe/iso/CybexOS-Live-44.iso` (2,293,989,376 bytes). Its adjacent -`CybexOS-Live-44.iso.sha256` records the verified delivery checksum: - -```text -23cfb3c10e73955298da1ae156957e9a9d27da9f1a0c43508554f2dac6871061 -``` - -Composition, upstream package signature checks, graphics-package checks, -installer-tool checks, and artifact checksum verification passed. Inspection -with `xorriso` confirmed BIOS and standard UEFI boot entries. This rebuild -has not been boot-tested and does not inherit the previous image's validation. -The optional `mkefiboot` Mac HFS+ image step reported a mount failure; Mac boot -support is unverified. Temporary build outputs, logs, and the builder VM -workspace were removed; only the ISO and its checksum were retained. - -## Complete offline application image — 2026-09-06 - -Testing image: `/data/pxe/iso/CybexOS-Live-44-full-apps-20260906.iso` -(7,181,475,840 bytes, 6.69 GiB), with an adjacent `.sha256` file. - -```text -440618fa5115b5939016e5b33143bcd46bc334c2387e849d3cbadfbc1c1e26d2 -``` - -This image includes 140 explicit native application package selections, -1,693 installed RPMs including dependencies, all three configured Flatpaks -and their runtimes, and the offline user application/toolchain seed. -The application and CLI are named `cybex`; `fedora-config` remains a -compatibility command. - -| Check | Result | -| --- | --- | -| All 16 source-check stages, 11 image tests, and 3 application-default tests | Passed | -| Upstream signatures, pinned payload checksums, and complete application manifests | Passed | -| Uninterrupted UEFI live boot with outbound networking blocked | Passed | -| Fastfetch, AI CLIs, Rust, Android tools, Neovim, T3 Code digest, and dictation model offline | Passed in live and installed accounts | -| Graphical Anaconda installation onto a blank 100 GiB virtual disk, entirely offline | Passed | -| Graceful shutdown and UEFI boot from the installed disk without a CD-ROM | Passed | -| New-account Hyprland session, installed welcome, and live-account cleanup | Passed | -| Sole service-owned Quickshell process and current journal audit | Passed before and after live/installed checks | -| SELinux enforcing, no Hyprland configuration errors, Docker and Tailscale active | Passed | -| Delivered ISO checksum and iVentoy refresh/list/service checks | Passed | - -Tests used QEMU/KVM, four virtual CPUs, 16 GiB RAM, virtio graphics and OVMF -without Secure Boot. The installed root and home use Btrfs. The test harness -now allows 150 seconds for first-login seed copying and flushes guest writes -before stopping a held VM. The final installation was shut down normally; -abruptly stopping an earlier test had lost its final unflushed filesystem -changes. No image modification was needed for the successful repeat. - -Temporary builder/test VMs, disks, logs, screenshots, and duplicate build -outputs were removed. Only the delivered ISO and checksum remain from this -build. Existing PXE images were preserved, and iVentoy remained running. -Physical hardware, Secure Boot and BIOS installation were not tested for -this image. - -## Simple installer VM installation — 2026-09-25 - -Build `20260925T140839Z-5f33cb32` from commit `a2d31c2`, published as -`CybexOS-Live-44-20260925T140839Z-5f33cb32.iso`. - -SHA-256: - -```text -c8d527fef5628ffbf575414e962d27a44af483869a14d1a1703b79d96cad142b -``` - -This was a manual run on the PXE host, not `image/qualify`, which needs -Tesseract there. The VM booted the ISO's kernel with a serial debug shell to -enable SSH, launched `liveinst` inside the live Hyprland session and drove -`cybexos-installer-backend` with the page's requests. - -| Check | Result | -| --- | --- | -| Inventory, geolocated timezone (Europe/Amsterdam), live keyboard | Passed | -| Plan and confirmation of encrypted Btrfs on a blank 100 GiB virtual disk | Passed | -| All 41 Anaconda steps, both CybexOS `%post` scripts | Passed | -| Target policy: verified encrypted root, `autologin: true` | Passed | -| UEFI boot from the installed disk, passphrase, automatic login to the welcome window | Passed | - -The previous build failed at "Running post-installation scripts": -`firewall-offline-cmd --set-default-zone=cybexos` exits 16 -(`ZONE_ALREADY_SET`) because the live image already defaults to that zone. -Anaconda then skipped the rest of both scripts plus its log copy and SELinux -context tasks. Such installations boot, but show the SDDM greeter. - -Tests used QEMU/KVM, eight virtual CPUs, 16 GiB RAM, virtio graphics and OVMF -without Secure Boot. Keyring unlock, logout/crash recovery, physical hardware -and BIOS installation were not tested. The VM, its disk and screenshots were -removed. - -## Installer diagnostics and installation-only boot — 2026-09-26 - -Build `20260926T055804Z-dbdd33d6` from commit `c2afe14`, published as -`CybexOS-Live-44-20260926T055804Z-dbdd33d6.iso`. - -SHA-256: - -```text -9b6d480e558590dfc257b6b661f221e3c479c9703ebcefa5f5dad517018c235a -``` - -The previous image (`20260925T194305Z-189bcf79`) failed every installation at -"Running post-installation scripts": offline provisioning copied the lingering -marker into `/var/lib/systemd/linger`, which does not exist in a fresh target. -Running that image's `%post` in a chroot of its root filesystem reproduced the -failure; with the fix, the complete script, the target helper and `dracut` -succeeded. The builder now performs that check on every composed image, and -it rejected the old image's root filesystem with the same error. - -`image/qualify` passed all 21 checks on UEFI QEMU, booting the default -**Install CybexOS** entry: the installation-only session (no Quickshell, app -seeding or dictation; installer opened by itself), encrypted installation, -installed boot without the ISO, encrypted Btrfs, autologin, desktop parity, -live cleanup, SELinux, and every keyring/logout/crash/manager-restart case. -The UEFI GRUB, the embedded EFI image and BIOS isolinux menus each list the -Install and Try-live pairs. The user then installed the published ISO on -physical hardware successfully. - -A first build showed Hyprland's random background splash, one of which reads -"Segmentation fault (core dumped)", behind the installation-only session; it -was not a crash (no core dumps). The published build disables the splash. -QEMU screendumps of the fullscreen installer can appear blank because of -direct scanout; a compositor capture showed the page rendered normally. - -Not exercised on a real installation: the log panel's `log` and `export-logs` -requests (covered by fixtures and the headless browser test), the live-desktop -entry after the menu change, BIOS, and Secure Boot. diff --git a/image/application_payload.py b/image/application_payload.py deleted file mode 100644 index fe4c8cc3..00000000 --- a/image/application_payload.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Portable filesystem helpers for the offline application seed.""" -import os -from pathlib import Path -import re - - -def include_applications(kickstart: str, packages: list[str]) -> str: - """Select apps directly, preserving DNF multilib selectors and user removals. - - Making every app a desktop RPM dependency would let removing Steam also - remove the desktop. Explicit compose selections install the full defaults - without coupling later application removals to the graphical session. - """ - section = re.search(r"(?m)^%packages[^\n]*\n", kickstart) - if section is None: - raise ValueError("The image kickstart has no package section") - offset = section.end() - return kickstart[:offset] + "\n".join(packages) + "\n" + kickstart[offset:] - - -def relativize_seed_links(seed: Path, original_home: Path) -> None: - """Relocate native installers' HOME links without changing system links.""" - for link in seed.rglob("*"): - if not link.is_symlink(): - continue - target = os.readlink(link) - if target.startswith(str(original_home) + "/"): - target = os.path.relpath(seed / Path(target).relative_to(original_home), link.parent) - link.unlink() - link.symlink_to(target) diff --git a/image/applications b/image/applications deleted file mode 100755 index 936bedda..00000000 --- a/image/applications +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 -"""Assemble offline apps using the same pinned installers as the workstation.""" -import configparser -import hashlib -import json -import os -from pathlib import Path -import shutil -import subprocess - -import yaml -from application_payload import relativize_seed_links -from source_build_provenance import collect as source_build_provenance -from system_packages import DEPENDENCY, contract as system_package_contract - -ROOT = Path(__file__).resolve().parents[1] -BUILD = Path("/home/builder/build") -SEED = Path("/home/cybex-seed") - - -def run(*args, **kwargs): - return subprocess.run(args, check=True, **kwargs) - - -def copy_tree(source, target): - target.parent.mkdir(parents=True, exist_ok=True) - if source.is_dir() and not source.is_symlink(): - shutil.copytree(source, target, symlinks=True, dirs_exist_ok=True) - elif source.is_symlink(): - target.symlink_to(os.readlink(source)) - else: - shutil.copy2(source, target) - - -# Ansible tasks whose packages the image installs. image/tests checks that -# every other package-installing task in roles/ is excluded deliberately. -PACKAGE_TASKS = ( - ("roles/apps/tasks/packages.yml", { - "Install signed vendor applications", "Install Steam 32-bit graphics userspace", - }), - ("roles/apps/tasks/fonts.yml", {"Install shell and browser fallback fonts"}), - ("roles/base/tasks/main.yml", {"Install Docker packages when selected"}), - # Firmware must be available at the first physical boot, before the - # detected hardware role runs its machine-specific configuration. - ("roles/xps-2026/tasks/packages.yml", { - "Install explicit Panther Lake firmware, media, and regulatory packages", - "Install full Panther Lake video-codec acceleration", - }), - ("roles/desktop/tasks/titlebars.yml", {"Install native titlebar build dependencies"}), - ("roles/desktop/tasks/notification-images.yml", {"Install native notification image build dependencies"}), - ("roles/desktop/tasks/main.yml", {"Install stable Hyprland and desktop integration packages", - "Install Fedora fuzzel and matugen rather than COPR variants"}), -) - - -def manifest_package_name(selection, selectors): - """Use bare names for RPM queries; the shared RPM must enforce constraints.""" - dependency = DEPENDENCY.fullmatch(selection.strip()) if isinstance(selection, str) else None - if not dependency: - raise ValueError(f"Unsupported application package selection: {selection!r}") - name, operator, version = dependency.groups() - if operator: - constraint = f"{name} {operator} {version}" - if constraint not in selectors: - raise ValueError("Application package constraint is not represented in the shared RPM contract: " + constraint) - return name - - -def package_names(): - """Read package groups from the application and desktop contracts.""" - defaults = yaml.safe_load((ROOT / "roles/apps/defaults/main.yml").read_text()) - baseline = yaml.safe_load((ROOT / "roles/base/defaults/main.yml").read_text()) - packages = list(baseline["base_required_packages"]) - system_packages = system_package_contract(ROOT) - packages.extend(system_packages['packages']) - # The workstation skips unavailable optional packages at converge time. - # The compose has no --ignoremissing, so one Fedora drops fails the build - # visibly instead of shipping an image that silently lacks it. - for key in ("apps_required_fedora_packages", "apps_optional_fedora_packages", "apps_omawrite_packages", - "apps_developer_fedora_packages", "apps_podman_packages", "apps_steam_packages"): - packages.extend(defaults[key]) - for path, names in PACKAGE_TASKS: - for task in yaml.safe_load((ROOT / path).read_text()): - if task.get("name") in names: - selected = task["ansible.builtin.dnf"]["name"] - packages.extend([selected] if isinstance(selected, str) else selected) - packages += ["tailscale", "java-25-openjdk-devel", "rpmfusion-free-release", "rpmfusion-nonfree-release"] - selectors = set(system_packages['selectors']) - return sorted({manifest_package_name(package, selectors) for package in packages}), \ - defaults["apps_flatpaks"] + defaults["apps_steam_flatpaks"] - - -def application_repositories(repo_directory=Path("/etc/yum.repos.d")): - """Load every reviewed vendor policy; missing files must stop the build.""" - policies = configparser.ConfigParser(interpolation=None) - repo_files = [*repo_directory.glob("rpmfusion-*.repo")] - # Brave Origin is published in the Brave Browser repository. Package - # names and repository filenames are independent contracts. - repo_files += [ROOT / "roles/apps/files" / (name + ".repo") - for name in ("brave-browser", "1password", "chatgpt", "tailscale")] - for path in repo_files: - # ConfigParser.read silently ignores missing files, which otherwise - # hides a renamed policy until dependency resolution during compose. - with path.open() as stream: - policies.read_file(stream, source=str(path)) - for section in list(policies.sections()): - if not policies[section].getboolean("enabled", fallback=True): - policies.remove_section(section) - elif not policies[section].getboolean("gpgcheck", fallback=False): - raise RuntimeError(f"Unsigned application repository: {section}") - return policies - - -def main(): - if os.geteuid() != 0 or Path("/etc/hostname").read_text().strip() != "image-builder": - raise RuntimeError("Application preparation is only allowed in the disposable image builder") - environment = dict(os.environ, ANSIBLE_ROLES_PATH=str(ROOT / "roles"), - ANSIBLE_LOCAL_TEMP=str(BUILD / "ansible-tmp")) - run("ansible-playbook", "-i", "localhost,", str(ROOT / "image/applications.yml"), - "--extra-vars", json.dumps({"primary_user": "cybex-seed", "primary_group": "cybex-seed", - "primary_home": str(SEED), "config_repo": str(ROOT)}), env=environment) - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - packages, flatpaks = package_names() - # The upstream helper already verified these exact inventory digests. - # Recheck the bytes when admitting them into the compose's local repo. - repo = BUILD / "repo" - repo.mkdir(exist_ok=True) - pinned_rpms = {} - for app in inventory["github_release_apps"]: - if app["kind"] != "rpm": - continue - matches = list((Path("/var/cache/cybexos-upstream") / app["name"]).rglob("*.rpm")) - expected = app["checksum"].removeprefix("sha256:") - matches = [file for file in matches if hashlib.file_digest(file.open("rb"), "sha256").hexdigest() == expected] - if len(matches) != 1: - raise RuntimeError(f"Missing verified RPM for {app['name']}") - file = matches[0] - shutil.copy2(file, repo / file.name) - pinned_rpms[file.name] = expected - packages.append(app["name"]) - (BUILD / "pinned-rpms.json").write_text(json.dumps(pinned_rpms, indent=2) + "\n") - - payload = BUILD / "applications-root" - payload.mkdir(exist_ok=False) - # An explicit allowlist prevents builder accounts, keys, caches, logs, - # container storage and other disposable state from reaching the image. - for relative in ("opt/cybexos-apps", "opt/cybexos-builds", "usr/local/bin", - "usr/local/libexec", "usr/local/share/fonts", - "usr/local/share/applications/omawrite.desktop", - "usr/share/icons/hicolor/scalable/apps/omawrite.svg"): - source = Path("/") / relative - if source.exists(): - copy_tree(source, payload / relative) - for log_dir in (payload / "opt/cybexos-builds").glob("*/logs"): - shutil.rmtree(log_dir) - seed = payload / "usr/share/cybexos/user-seed" - for relative in (".local/bin", ".local/share/claude", ".local/share/cybexos-user-tools", - ".local/share/voxtype", ".local/share/t3code-nightly", ".cargo/bin", - ".cargo/env", ".rustup/toolchains", ".rustup/settings.toml", ".rustup/update-hashes", - "Android/Sdk", ".config/nvim", ".config/voxtype", ".npm-global"): - source = SEED / relative - if source.exists(): - copy_tree(source, seed / relative) - shutil.rmtree(seed / ".config/nvim/.git") - # Native installers use absolute HOME links. Relativize only seed-owned - # links, so the same payload works for every account without downloads. - relativize_seed_links(seed, SEED) - android = payload / "usr/local/libexec/cybexos-android-sdk-update" - android.write_text(android.read_text().replace(f"sdk={SEED}/Android/Sdk", 'sdk="$HOME/Android/Sdk"')) - # Existing repo definitions keep native RPM updates working after install. - # Vendor RPM scriptlets can rewrite their installed repo files. Compose - # from the reviewed definitions that reference our checksum-pinned keys. - policies = application_repositories() - with (BUILD / "application-repositories.repo").open("w") as output: - policies.write(output) - installed = run("flatpak", "list", "--system", "--app", "--columns=application", - capture_output=True, text=True).stdout.splitlines() - if not set(flatpaks).issubset(installed): - raise RuntimeError("Incomplete offline Flatpak application payload") - manifest = {"packages": sorted(set(packages)), "flatpaks": flatpaks, - "features": inventory["features"], "user_seed": str(SEED), - "source_builds": source_build_provenance(inventory)} - (BUILD / "applications.json").write_text(json.dumps(manifest, indent=2) + "\n") - - -if __name__ == "__main__": - main() diff --git a/image/applications.yml b/image/applications.yml deleted file mode 100644 index 4f25869b..00000000 --- a/image/applications.yml +++ /dev/null @@ -1,84 +0,0 @@ ---- -- name: Prepare the complete offline application payload in the disposable builder - hosts: localhost - connection: local - gather_facts: true - module_defaults: - ansible.builtin.dnf: - install_weak_deps: false - vars_files: - - ../inventory/group_vars/all.yml - vars: - primary_user: cybex-seed - primary_group: cybex-seed - primary_home: /home/cybex-seed - config_repo: /home/builder/source - # Compose installs the ISO system baseline into the image root, not the - # disposable host that only prepares application stores and user tools. - apps_prepare_offline_payload: true - pre_tasks: - - name: Create the disposable application seed account - ansible.builtin.user: - name: "{{ primary_user }}" - home: "{{ primary_home }}" - shell: /bin/bash - - - name: Read the seed account identity for application handlers - ansible.builtin.getent: - database: passwd - key: "{{ primary_user }}" - - - name: Install application installer prerequisites - ansible.builtin.dnf: - name: [flatpak, gnupg2, curl, unzip, tar, zstd, fontconfig, java-25-openjdk-devel] - state: present - - - name: Create application ownership state - ansible.builtin.file: - path: /var/lib/cybexos/features - state: directory - mode: "0755" - - - name: Create application seed directories - ansible.builtin.file: - path: "{{ primary_home }}/{{ item }}" - state: directory - owner: "{{ primary_user }}" - group: "{{ primary_group }}" - mode: "0755" - loop: [.local/bin, .config/voxtype, .config/nvim] - - - name: Install the default dictation configuration before model setup - ansible.builtin.copy: - src: ../roles/dotfiles/files/voxtype.toml - dest: "{{ primary_home }}/.config/voxtype/config.toml" - owner: "{{ primary_user }}" - group: "{{ primary_group }}" - mode: "0644" - - roles: - - apps - - tasks: - - name: Install the T3 Code download helper - ansible.builtin.template: - src: ../roles/dotfiles/templates/t3code-update.j2 - dest: "{{ primary_home }}/.local/bin/t3code-update" - owner: "{{ primary_user }}" - group: "{{ primary_group }}" - mode: "0755" - - - name: Include the complete T3 Code application for offline first launch - become: true - become_user: "{{ primary_user }}" - ansible.builtin.command: "{{ primary_home }}/.local/bin/t3code-update" - register: image_t3_install - changed_when: "'CHANGED:' in image_t3_install.stdout" - - - name: Include the pinned Neovim starter - ansible.builtin.git: - repo: https://github.com/LazyVim/starter.git - dest: "{{ primary_home }}/.config/nvim" - version: "{{ lazyvim_starter_commit }}" - become: true - become_user: "{{ primary_user }}" diff --git a/image/benchmark-compression b/image/benchmark-compression deleted file mode 100755 index 3b5a7590..00000000 --- a/image/benchmark-compression +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 -"""Compare xz and zstd on identical SquashFS inputs; retain only measurements. - -For a live ISO, extract its SquashFS first and pass the directory containing -LiveOS/rootfs.img. Directory workloads are also supported. Extraction timing -is a storage benchmark, not a boot or graphical installation qualification. -""" -import argparse -import hashlib -import json -from pathlib import Path -import shutil -import subprocess -import tempfile -import time - - -def identity(root): - digest = hashlib.sha256() - size = files = 0 - for path in sorted(root.rglob('*')): - relative = path.relative_to(root).as_posix() - if path.is_symlink(): - value = 'link:' + str(path.readlink()) - elif path.is_file(): - with path.open('rb') as stream: - value = hashlib.file_digest(stream, 'sha256').hexdigest() - size += path.stat().st_size - files += 1 - else: - continue - digest.update(json.dumps([relative, value], separators=(',', ':')).encode()) - return {'sha256': digest.hexdigest(), 'bytes': size, 'files': files} - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('source', type=Path) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--processors', type=int, default=8) - parser.add_argument('--runs', type=int, default=3) - args = parser.parse_args() - source, output = args.source.resolve(strict=True), args.output.absolute() - if not source.is_dir() or output.exists() or output.is_relative_to(source): - parser.error('Use an existing source directory and a new output directory outside it') - if not 1 <= args.processors <= 256 or not 1 <= args.runs <= 10: - parser.error('processors must be 1–256 and runs 1–10') - output.mkdir(parents=True, mode=0o700) - report = {'source': identity(source), 'processors': args.processors, 'measurements': [], - 'scope': 'SquashFS compression and extraction; not boot or installation time', 'status': 'running'} - try: - with tempfile.TemporaryDirectory(prefix='compression-', dir=output) as directory: - work = Path(directory) - for attempt in range(args.runs): - # Alternate order to reduce systematic warm-cache bias. - for compression in (('xz', 'zstd') if attempt % 2 == 0 else ('zstd', 'xz')): - image = work / f'{compression}.squashfs' - extracted = work / f'{compression}-root' - row = {'compression': compression, 'run': attempt + 1} - with (output / f'{compression}-{attempt + 1}.log').open('w') as log: - start = time.monotonic() - subprocess.run(['mksquashfs', str(source), str(image), '-comp', compression, - '-processors', str(args.processors), '-noappend', '-no-progress'], - stdout=log, stderr=subprocess.STDOUT, check=True) - row.update(compress_seconds=time.monotonic() - start, bytes=image.stat().st_size) - start = time.monotonic() - subprocess.run(['unsquashfs', '-no-progress', '-processors', str(args.processors), - '-d', str(extracted), str(image)], stdout=log, stderr=subprocess.STDOUT, check=True) - row['extract_seconds'] = time.monotonic() - start - if identity(extracted) != report['source']: - raise RuntimeError('Extracted data differs from the source') - shutil.rmtree(extracted) - image.unlink() - report['measurements'].append(row) - print(json.dumps(row), flush=True) - if identity(source) != report['source']: - raise RuntimeError('Source changed during the comparison') - report['status'] = 'passed' - except BaseException as error: - report.update(status='failed', error=str(error) or type(error).__name__) - raise - finally: - (output / 'measurements.json').write_text(json.dumps(report, indent=2) + '\n') - - -if __name__ == '__main__': - main() diff --git a/image/boot-theme/theme.txt b/image/boot-theme/theme.txt deleted file mode 100644 index d713acb4..00000000 --- a/image/boot-theme/theme.txt +++ /dev/null @@ -1,42 +0,0 @@ -# CybexOS firmware menu. The logo is the same asset used by Plymouth. -desktop-color: "#0d0d0d" -title-text: "" -message-color: "#e8e7df" -message-bg-color: "#0d0d0d" -terminal-font: "Unifont Regular 16" -+ image { - left = 50%-125 - top = 23% - width = 250 - height = 59 - file = "logo.png" -} -+ label { - left = 10% - top = 37% - width = 80% - height = 32 - align = "center" - color = "#d3d283" - text = "CybexOS" -} -+ boot_menu { - left = 15% - top = 48% - width = 70% - height = 32% - item_color = "#e8e7df" - selected_item_color = "#d3d283" - item_height = 30 - item_padding = 8 - item_spacing = 6 -} -+ label { - left = 10% - top = 90% - width = 80% - height = 24 - align = "center" - color = "#a7a59b" - text = "Enter to start | E to edit | Esc to go back" -} diff --git a/image/boot_branding.py b/image/boot_branding.py deleted file mode 100644 index 19fb6ff8..00000000 --- a/image/boot_branding.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Style the generated firmware menu without replacing its boot commands. - -Call after LiveImageCreator._configure_bootloader(isodir), before its EFI -filesystem is generated. The upstream subclass hook is documented in -https://github.com/livecd-tools/livecd-tools/blob/main/imgcreate/live.py. -""" -from pathlib import Path -import re -import shutil - -MARKER = "# CybexOS menu appearance" -# Kernel argument for the installation-only session; see cybexos-live-setup. -INSTALL_ARGUMENT = "cybexos.install" - - -def grub_install_entries(text): - """Offer each generated live entry twice: straight to the installer, then live. - - The first entry stays the default, so an unattended boot opens the installer. - """ - if INSTALL_ARGUMENT in text: - return text - - def pair(match): - block = match.group(0) - install = re.sub(r"(?m)^([ \t]*linux(?:efi)?[ \t].*?)[ \t]*$", rf"\1 {INSTALL_ARGUMENT}", block, count=1) - return (install.replace("menuentry 'Start CybexOS", "menuentry 'Install CybexOS", 1) - + block.replace("menuentry 'Start CybexOS", "menuentry 'Try CybexOS live", 1)) - return re.sub(r"(?ms)^[ \t]*menuentry 'Start CybexOS[^\n]*\{\n.*?^[ \t]*\}\n", pair, text) - - -def isolinux_install_entries(text): - """The BIOS menu equivalent; `menu default` moves to the installer entry.""" - if INSTALL_ARGUMENT in text: - return text - - def pair(match): - name, body = match.group(1), match.group(2) - label = re.search(r"(?m)^\s*menu label (.*)$", body).group(1) - if "Start CybexOS" not in label.replace("^", ""): - return match.group(0) - main = label.startswith("^Start CybexOS") - live_label = label.replace("Start CybexOS", "Try CybexOS live", 1) - install_label = ("^Install CybexOS" + label[len("^Start CybexOS"):] if main - else label.replace("^", "").replace("Start CybexOS", "Install CybexOS", 1)) - live = body.replace(f"menu label {label}", f"menu label {live_label}", 1) - live = re.sub(r"(?m)^[ \t]*menu default\n", "", live) - install = body.replace(f"menu label {label}", f"menu label {install_label}", 1) - install = re.sub(r"(?m)^([ \t]*append .*?)[ \t]*$", rf"\1 {INSTALL_ARGUMENT}", install, count=1) - return f"label install-{name}\n{install}label {name}\n{live}" - return re.sub(r"(?m)^label (\S+)\n((?:[ \t]+.*\n)*)", pair, text) - - -def brand_boot_menu(isodir, install_root, source=None): - isodir, install_root = Path(isodir), Path(install_root) - source = Path(source) if source else Path(__file__).with_name("boot-theme") - grub_paths = [path for relative in ("EFI/BOOT/grub.cfg", "boot/grub2/grub.cfg") - if (path := isodir / relative).is_file()] - if grub_paths: - theme = isodir / "boot/grub2/themes/cybexos" - theme.mkdir(parents=True, exist_ok=True) - shutil.copy2(source / "theme.txt", theme / "theme.txt") - shutil.copy2(install_root / "usr/share/plymouth/themes/cybex/logo.png", theme / "logo.png") - font = install_root / "usr/share/grub/unicode.pf2" - if font.is_file(): - shutil.copy2(font, theme / "unicode.pf2") - # Text colors are a fallback when firmware cannot use gfxterm. Keep - # every menuentry, kernel argument, media check and recovery entry. - appearance = "\n" + MARKER + "\n" + '''set color_normal=light-gray/black -set color_highlight=yellow/black -if loadfont /boot/grub2/themes/cybexos/unicode.pf2; then - insmod gfxterm - insmod png - set gfxmode=auto - terminal_output gfxterm - set theme=/boot/grub2/themes/cybexos/theme.txt - export theme -fi -''' - for path in grub_paths: - text = grub_install_entries(path.read_text()) - path.write_text(text if MARKER in text else text + appearance) - bios = isodir / "isolinux/isolinux.cfg" - if bios.is_file(): - text = isolinux_install_entries(bios.read_text()) - # A generic Fedora splash can be referenced by stock syslinux. Use a - # dark canvas with the same warm accent, retaining all boot entries. - text = re.sub(r"(?m)^menu background .*\n", "", text) - text = re.sub(r"(?m)^menu title .*", "menu title CybexOS", text) - colors = {"title": "#ffd3d283 #ff0d0d0d", "sel": "#ff0d0d0d #ffd3d283", - "unsel": "#ffe8e7df #ff0d0d0d", "border": "#00000000 #ff0d0d0d"} - for key, color in colors.items(): - line = f"menu color {key} 0 {color} none" - pattern = rf"(?m)^menu color {key} .*" - text = re.sub(pattern, line, text) if re.search(pattern, text) else text + "\n" + line + "\n" - bios.write_text(text) diff --git a/image/browser-smoke.cjs b/image/browser-smoke.cjs deleted file mode 100644 index 7317d8d3..00000000 --- a/image/browser-smoke.cjs +++ /dev/null @@ -1,435 +0,0 @@ -#!/usr/bin/env node -// Source-only browser test. The Cockpit transport is a fixture: no installer, -// disk, VM, ISO, or system service is contacted. See README for dependencies. -const assert = require("node:assert/strict"); -const fs = require("node:fs/promises"); -const http = require("node:http"); -const path = require("node:path"); -const { chromium } = require("playwright-core"); -const { waitForInitialSetup } = require("./real_browser_qualification.cjs"); -const root = path.join(__dirname, "live-rootfs/usr/share/cockpit/cybexos-installer"); - -const transport = ` -window.fixtureRequests = []; -window.fixturePhase = sessionStorage.getItem("fixturePhase") || "setup"; -window.cockpit = { - file() { return { watch(callback) { setTimeout(() => callback(""), 0); }, close() {} }; }, - spawn(args) { - let stream, done; - return { - stream(callback) { stream = callback; return this; }, - then(callback) { done = callback; return this; }, - input(raw) { - const data = JSON.parse(raw || "{}"), command = args[1]; - fixtureRequests.push({ command, hasPassword: !!data.password, - locale: data.locale, timezone: data.timezone, encrypted: data.encrypted, - layout: data.layout, shrink: data.shrink, oem: data.oem, seed: data.seed, - username: data.username, alongsideConfirmed: data.alongside_confirmed, - eraseConfirmed: data.erase_confirmed, source: data.source }); - const busyRemaining = Number(sessionStorage.getItem("fixtureBusyRemaining") || "0"); - if (command === "inventory" && busyRemaining > 0) { - sessionStorage.setItem("fixtureBusyRemaining", String(busyRemaining - 1)); - setTimeout(() => { - stream(JSON.stringify({ event: "result", ok: false, - error: "Another installer operation is still running." }) + "\\n"); - done(); - }, 5); - return this; - } - const initialError = command === "inventory" && sessionStorage.getItem("fixtureInitialError"); - if (initialError && initialError !== "done") { - sessionStorage.setItem("fixtureInitialError", "done"); - setTimeout(() => { - stream(JSON.stringify({ event: "result", ok: false, - error: "Anaconda inventory failed." }) + "\\n"); - done(); - }, 5); - return this; - } - let result; - if (command === "status") result = fixturePhase === "failed-install" - ? { phase: fixturePhase, message: "Installation stopped during “Running post-installation scripts”.", - failed_step: "Running post-installation scripts", log_source: "post" } - : { phase: fixturePhase, message: "Fixture progress" }; - else if (command === "log") result = { source: data.source, available: true, - text: Array.from({ length: 200 }, (_, line) => \`fixture \${data.source} log line \${line}\`).join("\\n") }; - else if (command === "log-destinations") result = { destinations: [ - { name: "sdb1", path: "/dev/sdb1", label: "STICK", fstype: "exfat", size: 32 * 1024 ** 3, mountpoint: "" }] }; - else if (command === "export-logs") result = { file: "cybexos-install-logs-fixture.tar.gz", - device: "/dev/" + data.device, label: "STICK" }; - else if (command === "inventory" || command === "rescan") result = { - disks: [{ name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", - serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", - partitions: [{ path: "/dev/vda1", size: 2147483648, filesystem: "vfat" }] }, - { name: "vdb", path: "/dev/vdb", size: 536870912000, model: "Fixture Windows SSD", - serial: "FIXTURE-WINDOWS", wwn: "", systems: ["Windows"], - partitions: [{ path: "/dev/vdb1", size: 104857600, filesystem: "vfat" }, - { path: "/dev/vdb3", size: 429496729600, filesystem: "ntfs" }] }], - keyboards: [{ id: "us", label: "English (US)" }, { id: "nl", label: "Dutch" }], - locales: ["en_US.UTF-8", "nl_NL.UTF-8"], locale: "en_US.UTF-8", - keyboard: "us", timezones: ["America/Argentina/Buenos_Aires", "Europe/Amsterdam", "UTC"], - timezone: "UTC", detected_timezone: "" - }; - else if (command === "geolocate") result = { timezone: "Europe/Amsterdam" }; - else if (command === "seed") { - const mode = sessionStorage.getItem("fixtureSeed"); - const seed = { digest: "fixture-digest", oem: false, encrypted: true, username: "lab", - full_name: "Lab Machine", hostname: "lab-1", keyboard: "us", locale: "en_US.UTF-8", - timezone: "UTC", ssh_keys: 1, ssh_server: false, tailscale: true, countdown: 10, - disk: { name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", - serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", partitions: [] } }; - result = mode === "valid" ? { found: true, valid: true, errors: [], seed } - : mode === "invalid" ? { found: true, valid: false, errors: ['Set "disk.erase: true" to confirm that the matched disk is erased.'] } - : { found: false }; - } - else if (command === "alongside") result = { - mode: "shrink", free: 10737418240, required: 68719476736, systems: ["Windows"], - partition: { id: "vdb3", path: "/dev/vdb3", size: 429496729600, min_size: 107374182400, filesystem: "ntfs" }, - floor: 112742891520, min_space: 68719476736, max_space: 327491256320, default_space: 198105366528 - }; - else if (command === "keyboard") result = { keyboard: data.keyboard, boot_keyboard: data.keyboard }; - else if (command === "plan" && data.seed) result = { - phase: "review", token: "seed-token", countdown: 10, - disk: { name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", - serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", partitions: [] }, - account: { username: "lab", encrypted: true, locale: "en_US.UTF-8", timezone: "UTC", unattended: true }, - layout: { mode: "erase" }, boot_keyboard: "us", - actions: [{ "action-description": "Create", "object-description": "encrypted Btrfs", "device-name": "vda" }], - warnings: [] - }; - else if (command === "plan" && data.layout === "alongside") result = { - phase: "review", token: "alongside-token", - disk: { name: "vdb", path: "/dev/vdb", size: 536870912000, model: "Fixture Windows SSD", - serial: "FIXTURE-WINDOWS", wwn: "", partitions: [] }, - account: { username: data.username, encrypted: data.encrypted, locale: data.locale, - timezone: data.timezone, passwordless_wheel: data.passwordless_wheel, oem: !!data.oem }, - layout: { mode: "alongside", systems: ["Windows"], space: 10737418240 + 429496729600 - data.shrink.size, - kept: ["/dev/vdb1"], - resize: { path: "/dev/vdb3", filesystem: "ntfs", from: 429496729600, to: data.shrink.size } }, - boot_keyboard: data.keyboard, - actions: [{ "action-description": "Resize device", "object-description": "partition", "device-name": "vdb3" }], - warnings: [] - }; - else if (command === "plan") result = { - phase: "review", token: "fixture-token", - disk: { name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", - serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", - partitions: [{ path: "/dev/vda1", size: 2147483648, filesystem: "vfat" }] }, - account: { username: data.username, encrypted: data.encrypted, - locale: data.locale, timezone: data.timezone, - passwordless_wheel: data.passwordless_wheel, oem: !!data.oem }, - layout: { mode: "erase" }, - boot_keyboard: data.keyboard, - actions: [{ "action-description": "Create", "object-description": "encrypted Btrfs", "device-name": "vda" }], - warnings: [] - }; - else if (command === "install" && !sessionStorage.getItem("fixtureRejected")) { - // Anaconda's plan check rejects the first confirmation; nothing starts. - sessionStorage.setItem("fixtureRejected", "1"); - setTimeout(() => { - stream(JSON.stringify({ event: "result", ok: false, - error: "The planned disk changes changed. Review the installation again." }) + "\\n"); - done(); - }, 5); - return this; - } - else if (command === "install") { - fixturePhase = "installing"; - sessionStorage.setItem("fixturePhase", fixturePhase); - result = { phase: "installing" }; - setTimeout(() => { - fixturePhase = "complete"; - sessionStorage.setItem("fixturePhase", fixturePhase); - }, 100); - } else if (command === "diagnostics") result = { - schema: 1, installer: { phase: fixturePhase, message: "Installation did not finish." }, - worker: { ActiveState: "failed", Result: "exit-code" }, backend_ready: true - }; - else result = { phase: "setup" }; - setTimeout(() => { - stream(JSON.stringify({ event: "result", ok: true, data: result }) + "\\n"); - done(); - }, 5); - return this; - } - }; -}}; -`; - -async function screenshot(page, name) { - if (!process.env.CYBEXOS_UI_SCREENSHOTS) return; - const directory = path.resolve(process.env.CYBEXOS_UI_SCREENSHOTS); - await fs.mkdir(directory, { recursive: true }); - await page.screenshot({ path: path.join(directory, `${name}.png`), animations: "disabled" }); -} - -async function main() { - const server = http.createServer(async (request, response) => { - try { - const pathname = new URL(request.url, "http://localhost").pathname; - if (pathname.endsWith("/base1/cockpit.js")) { - response.setHeader("Content-Type", "text/javascript"); - response.end(transport); - return; - } - const file = path.basename(pathname) || "index.html"; - if (!["index.html", "installer.css", "installer.js", "model.mjs", "cybex-wordmark.svg"].includes(file)) { - response.writeHead(404).end(); - return; - } - response.setHeader("Content-Type", file.endsWith(".html") ? "text/html" - : file.endsWith(".css") ? "text/css" : file.endsWith(".svg") ? "image/svg+xml" : "text/javascript"); - response.end(await fs.readFile(path.join(root, file))); - } catch { - response.writeHead(500).end("Fixture server error"); - } - }); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - let browser; - try { - browser = await chromium.launch({ - executablePath: process.env.CYBEXOS_BROWSER || "/usr/bin/brave-origin", - headless: true, - args: ["--disable-dev-shm-usage"], - }); - const busyPage = await browser.newPage(); - await busyPage.addInitScript(() => sessionStorage.setItem("fixtureBusyRemaining", "4")); - await busyPage.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await waitForInitialSetup(busyPage, 5000, [10, 20, 40, 80, 160, 320, 320]); - assert.equal(await busyPage.locator("#setup").isVisible(), true); - assert.equal(await busyPage.evaluate(() => fixtureRequests.filter(request => request.command === "inventory").length), 5); - await busyPage.close(); - - const otherErrorPage = await browser.newPage(); - await otherErrorPage.addInitScript(() => sessionStorage.setItem("fixtureInitialError", "other")); - await otherErrorPage.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await assert.rejects(waitForInitialSetup(otherErrorPage, 250)); - assert.equal(await otherErrorPage.locator("#setup").isVisible(), false); - assert.equal(await otherErrorPage.evaluate(() => fixtureRequests.filter(request => request.command === "inventory").length), 1); - await otherErrorPage.close(); - - const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - await page.emulateMedia({ reducedMotion: "reduce" }); - const errors = []; - page.on("pageerror", error => errors.push(error.message)); - await page.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await page.locator("#setup").waitFor({ state: "visible" }); - await page.waitForFunction(() => !document.querySelector("#password").disabled); - await screenshot(page, "installer-setup"); - // Capitals become the lowercase account name as they are typed. - await page.locator("#username").pressSequentially("Alice"); - assert.equal(await page.inputValue("#username"), "alice"); - assert.equal(await page.inputValue("#disk"), ""); - assert.equal(await page.isChecked("#passwordless-wheel"), false); - await page.fill("#username", "root"); - await page.fill("#password", "fixture secret 123"); - await page.fill("#confirm", "fixture secret 123"); - await page.getByRole("button", { name: "Choose install location" }).click(); - assert.match(await page.textContent("#error"), /System names are reserved/); - await page.fill("#username", "alice"); - await page.fill("#password", "fixture secret 123"); - await page.fill("#confirm", "fixture secret 123"); - await page.check("#passwordless-wheel"); - await page.selectOption("#keyboard", "nl"); - await page.waitForFunction(() => !document.querySelector("#password").disabled); - assert.equal(await page.inputValue("#password"), ""); - assert.equal(await page.inputValue("#confirm"), ""); - // Geolocation fills the dropdown in the background unless a timezone was chosen. - await page.waitForFunction(() => document.querySelector("#timezone").value === "Europe/Amsterdam"); - assert.equal(await page.textContent("#timezone-status"), "Detected from your network location."); - assert.equal(await page.locator("#timezone option[value='America/Argentina/Buenos_Aires']").textContent(), - "Argentina / Buenos Aires"); - await page.fill("#keyboard-test", "ordinary test characters"); - await page.fill("#password", "fixture secret 123"); - await page.fill("#confirm", "fixture secret 123"); - await page.locator("#account-form details > summary").click(); - await page.selectOption("#locale", "nl_NL.UTF-8"); - await page.selectOption("#timezone", "Europe/Amsterdam"); - await page.getByRole("button", { name: "Choose install location" }).click(); - await page.locator("#location").waitFor({ state: "visible" }); - assert.equal(await page.isChecked("#encrypted"), true); - await page.click("#rescan-disks"); - await page.waitForFunction(() => !document.querySelector("#rescan-disks").disabled); - assert.equal(await page.inputValue("#disk"), ""); - await screenshot(page, "installer-location"); - await page.selectOption("#disk", "vda"); - assert.match(await page.textContent("#disk-details"), /FIXTURE-SERIAL/); - assert.match(await page.textContent("#disk-details"), /\/dev\/vda1.*2\.0 GiB/); - await page.getByRole("button", { name: "Review installation" }).click(); - await page.locator("#review").waitFor({ state: "visible" }); - assert.match(await page.textContent("#summary"), /Administrator commands do not ask for a password/); - assert.match(await page.textContent("#summary"), /FIXTURE-WWN/); - assert.equal(await page.isDisabled("#install"), true); - await screenshot(page, "installer-review"); - await page.check("#erase"); - await page.click("#install"); - // A rejected confirmation returns to disk selection instead of waiting on progress. - await page.locator("#location").waitFor({ state: "visible" }); - assert.match(await page.textContent("#error"), /planned disk changes changed/); - await page.getByRole("button", { name: "Review installation" }).click(); - await page.locator("#review").waitFor({ state: "visible" }); - assert.equal(await page.isChecked("#erase"), false); - await page.check("#erase"); - await page.click("#install"); - await page.getByRole("heading", { name: "Your workspace is ready." }).waitFor({ timeout: 10000 }); - assert.equal(await page.inputValue("#password"), ""); - assert.equal(await page.inputValue("#confirm"), ""); - assert.equal(await page.evaluate(() => fixtureRequests.filter(request => request.command === "install").length), 2); - assert.equal(await page.evaluate(() => fixtureRequests.find(request => request.command === "plan").timezone), "Europe/Amsterdam"); - assert.equal(await page.evaluate(() => fixtureRequests.find(request => request.command === "plan").locale), "nl_NL.UTF-8"); - await page.reload(); - await page.getByRole("heading", { name: "Your workspace is ready." }).waitFor(); - await page.evaluate(() => sessionStorage.setItem("fixturePhase", "failed-install")); - await page.reload(); - await page.getByRole("heading", { name: "Installation needs attention." }).waitFor(); - assert.equal(await page.isVisible("#failure-help"), true); - const downloadPromise = page.waitForEvent("download"); - await page.click("#save-diagnostics"); - const download = await downloadPromise; - assert.equal(download.suggestedFilename(), "cybexos-installer-diagnostics.json"); - assert.equal(await page.evaluate(() => fixtureRequests.some(request => request.command === "diagnostics")), true); - await page.setViewportSize({ width: 390, height: 844 }); - await screenshot(page, "installer-narrow"); - assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true); - await page.setViewportSize({ width: 1280, height: 900 }); - // The details panel follows a chosen log at any stage. - if (!await page.locator("#log-panel").evaluate(panel => panel.open)) - await page.locator("#log-panel summary").click(); - await page.selectOption("#log-source", "steps"); - await page.waitForFunction(() => document.querySelector("#log").textContent.includes("fixture steps log line 199")); - await page.selectOption("#log-source", "storage"); - await page.waitForFunction(() => document.querySelector("#log").textContent.includes("fixture storage log line")); - // A failed installation names its step and opens the explaining log. - await page.evaluate(() => sessionStorage.setItem("fixturePhase", "failed-install")); - await page.reload(); - await page.getByRole("heading", { name: "Installation needs attention." }).waitFor(); - assert.match(await page.textContent("#progress-message"), /Running post-installation scripts/); - await page.waitForFunction(() => document.querySelector("#log-panel").open - && document.querySelector("#log").textContent.includes("fixture post log line 199")); - assert.equal(await page.inputValue("#log-source"), "post"); - await page.click("#find-destinations"); - await page.waitForFunction(() => document.querySelector("#log-destination").value === "sdb1"); - await page.click("#export-logs"); - await page.waitForFunction(() => document.querySelector("#export-status").textContent.startsWith("Saved")); - assert.equal(await page.textContent("#export-status"), - "Saved cybexos-install-logs-fixture.tar.gz to STICK. You can remove the drive."); - await screenshot(page, "installer-failed-details"); - const plain = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - plain.on("pageerror", error => errors.push(error.message)); - await plain.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await plain.locator("#setup").waitFor({ state: "visible" }); - await plain.waitForFunction(() => !document.querySelector("#password").disabled); - await plain.fill("#username", "qualification"); - await plain.fill("#password", "fixture secret 123"); - await plain.fill("#confirm", "fixture secret 123"); - await plain.locator("#account-form details > summary").click(); - await plain.selectOption("#locale", "en_US.UTF-8"); - await plain.selectOption("#timezone", "UTC"); - await plain.getByRole("button", { name: "Choose install location" }).click(); - await plain.locator("#location").waitFor({ state: "visible" }); - await plain.selectOption("#disk", "vda"); - await plain.locator("#disk-form details > summary").click(); - await plain.uncheck("#encrypted"); - await plain.getByRole("button", { name: "Review installation" }).click(); - await plain.locator("#review").waitFor({ state: "visible" }); - assert.match(await plain.textContent("#summary"), /unencrypted/); - assert.equal(await plain.evaluate(() => fixtureRequests.find(request => request.command === "plan").encrypted), false); - await plain.close(); - const alongside = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - alongside.on("pageerror", error => errors.push(error.message)); - await alongside.addInitScript(() => sessionStorage.setItem("fixtureRejected", "1")); - await alongside.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await alongside.locator("#setup").waitFor({ state: "visible" }); - await alongside.waitForFunction(() => !document.querySelector("#password").disabled); - await alongside.fill("#username", "alice"); - await alongside.fill("#password", "fixture secret 123"); - await alongside.fill("#confirm", "fixture secret 123"); - await alongside.getByRole("button", { name: "Choose install location" }).click(); - await alongside.locator("#location").waitFor({ state: "visible" }); - await alongside.selectOption("#disk", "vdb"); - await alongside.locator("#space-field").waitFor({ state: "visible" }); - assert.equal(await alongside.isChecked("#layout-alongside"), true); - assert.match(await alongside.textContent("#layout-legend"), /already has Windows/); - assert.match(await alongside.textContent("#alongside-details"), /\/dev\/vdb3 is shrunk from 400\.0 GiB/); - await alongside.evaluate(() => { - const range = document.querySelector("#space"); - range.value = String(100 * 1024 ** 3); - range.dispatchEvent(new Event("input")); - }); - assert.match(await alongside.textContent("#space-label"), /100\.0 GiB/); - await screenshot(alongside, "installer-alongside"); - await alongside.getByRole("button", { name: "Review installation" }).click(); - await alongside.locator("#review").waitFor({ state: "visible" }); - const alongsidePlan = await alongside.evaluate(() => fixtureRequests.find(request => request.command === "plan")); - assert.equal(alongsidePlan.layout, "alongside"); - assert.deepEqual(alongsidePlan.shrink, { device: "vdb3", size: 429496729600 - (100 * 1024 ** 3 - 10737418240) }); - assert.match(await alongside.textContent("#summary"), /Resized\/dev\/vdb3 \(ntfs\) from 400\.0 GiB to 310\.0 GiB/); - assert.match(await alongside.textContent("#erase-label"), /\/dev\/vdb3 will be resized/); - assert.equal(await alongside.textContent("#install"), "Resize and install"); - await alongside.check("#erase"); - await alongside.click("#install"); - await alongside.locator("#progress").waitFor({ state: "visible" }); - const commit = await alongside.evaluate(() => fixtureRequests.find(request => request.command === "install")); - assert.equal(commit.alongsideConfirmed, true); - assert.equal(commit.eraseConfirmed, undefined); - await alongside.close(); - - const owner = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - owner.on("pageerror", error => errors.push(error.message)); - await owner.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await owner.locator("#setup").waitFor({ state: "visible" }); - await owner.waitForFunction(() => !document.querySelector("#password").disabled); - await owner.locator("#account-form details > summary").click(); - await owner.check("#oem"); - assert.equal(await owner.isVisible("#username"), false); - assert.equal(await owner.textContent("#password-label"), "Temporary disk passphrase"); - await owner.fill("#password", "handover phrase 123"); - await owner.fill("#confirm", "handover phrase 123"); - await owner.getByRole("button", { name: "Choose install location" }).click(); - await owner.locator("#location").waitFor({ state: "visible" }); - await owner.selectOption("#disk", "vda"); - await owner.getByRole("button", { name: "Review installation" }).click(); - await owner.locator("#review").waitFor({ state: "visible" }); - assert.match(await owner.textContent("#summary"), /Created by the new owner at first start/); - assert.match(await owner.textContent("#summary"), /Temporary passphrase → new-owner setup/); - assert.equal(await owner.evaluate(() => fixtureRequests.find(request => request.command === "plan").oem), true); - await owner.close(); - - const invalidSeed = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - invalidSeed.on("pageerror", error => errors.push(error.message)); - await invalidSeed.addInitScript(() => sessionStorage.setItem("fixtureSeed", "invalid")); - await invalidSeed.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await invalidSeed.locator("#unattended").waitFor({ state: "visible" }); - assert.match(await invalidSeed.textContent("#seed-errors"), /disk\.erase: true/); - assert.equal(await invalidSeed.evaluate(() => fixtureRequests.some(request => request.command === "plan")), false); - await screenshot(invalidSeed, "installer-seed-invalid"); - await invalidSeed.click("#seed-guided"); - await invalidSeed.locator("#setup").waitFor({ state: "visible" }); - await invalidSeed.close(); - - const seed = await browser.newPage({ viewport: { width: 1280, height: 900 } }); - seed.on("pageerror", error => errors.push(error.message)); - await seed.addInitScript(() => sessionStorage.setItem("fixtureSeed", "valid")); - await seed.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); - await seed.locator("#unattended").waitFor({ state: "visible" }); - await seed.waitForFunction(() => /Installing in 0:\d\d/.test(document.querySelector("#countdown").textContent)); - assert.match(await seed.textContent("#seed-summary"), /lab \(Lab Machine\)/); - assert.match(await seed.textContent("#seed-summary"), /joins Tailscale at first start/); - assert.equal(await seed.evaluate(() => fixtureRequests.find(request => request.command === "plan").seed), "fixture-digest"); - await screenshot(seed, "installer-seed-countdown"); - await seed.click("#seed-cancel"); - await seed.waitForFunction(() => document.querySelector("#unattended-title").textContent === "Unattended installation cancelled."); - assert.equal(await seed.evaluate(() => fixtureRequests.find(request => request.command === "cancel-seed").seed), "fixture-digest"); - await seed.waitForTimeout(11000); - assert.equal(await seed.evaluate(() => fixtureRequests.some(request => request.command === "install")), false); - await seed.close(); - - assert.deepEqual(errors, []); - console.log("PASS: three-screen flow, lowercase username, log panel and export, keyboard/locale/timezone choices, encrypted and plain disk paths, rejected-install recovery, erase confirmation, password clearing, progress/reload recovery, narrow layout, install alongside with a resize, new-owner mode, seed countdown, cancel and seed errors"); - } finally { - if (browser) await browser.close(); - await new Promise(resolve => server.close(resolve)); - } -} - -main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/image/browser_qualification.py b/image/browser_qualification.py deleted file mode 100644 index 736279bf..00000000 --- a/image/browser_qualification.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Connect a host browser to the guest's actual loopback Cockpit instance.""" -import json -import os -from pathlib import Path -import shutil -import socket -import subprocess -import time -from urllib.parse import urlsplit - -from vm_testing import free_port, run - -DRIVER = Path(__file__).with_name('real_browser_qualification.cjs') -DISCOVER = r''' -from pathlib import Path -for proc in Path('/proc').iterdir(): - if not proc.name.isdigit(): - continue - try: - args = proc.joinpath('cmdline').read_bytes().split(b'\0') - except (PermissionError, FileNotFoundError, ProcessLookupError): - continue - if not any(arg.endswith(b'/cybexos-installer-browser') for arg in args): - continue - for arg in args: - if arg.startswith(b'http://127.0.0.1') or arg.startswith(b'http://localhost'): - print(arg.decode('ascii')) - raise SystemExit(0) -raise SystemExit(1) -''' -INSTALLER_ACTIVE = r''' -from pathlib import Path -for proc in Path('/proc').iterdir(): - if not proc.name.isdigit(): - continue - try: - args = proc.joinpath('cmdline').read_bytes().split(b'\0') - except (PermissionError, FileNotFoundError, ProcessLookupError): - continue - if any(arg.endswith((b'/liveinst', b'/cybexos-installer-browser')) for arg in args): - raise SystemExit(0) -raise SystemExit(1) -''' - - -def ensure_installer_started(vm): - """Older ISO defaults open the desktop; newer defaults start the installer.""" - result = subprocess.run([*vm.ssh, 'python3 -'], input=INSTALLER_ACTIVE, - text=True, capture_output=True, timeout=10) - if result.returncode == 1: - run([*vm.ssh, 'XDG_RUNTIME_DIR=/run/user/1000 systemd-run --user --collect ' - '--unit=cybexos-qualification-anaconda /usr/bin/liveinst --nosave=all_ks'], timeout=30) - elif result.returncode: - raise RuntimeError('Could not inspect guest installer startup') - - -def validate_guest_url(value): - parsed = urlsplit(value) - if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost') - or parsed.username or parsed.password or parsed.query or parsed.fragment - or parsed.path not in ('/cockpit/@localhost/cybexos-installer/index.html', - '/cockpit/@localhost/anaconda-webui/index.html')): - raise ValueError('Guest installer URL was not the expected loopback Cockpit page') - port = parsed.port or 80 - if not 1 <= port <= 65535: - raise ValueError('Guest installer port is invalid') - return port - - -def discover_guest_port(vm, timeout=90): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - result = subprocess.run([*vm.ssh, 'python3 -'], input=DISCOVER, text=True, - capture_output=True, timeout=10) - if result.returncode == 0: - urls = [line for line in result.stdout.splitlines() if line.strip()] - if len(urls) != 1: - raise RuntimeError('Guest installer URL discovery was ambiguous') - return validate_guest_url(urls[0]) - vm.alive() - time.sleep(2) - raise RuntimeError('Guest installer browser URL was not discovered') - - -def wait_tunnel(port, process, timeout=20): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if process.poll() is not None: - raise RuntimeError('Installer Cockpit tunnel exited unexpectedly') - try: - with socket.create_connection(('127.0.0.1', port), timeout=1): - return - except OSError: - time.sleep(0.2) - raise RuntimeError('Installer Cockpit tunnel did not open') - - -def browser_dependencies(): - requested = os.environ.get('CYBEXOS_BROWSER') - browser = (shutil.which(requested) or requested) if requested else next((shutil.which(name) for name in - ('brave-origin', 'chromium', 'chromium-browser', 'google-chrome') if shutil.which(name)), None) - if not browser or not os.access(browser, os.X_OK): - raise RuntimeError('Install a Chromium browser or set CYBEXOS_BROWSER for graphical qualification') - check = subprocess.run(['node', '-e', - 'if(Number(process.versions.node.split(".")[0])<20) process.exit(1); require("playwright-core");'], - capture_output=True, text=True, timeout=10) - if check.returncode: - raise RuntimeError('Graphical qualification requires Node.js >=20 and playwright-core (see image/README.md)') - return browser - - -def qualify_browser(vm, *, password, target_disk, unused_disk, encrypted, keyboard, locale, - timezone, install_timeout, require_policy_controls=True): - browser = browser_dependencies() - remote_port = discover_guest_port(vm) - local_port = free_port() - tunnel = subprocess.Popen([*vm.ssh[:-1], '-N', '-L', - f'127.0.0.1:{local_port}:127.0.0.1:{remote_port}', vm.ssh[-1]], - stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - try: - wait_tunnel(local_port, tunnel) - payload = {'url': f'http://127.0.0.1:{local_port}/cockpit/@localhost/cybexos-installer/index.html', - 'browser': browser, 'password': password, 'target_disk': target_disk, - 'target_serial': 'CYBEXOS-QUALIFY', 'unused_disk': unused_disk, - 'encrypted': encrypted, 'keyboard': keyboard, 'locale': locale, - 'timezone': timezone, 'install_timeout_ms': install_timeout * 1000, - 'require_policy_controls': require_policy_controls} - try: - result = run(['node', str(DRIVER)], input=json.dumps(payload), text=True, - capture_output=True, timeout=install_timeout + 240) - except subprocess.CalledProcessError as error: - # Playwright prints the failed assertion/locator to stderr. Keep - # the useful part without ever including the fixture password. - detail = ((error.stderr or '') + '\n' + (error.stdout or '')).replace(password, '[redacted]') - raise RuntimeError(f'Installer browser driver failed: {detail[-5000:]}') from error - data = json.loads(result.stdout) - if data.get('check') != 'graphical-installer' or data.get('selected_disk') != target_disk: - raise RuntimeError('Browser driver did not confirm the selected disposable disk') - return data - finally: - tunnel.terminate() - try: - tunnel.wait(timeout=5) - except subprocess.TimeoutExpired: - tunnel.kill() - tunnel.wait() diff --git a/image/build b/image/build deleted file mode 100755 index ca6ebaf0..00000000 --- a/image/build +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env python3 -"""Build an ISO in a disposable Fedora VM; --preflight performs no build.""" -import argparse -from datetime import datetime, timezone -import json -import os -from pathlib import Path -import shutil -import signal -import socket -import subprocess -import sys -import tempfile -import time - -from build_support import (atomic_json, build_id, builder_cloud_config, create_seed, deliver_artifacts, - digest, phase, preflight, source_provenance, validate_qemu_path, - wait_for_builder_initialization) -from source_snapshot import archive_identity, tree_identity, write_archive - -ROOT = Path(__file__).resolve().parents[1] -IMAGE = "Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2" -IMAGE_URL = "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/" + IMAGE -IMAGE_SHA256 = "28680fe5b371a5a82ebf43a31926e086a168e59949d03969c5093e7071f90b7f" - - -def run(args, **kwargs): - return subprocess.run(args, check=True, **kwargs) - - -def source_archive(destination, additions=None): - write_archive(ROOT, destination, additions) - - -def stop(process): - if process is not None and process.poll() is None: - process.terminate() - try: - process.wait(timeout=20) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", type=Path, help="New task-specific output directory (default: images/BUILD-ID)") - parser.add_argument("--cache", type=Path, default=Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) / "cybexos/image") - parser.add_argument("--memory", type=int, default=24576, help="Builder VM RAM in MiB") - parser.add_argument("--cpus", type=int, default=min(8, os.cpu_count() or 1)) - parser.add_argument("--compression", "--compress", choices=("xz", "zstd"), default="xz", help="xz is the previously validated default; zstd requires new qualification") - parser.add_argument("--preflight", action="store_true", help="Only validate host dependencies/resources; no downloads, files or VM") - parser.add_argument("--update-channel", type=Path, help="Reviewed public RPM update channel JSON; key is validated before transfer") - parser.add_argument("--debug-on-failure", action="store_true", help="Keep a failed builder available until interrupted") - args = parser.parse_args() - identifier = build_id() - output = (args.output or Path.home() / ".local/share/cybexos/images" / identifier).expanduser().resolve() - cache = args.cache.expanduser().resolve() - validate_qemu_path(output) - validate_qemu_path(cache) - if output.exists() and any(output.iterdir()): - parser.error("output must be empty; use a new directory") - if output == Path("/data/pxe/iso") or Path("/data/pxe/iso") in output.parents: - parser.error("build staging must be outside the served ISO tree; publish completed artifacts separately") - try: - from download_cache import cached_download, merge_cache, pins, verified_entries - except ImportError as error: - parser.error(f"{error}; install python3-pyyaml (Fedora) or python3-yaml (Debian)") - resources = preflight(output, cache, args.memory, args.cpus) - if args.update_channel and not shutil.which("gpg"): - parser.error("--update-channel requires gpg to validate the public signing key") - if args.preflight: - print(json.dumps(resources, indent=2)) - return - output.mkdir(parents=True, exist_ok=True, mode=0o700) - output.chmod(0o700) - cache.mkdir(parents=True, exist_ok=True) - phases = [] - provenance = {"build_id": identifier, "utc": datetime.now(timezone.utc).isoformat(), - "compression": args.compression, "resources": resources, "status": "running", "phases": phases} - status_path = output / "build.json" - atomic_json(status_path, provenance) - vm = build_process = None - contract = pins(ROOT) - try: - with tempfile.TemporaryDirectory(prefix=identifier + "-", dir=cache) as temporary: - work = Path(temporary) - additions = {} - if args.update_channel: - from release_metadata import stage_update_channel - with phase("Validate public update channel", phases): - additions.update(stage_update_channel(args.update_channel, work / "channel")) - with phase("Verify cached Fedora builder", phases): - base = cached_download(IMAGE_URL, IMAGE_SHA256, cache / "base") - with phase("Record source and prepare isolated VM", phases): - provenance.update(source_revision=subprocess.check_output(["git", "-C", str(ROOT), "rev-parse", "HEAD"], text=True).strip(), - source_dirty=bool(subprocess.check_output(["git", "-C", str(ROOT), "status", "--porcelain"], text=True).strip()), - source_epoch=int(subprocess.check_output(["git", "-C", str(ROOT), "show", "-s", "--format=%ct", "HEAD"], text=True))) - installed = {key: provenance[key] for key in ("utc", "build_id", "source_revision", "source_dirty", "source_epoch")} - installed.update(tree_identity(ROOT)) - atomic_json(work / "build-provenance.json", installed) - additions["image/build-provenance.json"] = work / "build-provenance.json" - source_archive(work / "source.tar.gz", additions) - if archive_identity(work / "source.tar.gz") != {key: installed[key] for key in ('source_content_sha256', 'source_file_count')}: - raise RuntimeError('Source changed while preparing the build archive; retry from a stable checkout') - provenance["source"] = source_provenance(ROOT, work / "source.tar.gz") - # -no-user-config: host /etc/qemu may be unreadable and must not shape the builder. - provenance["tools"] = {command[0]: subprocess.check_output([*command, "--version"], text=True).splitlines()[0] - for command in (["qemu-system-x86_64", "-no-user-config"], ["qemu-img"])} - provenance["tools"]["python"] = sys.version - atomic_json(status_path, provenance) - key = work / "id_ed25519" - run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)]) - (work / "user-data").write_text("#cloud-config\n" + json.dumps( - builder_cloud_config(key.with_suffix(".pub").read_text().strip()))) - (work / "meta-data").write_text(f"instance-id: {identifier}\n") - create_seed(work, resources["seed_tool"]) - run(["qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", "-b", str(base), str(work / "system.qcow2"), "220G"]) - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - port = sock.getsockname()[1] - ssh_options = ["-i", str(key), "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=10", "-o", "StrictHostKeyChecking=accept-new", "-o", f"UserKnownHostsFile={work / 'known_hosts'}"] - ssh = ["ssh", *ssh_options, "-p", str(port), "builder@127.0.0.1"] - atomic_json(output / "builder.json", {"ssh": ssh, "work": str(work)}) - with (output / "builder-console.log").open("w") as console: - vm = subprocess.Popen([ - "qemu-system-x86_64", "-no-user-config", "-name", "cybexos-image-builder", "-machine", "q35,accel=kvm", "-cpu", "host", "-smp", str(args.cpus), "-m", str(args.memory), - "-drive", f"file={work / 'system.qcow2'},if=virtio,format=qcow2,werror=report,rerror=report", "-drive", f"file={work / 'seed.iso'},if=virtio,format=raw,readonly=on", - "-netdev", f"user,id=net,hostfwd=tcp:127.0.0.1:{port}-:22", "-device", "virtio-net-pci,netdev=net", "-display", "none", "-serial", "stdio", "-monitor", "none"], stdout=console, stderr=subprocess.STDOUT) - try: - with phase("Wait for builder and restore verified downloads", phases): - deadline = time.monotonic() + 600 - while time.monotonic() < deadline: - if vm.poll() is not None: - raise RuntimeError("Builder exited; see builder-console.log") - if subprocess.run([*ssh, "true"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0: - break - time.sleep(2) - else: - raise RuntimeError("Builder SSH readiness timed out") - wait_for_builder_initialization(ssh, output) - with (work / "source.tar.gz").open("rb") as archive: - run([*ssh, "tar xzf - -C /home/builder/source"], stdin=archive) - cached = verified_entries(cache / "downloads", contract) - cache_stage = work / "cache-import" - cache_stage.mkdir() - for entry in cached: - shutil.copyfile(cache / "downloads" / entry["sha256"], cache_stage / entry["sha256"]) - atomic_json(cache_stage / "manifest.json", {"entries": cached}) - run(["scp", *ssh_options, "-P", str(port), "-r", str(cache_stage), "builder@127.0.0.1:/home/builder/"]) - provenance["download_cache_hits"] = len(cached) - with phase("Build applications, desktop RPM and compressed image", phases): - with (output / "build.log").open("w") as log: - build_process = subprocess.Popen([*ssh, f"sudo env CYBEXOS_IMAGE_COMPRESSION={args.compression} bash /home/builder/source/image/build-in-guest"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - for line in build_process.stdout: - print(line, end="", flush=True) - log.write(line) - log.flush() - if build_process.wait(): - raise RuntimeError("Guest build failed; see build.log for the failing phase") - with phase("Save verified download cache", phases): - run(["scp", *ssh_options, "-P", str(port), "-r", "builder@127.0.0.1:/home/builder/cache-export", str(work)]) - merge_cache(work / "cache-export", cache / "downloads", contract) - with phase("Verify and atomically deliver completed artifacts", phases): - with tempfile.TemporaryDirectory(prefix=".delivery-", dir=output) as staging: - run(["scp", *ssh_options, "-P", str(port), "-r", "builder@127.0.0.1:/home/builder/artifacts/.", staging]) - from build_support import checksum_entries - verified = checksum_entries(staging) - shutil.copyfile(work / "source.tar.gz", Path(staging) / "source.tar.gz") - from fedora_target import fedora_release - # image/compose names the ISO after inventory fedora_release. - name = f"CybexOS-Live-{fedora_release()}" - original = Path(staging) / f"{name}.iso" - original.rename(Path(staging) / f"{name}-{identifier}.iso") - # The SPDX SBOM names the final ISO and reuses the - # guest-verified digests instead of rehashing the image. - from sbom import write_build_sbom - write_build_sbom(staging, f"{name}-{identifier}.iso", - {**verified, f"{name}-{identifier}.iso": verified[original.name]}, - work / "cache-export/manifest.json") - files = sorted(path for path in Path(staging).iterdir() if path.name != "SHA256SUMS") - (Path(staging) / "SHA256SUMS").write_text("".join(f"{digest(path)} {path.name}\n" for path in files)) - provenance["artifacts"] = deliver_artifacts(staging, output / "artifacts") - except BaseException as error: - if not isinstance(error, (KeyboardInterrupt, SystemExit)) and args.debug_on_failure and vm.poll() is None: - print(f"Failed builder retained for inspection: {output / 'builder.json'}. Interrupt again to clean up.", flush=True) - while vm.poll() is None: - time.sleep(1) - raise - finally: - stop(build_process) - stop(vm) - provenance["status"] = "complete" - print(f"Build complete: {output / 'artifacts'}\nNo boot qualification or PXE publication was performed.") - except BaseException as error: - provenance["status"] = "failed" - provenance["error"] = str(error) or type(error).__name__ - raise - finally: - stop(build_process) - stop(vm) - (output / "builder.json").unlink(missing_ok=True) - atomic_json(status_path, provenance) - - -if __name__ == "__main__": - def interrupted(number, _frame): - raise KeyboardInterrupt(number) - for name in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): - signal.signal(name, interrupted) - try: - main() - except KeyboardInterrupt as error: - number = error.args[0] if error.args else signal.SIGINT - print(f"image/build: interrupted by {signal.Signals(number).name}", file=sys.stderr) - sys.exit(128 + number) - except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: - print(f"image/build: {error}", file=sys.stderr) - sys.exit(1) diff --git a/image/build-in-guest b/image/build-in-guest deleted file mode 100755 index 1d8e4b9d..00000000 --- a/image/build-in-guest +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -# Called only inside the disposable builder created by image/build. -set -euo pipefail -[[ $(systemd-detect-virt) == kvm || $(systemd-detect-virt) == qemu ]] || { - echo 'Refusing to build outside the disposable QEMU VM.' >&2 - exit 2 -} -[[ $(hostname) == image-builder && $EUID == 0 ]] || exit 2 -source_root=/home/builder/source -build_root=/home/builder/build -artifacts=/home/builder/artifacts -mkdir -p "$build_root" "$artifacts" -phase_name=prerequisites -phase_started=$SECONDS -phase() { - printf '[guest] %s completed in %ss\n' "$phase_name" "$((SECONDS - phase_started))" - phase_name=$1 - phase_started=$SECONDS - printf '[guest] Starting %s\n' "$phase_name" -} -trap 'printf "[guest] FAILED: %s after %ss (line %s)\n" "$phase_name" "$((SECONDS - phase_started))" "$LINENO" >&2' ERR -printf '[guest] Starting %s\n' "$phase_name" -dnf install -y livecd-tools rpm-build createrepo_c python3-jinja2 python3-pyyaml \ - curl cpio policycoreutils pykickstart ansible-core -# Builder SELinux is permissive; live and installed systems enforce it. -setenforce 0 -phase verified-download-cache -python3 "$source_root/image/download_cache.py" import /home/builder/cache-import -if ! echo "581bd602d10705eb89003680335ffc3684b922c587073b6e52c371f949843fd7 $build_root/hyprland.gpg" | sha256sum --check --status; then - curl --fail --location --retry 3 --output "$build_root/hyprland.gpg" \ - https://download.copr.fedorainfracloud.org/results/sdegler/hyprland/pubkey.gpg -fi -echo "581bd602d10705eb89003680335ffc3684b922c587073b6e52c371f949843fd7 $build_root/hyprland.gpg" | sha256sum --check -phase offline-applications -python3 "$source_root/image/applications" -phase native-titlebars -python3 "$source_root/image/titlebars" -phase native-notification-images -python3 "$source_root/image/notification_images.py" -phase desktop-payload -python3 "$source_root/image/package" --output "$build_root" -phase desktop-rpm -rpmbuild -bb --define "_topdir $build_root/rpmbuild" "$build_root/desktop.spec" -mkdir -p "$build_root/repo" -cp "$build_root"/rpmbuild/RPMS/*/*.rpm "$build_root/repo/" -createrepo_c "$build_root/repo" -fedora_release=$(python3 "$source_root/image/fedora_target.py") -ksvalidator --version "F$fedora_release" "$build_root/live.ks" -mkdir -p "$build_root/tmp" -phase compose-and-compress -cd "$artifacts" -python3 "$source_root/image/compose" -phase manifests-and-cache -cp "$build_root/repo/"*.rpm "$artifacts/" -cp "$build_root/package-manifest.json" "$build_root/applications.json" "$artifacts/" -# Export the actual compiler images and resolved RPM versions independently of -# internal Podman/Cargo caches. applications.json also installs this evidence. -python3 - "$build_root/applications.json" "$artifacts/source-build-provenance.json" <<'PYTHON' -import json -from pathlib import Path -import sys -record = json.loads(Path(sys.argv[1]).read_text()) -Path(sys.argv[2]).write_text(json.dumps(record["source_builds"], indent=2) + "\n") -PYTHON -cp "$build_root/offline-payload.json" "$artifacts/" -cp "$build_root/titlebars.json" "$artifacts/" -cp "$build_root/notification-images.json" "$artifacts/" -cp "$source_root/image/build-provenance.json" "$artifacts/" -rpm -qa --qf '%{NEVRA}\n' | sort > "$artifacts/builder-packages.txt" -python3 "$source_root/image/download_cache.py" export /home/builder/cache-export -chown -R builder:builder /home/builder/cache-export -# Cover every delivered manifest as well as executable artifacts. -python3 - <<'PY' -from pathlib import Path -import hashlib -root = Path('/home/builder/artifacts') -with (root / 'SHA256SUMS').open('w') as output: - for path in sorted(root.iterdir()): - if path.name == 'SHA256SUMS': - continue - if not path.is_file() or path.is_symlink(): - raise RuntimeError(f'Unexpected artifact: {path.name}') - with path.open('rb') as stream: - checksum = hashlib.file_digest(stream, 'sha256').hexdigest() - output.write(f'{checksum} {path.name}\n') -PY -chown -R builder:builder "$artifacts" -phase complete diff --git a/image/build.repo b/image/build.repo deleted file mode 100644 index 5a263e88..00000000 --- a/image/build.repo +++ /dev/null @@ -1,36 +0,0 @@ -# The image compose adapter (image/compose) sets $releasever from inventory -# fedora_release in metalink and gpgkey values; livecd-tools substitutes it in -# baseurl. A release literal here would bypass that single source. -[fedora] -name=Fedora $releasever -metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever&arch=x86_64 -enabled=1 -gpgcheck=1 -gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-x86_64 - -[updates] -name=Fedora $releasever updates -metalink=https://mirrors.fedoraproject.org/metalink?repo=updates-released-f$releasever&arch=x86_64 -enabled=1 -gpgcheck=1 -gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-x86_64 - -[fedora-cisco-openh264] -name=Fedora $releasever openh264 (From Cisco) -metalink=https://mirrors.fedoraproject.org/metalink?repo=fedora-cisco-openh264-$releasever&arch=x86_64 -enabled=1 -gpgcheck=1 -gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-x86_64 - -[hyprland] -name=Hyprland COPR -baseurl=https://download.copr.fedorainfracloud.org/results/sdegler/hyprland/fedora-$releasever-x86_64/ -enabled=1 -gpgcheck=1 -gpgkey=file:///home/builder/build/hyprland.gpg - -[cybexos-local] -name=Locally built private alpha desktop -baseurl=file:///home/builder/build/repo -enabled=1 -gpgcheck=0 diff --git a/image/build_support.py b/image/build_support.py deleted file mode 100644 index dcff3cbb..00000000 --- a/image/build_support.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Host-side image tooling. Importing this module never builds or boots an image.""" -from contextlib import contextmanager -import ctypes -from datetime import datetime, timezone -import fnmatch -import hashlib -import json -import os -from pathlib import Path -import re -import shutil -import subprocess -import tempfile -import time -import uuid - - -SAFE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+~^-]*\Z") - - -def builder_cloud_config(public_key): - """Configure the disposable builder without an init-local hostname race.""" - return { - "users": [{"name": "builder", "groups": ["wheel"], "shell": "/bin/bash", - "sudo": "ALL=(ALL) NOPASSWD:ALL", "ssh_authorized_keys": [public_key]}], - "ssh_pwauth": False, - "disable_root": True, - # Fedora's hostnamectl requires services unavailable in init-local. - # Cloud-init records that early attempt as a degraded boot even when - # its later retry succeeds. Set it once during the final stage. - "preserve_hostname": True, - # QEMU user networking forwards IPv6 poorly: a mirror reached over it - # can stop sending mid-download without closing the connection, and - # dnf then waits indefinitely (seen twice in one build, 2026-10-14). - # The builder needs no IPv6, so it never starts using it. bootcmd runs - # before cloud-init's later stages and before any package transfer; - # source-build containers share the builder's network namespace. - "bootcmd": [["sysctl", "-q", "-w", "net.ipv6.conf.all.disable_ipv6=1", - "net.ipv6.conf.default.disable_ipv6=1"]], - "runcmd": [["hostnamectl", "set-hostname", "image-builder"]], - } - - -def wait_for_builder_initialization(ssh, output): - """Keep cloud-init failures strict and preserve their diagnosis on cleanup.""" - output = Path(output) - result = subprocess.run([*ssh, "cloud-init status --wait --format=json"], - capture_output=True, text=True, timeout=300, check=False) - status = output / "builder-cloud-init-status.log" - status.write_text(result.stdout + result.stderr) - if result.returncode: - # Status 2 means degraded, not successful. Do not waive arbitrary - # warnings; keep the useful guest evidence before deleting its disk. - with (output / "builder-cloud-init.log").open("w") as stream: - try: - subprocess.run([*ssh, "sudo -n tail -n 400 /var/log/cloud-init.log"], - stdout=stream, stderr=subprocess.STDOUT, timeout=30, - check=False) - except (OSError, subprocess.TimeoutExpired) as error: - stream.write(f"Could not collect guest cloud-init log: {error}\n") - raise RuntimeError(f"Builder cloud-init exited {result.returncode}; see {status.name} and builder-cloud-init.log") - subprocess.run([*ssh, 'test "$(hostname)" = image-builder && sudo -n true && mkdir -p /home/builder/source'], - check=True, timeout=30) - - -def digest(path): - with Path(path).open("rb") as stream: - return hashlib.file_digest(stream, "sha256").hexdigest() - - -def verify_sidecar(path): - """Verify one exact filename against its adjacent checksum file.""" - path = Path(path) - sidecar = path.with_name(path.name + ".sha256") - if sidecar.is_symlink() or not sidecar.is_file(): - raise ValueError("Artifact needs its adjacent regular .sha256 file") - match = re.fullmatch(r"([0-9a-fA-F]{64}) [ *]" + re.escape(path.name) + r"\n?", sidecar.read_text()) - if not match or digest(path) != match.group(1).lower(): - raise ValueError("Artifact checksum does not match its adjacent .sha256 file") - return match.group(1).lower() - - -def build_id(): - return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8] - - -def atomic_json(path, value): - path = Path(path) - with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, prefix=".json-", delete=False) as stream: - temporary = Path(stream.name) - try: - json.dump(value, stream, indent=2, sort_keys=True) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - except BaseException: - temporary.unlink(missing_ok=True) - raise - try: - temporary.replace(path) - finally: - temporary.unlink(missing_ok=True) - - -def checksum_entries(directory): - """Validate a flat artifact set. Reject traversal, duplicates and symlinks.""" - directory = Path(directory) - entries = {} - manifest = directory / "SHA256SUMS" - if manifest.is_symlink() or not manifest.is_file(): - raise ValueError("SHA256SUMS must be a regular file, never a symlink") - for line in manifest.read_text().splitlines(): - match = re.fullmatch(r"([0-9a-fA-F]{64}) [ *](?:\./)?([A-Za-z0-9][A-Za-z0-9._+~^-]*)", line) - if not match: - raise ValueError("Invalid SHA256SUMS entry") - checksum, name = match.groups() - path = directory / name - if name in entries or path.is_symlink() or not path.is_file(): - raise ValueError(f"Unsafe or duplicate artifact: {name}") - if digest(path) != checksum.lower(): - raise ValueError(f"Artifact checksum mismatch: {name}") - entries[name] = checksum.lower() - if not entries: - raise ValueError("Empty artifact checksum manifest") - files = {path.name for path in directory.iterdir() if path.name != "SHA256SUMS"} - if files != set(entries): - raise ValueError("Artifact directory contains unchecked files") - return entries - - -def rename_new_directory(source, destination): - """Linux atomic publication that refuses replacing even an empty directory.""" - libc = ctypes.CDLL(None, use_errno=True) - renameat2 = libc.renameat2 - renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] - renameat2.restype = ctypes.c_int - if renameat2(-100, os.fsencode(source), -100, os.fsencode(destination), 1): - error = ctypes.get_errno() - raise OSError(error, os.strerror(error), str(destination)) - - -def deliver_artifacts(staging, destination): - """Verify all bytes before publishing an entire directory on one filesystem.""" - staging, destination = Path(staging), Path(destination) - entries = checksum_entries(staging) - if destination.exists() or destination.is_symlink(): - raise FileExistsError(f"Refusing to replace artifacts: {destination}") - for path in staging.iterdir(): - path.chmod(0o644) - with path.open("rb") as stream: - os.fsync(stream.fileno()) - staging.chmod(0o755) - # Linux RENAME_NOREPLACE also protects against a concurrent same-user - # publisher creating an empty target after the check above. - rename_new_directory(staging, destination) - descriptor = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - return entries - - -def validate_qemu_path(path): - if any(character == "," or ord(character) < 32 for character in str(path)): - raise ValueError("QEMU file paths cannot contain commas or control characters") - return Path(path) - - -def existing_parent(path): - path = Path(path).resolve() - while not path.exists(): - path = path.parent - return path - - -def preflight(output, cache, memory, cpus, minimum_free_gib=180): - missing = [name for name in ("qemu-img", "qemu-system-x86_64", "ssh", "ssh-keygen", "scp") if not shutil.which(name)] - seed_tool = next((name for name in ("cloud-localds", "xorriso", "genisoimage", "mkisofs") if shutil.which(name)), None) - if seed_tool is None: - missing.append("cloud-localds or xorriso") - errors = [] - if missing: - errors.append("Missing: " + ", ".join(missing) + ". Fedora: sudo dnf install qemu-kvm qemu-img xorriso openssh-clients python3-pyyaml edk2-ovmf. Debian: sudo apt install qemu-system-x86 qemu-utils cloud-image-utils openssh-client python3-yaml ovmf.") - if not os.access("/dev/kvm", os.R_OK | os.W_OK): - errors.append("Read/write access to /dev/kvm is required (check the kvm group).") - if memory < 4096 or cpus < 1: - errors.append("Specify at least 4096 MiB RAM and one CPU; full builds normally need 24576 MiB.") - available = {} - for line in Path("/proc/meminfo").read_text().splitlines(): - key, value = line.split(":", 1) - available[key] = int(value.split()[0]) - if memory * 1024 > available.get("MemAvailable", 0): - errors.append(f"Requested {memory} MiB RAM exceeds currently available {available.get('MemAvailable', 0) // 1024} MiB; close applications or reduce --memory.") - if cpus > (os.cpu_count() or 1): - errors.append(f"Requested {cpus} CPUs exceeds {os.cpu_count() or 1} host CPUs.") - # The sparse builder resides in cache. Its peak estimate already includes - # artifacts; reserve additional delivery space when output is another disk. - parents = {existing_parent(cache): minimum_free_gib} - output_parent = existing_parent(output) - if output_parent.stat().st_dev != existing_parent(cache).stat().st_dev: - parents[output_parent] = 24 - for parent, required in parents.items(): - free = shutil.disk_usage(parent).free / 2**30 - if free < required: - errors.append(f"{parent}: {free:.1f} GiB free; require {required} GiB for build staging/delivery.") - if errors: - raise RuntimeError("\n".join(errors)) - return {"seed_tool": seed_tool, "memory_mib": memory, "cpus": cpus, - "free_gib": {str(path): round(shutil.disk_usage(path).free / 2**30, 1) for path in parents}} - - -def create_seed(work, seed_tool): - work = Path(work) - if seed_tool == "cloud-localds": - command = [seed_tool, str(work / "seed.iso"), str(work / "user-data"), str(work / "meta-data")] - else: - command = ([seed_tool, "-as", "mkisofs"] if seed_tool == "xorriso" else [seed_tool]) - command += ["-quiet", "-output", str(work / "seed.iso"), "-volid", "cidata", "-joliet", "-rock", - str(work / "user-data"), str(work / "meta-data")] - subprocess.run(command, check=True) - - -def source_provenance(root, archive): - def git(*args): - result = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, check=True) - return result.stdout.strip() - return {"revision": git("rev-parse", "HEAD"), "changes": git("status", "--porcelain=v1").splitlines(), - "source_archive_sha256": digest(archive), - "configuration_sha256": digest(root / "inventory/group_vars/all.yml")} - - -@contextmanager -def phase(name, records): - started = time.monotonic() - record = {"phase": name, "started_utc": datetime.now(timezone.utc).isoformat(), "status": "running"} - records.append(record) - print(f"\n[image] {name}", flush=True) - try: - yield - except BaseException: - record["status"] = "failed" - print(f"[image] FAILED: {name}", flush=True) - raise - else: - record["status"] = "complete" - finally: - record["seconds"] = round(time.monotonic() - started, 2) - print(f"[image] {name}: {record['status']} ({record['seconds']:.1f}s)", flush=True) - - -def select_firmware(directory=Path("/usr/share/qemu/firmware"), fallback_root=Path("/usr/share")): - """Use QEMU's machine-readable descriptor, preferring non-Secure-Boot UEFI.""" - for path in sorted(Path(directory).glob("*.json")): - data = json.loads(path.read_text()) - if "uefi" not in data.get("interface-types", []) or "secure-boot" in data.get("features", []): - continue - if not any(target.get("architecture") == "x86_64" and any(fnmatch.fnmatch("pc-q35-9.0", machine) for machine in target.get("machines", [])) for target in data.get("targets", [])): - continue - mapping = data.get("mapping", {}) - if mapping.get("device") != "flash": - continue - code, variables = mapping.get("executable", {}), mapping.get("nvram-template", {}) - if all(item.get("format") in ("raw", "qcow2") and Path(item.get("filename", "")).is_file() for item in (code, variables)): - return {"code": code, "variables": variables, "descriptor": str(path)} - for folder, code, variables in ( - ("OVMF", "OVMF_CODE_4M.fd", "OVMF_VARS_4M.fd"), - ("OVMF", "OVMF_CODE.fd", "OVMF_VARS.fd"), - ("edk2/ovmf", "OVMF_CODE.fd", "OVMF_VARS.fd"), - ("edk2/x64", "OVMF_CODE.4m.fd", "OVMF_VARS.4m.fd"), - ): - paths = [Path(fallback_root) / folder / name for name in (code, variables)] - if all(path.is_file() for path in paths): - return {"code": {"filename": str(paths[0]), "format": "raw"}, - "variables": {"filename": str(paths[1]), "format": "raw"}, "descriptor": None} - raise RuntimeError("No non-Secure-Boot x86_64 UEFI firmware found; install edk2-ovmf (Fedora) or ovmf (Debian).") - - -def prepare_firmware(work, selection=None): - selected = selection or select_firmware() - code, variables = selected["code"], selected["variables"] - target = Path(work) / ("OVMF_VARS.qcow2" if variables["format"] == "qcow2" else "OVMF_VARS.fd") - if not target.exists(): - shutil.copyfile(variables["filename"], target) - return ["-drive", f"if=pflash,format={code['format']},readonly=on,file={code['filename']}", - "-drive", f"if=pflash,format={variables['format']},file={target}"] diff --git a/image/channels/CYBEXOS-desktop.asc b/image/channels/CYBEXOS-desktop.asc deleted file mode 100644 index f48cabad..00000000 --- a/image/channels/CYBEXOS-desktop.asc +++ /dev/null @@ -1,16 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- - -mDMEarerDxYJKwYBBAHaRw8BAQdAmmD5CaWuawHOraUCulVZjFjAT0iUKf9mFBPL -2LkA9B20O0N5YmV4T1MgUlBNIFJlbGVhc2UgU2lnbmluZyA8bm9yZXBseUBkaWdp -dGFscGFscy5naXRodWIuaW8+iJkEExYKAEEWIQQWxgZCtyeK7OOpM8NUIgg5/fcJ -ngUCarerDwIbAQUJBaOagAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRBU -Igg5/fcJno2eAP98NYS4dX6fxbK0yUPM1Nb+aeH2v8P1d5WoeH2xn8j5cQD8C+lz -ETO5o+5dSmRbCJPv7bDfC9sWBNznW6uLqylJvwm4MwRqt6sPFgkrBgEEAdpHDwEB -B0DMqZcTm/bd+Yk8hrqRjWBi7/1yoAQ/tvfcJfYui627k4j1BBgWCgAmFiEEFsYG -QrcniuzjqTPDVCIIOf33CZ4FAmq3qw8CGwIFCQPCZwAAgQkQVCIIOf33CZ52IAQZ -FgoAHRYhBG6cYYz9wTHrUwGfYUyvtRx29FzVBQJqt6sPAAoJEEyvtRx29FzVk+sB -APoBywasY4Zv5bWN8czeindBYEauMAn7loIa62SwjOJHAP41NgT7cnuIF4moDHyp -TI83TSC9EB420/rWpPc5f3MqAtI4AQC73TWynBM+ACwHKf0n9qt4BXfjPAI/YQss -LlVC6dplXQEA06MRepyNZvoAzorba/isdLw/OVeoUsnvbF2AOl12SAc= -=61Bo ------END PGP PUBLIC KEY BLOCK----- diff --git a/image/channels/stable.json b/image/channels/stable.json deleted file mode 100644 index 3b39ae44..00000000 --- a/image/channels/stable.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "baseurl": "https://digitalpals.github.io/CybexOS/44/x86_64", - "fingerprint": "16C60642B7278AECE3A933C354220839FDF7099E", - "key_file": "CYBEXOS-desktop.asc" -} diff --git a/image/check-source b/image/check-source deleted file mode 100755 index a93b92a2..00000000 --- a/image/check-source +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -"""Run image tooling and installer fixtures without building or booting an ISO.""" -from pathlib import Path -import os -import subprocess -import sys - - -def main(): - root = Path(__file__).resolve().parents[1] - environment = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1", - "QT_QPA_PLATFORM": "offscreen", "QT_QUICK_BACKEND": "software"} - commands = [[sys.executable, "image/tests"], - [sys.executable, "-m", "unittest", "discover", "-s", "image", "-p", "test_*.py", "-v"]] - javascript = sorted({*root.glob("image/**/*.test.mjs"), *root.glob("image/**/*.test.cjs")}) - if javascript: - commands.append(["node", "--test", *(str(path) for path in javascript)]) - failures = 0 - for command in commands: - print("[source check] " + " ".join(command), flush=True) - try: - result = subprocess.run(command, cwd=root, env=environment, check=False) - failures += result.returncode != 0 - except OSError as error: - print(str(error), file=sys.stderr) - failures += 1 - return 1 if failures else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/image/compose b/image/compose deleted file mode 100755 index a7d720e6..00000000 --- a/image/compose +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env python3 -"""Compose with livecd-tools' API so DNF repository signing policy is honored.""" -import logging -import configparser -import hashlib -import json -import gc -import os -from pathlib import Path -import shutil -import subprocess -import traceback - -import imgcreate -import imgcreate.creator -from imgcreate.dnfinst import DnfLiveCD -import importlib.util -import rpm -import yaml -from fedora_target import fedora_release -from boot_branding import brand_boot_menu -from sbom import capture_inventories -from post_install_check import check_post_install - -logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") -source = Path(__file__).resolve().parent -# Inventory fedora_release; build.repo and live.ks name no release of their own. -release = fedora_release() -build = Path("/home/builder/build") -dnf_instances = [] -# The same series and comparison the installed systems' versionlock enforces. -_series_spec = importlib.util.spec_from_file_location("cybexos_versionlock", source / "library/cybexos_versionlock.py") -versionlock = importlib.util.module_from_spec(_series_spec) -_series_spec.loader.exec_module(versionlock) -qualified_series = yaml.safe_load((source.parent / "inventory/group_vars/all.yml").read_text())["qualified_package_series"] - - -class VerifiedDnf(DnfLiveCD): - """Add signature enforcement missing from Fedora 44's livecd-tools 31. - - The shipped runInstall explicitly skips signature checks. Keep its image - construction logic, but verify the downloaded transaction before RPM can - execute any package scriptlets. Repository policy comes from build.repo. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - dnf_instances.append(self) - - def addRepository(self, name, url=None, mirrorlist=None): - policy = configparser.ConfigParser(interpolation=None) - policy.read(source / "build.repo") - policy.read(build / "application-repositories.repo") - for repository_id in policy.sections(): - entry = policy[repository_id] - repository = super().addRepository(repository_id, entry.get("baseurl"), entry.get("mirrorlist")) - if entry.get("metalink"): - repository.metalink = entry["metalink"].replace("$releasever", release).replace("$basearch", "x86_64") - repository.gpgcheck = entry.getboolean("gpgcheck") - repository.repo_gpgcheck = entry.getboolean("repo_gpgcheck", fallback=False) - repository.skip_if_unavailable = False - if repository_id == "cybexos-local": - repository.metadata_expire = 0 - if repository.gpgcheck: - repository.gpgkey = [key.replace("$releasever", release).replace("$basearch", "x86_64") - for key in entry["gpgkey"].split()] - elif repository_id != "cybexos-local": - raise RuntimeError("Only the locally built RPM may be unsigned") - return self.repos[name] - - def fill_sack(self, *args, load_system_repo=False, **kwargs): - # livecd-tools only detects the BerkeleyDB rpmdb. Also load RPM 6's - # SQLite database, so a later pass sees the packages already installed. - root = Path(self.conf.installroot) - load_system_repo = load_system_repo or any( - (root / directory / "rpmdb.sqlite").exists() for directory in ("usr/lib/sysimage/rpm", "var/lib/rpm")) - return super().fill_sack(*args, load_system_repo=load_system_repo, **kwargs) - - def do_transaction(self, *args, **kwargs): - for package in self.transaction.install_set: - if not package.repo.gpgcheck: - if package.reponame != "cybexos-local": - raise RuntimeError(f"Unexpected unsigned package: {package}") - if package.name not in ("cybexos-desktop", "cybexos-offline-apps"): - pinned = json.loads((build / "pinned-rpms.json").read_text()) - path = Path(package.localPkg()) - with path.open("rb") as stream: - digest = hashlib.file_digest(stream, "sha256").hexdigest() - if pinned.get(path.name) != digest: - raise RuntimeError(f"Unverified local application RPM: {package}") - continue - result, message = self._sig_check_pkg(package) - if result == 1: - self._get_key_for_package(package, lambda *_args, **_kwargs: True) - result, message = self._sig_check_pkg(package) - if result: - raise RuntimeError(f"Package signature verification failed: {package}: {message}") - logging.info("Verified all upstream package signatures before installation") - # RPM 6 must accept the locally built unsigned artifact. Upstream - # signatures have already been checked explicitly above. - rpm.addMacro("_pkgverify_level", "digest") - return super().do_transaction(*args, **kwargs) - - -imgcreate.creator.DnfLiveCD = VerifiedDnf -kickstart = imgcreate.read_kickstart(str(build / "live.ks")) -# RPM 6 checks Requires(pretrans) against packages installed before the -# transaction. These pretrans scriptlets need /bin/sh, rpm and sed, which an -# empty image root lacks, so they are installed in a second transaction. -PRETRANS_PACKAGES = ("chatgpt",) -selected_packages = kickstart.handler.packages.packageList -deferred_packages = [name for name in selected_packages if name in PRETRANS_PACKAGES] - - -def install_packages(creator): - kickstart.handler.packages.packageList = [name for name in selected_packages if name not in deferred_packages] - creator.install() - if deferred_packages: - groups = kickstart.handler.packages.groupList - kickstart.handler.packages.packageList = deferred_packages - kickstart.handler.packages.groupList = [] - try: - creator.install() - finally: - kickstart.handler.packages.groupList = groups - kickstart.handler.packages.packageList = selected_packages - - -class CybexImageCreator(imgcreate.LiveImageCreator): - def _configure_bootloader(self, isodir): - super()._configure_bootloader(isodir) - brand_boot_menu(isodir, self._instroot) - - -creator = CybexImageCreator( - kickstart, f"CybexOS-Live-{release}", fslabel=f"CYBEXOS-LIVE-{release}", releasever=release, - tmpdir=str(build / "tmp"), title="CybexOS", product="CybexOS", -) -compression = os.environ.get("CYBEXOS_IMAGE_COMPRESSION", os.environ.get("CYBEXOS_SQUASHFS_COMPRESSION", "xz")) -if compression not in ("xz", "zstd"): - raise ValueError("Unsupported live image compression") -creator.compress_args = compression -logging.info("Live image compression: %s", compression) -try: - creator.mount(cachedir=str(build / "cache")) - shutil.copyfile(source / "dracut-live.conf", Path(creator._instroot) / "etc/dracut.conf.d/99-liveos.conf") - install_packages(creator) - # Flatpak validates signed OSTree commits during preparation. Copy the - # complete system installation, including runtimes, before SELinux relabel. - flatpak_root = Path(creator._instroot) / "var/lib/flatpak" - flatpak_root.mkdir(parents=True, exist_ok=True) - subprocess.run(["cp", "-a", "--reflink=auto", "/var/lib/flatpak/.", str(flatpak_root)], check=True) - vendor_policy = configparser.ConfigParser(interpolation=None) - vendor_policy.read(build / "application-repositories.repo") - # RPM Fusion release RPMs already own their repository definitions. - for section in list(vendor_policy.sections()): - if section.startswith("rpmfusion-"): - vendor_policy.remove_section(section) - for entry in vendor_policy.values(): - for key_url in entry.get("gpgkey", "").split(): - if not key_url.startswith("file:///etc/pki/rpm-gpg/"): - raise RuntimeError(f"Vendor key is not a reviewed local key: {key_url}") - key = Path(key_url.removeprefix("file://")) - shutil.copy2(key, Path(creator._instroot) / "etc/pki/rpm-gpg" / key.name) - subprocess.run(["chroot", creator._instroot, "/usr/libexec/cybexos-repository-policy"], check=True) - # imgcreate stages its initrd before Kickstart post scripts run. Select - # the branded theme and rebuild now, while all installed kernels exist. - subprocess.run(["chroot", creator._instroot, "plymouth-set-default-theme", "cybex"], check=True) - subprocess.run(["chroot", creator._instroot, "dracut", "--regenerate-all", "--force"], check=True) - creator.configure() - applications = json.loads((build / "applications.json").read_text()) - subprocess.run(["rpm", "--root", creator._instroot, "-q", *applications["packages"]], check=True) - # Publish only the compositor and shell series this release qualified - # (docs/dependency-policy.md); installed systems then stay within them. - series_problems = versionlock.outside_series( - qualified_series, lambda name: versionlock.installed_evr(name, creator._instroot), require=True) - if series_problems: - raise RuntimeError("Resolved packages are outside their qualified series: " + "; ".join(series_problems)) - logging.info("Qualified series: %s", versionlock.describe(qualified_series)) - installed_flatpaks = subprocess.check_output([ - "chroot", creator._instroot, "flatpak", "list", "--system", "--app", "--columns=application", - ], text=True).splitlines() - if not set(applications["flatpaks"]).issubset(installed_flatpaks): - raise RuntimeError("Missing offline Flatpak applications") - # linux-firmware alone does not pull in vendor GPU firmware when weak - # dependencies are disabled. Fail before publishing an incomplete image. - subprocess.run([ - "rpm", "--root", creator._instroot, "-q", - "kernel-core", "kernel-modules-core", "kernel-modules", "kernel-modules-extra", - "linux-firmware", "amd-gpu-firmware", "intel-gpu-firmware", "nvidia-gpu-firmware", - "mesa-dri-drivers", "mesa-vulkan-drivers", "mesa-libEGL", "pciutils", "fastfetch", - ], check=True) - subprocess.run([ - "chroot", creator._instroot, "/usr/bin/bash", "-euc", - "for tool in cybex fastfetch anaconda liveinst efibootmgr grubby grub2-mkconfig cryptsetup btrfs mkfs.vfat mkfs.ext4; " - 'do command -v "$tool" >/dev/null || exit 1; done', - ], check=True) - for relative in ("", "usr", "etc", "usr/libexec/cybexos-live-setup"): - metadata = (Path(creator._instroot) / relative).stat() - if metadata.st_uid != 0 or metadata.st_gid != 0 or metadata.st_mode & 0o022: - raise RuntimeError(f"Unsafe image ownership or permissions: /{relative}") - # Run the installer's own post-install scripts against this image, as - # Anaconda will, so a failing step stops the build instead of every install. - logging.info("Checking the installer post-install scripts against the composed image") - check_post_install(creator._instroot, build / "tmp") - with Path("/home/builder/artifacts/packages.txt").open("w") as manifest: - subprocess.run(["rpm", "--root", creator._instroot, "-qa", "--qf", "%{NEVRA}\n"], - check=True, stdout=manifest) - # Raw RPM and Flatpak inventories of the finished root. The host turns - # them into the release SBOM once the ISO has its final name. - capture_inventories(creator._instroot, Path("/home/builder/artifacts")) - creator.unmount() - creator.package() -except BaseException as error: - # Exceptions in DNF retain locals (and file handles) through chained - # tracebacks. Release those frames before livecd-tools unmounts its root. - chained = error - while chained is not None: - traceback.clear_frames(chained.__traceback__) - chained = chained.__context__ - raise -finally: - # livecd-tools skips Base.close() when dependency resolution raises. Close - # those handles before unmounting, so a failed build can clean its mounts. - for instance in dnf_instances: - instance.close() - gc.collect() - creator.cleanup() diff --git a/image/cybexos-desktop.spec b/image/cybexos-desktop.spec deleted file mode 100644 index cc5ddcfb..00000000 --- a/image/cybexos-desktop.spec +++ /dev/null @@ -1,167 +0,0 @@ -Name: cybexos-desktop -# The packager derives Version and Release from VERSION and build provenance. -# Epoch 1 allows source-versioned packages to replace the old hardcoded 0.1.0. -Epoch: 1 -Version: 0.1.0 -Release: 0.1.alpha%{?dist} -Summary: CybexOS Hyprland and Quickshell desktop -# CybexOS code is MIT. Bundled upstream software/artwork retains its own terms; -# aggregate redistribution clearance is still pending (docs/licensing.md). -License: MIT AND LicenseRef-CybexOS-Bundled-Components -URL: https://github.com/DigitalPals/CybexOS -Source0: desktop.tar -Source1: offline.files -%global offline_version 1.0.0 -%global offline_inputs unconfigured -BuildArch: x86_64 -Provides: cybexos-supported-fedora = %{fedora} -AutoReqProv: no -# Replaces the alpha package published under the project's former name. -Obsoletes: fedora-config-desktop < %{epoch}:%{version}-%{release} -# This RPM is an intermediate container; the live ISO compresses the installed -# filesystem separately. Avoid spending minutes recompressing user toolchains. -%global _binary_payload w3.zstdio -%global _binary_filedigest_algorithm 8 -Requires: bash coreutils util-linux systemd python3 ansible-core gnupg2 openssh-clients -Requires: sddm sddm-wayland-generic systemd-pam gnome-keyring-pam -# Settings > Security and the lock screen's fingerprint and security-key steps. -Requires: fprintd fprintd-pam pam-u2f pamu2fcfg -# Native compositor EVR requirement inserted by image/package -Requires: hyprland -Requires: cmake >= 3.27 -Requires: gcc-c++ make pkgconf-pkg-config hyprland-devel libdrm-devel libinput-devel systemd-devel pixman-devel wayland-devel libxkbcommon-devel patch binutils -Requires: hyprland-guiutils quickshell hypridle hyprlock hyprsunset -Requires: xdg-desktop-portal-hyprland xdg-desktop-portal-gtk xdg-utils -Requires: qt6-qtwebsockets-devel qt6-qt5compat qt6-qtsvg -Requires: qt6-qtbase qt6-qtdeclarative qt6-qtwayland -Requires: qt6-qtbase-devel qt6-qtdeclarative-devel -Requires: python3-pyside6 python3-websockets python3-gobject -Requires: kitty firefox nautilus jq curl NetworkManager iw qrencode iproute iputils dnsmasq -Requires: pipewire pipewire-pulseaudio wireplumber bluez brightnessctl playerctl -Requires: gnome-keyring polkit dbus-daemon dnf5-plugins flatpak sudo -Requires: gnome-online-accounts gnome-online-accounts-gtk evolution-data-server gnome-control-center zenity -Requires: glib2 NetworkManager-libnm nm-connection-editor pulseaudio-utils pavucontrol -Requires: grim slurp satty wl-clipboard cliphist wf-recorder libnotify -Requires: ImageMagick tesseract tesseract-langpack-eng btop matugen -Requires: hyprpicker zbar qt6-qtmultimedia -Requires: btrfs-progs tar zstd fastfetch dracut grub2-tools coreutils >= 9.5 -Requires: rsms-inter-fonts google-noto-sans-fonts google-noto-color-emoji-fonts -Requires: jetbrains-mono-fonts -Requires: xkeyboard-config tzdata -# Personal-file backups: the encrypted repository and its keyring password. -Requires: restic libsecret -# The graphical first-start setup for a new owner (cybexos-new-owner). -Requires: cage -Requires: cybexos-offline-apps >= 1:%{offline_version} -Requires: cybexos-offline-inputs = %{offline_inputs} - -%description -CybexOS (Cybex Opinionated System) is a Hyprland and Quickshell desktop for Fedora. -Shared desktop defaults, session services, and first-login welcome application. -Personal settings and overrides remain in each user's home directory. -Private alpha image integration; not a public distribution release. - -%package -n cybexos-offline-apps -Epoch: 1 -Version: %{offline_version} -Release: 1%{?dist} -Summary: CybexOS offline applications, fonts and developer toolchains -AutoReqProv: no -Provides: cybexos-offline-inputs = %{offline_inputs} - -%description -n cybexos-offline-apps -Independently versioned offline application payload for complete installations. -Desktop updates reuse this package until its reviewed application inputs change. - -%prep -%setup -q -c -n desktop - -%build - -# Bundled upstream binaries must retain the bytes their installers verified. -%global __os_install_post %{nil} -%global debug_package %{nil} - -%install -mkdir -p %{buildroot} -cp -a usr opt etc %{buildroot}/ - -%files -%license /usr/share/licenses/cybexos-desktop/LICENSE -%license /usr/share/licenses/cybexos-titlebars/ -%license /usr/share/licenses/cybexos-notification-images/ -/usr/lib64/qt6/qml/CybexOS/Images/ -/usr/lib64/cybexos/ -/usr/lib/systemd/system/cybexos-titlebars-build.service -/usr/lib/systemd/system/cybexos-titlebars-build.path -%config(noreplace) /etc/yum.repos.d/cybexos-desktop.repo -%config(noreplace) /etc/fonts/conf.d/49-cybexos-defaults.conf -/usr/bin/cybex -/usr/bin/cybexos-* -/usr/bin/hyprland-quickshell -/usr/libexec/cybexos-* -/usr/libexec/cybex_hermes/ -/usr/lib/systemd/user/*.service -/usr/lib/systemd/user/cybexos-backup.timer -/usr/lib/systemd/user/hypridle.service.d/ -/usr/lib/systemd/user/voxtype.service.d/ -/usr/lib/systemd/user/hyprland-session.target -/usr/lib/systemd/system/sddm.service.d/ -/usr/share/cybexos/ -/usr/share/applications/cybex.desktop -/usr/share/wayland-sessions/hyprland-quickshell.desktop -/usr/share/glib-2.0/schemas/90-cybexos-ibus.gschema.override -/usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override -%exclude /usr/share/cybexos/user-seed/ -%exclude /usr/share/cybexos/final-seed/ -%exclude /usr/share/cybexos/seed-groups.json -%exclude /usr/share/cybexos/offline-inputs.json -/usr/share/plymouth/themes/cybex/ -/usr/lib/sysctl.d/60-cybexos-hardening.conf -/etc/grub.d/42_cybexos_recovery -/usr/lib/kernel/install.d/95-cybexos-recovery.install -/usr/lib/dracut/dracut.conf.d/90-cybexos-recovery.conf -/usr/lib/dracut/modules.d/90cybexos-recovery/ -/usr/lib/systemd/system/cybexos-recovery-refresh.service -/usr/lib/systemd/system/cybexos-update-recover.service -/usr/lib/systemd/system/systemd-user-sessions.service.d/60-cybexos-update-recover.conf -/usr/lib/systemd/system/cybexos-major-upgrade-validate.service -/usr/lib/systemd/system/cybexos-major-upgrade-validate.timer -/usr/lib/systemd/system/cybexos-update-download.service -/usr/lib/systemd/system/cybexos-update-download.timer -/usr/lib/systemd/system/cybexos-reconcile.service -/usr/lib/systemd/system/cybexos-reconcile.timer -/usr/lib/systemd/system/cybexos-hardware-setup.service -/usr/lib/systemd/system/cybexos-hardware-setup.timer -/usr/lib/systemd/system/cybexos-first-boot.service -/usr/lib/systemd/system/cybexos-new-owner.service -/usr/lib/systemd/system/cybexos-tailscale-enroll.service - -/usr/lib/firewalld/zones/cybexos.xml - -%files -n cybexos-offline-apps -f %{SOURCE1} - -%posttrans -systemctl enable --now --no-block cybexos-titlebars-build.path >/dev/null 2>&1 || : -systemctl enable cybexos-titlebars-build.service >/dev/null 2>&1 || : -# Record work only inside the RPM transaction. A timer runs it after RPM releases -# its transaction lock, and repeats only for changed payloads/new accounts. -/usr/libexec/cybexos-reconcile --queue -systemctl enable --now --no-block cybexos-reconcile.timer >/dev/null 2>&1 || : -# The SDDM RPM owns /etc/pam.d/sddm-autologin. Install the shared policy after -# all package payloads are present on a fresh installation. Upgrades use the -# ownership-aware deferred policy so local PAM edits are retained. -if [ "$1" -eq 1 ]; then - /usr/libexec/cybexos-login-prepare --install-pam -fi -# Bootable recovery points are refreshed at every boot; enabling is idempotent. -systemctl enable cybexos-recovery-refresh.service >/dev/null 2>&1 || : -systemctl enable cybexos-update-recover.service >/dev/null 2>&1 || : -systemctl enable cybexos-major-upgrade-validate.timer >/dev/null 2>&1 || : -systemctl enable cybexos-hardware-setup.timer >/dev/null 2>&1 || : -# Inert unless the installer or a factory reset left first-start work. -systemctl enable cybexos-first-boot.service cybexos-new-owner.service >/dev/null 2>&1 || : - -%changelog -* Sat Sep 05 2026 CybexOS - 0.1.0-0.1.alpha -- Initial private live-image desktop package. diff --git a/image/desktop_payload.py b/image/desktop_payload.py deleted file mode 100644 index f88667de..00000000 --- a/image/desktop_payload.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Portable desktop policy shared with the workstation; no host-home inputs.""" -import json -from pathlib import Path -import shutil -import hashlib - - -def prepare_font_licenses(payload, inventory): - """Keep the checkout's canonical fonts, verifying the offline inputs. - - The applications payload already includes these checksum-pinned files. - Installing a second font copy wastes space and makes the two paths differ. - """ - for font in inventory["pinned_font_files"]: - base = payload / "usr/local/share/fonts" / font["name"] / font["version"] - for filename, checksum in ((font["filename"], font["checksum"]), - (font["license_filename"], font["license_checksum"])): - source = base / filename - if not source.is_file() or source.is_symlink() or hashlib.sha256(source.read_bytes()).hexdigest() != checksum.removeprefix("sha256:"): - raise ValueError(f"Missing or corrupt prepared font input: {font['name']} {filename}") - destination = payload / "usr/share/licenses/cybexos-fonts" / (font["name"] + "-" + font["license_filename"]) - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(base / font["license_filename"], destination) - destination.chmod(0o644) - - -def prepare_quickshell(root, payload): - """Package the same shell sources deployed by the checkout desktop role.""" - quickshell = root / "roles/desktop/files/quickshell" - runtime = payload / "usr/share/cybexos/runtime/quickshell" - for source in sorted(quickshell.rglob("*")): - if not source.is_file() or source.is_symlink() or "__pycache__" in source.parts or source.suffix == ".pyc": - continue - target = runtime / source.relative_to(quickshell) - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source, target) - target.chmod(0o755 if source.parent.name == "scripts" and source.suffix != ".py" else 0o644) - if source.suffix in (".qml", ".js"): - target.write_text(target.read_text().replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - - -def prepare_defaults(root, payload, environment, inventory): - contract = json.loads((root / "assets/desktop-contract.json").read_text()) - vendor = payload / "usr/share/cybexos" - seed = vendor / "user-seed" - config = seed / ".config/cybexos" - config.mkdir(parents=True, exist_ok=True) - (config / "shell.json").write_text(json.dumps(contract["shell"], indent=2) + "\n") - shutil.copytree(root / "assets/wallpapers", seed / "Pictures/Wallpapers", dirs_exist_ok=True) - shutil.copy2(root / "assets/desktop-contract.json", vendor / "desktop-contract.json") - shutil.copy2(root / "assets/PROVENANCE.json", vendor / "artwork-provenance.json") - # Use the same MIME policy, including T3's OAuth callback, as Ansible. - mime = environment.from_string((root / "roles/dotfiles/templates/mimeapps.list.j2").read_text()) - (seed / ".config/mimeapps.list").write_text(mime.render(features=inventory["features"])) - - theme = payload / "usr/share/plymouth/themes/cybex" - theme.mkdir(parents=True, exist_ok=True) - for file in (root / "roles/boot/files").iterdir(): - if file.is_file() and not file.is_symlink(): - shutil.copy2(file, theme / file.name) - # Fail packaging when an image referred to by the shared script is absent. - import re - for name in re.findall(r'Image\("([^"/]+)"\)', (theme / "cybex.script").read_text()): - if not (theme / name).is_file(): - raise FileNotFoundError(f"Incomplete Plymouth artwork: {name}") - hardening = payload / "usr/lib/sysctl.d/60-cybexos-hardening.conf" - hardening.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(root / "roles/base/files/60-cybexos-hardening.conf", hardening) - firewall = payload / "usr/lib/firewalld/zones/cybexos.xml" - firewall.parent.mkdir(parents=True, exist_ok=True) - firewall.write_text(environment.from_string( - (root / "roles/base/templates/cybexos-zone.xml.j2").read_text() - ).render(features=inventory["features"], firewall_ports=inventory["firewall_ports"])) - return contract - - -# The shared cybexos_user_include module's first-run Kitty block. User -# preferences follow it, so explicit values have precedence over defaults. -KITTY_INCLUDE = "# BEGIN CYBEXOS MANAGED INCLUDE\ninclude cybexos.conf\n# END CYBEXOS MANAGED INCLUDE\n" - - -def prepare_app_launchers(root, payload): - """Package the same desktop launchers that checkout installs deploy.""" - applications = payload / 'usr/share/cybexos/user-seed/.local/share/applications' - applications.mkdir(parents=True, exist_ok=True) - for name in ('nvim-kitty', 'brave-origin', 'chatgpt', 'com.onepassword.OnePassword'): - target = applications / (name + '.desktop') - shutil.copyfile(root / 'roles/dotfiles/files' / target.name, target) - target.chmod(0o644) - - -def prepare_session(root, payload, inventory): - """Session pieces the workstation installs per user, packaged once. - - The seed is copied into a home only where a file is absent, so the - vendor Kitty settings live in the managed fragment that provisioning - keeps current; the seeded kitty.conf only includes it and is the user's. - """ - vendor = payload / "usr/share/cybexos" - kitty = vendor / "user-seed/.config/kitty" - kitty.mkdir(parents=True, exist_ok=True) - shutil.copyfile(root / "roles/dotfiles/files/kitty.conf", kitty / "cybexos.conf") - (kitty / "kitty.conf").write_text(KITTY_INCLUDE) - prepare_system_session(root, payload, inventory) - - -def prepare_system_session(root, payload, inventory): - """The system-wide session files; image/repair-installed stages these too.""" - vendor = payload / "usr/share/cybexos" - # Provisioning links every account's agent skill slots to this copy. - skills = vendor / "agent-skills/cybexos" - if skills.exists(): - shutil.rmtree(skills) - shutil.copytree(root / "agent-skills/cybexos", skills) - # The workstation's user unit renders the same features. Installed - # systems apply their saved choice in a drop-in (image/provision.yml). - features = inventory["features"] - unit = payload / "usr/lib/systemd/user/quickshell.service" - unit.parent.mkdir(parents=True, exist_ok=True) - unit.write_text( - "[Unit]\nDescription=Quickshell desktop shell\nPartOf=hyprland-session.target\n\n" - "[Service]\nExecStart=/usr/share/cybexos/bin/cybexos-runtime exec quickshell\n" - f"Environment=CYBEXOS_CONNECTED_WIDGETS={int(bool(features['connected_widgets']))}\n" - f"Environment=CYBEXOS_DEVELOPER_TOOLS={int(bool(features['developer_tools']))}\n" - "Restart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=hyprland-session.target\n") - prepare_backup_units(root, payload) - # XPS hardware only: the unit starts when the session exports - # CYBEXOS_XPS_2026=1 from /etc/cybexos/hardware.json. - watcher = payload / "usr/libexec/cybexos-external-monitor-toggle" - watcher.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(root / "roles/dotfiles/templates/external-monitor-toggle.j2", watcher) - watcher.chmod(0o755) - - -def prepare_backup_units(root, payload): - """The personal-file backup timer and service the desktop role installs - per account. The helper is packaged, so the unit runs it from there - rather than through each account's ~/.local/bin link.""" - units = payload / "usr/lib/systemd/user" - units.mkdir(parents=True, exist_ok=True) - for name in ("cybexos-backup.service", "cybexos-backup.timer"): - content = (root / "roles/desktop/files" / name).read_text() - content = content.replace("%h/.local/bin/cybexos-backup", "/usr/share/cybexos/bin/cybexos-backup") - (units / name).write_text(content) - (units / name).chmod(0o644) - - -def split_seed(vendor, contract): - """Move small session defaults out of the expensive toolchain seed. - - User init merges both trees without replacing personal files. No personal - plugins, tokens, identities, monitor overrides or runtime state are inputs. - """ - seed = vendor / "user-seed" - for name, paths in (("essential-seed", contract["essentialPaths"]), - ("final-seed", contract.get("activationPaths", []))): - target = vendor / name - target.mkdir(parents=True, exist_ok=True) - for relative in paths: - path = Path(relative) - if path.is_absolute() or ".." in path.parts: - raise ValueError(f"Invalid desktop seed path: {relative}") - source = seed / path - if source.exists() or source.is_symlink(): - destination = target / path - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.move(source, destination) - # Record seed size during packaging, avoiding a whole-toolchain traversal - # on the critical path to every login. - total = sum(file.stat().st_size for tree in (seed, vendor / "final-seed") for file in tree.rglob("*") - if file.is_file() and not file.is_symlink()) - (vendor / "seed-groups.json").write_text(json.dumps({"totalBytes": total}) + "\n") - return total - - -def prepare_managed_defaults(vendor): - """Version only vendor fragments; application stores and user settings stay seeded once.""" - paths = ( - '.config/fish/conf.d/50-cybexos.fish', - '.config/kitty/cybexos.conf', - '.local/share/nautilus-python/extensions/localsend.py', - ) - selected = [] - for relative in paths: - for tree in ('essential-seed', 'user-seed', 'final-seed'): - source = vendor / tree / relative - if source.is_file() and not source.is_symlink(): - destination = vendor / 'managed-seed' / relative - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.move(source, destination) - selected.append(relative) - break - (vendor / 'managed-defaults.json').write_text(json.dumps(selected) + '\n') diff --git a/image/distribution_review.py b/image/distribution_review.py deleted file mode 100644 index 4e4d5d52..00000000 --- a/image/distribution_review.py +++ /dev/null @@ -1,247 +0,0 @@ -#!/usr/bin/env python3 -"""Capture actual distribution bytes and require channel-specific owner review. - -Capture is available to internal builds without approval. check-public captures -again from the candidate, then requires complete, digest-bound review coverage. -This records decisions; it cannot establish legal rights or authenticate a reviewer. -""" -import argparse -from datetime import datetime -import hashlib -import json -from pathlib import Path -import re -import stat -import subprocess -import sys - -ROOT = Path(__file__).resolve().parents[1] -CHANNELS = ('source', 'desktop-rpm', 'offline-rpm', 'iso', 'web') - - -def digest(path): - with path.open('rb') as stream: - return hashlib.file_digest(stream, 'sha256').hexdigest() - - -def identity(document): - return hashlib.sha256(json.dumps(document, sort_keys=True, separators=(',', ':')).encode()).hexdigest() - - -def regular(path): - if path.is_symlink() or not path.is_file(): - raise ValueError(f'Expected regular file: {path}') - - -def files(root): - if root.is_symlink() or not root.is_dir(): - raise ValueError(f'Expected payload directory: {root}') - result = {} - for path in sorted(root.rglob('*')): - key = 'file:' + path.relative_to(root).as_posix() - mode = path.lstat().st_mode - if stat.S_ISLNK(mode): - result[key] = {'kind': 'link', 'target': str(path.readlink())} - elif stat.S_ISREG(mode): - result[key] = {'kind': 'file', 'bytes': path.stat().st_size, 'sha256': digest(path), - 'executable': bool(mode & 0o111)} - elif not stat.S_ISDIR(mode): - raise ValueError(f'Unsupported shipped file: {path}') - if not result: - raise ValueError('Empty payload inventory') - return result - - -def rpm_files(path): - regular(path) - def query(formatting): - return subprocess.check_output(['rpm', '-qp', '--queryformat', formatting, str(path)], - text=True).strip() - if query('%{FILEDIGESTALGO}') != '8': - raise ValueError('Public RPM inventory requires SHA-256 payload digests') - result = {} - for line in query('[%{FILENAMES}\t%{FILEDIGESTS}\t%{FILELINKTOS}\t%{FILEMODES:octal}\n]').splitlines(): - name, checksum, target, mode = line.split('\t') - key = 'rpm:' + name - if key in result: - raise ValueError(f'Duplicate RPM payload path: {name}') - result[key] = {'sha256': checksum, 'target': target, 'mode': mode} - if not result: - raise ValueError('Empty RPM payload inventory') - return result - - -def revalidate_artifacts(inventory, directory): - """Bind hosted Fedora RPM-query evidence to the exact later upload bytes.""" - if not inventory.get('artifacts'): - raise ValueError('Missing captured artifact identity') - for name, record in inventory['artifacts'].items(): - if Path(name).name != name or name in ('.', '..'): - raise ValueError('Unsafe captured artifact name') - path = directory / name - regular(path) - if path.stat().st_size != record.get('bytes') or digest(path) != record.get('sha256'): - raise ValueError(f'Artifact changed after payload capture: {name}') - - -def capture(channel, *, root=None, artifacts=(), sbom=None): - if channel not in CHANNELS: - raise ValueError('Unknown distribution channel') - artifact_map = {} - for path in artifacts: - regular(path) - if path.name in artifact_map: - raise ValueError('Duplicate artifact basename') - artifact_map[path.name] = {'sha256': digest(path), 'bytes': path.stat().st_size} - if channel in ('desktop-rpm', 'offline-rpm'): - prefix = 'cybexos-desktop-' if channel == 'desktop-rpm' else 'cybexos-offline-apps-' - if len(artifacts) != 1 or not artifacts[0].name.startswith(prefix) or artifacts[0].suffix != '.rpm': - raise ValueError('Expected exactly one channel-specific RPM') - items = rpm_files(artifacts[0]) - else: - if root is None: - raise ValueError('A complete payload directory is required') - items = files(root) - if channel == 'source' and (not artifact_map or 'file:LICENSE' not in items): - raise ValueError('Source review requires the actual archive and extracted payload') - if channel == 'iso': - # The prepared publication contains all ISO parts, their checksum files, - # the signed RPMs and metadata. Its SBOM describes the exact raw ISO. - if sbom is None or sbom.resolve().parent != root.resolve(): - raise ValueError('ISO review requires the shipped SPDX SBOM inside the asset directory') - regular(sbom) - from sbom import described_package, verify_release_sbom - sidecar = sbom.with_name(sbom.name.removesuffix('.spdx.json') + '.iso.sha256') - regular(sidecar) - match = re.fullmatch(r'([a-f0-9]{64}) ([A-Za-z0-9._-]+\.iso)\n?', sidecar.read_text()) - if not match: - raise ValueError('Missing exact ISO checksum') - iso_digest, iso_name = match.groups() - document = verify_release_sbom(sbom, iso_name, iso_digest) - parts_path = root / (iso_name + '.parts.json') - regular(parts_path) - parts = json.loads(parts_path.read_text()) - if (parts.get('format') != 1 or parts.get('file') != iso_name - or parts.get('sha256') != iso_digest or not parts.get('parts')): - raise ValueError('ISO reconstruction identity disagrees with the SBOM') - complete, total = hashlib.sha256(), 0 - for index, part in enumerate(parts['parts']): - name = f'{iso_name}.part-{index:03d}' - recorded = items.get('file:' + name, {}) - if (part.get('file') != name or recorded.get('kind') != 'file' - or recorded.get('sha256') != part.get('sha256') - or recorded.get('bytes') != part.get('bytes')): - raise ValueError('Missing or changed ISO part') - with (root / name).open('rb') as stream: - while block := stream.read(8 * 1024 * 1024): - complete.update(block) - total += len(block) - if total != parts.get('bytes') or complete.hexdigest() != iso_digest: - raise ValueError('ISO parts do not reconstruct the SBOM subject') - for package in document['packages']: - key = 'package:' + package['SPDXID'] - if key in items: - raise ValueError('Duplicate SPDX package') - items[key] = package - # Raw build inventories and toolchain facts must be available to reviewers, - # rather than existing only inside a Podman cache or an old audit report. - for name in ('rpm-inventory.tsv', 'flatpak-inventory.tsv', 'package-manifest.json', - 'source-build-provenance.json', 'build-provenance.json'): - if 'file:' + name not in items: - raise ValueError(f'Missing actual build evidence: {name}') - source_facts = items['file:source-build-provenance.json']['sha256'] - if source_facts not in described_package(document).get('sourceInfo', ''): - raise ValueError('Source toolchain evidence is not bound to the ISO SBOM') - for name, artifact in artifact_map.items(): - items['artifact:' + name] = artifact - return {'schema': 1, 'channel': channel, 'artifacts': artifact_map, 'items': items} - - -def nonempty(value): - return isinstance(value, str) and bool(value.strip()) - - -def verify(inventory, manifest, evidence_root=ROOT): - if manifest.get('schema') != 1 or manifest.get('publication') != 'PUBLIC': - raise ValueError('Expected explicit PUBLIC distribution review manifest') - matches = [entry for entry in manifest.get('reviews', []) - if entry.get('channel') == inventory['channel'] and entry.get('inventorySha256') == identity(inventory)] - if len(matches) != 1: - raise ValueError(f'No unique reviewed {inventory["channel"]} inventory for {identity(inventory)}') - review = matches[0] - if review.get('decision') != 'approved' or not nonempty(review.get('reviewedBy')): - raise ValueError('Owner review is unresolved') - try: - date = datetime.fromisoformat(review['reviewedAt'].replace('Z', '+00:00')) - if date.tzinfo is None: - raise ValueError('Review timestamp needs a timezone') - except (KeyError, TypeError, ValueError) as error: - raise ValueError('Review needs a valid timestamp with timezone') from error - covered = set() - for component in review.get('components', []): - if (component.get('decision') != 'approved' - or not all(nonempty(component.get(field)) for field in ('name', 'license', 'obligations')) - or component.get('license', '').upper() in ('UNKNOWN', 'NOASSERTION', 'UNRESOLVED', 'NONE') - or component.get('trademarks') not in ('reviewed', 'not-applicable')): - raise ValueError('Unresolved component copyright, obligations or trademark review') - evidence = component.get('evidence', {}) - relative = Path(evidence.get('path', '')) - path = evidence_root / relative - if (relative.is_absolute() or '..' in relative.parts or not relative.parts - or any(parent.is_symlink() for parent in [path, *path.parents])): - raise ValueError('Evidence must be a regular repository file') - regular(path) - if not re.fullmatch('[a-f0-9]{64}', evidence.get('sha256', '')) or digest(path) != evidence['sha256']: - raise ValueError('Review evidence changed or is missing a SHA-256') - members = component.get('items', []) - # Compact groups are still bound to the exact full inventory digest; - # prefixes never approve future files. Keep original code, each third- - # party component, and independent trademark decisions in separate groups. - if isinstance(members, dict): - prefixes = members.get('prefixes', []) - excludes = members.get('excludePrefixes', []) - if (not prefixes or not all(nonempty(value) for value in [*prefixes, *excludes]) - or set(members) - {'prefixes', 'excludePrefixes'}): - raise ValueError('Invalid component inventory selectors') - members = [key for key in inventory['items'] - if key.startswith(tuple(prefixes)) and not key.startswith(tuple(excludes))] - if not members or len(set(members)) != len(members): - raise ValueError('Component must identify exact inventory items') - if covered.intersection(members) or not set(members).issubset(inventory['items']): - raise ValueError('Duplicate or unshipped review item') - covered.update(members) - missing = set(inventory['items']) - covered - if missing: - raise ValueError(f'Unreviewed shipped items: {", ".join(sorted(missing)[:8])}') - return {'publication': 'PUBLIC', 'channel': inventory['channel'], - 'inventorySha256': identity(inventory), 'reviewedBy': review['reviewedBy'], - 'reviewedAt': review['reviewedAt']} - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('command', choices=('capture', 'check-public')) - parser.add_argument('--channel', required=True, choices=CHANNELS) - parser.add_argument('--root', type=Path) - parser.add_argument('--artifact', action='append', type=Path, default=[]) - parser.add_argument('--sbom', type=Path) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--reviews', type=Path, default=ROOT / 'assets/distribution-review.json') - args = parser.parse_args() - try: - if args.root and args.output.resolve().is_relative_to(args.root.resolve()): - raise ValueError('Write inventories outside their input payload directory') - record = capture(args.channel, root=args.root, artifacts=args.artifact, sbom=args.sbom) - args.output.write_text(json.dumps(record, indent=2, sort_keys=True) + '\n') - print(f'{args.channel} inventory SHA-256: {identity(record)}') - if args.command == 'check-public': - verify(record, json.loads(args.reviews.read_text())) - print('PUBLIC distribution review passed') - return 0 - except (OSError, ValueError, KeyError, TypeError, subprocess.CalledProcessError) as error: - print(f'distribution-review: {error}', file=sys.stderr) - return 1 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/image/download_cache.py b/image/download_cache.py deleted file mode 100644 index f3ba5cda..00000000 --- a/image/download_cache.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Reuse only inventory-checksummed downloads, never builder-installed state.""" -import argparse -import http.client -import json -import os -from pathlib import Path, PurePosixPath -import re -import shutil -import tempfile -import time -import urllib.request -import fcntl - -import yaml -from build_support import atomic_json, digest - -COPR_SHA256 = "581bd602d10705eb89003680335ffc3684b922c587073b6e52c371f949843fd7" - - -def pins(root): - inventory = yaml.safe_load((Path(root) / "inventory/group_vars/all.yml").read_text()) - result = [] - for app in inventory.get("github_release_apps", []) + inventory.get("nerd_font_apps", []): - tag = re.sub(r"[^A-Za-z0-9._+-]", "_", str(app["version"])) - result.append((app["checksum"].removeprefix("sha256:"), - f"/var/cache/cybexos-upstream/{app['name']}/{tag}/", app["asset_regex"])) - for font in inventory.get("pinned_font_archives", []): - result.append((font["checksum"].removeprefix("sha256:"), - f"/var/cache/cybexos-upstream/{font['name']}/{font['version']}.zip", None)) - for font in inventory.get("pinned_font_files", []): - for checksum, filename in (("checksum", "filename"), ("license_checksum", "license_filename")): - result.append((font[checksum].removeprefix("sha256:"), - f"/usr/local/share/fonts/{font['name']}/{font['version']}/{font[filename]}", None)) - result.append((COPR_SHA256, "/home/builder/build/hyprland.gpg", None)) - return result - - -def allowed(entry, contract): - path = entry.get("path", "") - if not path.startswith("/") or ".." in PurePosixPath(path).parts or "\x00" in path: - return False - for checksum, location, pattern in contract: - if entry.get("sha256") != checksum: - continue - if pattern is None and path == location: - return True - if pattern and str(PurePosixPath(path).parent) + "/" == location and re.search(pattern, PurePosixPath(path).name): - return True - return False - - -def verified_entries(directory, contract): - directory = Path(directory) - manifest = directory / "manifest.json" - if not manifest.exists(): - return [] - result = [] - for entry in json.loads(manifest.read_text())["entries"]: - if not allowed(entry, contract): - continue # An older inventory may leave obsolete entries; never import them. - blob = directory / entry["sha256"] - if blob.is_symlink() or not blob.is_file() or digest(blob) != entry["sha256"]: - # Corrupt caches are disposable; miss rather than trust them. - continue - result.append(entry) - return result - - -def merge_cache(source, destination, contract): - source, destination = Path(source), Path(destination) - destination.mkdir(parents=True, exist_ok=True) - with (destination / ".lock").open("a") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - existing = {entry["path"]: entry for entry in verified_entries(destination, contract)} - for entry in verified_entries(source, contract): - target = destination / entry["sha256"] - if not target.is_file() or digest(target) != entry["sha256"]: - with tempfile.NamedTemporaryFile(dir=destination, delete=False) as stream: - temporary = Path(stream.name) - try: - shutil.copyfile(source / entry["sha256"], temporary) - if digest(temporary) != entry["sha256"]: - raise ValueError("Download cache changed during transfer") - temporary.replace(target) - finally: - temporary.unlink(missing_ok=True) - existing[entry["path"]] = entry - atomic_json(destination / "manifest.json", {"entries": list(existing.values())}) - - -def export_cache(directory, contract, filesystem=Path("/")): - directory = Path(directory) - directory.mkdir(parents=True, exist_ok=True) - entries = [] - for checksum, location, pattern in contract: - base = filesystem / location.lstrip("/") - candidates = list(base.iterdir()) if pattern and base.is_dir() else ([] if pattern else [base]) - for candidate in candidates: - if not candidate.is_file() or candidate.is_symlink(): - continue - entry = {"path": "/" + str(candidate.relative_to(filesystem)), "sha256": checksum} - if not allowed(entry, contract) or digest(candidate) != checksum: - continue - shutil.copyfile(candidate, directory / checksum) - entries.append(entry) - atomic_json(directory / "manifest.json", {"entries": entries}) - - -def import_cache(directory, contract, filesystem=Path("/")): - for entry in verified_entries(directory, contract): - target = filesystem / entry["path"].lstrip("/") - # Never let a poisoned guest path redirect a root copy outside its contract. - if any(parent.is_symlink() for parent in [target, *target.parents]): - raise ValueError("Refusing a symlink in download cache destination") - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(Path(directory) / entry["sha256"], target) - target.chmod(0o644) - - -def cached_download(url, checksum, directory): - """Content-addressed cloud base cache with verified atomic downloads.""" - directory = Path(directory) - directory.mkdir(parents=True, exist_ok=True) - target = directory / checksum - with (directory / (checksum + ".lock")).open("a") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - if target.is_file() and not target.is_symlink() and digest(target) == checksum: - return target - with tempfile.NamedTemporaryFile(dir=directory, prefix=".download-", delete=False) as stream: - temporary = Path(stream.name) - try: - for attempt in range(1, 5): - try: - with urllib.request.urlopen(url, timeout=60) as response, temporary.open("wb") as stream: - shutil.copyfileobj(response, stream, 1024 * 1024) - if digest(temporary) == checksum: - temporary.replace(target) - break - problem = "checksum mismatch" - except (OSError, http.client.HTTPException) as error: - problem = str(error) or type(error).__name__ - temporary.unlink(missing_ok=True) - print(f"Download attempt {attempt} of 4 failed: {problem}", flush=True) - if attempt == 4: - raise RuntimeError(f"Could not download verified builder image: {problem}") - time.sleep(10 * attempt) - finally: - temporary.unlink(missing_ok=True) - return target - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("action", choices=("import", "export")) - parser.add_argument("directory", type=Path) - args = parser.parse_args() - if os.geteuid() != 0 or Path("/etc/hostname").read_text().strip() != "image-builder": - parser.error("only the disposable image builder may import/export guest cache") - contract = pins(Path(__file__).resolve().parents[1]) - (import_cache if args.action == "import" else export_cache)(args.directory, contract) - - -if __name__ == "__main__": - main() diff --git a/image/dracut-live.conf b/image/dracut-live.conf deleted file mode 100644 index f634d63d..00000000 --- a/image/dracut-live.conf +++ /dev/null @@ -1,8 +0,0 @@ -# Generic live initramfs. Do not force legacy IDE/FireWire modules that were -# removed from modern kernels (livecd-tools 31 still names them). -filesystems+=" vfat isofs ext4 xfs btrfs squashfs " -# Include early kernel modesetting for the generic live image. Let the kernel -# select the matching GPU driver; do not force a vendor or blacklist Nouveau. -add_dracutmodules+=" livenet dmsquash-live pollcdrom drm " -hostonly="no" -dracut_rescue_image="no" diff --git a/image/fedora_target.py b/image/fedora_target.py deleted file mode 100644 index 82bd7212..00000000 --- a/image/fedora_target.py +++ /dev/null @@ -1,36 +0,0 @@ -"""The Fedora release image tooling targets, read from the installation contract. - -`fedora_release` in inventory/group_vars/all.yml is the release both -installation paths assert at runtime (site.yml and image/provision.yml), and -release-manifest.json `supportedFedora` is what updates and major upgrades -accept. Image tooling derives its release from the former and refuses one the -latter does not support. Checksum-pinned inputs that still name a release (the -Fedora Cloud builder image, for one) are kept in step by -image/test_fedora_release.py. `python3 image/fedora_target.py` prints the -release for shell steps. -""" -import json -from pathlib import Path -import re - -ROOT = Path(__file__).resolve().parents[1] -# One quoted decimal, exactly as the inventory declares it. CI workflows and -# the image builder call this module rather than parsing the line themselves. -DECLARATION = re.compile(r'^fedora_release: "([1-9][0-9]*)"$', re.MULTILINE) - - -def fedora_release(root=ROOT): - """Return the targeted release as a string, e.g. "44".""" - root = Path(root) - declared = DECLARATION.findall((root / "inventory/group_vars/all.yml").read_text()) - if len(declared) != 1: - raise ValueError('inventory/group_vars/all.yml must declare fedora_release once, as a quoted number') - release = declared[0] - supported = json.loads((root / "release-manifest.json").read_text()).get("supportedFedora") - if not isinstance(supported, list) or release not in supported: - raise ValueError(f"Fedora {release} is not in release-manifest.json supportedFedora") - return release - - -if __name__ == "__main__": - print(fedora_release()) diff --git a/image/github_release.py b/image/github_release.py deleted file mode 100644 index 56e41b9d..00000000 --- a/image/github_release.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Prepare verified GitHub Release assets and a small GitHub Pages RPM repository.""" -import argparse -import hashlib -import gzip -import json -from pathlib import Path -import re -import shutil -import subprocess -import tempfile -from urllib.parse import urljoin -import xml.etree.ElementTree as ET - -from qualification_contract import FRESH_SCENARIOS, validate as validate_qualification -from release_metadata import fingerprint, public_key -from release_repository import rename_new_directory, sha256, verify_signed_rpm -from sbom import sbom_name, verify_release_sbom - -ASSET_LIMIT = 2 * 1024 ** 3 -ISO_PART_SIZE = 1900 * 1024 ** 2 - - -def github_url(repository, tag): - if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', repository): - raise ValueError('Use an owner/repository GitHub name') - if not re.fullmatch(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', tag): - raise ValueError('Use a versioned vX.Y.Z release tag') - return f'https://github.com/{repository}/releases/download/{tag}' - - -def verify_checksums(directory, filename="SHA256SUMS"): - manifest = directory / filename - checked = set() - for line in manifest.read_text().splitlines(): - match = re.fullmatch(r'([0-9a-f]{64}) (.+)', line) - if not match: - raise ValueError('Invalid checksum manifest') - if not re.fullmatch(r'[A-Za-z0-9_.+~^/-]+', match[2]): - raise ValueError('Checksum filenames must be safe ASCII paths') - name = Path(match[2]) - if name.is_absolute() or '..' in name.parts or name.as_posix() in checked: - raise ValueError('Unsafe or repeated checksum filename') - candidate = directory / name - if candidate.is_symlink() or not candidate.is_file() or not candidate.resolve().is_relative_to(directory.resolve()): - raise ValueError('Checksum file escapes the artifact directory') - if sha256(candidate) != match[1]: - raise ValueError(f'Artifact checksum mismatch: {name}') - checked.add(name.as_posix()) - inventory = set() - for path in directory.rglob('*'): - if path.is_symlink() or (not path.is_dir() and not path.is_file()): - raise ValueError('Artifact trees must contain only regular files and directories') - if path.is_file() and path != manifest: - inventory.add(path.relative_to(directory).as_posix()) - if checked != inventory: - raise ValueError('Checksum inventory does not cover the complete artifact directory') - return checked - - -def verify_release(directory, expected, temporary): - public_key(directory / 'CYBEXOS-desktop.asc', expected) - gpg_home = temporary / 'gpg' - gpg_home.mkdir(mode=0o700) - gpg = ['gpg', '--no-options', '--homedir', str(gpg_home), '--batch', '--no-autostart'] - subprocess.run([*gpg, '--import', str(directory / 'CYBEXOS-desktop.asc')], check=True, capture_output=True) - for relative in ('release.json', 'repodata/repomd.xml'): - subprocess.run([*gpg, '--verify', str(directory / (relative + '.asc')), str(directory / relative)], - check=True, capture_output=True) - - -def split_iso(source, destination, chunk_size=ISO_PART_SIZE): - """Keep the original filename/checksum, with a deterministic reconstruction manifest.""" - if not source.name.isascii() or not re.fullmatch(r'[A-Za-z0-9._-]+\.iso', source.name): - raise ValueError('ISO filename must be ASCII without spaces') - if not isinstance(chunk_size, int) or not 0 < chunk_size < ASSET_LIMIT: - raise ValueError('ISO part size must be positive and below the asset limit') - digest = hashlib.sha256() - parts = [] - with source.open('rb') as stream: - number = 0 - while True: - remaining = chunk_size - name = f'{source.name}.part-{number:03d}' - part = destination / name - part_digest = hashlib.sha256() - size = 0 - with part.open('xb') as output: - while remaining: - block = stream.read(min(8 * 1024 * 1024, remaining)) - if not block: - break - output.write(block) - digest.update(block) - part_digest.update(block) - size += len(block) - remaining -= len(block) - if not size: - part.unlink() - break - parts.append({'file': name, 'bytes': size, 'sha256': part_digest.hexdigest()}) - number += 1 - if not parts: - raise ValueError('ISO is empty') - record = {'format': 1, 'file': source.name, 'bytes': sum(item['bytes'] for item in parts), - 'sha256': digest.hexdigest(), 'parts': parts} - (destination / (source.name + '.parts.json')).write_text(json.dumps(record, indent=2) + '\n') - (destination / (source.name + '.sha256')).write_text(f"{record['sha256']} {source.name}\n") - return record - - -def verify_metadata(directory, packages): - """Bind authenticated repomd metadata to the exact RPMs we will upload.""" - namespace = {'r': 'http://linux.duke.edu/metadata/repo', - 'p': 'http://linux.duke.edu/metadata/common'} - root = ET.parse(directory / 'repodata/repomd.xml').getroot() - seen, primary = set(), None - for entry in root.findall('r:data', namespace): - location = entry.find('r:location', namespace) - checksum = entry.find('r:checksum', namespace) - if location is None or checksum is None or checksum.get('type') != 'sha256': - raise ValueError('Repository metadata requires SHA-256 locations') - relative = location.get('href', '') - path = Path(relative) - if path.is_absolute() or '..' in path.parts or len(path.parts) != 2 or path.parts[0] != 'repodata': - raise ValueError('Repository metadata location escapes repodata') - if relative in seen: - raise ValueError('Repeated repository metadata location') - seen.add(relative) - source = directory / path - if sha256(source) != checksum.text: - raise ValueError('Signed repository metadata checksum mismatch') - if entry.get('type') == 'primary': - if primary is not None: - raise ValueError('Repeated primary repository metadata') - if source.suffix != '.gz': - raise ValueError('Primary metadata must use gzip compression') - with gzip.open(source, 'rb') as stream: - content = stream.read(64 * 1024 * 1024 + 1) - if len(content) > 64 * 1024 * 1024: - raise ValueError('Primary repository metadata exceeds the size limit') - primary = ET.fromstring(content) - if primary is None: - raise ValueError('Repository is missing primary metadata') - listed = set() - records = {package['sha256']: package for package in packages} - if len(records) != len(packages): - raise ValueError('Repeated package digest') - for package in primary.findall('p:package', namespace): - checksum = package.find('p:checksum', namespace) - location = package.find('p:location', namespace) - if checksum is None or checksum.get('type') != 'sha256' or location is None: - raise ValueError('Primary package requires a SHA-256 checksum and URL') - record = records.get(checksum.text) - if record is None or checksum.text in listed: - raise ValueError('Primary metadata does not match the release packages') - listed.add(checksum.text) - base = location.get('{http://www.w3.org/XML/1998/namespace}base', '') - if urljoin(base, location.get('href', '')) != record['url']: - raise ValueError('Primary metadata package URL differs from this GitHub Release') - version = package.find('p:version', namespace) - if (package.findtext('p:name', namespaces=namespace) != record['name'] - or package.findtext('p:arch', namespaces=namespace) != record['arch'] - or version is None - or any(version.get(key) != record[field] for key, field in - (('epoch', 'epoch'), ('ver', 'version'), ('rel', 'release')))): - raise ValueError('Primary metadata package identity differs from the manifest') - if listed != set(records): - raise ValueError('Primary metadata omits a release package') - allowed = seen | {'repodata/repomd.xml', 'repodata/repomd.xml.asc'} - present = {str(path.relative_to(directory)) for path in (directory / 'repodata').rglob('*') if path.is_file()} - if present != allowed: - raise ValueError('Repository contains unreferenced metadata files') - - -def verify_qualifications(paths, iso_digest, packages): - """Require fresh installer and prior-release upgrade/recovery evidence.""" - scenarios = FRESH_SCENARIOS - fresh, upgrade, reports = set(), False, [] - candidates = {package['unsigned_input_sha256'] for package in packages - if package.get('name', 'cybexos-desktop') == 'cybexos-desktop'} - offline = next((package['unsigned_input_sha256'] for package in packages - if package.get('name') == 'cybexos-offline-apps'), None) - for path in paths: - path = Path(path) - if path.stat().st_size > 1024 * 1024: - raise ValueError('Qualification report exceeds the size limit') - report = json.loads(path.read_text()) - if not isinstance(report, dict) or report.get('status') != 'passed': - raise ValueError('Every qualification report must have passed') - validate_qualification(report) - checks = report.get('checks') - if not isinstance(checks, list) or not all(isinstance(item, str) for item in checks): - raise ValueError('Qualification checks must be a list of completed check names') - prior = report.get('iso_sha256', '') - if not isinstance(prior, str) or not re.fullmatch(r'[0-9a-f]{64}', prior): - raise ValueError('Qualification must identify the tested ISO SHA-256') - if (prior == iso_digest and report['qualification_kind'] == 'fresh' - and report['boot_entry'] == 'install' and report.get('scenario') in scenarios): - if report['scenario'].startswith('plain-'): - checkout = report.get('checkout_parity', {}) - content = report.get('source_content_sha256', '') - if (not isinstance(content, str) or not re.fullmatch(r'[0-9a-f]{64}', content) - or not {'installed-parity-fresh', 'installed-parity-saved'} <= set(checks) - or checkout.get('status') != 'passed' or checkout.get('scenario') != report['scenario'] - or checkout.get('source_content_sha256') != content - or checkout.get('source_revision') != report.get('source_revision') - or not {'full-checkout-installation', 'installed-parity-fresh', 'installed-parity-saved', - 'full-graphical-session'} <= set(checkout.get('checks', []))): - raise ValueError('Release requires same-source real checkout/ISO parity for fresh and saved choices') - fresh.add(report['scenario']) - if (report['qualification_kind'] == 'upgrade' and prior != iso_digest - and report.get('candidate_rpm_sha256') in candidates - and (offline is None or report.get('candidate_offline_rpm_sha256') == offline) - and {'installed-rpm-upgrade', 'recovery-boot-restore'} <= set(checks)): - upgrade = True - reports.append(report) - sources = {(report['source_revision'], report['source_content_sha256']) for report in reports} - if len(sources) > 1: - raise ValueError('Qualification reports must bind the same candidate source revision and content digest') - if fresh != scenarios: - raise ValueError('Release requires all four fresh ISO installer qualifications: ' + ', '.join(sorted(scenarios - fresh))) - if not upgrade: - raise ValueError('Release requires a different prior ISO with the exact candidate RPM upgrade and recovery qualification') - return reports - - -def prepare(signed, artifacts, destination, repository, tag, expected, qualifications=()): - signed, artifacts, destination = Path(signed), Path(artifacts), Path(destination).absolute() - expected = fingerprint(expected) - downloads = github_url(repository, tag) - if destination.exists() or destination.is_symlink(): - raise ValueError('Output must be a new directory') - repository_files = verify_checksums(signed) - artifact_files = verify_checksums(artifacts) - manifest = json.loads((signed / 'release.json').read_text()) - channel = json.loads((signed / 'update-channel.json').read_text()) - owner, project = repository.split('/') - pages_url = f'https://{owner.lower()}.github.io/{project}/44/x86_64' - if (manifest.get('format') != 1 or manifest.get('baseurl') != pages_url - or channel != {'baseurl': pages_url, 'fingerprint': expected, 'key_file': 'CYBEXOS-desktop.asc'}): - raise ValueError('Release channel must match this exact GitHub Pages repository') - if manifest['fingerprint'] != expected: - raise ValueError('Release manifest uses an unexpected signing key') - packages = manifest['packages'] - if not isinstance(packages, list) or not packages: - raise ValueError('Release contains no RPMs') - package_names = set() - identities = set() - for package in packages: - if (package.get('name') not in ('cybexos-desktop', 'cybexos-offline-apps') - or package.get('arch') != 'x86_64' - or (package['name'] == 'cybexos-desktop' and package.get('version') != tag[1:].replace('-', '~', 1)) - or (package['name'] == 'cybexos-offline-apps' and not re.fullmatch(r'\d+\.\d+\.\d+', package.get('version', '')))): - raise ValueError('RPM identity must match the release tag and desktop architecture') - if package['name'] in identities: - raise ValueError('Release contains duplicate package names') - identities.add(package['name']) - path = signed / package['file'] - if not re.fullmatch(r'Packages/[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm', package['file']) or path.name in package_names: - raise ValueError('RPM filenames must be safe, distinct release assets') - package_names.add(path.name) - for key in ('sha256', 'unsigned_input_sha256'): - if not isinstance(package.get(key), str) or not re.fullmatch(r'[0-9a-f]{64}', package[key]): - raise ValueError('Release package is missing a complete SHA-256 identity') - if package['file'] not in repository_files or package.get('url') != downloads + '/' + path.name: - raise ValueError('RPM metadata must reference this exact GitHub Release') - if path.stat().st_size >= ASSET_LIMIT: - raise ValueError('Desktop RPM exceeds the GitHub 2 GiB asset limit; split the package before release') - if sha256(path) != package['sha256']: - raise ValueError('Signed manifest RPM digest mismatch') - if 'cybexos-desktop' not in identities: - raise ValueError('Release requires the desktop RPM') - isos = [artifacts / name for name in artifact_files if name.endswith('.iso')] - if len(isos) != 1: - raise ValueError('Exactly one checksum-verified ISO is required') - iso_digest = sha256(isos[0]) - # The SPDX SBOM must describe this exact ISO and the candidate RPM bytes. - sbom = artifacts / sbom_name(isos[0].name) - if sbom.name not in artifact_files or [name for name in artifact_files if name.endswith('.spdx.json')] != [sbom.name]: - raise ValueError('Exactly one checksum-verified SPDX SBOM named after the ISO is required') - sbom_digest = sha256(sbom) - verify_release_sbom(sbom, isos[0].name, iso_digest, - {package['name']: package['unsigned_input_sha256'] for package in packages}) - reports = verify_qualifications(qualifications, iso_digest, packages) - destination.parent.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix='.cybexos-github-', dir=destination.parent) as directory: - work = Path(directory) - verify_release(signed, expected, work) - verify_metadata(signed, packages) - stage = work / 'output' - assets = stage / 'assets' - pages = stage / 'pages/44/x86_64' - assets.mkdir(parents=True) - pages.mkdir(parents=True) - for package in packages: - source = signed / package['file'] - copied = assets / source.name - shutil.copyfile(source, copied) - if sha256(copied) != package['sha256']: - raise ValueError('RPM changed during release preparation') - verify_signed_rpm(copied, signed / 'CYBEXOS-desktop.asc', work) - for name in ('release.json', 'release.json.asc', 'CYBEXOS-desktop.asc', 'update-channel.json'): - content = (signed / name).read_bytes() - if name == 'update-channel.json' and json.loads(content) != channel: - raise ValueError('Channel configuration changed during release preparation') - # Both delivery locations must contain the same snapshot; the - # copied Pages signatures are verified again below. - (assets / name).write_bytes(content) - (pages / name).write_bytes(content) - shutil.copytree(signed / 'repodata', pages / 'repodata') - iso = split_iso(isos[0], assets) - if iso['sha256'] != iso_digest: - raise ValueError('ISO changed during release preparation') - shutil.copyfile(Path(__file__).with_name('reconstruct-iso'), assets / 'reconstruct-iso.py') - shutil.copyfile(sbom, assets / sbom.name) - if sha256(assets / sbom.name) != sbom_digest: - raise ValueError('SBOM changed during release preparation') - (assets / 'qualification-reports.json').write_text(json.dumps(reports, indent=2) + '\n') - copied_verify = work / 'copied-verification' - copied_verify.mkdir() - verify_release(pages, expected, copied_verify) - verify_metadata(pages, packages) - # Metadata checksums contain only files hosted by Pages, never the large RPMs. - for target in (assets, pages): - files = sorted(path for path in target.rglob('*') if path.is_file()) - checksum_name = 'desktop-SHA256SUMS' if target == assets else 'SHA256SUMS' - (target / checksum_name).write_text(''.join(f'{sha256(path)} {path.relative_to(target)}\n' for path in files)) - (stage / 'pages/.nojekyll').touch() - (stage / 'pages/index.html').write_text('' - 'CybexOS updates

CybexOS desktop updates

' - '

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

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

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

- -

-    
-    
-

-
- -
- - diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.css b/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.css deleted file mode 100644 index 029581fa..00000000 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.css +++ /dev/null @@ -1,194 +0,0 @@ -@font-face { - font-family: Figtree; - src: local(Figtree); -} - -:root { - /* Keep the palette aligned with the Cybex Welcome screen. */ - color-scheme: dark; - font: 16px Figtree, Inter, system-ui, sans-serif; - --background: #0e0e10; - --ink: #f4f4f5; - --muted: #b4b4bb; - --faint: #80808a; - --surface: #17171a; - --surface-hover: #202024; - --line: #2a2a30; - --accent: #dd0034; - --accent-hover: #f0164a; - --accent-down: #b8002b; - --accent-text: #ff6b86; - color: var(--ink); - background: var(--background); -} - -* { box-sizing: border-box; } -body { - margin: 0; - min-height: 100vh; - display: grid; - grid-template-columns: minmax(265px, 33%) 1fr; -} -aside { - padding: 40px; - display: flex; - flex-direction: column; - background: var(--surface); - border-right: 1px solid var(--line); -} -.wordmark { display: block; width: auto; height: 30px; align-self: flex-start; } -h1 { - font-size: clamp(34px, 3.8vw, 54px); - line-height: 1.05; - font-weight: 600; - letter-spacing: -.035em; - margin: 64px 0 18px; -} -aside p { line-height: 1.6; color: var(--muted); } -ol { padding: 0; list-style: none; margin: 30px 0 50px; } -li { margin: 17px 0; } -#steps li { color: var(--muted); font-size: 12px; letter-spacing: .08em; } -#steps span { font-size: 15px; letter-spacing: 0; margin-left: 12px; } -#steps [aria-current] { color: var(--accent-text); } -aside small { font-size: 10px; letter-spacing: .16em; margin-top: auto; color: var(--faint); } -main { - width: 100%; - min-width: 0; - max-width: 950px; - padding: 52px clamp(28px, 5vw, 84px); - align-self: center; -} -.eyebrow { letter-spacing: .1em; font-size: 12px; font-weight: 600; color: var(--faint); } -h2 { - font-size: clamp(30px, 3.4vw, 38px); - letter-spacing: -.035em; - line-height: 1.12; - font-weight: 600; - margin: 16px 0; -} -p { color: var(--muted); line-height: 1.55; } -label { display: block; margin: 20px 0 0; font-size: 14px; } -input, select { - display: block; - border: 1px solid var(--line); - background: var(--surface); - color: var(--ink); - border-radius: 12px; - width: 100%; - min-width: 0; - min-height: 48px; - padding: 10px 12px; - font: inherit; - margin-top: 8px; -} -input::placeholder { color: var(--faint); opacity: 1; } -input:hover, select:hover { border-color: var(--faint); } -input:focus, select:focus, button:focus-visible, a:focus-visible, summary:focus-visible { - outline: 2px solid var(--ink); - outline-offset: 3px; -} -.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } -.hint { font-size: 13px; color: var(--muted); } -.card { - background: var(--surface); - border: 1px solid var(--line); - border-radius: 16px; - padding: 23px; - margin: 24px 0; -} -.card p { margin-bottom: 0; font-size: 14px; } -#disk-details { white-space: pre-line; line-height: 1.6; overflow-wrap: anywhere; } -#rescan-disks { margin-top: 12px; } -#failure-help button { margin-top: 12px; } -details { border-top: 1px solid var(--line); margin: 24px 0; padding-top: 16px; } -summary { cursor: pointer; font-size: 14px; color: var(--muted); } -button, a { font: inherit; } -button { - cursor: pointer; - border: 0; - border-radius: 12px; - min-height: 48px; - padding: 12px 26px; - font-weight: 600; -} -.primary { background: var(--accent); color: #fff; } -.primary:hover { background: var(--accent-hover); } -.primary:active { background: var(--accent-down); } -.secondary { background: var(--surface); color: var(--ink); } -.secondary:hover { background: var(--surface-hover); } -button:disabled { opacity: .5; } -footer { - display: flex; - flex-wrap: wrap; - gap: 16px; - margin-top: 30px; - padding-top: 20px; - border-top: 1px solid var(--line); - justify-content: flex-end; -} -.check { display: flex; gap: 12px; align-items: flex-start; line-height: 1.5; } -.check input { - flex: 0 0 19px; - width: 19px; - height: 19px; - min-height: 0; - margin: 3px 0 0; - accent-color: var(--accent); -} -.erase { padding: 20px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; } -dl { display: grid; grid-template-columns: 130px 1fr; gap: 14px; padding: 24px; background: var(--surface); border-radius: 16px; } -dt { color: var(--muted); font-size: 14px; } -dd { margin: 0; overflow-wrap: anywhere; white-space: pre-line; } -#error { padding: 17px 20px; border: 1px solid var(--accent-text); background: var(--surface); color: var(--accent-text); border-radius: 12px; margin-bottom: 22px; } -#busy { padding: 20px; color: var(--muted); } -#actions { padding-left: 18px; font-size: 13px; color: var(--muted); } -#warnings { font-size: 13px; color: var(--accent-text); } -progress { width: 100%; height: 10px; margin-top: 34px; accent-color: var(--accent); } -a { color: var(--accent-text); display: inline-block; margin: 18px; } -#startup-retry { margin-top: 20px; } -fieldset { min-width: 0; } -legend { padding: 0 6px; font-weight: 600; } -fieldset .check { margin-top: 14px; } -#alongside-details { margin-top: 14px; font-size: 14px; color: var(--muted); white-space: pre-line; } -#space { accent-color: var(--accent); padding: 0; min-height: 32px; border: 0; background: transparent; } -#seed-errors { color: var(--accent-text); padding-left: 18px; line-height: 1.5; } -#countdown { font-size: 22px; color: var(--ink); font-variant-numeric: tabular-nums; } -#log { - max-height: 45vh; - min-height: 8em; - overflow: auto; - margin: 12px 0 0; - padding: 14px 16px; - background: #09090b; - border: 1px solid var(--line); - border-radius: 12px; - color: var(--muted); - font: 12px/1.5 ui-monospace, "JetBrains Mono", monospace; - white-space: pre-wrap; - overflow-wrap: anywhere; -} -#log-panel footer { margin-top: 16px; padding-top: 0; border-top: 0; justify-content: flex-start; } -[hidden] { display: none !important; } -button:disabled, input:disabled, select:disabled { cursor: wait; } - -@media (max-width: 800px) { - body { grid-template-columns: 1fr; } - aside { padding: 24px; border-right: 0; border-bottom: 1px solid var(--line); } - aside h1, aside p, aside small { display: none; } - ol { display: flex; flex-wrap: wrap; gap: 12px 20px; margin: 24px 0 0; } - #steps li { margin: 0; } - #steps span { font-size: 12px; margin-left: 5px; } - main { padding: 30px 22px; } - .pair { grid-template-columns: 1fr; } -} -@media (max-width: 480px) { - dl { grid-template-columns: 1fr; gap: 8px; } - dd + dt { margin-top: 8px; } -} -@media (prefers-reduced-motion: no-preference) { - section { animation: arrive .22s ease-out; } - @keyframes arrive { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } - } -} diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.js b/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.js deleted file mode 100644 index 00427ecb..00000000 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.js +++ /dev/null @@ -1,516 +0,0 @@ -import {Wizard, accountName, size, systemsLabel, clampSpace, shrinkFor, countdownText, reviewFields, confirmationCopy} from "./model.mjs"; -const wizard = new Wizard(); -const $ = id => document.getElementById(id); -const ids = ["setup", "location", "review", "progress", "unattended"]; -let initializing = true; -let polling; -let installationComplete = false; -let appliedKeyboard = null; -let timezoneChosen = false; -let availableDisks = []; -// The alongside analysis for the selected disk: {disk, options} or {disk, error}. -let alongside = null; -let countdown = null; -let seedDigest = null; -let logTimer; -let logPending = false; - -function fail(error) { - $("error").textContent = error.message || "The installer could not complete this operation."; - $("error").hidden = false; -} -function render() { - ids.forEach(id => { $(id).hidden = initializing || wizard.page !== id; }); - document.querySelectorAll("#steps li").forEach((item, index) => { - item.removeAttribute("aria-current"); - if (index === ids.indexOf(wizard.page)) item.setAttribute("aria-current", "step"); - }); - // The log panel stays usable while an operation runs; that is when it helps. - document.querySelectorAll("button, input, select").forEach(item => { item.disabled = wizard.busy && !item.closest("#log-panel"); }); - $("install").disabled = wizard.busy || !$("erase").checked; - ["password", "confirm", "keyboard-test"].forEach(id => { $(id).disabled = wizard.busy || appliedKeyboard !== $("keyboard").value; }); - // Cancelling a countdown must always be possible. - $("seed-cancel").disabled = !countdown && wizard.busy; - $("busy").hidden = !initializing && !wizard.busy; -} -function call(command, data = {}, onProgress = () => {}) { - return new Promise((resolve, reject) => { - let buffer = "", result; - const process = cockpit.spawn(["/usr/libexec/cybexos-installer-backend", command], - {superuser: "require", err: "message"}); - process.stream(chunk => { - buffer += chunk; - let index; - while ((index = buffer.indexOf("\n")) >= 0) { - const line = buffer.slice(0, index); buffer = buffer.slice(index + 1); - try { - const event = JSON.parse(line); - if (event.event === "result") result = event; - if (event.event === "progress") onProgress(event); - } catch { /* Ignore non-protocol diagnostics without displaying them. */ } - } - }); - const finish = () => result?.ok ? resolve(result.data) - : reject(new Error(result?.error || "The installer connection was interrupted. Refresh its status before continuing.")); - process.then(finish, finish); - process.input(JSON.stringify(data), false); - }); -} -async function run(message, work) { - $("error").hidden = true; - $("busy").textContent = message; - try { - const promise = wizard.operation(work); - render(); - await promise; - } catch (error) { fail(error); } - finally { render(); } -} -function options(id, values, selected) { - $(id).replaceChildren(...values.map(value => { - const option = document.createElement("option"); - option.value = typeof value === "object" ? value.id : value; - option.textContent = typeof value === "object" ? value.label : value; - return option; - })); - if (selected) $(id).value = selected; -} -function definitions(id, fields) { - $(id).replaceChildren(...fields.flatMap(([key, value]) => { - const term = document.createElement("dt"), detail = document.createElement("dd"); - term.textContent = key; detail.textContent = value; return [term, detail]; - })); -} -function partitionSummary(disk) { - if (!disk.partitions?.length) return "No existing partitions reported"; - return disk.partitions.map(partition => - `${partition.path || "Partition"} (${size(partition.size)}${partition.filesystem ? `, ${partition.filesystem}` : ""})`).join("; "); -} -function diskIdentity(disk) { - return [`${disk.model} · ${disk.path} · ${size(disk.size)}`, - `Serial: ${disk.serial || "Not reported"}`, - `WWN: ${disk.wwn || "Not reported"}`, - `Existing partitions: ${partitionSummary(disk)}`].join("\n"); -} -function selectedLayout() { - return !$("layout-choice").hidden && $("layout-alongside").checked && alongside?.options - && alongside.disk === $("disk").value ? "alongside" : "erase"; -} -function showLayout() { - const layout = selectedLayout(); - $("location-intro").textContent = layout === "alongside" - ? "CybexOS is installed next to the existing system. Everything else on the disk is kept." - : "Choose a disk for CybexOS. The entire selected disk will be erased."; - const choice = alongside?.options; - $("space-field").hidden = layout !== "alongside" || choice?.mode !== "shrink"; - if (alongside?.error) $("alongside-details").textContent = alongside.error; - else if (!choice) $("alongside-details").textContent = "Checking the free space on this disk…"; - else if (choice.mode === "free") - $("alongside-details").textContent = `CybexOS uses ${size(choice.space)} of free space. No partition is resized.`; - else { - const space = clampSpace(choice, $("space").value); - const shrink = shrinkFor(choice, space); - $("space-label").textContent = `Space for CybexOS: ${size(space)}`; - $("alongside-details").textContent = - `There is not enough free space, so ${choice.partition.path} is shrunk from ` + - `${size(choice.partition.size)} to ${size(shrink.size)}. Back up that system first.`; - } -} -function showSelectedDisk() { - const disk = availableDisks.find(item => item.name === $("disk").value); - $("disk-details").textContent = disk ? diskIdentity(disk) : "Choose a disk to see its identity and existing partitions."; - alongside = null; - const systems = disk?.systems || []; - $("layout-choice").hidden = !systems.length; - if (systems.length) { - const label = systemsLabel(systems); - $("layout-legend").textContent = `This disk already has ${label}`; - $("alongside-label").textContent = `Install alongside ${label}`; - $("layout-alongside").checked = true; - const name = disk.name; - run("Checking the free space on this disk…", async () => { - try { - const options = await call("alongside", {disk: name}); - alongside = {disk: name, options}; - if (options.mode === "shrink") { - $("space").min = options.min_space; $("space").max = options.max_space; - $("space").value = options.default_space; - } - } catch (error) { - // Installing alongside is unavailable on this disk; erasing still is. - alongside = {disk: name, error: error.message}; - $("layout-erase").checked = true; - } - showLayout(); - }); - } - showLayout(); -} -function showDisks(disks) { - availableDisks = disks; - options("disk", [{id: "", label: "Select a disk…"}, ...disks.map(disk => ({ - id: disk.name, label: `${disk.model} — ${size(disk.size)} — ${disk.path}${disk.removable ? " (removable)" : ""}` - }))]); - $("disk").value = ""; - showSelectedDisk(); -} -function timezoneOptions(zones, selected) { - const groups = new Map(); - for (const zone of zones) { - const [region, ...place] = zone.split("/"); - const option = document.createElement("option"); - option.value = zone; - option.textContent = place.length ? place.join(" / ").replaceAll("_", " ") : zone; - if (!place.length) { groups.set(zone, option); continue; } - if (!groups.has(region)) { - const group = document.createElement("optgroup"); - group.label = region; - groups.set(region, group); - } - groups.get(region).append(option); - } - $("timezone").replaceChildren(...groups.values()); - $("timezone").value = selected; -} -function showDetectedTimezone(zone) { - $("timezone").value = zone; - $("timezone-status").textContent = "Detected from your network location."; -} -function detectTimezone(zones) { - // Geolocation waits for the network; never hold setup up for it. - call("geolocate").then(result => { - if (!timezoneChosen && zones.includes(result.timezone)) showDetectedTimezone(result.timezone); - }, () => {}); -} -function waitForBackend() { - return new Promise((resolve, reject) => { - const ready = cockpit.file("/run/anaconda/backend_ready"); - const timeout = setTimeout(() => { - ready.close(); reject(new Error("Anaconda is taking longer than expected to start. Try again or open the full installer.")); - }, 120000); - ready.watch(content => { - if (content !== null) { clearTimeout(timeout); ready.close(); resolve(); } - }); - }); -} -function showOwnerMode() { - const oem = $("oem").checked; - $("username-field").hidden = oem; - $("username").required = !oem; - $("password-label").textContent = oem ? "Temporary disk passphrase" : "Password"; - $("confirm-label").textContent = oem ? "Confirm passphrase" : "Confirm password"; - $("password-hint").textContent = oem - ? "Give this passphrase to the new owner. They type it once at first start, then choose their own password. Without encryption it is not used." - : "Use at least 12 characters. Your chosen layout will also be used at the boot unlock screen."; -} -function account() { - return {...Object.fromEntries(["username", "password", "confirm", "keyboard", "locale", "timezone", "hostname"].map(id => [id, $(id).value])), - passwordless_wheel: $("passwordless-wheel").checked, oem: $("oem").checked}; -} -function showReview(plan) { - wizard.plan = plan; wizard.page = "review"; - $("erase").checked = false; - const copy = confirmationCopy(plan); - $("erase-label").textContent = copy.check; - $("install").textContent = copy.button; - definitions("summary", reviewFields(plan, diskIdentity)); - $("actions").replaceChildren(...plan.actions.map(action => { - const item = document.createElement("li"); - item.textContent = [action["action-description"], action["object-description"], action["device-name"]].filter(Boolean).join(" · "); - return item; - })); - $("warnings").textContent = plan.warnings.join("\n"); -} -function showProgress(data) { - wizard.page = "progress"; - $("progress-message").textContent = data.message || "Anaconda is preparing the installation…"; - if (data.total > 0) { $("meter").max = data.total; $("meter").value = data.step || 0; } - else $("meter").removeAttribute("value"); - if (data.phase === "complete") { - installationComplete = true; - clearInterval(polling); - $("progress-title").textContent = "Your workspace is ready."; - $("meter").max = 1; $("meter").value = 1; $("restart-help").hidden = false; - $("reboot").hidden = false; - } else if (data.phase === "failed-install") { - clearInterval(polling); - $("progress-title").textContent = "Installation needs attention."; - showLog(data.log_source || "steps"); - } - $("failure-help").hidden = data.phase !== "failed-install"; - render(); -} -// Diagnostics bypass run(): they never mark the wizard busy or block a step. -function refreshLog() { - if (logPending || !$("log-panel").open) return; - logPending = true; - call("log", {source: $("log-source").value}).then(data => { - const log = $("log"), following = log.scrollTop + log.clientHeight >= log.scrollHeight - 8; - log.textContent = data.available ? data.text : "This log has not been written yet."; - if (following) log.scrollTop = log.scrollHeight; - }, error => { $("log").textContent = error.message; }).finally(() => { logPending = false; }); -} -function showLog(source) { - if (!$("log-panel").open) { $("log-source").value = source; $("log-panel").open = true; } - refreshLog(); -} -function monitor() { - clearInterval(polling); - polling = setInterval(() => { - if (!wizard.busy) call("status").then(showProgress, fail); - }, 2000); -} -function seedFields(seed) { - const fields = []; - if (seed.disk) fields.push(["Disk", diskIdentity(seed.disk)], ["Disk changes", "Erase the entire disk"]); - fields.push(["Account", seed.oem ? "Created by the new owner at first start" - : `${seed.username}${seed.full_name ? ` (${seed.full_name})` : ""}`]); - fields.push(["Storage", seed.encrypted ? "Encrypted Btrfs · LUKS2" : "Btrfs · unencrypted"]); - fields.push(["Computer", `${seed.hostname} · ${seed.keyboard} · ${seed.locale} · ${seed.timezone}`]); - const extras = [seed.ssh_keys ? `${seed.ssh_keys} SSH key${seed.ssh_keys === 1 ? "" : "s"}` : "", - seed.ssh_server ? "SSH server on" : "", seed.tailscale ? "joins Tailscale at first start" : ""].filter(Boolean); - if (extras.length) fields.push(["Access", extras.join(" · ")]); - return fields; -} -function stopCountdown() { - clearInterval(countdown); - countdown = null; - $("countdown-meter").hidden = true; -} -function showSeedProblem(title, errors, summary) { - stopCountdown(); - wizard.page = "unattended"; - $("unattended-title").textContent = title; - $("unattended-intro").textContent = "Nothing was changed. Fix the seed volume and restart, or use the guided installer."; - definitions("seed-summary", summary ? seedFields(summary) : []); - $("seed-errors").replaceChildren(...errors.map(message => { - const item = document.createElement("li"); item.textContent = message; return item; - })); - $("countdown").textContent = ""; - $("seed-cancel").hidden = true; - $("seed-guided").hidden = false; - render(); -} -function startCountdown(plan) { - // The backend also refuses to install before this time has passed. - const ends = Date.now() + plan.countdown * 1000; - $("countdown-meter").max = plan.countdown; - $("countdown-meter").hidden = false; - const tick = () => { - const remaining = (ends - Date.now()) / 1000; - $("countdown").textContent = countdownText(remaining); - $("countdown-meter").value = Math.max(0, plan.countdown - remaining); - if (remaining > 0) return; - stopCountdown(); - run("Starting the installation…", async () => { - showProgress({phase: "installing"}); - try { - showProgress(await call("install", {token: plan.token, confirmed_disk: plan.disk.name, erase_confirmed: true}, showProgress)); - monitor(); - } catch (error) { - const state = await call("status").catch(() => null); - if (state && ["installing", "complete", "failed-install"].includes(state.phase)) { - showProgress(state); - if (state.phase === "installing") monitor(); - } else showSeedProblem("The installation did not start.", [error.message]); - throw error; - } - }); - }; - countdown = setInterval(tick, 250); - tick(); -} -async function unattended(found) { - wizard.page = "unattended"; - initializing = false; - seedDigest = found.seed?.digest || null; - if (found.cancelled) { - showSeedProblem("Unattended installation cancelled.", [], found.seed); - $("unattended-intro").textContent = "Nothing was changed. Remove the seed volume and restart, or use the guided installer."; - return; - } - if (!found.valid) { - showSeedProblem("The seed needs attention.", found.errors, found.seed); - return; - } - definitions("seed-summary", seedFields(found.seed)); - $("seed-errors").replaceChildren(); - render(); - $("busy").textContent = "Planning the installation with Anaconda…"; - const plan = await call("plan", {seed: found.seed.digest}); - wizard.plan = plan; - const changes = plan.actions.length; - definitions("seed-summary", [...seedFields(found.seed), - ["Anaconda plan", `${changes} planned disk change${changes === 1 ? "" : "s"}, validated`]]); - $("unattended-intro").textContent = "The matched disk will be erased when the countdown ends. Cancel to keep it unchanged."; - startCountdown(plan); -} -async function initialize() { - initializing = true; $("startup-retry").hidden = true; - await run("Starting Anaconda and finding your disks…", async () => { - const state = await call("status"); - if (["installing", "complete", "failed-install"].includes(state.phase)) { - initializing = false; showProgress(state); - if (state.phase === "installing") monitor(); - return; - } - await waitForBackend(); - const data = await call("inventory"); - options("keyboard", data.keyboards, data.keyboard); - options("locale", data.locales, data.locale); - timezoneOptions(data.timezones, data.timezone); - if (data.detected_timezone) showDetectedTimezone(data.detected_timezone); - else detectTimezone(data.timezones); - showDisks(data.disks); - // An attached seed volume replaces the questions with its countdown. - const found = await call("seed"); - if (found.found) { - await unattended(found); - return; - } - initializing = false; - const keyboard = await call("keyboard", {keyboard: $("keyboard").value}); - appliedKeyboard = keyboard.keyboard; - $("keyboard-status").textContent = `Keyboard applied. Boot unlock layout: ${keyboard.boot_keyboard}.`; - }); - if (initializing) { $("busy").hidden = true; $("startup-retry").hidden = false; } -} -$("keyboard").addEventListener("change", () => { - appliedKeyboard = null; - $("password").value = ""; $("confirm").value = ""; $("keyboard-test").value = ""; - run("Applying your keyboard layout…", async () => { - const result = await call("keyboard", {keyboard: $("keyboard").value}); - appliedKeyboard = result.keyboard; - $("keyboard-status").textContent = `Keyboard applied. Boot unlock layout: ${result.boot_keyboard}.`; - }); -}); -$("username").addEventListener("input", () => { - const field = $("username"), name = accountName(field.value); - if (name === field.value) return; - const {selectionStart, selectionEnd} = field; - field.value = name; field.setSelectionRange(selectionStart, selectionEnd); -}); -$("timezone").addEventListener("change", () => { - timezoneChosen = true; - $("timezone-status").textContent = ""; -}); -$("oem").addEventListener("change", showOwnerMode); -$("disk").addEventListener("change", showSelectedDisk); -$("layout-alongside").addEventListener("change", showLayout); -$("layout-erase").addEventListener("change", showLayout); -$("space").addEventListener("input", showLayout); -$("rescan-disks").addEventListener("click", () => run("Rescanning disks with Anaconda…", async () => { - const data = await call("rescan"); - showDisks(data.disks); -})); -$("account-form").addEventListener("submit", event => { - event.preventDefault(); - try { - if (appliedKeyboard !== $("keyboard").value) throw new Error("Apply your keyboard layout before entering your password."); - wizard.account(account()); wizard.move("location"); $("error").hidden = true; render(); - } - catch (error) { fail(error); } -}); -$("disk-form").addEventListener("submit", event => { - event.preventDefault(); - run("Planning your disk layout and checking it with Anaconda…", async () => { - const layout = selectedLayout(); - const data = {...wizard.account(account()), disk: $("disk").value, encrypted: $("encrypted").checked, layout}; - if (layout === "alongside") { - const shrink = shrinkFor(alongside.options, $("space").value); - if (shrink) data.shrink = shrink; - } - wizard.plan = null; - showReview(await call("plan", data)); - }); -}); -document.querySelectorAll(".back").forEach(button => button.addEventListener("click", () => { - try { wizard.move(button.dataset.page); render(); } catch (error) { fail(error); } -})); -$("erase").addEventListener("change", render); -$("install").addEventListener("click", () => { - let confirmation; - try { confirmation = wizard.confirmation($("erase").checked); } catch (error) { fail(error); return; } - run("Installing CybexOS…", async () => { - // Drop browser copies before the destructive transaction starts. - const entered = [$("password").value, $("confirm").value]; - $("password").value = ""; $("confirm").value = ""; - showProgress({phase: "installing"}); - try { showProgress(await call("install", confirmation, showProgress)); monitor(); } - catch (error) { - const state = await call("status").catch(() => null); - if (state && !["installing", "complete", "failed-install"].includes(state.phase)) { - // Rejected before Anaconda started: this review is no longer valid. - // Nothing was written, so keep the entered passwords for a new review. - [$("password").value, $("confirm").value] = entered; - wizard.plan = null; wizard.page = "location"; - } else if (state) { - showProgress(state); - if (state.phase === "installing") monitor(); - } - throw error; - } - }); -}); -$("seed-cancel").addEventListener("click", () => { - stopCountdown(); - run("Cancelling the unattended installation…", async () => { - await call("cancel-seed", {seed: seedDigest}); - showSeedProblem("Unattended installation cancelled.", []); - $("unattended-intro").textContent = "Nothing was changed. Remove the seed volume and restart, or use the guided installer."; - }); -}); -$("seed-guided").addEventListener("click", () => run("Opening the guided installer…", async () => { - stopCountdown(); - await call("reset"); - wizard.plan = null; - wizard.page = "setup"; - const keyboard = await call("keyboard", {keyboard: $("keyboard").value}); - appliedKeyboard = keyboard.keyboard; - $("keyboard-status").textContent = `Keyboard applied. Boot unlock layout: ${keyboard.boot_keyboard}.`; -})); -$("advanced").addEventListener("click", () => run("Opening the full installer…", async () => { - await call("advanced"); - $("password").value = ""; $("confirm").value = ""; - window.location.href = "../anaconda-webui/index.html"; -})); -$("refresh-status").addEventListener("click", () => run("Checking installation status…", async () => showProgress(await call("status")))); -$("save-diagnostics").addEventListener("click", () => run("Preparing redacted diagnostics…", async () => { - const report = await call("diagnostics"); - const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2) + "\n"], {type: "application/json"})); - const link = document.createElement("a"); - link.href = url; link.download = "cybexos-installer-diagnostics.json"; - document.body.append(link); link.click(); link.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); -})); -$("retry").addEventListener("click", initialize); -$("log-panel").addEventListener("toggle", () => { - clearInterval(logTimer); - if (!$("log-panel").open) return; - $("log").scrollTop = $("log").scrollHeight; - refreshLog(); - logTimer = setInterval(refreshLog, 2000); -}); -$("log-source").addEventListener("change", () => { $("log").textContent = ""; refreshLog(); }); -$("find-destinations").addEventListener("click", () => { - $("export-status").textContent = "Searching for USB drives…"; - call("log-destinations").then(data => { - options("log-destination", data.destinations.length - ? data.destinations.map(item => ({id: item.name, - label: `${item.label || item.name} — ${item.fstype} — ${(item.size / 1024**3).toFixed(1)} GiB — ${item.path}`})) - : [{id: "", label: "No writable USB drive found"}]); - $("export-status").textContent = ""; - }, error => { $("export-status").textContent = error.message; }); -}); -$("export-logs").addEventListener("click", () => { - if (!$("log-destination").value) { $("export-status").textContent = "Search for a USB drive first."; return; } - $("export-status").textContent = "Saving logs…"; - call("export-logs", {device: $("log-destination").value}).then( - data => { $("export-status").textContent = `Saved ${data.file} to ${data.label || data.device}. You can remove the drive.`; }, - error => { $("export-status").textContent = error.message; }); -}); -$("reboot").addEventListener("click", () => run("Restarting…", () => call("reboot"))); -window.addEventListener("beforeunload", event => { - if ((wizard.page === "progress" && !installationComplete) || wizard.busy || countdown) { event.preventDefault(); event.returnValue = ""; } -}); -initialize(); diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/manifest.json b/image/live-rootfs/usr/share/cockpit/cybexos-installer/manifest.json deleted file mode 100644 index 33d873bd..00000000 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/manifest.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": 1, - "requires": {"cockpit": "300"}, - "content-security-policy": "default-src 'self'; connect-src 'self' ws: wss:; object-src 'none'; base-uri 'self'; form-action 'self'" -} diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs b/image/live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs deleted file mode 100644 index 8a0e3eae..00000000 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs +++ /dev/null @@ -1,109 +0,0 @@ -// Pure state and validation shared by the real UI and fixture tests. -const reserved = new Set(["root", "liveuser", "bin", "daemon", "adm", "mail", "ftp", "nobody", - "dbus", "systemd", "gdm", "sddm", "sshd", "polkitd", "chrony"]); -export const GiB = 1024 ** 3; -const MiB = 1024 ** 2; - -// Linux account names are lowercase; typing "John" creates the account "john". -export const accountName = value => value.toLowerCase(); -export class Wizard { - constructor() { this.page = "setup"; this.busy = false; this.plan = null; } - move(page) { - if (this.busy || this.page === "progress" || this.page === "unattended") throw new Error("An installation operation is still running."); - if (!["setup", "location", "review"].includes(page)) throw new Error("Unknown setup page."); - if (page === "review" && !this.plan) throw new Error("Review the disk before installing."); - if (page !== "review") this.plan = null; - this.page = page; - } - async operation(work) { - if (this.busy) throw new Error("An installation operation is still running."); - this.busy = true; - try { return await work(); } finally { this.busy = false; } - } - account(data) { - // A new-owner installation has no account yet; its passphrase is the - // temporary disk passphrase the new owner replaces at first start. - const what = data.oem ? "passphrase" : "password"; - if (!data.oem && (typeof data.username !== "string" || !/^[a-z_][a-z0-9_-]{0,30}$/.test(data.username) || reserved.has(data.username))) - throw new Error("Choose a username of 1–31 lowercase letters, numbers, underscores or dashes, starting with a letter. System names are reserved."); - if (typeof data.password !== "string" || Array.from(data.password).length < 12 || Array.from(data.password).length > 512) - throw new Error(`Use a ${what} of 12–512 characters.`); - if (/[\x00-\x1f\x7f]/.test(data.password)) throw new Error(`The ${what} cannot contain control characters.`); - if (data.password !== data.confirm) throw new Error(`The ${what}s do not match.`); - return data; - } - confirmation(checked) { - if (this.page !== "review" || !this.plan || !checked) throw new Error("Confirm the selected disk before installing."); - const confirmed = this.plan.layout?.mode === "alongside" ? {alongside_confirmed: true} : {erase_confirmed: true}; - return {token: this.plan.token, confirmed_disk: this.plan.disk.name, ...confirmed}; - } -} - -export function size(bytes) { - return Number.isFinite(Number(bytes)) ? `${(Number(bytes) / GiB).toFixed(1)} GiB` : "Unknown size"; -} - -// "Windows", "Windows and Linux", "Windows, Linux and macOS". -export function systemsLabel(systems) { - const names = (systems || []).filter(name => typeof name === "string" && name); - if (names.length < 2) return names[0] || ""; - return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; -} - -// The CybexOS space the slider may choose, rounded to whole GiB inside the -// range the backend offered. -export function clampSpace(options, value) { - const low = options.min_space, high = options.max_space; - const chosen = Math.round(Number(value) / GiB) * GiB; - return Math.min(high, Math.max(low, Number.isFinite(chosen) ? chosen : low)); -} - -// The single shrink request for a chosen CybexOS size, or null when free -// space suffices. The partition keeps everything CybexOS does not take. -export function shrinkFor(options, space) { - if (!options || options.mode !== "shrink") return null; - const taken = clampSpace(options, space) - options.free; - const target = Math.floor((options.partition.size - taken) / MiB) * MiB; - return {device: options.partition.id, size: Math.max(options.floor, target)}; -} - -export function countdownText(seconds) { - const value = Math.max(0, Math.ceil(seconds)); - return `Installing in ${Math.floor(value / 60)}:${String(value % 60).padStart(2, "0")}`; -} - -// The review's summary rows; every value is plain text for textContent. -export function reviewFields(plan, diskIdentity) { - const account = plan.account, layout = plan.layout || {mode: "erase"}; - const fields = [["Disk", diskIdentity(plan.disk)]]; - if (layout.mode === "alongside") { - const others = systemsLabel(layout.systems) || "the existing systems"; - fields.push(["Install location", `Alongside ${others}. The boot menu lists every system.`]); - if (layout.resize) - fields.push(["Resized", `${layout.resize.path}${layout.resize.filesystem ? ` (${layout.resize.filesystem})` : ""} from ${size(layout.resize.from)} to ${size(layout.resize.to)}`]); - fields.push(["New for CybexOS", `${size(layout.space)} of ${layout.resize ? "freed" : "free"} space`]); - fields.push(["Unchanged", (layout.kept || []).length ? `${layout.kept.join(", ")}; the EFI system partition is shared` : "Every other partition"]); - } else { - fields.push(["Install location", "Erase the entire disk"]); - } - fields.push(["Account", account.oem ? "Created by the new owner at first start" : account.username]); - fields.push(["Storage", account.encrypted ? "Encrypted Btrfs · LUKS2" : "Btrfs · unencrypted"]); - fields.push(["At startup", account.oem - ? (account.encrypted ? "Temporary passphrase → new-owner setup" : "New-owner setup") - : (account.encrypted ? "Unlock your disk → automatic login" : "Sign in to your account")]); - fields.push(["Sudo", account.passwordless_wheel ? "Administrator commands do not ask for a password" - : account.oem ? "Administrator commands ask for the new owner's password" - : "Administrator commands ask for your account password"]); - fields.push(["Boot keyboard", plan.boot_keyboard], ["Language", account.locale], ["Timezone", account.timezone]); - return fields; -} - -// The review's confirmation copy for each layout. -export function confirmationCopy(plan) { - const layout = plan?.layout || {mode: "erase"}; - if (layout.mode !== "alongside") - return {check: "I understand that all data on the selected disk will be erased.", button: "Erase disk and install"}; - if (layout.resize) - return {check: `I have a backup, and I understand that ${layout.resize.path} will be resized.`, button: "Resize and install"}; - return {check: "I understand that CybexOS will be installed in the free space of this disk.", button: "Install alongside"}; -} diff --git a/image/live.ks b/image/live.ks deleted file mode 100644 index 7bce86c3..00000000 --- a/image/live.ks +++ /dev/null @@ -1,113 +0,0 @@ -# Image construction only. This kickstart is NOT passed to the GUI installer: -# disk selection and all destructive confirmations remain interactive. -lang en_US.UTF-8 -keyboard us -timezone UTC --utc -rootpw --lock -# with-mdns4 lets nss-mdns resolve .local names, matching the workstation. -# with-fingerprint offers fprintd beside the password (docs/security.md). -authselect select sssd with-silent-lastlog with-mdns4 with-fingerprint --force -selinux --enforcing -firewall --enabled -# Docker is socket-activated on demand by roles/base/tasks/docker-activation.yml, -# as on the workstation, rather than started at boot. -services --enabled=NetworkManager,sddm,firewalld,bluetooth,tailscaled --disabled=sshd -bootloader --timeout=5 --append="quiet rhgb" -part / --size=61440 --fstype=ext4 - -# Repository configuration, including signature verification, is supplied -# through image/compose's verified DNF adapter from image/build.repo. -# The library also requires a syntactically present Kickstart repository; -# the adapter replaces this entry with the signed DNF definitions. Like -# build.repo, it names no release: compose sets $releasever from inventory. -repo --name=fedora --baseurl=https://download.fedoraproject.org/pub/fedora/linux/releases/$releasever/Everything/x86_64/os/ - -%packages --excludedocs --exclude-weakdeps -@core -@anaconda-tools -kernel -kernel-modules -kernel-modules-extra -dracut-live -dracut-config-generic -grub2-efi-x64 -grub2-efi-x64-cdboot -grub2-pc -grub2-pc-modules -shim-x64 -syslinux -syslinux-extlinux -memtest86+ -fedora-release-identity-basic -fedora-release-common -fedora-repos -generic-logos -generic-release-notes -anaconda-live -anaconda-webui -libblockdev-plugins-all -lvm2 -mdadm -cryptsetup -sddm -cybexos-desktop -selinux-policy-targeted -policycoreutils -firewalld -NetworkManager-wifi -NetworkManager-bluetooth -iwlwifi-mvm-firmware -linux-firmware -# GPU firmware is split out of linux-firmware and must be explicit because -# weak dependencies are disabled above. Cover physical GPUs, not just QEMU. -amd-gpu-firmware -intel-gpu-firmware -nvidia-gpu-firmware -mesa-dri-drivers -mesa-vulkan-drivers -mesa-libEGL -pciutils -xorg-x11-server-Xwayland -at-spi2-core -adwaita-icon-theme -adw-gtk3-theme -# The shared base transaction selects these providers before desktop packages -# can pull Fedora alternatives. Keep compose's provider choice explicit too. -fdk-aac -jack-audio-connection-kit -openssh-server -rsync -btrfs-progs -dosfstools -e2fsprogs -chrony -plymouth -plymouth-system-theme -plymouth-plugin-script --dracut-config-rescue -%end - -%post --nochroot --erroronfail -set -eu -python3 /home/builder/source/image/install-live-rootfs "$INSTALL_ROOT" -install -Dm0644 /home/builder/build/hyprland.gpg "$INSTALL_ROOT/etc/pki/rpm-gpg/CYBEXOS-sdegler-hyprland-copr.gpg" -install -Dm0644 /home/builder/source/roles/desktop/templates/hyprland-copr.repo.j2 "$INSTALL_ROOT/etc/yum.repos.d/cybexos-hyprland.repo" -%end - -%post --erroronfail -set -eu -systemctl enable cybexos-live.service -systemctl set-default graphical.target -# Both live boot and the installed LUKS prompt use the shared Cybex artwork. -plymouth-set-default-theme cybex -firewall-offline-cmd --set-default-zone=cybexos -mkdir -p /etc/dracut.conf.d -cat > /etc/dracut.conf.d/99-live.conf <<'DRACUT' -add_dracutmodules+=" dmsquash-live livenet " -hostonly="no" -DRACUT -rm -f /etc/machine-id /var/lib/dbus/machine-id -touch /etc/machine-id -rm -f /etc/ssh/ssh_host_* -dnf clean all -%end diff --git a/image/login_qualification.py b/image/login_qualification.py deleted file mode 100644 index d84b7b2f..00000000 --- a/image/login_qualification.py +++ /dev/null @@ -1,333 +0,0 @@ -"""Guest-only SDDM/keyring qualification on TestVM's disposable installation. - -No commands in this module run at import time. Secrets are synthetic fixture -values, sent over the guest's private SSH connection on stdin, never in argv. -""" -import json -import time - -from vm_testing import poweroff_guest, run - - -USER_ENV = ('export XDG_RUNTIME_DIR=/run/user/$(id -u); ' - 'export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus; ') - - -KEYRING_PROBE = r''' -from pathlib import Path -from gi.repository import Gio, GLib - -bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) -service = 'org.freedesktop.secrets' -base = '/org/freedesktop/secrets' -interface = 'org.freedesktop.Secret.' -def call(path, iface, method, signature=None, values=None): - parameters = GLib.Variant(signature, values) if signature else None - return bus.call_sync(service, path, iface, method, parameters, None, - Gio.DBusCallFlags.NONE, 10000, None).unpack() - -# ReadAlias and Locked inspect state; do not call Unlock or answer a prompt. -collection, = call(base, interface + 'Service', 'ReadAlias', '(s)', ('login',)) -assert collection != '/', 'PAM did not create the login keyring' -locked, = call(collection, 'org.freedesktop.DBus.Properties', 'Get', '(ss)', - (interface + 'Collection', 'Locked')) -assert bool(locked) == (ACTION == 'locked'), 'Unexpected login-keyring lock state' -files = list((Path.home() / '.local/share/keyrings').glob('*.keyring')) -assert files, 'Persistent login keyring missing' -assert any(path.read_bytes().startswith(b'GnomeKeyring\n\r\0\n') for path in files), \ - 'Encrypted binary keyring missing' -assert all(EXPECTED.encode() not in path.read_bytes() for path in files), \ - 'Synthetic secret persisted as plaintext' -if ACTION == 'locked': - print('Encrypted login keyring is locked; no unlock was attempted.') - raise SystemExit(0) -if ACTION == 'lock': - locked_paths, prompt = call(base, interface + 'Service', 'Lock', '(ao)', ([collection],)) - assert prompt == '/' and collection in locked_paths, 'Locking the synthetic collection requested interaction' - locked, = call(collection, 'org.freedesktop.DBus.Properties', 'Get', '(ss)', - (interface + 'Collection', 'Locked')) - assert locked, 'Synthetic login keyring did not lock' - print('Synthetic login keyring locked before session teardown.') - raise SystemExit(0) - -_, session = call(base, interface + 'Service', 'OpenSession', '(sv)', - ('plain', GLib.Variant('s', ''))) -attributes = {'cybexos-qualification': 'encrypted-login-roundtrip'} -try: - if ACTION == 'create': - properties = { - interface + 'Item.Label': GLib.Variant('s', 'CybexOS disposable qualification'), - interface + 'Item.Attributes': GLib.Variant('a{ss}', attributes), - } - _, prompt = call(collection, interface + 'Collection', 'CreateItem', - '(a{sv}(oayays)b)', - (properties, (session, b'', EXPECTED.encode(), 'text/plain'), True)) - assert prompt == '/', 'Creating a synthetic item requested interaction' - unlocked, locked_items = call(base, interface + 'Service', 'SearchItems', - '(a{ss})', (attributes,)) - assert len(unlocked) == 1 and not locked_items, 'Synthetic item is not unlocked' - secret, = call(unlocked[0], interface + 'Item', 'GetSecret', '(o)', (session,)) - assert bytes(secret[2]) == EXPECTED.encode(), 'Synthetic item changed across login' - if ACTION == 'delete': - prompt, = call(unlocked[0], interface + 'Item', 'Delete') - assert prompt == '/', 'Deleting a synthetic item requested interaction' -finally: - call(session, interface + 'Session', 'Close') -print('Encrypted login keyring and synthetic secret passed without a prompt.') -''' - - -SESSION_STATE = r''' -import json, subprocess -def output(*argv): - return subprocess.check_output(argv, text=True).strip() -sessions = [] -for line in output('loginctl', 'list-sessions', '--no-legend', '--no-pager').splitlines(): - identifier = line.split()[0] - try: - detail = output('loginctl', 'show-session', identifier, - '-p', 'User', '-p', 'Name', '-p', 'Class', '-p', 'Type', '-p', 'State') - except subprocess.CalledProcessError: - continue # A disappearing SSH session is expected during polling. - value = dict(row.split('=', 1) for row in detail.splitlines()) - sessions.append(value) -print(json.dumps({ - 'desktop': any(s.get('Name') == 'qualification' and s.get('Class') == 'user' - and s.get('Type') == 'wayland' and s.get('State') in ('active', 'online') - for s in sessions), - 'greeter': any(s.get('Class') == 'greeter' and s.get('State') in ('active', 'online') - for s in sessions), - 'sddm': subprocess.run(['systemctl', 'is-active', '--quiet', 'sddm.service']).returncode == 0, -})) -''' - - -LOGOUT = r''' -import os, re, subprocess -# SSH does not inherit the compositor environment. Import only the two values -# needed to address the actual graphical session, never execute shell exports. -raw = subprocess.check_output(['systemctl', '--user', 'show-environment'], text=True) -manager = dict(line.split('=', 1) for line in raw.splitlines() if '=' in line) -environment = dict(os.environ) -for name in ('HYPRLAND_INSTANCE_SIGNATURE', 'WAYLAND_DISPLAY'): - value = manager.get(name, '') - assert re.fullmatch(r'[A-Za-z0-9_.:/+-]+', value), 'Missing or invalid compositor environment' - environment[name] = value -subprocess.run(['/usr/libexec/cybexos-session-action', 'logout'], env=environment, check=True) -''' - - -DISABLE_BOOT_CACHE = r''' -python3 - <<'PY' -from pathlib import Path -path = Path('/etc/pam.d/sddm-autologin') -# The backup must survive reboot. /run is tmpfs and cannot be renamed into -# /root, so create this nonsecret fixture backup directly on the root disk. -backup = Path('/root/cybexos-qualification-pam-backup') -assert not backup.exists() -original = path.read_bytes() -assert b'pam_systemd_loadkey.so' in original -backup.write_bytes(original) -backup.chmod(0o600) -lines = original.decode().splitlines(keepends=True) -path.write_text(''.join('# qualification-cache-unavailable ' + line - if 'pam_systemd_loadkey.so' in line and not line.lstrip().startswith('#') - else line for line in lines)) -PY -''' - - -FAIL_FIRST_LAUNCH = r""" -python3 - <<'PY' -import os -from pathlib import Path -import tempfile -launcher = Path('/usr/bin/hyprland-quickshell') -backup = Path('/root/cybexos-qualification-launcher-backup') -marker = Path('/home/qualification/.local/state/cybexos/qualification-launch-failed') -assert launcher.is_file() and not launcher.is_symlink() -assert not backup.exists() and not marker.exists() -original = launcher.read_bytes() -assert original.startswith(b'#!/usr/bin/env bash\n') -backup.write_bytes(original) -backup.chmod(launcher.stat().st_mode & 0o777) -prefix = br'''#!/usr/bin/env bash -set -euo pipefail -# Disposable qualification fixture: fail before the real launcher once. -fixture_marker="${XDG_STATE_HOME:-$HOME/.local/state}/cybexos/qualification-launch-failed" -mkdir -p "$(dirname "$fixture_marker")" -if (set -C; printf '%s\n' first-autologin-failure > "$fixture_marker") 2>/dev/null; then - exit 1 -fi -''' -fd, name = tempfile.mkstemp(prefix='.cybexos-qualification-launcher-', dir=launcher.parent) -try: - with os.fdopen(fd, 'wb') as stream: - stream.write(prefix + original.split(b'\n', 1)[1]) - stream.flush() - os.fchmod(stream.fileno(), launcher.stat().st_mode & 0o777) - os.fsync(stream.fileno()) - os.replace(name, launcher) -finally: - Path(name).unlink(missing_ok=True) -PY -restorecon /usr/bin/hyprland-quickshell -""" - - -RESTORE_LAUNCHER = r''' -if test -f /root/cybexos-qualification-launcher-backup; then - cp -a /root/cybexos-qualification-launcher-backup /usr/bin/hyprland-quickshell.cybexos-qualification-restore - mv -f /usr/bin/hyprland-quickshell.cybexos-qualification-restore /usr/bin/hyprland-quickshell - restorecon /usr/bin/hyprland-quickshell - rm /root/cybexos-qualification-launcher-backup -fi -rm -f /home/qualification/.local/state/cybexos/qualification-launch-failed -''' - - -FAILED_LAUNCH_STATE = r''' -import configparser, json, pwd, stat -from pathlib import Path -marker = Path('/home/qualification/.local/state/cybexos/qualification-launch-failed') -info = marker.lstat() -assert stat.S_ISREG(info.st_mode) and info.st_uid == pwd.getpwnam('qualification').pw_uid -assert marker.read_text() == 'first-autologin-failure\n', 'The injected launch failure did not run' -consumed = Path('/run/cybexos-login/autologin-used').lstat() -assert stat.S_ISREG(consumed.st_mode) and consumed.st_uid == 0 and consumed.st_mode & 0o777 == 0o600, \ - 'The failed autologin did not consume its protected boot marker' -settings = configparser.ConfigParser() -settings.read('/etc/sddm.conf') -status = json.loads(Path('/run/cybexos-login/status.json').read_text()) -if RESTARTED: - assert settings.get('Autologin', 'User') == '', 'Manager restart re-enabled failed autologin' - assert status['autologin'] is False and status['reason'] == 'autologin-already-used' -else: - assert settings.get('Autologin', 'User') == 'qualification' - assert status['autologin'] is True and status['reason'] == 'encrypted-root', \ - 'The first login was not an encrypted-root autologin attempt' -''' - - -def user_script(vm, script, timeout=60): - return run([*vm.ssh, USER_ENV + 'python3 -'], input=script, text=True, - capture_output=True, timeout=timeout) - - -def keyring_probe(vm, action, secret): - if action not in ('create', 'read', 'lock', 'locked', 'delete'): - raise ValueError('Unknown keyring qualification action') - script = 'ACTION = ' + repr(action) + '\nEXPECTED = ' + repr(secret) + '\n' + KEYRING_PROBE - user_script(vm, script) - - -def wait_login_state(vm, desktop, timeout=90, stable_for=0): - deadline, stable_since = time.monotonic() + timeout, None - while time.monotonic() < deadline: - vm.alive() - state = json.loads(user_script(vm, SESSION_STATE).stdout) - matched = state['sddm'] and state['desktop'] == desktop and (desktop or state['greeter']) - if matched: - if stable_since is None: - stable_since = time.monotonic() - if time.monotonic() - stable_since >= stable_for: - return state - else: - stable_since = None - time.sleep(1) - raise RuntimeError('Installed login/session state did not settle as expected') - - -def reboot_installed(vm, password, root_script, expect_desktop=True): - poweroff_guest(vm, password, root_script, timeout=60) - vm.start(user='qualification') - vm.unlock_disk(password) - vm.wait_ssh(setup=False, redactions=(password,)) - if expect_desktop: - vm.wait_desktop() - wait_login_state(vm, desktop=expect_desktop, stable_for=0 if expect_desktop else 10) - - -def qualify_failed_autologin(vm, password, root_script, report, secret): - """A failed first session must leave the greeter usable without another auto attempt.""" - try: - root_script(vm, FAIL_FIRST_LAUNCH, password) - reboot_installed(vm, password, root_script, expect_desktop=False) - root_script(vm, "python3 - <<'PY'\nRESTARTED = False\n" + FAILED_LAUNCH_STATE + '\nPY\n', password) - report['checks'].append('first-autologin-launch-failure-consumes-boot-attempt') - root_script(vm, 'systemctl restart sddm.service\n', password) - wait_login_state(vm, desktop=False, stable_for=10) - root_script(vm, "python3 - <<'PY'\nRESTARTED = True\n" + FAILED_LAUNCH_STATE + '\nPY\n', password) - report['checks'].append('failed-first-autologin-manager-restart-requires-authentication') - time.sleep(2) - vm.type(password + '\n') - vm.wait_desktop() - wait_login_state(vm, desktop=True) - keyring_probe(vm, 'read', secret) - vm.audit(applications=False) - report['checks'].append('failed-first-autologin-password-login-recovers-desktop') - finally: - root_script(vm, RESTORE_LAUNCHER, password) - - -def qualify_login(vm, password, root_script, report, secret): - """Exercise the actual installed manager and vault; stop on the first failure.""" - root_script(vm, 'systemctl enable sshd.service\nfirewall-cmd --permanent --add-service=ssh\n', password) - wait_login_state(vm, desktop=True) - keyring_probe(vm, 'create', secret) - report['checks'].append('initial-encrypted-keyring-unlocked-without-prompt') - reboot_installed(vm, password, root_script) - keyring_probe(vm, 'read', secret) - vm.audit(applications=False) - report['checks'].append('synthetic-keyring-secret-survives-cold-reboot') - - for action in ('logout', 'compositor-crash', 'manager-restart'): - # Run the shared mandatory audit before every deliberate session teardown. - vm.audit(applications=False) - # SSH can keep the user manager and an already-unlocked keyring alive. - # Lock explicitly so the subsequent successful read proves that the - # new password/PAM login unlocked it, rather than reusing old state. - keyring_probe(vm, 'lock', secret) - if action == 'manager-restart': - root_script(vm, 'systemctl restart sddm.service\n', password) - elif action == 'compositor-crash': - # Limit the kill to the synthetic user's actual compositor processes. - root_script(vm, 'pkill -KILL -u qualification -x Hyprland\n', password) - else: - user_script(vm, LOGOUT) - wait_login_state(vm, desktop=False, stable_for=10) - keyring_probe(vm, 'locked', secret) - report['checks'].append(action + '-requires-authentication') - # The embedded SDDM theme focuses the selected user's password input. - # Verify the greeter state first, type once, and never retry blindly. - time.sleep(2) - vm.type(password + '\n') - vm.wait_desktop() - wait_login_state(vm, desktop=True) - keyring_probe(vm, 'read', secret) - vm.audit(applications=False) - report['checks'].append(action + '-password-recovery-and-keyring') - - # Model an unavailable/expired boot-password cache without accessing its - # contents or changing LUKS slots: disable only loadkey in this task-owned VM. - root_script(vm, DISABLE_BOOT_CACHE, password) - try: - reboot_installed(vm, password, root_script) - keyring_probe(vm, 'locked', secret) - report['checks'].append('missing-cached-password-preserves-locked-encrypted-keyring') - finally: - root_script(vm, 'cp /root/cybexos-qualification-pam-backup /etc/pam.d/sddm-autologin\n' - 'restorecon /etc/pam.d/sddm-autologin\n' - 'rm /root/cybexos-qualification-pam-backup\n', password) - # Exercise the ordinary password-login fallback with the original PAM stack. - user_script(vm, LOGOUT) - wait_login_state(vm, desktop=False, stable_for=5) - keyring_probe(vm, 'locked', secret) - vm.type(password + '\n') - vm.wait_desktop() - wait_login_state(vm, desktop=True) - keyring_probe(vm, 'read', secret) - vm.audit(applications=False) - report['checks'].append('fallback-password-login-unlocks-existing-keyring') - qualify_failed_autologin(vm, password, root_script, report, secret) - keyring_probe(vm, 'delete', secret) diff --git a/image/notification_images.py b/image/notification_images.py deleted file mode 100644 index dfdba2ed..00000000 --- a/image/notification_images.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/python3 -"""Stage and admit the same local Qt image module used by checkout installs.""" -# SPDX-License-Identifier: MIT -import hashlib -import json -import os -from pathlib import Path -import shutil -import subprocess - -ROOT = Path(__file__).resolve().parents[1] -RECIPE = Path('roles/desktop/files/notification-images') -SOURCES = ('CMakeLists.txt', 'LICENSE', 'build.py', 'imagefingerprints.cpp', - 'imagefingerprints.h', 'plugin.cpp', 'plugins.qmltypes', 'qmldir', 'tests.cpp') -MODULE = 'usr/lib64/qt6/qml/CybexOS/Images' -ARTIFACTS = tuple(f'{MODULE}/{name}' for name in - ('libcybexosnotificationimagesplugin.so', 'qmldir', 'plugins.qmltypes')) + ( - 'usr/share/licenses/cybexos-notification-images/LICENSE',) -MANIFEST = 'usr/share/cybexos/notification-images/build.json' - - -def sha256(path): - return hashlib.sha256(Path(path).read_bytes()).hexdigest() - - -def prepare_recipe(root, destination): - recipe = destination / 'usr/share/cybexos/notification-images' - recipe.mkdir(parents=True, exist_ok=True) - for name in SOURCES: - shutil.copyfile(root / RECIPE / name, recipe / name) - - -def prepare_notification_images(root, build, payload): - staged = build / 'notification-images-root' - manifest = json.loads((staged / MANIFEST).read_text()) - expected = {name: sha256(root / RECIPE / name) for name in SOURCES} - if (manifest.get('v') != 1 or manifest['inputs']['source_sha256'] != expected - or json.loads((build / 'notification-images.json').read_text()) != manifest - or set(manifest['artifacts']) != set(ARTIFACTS)): - raise RuntimeError('Native notification image build differs from this source revision') - for name in SOURCES: - if sha256(staged / 'usr/share/cybexos/notification-images' / name) != expected[name]: - raise RuntimeError('Staged notification image recipe differs: ' + name) - for name in ARTIFACTS: - if sha256(staged / name) != manifest['artifacts'][name]: - raise RuntimeError('Native notification image artifact differs: ' + name) - destination = payload / name - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(staged / name, destination) - destination.chmod(0o755 if name.endswith('.so') else 0o644) - prepare_recipe(root, payload) - shutil.copyfile(staged / MANIFEST, payload / MANIFEST) - return manifest - - -def main(): - if os.geteuid() != 0 or Path('/etc/hostname').read_text().strip() != 'image-builder': - raise RuntimeError('Native image builds require the disposable Fedora image builder') - import yaml - tasks = yaml.safe_load((ROOT / 'roles/desktop/tasks/notification-images.yml').read_text()) - packages = next(task['ansible.builtin.dnf']['name'] for task in tasks if 'ansible.builtin.dnf' in task) - subprocess.run(['dnf', 'install', '-y', *packages], check=True) - build = Path('/home/builder/build') - staged = build / 'notification-images-root' - staged.mkdir(parents=True, exist_ok=False) - prepare_recipe(ROOT, staged) - subprocess.run(['python3', str(staged / 'usr/share/cybexos/notification-images/build.py'), - '--root', str(staged)], check=True) - shutil.copyfile(staged / MANIFEST, build / 'notification-images.json') - - -if __name__ == '__main__': - main() diff --git a/image/offline-payload.json b/image/offline-payload.json deleted file mode 100644 index 8d56e844..00000000 --- a/image/offline-payload.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "version": "1.5.2", - "inputs_sha256": "300206dc08b2e072f5ff3cbbe6dc48270ac1a7034aaa6bd79b5fba9308b1389c" -} diff --git a/image/offline_payload.py b/image/offline_payload.py deleted file mode 100644 index 836870f1..00000000 --- a/image/offline_payload.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Independently version the large, offline application payload. - -Run `python3 image/offline_payload.py --version X.Y.Z` when changing its -reviewed inputs. Desktop-only changes do not advance this RPM's identity. -""" -import argparse -import hashlib -import json -from pathlib import Path -import re - -ROOT = Path(__file__).resolve().parents[1] -PACKAGE = "cybexos-offline-apps" -PREFIXES = ( - "opt/cybexos-apps", "opt/cybexos-builds", "usr/local/bin", "usr/local/libexec", - "usr/local/share/fonts", "usr/local/share/applications/omawrite.desktop", - "usr/share/icons/hicolor/scalable/apps/omawrite.svg", - "usr/share/cybexos/user-seed", "usr/share/cybexos/final-seed", - "usr/share/cybexos/seed-groups.json", "usr/share/licenses/cybexos-fonts", - "usr/share/cybexos/offline-inputs.json", -) -INPUTS = ( - "inventory/group_vars/all.yml", "roles/apps", "roles/dotfiles", "image/applications", - "image/applications.yml", "image/application_payload.py", "image/offline_payload.py", "image/package", - "assets/EDM115-newline2.omp.json", "assets/nautilus-localsend.py", - "assets/desktop-contract.json", "image/desktop_payload.py", - "image/system_packages.py", "image/live.ks", "image/cybexos-desktop.spec", - "roles/base/defaults/main.yml", "roles/desktop/tasks/main.yml", - "roles/xps-2026/tasks/packages.yml", -) - - -def input_digest(root=ROOT): - records = [] - for relative in INPUTS: - source = root / relative - if not source.exists(): - raise ValueError(f"Missing offline application input: {relative}") - files = sorted(source.rglob("*")) if source.is_dir() else [source] - for path in files: - if path.is_file() and not path.is_symlink() and "__pycache__" not in path.parts and path.suffix != ".pyc": - records.append((path.relative_to(root).as_posix(), hashlib.sha256(path.read_bytes()).hexdigest())) - return hashlib.sha256(json.dumps(records, separators=(",", ":")).encode()).hexdigest() - - -def configuration(root=ROOT): - config = json.loads((root / "image/offline-payload.json").read_text()) - if set(config) != {"version", "inputs_sha256"} or not re.fullmatch(r"\d+\.\d+\.\d+", config["version"]): - raise ValueError("Offline payload requires an independent X.Y.Z version and input digest") - if config["inputs_sha256"] != input_digest(root): - raise ValueError("Offline application inputs changed: run python3 image/offline_payload.py --version NEW_VERSION") - return config - - -def file_lists(payload): - """Directory entries recurse in RPM; both packages partition one payload.""" - return ["/" + relative for relative in PREFIXES - if (payload / relative).exists() or (payload / relative).is_symlink()] - - -def inventory(payload): - result = {"bytes": 0, "files": 0, "groups": {}} - for relative in PREFIXES: - source = payload / relative - paths = list(source.rglob("*")) if source.is_dir() else [source] - sizes = [path.stat().st_size for path in paths if path.is_file() and not path.is_symlink()] - result["groups"][relative] = {"bytes": sum(sizes), "files": len(sizes)} - result["bytes"] += sum(sizes) - result["files"] += len(sizes) - return result - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--version", help="Advance the offline payload version and record its reviewed inputs") - args = parser.parse_args() - path = ROOT / "image/offline-payload.json" - if args.version: - if not re.fullmatch(r"\d+\.\d+\.\d+", args.version): - parser.error("version must be X.Y.Z") - if path.exists(): - previous = json.loads(path.read_text()) - if previous["inputs_sha256"] != input_digest() and tuple(map(int, args.version.split("."))) <= tuple(map(int, previous["version"].split("."))): - parser.error("Changed offline inputs require a newer version") - path.write_text(json.dumps({"version": args.version, "inputs_sha256": input_digest()}, indent=2) + "\n") - print(json.dumps(configuration(), indent=2)) - - -if __name__ == "__main__": - main() diff --git a/image/offline_upgrade_inputs.py b/image/offline_upgrade_inputs.py deleted file mode 100644 index 4aae82b7..00000000 --- a/image/offline_upgrade_inputs.py +++ /dev/null @@ -1,487 +0,0 @@ -"""Bounded, release-bound third-party RPM fixtures for offline upgrade qualification. - -Importing this module never downloads, installs, boots, or imports trust keys. -""" -import argparse -from contextlib import contextmanager -from dataclasses import dataclass -import hashlib -import inspect -import itertools -import json -import os -from pathlib import Path -import re -import shutil -import stat -import subprocess -import tempfile -from urllib.parse import urlsplit - -from build_support import atomic_json, digest - -MANIFEST = 'manifest.json' -MAX_MANIFEST_BYTES = 1024 * 1024 -MAX_RPMS = 256 -MAX_RPM_BYTES = 512 * 1024 * 1024 -MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024 -SIGNATURE_TAGS = ('RSAHEADER', 'DSAHEADER', 'SIGPGP', 'SIGGPG') -BINDINGS = ('baseline_iso_sha256', 'candidate_rpm_sha256', 'candidate_offline_rpm_sha256') -RPM_FIELDS = {'filename', 'name', 'nevra', 'architecture', 'sha256', 'size', 'signature', 'repository'} - - -@contextmanager -def regular_stream(path, maximum): - """Never open a symlink, device or FIFO, or read an unbounded input.""" - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) - with os.fdopen(descriptor, 'rb') as stream: - info = os.fstat(stream.fileno()) - if not stat.S_ISREG(info.st_mode) or not 0 < info.st_size <= maximum: - raise ValueError(f'Expected a bounded regular file: {path}') - yield stream, info.st_size - - -def file_sha256(path, maximum, expected_size=None): - with regular_stream(path, maximum) as (stream, size): - if expected_size is not None and size != expected_size: - raise ValueError(f'RPM size mismatch: {path}') - checksum = hashlib.sha256() - remaining = size - while remaining: - chunk = stream.read(min(1024 * 1024, remaining)) - if not chunk: - raise ValueError(f'File changed while reading: {path}') - checksum.update(chunk) - remaining -= len(chunk) - if stream.read(1): - raise ValueError(f'File changed while reading: {path}') - return checksum.hexdigest() - - -def unique_object(pairs): - result = {} - for key, value in pairs: - if key in result: - raise ValueError(f'Duplicate manifest key: {key}') - result[key] = value - return result - - -def read_json(path): - with regular_stream(path, MAX_MANIFEST_BYTES) as (stream, _): - data = stream.read(MAX_MANIFEST_BYTES + 1) - if len(data) > MAX_MANIFEST_BYTES: - raise ValueError('Manifest exceeds size limit') - return json.loads(data, object_pairs_hook=unique_object), hashlib.sha256(data).hexdigest() - - -def exact_fields(value, fields): - if not isinstance(value, dict) or set(value) != set(fields): - raise ValueError('Malformed dependency manifest fields') - - -def valid_sha(value): - return isinstance(value, str) and re.fullmatch(r'[0-9a-f]{64}', value) - - -def validate_record(record): - exact_fields(record, RPM_FIELDS) - for field in ('filename', 'name', 'nevra', 'architecture'): - if not isinstance(record[field], str) or len(record[field]) > 255: - raise ValueError(f'Malformed RPM {field}') - if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm', record['filename']): - raise ValueError('Unsafe dependency RPM filename') - if (not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+-]*', record['name']) - or record['name'].startswith('cybexos-') or record['name'] == 'gpg-pubkey'): - raise ValueError('Dependency must be a third-party package, never first-party or a trust key') - if record['architecture'] not in ('x86_64', 'noarch'): - raise ValueError('Dependency architecture must be x86_64 or noarch') - evr = r'(?:0|[1-9][0-9]{0,9}):[A-Za-z0-9][A-Za-z0-9._+~^]*-[A-Za-z0-9][A-Za-z0-9._+~^]*' - if not re.fullmatch(re.escape(record['name']) + '-' + evr + r'\.' + record['architecture'], record['nevra']): - raise ValueError('Dependency NEVRA must include an explicit numeric epoch and matching architecture') - if not valid_sha(record['sha256']): - raise ValueError('Malformed RPM SHA-256') - if type(record['size']) is not int or not 0 < record['size'] <= MAX_RPM_BYTES: - raise ValueError('Dependency RPM exceeds size limit') - signature = record['signature'] - exact_fields(signature, ('tag', 'value')) - if (signature['tag'] not in SIGNATURE_TAGS or not isinstance(signature['value'], str) - or len(signature['value']) > 1024 - or not re.fullmatch(r'[\x20-\x7e]+ Key ID [0-9a-fA-F]{8,64}', signature['value'])): - raise ValueError('Missing or malformed RPM signature identity') - validate_repository(record['repository']) - - -def validate_repository(repository): - exact_fields(repository, ('id', 'url')) - if (not isinstance(repository['id'], str) - or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,127}', repository['id']) - or not isinstance(repository['url'], str) or len(repository['url']) > 2048 - or not re.fullmatch(r'[\x21-\x7e]+', repository['url'])): - raise ValueError('Missing or malformed repository provenance') - url = urlsplit(repository['url']) - # Accessing .port also rejects malformed/non-numeric/out-of-range ports. - if url.scheme != 'https' or not url.hostname or url.port == 0 or url.username or url.password or url.fragment: - raise ValueError('Repository provenance must be an HTTPS URL without credentials or a fragment') - - -@dataclass(frozen=True) -class DependencyBundle: - directory: Path - manifest: dict - manifest_sha256: str - - def evidence(self): - return {**self.manifest, 'manifest_sha256': self.manifest_sha256, - 'total_bytes': sum(item['size'] for item in self.manifest['rpms'])} - - def revalidate(self): - current = load_bundle(self.directory) - if current.manifest_sha256 != self.manifest_sha256: - raise ValueError('Dependency manifest changed during qualification') - return current - - -def load_bundle(directory, *, baseline_iso=None, candidate_rpm=None, candidate_offline_rpm=None): - directory = Path(directory).absolute() - if any(path.is_symlink() for path in (directory, *directory.parents)) or not directory.is_dir(): - raise ValueError('Dependency input must be a directory without symlinks') - manifest, sha = read_json(directory / MANIFEST) - exact_fields(manifest, ('schema', *BINDINGS, 'rpms')) - if type(manifest['schema']) is not int or manifest['schema'] != 1: - raise ValueError('Unsupported dependency manifest schema') - if any(not valid_sha(manifest[key]) for key in BINDINGS): - raise ValueError('Malformed dependency artifact bindings') - supplied = (baseline_iso, candidate_rpm, candidate_offline_rpm) - if any(path is not None for path in supplied): - if any(path is None for path in supplied): - raise ValueError('Dependency bundle requires the baseline ISO and both first-party candidates') - for key, path in zip(BINDINGS, supplied): - if digest(path) != manifest[key]: - raise ValueError(f'Dependency bundle artifact binding mismatch: {key}') - records = manifest['rpms'] - if not isinstance(records, list) or not 1 <= len(records) <= MAX_RPMS: - raise ValueError('Dependency RPM count must be between 1 and 256') - names, identities, total = set(), set(), 0 - for record in records: - validate_record(record) - identity = (record['name'], record['architecture']) - if record['filename'] in names or identity in identities: - raise ValueError('Duplicate dependency RPM filename or package architecture') - names.add(record['filename']) - identities.add(identity) - total += record['size'] - if total > MAX_TOTAL_BYTES: - raise ValueError('Dependency bundle exceeds total size limit') - entries = list(itertools.islice(directory.iterdir(), MAX_RPMS + 2)) - if {path.name for path in entries} != names | {MANIFEST} or len(entries) != len(names) + 1: - raise ValueError('Dependency directory has extra or missing files') - for record in records: - if file_sha256(directory / record['filename'], MAX_RPM_BYTES, record['size']) != record['sha256']: - raise ValueError(f'Dependency RPM checksum mismatch: {record["filename"]}') - return DependencyBundle(directory, manifest, sha) - - -def rpm_identity(path): - """Used only by the optional host manifest creator (requires rpm).""" - fields = '%{NAME}\n%{EPOCHNUM}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n' - fields += ''.join('%{' + tag + ':pgpsig}\n' for tag in SIGNATURE_TAGS) - values = subprocess.run(['rpm', '-qp', '--qf', fields, str(path)], check=True, - capture_output=True, text=True, timeout=30, - env={**os.environ, 'LC_ALL': 'C', 'TZ': 'UTC'}).stdout.splitlines() - if len(values) != 5 + len(SIGNATURE_TAGS): - raise ValueError('Unexpected RPM identity output') - name, epoch, version, release, architecture = values[:5] - signatures = [(tag, value) for tag, value in zip(SIGNATURE_TAGS, values[5:]) if value != '(none)'] - if not signatures: - raise ValueError('Dependency RPM is unsigned') - tag, value = signatures[0] - return {'name': name, 'nevra': f'{name}-{epoch}:{version}-{release}.{architecture}', - 'architecture': architecture, 'signature': {'tag': tag, 'value': value}} - - -def create_manifest(directory, baseline_iso, candidate_rpm, candidate_offline_rpm, provenance): - """Describe already verified downloads; never fetch RPMs or trust new keys.""" - directory = Path(directory).absolute() - if any(path.is_symlink() for path in (directory, *directory.parents)) or not directory.is_dir(): - raise ValueError('Dependency input must be a directory without symlinks') - destination = directory / MANIFEST - if destination.exists() or destination.is_symlink(): - raise ValueError('Refusing to replace a dependency manifest') - entries = list(itertools.islice(directory.iterdir(), MAX_RPMS + 1)) - if not 1 <= len(entries) <= MAX_RPMS or not isinstance(provenance, dict) or set(provenance) != {p.name for p in entries}: - raise ValueError('Provenance must describe every RPM and no extra files') - records = [] - total = 0 - for path in sorted(entries): - if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm', path.name): - raise ValueError('Unsafe dependency RPM filename') - # Bound input before invoking the host RPM parser. - sha = file_sha256(path, MAX_RPM_BYTES) - size = path.stat().st_size - record = {'filename': path.name, **rpm_identity(path), 'size': size, - 'sha256': sha, 'repository': provenance[path.name]} - validate_record(record) - total += size - records.append(record) - if total > MAX_TOTAL_BYTES: - raise ValueError('Dependency bundle exceeds total size limit') - manifest = {'schema': 1, **dict(zip(BINDINGS, map(digest, (baseline_iso, candidate_rpm, candidate_offline_rpm)))), - 'rpms': records} - atomic_json(destination, manifest) - try: - return load_bundle(directory, baseline_iso=baseline_iso, candidate_rpm=candidate_rpm, - candidate_offline_rpm=candidate_offline_rpm) - except BaseException: - destination.unlink() - raise - - -@dataclass(frozen=True) -class RawDependencyFixture: - """Private pre-build snapshots, with a content identity but no artifact bindings.""" - directory: Path - records: list - provenance_sha256: str - - def evidence(self): - value = {'schema': 1, 'provenance_sha256': self.provenance_sha256, 'rpms': self.records} - sha = hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() - return {**value, 'fixture_sha256': sha, 'total_bytes': sum(record['size'] for record in self.records)} - - def bind(self, baseline_iso, candidate_rpm, candidate_offline_rpm): - bundle = create_manifest(self.directory, baseline_iso, candidate_rpm, candidate_offline_rpm, - {record['filename']: record['repository'] for record in self.records}) - if bundle.manifest['rpms'] != self.records: - (self.directory / MANIFEST).unlink() - raise ValueError('Raw dependency fixture changed after admission') - return bundle - - -def snapshot_raw_dependencies(directory, provenance_file, destination): - """Admit trusted raw RPMs before a build, without changing caller inputs.""" - directory, provenance_file, destination = (Path(path).absolute() for path in (directory, provenance_file, destination)) - if any(path.is_symlink() for path in (directory, *directory.parents)) or not directory.is_dir(): - raise ValueError('Raw dependency input must be a directory without symlinks') - if any(path.is_symlink() for path in (provenance_file, *provenance_file.parents)): - raise ValueError('Dependency provenance must not use symlinks') - if destination.resolve().is_relative_to(directory.resolve()): - raise ValueError('Dependency staging must be outside the caller input directory') - provenance, provenance_sha = read_json(provenance_file) - entries = list(itertools.islice(directory.iterdir(), MAX_RPMS + 1)) - if (not 1 <= len(entries) <= MAX_RPMS or not isinstance(provenance, dict) - or set(provenance) != {path.name for path in entries}): - raise ValueError('Provenance must describe every raw RPM and no extra files (1 to 256 RPMs)') - for path in entries: - if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm', path.name): - raise ValueError('Unsafe raw dependency RPM filename') - validate_repository(provenance[path.name]) - destination.mkdir(mode=0o700) - try: - records, identities, total = [], set(), 0 - for path in sorted(entries): - copied = destination / path.name - with regular_stream(path, MAX_RPM_BYTES) as (source, size): - total += size - if total > MAX_TOTAL_BYTES: - raise ValueError('Raw dependency fixture exceeds total size limit') - sha, remaining = hashlib.sha256(), size - with copied.open('xb') as output: - while remaining: - chunk = source.read(min(1024 * 1024, remaining)) - if not chunk: - raise ValueError('Raw dependency RPM changed while copying') - output.write(chunk) - sha.update(chunk) - remaining -= len(chunk) - if source.read(1): - raise ValueError('Raw dependency RPM grew while copying') - os.fchmod(output.fileno(), 0o400) - record = {'filename': path.name, **rpm_identity(copied), 'size': size, - 'sha256': sha.hexdigest(), 'repository': provenance[path.name]} - validate_record(record) - identity = (record['name'], record['architecture']) - if identity in identities: - raise ValueError('Duplicate raw dependency package architecture') - identities.add(identity) - records.append(record) - # Artifact hashes have fixed length, so reject a fixture that cannot - # fit the bound manifest before spending time on the candidate build. - preview = {'schema': 1, **{key: '0' * 64 for key in BINDINGS}, 'rpms': records} - if len(json.dumps(preview, indent=2, sort_keys=True).encode()) + 1 > MAX_MANIFEST_BYTES: - raise ValueError('Raw dependency manifest exceeds size limit') - return RawDependencyFixture(destination, records, provenance_sha) - except BaseException: - shutil.rmtree(destination) - raise - - -@contextmanager -def transfer_stream(bundle, record): - """Snapshot bounded bytes so a file growing during SSH cannot extend transfer.""" - with regular_stream(bundle.directory / record['filename'], MAX_RPM_BYTES) as (source, size), tempfile.TemporaryFile() as snapshot: - if size != record['size']: - raise ValueError('Dependency RPM size changed before transfer') - checksum = hashlib.sha256() - remaining = size - while remaining: - chunk = source.read(min(1024 * 1024, remaining)) - if not chunk: - raise ValueError('Dependency RPM changed before transfer') - snapshot.write(chunk) - checksum.update(chunk) - remaining -= len(chunk) - if source.read(1) or checksum.hexdigest() != record['sha256']: - raise ValueError('Dependency RPM checksum changed before transfer') - snapshot.seek(0) - yield snapshot - - -def verify_guest(directory, records): - """Self-contained verifier executed as root with the guest's existing RPM DB.""" - import hashlib - import os - from pathlib import Path - import re - import stat - import subprocess - - directory = Path(directory) - if directory.is_symlink() or not directory.is_dir(): - raise ValueError('Unsafe guest dependency directory') - if {path.name for path in directory.iterdir()} != {item['filename'] for item in records}: - raise ValueError('Extra or missing guest dependency RPMs') - environment = {**os.environ, 'LC_ALL': 'C', 'TZ': 'UTC'} - for record in records: - path = directory / record['filename'] - info = path.lstat() - if not stat.S_ISREG(info.st_mode) or info.st_size != record['size']: - raise ValueError('Guest dependency RPM size or file type mismatch') - with path.open('rb') as stream: - if hashlib.file_digest(stream, 'sha256').hexdigest() != record['sha256']: - raise ValueError('Guest dependency RPM checksum mismatch') - identity = '%{NAME}-%{EPOCHNUM}:%{VERSION}-%{RELEASE}.%{ARCH}\n%{ARCH}\n' - identity += '%{' + record['signature']['tag'] + ':pgpsig}\n' - result = subprocess.run(['rpm', '-qp', '--qf', identity, str(path)], check=True, - capture_output=True, text=True, timeout=30, env=environment) - if result.stdout.splitlines() != [record['nevra'], record['architecture'], record['signature']['value']]: - raise ValueError('Guest dependency RPM identity or signature header mismatch') - # No --import or alternate database: use only installed guest trust. - result = subprocess.run(['rpm', '--define', '_pkgverify_level all', '--define', '_pkgverify_flags 0x0', - '--checksig', '--verbose', str(path)], check=True, - capture_output=True, text=True, timeout=30, env=environment) - if not re.search(r'(?im)^\s*.*\b(?:signature|openpgp)\b.*:\s*OK\s*$', result.stdout): - raise ValueError('Guest dependency RPM has no verified trusted signature') - - -def guest_verification_script(directory, records): - return "python3 - <<'PY'\n" + inspect.getsource(verify_guest) + f'\nverify_guest({str(directory)!r}, {records!r})\nPY\n' - - -def seal_guest_inputs(source_directory, records, firstparty): - """Copy account-owned transfers into new root-only inodes before verification.""" - import hashlib - import os - from pathlib import Path - import shutil - import stat - import tempfile - - if os.geteuid() != 0: - raise PermissionError('Upgrade staging requires root') - parent = Path('/tmp') - info = parent.lstat() - if (not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 - or (info.st_mode & 0o022 and not info.st_mode & stat.S_ISVTX)): - raise PermissionError('Upgrade staging parent must be root-owned and protected against renames') - if set(firstparty) != {'candidate', 'offline_candidate'}: - raise ValueError('Sealing requires both first-party candidates') - directory = Path(tempfile.mkdtemp(prefix='cybexos-qualification-sealed-', dir=parent)) - try: - os.chmod(directory, 0o700) - (directory / 'candidates').mkdir(mode=0o700) - (directory / 'dependencies').mkdir(mode=0o700) - - def copy_file(source, destination, expected, *, source_fd=None): - # Never follow a swapped symlink or block on an account-owned FIFO. - descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=source_fd) - with os.fdopen(descriptor, 'rb') as original: - info = os.fstat(original.fileno()) - size = expected['size'] - if (type(size) is not int or size <= 0 or not stat.S_ISREG(info.st_mode) - or info.st_size != size): - raise ValueError('Transferred upgrade RPM size or file type mismatch') - output_fd = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) - with os.fdopen(output_fd, 'wb') as output: - remaining = size - while remaining: - chunk = original.read(min(1024 * 1024, remaining)) - if not chunk: - raise ValueError('Transferred upgrade RPM changed during sealing') - output.write(chunk) - remaining -= len(chunk) - if original.read(1): - raise ValueError('Transferred upgrade RPM grew during sealing') - output.flush() - os.fchmod(output.fileno(), 0o400) - # Verify the new inode, never the account-owned source pathname. - with destination.open('rb') as copied: - if hashlib.file_digest(copied, 'sha256').hexdigest() != expected['sha256']: - raise ValueError('Sealed upgrade RPM checksum mismatch') - - result = {'directory': str(directory), 'dependencies': []} - for key, expected in firstparty.items(): - destination = directory / 'candidates' / (key + '.rpm') - copy_file(expected['source'], destination, expected) - result[key] = str(destination) - # Pin the original directory inode too; a parent-path swap cannot turn - # an RPM open into traversal through an account-controlled symlink. - source_fd = os.open(source_directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) - try: - for record in records: - destination = directory / 'dependencies' / record['filename'] - copy_file(record['filename'], destination, record, source_fd=source_fd) - result['dependencies'].append(str(destination)) - finally: - os.close(source_fd) - verify_guest(directory / 'dependencies', records) - return result - except BaseException: - shutil.rmtree(directory) - raise - - -def guest_sealing_script(directory, records, firstparty): - return ("python3 - <<'PY'\nimport json\n" + inspect.getsource(verify_guest) + '\n' - + inspect.getsource(seal_guest_inputs) - + f'\nprint(json.dumps(seal_guest_inputs({str(directory)!r}, {records!r}, {firstparty!r})))\nPY\n') - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('action', choices=('create', 'verify')) - parser.add_argument('directory', type=Path) - parser.add_argument('--baseline-iso', type=Path, required=True) - parser.add_argument('--candidate-rpm', type=Path, required=True) - parser.add_argument('--candidate-offline-rpm', type=Path, required=True) - parser.add_argument('--provenance', type=Path, help='Create only: JSON mapping RPM filenames to {id, url} repositories') - args = parser.parse_args() - try: - if args.action == 'create': - if not args.provenance: - parser.error('create requires --provenance') - provenance, _ = read_json(args.provenance) - bundle = create_manifest(args.directory, args.baseline_iso, args.candidate_rpm, - args.candidate_offline_rpm, provenance) - else: - if args.provenance: - parser.error('--provenance is only valid for create') - bundle = load_bundle(args.directory, baseline_iso=args.baseline_iso, candidate_rpm=args.candidate_rpm, - candidate_offline_rpm=args.candidate_offline_rpm) - except (OSError, ValueError, subprocess.SubprocessError) as error: - parser.error(str(error)) - print(json.dumps(bundle.evidence(), indent=2, sort_keys=True)) - - -if __name__ == '__main__': - main() diff --git a/image/package b/image/package deleted file mode 100755 index 293dcd97..00000000 --- a/image/package +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env python3 -"""Assemble the RPM payload from the existing desktop and image integration.""" - -import argparse -import hashlib -import json -from pathlib import Path -import shutil -import shlex -import subprocess -import tarfile - -import jinja2 -import yaml -from application_payload import relativize_seed_links, include_applications -from desktop_payload import prepare_defaults, prepare_session, split_seed, prepare_managed_defaults, prepare_app_launchers, prepare_quickshell, prepare_font_licenses -from provision_payload import prepare_provision -from release_metadata import install_update_channel, render_spec -from titlebars_payload import prepare_titlebars -from notification_images import prepare_notification_images -from offline_payload import configuration as offline_configuration, file_lists, inventory as offline_inventory - -ROOT = Path(__file__).resolve().parents[1] - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - build = args.output.resolve() - payload = build / "payload" - payload.mkdir(parents=True, exist_ok=False) - applications = json.loads((build / "applications.json").read_text()) - # Resolve the starter's plugin graph in the builder, so opening Neovim - # offline does not need to bootstrap Lazy or fetch the default plugins. - seed_home = Path(applications["user_seed"]) - subprocess.run(["runuser", "-u", "cybex-seed", "--", "env", - f"HOME={seed_home}", f"PATH={seed_home}/.cargo/bin:{seed_home}/.local/bin:/usr/local/bin:/usr/bin", - "nvim", "--headless", "+Lazy! install", "+qa"], check=True, timeout=600) - seed_root = build / "applications-root/usr/share/cybexos/user-seed" - nvim_payload = seed_root / ".local/share/nvim" - if nvim_payload.exists(): - shutil.rmtree(nvim_payload) - shutil.copytree(seed_home / ".local/share/nvim", nvim_payload, symlinks=True) - shutil.copy2(seed_home / ".config/nvim/lazy-lock.json", seed_root / ".config/nvim/lazy-lock.json") - shutil.copytree(build / "applications-root", payload, dirs_exist_ok=True, symlinks=True) - provenance = install_update_channel(ROOT / "image", payload) - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - environment = jinja2.Environment(undefined=jinja2.StrictUndefined, keep_trailing_newline=True) - environment.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no, - to_json=json.dumps, quote=shlex.quote) - values = {"features": inventory["features"], "cybexos_xps_2026": False, - "machine_keyboard_layout": "us", "machine_keyboard_variant": "", - "cybexos_dynamic_hardware": True} - - def write(relative, content, executable=False): - target = payload / str(relative).lstrip("/") - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content) - target.chmod(0o755 if executable else 0o644) - - def copy(source, relative, executable=False): - target = payload / str(relative).lstrip("/") - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(ROOT / source, target) - target.chmod(0o755 if executable else 0o644) - - runtime = "usr/share/cybexos/runtime" - copy("LICENSE", "usr/share/licenses/cybexos-desktop/LICENSE") - copy("release-manifest.json", "usr/share/cybexos/release-manifest.json") - prepare_quickshell(ROOT, payload) - prepare_titlebars(ROOT, build, payload) - prepare_notification_images(ROOT, build, payload) - for name in ("hyprland.lua", "bindings.lua", "autostart.lua", "displays.lua", "input_preferences.lua", - "workspace_state.lua", "workspace_model.lua", "workspace_layouts.lua", "workspaces.lua"): - content = (ROOT / "roles/desktop/files" / name).read_text() - content = content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-") - write(f"{runtime}/hypr/{name}", content) - for name in ("features.lua", "monitors.lua", "input.lua", "looknfeel.lua", "hypridle.conf", "hyprlock.conf"): - content = environment.from_string((ROOT / "roles/desktop/templates" / (name + ".j2")).read_text()).render(**values) - content = content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-") - if name == "input.lua": - content = content.replace('kb_layout = "us"', 'kb_layout = os.getenv("CYBEXOS_KEYBOARD_LAYOUT") or "us"') - content = content.replace('kb_variant = ""', 'kb_variant = os.getenv("CYBEXOS_KEYBOARD_VARIANT") or ""') - write(f"{runtime}/hypr/{name}", content) - contract = json.loads((ROOT / "assets/desktop-contract.json").read_text()) - helpers = contract["userHelpers"] + ["cybexos-runtime", "cybexos-update-run"] - for name in helpers: - copy(f"assets/scripts/{name}", f"usr/share/cybexos/bin/{name}", True) - # The earlier browser-profile export, now a mode of cybexos-backup; the - # dotfiles role links the same names on checkout installations. - for name in ("app-backup", "browser-backup"): - (payload / "usr/share/cybexos/bin" / name).symlink_to("cybexos-backup") - copy("roles/dotfiles/files/dev-distrobox-init", "usr/share/cybexos/bin/dev-distrobox-init", True) - updater = payload / "usr/share/cybexos/bin/cybexos-update-run" - updater.write_text(updater.read_text().replace("snapshot_helper_root=/usr/local/libexec", "snapshot_helper_root=/usr/libexec")) - copy("roles/dotfiles/templates/brightness-control.j2", "usr/share/cybexos/bin/brightness-control", True) - for name in ("fastfetch-link-speed", "t3code-update", "t3code-desktop", "spotify"): - copy(f"roles/dotfiles/templates/{name}.j2", f"usr/share/cybexos/bin/{name}", True) - seed = "usr/share/cybexos/user-seed" - # Managed updater scripts follow the RPM; application binaries and their - # version stores remain writable per-user after offline initialization. - copy_target = payload / seed / ".local/bin/update-user-tools" - shutil.move(copy_target, payload / "usr/share/cybexos/bin/update-user-tools") - (payload / seed / ".local/bin/t3code-update").unlink() - for source, destination in ( - ("fastfetch.jsonc", ".config/fastfetch/config.jsonc"), - ("fish-config.fish", ".config/fish/conf.d/50-cybexos.fish"), - ): - copy(f"roles/dotfiles/files/{source}", f"{seed}/{destination}") - copy("assets/EDM115-newline2.omp.json", f"{seed}/.config/oh-my-posh/EDM115-newline2.omp.json") - copy("assets/nautilus-localsend.py", f"{seed}/.local/share/nautilus-python/extensions/localsend.py") - for name in ("localsend-share-clipboard", "localsend-share-file", "localsend-share-folder"): - content = (ROOT / "roles/dotfiles/templates" / (name + ".desktop.j2")).read_text() - write(f"{seed}/.local/share/applications/{name}.desktop", - content.replace("{{ primary_home }}/.local/bin/", "")) - prepare_app_launchers(ROOT, payload) - t3_desktop = (ROOT / "roles/dotfiles/templates/t3code-nightly.desktop.j2").read_text() - write(f"{seed}/.local/share/applications/t3code-nightly.desktop", - t3_desktop.replace("{{ primary_home }}/.local/bin/", "")) - for box in inventory["distrobox_images"]: - launcher = environment.from_string((ROOT / "roles/dotfiles/templates/distrobox-shell.j2").read_text()).render(item=box) - write(f"usr/share/cybexos/bin/dev-{box['name']}-shell", launcher, True) - desktop = (ROOT / f"roles/dotfiles/templates/dev-{box['name']}.desktop.j2").read_text() - write(f"{seed}/.local/share/applications/dev-{box['name']}.desktop", - desktop.replace("{{ primary_home }}/.local/bin/", "")) - # Systemd's %h resolves at service start, after account creation. - hermes = environment.from_string((ROOT / "roles/desktop/templates/hermes-menubar-bridge.service.j2").read_text()).render(primary_home="%h", hermes_bridge_executable="/usr/libexec/cybexos-hermes-menubar-bridge") - write("usr/lib/systemd/user/hermes-menubar-bridge.service", hermes) - copy("roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py", "usr/libexec/cybexos-hermes-menubar-bridge", True) - for source in sorted((ROOT / "roles/desktop/files/hermes-menubar-bridge/cybex_hermes").glob("*.py")): - copy(source.relative_to(ROOT), f"usr/libexec/cybex_hermes/{source.name}") - # Voxtype's RPM owns its system user unit. The session target starts that - # unit; do not collide with it by packaging our per-user Ansible template. - write(f"{seed}/.npmrc", "prefix=${HOME}/.npm-global\n") - write("usr/share/cybexos/applications.json", json.dumps(applications, indent=2) + "\n") - copy("roles/base/files/cybexos-system-snapshot", "usr/libexec/cybexos-system-snapshot", True) - # Settings runs this through pkexec; provisioning applies it from the role. - copy("roles/base/files/cybexos-auth-policy", "usr/libexec/cybexos-auth-policy", True) - for name in ("cybexos-update-transaction", "cybexos-update-bootstrap", "cybexos-update-recover", "cybexos-vendor-paths.json", "cybexos-major-upgrade"): - copy(f"roles/base/files/{name}", f"usr/libexec/{name}", not name.endswith('.json')) - for name in ("cybexos-update-recover.service", "cybexos-update-recover-login.conf"): - copy(f"roles/base/files/{name}", f"usr/libexec/{name}") - recover = (ROOT / "roles/base/files/cybexos-update-recover.service").read_text() - write("usr/lib/systemd/system/cybexos-update-recover.service", - recover.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - for unit in ("systemd-user-sessions.service", "sddm.service"): - copy("roles/base/files/cybexos-update-recover-login.conf", - f"usr/lib/systemd/system/{unit}.d/60-cybexos-update-recover.conf") - for name in ("cybexos-major-upgrade-validate.service", "cybexos-major-upgrade-validate.timer"): - content = (ROOT / "roles/base/files" / name).read_text() - write(f"usr/lib/systemd/system/{name}", - content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - # Background update downloads, as the base role installs them. Shipped - # off: %posttrans never enables the timer, so systemd keeps the person's - # choice across package updates. - copy("roles/base/files/cybexos-update-download", "usr/libexec/cybexos-update-download", True) - for name in ("cybexos-update-download.service", "cybexos-update-download.timer"): - content = (ROOT / "roles/base/files" / name).read_text() - write(f"usr/lib/systemd/system/{name}", - content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - # `sudo cybexos-system-snapshot ...`, as documented; libexec is not on PATH. - (payload / "usr/bin").mkdir(parents=True, exist_ok=True) - (payload / "usr/bin/cybexos-system-snapshot").symlink_to("../libexec/cybexos-system-snapshot") - # Recovery booting: vendor locations for dracut and kernel-install; GRUB - # reads /etc/grub.d only. The installer's grub2-mkconfig and dracut runs - # pick these up for the installed system. - recovery = "roles/base/files/recovery" - copy(f"{recovery}/42_cybexos_recovery", "etc/grub.d/42_cybexos_recovery", True) - copy(f"{recovery}/95-cybexos-recovery.install", "usr/lib/kernel/install.d/95-cybexos-recovery.install", True) - copy(f"{recovery}/90-cybexos-recovery.conf", "usr/lib/dracut/dracut.conf.d/90-cybexos-recovery.conf") - for name in ("module-setup.sh", "cybexos-recovery-overlay.sh"): - copy(f"{recovery}/90cybexos-recovery/{name}", f"usr/lib/dracut/modules.d/90cybexos-recovery/{name}", True) - refresh = (ROOT / recovery / "cybexos-recovery-refresh.service").read_text() - write("usr/lib/systemd/system/cybexos-recovery-refresh.service", - refresh.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) - for name in ("cybexos-session-action", "hyprland-session-start"): - output_name = name if name.startswith("cybexos") else "cybexos-" + name - copy(f"roles/desktop/files/{name}", f"usr/libexec/{output_name}", True) - # Login policy and authentication use the same sources as workstation - # deployment. SDDM owns its PAM file; our post-transaction helper installs - # this template with a backup instead of creating an RPM file conflict. - copy("roles/desktop/files/login/cybexos-login-prepare", "usr/libexec/cybexos-login-prepare", True) - copy("roles/desktop/files/login/sddm-autologin", "usr/share/cybexos/login/sddm-autologin") - copy("roles/desktop/files/login/50-cybexos.conf", "usr/lib/systemd/system/sddm.service.d/50-cybexos.conf") - for source in sorted((ROOT / "image/rootfs").rglob("*")): - if source.is_file() and "__pycache__" not in source.parts and source.suffix != ".pyc": - relative = source.relative_to(ROOT / "image/rootfs") - copy(source.relative_to(ROOT), relative, relative.parts[1] in ("bin", "libexec")) - prepare_defaults(ROOT, payload, environment, inventory) - prepare_session(ROOT, payload, inventory) - prepare_provision(ROOT, payload) - copy("image/library/cybexos_managed_file.py", "usr/share/cybexos/lib/managed_files.py") - copy("image/release_metadata.py", "usr/share/cybexos/lib/release_metadata.py") - copy("image/installation_policy.py", "usr/share/cybexos/lib/installation_policy.py") - copy("roles/apps/files/cybexos-repository-policy", "usr/libexec/cybexos-repository-policy", True) - # The workstation's font family mappings; roles/apps/tasks/fonts.yml - # installs the same file on checkout deployments. - copy("roles/apps/files/49-cybexos-defaults.conf", "etc/fonts/conf.d/49-cybexos-defaults.conf") - # The same IBus unit and shortcut default as workstation deployment. Fedora's - # glib2 file trigger compiles the schema override when the RPM installs. - copy("roles/desktop/files/cybexos-input-method.service", "usr/lib/systemd/user/cybexos-input-method.service") - copy("roles/desktop/files/90-cybexos-ibus.gschema.override", - "usr/share/glib-2.0/schemas/90-cybexos-ibus.gschema.override") - # Share the Files/GVfs default with checkout installations. - copy("roles/desktop/files/90-cybexos-smb.gschema.override", - "usr/share/glib-2.0/schemas/90-cybexos-smb.gschema.override") - for name in ("brave-browser", "1password", "chatgpt", "tailscale"): - copy(f"roles/apps/files/{name}.repo", f"usr/share/cybexos/repository-policy/{name}.repo") - prepare_font_licenses(payload, inventory) - relativize_seed_links(payload / seed, seed_home) - split_seed(payload / "usr/share/cybexos", contract) - prepare_managed_defaults(payload / "usr/share/cybexos") - offline = offline_configuration(ROOT) - write("usr/share/cybexos/offline-inputs.json", json.dumps(offline, indent=2) + "\n") - manifest = {} - for file in sorted(payload.rglob("*")): - if file.is_symlink(): - manifest[str(file.relative_to(payload))] = {"link": str(file.readlink())} - elif file.is_file(): - file.chmod(0o755 if file.stat().st_mode & 0o111 else 0o644) - with file.open("rb") as stream: - manifest[str(file.relative_to(payload))] = hashlib.file_digest(stream, "sha256").hexdigest() - elif file.is_dir(): - file.chmod(0o755) - # The complete payload identity changes on every effective package change. - identity = hashlib.sha256(json.dumps(manifest, sort_keys=True).encode()).hexdigest() - write("usr/share/cybexos/reconcile-version", identity + "\n") - manifest["usr/share/cybexos/reconcile-version"] = hashlib.sha256((identity + "\n").encode()).hexdigest() - (build / "package-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") - sources = build / "rpmbuild/SOURCES" - sources.mkdir(parents=True) - (sources / "offline.files").write_text("\n".join(file_lists(payload)) + "\n") - (build / "offline-payload.json").write_text(json.dumps({**offline, **offline_inventory(payload)}, indent=2) + "\n") - spec = render_spec((ROOT / "image/cybexos-desktop.spec").read_text(), ROOT / "VERSION", provenance) - native = json.loads((build / "titlebars.json").read_text()) - hyprland_rpm = native["inputs"]["hyprland"]["rpm"].rsplit(".", 1)[0] - spec = spec.replace("# Native compositor EVR requirement inserted by image/package", - "Requires: hyprland = " + hyprland_rpm) - spec = spec.replace("%global offline_version 1.0.0", "%global offline_version " + offline["version"]) - spec = spec.replace("%global offline_inputs unconfigured", "%global offline_inputs " + offline["inputs_sha256"]) - (build / "desktop.spec").write_text(spec) - (build / "live.ks").write_text(include_applications( - (ROOT / "image/live.ks").read_text(), applications["packages"], - )) - with tarfile.open(sources / "desktop.tar", "w") as archive: - archive.add(payload / "usr", arcname="usr") - archive.add(payload / "opt", arcname="opt") - archive.add(payload / "etc", arcname="etc") - - -if __name__ == "__main__": - main() diff --git a/image/parity-exceptions.json b/image/parity-exceptions.json deleted file mode 100644 index 0f4110d1..00000000 --- a/image/parity-exceptions.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "iso": { - "cybexos-desktop.x86_64": "The ISO delivers the shared desktop payload as an RPM; checkout installs the same source through Ansible.", - "cybexos-offline-apps.x86_64": "The ISO delivers the shared application seed as an independently versioned RPM; checkout uses the same application roles." - }, - "checkout": { - "gpg-pubkey.(none)=56f49901ab19baf099a95a76c3de1dd4f661cdcb-63ab09ad": "Exact Brave prerelease signing key shipped by signed brave-keyring 1.19-1 and imported by its background postinstall script on running Fedora. The ISO's initial chroot lacks timeout during that transaction, so its importer does not run. Both paths retain the same signed keyring package and pinned stable repository; no prerelease repository is enabled.", - "gpg-pubkey.(none)=8c1f16ab24df8f75c1cf56595929a141e0e87f1f-67d85d48": "Exact Brave prerelease signing key shipped by signed brave-keyring 1.19-1 and imported by its background postinstall script on running Fedora. The ISO's initial chroot lacks timeout during that transaction, so its importer does not run. Both paths retain the same signed keyring package and pinned stable repository; no prerelease repository is enabled.", - "gpg-pubkey.(none)=b721e073b7ef8e56acc6b23ecbc67d2399225ccf-68908a61": "Exact Brave prerelease signing key shipped by signed brave-keyring 1.19-1 and imported by its background postinstall script on running Fedora. The ISO's initial chroot lacks timeout during that transaction, so its importer does not run. Both paths retain the same signed keyring package and pinned stable repository; no prerelease repository is enabled.", - "cloud-init.noarch": "The pinned Fedora Cloud base uses cloud-init only to provision this disposable qualification guest.", - "cloud-utils-growpart.noarch": "The pinned Fedora Cloud base expands only its disposable virtual disk during first boot." - } -} diff --git a/image/parity_qualification.py b/image/parity_qualification.py deleted file mode 100644 index 1214644c..00000000 --- a/image/parity_qualification.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Shared real-desktop and saved-preference checks for both installer paths.""" -from pathlib import Path -import subprocess - -ROOT = Path(__file__).resolve().parents[1] - - -def desktop_lifecycle(vm): - helper = (ROOT / 'tests/lib/quickshell-live').read_text() - test = (ROOT / 'tests/system-settings-live').read_text().split('qs_live_begin\n', 1)[1] - script = 'set -euo pipefail\nexport XDG_RUNTIME_DIR=/run/user/$(id -u)\n' + helper + '\nqs_live_begin\n' + test - try: - subprocess.run([*vm.ssh, 'bash -s'], input=script, text=True, capture_output=True, check=True, timeout=300) - except subprocess.CalledProcessError as error: - detail = ((error.stdout or '') + (error.stderr or ''))[-4000:] - raise RuntimeError('Managed Settings lifecycle failed: ' + detail) from error - - -SAVED_CHOICES = r''' -import json, os, pwd -from pathlib import Path -account=pwd.getpwnam('qualification') -home=Path(account.pw_dir) -path=home / '.config/cybexos/shell.json' -data=json.loads(path.read_text()) -data['position']='bottom' -data['qualificationFuture']={'preserve':[False,0,'user-owned']} -path.write_text(json.dumps(data,indent=2)+'\n') -files={ - '.config/cybexos/input.json': json.dumps({'v':1,'keyboard':{'layouts':[{'layout':'us','variant':''},{'layout':'nl','variant':''}], 'shortcut':'grp:alt_shift_toggle'}, 'touchpad':{'tap':False,'naturalScroll':False,'sensitivity':0.25},'qualificationFuture':True})+'\n', - '.config/cybexos/hypr/user.lua': 'hl.config({ input = { repeat_rate = 37 } })\n', - 'qualification-personal-marker': 'qualification-preserve\n', -} -for relative, value in files.items(): - target=home / relative - target.parent.mkdir(parents=True,exist_ok=True) - target.write_text(value) - os.chown(target,account.pw_uid,account.pw_gid) -os.chown(path,account.pw_uid,account.pw_gid) -''' - - -def prepare_saved_choices(vm, password, root_script): - root_script(vm, "python3 - <<'PY'\n" + SAVED_CHOICES + '\nPY\n', password) - - -def verify_saved_choices(vm, password, root_script): - root_script(vm, r'''python3 - <<'PY' -import json -from pathlib import Path -home=Path('/home/qualification') -data=json.loads((home / '.config/cybexos/shell.json').read_text()) -assert data['position']=='bottom' -assert data['qualificationFuture']=={'preserve':[False,0,'user-owned']} -assert (home / '.config/cybexos/hypr/user.lua').read_text()=='hl.config({ input = { repeat_rate = 37 } })\n' -assert json.loads((home / '.config/cybexos/input.json').read_text())['qualificationFuture'] is True -assert (home / 'qualification-personal-marker').read_text()=='qualification-preserve\n' -PY -''', password) diff --git a/image/post_install_check.py b/image/post_install_check.py deleted file mode 100644 index af70e3a3..00000000 --- a/image/post_install_check.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Run the installer's post-install scripts against the composed image. - -Anaconda installs a live image by copying its root filesystem, then runs -live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks in the copy. A -failing command there aborts every installation after the boot loader step, -which source fixtures cannot reveal. The builder reproduces that step before -packaging: a throwaway overlay of the image root receives an administrator -account like the one Anaconda creates, the Live /run shape Anaconda binds into -the target, and no network. The image root itself is never modified. -""" -import json -from pathlib import Path -import re -import shutil -import subprocess -import tempfile -import types - -HOOK = 'usr/share/anaconda/post-scripts/90-cybexos.ks' -TARGET_HELPER = 'usr/libexec/cybexos-installer-target' -ACCOUNT = 'cybexcheck' - - -def chroot_post_script(text): - """Return the body of the single chroot %post section Anaconda runs.""" - sections = re.findall(r'^%post\b([^\n]*)\n(.*?)^%end\s*$', text, re.MULTILINE | re.DOTALL) - bodies = [body for options, body in sections if '--nochroot' not in options.split()] - if len(bodies) != 1: - raise ValueError('Expected exactly one chroot %post section in the installer hook') - return bodies[0] - - -def load_target_helper(root): - # Compile in memory: an import would write __pycache__ into the image root. - path = root / TARGET_HELPER - module = types.ModuleType('cybexos_installer_target') - exec(compile(path.read_text(), str(path), 'exec'), module.__dict__) - return module - - -def verify_target(root, username=ACCOUNT): - """The installed-system state the post-install scripts must leave behind.""" - problems = [] - for path in ('etc/sudoers.d/cybexos-live', 'usr/lib/systemd/system/cybexos-live.service', - 'usr/lib/systemd/user/cybexos-install-session.target', - 'usr/libexec/cybexos-installer-backend', 'usr/share/cockpit/cybexos-installer', - 'etc/dracut.conf.d/99-live.conf', 'etc/anaconda/conf.d/20-cybexos.conf'): - if (root / path).exists(): - problems.append(f'live-only /{path} remains') - if not (root / 'var/lib/systemd/linger' / username).is_file(): - problems.append('user lingering was not enabled') - try: - login = json.loads((root / 'etc/cybexos/login.json').read_text()) - if login != {'version': 1, 'user': username, 'autologin': True, 'live': False}: - problems.append(f'unexpected login policy {login}') - except (OSError, ValueError): - problems.append('the login policy is missing or invalid') - config = root / 'etc/cybexos/config.yml' - text = config.read_text() if config.is_file() else '' - if f"primary_user: '{username}'" not in text or 'desktop_autologin: true' not in text: - problems.append('the installation choices were not recorded') - if not (root / 'etc/cybexos/hardware.json').is_file(): - problems.append('hardware detection did not run') - if problems: - raise RuntimeError('Post-install check: ' + '; '.join(problems)) - - -def check_post_install(image_root, work_parent, run=subprocess.run): - image_root = Path(image_root) - script = chroot_post_script((image_root / HOOK).read_text()) - work = Path(tempfile.mkdtemp(prefix='post-install-check-', dir=work_parent)) - merged = work / 'root' - mounts = [] - - def mount(*arguments, target): - run(['mount', *arguments, str(target)], check=True) - mounts.append(target) - - try: - for name in ('upper', 'work', 'root'): - (work / name).mkdir() - mount('-t', 'overlay', 'overlay', '-o', - f'lowerdir={image_root},upperdir={work / "upper"},workdir={work / "work"}', target=merged) - for name in ('proc', 'sys', 'dev', 'dev/pts'): - mount('--bind', '/' + name, target=merged / name) - # Anaconda binds the live /run, which has /run/lock, into the target. - for name in ('run', 'tmp'): - mount('-t', 'tmpfs', '-o', 'mode=0755' if name == 'run' else 'mode=1777', 'tmpfs', target=merged / name) - (merged / 'run/lock').mkdir(mode=0o1777) - (merged / 'run/lock').chmod(0o1777) - run(['chroot', str(merged), 'useradd', '--create-home', '--groups', 'wheel', - '--comment', 'Post-install check', ACCOUNT], check=True) - (merged / 'tmp/cybexos-post.sh').write_text(script) - log = work / 'post-install.log' - with log.open('w') as stream: - result = run(['unshare', '--net', 'chroot', str(merged), '/bin/bash', '-eux', '/tmp/cybexos-post.sh'], - stdout=stream, stderr=subprocess.STDOUT, check=False) - if result.returncode: - tail = log.read_text(errors='replace').splitlines()[-80:] - raise RuntimeError('The installer post-install script failed in the composed image ' - f'(exit {result.returncode}):\n' + '\n'.join(tail)) - # The --nochroot hook finalizes the target from outside; exercise the - # same decision for a verified encrypted installation. - load_target_helper(image_root).finalize( - merged, {'phase': 'installing', 'account': {'username': ACCOUNT, 'encrypted': True, 'passwordless_wheel': False}}, True) - verify_target(merged) - finally: - stuck = [target for target in reversed(mounts) - if run(['umount', str(target)], check=False).returncode - and run(['umount', '--lazy', str(target)], check=False).returncode] - # Never recurse into a directory that may still reach the builder's - # /dev, /proc or /sys through a bind mount. - if not stuck: - shutil.rmtree(work, ignore_errors=True) diff --git a/image/prepare-github-release b/image/prepare-github-release deleted file mode 100755 index 84d7911a..00000000 --- a/image/prepare-github-release +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python3 -from github_release import main - -if __name__ == '__main__': - main() diff --git a/image/provision.cfg b/image/provision.cfg deleted file mode 100644 index e88a2447..00000000 --- a/image/provision.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[defaults] -stdout_callback = default -retry_files_enabled = False diff --git a/image/provision.yml b/image/provision.yml deleted file mode 100644 index b6cefcf2..00000000 --- a/image/provision.yml +++ /dev/null @@ -1,276 +0,0 @@ ---- -- name: Configure an installed CybexOS account with the workstation policy - hosts: localhost - connection: local - gather_facts: true - module_defaults: - ansible.builtin.dnf: - install_weak_deps: false - vars_files: - - ../inventory/group_vars/all.yml - tasks: - - name: Require the supported installation platform - ansible.builtin.assert: - that: - - ansible_facts.distribution == 'Fedora' - - ansible_facts.distribution_major_version == fedora_release - - ansible_facts.architecture == 'x86_64' - - primary_user != 'root' - - primary_user != 'liveuser' - - - name: Apply the common installed-system policy - ansible.builtin.include_role: - name: base - tasks_from: "{{ (cybexos_reconcile | default(false) | bool) | ternary('reconcile', 'image') }}" - when: - - not cybexos_hardware_only | bool - - - name: Apply the shared Fish configuration - ansible.builtin.include_role: - name: dotfiles - tasks_from: shell-defaults - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Prefer reliable hardware video decoding in mpv - ansible.builtin.include_role: - name: apps - tasks_from: mpv - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - # The workstation installer manages personal defaults for accounts that - # have none of their own; every image account starts from the seed. A - # saved configuration's choice arrives as extra vars, which always win. - - name: Manage personal defaults on image installations by default - ansible.builtin.set_fact: - manage_personal_dotfiles: true - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Apply the shared session environment - ansible.builtin.include_role: - name: dotfiles - tasks_from: environment - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - # Runs before the account is seeded: the Kitty include and fragment it - # writes are what the seed would provide, so the seed leaves them alone. - - name: Apply the shared personal defaults - ansible.builtin.include_role: - name: dotfiles - tasks_from: personal - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Link the packaged CybexOS agent skill for coding agents - ansible.builtin.include_role: - name: dotfiles - tasks_from: agent-skills - vars: - dotfiles_agent_skill_canonical: /usr/share/cybexos/agent-skills/cybexos - when: not cybexos_hardware_only | bool - - - name: Apply the shared portal preferences - ansible.builtin.include_role: - name: desktop - tasks_from: portals - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - # The compose installs the CPU-default selector; the image builder's own - # selection never reaches the image. A GPU-less machine keeps the CPU. - - name: Select the Voxtype GPU backend - ansible.builtin.include_role: - name: apps - tasks_from: voxtype-backend - vars: - apps_voxtype_gpu_optional: true - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - features.developer_tools | bool - - # The package's user units carry the inventory's feature defaults. The - # saved configuration can differ; system-wide user-unit configuration - # applies it to every account without editing a packaged file. - - name: Apply the configured desktop features to every account - when: not cybexos_hardware_only | bool - block: - - name: Create the Quickshell feature drop-in directory - ansible.builtin.file: - path: /etc/systemd/user/quickshell.service.d - state: directory - mode: "0755" - - - name: Record the configured shell features - ansible.builtin.copy: - dest: /etc/systemd/user/quickshell.service.d/50-cybexos-features.conf - mode: "0644" - content: | - [Service] - Environment=CYBEXOS_CONNECTED_WIDGETS={{ (features.connected_widgets | bool) | ternary('1', '0') }} - Environment=CYBEXOS_DEVELOPER_TOOLS={{ (features.developer_tools | bool) | ternary('1', '0') }} - register: image_shell_features - when: not cybexos_reconcile | default(false) | bool - - - name: Reconcile the configured shell features while preserving local edits - cybexos_managed_file: - dest: /etc/systemd/user/quickshell.service.d/50-cybexos-features.conf - mode: "0644" - content: | - [Service] - Environment=CYBEXOS_CONNECTED_WIDGETS={{ (features.connected_widgets | bool) | ternary('1', '0') }} - Environment=CYBEXOS_DEVELOPER_TOOLS={{ (features.developer_tools | bool) | ternary('1', '0') }} - register: image_reconcile_shell_features - when: cybexos_reconcile | default(false) | bool - - - name: Inspect the Hermes bridge mask - ansible.builtin.stat: - path: /etc/systemd/user/hermes-menubar-bridge.service - follow: false - register: image_hermes_mask - - # The workstation installs the bridge only with connected widgets. - # The session target wants it, so a mask is how an image omits it. - - name: Mask the Hermes bridge when connected widgets are deselected - ansible.builtin.file: - src: /dev/null - dest: /etc/systemd/user/hermes-menubar-bridge.service - state: link - register: image_hermes_masked - when: - - not features.connected_widgets | bool - - not image_hermes_mask.stat.exists - - - name: Unmask the Hermes bridge when connected widgets are selected - ansible.builtin.file: - path: /etc/systemd/user/hermes-menubar-bridge.service - state: absent - register: image_hermes_unmasked - when: - - features.connected_widgets | bool - - not cybexos_reconcile | default(false) | bool - - image_hermes_mask.stat.islnk | default(false) - - image_hermes_mask.stat.lnk_source | default('') == '/dev/null' - - # The running shell keeps its environment; the next login applies it. - # Use the local account bus, without a transient --machine bridge. - - name: Inspect the account's runtime bus - ansible.builtin.stat: - path: "/run/user/{{ ansible_facts.getent_passwd[primary_user][1] }}/bus" - register: image_account_bus - when: not cybexos_offline | bool - - - name: Reload the account's user manager after a feature change - ansible.builtin.command: - argv: - - /usr/bin/timeout - - --kill-after=5s - - 30s - - /usr/sbin/runuser - - -u - - "{{ primary_user }}" - - -- - - /usr/bin/env - - -i - - PATH=/usr/sbin:/usr/bin:/sbin:/bin - - "HOME={{ primary_home }}" - - LANG=C.UTF-8 - - PYTHONDONTWRITEBYTECODE=1 - - "XDG_RUNTIME_DIR=/run/user/{{ ansible_facts.getent_passwd[primary_user][1] }}" - - "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{{ ansible_facts.getent_passwd[primary_user][1] }}/bus" - - /usr/bin/systemctl - - --user - - daemon-reload - changed_when: true - when: - - not cybexos_offline | bool - - image_account_bus.stat.exists | default(false) - - image_shell_features is changed or image_reconcile_shell_features is changed or image_hermes_masked is changed or image_hermes_unmasked is changed - - - name: Detect hardware during offline installation as well as repair - ansible.builtin.include_role: - name: xps-2026 - tasks_from: detect - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Detect an NVIDIA GPU during offline installation as well as repair - ansible.builtin.include_role: - name: nvidia - tasks_from: detect - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Create the machine policy directory - ansible.builtin.file: - path: /etc/cybexos - state: directory - mode: "0755" - - # The welcome window and doctor read this before the first hardware - # setup has run; the setup itself detects again after boot. - - name: Record the detected desktop hardware policy - ansible.builtin.copy: - dest: /etc/cybexos/hardware.json - content: "{{ {'xps_2026': xps_2026_is_supported | bool, 'nvidia': nvidia_is_present | bool} | to_json }}\n" - mode: "0644" - when: - - not cybexos_hardware_only | bool - - not cybexos_reconcile | default(false) | bool - - - name: Read the hardware account identity - ansible.builtin.getent: - database: passwd - key: "{{ primary_user }}" - when: cybexos_hardware_only | bool - - - name: Apply the detected workstation hardware role - ansible.builtin.include_role: - name: xps-2026 - when: cybexos_hardware_only | bool - - # The same role as the checkout installation. Its outcome joins the - # hardware result below; a failed driver never fails this play. - - name: Apply the NVIDIA driver role on detected NVIDIA hardware - ansible.builtin.include_role: - name: nvidia - vars: - nvidia_retry_command: cybex repair --hardware - nvidia_setup_automatic: "{{ cybexos_hardware_automatic | default(false) | bool }}" - when: cybexos_hardware_only | bool - - - name: Require a working or reboot-pending camera on matching hardware - ansible.builtin.assert: - that: - - xps_2026_camera_ready | default(false) | bool or xps_2026_camera_reboot_required | default(false) | bool or xps_2026_camera_preserve_previous | default(false) | bool - fail_msg: "The detected camera setup did not complete. Inspect the preceding hardware diagnostics and retry cybex repair --hardware." - when: - - cybexos_hardware_only | bool - - xps_2026_is_supported | bool - - xps_2026_camera_enabled | bool - - xps_2026_has_ovti08f4 | bool - - xps_2026_has_ipu7 | bool - - - name: Record the hardware role outcome for the boot-aware setup wrapper - ansible.builtin.copy: - dest: /var/lib/cybexos/hardware-result.json - mode: "0644" - content: >- - {{ {'reboot_required': xps_2026_camera_reboot_required | default(false) | bool, - 'camera_ready': xps_2026_camera_ready | default(false) | bool, - 'preserved_previous': xps_2026_camera_preserve_previous | default(false) | bool, - 'nvidia': {'state': (nvidia_status | default({'state': 'absent'})).state, - 'key': (nvidia_status | default({})).key | default(''), - 'reason': (nvidia_status | default({})).reason | default('')}} | to_json }} - when: cybexos_hardware_only | bool diff --git a/image/provision_payload.py b/image/provision_payload.py deleted file mode 100644 index 33157b92..00000000 --- a/image/provision_payload.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Install the reviewed shared provisioning sources, excluding personal state.""" -import shutil - - -def prepare_provision(root, payload): - destination = payload / 'usr/share/cybexos/provision' - for relative in ('image/provision.yml', 'image/provision.cfg', 'image/library', - 'image/system_packages.py', 'image/live.ks', 'image/cybexos-desktop.spec', - 'inventory/group_vars/all.yml', - 'roles/base', 'roles/xps-2026', 'roles/nvidia', 'roles/apps/tasks/mpv.yml', - # The NVIDIA role enables RPM Fusion with the applications role's pinned keys. - 'roles/apps/tasks/rpmfusion.yml', 'roles/apps/files/rpmfusion-free-fedora-2020.asc', - 'roles/apps/files/rpmfusion-nonfree-fedora-2020.asc', - 'roles/apps/tasks/voxtype-backend.yml', 'roles/apps/handlers/main.yml', - 'roles/dotfiles/tasks/shell-defaults.yml', - 'roles/dotfiles/files/fish-config.fish', - 'roles/dotfiles/tasks/environment.yml', 'roles/dotfiles/templates/environment.conf.j2', - 'roles/dotfiles/tasks/personal.yml', 'roles/dotfiles/files/kitty.conf', - 'roles/dotfiles/files/manage-firefox-policy', 'roles/dotfiles/files/ssh.conf', - 'roles/dotfiles/files/gitconfig', - 'roles/dotfiles/tasks/agent-skills.yml', 'scripts/manage-agent-skills', - 'roles/desktop/tasks/portals.yml'): - source = root / relative - paths = sorted(source.rglob('*')) if source.is_dir() else [source] - for path in paths: - if not path.is_file() or path.is_symlink() or '__pycache__' in path.parts or path.suffix == '.pyc': - continue - target = destination / path.relative_to(root) - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(path, target) - target.chmod(0o755 if path.stat().st_mode & 0o111 else 0o644) diff --git a/image/public_update_qualification.py b/image/public_update_qualification.py deleted file mode 100644 index 926db97e..00000000 --- a/image/public_update_qualification.py +++ /dev/null @@ -1,1064 +0,0 @@ -"""Public updater crash/recovery qualification in the existing encrypted guest. - -The guest-only DNF adapter selects the already checksum/signature-verified -local candidate transaction, with repositories disabled. It executes real DNF; -no updater, health, storage, authorization or recovery code is substituted. -""" -import base64 -import inspect -import json -import os -from pathlib import Path -import re -import selectors -import shlex -import signal -import subprocess -import sys -import time - -from build_support import digest -from vm_testing import run - -ROOT = Path(__file__).resolve().parents[1] -WORK = '/run/cybexos-qualification-public' -SOURCE = WORK + '/source' -UPDATER = SOURCE + '/assets/scripts/cybexos-update-run' -OBSERVER = '/usr/local/libexec/cybexos-qualification-recovery-observer' -UNIT = 'cybexos-qualification-recovery-observer.service' -EVIDENCE = '/home/qualification/qualification-public-recovery.json' -# Use an actual vendor-manifest member whose original bytes are recorded. -VENDOR = '/home/qualification/.local/share/applications/localsend-share-folder.desktop' - -# One root process, authenticated before launch, owns the control protocol for -# exactly one boot/cycle. Opening another SSH session after DNF can block in -# pam_systemd while PID 1 reconnects its public bus. This channel is control -# only: PUBLIC_HEALTH must still run through fresh ordinary SSH after it closes. -CONTROL_SERVER = r''' -import base64, ctypes, json, os, pathlib, selectors, signal, subprocess, sys, time -boot_path = pathlib.Path('/proc/sys/kernel/random/boot_id') -boot = boot_path.read_text().strip() -deadline = time.monotonic() + 1500 -# Adopt escaped command descendants so EOF, cancellation and timeouts can -# terminate/reap them too, including children which called setsid(). -if ctypes.CDLL(None, use_errno=True).prctl(36, 1, 0, 0, 0) != 0: - raise RuntimeError('Cannot own control command descendants') -def interrupted(*_args): - raise RuntimeError('Control process interrupted') -for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): - signal.signal(sig, interrupted) -def emit(event, **values): - print(json.dumps(dict(event=event, boot=boot_path.read_text().strip(), **values)), flush=True) -def reap(): - # Each request's children are private to this process. Kill by parentage, - # then reap adopted descendants; never signal an unrelated guest process. - for _ in range(100): - children = [] - for entry in pathlib.Path('/proc').glob('[0-9]*/stat'): - try: - fields = entry.read_text().rsplit(')', 1)[1].split() - if int(fields[1]) == os.getpid(): - children.append(int(entry.parent.name)) - except (FileNotFoundError, ProcessLookupError): - pass - for pid in children: - try: os.kill(pid, signal.SIGKILL) - except ProcessLookupError: pass - while True: - try: pid, _ = os.waitpid(-1, os.WNOHANG) - except ChildProcessError: return - if not pid: break - time.sleep(.01) - raise RuntimeError('Control command descendants did not drain') -buffer = b'' -emit('ready', protocol=1, uid=os.geteuid()) -sequence = 0 -try: - while True: - with selectors.DefaultSelector() as poll: - poll.register(0, selectors.EVENT_READ) - while b'\n' not in buffer: - remaining = deadline - time.monotonic() - if remaining <= 0 or not poll.select(remaining): - raise RuntimeError('Control cycle deadline expired') - chunk = os.read(0, 65536) - if not chunk: raise EOFError('Control input closed') - buffer += chunk - if len(buffer) > 4194304: raise ValueError('Excessive control request') - line, buffer = buffer.split(b'\n', 1) - request = json.loads(line) - sequence += 1 - if type(request.get('id')) is not int or request['id'] != sequence or boot_path.read_text().strip() != boot: - raise ValueError('Control sequence or boot changed') - if request == {'id': sequence, 'op': 'close'}: - emit('closed', id=sequence) - break - if (set(request) != {'id', 'op', 'script', 'timeout'} or request['op'] != 'run' - or not isinstance(request['script'], str) - or not 0 < request['timeout'] <= 120): - raise ValueError('Invalid control request') - end = min(deadline, time.monotonic() + request['timeout']) - process = subprocess.Popen(['bash', '-e', '-s'], stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - start_new_session=True) - try: - # Scripts are bounded; nonblocking input also handles a command - # which exits without reading its entire script. - pending = memoryview(request['script'].encode()) - with selectors.DefaultSelector() as poll: - poll.register(0, selectors.EVENT_READ, 'control') - for stream, name in ((process.stdout, 'stdout'), (process.stderr, 'stderr')): - os.set_blocking(stream.fileno(), False) - poll.register(stream, selectors.EVENT_READ, name) - os.set_blocking(process.stdin.fileno(), False) - poll.register(process.stdin, selectors.EVENT_WRITE, 'script') - while len(poll.get_map()) > 1: - remaining = end - time.monotonic() - if remaining <= 0: raise TimeoutError('Control request expired') - for key, _ in poll.select(remaining): - if key.data == 'control': - # The protocol is strictly request/response. Extra - # input or EOF during execution invalidates it. - raise EOFError('Control input changed during request') - if key.data == 'script': - try: pending = pending[os.write(key.fd, pending):] - except BrokenPipeError: pending = memoryview(b'') - if not pending: - poll.unregister(key.fileobj) - key.fileobj.close() - else: - data = os.read(key.fd, 32768) - if data: - emit('output', id=sequence, stream=key.data, - data=base64.b64encode(data).decode('ascii')) - else: - poll.unregister(key.fileobj) - key.fileobj.close() - code = process.wait(timeout=max(.001, end - time.monotonic())) - finally: - if process.poll() is None: - try: os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: pass - # Reap the direct child through Popen, then all adopted children. - process.wait() - for stream in (process.stdin, process.stdout, process.stderr): - if not stream.closed: stream.close() - reap() - emit('result', id=sequence, returncode=code) -finally: - reap() -''' - - -class PublicControl: - """Bounded, non-reconnecting root control; never qualifies public health.""" - - def __init__(self, vm, password): - self.vm, self.password = vm, password - self.process = None - self.broken = False - self.boot = None - self.sequence = 0 - self.buffer = b'' - self.launch_attempted = False - self.identifier = None - self.drained = False - self.drain_attempted = False - self.keep_fixture = False - - @property - def available(self): - return self.process is not None and not self.broken and self.process.poll() is None - - def remaining(self, bound): - remaining = min(bound, self.deadline - time.monotonic()) - if remaining <= 0: - self.broken = True - raise RuntimeError('Public control cycle exceeded the qualification deadline') - return remaining - - def __enter__(self): - self.deadline = time.monotonic() + 1500 - try: - command = "sudo -k -S -p '' python3 -u -c " + shlex.quote(CONTROL_SERVER) - self.process = subprocess.Popen([*self.vm.ssh, command], stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - start_new_session=True) - for stream in (self.process.stdin, self.process.stdout, self.process.stderr): - os.set_blocking(stream.fileno(), False) - end = time.monotonic() + self.remaining(15) - self._send((self.password + '\n').encode(), end) - ready = self._receive(end) - if (set(ready) != {'event', 'boot', 'protocol', 'uid'} or ready['event'] != 'ready' - or type(ready['protocol']) is not int or ready['protocol'] != 1 - or type(ready['uid']) is not int or ready['uid'] != 0 - or not isinstance(ready['boot'], str) - or not re.fullmatch(r'[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', ready['boot'])): - raise RuntimeError('Invalid public control ready handshake') - self.boot = ready['boot'] - return self - except BaseException as error: - self.broken = True - self.close(failed=True) - if isinstance(error, (EOFError, TimeoutError, ValueError, OSError)): - raise RuntimeError('Public control failed before ready: ' + type(error).__name__) from None - raise - - def _send(self, data, end): - with selectors.DefaultSelector() as poll: - poll.register(self.process.stdin, selectors.EVENT_WRITE) - while data: - if time.monotonic() >= end or not poll.select(max(0, end - time.monotonic())): - raise TimeoutError('Public control request timed out') - data = data[os.write(self.process.stdin.fileno(), data):] - - def _receive(self, end): - with selectors.DefaultSelector() as poll: - poll.register(self.process.stdout, selectors.EVENT_READ, 'protocol') - poll.register(self.process.stderr, selectors.EVENT_READ, 'diagnostic') - while b'\n' not in self.buffer: - if time.monotonic() >= end: - raise TimeoutError('Public control request timed out') - events = poll.select(max(0, end - time.monotonic())) - if not events: - raise TimeoutError('Public control request timed out') - for key, _ in events: - data = os.read(key.fd, 65536) - if key.data == 'diagnostic': - # Authentication diagnostics are never protocol or - # health evidence, and may contain sensitive text. - if not data: - poll.unregister(key.fileobj) - continue - if not data: - raise EOFError('Public control SSH closed') - self.buffer += data - if len(self.buffer) > 4194304: - raise ValueError('Excessive public control response') - line, self.buffer = self.buffer.split(b'\n', 1) - value = json.loads(line) - if not isinstance(value, dict): - raise ValueError('Invalid public control response') - return value - - def __call__(self, _vm, script, _password, timeout=120, *, shutdown=False): - if not self.available: - raise RuntimeError('Public control is unavailable; reconnect is forbidden') - output = {'stdout': bytearray(), 'stderr': bytearray()} - try: - bound = self.remaining(min(timeout, 120)) - end = time.monotonic() + bound - self.sequence += 1 - request = {'id': self.sequence, 'op': 'run', 'script': script, 'timeout': bound} - self._send((json.dumps(request) + '\n').encode(), end) - while True: - value = self._receive(end) - if (value.get('boot') != self.boot or type(value.get('id')) is not int - or value['id'] != self.sequence): - raise ValueError('Public control boot or sequence changed') - if (set(value) == {'event', 'boot', 'id', 'stream', 'data'} - and value['event'] == 'output' and value['stream'] in output): - output[value['stream']] += base64.b64decode(value['data'], validate=True) - if sum(map(len, output.values())) > 8388608: - raise ValueError('Excessive public control output') - elif (set(value) == {'event', 'boot', 'id', 'returncode'} - and value['event'] == 'result' and type(value['returncode']) is int): - break - else: - raise ValueError('Malformed public control response') - except BaseException as error: - self.broken = True - # Only poweroff_guest may accept the expected shutdown disconnect, - # after seeing its unpredictable preparation marker and QEMU exit. - if shutdown and isinstance(error, EOFError): - raise subprocess.CalledProcessError(255, ['public-control-shutdown'], - output=output['stdout'].decode(errors='replace'), - stderr=output['stderr'].decode(errors='replace')) from None - if isinstance(error, (EOFError, TimeoutError, ValueError, OSError)): - raise RuntimeError('Public control failed: ' + type(error).__name__) from None - raise - result = subprocess.CompletedProcess(['public-control'], value['returncode'], - output['stdout'].decode(errors='replace'), output['stderr'].decode(errors='replace')) - if result.returncode: - raise subprocess.CalledProcessError(result.returncode, result.args, - output=result.stdout.replace(self.password, '[redacted]'), - stderr=result.stderr.replace(self.password, '[redacted]')) - return result - - def start(self): - self.launch_attempted = True - self.identifier = start_public(self.vm, self.password, timeout=self.remaining(90)) - # Detect a changed boot even when the ordinary launcher returned a - # plausible run record. No evidence is awarded on this channel alone. - self(self.vm, 'true\n', self.password) - return self.identifier - - def ensure_drained(self): - if self.drained: - return True - if self.drain_attempted or not self.available: - return False - if self.launch_attempted and not self.identifier: - return False - self.drain_attempted = True - if self.identifier: - stop_interrupted(self.vm, self.password, self, self.identifier, {}, strict=False) - self.drained = True - return True - - def cleanup_staging(self, vm, script, password, timeout=30): - if self.ensure_drained(): - self(vm, script, password, timeout=timeout) - # Lost control leaves guest files/workers to the owned VM teardown. - - def poweroff(self): - from vm_testing import poweroff_guest - def execute(vm, script, password): - return self(vm, script, password, shutdown='systemctl poweroff --no-block\n' in script) - process = self.vm.process - control = self - class BoundedProcess: - def wait(self, timeout): - # Preparation consumed part of the cycle too. Recompute at - # the actual QEMU wait, retaining the existing 90s upper bound. - return process.wait(timeout=control.remaining(timeout)) - def __getattr__(self, name): - return getattr(process, name) - self.vm.process = BoundedProcess() - try: - poweroff_guest(self.vm, self.password, execute, timeout=self.remaining(90)) - finally: - self.vm.process = process - self.keep_fixture = True # The pre-login observer must survive reboot. - - def close(self, *, failed=False): - if self.process is None: - return - error = None - try: - if self.available and not self.keep_fixture: - end = time.monotonic() + self.remaining(5) - self.sequence += 1 - self._send((json.dumps({'id': self.sequence, 'op': 'close'}) + '\n').encode(), end) - reply = self._receive(end) - if (type(reply.get('id')) is not int - or reply != {'event': 'closed', 'boot': self.boot, 'id': self.sequence}): - raise RuntimeError('Invalid public control close acknowledgement') - elif not failed and not self.keep_fixture: - raise RuntimeError('Public control was lost before close') - except BaseException as caught: - error = caught - finally: - self.broken = True - self.process.stdin.close() - try: - code = self.process.wait(timeout=3) - expected = (0, 255) if self.keep_fixture else (0,) - if code not in expected and not failed and error is None: - error = RuntimeError('Public control SSH exited unsuccessfully') - except subprocess.TimeoutExpired: - try: - os.killpg(self.process.pid, signal.SIGTERM) - except ProcessLookupError: - pass - try: - self.process.wait(timeout=3) - except subprocess.TimeoutExpired: - try: - os.killpg(self.process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - self.process.wait() - if not failed and error is None: - error = RuntimeError('Public control SSH did not close') - self.process.stdout.close() - self.process.stderr.close() - if error is not None and not failed: - raise error - - def __exit__(self, kind, _value, _traceback): - failed = kind is not None - try: - if not self.keep_fixture and self.ensure_drained(): - cleanup_fixture(self.vm, self.password, self) - except BaseException: - if not failed: - raise - finally: - self.close(failed=failed or sys.exc_info()[0] is not None) - -# This process runs before the recovery service, independently of logins, and -# writes outside root so the first recovery boot's evidence survives root swap. -OBSERVER_SCRIPT = r'''#!/usr/bin/python3 -import glob, json, pathlib, signal, subprocess, time -# Reboot may be queued immediately after the durable terminal write. Finish -# observing that write before our unit stops; mixed kill mode keeps the probe alive. -signal.signal(signal.SIGTERM, lambda *_args: None) -home = pathlib.Path('/home/qualification') -output = home / 'qualification-public-recovery.json' -identifier = (home / 'qualification-public-run-id').read_text().strip() -boot = pathlib.Path('/proc/sys/kernel/random/boot_id').read_text().strip() -if output.exists() and json.loads(output.read_text()).get('state') == 'rolled-back': - pathlib.Path('/run/cybexos-qualification-observer-ready').touch() - raise SystemExit(0) -seen = [] -for _ in range(12000): - candidates = ['/usr/libexec/cybexos-update-transaction'] + glob.glob( - '/usr/local/libexec/cybexos-update-bootstrap.d/*/cybexos-update-transaction') - helper = next((p for p in candidates if pathlib.Path(p).is_file()), None) - if helper: - result = subprocess.run([helper, 'status', identifier], capture_output=True, text=True, timeout=30) - if result.returncode == 0: - record = json.loads(result.stdout) - if record['state'] in ('applying', 'rolling-back', 'rolled-back'): - # Check the real systemd ordering barrier while recovery runs. - for unit in ('sddm.service', 'systemd-user-sessions.service'): - state = subprocess.check_output(['systemctl', 'show', unit, '-p', 'ActiveState', '--value'], text=True).strip() - assert state not in ('active', 'activating', 'reloading'), 'Login started before recovery settled' - assert pathlib.Path('/run/nologin').exists(), 'Login barrier is absent' - if record['state'] not in seen: - seen.append(record['state']) - value = {'bootId': boot, 'transaction': identifier, 'states': seen, - 'preLogin': True, 'state': record['state']} - temporary = output.with_suffix('.tmp') - temporary.write_text(json.dumps(value) + '\n') - temporary.replace(output) - pathlib.Path('/run/cybexos-qualification-observer-ready').touch() - if record['state'] == 'rolled-back': - break - elif record['state'] in ('committed', 'aborted'): - raise RuntimeError('The interrupted transaction did not recover') - time.sleep(0.1) -else: - raise RuntimeError('Boot recovery did not settle within its deadline') -''' - -DNF_ADAPTER = r'''#!/usr/bin/python3 -import hashlib, json, os, pathlib, subprocess, sys, time -work = pathlib.Path('/run/cybexos-qualification-public') -config = json.loads((work / 'dnf.json').read_text()) -args = sys.argv[1:] -expected = ['-y', 'upgrade', '--refresh', '--no-best'] -assert args in (expected, expected + ['--downloadonly']), 'Unexpected qualification DNF invocation' -for record in config['rpms']: - path = pathlib.Path(record['path']) - assert path.is_file() and not path.is_symlink() and path.stat().st_uid == 0 - assert hashlib.sha256(path.read_bytes()).hexdigest() == record['sha256'], 'Staged candidate changed' -command = ['/usr/bin/dnf', '-y', '--disablerepo=*', 'install'] -if '--downloadonly' in args: - command.append('--downloadonly') -subprocess.run(command + [r['path'] for r in config['rpms']], check=True, timeout=1200) -if '--downloadonly' not in args and config['interrupt']: - vendor = pathlib.Path(config['vendor']) - vendor.unlink() - vendor.write_bytes(b'qualification candidate vendor bytes\n') - pathlib.Path('/etc/cybexos/qualification-public-root').write_text('applied\n') - # A personal edit made AFTER the checkpoint must also survive rollback. - pathlib.Path('/home/qualification/qualification-public-personal').write_text('after-checkpoint\n') - heartbeat = work / 'heartbeat' - code = 'import pathlib,sys,time\np=pathlib.Path(sys.argv[1])\nwhile True:\n p.open("a").write("worker\\n"); time.sleep(.05)\n' - child = subprocess.Popen(['/usr/bin/python3', '-c', code, str(heartbeat)], start_new_session=True) - (work / 'applied.json').write_text(json.dumps({'pid': os.getpid(), 'detachedPid': child.pid})) - # The host kills the real updater unit here. Expiry fails the qualification - # and exercises normal updater error handling, never an unbounded fixture. - for _ in range(3000): - time.sleep(.1) - raise RuntimeError('Qualification interruption was not delivered') -''' - - -# Run only inside the already-owned candidate guest. Local fixtures substitute -# the manager boundary; this release check exercises actual systemd runtime -# limits, setsid and lock lifetime against the installed helper. -PROVISION_TIMEOUT = r'''python3 - <<'PY' -import fcntl, importlib.machinery, importlib.util, pathlib, subprocess, tempfile, time -loader = importlib.machinery.SourceFileLoader('qualification_provision', '/usr/libexec/cybexos-configure-installed') -spec = importlib.util.spec_from_loader(loader.name, loader) -module = importlib.util.module_from_spec(spec) -loader.exec_module(module) -with tempfile.TemporaryDirectory(prefix='cybexos-qualification-provision-', dir='/run') as temporary: - directory = pathlib.Path(temporary) - heartbeat = directory / 'heartbeat' - overlap = directory / 'overlap' - worker = directory / 'worker.py' - code = 'import fcntl,pathlib,sys,time\nheartbeat,overlap=map(pathlib.Path,sys.argv[1:])\nwhile True:\n with open("/run/lock/cybexos/provision.lock","a") as lock:\n try: fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)\n except BlockingIOError: pass\n else: overlap.touch()\n with heartbeat.open("a") as stream: stream.write("descendant\\n")\n time.sleep(.02)\n' - worker.write_text('import subprocess,sys,time\nsubprocess.Popen([sys.executable,"-c",' + repr(code) + ',sys.argv[1],sys.argv[2]],start_new_session=True)\ntime.sleep(120)\n') - environment = {'PATH':'/usr/sbin:/usr/bin:/sbin:/bin','HOME':'/root', - 'LANG':'C.UTF-8','PYTHONDONTWRITEBYTECODE':'1'} - lockdir = pathlib.Path('/run/lock/cybexos') - lockdir.mkdir(exist_ok=True) - with (lockdir / 'provision.lock').open('a') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - try: - module.contained(['/usr/bin/python3', str(worker), str(heartbeat), str(overlap)], env=environment, timeout=2) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - pass - else: - raise RuntimeError('The deliberately timed-out provisioning workload succeeded') - assert heartbeat.is_file() and heartbeat.stat().st_size > 0, 'Detached writer never ran' - settled = heartbeat.read_bytes() - assert not overlap.exists(), 'Provisioning serialization was released early' - assert not module.populated(), 'Provisioning cgroup was still populated after timeout' - time.sleep(.2) - assert heartbeat.read_bytes() == settled, 'Detached worker wrote after timeout' - retry = 'import pathlib,sys,time; p=pathlib.Path(sys.argv[1]); before=p.read_bytes(); time.sleep(.2); assert p.read_bytes()==before' - module.contained(['/usr/bin/python3','-c',retry,str(heartbeat)], env=environment, timeout=10) - assert heartbeat.read_bytes() == settled and not overlap.exists(), 'Retry overlapped a timed-out worker' -PY -''' - - -PUBLIC_HEALTH = r'''import json, os, pwd, shlex, subprocess, time -deadline = time.monotonic() + 45 -# Pin public inspection to the ordinary system bus, even if SSH inherited a -# bus-address environment variable. Never fall back to /run/systemd/private. -os.environ['DBUS_SYSTEM_BUS_ADDRESS'] = 'unix:path=/run/dbus/system_bus_socket' -def command(args): - remaining = deadline - time.monotonic() - if remaining <= 0: - raise RuntimeError('Public manager health inspection exceeded its deadline') - try: - return subprocess.run(args, check=True, capture_output=True, text=True, - timeout=min(10, remaining)).stdout - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: - stderr = error.stderr or '' - if isinstance(stderr, bytes): - stderr = stderr.decode(errors='replace') - if len(stderr) > 2000: - stderr = '[stderr omitted: ' + str(len(stderr)) + ' characters exceeds 2000-character limit]' - reason = ('timed out' if isinstance(error, subprocess.TimeoutExpired) - else 'exited ' + str(error.returncode)) - raise RuntimeError('Public health command ' + os.path.basename(args[0]) + ' ' + reason - + ': ' + stderr) from None -version = shlex.split(command(['/usr/bin/busctl', '--system', '--timeout=10', - 'get-property', 'org.freedesktop.systemd1', '/org/freedesktop/systemd1', - 'org.freedesktop.systemd1.Manager', 'Version'])) -if len(version) != 2 or version[0] != 's' or not version[1].strip(): - raise RuntimeError('Public system bus did not return Manager.Version') -account = pwd.getpwuid(os.getuid()) -if account.pw_name != 'qualification' or account.pw_uid < 1000: - raise RuntimeError('Public health inspection requires the ordinary qualification SSH account') -# Read the existing user's session directly. The Fedora RPM trigger's root -# --user -M bridge is precisely the path that may have missed marked work. -os.environ['XDG_RUNTIME_DIR'] = '/run/user/' + str(account.pw_uid) -os.environ['DBUS_SESSION_BUS_ADDRESS'] = 'unix:path=' + os.environ['XDG_RUNTIME_DIR'] + '/bus' -evidence = {'public_manager_version': version[1], 'ssh_user': account.pw_name, - 'ssh_uid': account.pw_uid} -for scope in ('system', 'user'): - client = ['/usr/bin/systemctl', '--' + scope, '--no-pager'] - listing = json.loads(command(client + ['list-units', '--all', '--output=json'])) - if not isinstance(listing, list) or not listing or len(listing) > 2048: - raise RuntimeError('Invalid or excessive ' + scope + ' unit inventory') - names = sorted({row['unit'] for row in listing} | set(required[scope])) - properties = ('Id', 'Markers', 'LoadState', 'ActiveState', 'SubState', 'UnitFileState', 'Result') - units = {} - for offset in range(0, len(names), 64): - output = command(client + ['show', '--all', - '--property=' + ','.join(properties), '--', *names[offset:offset + 64]]) - for block in output.strip().split('\n\n'): - values = dict(line.split('=', 1) for line in block.splitlines()) - name = values['Id'] - if name in units or not set(('Id', 'Markers', 'LoadState', 'ActiveState', 'SubState')) <= values.keys(): - raise RuntimeError('Incomplete or duplicate ' + scope + ' unit state: ' + name) - values['Markers'] = values['Markers'].split() - units[name] = values - if set(units) != set(names): - raise RuntimeError('Incomplete ' + scope + ' unit inventory') - evidence[scope] = units -print(json.dumps(evidence)) -''' - - -def required_service_states(): - # Read the shared workstation service policy, rather than inventing a - # separate baseline. RPM posttrans enables the additional packaged units; - # only its --now watchers must be active. Enabled oneshots may be inactive. - import yaml - defaults = yaml.safe_load((ROOT / 'roles/base/defaults/main.yml').read_text()) - system = {name: {'enabled': True, 'active': True} for name in defaults['base_core_services']} - system['sddm.service'] = {'active': True} - for name in ('cybexos-titlebars-build.path', 'cybexos-reconcile.timer'): - system[name] = {'enabled': True, 'active': True} - for name in ('cybexos-titlebars-build.service', 'cybexos-recovery-refresh.service', - 'cybexos-update-recover.service', 'cybexos-major-upgrade-validate.timer', - 'cybexos-hardware-setup.timer', 'cybexos-first-boot.service', 'cybexos-new-owner.service'): - system[name] = {'enabled': True} - # These are the running desktop services already required by TestVM.audit. - user = {name + '.service': {'active': True} for name in ('quickshell', 'hypridle', 'voxtype')} - return {'system': system, 'user': user} - - -def validate_public_health(evidence, required): - if (not isinstance(evidence.get('public_manager_version'), str) - or not evidence['public_manager_version'].strip() - or evidence.get('ssh_user') != 'qualification' - or type(evidence.get('ssh_uid')) is not int or evidence['ssh_uid'] < 1000): - raise RuntimeError('Public manager or ordinary SSH health evidence is missing') - for scope in ('system', 'user'): - units = evidence.get(scope) - if not isinstance(units, dict) or not units: - raise RuntimeError('Missing ' + scope + ' unit health evidence') - for name, state in units.items(): - markers = state.get('Markers') - if not isinstance(markers, list) or not all(isinstance(item, str) for item in markers): - raise RuntimeError('Missing ' + scope + ' unit markers: ' + name) - pending = sorted(set(markers) & {'needs-restart', 'needs-reload'}) - if pending: - raise RuntimeError('Pending RPM-trigger work in ' + scope + ' unit ' + name + ': ' + ', '.join(pending)) - # Other marker types are retained verbatim, not cleared or treated - # as a request to restart anything during read-only qualification. - for name, policy in required[scope].items(): - state = units.get(name, {}) - if (state.get('LoadState') != 'loaded' - or state.get('ActiveState') not in ('active', 'inactive') - or state.get('Result', 'success') != 'success' - or (name.endswith('.service') and state.get('Result') != 'success') - or (policy.get('active') and state.get('ActiveState') != 'active') - or (policy.get('enabled') and state.get('UnitFileState') != 'enabled')): - raise RuntimeError('Required ' + scope + ' unit is unhealthy: ' + name) - - -def verify_public_health(vm, password, root_script): - required = required_service_states() - script = 'required = ' + repr(required) + '\n' + PUBLIC_HEALTH - try: - # This query uses the actual public system bus from ordinary SSH. A - # root-private daemon-reload cannot stand in for this health evidence. - result = run([*vm.ssh, 'python3 -'], input=script, text=True, capture_output=True, timeout=55) - evidence = json.loads(result.stdout) - validate_public_health(evidence, required) - sudo = subprocess.run([*vm.ssh, 'sudo -k -n true'], capture_output=True, text=True, timeout=15) - if sudo.returncode != 1: - raise RuntimeError('Post-update sudo no longer requires ordinary password authentication') - authenticated = root_script(vm, 'id -u\n', password, timeout=15) - if authenticated.stdout.strip() != '0': - raise RuntimeError('Post-update password-authenticated sudo is not functional') - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: - reason = ('timed out' if isinstance(error, subprocess.TimeoutExpired) - else f'exited {error.returncode}') - details = [] - for value in (error.stdout, error.stderr): - if isinstance(value, bytes): - value = value.decode(errors='replace') - if value: - details.append(value.replace(password, '[redacted]')) - raise RuntimeError('Post-update public health inspection failed (' + reason + '): ' + - '\n'.join(details)[-5000:]) from None - evidence['password_authenticated_sudo'] = True - evidence['service_requirements'] = required - return evidence - - -def transfer_source(vm, password, root_script): - files = ['assets/scripts/cybexos-update-run', 'site.yml', 'tests/run'] - files += ['roles/base/files/' + name for name in ( - 'cybexos-system-snapshot', 'cybexos-update-transaction', 'cybexos-update-recover', - 'cybexos-major-upgrade', 'cybexos-update-bootstrap', 'cybexos-vendor-paths.json', - 'cybexos-update-recover.service', 'cybexos-update-recover-login.conf')] - # root_script's authenticated channel writes reviewed source as data; it - # grants neither a new sudo policy nor a production qualification switch. - script = "python3 - <<'PY'\nfrom pathlib import Path\nimport time\n" - script += f'root = Path({SOURCE!r})\nroot.mkdir(parents=True, exist_ok=True, mode=0o755)\n' - for relative in files: - content = (ROOT / relative).read_text() - script += (f'p = root / {relative!r}\np.parent.mkdir(parents=True, exist_ok=True)\n' - f'p.write_text({content!r})\np.chmod(0o755)\n') - script += 'PY\n' - root_script(vm, script, password) - return digest(ROOT / 'assets/scripts/cybexos-update-run') - - -def provision_fixture(vm, password, root_script, rpms, interrupt): - records = [{'path': path, 'sha256': sha} for path, sha in rpms] - script = "python3 - <<'PY'\nfrom pathlib import Path\nimport json\n" - script += f'work = Path({WORK!r})\nwork.mkdir(parents=True, exist_ok=True, mode=0o755)\n' - script += "adapter = Path('/usr/local/bin/dnf')\nassert not adapter.exists() and not adapter.is_symlink(), 'Refusing to replace an existing DNF command'\n" - script += f'adapter.write_text({DNF_ADAPTER!r})\nadapter.chmod(0o755)\n' - config = {'rpms': records, 'interrupt': interrupt, 'vendor': VENDOR} - script += f'(work / "dnf.json").write_text({json.dumps(config)!r})\n' - script += "Path('/etc/cybexos/qualification-public-root').write_text('before\\n')\n" - script += f"vendor = Path({VENDOR!r})\nif not vendor.exists() and not vendor.is_symlink():\n vendor.parent.mkdir(parents=True, exist_ok=True)\n vendor.write_text('[Desktop Entry]\\nType=Application\\nName=Qualification sentinel\\nExec=/usr/bin/true\\n')\n" - if interrupt: - script += f'Path({OBSERVER!r}).write_text({OBSERVER_SCRIPT!r})\nPath({OBSERVER!r}).chmod(0o755)\n' - unit = ('[Unit]\nDescription=Observe disposable qualification recovery before login\n' - 'After=local-fs.target\nRequiresMountsFor=/home /var\n' - 'Before=cybexos-update-recover.service systemd-user-sessions.service sddm.service\n' - '[Service]\nType=simple\nExecStart=' + OBSERVER + '\n' - 'ExecStartPost=/usr/bin/timeout 30 /usr/bin/bash -c "until test -e /run/cybexos-qualification-observer-ready; do sleep .1; done"\n' - 'KillMode=mixed\nTimeoutStopSec=30\n[Install]\nWantedBy=multi-user.target\n') - script += f'Path("/etc/systemd/system/{UNIT}").write_text({unit!r})\n' - script += 'PY\n' - script += 'restorecon /usr/local/bin/dnf\n' - if interrupt: - script += f'restorecon {OBSERVER} /etc/systemd/system/{UNIT}\n' - script += f'systemctl daemon-reload\nsystemctl enable {UNIT}\n' - root_script(vm, script, password) - - -def start_public(vm, password, *, timeout=90): - # Authenticate through the existing sudo policy. The client's cached - # authorization launches the normal root worker; no testing environment - # override or passwordless rule is installed. - # With -v, sudo's -k deliberately prevents retaining the validated ticket. - # Invalidate separately so the following ordinary validation caches it. - def diagnostics(stdout, stderr): - values = [] - for label, value in (('stdout', stdout), ('stderr', stderr)): - if isinstance(value, bytes): - value = value.decode(errors='replace') - if value: - values.append(label + ': ' + value.replace(password, '[redacted]')) - return '\n'.join(values)[-5000:] - - try: - result = run([*vm.ssh, "sudo -k && sudo -S -p '' -v && exec " + shlex.quote(UPDATER) + - ' start --json --no-flatpak --system-unit'], input=password + '\n', text=True, - capture_output=True, timeout=timeout) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error: - reason = ('timed out' if isinstance(error, subprocess.TimeoutExpired) - else f'exited {error.returncode}') - raise RuntimeError('Public updater launch ' + reason + ': ' + - diagnostics(error.stdout, error.stderr)) from None - try: - status = json.loads(result.stdout) - except ValueError: - raise RuntimeError('Public updater did not return a run record: ' + - diagnostics(result.stdout, result.stderr)) from None - if (not re.fullmatch(r'\d{8}-\d{6}-\d+-\d+', str(status.get('id', ''))) - or status.get('systemUnit') is not True or status.get('state') not in ('queued', 'running')): - raise RuntimeError('Public updater did not launch its durable system worker') - return status['id'] - - -def guest_record(vm, password, root_script, identifier): - script = "python3 - <<'PY'\nimport json\nfrom pathlib import Path\n" - script += f"root = Path('/home/qualification/.local/state/cybexos/update/logs') / {identifier!r}\n" - script += "print((root / 'status.json').read_text())\nPY\n" - return json.loads(root_script(vm, script, password).stdout) - - -def transaction(vm, password, root_script, identifier): - script = "python3 - <<'PY'\nimport glob, pathlib, subprocess\n" - script += "candidates = ['/usr/libexec/cybexos-update-transaction'] + glob.glob('/usr/local/libexec/cybexos-update-bootstrap.d/*/cybexos-update-transaction')\n" - script += "helper = next(p for p in candidates if pathlib.Path(p).is_file())\n" - script += f"subprocess.run([helper, 'status', {identifier!r}], check=True)\nPY\n" - return json.loads(root_script(vm, script, password).stdout) - - -def wait_public(vm, password, root_script, identifier, *, interrupted=False): - deadline = root_script.deadline if isinstance(root_script, PublicControl) else time.monotonic() + 1500 - while time.monotonic() < deadline: - status = guest_record(vm, password, root_script, identifier) - if interrupted: - result = root_script(vm, f'if test -f {WORK}/applied.json; then cat {WORK}/applied.json; fi\n', password) - if result.stdout.strip(): - if transaction(vm, password, root_script, identifier)['state'] != 'applying': - raise RuntimeError('Interruption boundary is outside the durable applying state') - return status, json.loads(result.stdout) - if status['state'] in ('done', 'failed', 'cancelled'): - if interrupted or status['state'] != 'done' or status.get('exitCode') != 0: - raise RuntimeError('Public updater did not reach the expected boundary: ' + status['state']) - record = transaction(vm, password, root_script, identifier) - if record['state'] != 'committed': - raise RuntimeError('Public updater returned success without committing its journal') - return status, record - time.sleep(1) - raise RuntimeError('Public updater exceeded the qualification deadline') - - -def stop_interrupted(vm, password, root_script, identifier, workers, *, strict=True): - unit = 'cybexos-update-' + identifier + '.service' - script = f"systemctl kill --kill-whom=all --signal=SIGKILL {shlex.quote(unit)}\n" - if not strict: - # A terminal/failed launch may already have unloaded its unit. The - # cgroup, heartbeat and recorded PID checks below remain mandatory. - script = script.rstrip() + ' || true\n' - script += "python3 - <<'PY'\nimport pathlib,time\n" - script += f"group = pathlib.Path('/sys/fs/cgroup/system.slice/{unit}')\n" - script += f"heartbeat = pathlib.Path('{WORK}/heartbeat')\n" - script += "for _ in range(300):\n if not group.exists() or 'populated 1' not in (group / 'cgroup.events').read_text().splitlines(): break\n time.sleep(.1)\nelse: raise RuntimeError('Updater workers survived SIGKILL')\n" - script += "before = heartbeat.read_bytes() if heartbeat.exists() else b''\ntime.sleep(.3)\nassert (heartbeat.read_bytes() if heartbeat.exists() else b'') == before, 'Detached worker kept writing'\n" - for pid in workers.values(): - if type(pid) is not int or pid <= 1: - raise ValueError('Invalid qualification worker PID') - script += f"assert not pathlib.Path('/proc/{pid}').exists() or pathlib.Path('/proc/{pid}/stat').read_text().split()[2] == 'Z', 'Worker survived'\n" - script += f"applied = pathlib.Path('{WORK}/applied.json')\n" - script += "if applied.exists():\n import json\n for pid in json.loads(applied.read_text()).values():\n assert type(pid) is int and pid > 1, 'Invalid worker PID'\n process = pathlib.Path('/proc') / str(pid) / 'stat'\n assert not process.exists() or process.read_text().rsplit(')', 1)[1].split()[0] == 'Z', 'Recorded worker survived'\n" - script += 'PY\n' - script += 'systemctl stop cybexos-reconcile.service\n/usr/libexec/cybexos-configure-installed --drain\n' - root_script(vm, script, password) - verify_no_workers(vm, password, root_script, [identifier]) - - -def cleanup_fixture(vm, password, root_script): - # A failed admission must not remove a pre-existing command. Byte identity - # also recognizes our own adapter after root rollback erased /run state. - script = "python3 - <<'PY'\nfrom pathlib import Path\nimport shutil,subprocess\n" - script += f"adapter = Path('/usr/local/bin/dnf')\nexpected = {DNF_ADAPTER!r}\n" - script += "if not adapter.is_symlink() and adapter.is_file() and adapter.read_text() == expected:\n adapter.unlink()\n" - script += f"observer = Path({OBSERVER!r})\nunit = Path('/etc/systemd/system/{UNIT}')\n" - script += f"if not observer.is_symlink() and observer.is_file() and observer.read_text() == {OBSERVER_SCRIPT!r}:\n" - script += f" subprocess.run(['systemctl','disable','--now',{UNIT!r}],check=False,timeout=60)\n observer.unlink()\n unit.unlink(missing_ok=True)\n" - script += f"work = Path({WORK!r})\nif work.is_dir() and not work.is_symlink(): shutil.rmtree(work)\n" - script += "subprocess.run(['systemctl','daemon-reload'],check=True,timeout=30)\nPY\n" - root_script(vm, script, password) - - -def verify_recovered(vm, password, root_script, identifier, before): - record = transaction(vm, password, root_script, identifier) - # bootId identifies the interrupted boot, not the recovery boot. - if record['state'] != 'rolled-back' or record['bootId'] != before['boot_id']: - raise RuntimeError('Interrupted journal did not settle as rolled-back') - script = "python3 - <<'PY'\nimport glob,hashlib,importlib.machinery,importlib.util,json,pathlib,subprocess\n" - script += f"evidence = json.loads(pathlib.Path({EVIDENCE!r}).read_text())\n" - script += f"assert evidence['transaction'] == {identifier!r} and evidence['preLogin'] is True\n" - script += "assert evidence['state'] == 'rolled-back' and 'applying' in evidence['states']\n" - script += f"assert evidence['bootId'] != {before['boot_id']!r}\n" - script += "boot = pathlib.Path('/proc/sys/kernel/random/boot_id').read_text().strip()\nassert boot != evidence['bootId'], 'Recovery did not complete its automatic restart'\n" - script += "assert pathlib.Path('/etc/cybexos/qualification-public-root').read_text() == 'before\\n'\n" - script += f"assert hashlib.sha256(pathlib.Path({VENDOR!r}).read_bytes()).hexdigest() == {before['vendor_sha256']!r}, 'Vendor checkpoint was not restored'\n" - script += "assert pathlib.Path('/home/qualification/qualification-public-personal').read_text() == 'after-checkpoint\\n', 'Personal post-checkpoint edit was lost'\n" - script += "packages = subprocess.check_output(['rpm','-q','--qf','%{NEVRA}\\n','cybexos-desktop','cybexos-offline-apps'],text=True)\n" - script += f"assert packages == {before['packages']!r}, 'Package identity did not roll back'\n" - script += "helpers = ['/usr/libexec/cybexos-system-snapshot'] + glob.glob('/usr/local/libexec/cybexos-update-bootstrap.d/*/cybexos-system-snapshot')\npath = next(p for p in helpers if pathlib.Path(p).is_file())\nloader = importlib.machinery.SourceFileLoader('qualification_snapshot', path)\nspec = importlib.util.spec_from_loader(loader.name, loader)\nmodule = importlib.util.module_from_spec(spec)\nloader.exec_module(module)\nlayout = module.detect_layout()\nassert layout.running == '/root' and not layout.pending_reboot\n" - script += f"point = {record['snapshot']!r}\n" - script += "with module.opened_store(layout, create=False) as store:\n records = [module.read_record(store, p.stem) for p in store.replaced.glob('*.json')]\n restored = [r for r in records if r.get('point') == point and r.get('state') == 'complete']\n assert len(restored) == 1, 'Recovery exchanged the root more than once'\n assert module.subvolume_uuid(store.top / 'root') == restored[0]['newRoot'], 'Running root differs from restored root'\n assert not (store.metadata / (point + '.pin')).exists(), 'Terminal journal still pins recovery point'\n assert not (store.path / 'transactions' / " + repr(identifier) + " / 'vendor').exists(), 'Vendor checkpoint did not settle'\n print(json.dumps({'rootUuid': restored[0]['newRoot'], 'bootId': boot, 'recoveryBootId': evidence['bootId']}))\nPY\n" - return json.loads(root_script(vm, script, password).stdout) - - -def verify_no_workers(vm, password, root_script, identifiers): - script = "python3 - <<'PY'\nfrom pathlib import Path\nimport time\n" - groups = ['cybexos-update-' + identifier + '.service' for identifier in identifiers] - groups += ['cybexos-provision-work.service', 'cybexos-reconcile.service'] - script += f'groups = {groups!r}\n' - script += "for unit in groups:\n path = Path('/sys/fs/cgroup/system.slice') / unit / 'cgroup.events'\n for _ in range(300):\n if not path.exists() or 'populated 1' not in path.read_text().splitlines(): break\n time.sleep(.1)\n else: raise RuntimeError('A transaction worker survived')\nPY\n" - root_script(vm, script, password) - - -def installed_updater_identity(candidate, candidate_sha256, candidate_size): - """Check installed bytes against the exact candidate's packaged manifest.""" - import hashlib - import os - import re - import stat - import subprocess - import time - - deadline = time.monotonic() + 30 - installed = '/usr/share/cybexos/bin/cybexos-update-run' - - def remaining(): - value = deadline - time.monotonic() - if value <= 0: - raise RuntimeError('Installed updater identity exceeded its deadline') - return value - - def opened(path): - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) - stream = os.fdopen(descriptor, 'rb') - info = os.fstat(stream.fileno()) - if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_gid != 0 - or info.st_mode & 0o022): - stream.close() - raise RuntimeError('Updater identity requires protected root-owned regular files') - return stream - - def fingerprint(stream, size): - before = os.fstat(stream.fileno()) - if before.st_size != size or size <= 0: - raise RuntimeError('Updater identity file size mismatch') - checksum = hashlib.sha256() - unread = size - while unread: - remaining() - data = stream.read(min(unread, 1024 * 1024)) - if not data: - raise RuntimeError('Updater identity file changed while hashing') - checksum.update(data) - unread -= len(data) - after = os.fstat(stream.fileno()) - if stream.read(1) or (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != ( - after.st_size, after.st_mtime_ns, after.st_ctime_ns): - raise RuntimeError('Updater identity file changed while hashing') - return checksum.hexdigest() - - def query(arguments, *, pass_fds=()): - result = subprocess.run(['/usr/bin/rpm', *arguments], check=True, capture_output=True, - text=True, timeout=min(10, remaining()), pass_fds=pass_fds) - if len(result.stdout) > 4 * 1024 * 1024: - raise RuntimeError('Excessive updater RPM header output') - return result.stdout.splitlines() - - if (not re.fullmatch(r'[0-9a-f]{64}', candidate_sha256) - or type(candidate_size) is not int or candidate_size <= 0): - raise ValueError('Invalid updater candidate binding') - with opened(candidate) as stream: - if fingerprint(stream, candidate_size) != candidate_sha256: - raise RuntimeError('Updater candidate RPM checksum mismatch') - # RPM's documented parallel file arrays preserve the canonical package - # rendering. Query the pinned, already-hashed inode, never raw source - # or a second mutable candidate pathname. OpenPGP SHA256 algorithm=8. - queryformat = ('%{NEVRA}\n%{FILEDIGESTALGO}\n' - '[%{FILENAMES}\t%{FILEDIGESTS}\t%{FILEMODES}\t' - '%{FILEUSERNAME}\t%{FILEGROUPNAME}\n]') - header = query(['-qp', '--qf', queryformat, '--', f'/proc/self/fd/{stream.fileno()}'], - pass_fds=(stream.fileno(),)) - if (len(header) < 3 or not re.fullmatch(r'cybexos-desktop-[^\s/]{1,240}', header[0]) - or header[1] != '8'): - raise RuntimeError('Invalid updater RPM identity or non-SHA256 file digests') - rows = [line.split('\t') for line in header[2:]] - if any(len(row) != 5 for row in rows): - raise RuntimeError('Malformed updater RPM file manifest') - matches = [row for row in rows if row[0] == installed] - if len(matches) != 1: - raise RuntimeError('Updater RPM must contain exactly one installed helper entry') - _path, expected, mode, owner, group = matches[0] - if (not re.fullmatch(r'[0-9a-f]{64}', expected) or not re.fullmatch(r'[0-9]{1,5}', mode) - or not stat.S_ISREG(int(mode)) or int(mode) & 0o022 or (owner, group) != ('root', 'root')): - raise RuntimeError('Invalid updater RPM helper digest, type or ownership') - if query(['-q', '--qf', '%{NEVRA}\n', 'cybexos-desktop']) != [header[0]]: - raise RuntimeError('Installed updater package differs from the bound candidate NEVRA') - with opened(installed) as stream: - size = os.fstat(stream.fileno()).st_size - if size > 4 * 1024 * 1024: - raise RuntimeError('Excessive installed updater size') - actual = fingerprint(stream, size) - if actual != expected: - raise RuntimeError('Installed updater differs from the bound candidate file digest') - remaining() - return {'path': installed, 'expected_sha256': expected, 'actual_sha256': actual, - 'candidate_rpm_sha256': candidate_sha256, 'candidate_nevra': header[0]} - - -def verify_installed_updater(vm, password, root_script, candidate, candidate_sha256, candidate_size): - script = "python3 - <<'PY'\nimport json\n" + inspect.getsource(installed_updater_identity) - script += (f'\nprint(json.dumps(installed_updater_identity({candidate!r}, ' - f'{candidate_sha256!r}, {candidate_size!r})))\nPY\n') - return json.loads(root_script(vm, script, password, timeout=30).stdout) - - -def boot_recovered(vm, password): - """Let both pre-login recovery boots settle before restoring test access.""" - vm.start(user='qualification') - vm.unlock_disk(password) - from vm_testing import is_disk_prompt - deadline = time.monotonic() + 60 - while is_disk_prompt(vm.screen_text()): - if time.monotonic() >= deadline: - raise RuntimeError('The first recovery boot did not leave its unlock prompt') - time.sleep(1) - # This second prompt is reached only after automatic recovery has settled - # and requested its restart. Never bootstrap SSH on the recovery boot. - vm.unlock_disk(password, timeout=900) - try: - vm.wait_ssh(timeout=12, setup=False, redactions=(password,)) - except RuntimeError: - # The restored baseline disables sshd and closes SSH in the firewall. - # Authenticate at the normal console after pam_nologin releases it; - # start SSH/open the runtime firewall only, as boot_installed does. - vm.bootstrap_installed_ssh(password) - from qualification import focus_installed_desktop - focus_installed_desktop(vm, password, True) - - -def qualify_public_updater(vm, candidate, offline_candidate, password, root_script, - *, candidate_dependencies, boot_installed, poweroff_installed, - verify_user_choices, preference, report): - # Import here to retain the existing upgrade module's public input helpers. - from upgrade_qualification import (CANDIDATE, OFFLINE_CANDIDATE, copy_candidate, - copy_dependencies, inspect_version) - if not offline_candidate: - raise ValueError('Public updater qualification requires both candidate RPMs') - sha = copy_candidate(vm, candidate) - offline_sha = copy_candidate(vm, offline_candidate, OFFLINE_CANDIDATE) - if sha != report['candidate_rpm_sha256'] or offline_sha != report['candidate_offline_rpm_sha256']: - raise RuntimeError('Public updater candidates differ from the qualified artifacts') - firstparty = {'candidate': {'source': CANDIDATE, 'sha256': sha, 'size': Path(candidate).stat().st_size}, - 'offline_candidate': {'source': OFFLINE_CANDIDATE, 'sha256': offline_sha, - 'size': Path(offline_candidate).stat().st_size}} - identifiers = [] - # The controller outlives the staging context: its authenticated process - # handles both root-owned and user-owned cleanup without another login. - with PublicControl(vm, password) as control: - with copy_dependencies(vm, candidate_dependencies, password, root_script, - firstparty=firstparty, cleanup=control.cleanup_staging) as staged: - rpms = [(staged['candidate'], sha), (staged['offline_candidate'], offline_sha)] - if candidate_dependencies: - rpms += [(path, item['sha256']) for path, item in - zip(staged['dependencies'], candidate_dependencies.manifest['rpms'], strict=True)] - transfer_source(vm, password, control) - provision_fixture(vm, password, control, rpms, True) - script = "python3 - <<'PY'\nimport hashlib,json,pathlib,subprocess\n" - script += f"vendor = pathlib.Path({VENDOR!r})\nassert vendor.is_file()\n" - script += "print(json.dumps({'boot_id': pathlib.Path('/proc/sys/kernel/random/boot_id').read_text().strip(), 'vendor_sha256': hashlib.sha256(vendor.read_bytes()).hexdigest(), 'packages': subprocess.check_output(['rpm','-q','--qf','%{NEVRA}\\n','cybexos-desktop','cybexos-offline-apps'],text=True)}))\nPY\n" - before = json.loads(control(vm, script, password).stdout) - identifier = control.start() - identifiers.append(identifier) - control(vm, f"printf '%s\\n' {shlex.quote(identifier)} > /home/qualification/qualification-public-run-id\n", password) - _status, workers = wait_public(vm, password, control, identifier, interrupted=True) - versions = inspect_version(vm, candidate=staged['candidate'], root_script=control, password=password) - if versions['installed'] != versions['candidate']: - raise RuntimeError('Interruption occurred before the candidate package was applied') - control.drain_attempted = True - stop_interrupted(vm, password, control, identifier, workers) - control.drained = True - # Shutdown preparation also uses the same process. The observer is - # retained only after marked shutdown and a clean QEMU exit. - control.poweroff() - report['checks'].append('public-updater-interrupted-after-apply') - # No manual recovery call occurs here: both pre-login recovery and its - # restart must run through the installed systemd dependency. - boot_recovered(vm, password) - restored = verify_recovered(vm, password, root_script, identifier, before) - verify_user_choices(vm, password, root_script, preference) - report['checks'] += ['public-updater-pre-login-recovery', - 'public-updater-root-package-vendor-restored', - 'public-updater-personal-data-preserved'] - cleanup_fixture(vm, password, root_script) - poweroff_installed(vm, password) - boot_installed(vm, password, True) - again = verify_recovered(vm, password, root_script, identifier, before) - if again['rootUuid'] != restored['rootUuid']: - raise RuntimeError('A terminal recovery exchanged the root on another boot') - report['checks'].append('public-updater-second-boot-settled') - copy_candidate(vm, candidate) - copy_candidate(vm, offline_candidate, OFFLINE_CANDIDATE) - with PublicControl(vm, password) as control: - with copy_dependencies(vm, candidate_dependencies, password, root_script, - firstparty=firstparty, cleanup=control.cleanup_staging) as staged: - rpms = [(staged['candidate'], sha), (staged['offline_candidate'], offline_sha)] - if candidate_dependencies: - rpms += [(path, item['sha256']) for path, item in - zip(staged['dependencies'], candidate_dependencies.manifest['rpms'], strict=True)] - updater_sha = transfer_source(vm, password, control) - provision_fixture(vm, password, control, rpms, False) - committed = control.start() - identifiers.append(committed) - wait_public(vm, password, control, committed) - # Observe the product's terminal state before the timeout fixture - # reuses/drains provisioning, or cleanup can stop leaked workers. - # A surviving worker must fail qualification even if teardown can - # subsequently remove it safely. - verify_no_workers(vm, password, control, identifiers) - versions = inspect_version(vm, candidate=staged['candidate'], root_script=control, password=password) - if versions['installed'] != versions['candidate']: - raise RuntimeError('Committed public update differs from the candidate package') - control(vm, "test \"$(rpm -q --qf '%{NEVRA}' cybexos-offline-apps)\" = " - f"\"$(rpm -qp --qf '%{{NEVRA}}' {shlex.quote(staged['offline_candidate'])})\"\n", password) - installed_helper = verify_installed_updater(vm, password, control, staged['candidate'], sha, - firstparty['candidate']['size']) - build = json.loads(control(vm, 'cat /usr/share/cybexos/build.json\n', password).stdout) - verify_user_choices(vm, password, control, preference) - control(vm, PROVISION_TIMEOUT, password, timeout=120) - control.ensure_drained() - verify_no_workers(vm, password, control, identifiers) - # Control success grants no health credit. Close and reap it (including - # fixture/staging cleanup) before the exact fresh ordinary SSH inspection. - health = verify_public_health(vm, password, root_script) - report.update({key: build[key] for key in ('source_revision', 'source_content_sha256')}) - report['public_updater'] = {'post_commit_health': health, 'interrupted': identifier, - 'committed': committed, 'updater_sha256': updater_sha, - 'installed_updater': installed_helper, **restored} - report['checks'] += ['public-updater-committed', - 'provisioning-cgroup-timeout-drains-detached-workers', - 'public-updater-no-surviving-workers'] diff --git a/image/publish-pxe b/image/publish-pxe deleted file mode 100755 index 9a956792..00000000 --- a/image/publish-pxe +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python3 -from pxe_publish import main - -if __name__ == '__main__': - main() diff --git a/image/pxe_publish.py b/image/pxe_publish.py deleted file mode 100644 index 9d9c2d0d..00000000 --- a/image/pxe_publish.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Checksum-first, no-replace publication to a local iVentoy installation.""" -import argparse -import json -import os -from pathlib import Path -import shutil -import subprocess -import tempfile -import time -import urllib.error -import urllib.request -from urllib.parse import urlsplit - -from build_support import SAFE_NAME, checksum_entries, digest - - -# Statically inspected in the official 1.0.41 Linux release (no binaries run): -# query_status -> {"status":"running"|"refreshing"|...}; get_img_tree -> list. -# https://github.com/ventoy/PXE/releases/tag/v1.0.41 -DEFAULT_CONTRACT = {"pxe_status_pointer": "/status", "pxe_running_value": "running", - "refresh_busy_pointer": "/status", "refresh_idle_value": "running", - "refresh_busy_value": "refreshing"} - - -def pointer(value, path): - if not path.startswith("/"): - raise ValueError("API contract paths must be JSON pointers") - for part in path[1:].split("/"): - part = part.replace("~1", "/").replace("~0", "~") - value = value[int(part)] if isinstance(value, list) else value[part] - return value - - -def validate_status(response, contract): - if not isinstance(response, dict) or response.get("result", "success") != "success": - raise RuntimeError("iVentoy status request did not succeed") - try: - running = pointer(response, contract["pxe_status_pointer"]) - busy = pointer(response, contract["refresh_busy_pointer"]) - except (KeyError, TypeError, IndexError, ValueError) as error: - raise RuntimeError("iVentoy status shape differs from the reviewed API contract; inspect installed vtoy_image.html") from error - if busy == contract["refresh_busy_value"]: - return True - if running != contract["pxe_running_value"]: - raise RuntimeError(f"iVentoy PXE is not running: {running!r}") - if busy not in (contract["refresh_idle_value"], contract["refresh_busy_value"]): - raise RuntimeError("Unknown iVentoy refresh state") - return busy == contract["refresh_busy_value"] - - -def tree_contains(value, filename): - if isinstance(value, str): - return value == filename or value.replace("\\", "/").rsplit("/", 1)[-1] == filename - if isinstance(value, dict): - return any(tree_contains(item, filename) for item in value.values()) - if isinstance(value, list): - return any(tree_contains(item, filename) for item in value) - return False - - -class IVentoy: - def __init__(self, url, contract, timeout=180, request=None, sleep=time.sleep, clock=time.monotonic): - parsed = urlsplit(url) - if parsed.scheme != "http" or parsed.hostname not in ("127.0.0.1", "localhost", "::1"): - raise ValueError("Run this tool on the PXE server and use its loopback iVentoy URL") - self.url, self.contract, self.timeout = url, contract, timeout - self.request = request - self.sleep, self.clock = sleep, clock - required = {"pxe_status_pointer", "pxe_running_value", "refresh_busy_pointer", "refresh_idle_value", "refresh_busy_value"} - if not required.issubset(contract) or contract["refresh_idle_value"] == contract["refresh_busy_value"]: - raise ValueError("Incomplete iVentoy API contract") - - def _request(self, method, timeout): - request = urllib.request.Request(self.url, json.dumps({"method": method}).encode(), {"Content-Type": "application/json"}) - with urllib.request.urlopen(request, timeout=timeout) as response: - return json.load(response) - - def _query(self, method, deadline): - remaining = deadline - self.clock() - if remaining <= 0: - raise RuntimeError("iVentoy refresh verification deadline exceeded") - response = (self.request(method) if self.request is not None else - self._request(method, min(30, remaining))) - if self.clock() >= deadline: - raise RuntimeError("iVentoy refresh verification deadline exceeded") - return response - - def _pause(self, deadline): - self.sleep(max(0, min(2, deadline - self.clock()))) - - def wait_idle(self, deadline=None, retry_timeouts=False): - deadline = self.clock() + self.timeout if deadline is None else deadline - while self.clock() < deadline: - try: - status = self._query("query_status", deadline) - except (TimeoutError, urllib.error.URLError) as error: - # The accepted scan can temporarily block iVentoy's HTTP - # thread. Retry only this read, never an ambiguous refresh - # mutation, malformed JSON, or another transport error. - timed_out = isinstance(error, TimeoutError) or isinstance(error.reason, TimeoutError) - if not retry_timeouts or not timed_out: - raise - self._pause(deadline) - continue - if not validate_status(status, self.contract): - return - self._pause(deadline) - raise RuntimeError("iVentoy did not confirm an idle, running PXE service before the deadline; published files are retained") - - def refresh(self, filename): - deadline = self.clock() + self.timeout - while self.clock() < deadline: - self.wait_idle(deadline) - result = self._query("refresh_img_list", deadline) - if result.get("result") == "success": - break - if result.get("result") != "busy": - raise RuntimeError(f"iVentoy rejected refresh: {result.get('result')!r}") - self._pause(deadline) - else: - raise RuntimeError("iVentoy refresh request remained busy") - self.wait_idle(deadline, retry_timeouts=True) - tree = self._query("get_img_tree", deadline) - if not isinstance(tree, list) or not tree_contains(tree, filename): - raise RuntimeError("iVentoy did not list the published filename") - - -def staging_parent(served, requested=None): - """Writable directory outside the served tree on the same filesystem. - - Publication hardlinks complete files into place, so staging must share the - served directory's filesystem. Without a request, use its nearest writable - ancestor; the served parent may be root-owned on a PXE host. - """ - if not Path(served).is_dir(): - raise ValueError("Served ISO directory must exist") - served = Path(served).resolve() - candidates = [Path(requested).resolve()] if requested is not None else list(served.parents) - for candidate in candidates: - if candidate == served or served in candidate.parents: - raise ValueError("Staging must be outside the served ISO directory") - if (candidate.is_dir() and candidate.stat().st_dev == served.stat().st_dev - and os.access(candidate, os.W_OK | os.X_OK)): - return candidate - if requested is not None: - raise ValueError("Staging directory must be writable and on the served ISO filesystem") - raise ValueError("No writable ancestor shares the served ISO filesystem; pass --staging") - - -def publish(iso, served, filename, expected=None, staging=None): - """Caller has validated iVentoy. Existing files are never replaced.""" - iso, served = Path(iso), Path(served) - if not SAFE_NAME.fullmatch(filename) or not filename.endswith(".iso"): - raise ValueError("ISO filename must be ASCII without spaces and end with .iso") - if served.is_symlink() or not served.is_dir(): - raise ValueError("Served ISO directory must exist and must not be a symlink") - staging = staging_parent(served, staging) - destination, checksum = served / filename, served / (filename + ".sha256") - actual = digest(iso) - if expected is not None and actual != expected: - raise ValueError("Source ISO changed after manifest verification") - expected = actual - if destination.exists() or checksum.exists() or destination.is_symlink() or checksum.is_symlink(): - if (destination.is_file() and checksum.is_file() and not destination.is_symlink() and not checksum.is_symlink() - and digest(destination) == expected and checksum.read_text() == f"{expected} {filename}\n"): - return destination, expected # Retry a failed refresh without replacing verified files. - raise FileExistsError("Refusing to replace a different or incomplete existing ISO/checksum") - with tempfile.TemporaryDirectory(prefix=".cybexos-publish-", dir=staging) as temporary: - stage = Path(temporary) - image = stage / filename - shutil.copyfile(iso, image) - image.chmod(0o644) - if digest(image) != expected: - raise RuntimeError("Staged ISO checksum mismatch; nothing published") - sums = stage / checksum.name - sums.write_text(f"{expected} {filename}\n") - sums.chmod(0o644) - for path in (image, sums): - with path.open("rb") as stream: - os.fsync(stream.fileno()) - # Hardlinks publish already-complete bytes atomically and fail if a - # competing process created either destination. Staging is same FS. - os.link(sums, checksum) - try: - os.link(image, destination) - except BaseException: - checksum.unlink() - raise - descriptor = os.open(served, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - return destination, expected - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("artifacts", type=Path, help="Verified build artifacts directory containing SHA256SUMS") - parser.add_argument("--served", type=Path, default=Path("/data/pxe/iso")) - parser.add_argument("--staging", type=Path, help="Writable directory on the served filesystem, outside it (default: nearest writable ancestor)") - parser.add_argument("--api-contract", type=Path, help="JSON pointers/values reviewed against the installed iVentoy UI") - parser.add_argument("--url", default="http://127.0.0.1:26000/iventoy/json") - parser.add_argument("--execute", action="store_true", help="Explicitly publish and refresh iVentoy; default only checks artifacts") - args = parser.parse_args() - entries = checksum_entries(args.artifacts) - isos = [name for name in entries if name.endswith(".iso")] - if len(isos) != 1: - parser.error("artifacts must contain exactly one checksummed ISO") - name = isos[0] - staging = staging_parent(args.served, args.staging) - if not args.execute: - print(json.dumps({"action": "publish-and-refresh", "filename": name, "sha256": entries[name], "destination": str(args.served / name), - "staging": str(staging), "executed": False}, indent=2)) - return - contract = json.loads(args.api_contract.read_text()) if args.api_contract else DEFAULT_CONTRACT - client = IVentoy(args.url, contract) - subprocess.run(["systemctl", "is-active", "--quiet", "iventoy.service"], check=True) - client.wait_idle() - destination, checksum = publish(args.artifacts / name, args.served, name, entries[name], staging) - try: - client.refresh(name) - subprocess.run(["systemctl", "is-active", "--quiet", "iventoy.service"], check=True) - except BaseException as error: - raise RuntimeError(f"Published verified ISO retained at {destination}; refresh verification failed: {error}. Inspect status before retrying; no service restart was attempted.") from error - print(f"Published {destination}\nSHA256 {checksum}\niVentoy refresh/list/PXE/service checks passed.") diff --git a/image/qmp_control.py b/image/qmp_control.py deleted file mode 100644 index 229c9f82..00000000 --- a/image/qmp_control.py +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env python3 -"""Inspect only the disposable VM described by image/test-live's vm.json.""" -import argparse -import json -from pathlib import Path -import socket -import subprocess -import time -import sys - - -class Qmp: - def __init__(self, path): - self.socket = socket.socket(socket.AF_UNIX) - self.socket.settimeout(10) - self.socket.connect(path) - self.stream = self.socket.makefile("rwb", buffering=0) - self.stream.readline() - self.call("qmp_capabilities") - - def call(self, command, arguments=None): - self.stream.write((json.dumps({"execute": command, "arguments": arguments or {}}) + "\n").encode()) - while line := self.stream.readline(): - response = json.loads(line) - if "error" in response: - raise RuntimeError(response["error"]) - if "return" in response: - return response["return"] - raise RuntimeError("QEMU disconnected") - - -def type_text(qmp, text): - if any(ord(character) > 127 for character in text): - raise ValueError("QMP typing supports ASCII only") - punctuation = {" ": "spc", "\n": "ret", "/": "slash", ".": "dot", "-": "minus", - "=": "equal", "[": "bracket_left", "]": "bracket_right", "'": "apostrophe", - ";": "semicolon", "\\": "backslash", ",": "comma", "`": "grave_accent"} - shifted = dict(zip('!@#$%^&*()_+{}:"|<>?~', '1234567890-=[];\'\\,./`')) - for character in text: - shift = character.isupper() or character in shifted - base = shifted.get(character, character.lower()) - code = punctuation.get(base, base) - codes = ["shift", code] if shift else [code] - events = [{"type": "key", "data": {"down": True, "key": {"type": "qcode", "data": item}}} for item in codes] - events += [{"type": "key", "data": {"down": False, "key": {"type": "qcode", "data": item}}} for item in reversed(codes)] - qmp.call("input-send-event", {"events": events}) - time.sleep(0.015) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("directory", type=Path) - subparsers = parser.add_subparsers(dest="command", required=True) - screenshot = subparsers.add_parser("screenshot") - screenshot.add_argument("filename", type=Path) - key = subparsers.add_parser("key") - key.add_argument("keys", help="QEMU key names joined with +, e.g. ctrl+alt+f2") - typing = subparsers.add_parser("type") - typing.add_argument("text", nargs="?", default="", help="ASCII text to type into the focused VM window") - typing.add_argument("--stdin", action="store_true", help="Read private text from stdin instead of process arguments") - click = subparsers.add_parser("click") - click.add_argument("x", type=int) - click.add_argument("y", type=int) - click.add_argument("--width", type=int, default=1280) - click.add_argument("--height", type=int, default=800) - ssh = subparsers.add_parser("ssh") - ssh.add_argument("arguments", nargs=argparse.REMAINDER) - args = parser.parse_args() - state = json.loads((args.directory / "vm.json").read_text()) - if args.command == "ssh": - raise SystemExit(subprocess.run([*state["ssh"], *args.arguments]).returncode) - qmp = Qmp(state["qmp"]) - if args.command == "screenshot": - qmp.call("screendump", {"filename": str(args.filename.resolve()), "format": "png"}) - elif args.command == "key": - qmp.call("send-key", {"keys": [{"type": "qcode", "data": key} for key in args.keys.split("+")]}) - elif args.command == "type": - type_text(qmp, sys.stdin.read() if args.stdin else args.text) - elif args.command == "click": - qmp.call("input-send-event", {"events": [ - {"type": "abs", "data": {"axis": "x", "value": int(args.x / args.width * 32767)}}, - {"type": "abs", "data": {"axis": "y", "value": int(args.y / args.height * 32767)}}, - ]}) - time.sleep(0.15) - qmp.call("input-send-event", {"events": [{"type": "btn", "data": {"down": True, "button": "left"}}]}) - time.sleep(0.15) - qmp.call("input-send-event", {"events": [{"type": "btn", "data": {"down": False, "button": "left"}}]}) - - -if __name__ == "__main__": - main() diff --git a/image/qualification-results/2026-09-26/build.json b/image/qualification-results/2026-09-26/build.json deleted file mode 100644 index 6fff3486..00000000 --- a/image/qualification-results/2026-09-26/build.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "artifacts": { - "CybexOS-Live-44-20260926T150946Z-99900b99.iso": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", - "applications.json": "952ccf1a5e7673b01a0f2143668d70303f971065be25bd35d21d946d17689f36", - "build-provenance.json": "016a81a05571e2b095459a0d05c51666acb7611c31cbee4658ad5b8a65dfa581", - "builder-packages.txt": "c47a03ea52d880785d72c15396b6f66edcacced34b27fc5005e23a377bb7018d", - "cybexos-desktop-0.0.0~dev-1.20260926150946.ga991a97d0999.fc44.x86_64.rpm": "e42fa8fdab34eab40d32e93fea9b8c9399abdff1c5be40d8f60207984c41ed8b", - "mdview-0.0.1-1.fc44.x86_64.rpm": "2100ed7607ffb6b49fdaca46fd14d09da6f716bcdac8fa8596e5f051f199d83d", - "package-manifest.json": "6a99aaffc29a432d301f507a1439cfffdd6baa6d212472426d1413e71df4d8dc", - "packages.txt": "ac068e4d42f5502c65268c5f10ce76953c6502e4f25ad869bb678b5f2aa481d9", - "voxtype-0.7.5-1.x86_64.rpm": "50dd10fe8dcdd0f0d7a2796ca8d628e21810c81e2ae1c365075643e552e79c61" - }, - "build_id": "20260926T150946Z-99900b99", - "compression": "xz", - "download_cache_hits": 51, - "phases": [ - { - "phase": "Validate public update channel", - "seconds": 0.01, - "started_utc": "2026-09-26T15:09:46.164249+00:00", - "status": "complete" - }, - { - "phase": "Verify cached Fedora builder", - "seconds": 0.34, - "started_utc": "2026-09-26T15:09:46.178644+00:00", - "status": "complete" - }, - { - "phase": "Record source and prepare isolated VM", - "seconds": 0.98, - "started_utc": "2026-09-26T15:09:46.514244+00:00", - "status": "complete" - }, - { - "phase": "Wait for builder and restore verified downloads", - "seconds": 12.92, - "started_utc": "2026-09-26T15:09:47.491321+00:00", - "status": "complete" - }, - { - "phase": "Build applications, desktop RPM and compressed image", - "seconds": 1168.17, - "started_utc": "2026-09-26T15:10:00.409922+00:00", - "status": "complete" - }, - { - "phase": "Save verified download cache", - "seconds": 3.3, - "started_utc": "2026-09-26T15:29:28.582309+00:00", - "status": "complete" - }, - { - "phase": "Verify and atomically deliver completed artifacts", - "seconds": 55.94, - "started_utc": "2026-09-26T15:29:31.887078+00:00", - "status": "complete" - } - ], - "resources": { - "cpus": 8, - "free_gib": { - "/data/cybexos-candidate-20260926-installer-qualification/cache": 1624.6 - }, - "memory_mib": 24576, - "seed_tool": "cloud-localds" - }, - "source": { - "changes": [], - "configuration_sha256": "e0a03813306db3a615ac54a2b331c0d023fedee0d5b8d240c902509ff89808f6", - "revision": "a991a97d099936298edfa7ad4c1747a3d0261f74", - "source_archive_sha256": "a0f0462f828f637c68c5dcb8233a78e5f32541223aef1b76f60cdc0d040bb1cc" - }, - "source_dirty": false, - "source_epoch": 1790435325, - "source_revision": "a991a97d099936298edfa7ad4c1747a3d0261f74", - "status": "complete", - "tools": { - "python": "3.13.5 (main, Aug 10 2026, 12:06:59) [GCC 14.2.0]", - "qemu-img": "qemu-img version 10.0.13 (Debian 1:10.0.13+ds-0+deb13u1)", - "qemu-system-x86_64": "QEMU emulator version 10.0.13 (Debian 1:10.0.13+ds-0+deb13u1)" - }, - "utc": "2026-09-26T15:09:46.150792+00:00" -} diff --git a/image/qualification-results/2026-09-26/encrypted-nl.json b/image/qualification-results/2026-09-26/encrypted-nl.json deleted file mode 100644 index 7b87c0fc..00000000 --- a/image/qualification-results/2026-09-26/encrypted-nl.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_rpm_sha256": null, - "checks": [ - "live-boot-and-offline-applications", - "graphical-installer", - "encrypted-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "encrypted-btrfs", - "autologin", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "sudo-requires-password", - "initial-encrypted-keyring-unlocked-without-prompt", - "synthetic-keyring-secret-survives-cold-reboot", - "logout-requires-authentication", - "logout-password-recovery-and-keyring", - "compositor-crash-requires-authentication", - "compositor-crash-password-recovery-and-keyring", - "manager-restart-requires-authentication", - "manager-restart-password-recovery-and-keyring", - "missing-cached-password-preserves-locked-encrypted-keyring", - "fallback-password-login-unlocks-existing-keyring", - "first-autologin-launch-failure-consumes-boot-attempt", - "failed-first-autologin-manager-restart-requires-authentication", - "failed-first-autologin-password-login-recovers-desktop" - ], - "firmware": "uefi", - "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", - "network": "outbound-blocked", - "scenario": "encrypted-nl", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", - "status": "passed" -} diff --git a/image/qualification-results/2026-09-26/encrypted-us.json b/image/qualification-results/2026-09-26/encrypted-us.json deleted file mode 100644 index 80213bbc..00000000 --- a/image/qualification-results/2026-09-26/encrypted-us.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_rpm_sha256": null, - "checks": [ - "live-boot-and-offline-applications", - "graphical-installer", - "encrypted-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "encrypted-btrfs", - "autologin", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "sudo-requires-password", - "initial-encrypted-keyring-unlocked-without-prompt", - "synthetic-keyring-secret-survives-cold-reboot", - "logout-requires-authentication", - "logout-password-recovery-and-keyring", - "compositor-crash-requires-authentication", - "compositor-crash-password-recovery-and-keyring", - "manager-restart-requires-authentication", - "manager-restart-password-recovery-and-keyring", - "missing-cached-password-preserves-locked-encrypted-keyring", - "fallback-password-login-unlocks-existing-keyring", - "first-autologin-launch-failure-consumes-boot-attempt", - "failed-first-autologin-manager-restart-requires-authentication", - "failed-first-autologin-password-login-recovers-desktop" - ], - "firmware": "uefi", - "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", - "network": "outbound-blocked", - "scenario": "encrypted-us", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_revision": "a991a97d099936298edfa7ad4c1747a3d0261f74", - "status": "passed" -} diff --git a/image/qualification-results/2026-09-26/plain-nl.json b/image/qualification-results/2026-09-26/plain-nl.json deleted file mode 100644 index 726f13a8..00000000 --- a/image/qualification-results/2026-09-26/plain-nl.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_rpm_sha256": null, - "checks": [ - "live-boot-and-offline-applications", - "graphical-installer", - "plain-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "plain-btrfs", - "password-login", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "sudo-requires-password" - ], - "firmware": "uefi", - "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", - "network": "outbound-blocked", - "scenario": "plain-nl", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", - "status": "passed" -} diff --git a/image/qualification-results/2026-09-26/plain-us.json b/image/qualification-results/2026-09-26/plain-us.json deleted file mode 100644 index 3405facd..00000000 --- a/image/qualification-results/2026-09-26/plain-us.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_rpm_sha256": null, - "checks": [ - "live-boot-and-offline-applications", - "graphical-installer", - "plain-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "plain-btrfs", - "password-login", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "sudo-requires-password" - ], - "firmware": "uefi", - "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", - "network": "outbound-blocked", - "scenario": "plain-us", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", - "status": "passed" -} diff --git a/image/qualification-results/2026-09-26/upgrade-recovery.json b/image/qualification-results/2026-09-26/upgrade-recovery.json deleted file mode 100644 index 6735627e..00000000 --- a/image/qualification-results/2026-09-26/upgrade-recovery.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", - "candidate_rpm_sha256": "e42fa8fdab34eab40d32e93fea9b8c9399abdff1c5be40d8f60207984c41ed8b", - "checks": [ - "legacy-installer-only-session-desktop-bootstrap", - "live-boot-and-offline-applications", - "graphical-installer", - "encrypted-installation", - "unused-disk-unchanged", - "installed-boot-without-iso", - "encrypted-btrfs", - "autologin", - "desktop-parity", - "selected-locale-timezone-keyboard", - "live-cleanup", - "selinux-enforcing", - "installed-rpm-upgrade", - "recovery-boot-restore" - ], - "firmware": "uefi", - "iso_sha256": "9b6d480e558590dfc257b6b661f221e3c479c9703ebcefa5f5dad517018c235a", - "legacy_session_bootstrap": "activated full desktop target from baseline installer-only session", - "network": "outbound-blocked", - "scenario": "encrypted-us", - "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", - "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", - "status": "passed" -} diff --git a/image/qualification.py b/image/qualification.py deleted file mode 100644 index 6f15771f..00000000 --- a/image/qualification.py +++ /dev/null @@ -1,488 +0,0 @@ -"""Opt-in graphical installation and lifecycle qualification on disposable QEMU disks.""" -import argparse -import json -from pathlib import Path -import secrets -import signal -import subprocess -import sys -import time - -from build_support import atomic_json, digest -from browser_qualification import browser_dependencies, ensure_installer_started, qualify_browser -from login_qualification import qualify_login -from qualification_contract import SCHEMA, validate as validate_qualification -from public_update_qualification import qualify_public_updater -from installed_outcomes import capture_guest -from offline_upgrade_inputs import load_bundle -from parity_qualification import desktop_lifecycle, prepare_saved_choices, verify_saved_choices -from upgrade_qualification import (create_recovery_point, prepare_user_choices, select_recovery_boot, - upgrade, verify_recovery_boot, verify_restored, verify_user_choices) -from vm_testing import QUALIFICATION_DISK_SERIAL, QUALIFICATION_UNUSED_SERIAL, TestVM, poweroff_guest, require_test_iso, run - - -BACKEND = '/usr/libexec/cybexos-installer-backend' -SCENARIOS = { - 'encrypted-us': (True, 'us', 'en_US.UTF-8', 'UTC'), - 'plain-us': (False, 'us', 'en_US.UTF-8', 'UTC'), - 'encrypted-nl': (True, 'nl', 'nl_NL.UTF-8', 'Europe/Amsterdam'), - 'plain-nl': (False, 'nl', 'nl_NL.UTF-8', 'Europe/Amsterdam'), - 'encrypted-de': (True, 'de', 'de_DE.UTF-8', 'Europe/Berlin'), - 'plain-de': (False, 'de', 'de_DE.UTF-8', 'Europe/Berlin'), -} - - -def backend(vm, action, payload=None): - result = run([*vm.ssh, f'sudo {BACKEND} {action}'], input=json.dumps(payload or {}), text=True, capture_output=True, timeout=120) - responses = [json.loads(line) for line in result.stdout.splitlines() if line.strip()] - results = [item for item in responses if item.get('event') == 'result'] - if not results or not results[-1].get('ok'): - raise RuntimeError(f'Installer {action} failed without a successful result') - return results[-1]['data'] - - -def root_script(vm, script, password, timeout=120): - # Password stays in memory/stdin and is never part of a command line or log. - if subprocess.run([*vm.ssh, "sudo -k -n true"], capture_output=True, timeout=15).returncode == 0: - return run([*vm.ssh, "sudo -n bash -e -s"], input=script, text=True, capture_output=True, timeout=timeout) - return run([*vm.ssh, "sudo -k -S -p '' bash -e -s"], input=password + '\n' + script, text=True, capture_output=True, timeout=timeout) - - -def reapply_saved_policy(vm, password): - # ISO qualification deliberately blocks outbound networking. Reapply the - # packaged policy with the same supported offline mode as installation. - try: - root_script(vm, '/usr/libexec/cybexos-configure-installed --offline --user qualification\n', - password, timeout=1800) - except subprocess.CalledProcessError as error: - details = ((error.stdout or '') + (error.stderr or '')).replace(password, '[redacted]')[-3000:].strip() - raise RuntimeError(f'Reapplying the installed policy failed: {details}') from error - - -# The default "Install CybexOS" boot entry: only the installer session runs, -# and its window has already started Anaconda. -INSTALL_MODE_AUDIT = r''' -set -eu -test -e /run/cybexos-install-mode -tr ' ' '\n' /dev/null -''' - - -INSTALLED_AUDIT = r''' -trap 'printf "Installed audit shell check failed at line %s\n" "$LINENO" >&2' ERR -if getent passwd liveuser; then - echo 'Installed audit failed: liveuser still exists' >&2 - exit 1 -else - test "$?" -eq 2 -fi -for path in /etc/sudoers.d/cybexos-live /etc/polkit-1/rules.d/49-cybexos-live.rules /usr/lib/systemd/system/cybexos-live.service; do - test ! -e "$path" -done -test "$(getenforce)" = Enforcing -test "$(findmnt -n -o FSTYPE /)" = btrfs -root_source=$(findmnt -n -o SOURCE /) -root_types=$(lsblk -s -n -o TYPE "${root_source%%\[*}") -if [ "${EXPECTED_ENCRYPTED}" = true ]; then - grep -qx crypt <<< "$root_types" -elif grep -qx crypt <<< "$root_types"; then - echo 'Installed audit failed: plain installation uses encryption' >&2 - exit 1 -fi -systemctl is-active --quiet sddm.service -test "$(systemctl show sddm.service -p KeyringMode --value)" = inherit -if rpm -q gdm >/dev/null; then - echo 'Installed audit failed: GDM is installed' >&2 - exit 1 -else - test "$?" -eq 1 -fi -if [ "${REQUIRE_SECURE_SUDO}" = true ]; then - grep -qx 'passwordless_wheel: false' /etc/cybexos/config.yml - test ! -e /etc/sudoers.d/10-wheel-nopasswd -fi -python3 - <<'CHECK' -import configparser,json -import os -from pathlib import Path -import re -import subprocess -encrypted=os.environ['EXPECTED_ENCRYPTED'] == 'true' -policy=json.loads(Path('/etc/cybexos/login.json').read_text()) -assert policy == {'version':1, 'user':'qualification', 'autologin':encrypted, 'live':False}, 'Installed login policy differs' -config=configparser.ConfigParser() -config.read('/etc/sddm.conf') -assert config.get('Autologin','User',fallback='') == ('qualification' if encrypted else ''), 'SDDM login user differs' -if encrypted: - assert config.get('Autologin','Session') == 'hyprland-quickshell.desktop', 'SDDM session differs' - assert not config.getboolean('Autologin','Relogin'), 'SDDM permits repeated autologin' -marker=Path('/run/cybexos-login/autologin-used') -if encrypted: - assert marker.exists() and marker.stat().st_uid == 0, 'Autologin marker missing or not root-owned' - assert not marker.stat().st_mode & 0o077, 'Autologin marker permissions are too broad' -plymouth=configparser.ConfigParser() -plymouth.read('/etc/plymouth/plymouthd.conf') -assert plymouth.get('Daemon','Theme') == 'cybex', 'Plymouth theme differs' -contract=json.loads(Path('/usr/share/cybexos/desktop-contract.json').read_text()) -settings=json.loads(Path('/home/qualification/.config/cybexos/shell.json').read_text()) -for key,value in contract['shell'].items(): - assert settings.get(key) == value, f'Desktop default mismatch: {key}' -assert not Path('/home/qualification/.config/cybexos/hypr/user.lua').exists(), 'Unexpected personal Hyprland override' -assert Path('/home/qualification/.local/state/cybexos/offline-apps-seeded').exists(), 'Offline application seed marker missing' -expected_locale=os.environ['EXPECTED_LOCALE'] -expected_timezone=os.environ['EXPECTED_TIMEZONE'] -expected_keyboard=os.environ['EXPECTED_KEYBOARD'] -expected_boot_keymap=os.environ['EXPECTED_BOOT_KEYMAP'] -locale=dict(line.split('=', 1) for line in Path('/etc/locale.conf').read_text().splitlines() - if line.startswith('LANG=')) -assert locale['LANG'].strip('"') == expected_locale, 'Installed locale differs' -# Fedora's UTC aliases can be hardlinks, whose resolved path names differ. -assert Path('/etc/localtime').samefile(Path('/usr/share/zoneinfo') / expected_timezone), 'Installed timezone differs' -console=dict(line.split('=', 1) for line in Path('/etc/vconsole.conf').read_text().splitlines() - if line.startswith('KEYMAP=')) -assert console['KEYMAP'].strip('"') == expected_boot_keymap, 'Installed console keymap differs' -localectl=subprocess.check_output(['localectl', 'status'], text=True, - env={**os.environ, 'LC_ALL': 'C'}) -assert re.search(r'^\s*X11 Layout:\s*' + re.escape(expected_keyboard) + r'\s*$', localectl, re.M), 'Installed X11 keyboard differs' -CHECK -''' - - -DESKTOP_KEYBOARD_AUDIT = r''' -import json -import os -import subprocess -import sys - -expected = sys.argv[1] -manager = subprocess.check_output(['systemctl', '--user', 'show-environment'], text=True) -for line in manager.splitlines(): - if line.startswith(('HYPRLAND_INSTANCE_SIGNATURE=', 'WAYLAND_DISPLAY=')): - key, value = line.split('=', 1) - os.environ[key] = value -assert os.environ.get('HYPRLAND_INSTANCE_SIGNATURE') -result = subprocess.check_output(['hyprctl', '-j', 'getoption', 'input:kb_layout'], text=True) -assert json.loads(result)['str'] == expected, 'Desktop keyboard differs from installed choice' -''' - - -def installed_audit(encrypted, require_secure_sudo, keyboard, boot_keymap, locale, timezone): - return (f'export EXPECTED_ENCRYPTED={str(encrypted).lower()}\n' - f'export REQUIRE_SECURE_SUDO={str(require_secure_sudo).lower()}\n' - f'export EXPECTED_KEYBOARD={keyboard}\n' - f'export EXPECTED_BOOT_KEYMAP={boot_keymap}\n' - f'export EXPECTED_LOCALE={locale}\n' - f'export EXPECTED_TIMEZONE={timezone}\n' + INSTALLED_AUDIT) - - -def verify_installed_audit(vm, encrypted, require_secure_sudo, keyboard, boot_keymap, - locale, timezone, password): - script = installed_audit(encrypted, require_secure_sudo, keyboard, boot_keymap, locale, timezone) - try: - return root_script(vm, script, password) - except subprocess.CalledProcessError as error: - details = '\n'.join(str(part or '') for part in (error.stdout, error.stderr)) - details = details.replace(password, '[redacted]')[-3000:].strip() - raise RuntimeError(f'Installed audit failed (exit {error.returncode}): {details}') from error - - -def qualification_disks(vm): - output = run([*vm.ssh, 'lsblk -dn -o NAME,SERIAL'], text=True, capture_output=True, timeout=15).stdout - devices = {} - for line in output.splitlines(): - parts = line.split() - if len(parts) == 2 and parts[1] in (QUALIFICATION_DISK_SERIAL, QUALIFICATION_UNUSED_SERIAL): - if parts[1] in devices: - raise RuntimeError('Duplicate qualification disk serial in guest') - devices[parts[1]] = parts[0] - if set(devices) != {QUALIFICATION_DISK_SERIAL, QUALIFICATION_UNUSED_SERIAL}: - raise RuntimeError('Both serial-identified disposable disks must be visible in the guest') - return devices[QUALIFICATION_DISK_SERIAL], devices[QUALIFICATION_UNUSED_SERIAL] - - -def focus_installed_desktop(vm, password, autologin): - """Return from the verified text console to the graphical session.""" - from login_qualification import wait_login_state - # On subsequent boots SSH can become ready before SDDM creates its VT. - wait_login_state(vm, desktop=autologin) - script = '''python3 - <<'PY' -import subprocess -for line in subprocess.check_output(['loginctl', 'list-sessions', '--no-legend', '--no-pager'], text=True).splitlines(): - identifier = line.split()[0] - values = dict(item.split('=', 1) for item in subprocess.check_output( - ['loginctl', 'show-session', identifier, '-p', 'Class', '-p', 'Name', '-p', 'Type', '-p', 'VTNr'], text=True).splitlines()) - if ((values.get('Class') == 'greeter' and not AUTOLOGIN) or - (values.get('Class') == 'user' and values.get('Name') == 'qualification' and - values.get('Type') == 'wayland' and AUTOLOGIN)) and values.get('VTNr', '').isdigit(): - print(values['VTNr']) - raise SystemExit(0) -raise SystemExit(1) -PY -''' - script = script.replace('import subprocess\n', f'import subprocess\nAUTOLOGIN = {autologin!r}\n', 1) - vt = int(run([*vm.ssh, 'bash -s'], input=script, text=True, capture_output=True, - timeout=20).stdout.strip()) - if not 1 <= vt <= 6: - raise RuntimeError('Installed greeter was not on an expected virtual terminal') - vm.keypress(f'ctrl+alt+f{vt}') - if autologin: - vm.wait_desktop() - wait_login_state(vm, desktop=True) - return - time.sleep(2) - vm.type(password + '\n') - vm.wait_desktop() - wait_login_state(vm, desktop=True) - - -def boot_installed(vm, password, encrypted, *, autologin=None): - if autologin is None: - autologin = encrypted - vm.start(user='qualification') - if encrypted: - vm.unlock_disk(password) - try: - vm.wait_ssh(timeout=12, setup=False, redactions=(password,)) - except RuntimeError: - vm.bootstrap_installed_ssh(password) - focus_installed_desktop(vm, password, autologin) - - -def poweroff_installed(vm, password): - poweroff_guest(vm, password, root_script) - - -def main(): - # Register here as well as direct module execution: image/qualify imports - # this function, so module __main__ hooks alone do not protect cleanup. - for name in (signal.SIGTERM, signal.SIGHUP): - signal.signal(name, interrupt) - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('iso', type=Path) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--firmware', choices=('uefi', 'bios'), default='uefi') - parser.add_argument('--scenario', choices=tuple(SCENARIOS), default='encrypted-us') - parser.add_argument('--candidate-rpm', type=Path, help='Newer desktop RPM for an offline N to N+1 upgrade') - parser.add_argument('--candidate-offline-rpm', type=Path, help='Companion offline applications RPM for the same upgrade') - parser.add_argument('--candidate-dependencies', type=Path, - help='Manifest-bound third-party RPM directory for the single offline upgrade transaction') - parser.add_argument('--recovery-check', action='store_true', help='Boot and restore the pre-upgrade recovery point') - parser.add_argument('--legacy-installer', action='store_true', help='Permit baseline installer without new policy/rescan controls') - parser.add_argument('--execute-vm', action='store_true') - parser.add_argument('--erase-disposable-disk', action='store_true', help='Explicitly allow installation onto the new task-owned virtual disk') - parser.add_argument('--keep-artifacts', action='store_true', help='Retain task-owned disk/logs for unresolved diagnostics') - parser.add_argument('--install-timeout', type=int, default=1800) - parser.add_argument('--capture-outcomes', action='store_true', help='Capture fresh and saved-choice outcomes for same-source checkout comparison') - parser.add_argument('--boot-entry', choices=('install', 'live'), default='install', - help='ISO menu entry: the default installation-only session, or the live desktop') - args = parser.parse_args() - if not args.execute_vm or not args.erase_disposable_disk: - parser.error('qualification requires --execute-vm --erase-disposable-disk; no VM or installer starts without both') - if args.candidate_rpm and not (args.recovery_check and args.candidate_offline_rpm): - parser.error('Candidate qualification requires --recovery-check and both candidate RPMs') - if args.candidate_rpm and args.scenario != 'encrypted-us': - parser.error('Candidate/public updater qualification reuses the encrypted-us guest') - if args.recovery_check and not args.candidate_rpm: - parser.error('--recovery-check requires --candidate-rpm') - if args.candidate_offline_rpm and not args.candidate_rpm: - parser.error('--candidate-offline-rpm requires --candidate-rpm') - if args.candidate_dependencies and not (args.candidate_rpm and args.candidate_offline_rpm): - parser.error('--candidate-dependencies requires both --candidate-rpm and --candidate-offline-rpm') - if args.candidate_offline_rpm and (args.candidate_offline_rpm.is_symlink() or not args.candidate_offline_rpm.is_file() - or args.candidate_offline_rpm.suffix != '.rpm'): - parser.error('--candidate-offline-rpm must be a regular .rpm file') - if args.capture_outcomes and (args.candidate_rpm or args.legacy_installer): - parser.error('Outcome parity capture requires the fresh candidate ISO') - iso = require_test_iso(args.iso) - if args.candidate_rpm and (args.candidate_rpm.is_symlink() or not args.candidate_rpm.is_file() - or args.candidate_rpm.suffix != '.rpm'): - parser.error('--candidate-rpm must be a regular .rpm file') - dependencies = None - if args.candidate_dependencies: - try: - dependencies = load_bundle(args.candidate_dependencies, baseline_iso=iso, - candidate_rpm=args.candidate_rpm, candidate_offline_rpm=args.candidate_offline_rpm) - except (ValueError, OSError) as error: - parser.error(str(error)) - # Direct invocations need the same early host check as release-gate. - # Missing browser/Node dependencies must not consume a live boot or disk. - browser_dependencies() - encrypted, keyboard, locale, timezone = SCENARIOS[args.scenario] - vm = TestVM(args.output, args.firmware, guard_disk=True) - # Hex uses the same physical keys under US, NL and DE layouts, including - # the early boot unlock and text-console bootstrap paths. - password = secrets.token_hex(24) - report = {'evidence_schema': SCHEMA, 'execution_mode': 'qemu', - 'qualification_kind': 'upgrade' if args.candidate_rpm else 'fresh', - 'legacy_installer': args.legacy_installer, 'iso_sha256': digest(iso), - 'candidate_rpm_sha256': digest(args.candidate_rpm) if args.candidate_rpm else None, - 'candidate_offline_rpm_sha256': digest(args.candidate_offline_rpm) if args.candidate_offline_rpm else None, - 'boot_entry': args.boot_entry, 'scenario': args.scenario, 'firmware': args.firmware, 'status': 'failed', 'checks': [], - 'network': 'outbound-blocked', 'bootstrap': 'recognized disk-unlock prompt; bounded graphical SSH setup', 'scope': 'QEMU fixture; does not qualify physical hardware or Secure Boot'} - if dependencies: - report['candidate_dependencies'] = dependencies.evidence() - try: - vm.prepare() - untouched_sha = digest(vm.unused_disk) - vm.start(iso) - vm.expect_shell = args.boot_entry == 'live' - vm.select_boot_entry(args.boot_entry) - vm.wait_ssh() - if args.boot_entry == 'install' and not args.legacy_installer: - run([*vm.ssh, 'bash -s'], input=INSTALL_MODE_AUDIT, text=True, timeout=45) - report['checks'].append('installation-only-session') - if args.legacy_installer: - # The retained N image deliberately starts an installer-only - # session target in live mode. Activate its full desktop target - # only in this disposable older guest for the shell/application - # audit. New images must pass normal startup without this branch. - live_shell = subprocess.run( - [*vm.ssh, 'systemctl --user is-active --quiet quickshell.service'], - capture_output=True, timeout=15) - if live_shell.returncode: - installer_only = subprocess.run( - [*vm.ssh, 'test -e /run/cybexos-install-mode && ' - 'systemctl --user is-active --quiet cybexos-install-session.target'], - capture_output=True, timeout=15) - if installer_only.returncode: - raise RuntimeError('Legacy guest shell is inactive outside the expected installer-only session') - run([*vm.ssh, 'systemctl --user start hyprland-session.target'], timeout=30) - report['legacy_session_bootstrap'] = 'activated full desktop target from baseline installer-only session' - report['checks'].append('legacy-installer-only-session-desktop-bootstrap') - if args.boot_entry == 'live' or args.legacy_installer: - vm.expect_shell = True - vm.audit() - report['checks'].append('live-boot-and-offline-applications') - target_disk, unused_disk = qualification_disks(vm) - if args.boot_entry == 'live' or args.legacy_installer: - ensure_installer_started(vm) - browser_result = qualify_browser(vm, password=password, target_disk=target_disk, unused_disk=unused_disk, - encrypted=encrypted, keyboard=keyboard, locale=locale, timezone=timezone, - install_timeout=args.install_timeout, require_policy_controls=not args.legacy_installer) - report['checks'].append('graphical-installer') - report['checks'].append('encrypted-installation' if encrypted else 'plain-installation') - # Audit live state before a graceful shutdown; target mounts are left - # to Anaconda/systemd. Do not assume /mnt/sysroot survives completion. - if args.boot_entry == 'install' and not args.legacy_installer: - run([*vm.ssh, 'bash -s'], input=INSTALL_MODE_AUDIT, text=True, timeout=45) - else: - vm.audit(applications=False) - vm.stop(graceful=True) - if digest(vm.unused_disk) != untouched_sha: - raise RuntimeError('The unused disposable guard disk changed during installation') - report['checks'].append('unused-disk-unchanged') - (vm.work / 'known_hosts').unlink(missing_ok=True) - vm.expect_shell = True - boot_installed(vm, password, encrypted) # No ISO/CD-ROM attached. - vm.audit() - if not args.candidate_rpm: - build = json.loads(root_script(vm, 'cat /usr/share/cybexos/build.json\n', password).stdout) - report.update({key: build[key] for key in ('source_revision', 'source_content_sha256')}) - verify_installed_audit(vm, encrypted, not args.legacy_installer, - keyboard, browser_result['boot_keyboard'], locale, timezone, password) - run([*vm.ssh, f'python3 - {keyboard}'], input=DESKTOP_KEYBOARD_AUDIT, - text=True, capture_output=True, timeout=20) - report['checks'] += ['installed-boot-without-iso', 'encrypted-btrfs' if encrypted else 'plain-btrfs', - 'autologin' if encrypted else 'password-login', 'desktop-parity', - 'selected-locale-timezone-keyboard', 'live-cleanup', 'selinux-enforcing'] - if not args.legacy_installer: - sudo = subprocess.run([*vm.ssh, 'sudo -k -n true'], capture_output=True, timeout=15) - if sudo.returncode == 0: - raise RuntimeError('Fresh installation unexpectedly allows passwordless sudo') - report['checks'].append('sudo-requires-password') - if encrypted and not args.candidate_rpm: - qualify_login(vm, password, root_script, report, secrets.token_urlsafe(32)) - if args.candidate_rpm: - preference = prepare_user_choices(vm, password, root_script) - point = create_recovery_point(vm, password, root_script) if args.recovery_check and not dependencies else None - options = {'candidate_dependencies': dependencies} if dependencies else {} - sha, versions = upgrade(vm, args.candidate_rpm, password, root_script, - offline_candidate=args.candidate_offline_rpm, **options) - if sha != report['candidate_rpm_sha256']: - raise RuntimeError('Candidate RPM checksum changed during qualification') - if args.candidate_offline_rpm and versions['candidate_offline_rpm_sha256'] != report['candidate_offline_rpm_sha256']: - raise RuntimeError('Candidate offline RPM checksum changed during qualification') - if dependencies: - point = versions['recovery_point'] - report['candidate_dependencies']['guest_signatures_verified'] = True - report['checks'].append('offline-dependency-signatures-and-single-transaction') - report['pre_upgrade_recovery_point'] = point - report['checks'].append('installed-rpm-upgrade') - verify_user_choices(vm, password, root_script, preference) - if point and args.recovery_check: - select_recovery_boot(vm, point, password, root_script) - poweroff_installed(vm, password) - # The recovery overlay cannot prove encrypted root ancestry, - # so login policy requires a password even after LUKS unlock. - boot_installed(vm, password, encrypted, autologin=False) - verify_recovery_boot(vm, point, password, root_script) - poweroff_installed(vm, password) - boot_installed(vm, password, encrypted) - verify_restored(vm, versions['installed'], password, root_script) - verify_user_choices(vm, password, root_script, preference) - report['checks'].append('recovery-boot-restore') - qualify_public_updater(vm, args.candidate_rpm, args.candidate_offline_rpm, - password, root_script, candidate_dependencies=dependencies, - boot_installed=boot_installed, poweroff_installed=poweroff_installed, - verify_user_choices=verify_user_choices, preference=preference, - report=report) - if args.capture_outcomes: - desktop_lifecycle(vm) - fresh = capture_guest(vm, root_script, password, installation='iso', scenario=args.scenario, - profile='fresh', manifest='/usr/share/cybexos/applications.json', - provenance='/usr/share/cybexos/build.json') - atomic_json(args.output / 'outcomes-fresh.json', fresh) - prepare_saved_choices(vm, password, root_script) - reapply_saved_policy(vm, password) - poweroff_installed(vm, password) - boot_installed(vm, password, encrypted) - verify_saved_choices(vm, password, root_script) - desktop_lifecycle(vm) - saved = capture_guest(vm, root_script, password, installation='iso', scenario=args.scenario, - profile='saved', manifest='/usr/share/cybexos/applications.json', - provenance='/usr/share/cybexos/build.json') - atomic_json(args.output / 'outcomes-saved.json', saved) - report['source_revision'] = fresh['source_revision'] - report['source_content_sha256'] = fresh['source_content_sha256'] - report['checks'] += ['installed-outcomes-captured', 'saved-choice-reconfiguration', 'native-settings-lifecycle'] - # Clear the temporary test access before stopping the disposable disk. - vm.audit(applications=False) - poweroff_guest(vm, password, root_script, timeout=60, cleanup_script=( - 'rm -f /home/qualification/.ssh/authorized_keys /home/qualification/.bash_history\n' - 'systemctl disable sshd.service\n')) - if digest(vm.unused_disk) != untouched_sha: - raise RuntimeError('The unused disposable guard disk changed after installed boot') - report['status'] = 'passed' - validate_qualification(report) - except BaseException as error: - report['status'] = 'failed' - report['error'] = str(error) or type(error).__name__ - raise - finally: - if vm.owned: - failed_before_cleanup = sys.exc_info()[0] is not None - try: - vm.cleanup(args.keep_artifacts) - except BaseException as cleanup_error: - report['status'] = 'failed' - report['cleanup_error'] = str(cleanup_error) or type(cleanup_error).__name__ - if not failed_before_cleanup: - raise - finally: - atomic_json(args.output / 'qualification.json', report) - print(f"Qualification passed: {args.output / 'qualification.json'}") - - -def interrupt(_signum, _frame): - raise KeyboardInterrupt - - -if __name__ == '__main__': - main() diff --git a/image/qualification_contract.py b/image/qualification_contract.py deleted file mode 100644 index 4a97b357..00000000 --- a/image/qualification_contract.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Versioned completed-evidence contract for QEMU producers and release admission. - -This is an evidence inventory, not a substitute for executing the guest checks. -Artifact SHA-256 identities and same-source checkout parity remain mandatory. -""" -import re - -SCHEMA = 1 -FRESH_SCENARIOS = frozenset(('encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl')) -BASE = frozenset(('graphical-installer', 'unused-disk-unchanged', 'installed-boot-without-iso', - 'desktop-parity', 'selected-locale-timezone-keyboard', 'live-cleanup', - 'selinux-enforcing')) -LOGIN = frozenset(( - 'initial-encrypted-keyring-unlocked-without-prompt', - 'synthetic-keyring-secret-survives-cold-reboot', - 'missing-cached-password-preserves-locked-encrypted-keyring', - 'fallback-password-login-unlocks-existing-keyring', - 'first-autologin-launch-failure-consumes-boot-attempt', - 'failed-first-autologin-manager-restart-requires-authentication', - 'failed-first-autologin-password-login-recovers-desktop', - *(action + suffix for action in ('logout', 'compositor-crash', 'manager-restart') - for suffix in ('-requires-authentication', '-password-recovery-and-keyring')), -)) -PUBLIC_UPDATE = frozenset(( - 'public-updater-committed', 'public-updater-interrupted-after-apply', - 'public-updater-pre-login-recovery', 'public-updater-second-boot-settled', - 'public-updater-root-package-vendor-restored', 'public-updater-personal-data-preserved', - 'public-updater-no-surviving-workers', - 'provisioning-cgroup-timeout-drains-detached-workers', -)) - - -def required_checks(scenario, kind, boot_entry='install'): - if not isinstance(scenario, str) or not isinstance(kind, str): - raise ValueError('Qualification scenario and kind must be strings') - if scenario not in FRESH_SCENARIOS | {'encrypted-de', 'plain-de'} or kind not in ('fresh', 'upgrade'): - raise ValueError('Unknown qualification scenario or kind') - encrypted = scenario.startswith('encrypted-') - result = BASE | ({'encrypted-installation', 'encrypted-btrfs', 'autologin'} if encrypted else - {'plain-installation', 'plain-btrfs', 'password-login'}) - if kind == 'upgrade': - if scenario != 'encrypted-us': - raise ValueError('Public updater qualification requires the existing encrypted-us guest') - return result | {'installed-rpm-upgrade', 'recovery-boot-restore'} | PUBLIC_UPDATE - session = 'installation-only-session' if boot_entry == 'install' else 'live-boot-and-offline-applications' - return result | {session, 'sudo-requires-password'} | (LOGIN if encrypted else set()) - - -def validate(report): - if (type(report.get('evidence_schema')) is not int or report['evidence_schema'] != SCHEMA - or report.get('execution_mode') != 'qemu' - or report.get('network') != 'outbound-blocked' - or report.get('firmware') not in ('uefi', 'bios') - or report.get('boot_entry') not in ('install', 'live') - or type(report.get('legacy_installer')) is not bool): - raise ValueError('Unsupported qualification evidence schema or execution mode') - kind = report.get('qualification_kind') - if kind == 'fresh' and (report.get('legacy_installer') is not False - or report.get('candidate_rpm_sha256') is not None): - raise ValueError('Fresh qualification must execute the current default installer') - if kind == 'upgrade' and not re.fullmatch(r'[0-9a-f]{64}', str(report.get('candidate_rpm_sha256', ''))): - raise ValueError('Upgrade qualification must bind the exact candidate RPM') - for key, pattern in (('iso_sha256', r'[0-9a-f]{64}'), ('source_revision', r'[0-9a-f]{40,64}'), - ('source_content_sha256', r'[0-9a-f]{64}')): - if not isinstance(report.get(key), str) or not re.fullmatch(pattern, report[key]): - raise ValueError('Qualification must identify the exact artifact/source digest: ' + key) - checks = report.get('checks') - if (not isinstance(checks, list) or not all(isinstance(item, str) for item in checks) - or len(checks) != len(set(checks))): - raise ValueError('Qualification checks must be distinct completed check names') - missing = required_checks(report.get('scenario'), kind, report['boot_entry']) - set(checks) - if missing: - raise ValueError('Qualification is missing required scenario evidence: ' + ', '.join(sorted(missing))) - if report.get('status') != 'passed': - raise ValueError('Every qualification report must have passed') diff --git a/image/qualify b/image/qualify deleted file mode 100755 index 95bffe09..00000000 --- a/image/qualify +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python3 -from qualification import main - -if __name__ == '__main__': - main() diff --git a/image/qualify-checkout b/image/qualify-checkout deleted file mode 100755 index a3dfcc0c..00000000 --- a/image/qualify-checkout +++ /dev/null @@ -1,271 +0,0 @@ -#!/usr/bin/env python3 -"""Install the exact ISO source on pinned Fedora, boot its desktop, compare outcomes.""" -import argparse -import json -import os -from pathlib import Path -import secrets -import shlex -import shutil -import signal -import subprocess -import tarfile - -from build_support import atomic_json, create_seed, digest, checksum_entries -from download_cache import cached_download -from installed_outcomes import capture_guest, compare, read_json -from parity_qualification import desktop_lifecycle, prepare_saved_choices, verify_saved_choices -from qualification import SCENARIOS, boot_installed, poweroff_installed, root_script -from source_snapshot import archive_identity -from vm_testing import TestVM, run - -ROOT = Path(__file__).resolve().parents[1] -IMAGE = 'Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2' -IMAGE_URL = 'https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/' + IMAGE -IMAGE_SHA256 = '28680fe5b371a5a82ebf43a31926e086a168e59949d03969c5093e7071f90b7f' -SOURCE = '/home/qualification/cybexos-source' - - -def verify_inputs(artifacts, iso_results, scenario): - checksum_entries(artifacts) - archive = artifacts / 'source.tar.gz' - identity = archive_identity(archive) - provenance = read_json(artifacts / 'build-provenance.json') - if any(provenance.get(key) != value for key, value in identity.items()): - raise ValueError('Source archive does not match the candidate ISO provenance') - with tarfile.open(archive, 'r:gz') as stream: - embedded = json.load(stream.extractfile('image/build-provenance.json')) - if embedded != provenance: - raise ValueError('Source archive embeds different build provenance') - qualification = read_json(iso_results / 'qualification.json') - isos = list(artifacts.glob('*.iso')) - if (len(isos) != 1 or qualification.get('status') != 'passed' - or qualification.get('scenario') != scenario or qualification.get('iso_sha256') != digest(isos[0])): - raise ValueError('Checkout parity requires the exact candidate ISO qualification') - for profile in ('fresh', 'saved'): - result = read_json(iso_results / ('outcomes-' + profile + '.json')) - if (result.get('source_content_sha256') != identity['source_content_sha256'] or - result.get('source_revision') != provenance.get('source_revision') or - result.get('scenario') != scenario or result.get('profile') != profile or result.get('installation') != 'iso'): - raise ValueError('ISO outcomes and checkout source are not from the same build') - return archive, provenance - - -def cloud_seed(vm, password): - # Only a salted password hash is placed in cloud-init; the plaintext stays - # in memory/stdin and never appears in argv, reports, logs or screenshots. - encoded = run(['openssl', 'passwd', '-6', '-stdin'], input=password + '\n', text=True, capture_output=True).stdout.strip() - config = {'users': [{'name': 'qualification', 'groups': ['wheel'], 'shell': '/bin/bash', - 'sudo': 'ALL=(ALL) NOPASSWD:ALL', 'lock_passwd': False, 'passwd': encoded, - 'ssh_authorized_keys': [vm.key.with_suffix('.pub').read_text().strip()]}], - 'ssh_pwauth': False, 'disable_root': True, 'preserve_hostname': True, - 'runcmd': [['hostnamectl', 'set-hostname', 'cybexos-qualification'], - ['loginctl', 'enable-linger', 'qualification'], - ['systemctl', 'enable', '--now', 'sshd.service']]} - (vm.work / 'user-data').write_text('#cloud-config\n' + json.dumps(config)) - (vm.work / 'meta-data').write_text('instance-id: cybexos-parity-' + secrets.token_hex(8) + '\n') - create_seed(vm.work, 'cloud-localds') - vm.seed = vm.work / 'seed.iso' - - -PREPARE_CLOUD_BASELINE = r'''set -euo pipefail -# Compare current Fedora against the ISO's current Fedora baseline. The pinned -# Cloud image predates the candidate by months. This is OS fixture preparation, -# not a project package or application seed import. -dnf upgrade -y --refresh --setopt=install_weak_deps=False --setopt=installonly_limit=2 -dnf install -y --setopt=install_weak_deps=False ansible-core git python3-pyyaml firewalld glibc-all-langpacks -# Cloud's release package pins an older common/identity package set even after -# upgrade. Use the ISO's generic Fedora identity in this disposable OS only. -dnf swap -y --setopt=install_weak_deps=False fedora-release-identity-cloud fedora-release-identity-basic -dnf swap -y --setopt=install_weak_deps=False fedora-release-cloud fedora-release -# Swap can select the release-repository build. Upgrade the new identity too; -# updating only the old Cloud identity before swapping is insufficient. -dnf upgrade -y --refresh --best --setopt=install_weak_deps=False fedora-release fedora-release-common fedora-release-identity-basic -# These belong only to the disposable Cloud bootstrap, not a desktop product. -# Keep all removal inside this explicitly created qualification guest. -systemctl is-active --quiet NetworkManager.service -bootstrap_packages=() -for package in cloud-init cloud-utils-growpart qemu-guest-agent \ - console-login-helper-messages console-login-helper-messages-issuegen \ - console-login-helper-messages-motdgen console-login-helper-messages-profile \ - rpm-plugin-audit rpm-plugin-systemd-inhibit systemd-networkd \ - glibc-minimal-langpack glibc-langpack-en glibc-langpack-nl \ - composefs criu-libs criu dhcpcd fuse-overlayfs gawk-all-langpacks \ - grub2-efi-ia32-modules grub2-efi-x64-modules initscripts-service kpartx \ - libbsd libmd libnet libretls memstrack mkpasswd nano-default-editor netcat \ - pkcs11-provider python-unversioned-command python3-configobj \ - python3-dnf-plugin-tracer python3-dnf-plugins-extras-common python3-html2text \ - python3-jsonpatch python3-jsonpointer python3-oauthlib python3-pysocks \ - python3-tracer python3-unbound python3-urllib3+socks \ - qemu-user-static qemu-user-static-aarch64 qemu-user-static-alpha \ - qemu-user-static-arm qemu-user-static-hexagon qemu-user-static-hppa \ - qemu-user-static-loongarch64 qemu-user-static-m68k qemu-user-static-microblaze \ - qemu-user-static-mips qemu-user-static-or1k qemu-user-static-ppc \ - qemu-user-static-riscv qemu-user-static-s390x qemu-user-static-sh4 \ - qemu-user-static-sparc qemu-user-static-x86 qemu-user-static-xtensa \ - sudo-python-plugin tpm2-tools tpm2-tss-fapi tracer-common unbound-anchor wcurl; do - if rpm -q "$package" >/dev/null 2>&1; then bootstrap_packages+=("$package"); fi -done -if (( ${#bootstrap_packages[@]} )); then - dnf remove -y --no-autoremove "${bootstrap_packages[@]}" -fi -''' - -FINALIZE_CLOUD_BASELINE = r'''set -euo pipefail -# Only this newly created test guest is pruned. Rebooting first means DNF can -# retain the latest/running kernel without protecting the old Cloud kernel. -dnf remove -y --oldinstallonly --limit=1 --no-autoremove -python3 - <<'BASELINE_PY' -import json, subprocess -kernel = subprocess.check_output(['uname', '-r'], text=True).strip() -installed = subprocess.check_output(['rpm', '-q', 'kernel-core', '--qf', '%{VERSION}-%{RELEASE}.%{ARCH}\n'], text=True).splitlines() -if installed != [kernel]: - raise RuntimeError('Cloud fixture did not boot and retain exactly the current kernel') -print(json.dumps({'running_kernel': kernel, 'installed_kernel_core': installed, - 'installed_packages': sorted(subprocess.check_output(['rpm', '-qa', '--qf', '%{NAME}.%{ARCH}=%{EVR}\n'], text=True).splitlines()), - 'scope': 'Disposable Fedora OS preparation before the public CybexOS installer; no product payload imported'})) -BASELINE_PY -''' - - -def prepare_cloud_baseline(vm, password): - """Update only a freshly owned Cloud fixture, then boot its current kernel.""" - if not vm.owned or vm.network_restricted: - raise ValueError('Cloud preparation requires an owned, online disposable VM') - vm.expect_shell = False - try: - root_script(vm, PREPARE_CLOUD_BASELINE, password, timeout=1800) - poweroff_installed(vm, password) - vm.seed = None - vm.start(user='qualification') - vm.wait_ssh(timeout=600, setup=False, redactions=(password,)) - result = root_script(vm, FINALIZE_CLOUD_BASELINE, password, timeout=600) - except subprocess.CalledProcessError as error: - detail = ((error.stdout or '') + (error.stderr or '')).replace(password, '[redacted]')[-6000:] - raise RuntimeError('Disposable Fedora baseline preparation failed: ' + detail) from error - return json.loads(result.stdout.splitlines()[-1]) - - -def install_script(scenario, *, repeat=False): - encrypted, keyboard, locale, timezone = SCENARIOS[scenario] - if encrypted: - raise ValueError('Pinned cloud parity scenarios are plaintext; encrypted ISO qualification remains separate') - initial = '' if repeat else f''' -localectl set-locale LANG={shlex.quote(locale)} -timedatectl set-timezone {shlex.quote(timezone)} -localectl set-x11-keymap {shlex.quote(keyboard)} -''' - return f'''set -euo pipefail -export ANSIBLE_FORCE_COLOR=0 PYTHONDONTWRITEBYTECODE=1 -export SUDO_USER=qualification -{initial} -export LANG={shlex.quote(locale)} -cd {SOURCE} -# Keep the established transport alive through strict firewall convergence; -# the new runtime SSH exception is test-only and disappears on reboot. -cleanup() {{ firewall-cmd --zone=cybexos --add-service=ssh >/dev/null 2>&1 || true; }} -trap cleanup EXIT -./install --non-interactive -rm -f /etc/sudoers.d/90-cloud-init-users -test "$(systemctl get-default)" = graphical.target -systemctl is-enabled --quiet sddm.service -cleanup -trap - EXIT -''' - - -def install(vm, scenario, password, *, repeat=False): - # Use the public installer's supported sudo invocation. SUDO_USER selects - # the account; install derives its actual home through getent, not root's. - try: - root_script(vm, install_script(scenario, repeat=repeat), password, timeout=7200) - except subprocess.CalledProcessError as error: - detail = ((error.stdout or '') + (error.stderr or '')).replace(password, '[redacted]')[-6000:] - raise RuntimeError('Full checkout installation failed: ' + detail) from error - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--artifacts', required=True, type=Path) - parser.add_argument('--iso-results', required=True, type=Path) - parser.add_argument('--scenario', choices=('plain-us', 'plain-nl'), required=True) - parser.add_argument('--output', required=True, type=Path) - parser.add_argument('--cache', type=Path, default=Path(os.environ.get('XDG_CACHE_HOME', str(Path.home() / '.cache'))) / 'cybexos/image/base') - parser.add_argument('--execute-vm', action='store_true') - parser.add_argument('--keep-artifacts', action='store_true') - args = parser.parse_args() - if not args.execute_vm: - parser.error('--execute-vm explicitly authorizes new disposable VM disks and a complete guest installation') - for signal_number in (signal.SIGTERM, signal.SIGHUP): - signal.signal(signal_number, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) - for executable in ('cloud-localds', 'openssl'): - if not shutil.which(executable): - raise RuntimeError('Missing checkout qualification prerequisite: ' + executable) - archive, provenance = verify_inputs(args.artifacts, args.iso_results, args.scenario) - base = cached_download(IMAGE_URL, IMAGE_SHA256, args.cache) - vm = TestVM(args.output, network_restricted=False) - password = secrets.token_hex(24) - report = {'status': 'failed', 'scenario': args.scenario, 'source_revision': provenance['source_revision'], - 'source_content_sha256': provenance['source_content_sha256'], 'source_archive_sha256': digest(archive), - 'fedora_cloud_sha256': IMAGE_SHA256, 'checks': [], - 'scope': 'Full checkout installation and graphical session on disposable QEMU Fedora Cloud; no physical hardware qualification'} - try: - vm.prepare() - vm.disk.unlink() # Only the disk just created by this owned TestVM. - run(['qemu-img', 'create', '-q', '-f', 'qcow2', '-F', 'qcow2', '-b', str(base), str(vm.disk), '100G']) - cloud_seed(vm, password) - vm.start(user='qualification') - vm.wait_ssh(timeout=600, setup=False, redactions=(password,)) - run([*vm.ssh, 'cloud-init status --wait --format=json'], text=True, capture_output=True, timeout=600) - report['fedora_baseline'] = prepare_cloud_baseline(vm, password) - run([*vm.ssh, f'mkdir -p {SOURCE}'], timeout=20) - with archive.open('rb') as source: - run([*vm.ssh, f'tar xzf - -C {SOURCE}'], stdin=source, capture_output=True, timeout=120) - with (args.artifacts / 'applications.json').open('rb') as manifest: - run([*vm.ssh, 'cat > /home/qualification/cybexos-applications.json'], stdin=manifest, timeout=30) - install(vm, args.scenario, password) - report['checks'].append('full-checkout-installation') - # The first shutdown happens before a graphical session is necessarily - # ready; use the authenticated poweroff helper, not TestVM.stop's audit. - poweroff_installed(vm, password) - vm.seed = None - boot_installed(vm, password, False) - vm.expect_shell = True - for profile in ('fresh', 'saved'): - if profile == 'saved': - prepare_saved_choices(vm, password, root_script) - install(vm, args.scenario, password, repeat=True) - poweroff_installed(vm, password) - boot_installed(vm, password, False) - verify_saved_choices(vm, password, root_script) - vm.audit(applications=False) - desktop_lifecycle(vm) - result = capture_guest(vm, root_script, password, installation='checkout', scenario=args.scenario, - profile=profile, manifest='/home/qualification/cybexos-applications.json', - provenance=SOURCE + '/image/build-provenance.json') - atomic_json(args.output / ('outcomes-' + profile + '.json'), result) - comparison = compare(read_json(args.iso_results / ('outcomes-' + profile + '.json')), result, - read_json(ROOT / 'image/parity-exceptions.json')) - atomic_json(args.output / ('parity-' + profile + '.json'), comparison) - if comparison['status'] != 'passed': - raise RuntimeError('Installed outcome mismatch; inspect parity-' + profile + '.json. No differences were auto-approved.') - report['checks'].append('installed-parity-' + profile) - report['checks'] += ['same-source-content', 'native-settings-lifecycle', 'saved-choice-reconfiguration', 'full-graphical-session'] - poweroff_installed(vm, password) - report['status'] = 'passed' - except BaseException as error: - report['error'] = (str(error) or type(error).__name__).replace(password, '[redacted]') - raise - finally: - if vm.owned: - try: - vm.cleanup(args.keep_artifacts) - finally: - for name in ('user-data', 'meta-data', 'seed.iso'): - (vm.work / name).unlink(missing_ok=True) - atomic_json(args.output / 'checkout-qualification.json', report) - print(json.dumps(report)) - - -if __name__ == '__main__': - main() diff --git a/image/real_browser_qualification.cjs b/image/real_browser_qualification.cjs deleted file mode 100644 index 31c6f7d7..00000000 --- a/image/real_browser_qualification.cjs +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env node -// Drives the guest's real Cockpit/Anaconda page through an SSH tunnel. -// Fixture credentials arrive on stdin and are never printed or saved. -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -let secret = ""; - -async function waitForInitialSetup(page, timeoutMs = 120000, - retryDelays = [1000, 2000, 4000, 8000, 16000, 32000, 32000]) { - const deadline = Date.now() + timeoutMs; - let retries = 0; - while (true) { - const remaining = deadline - Date.now(); - if (remaining <= 0) throw new Error("Installer setup did not become ready before its deadline"); - await page.waitForFunction(() => { - const setup = document.querySelector("#setup"); - if (setup && !setup.hidden) return true; - const retry = document.querySelector("#startup-retry"); - const error = document.querySelector("#error"); - return setup?.hidden && retry && !retry.hidden && error && !error.hidden && - error.textContent.trim() === "Another installer operation is still running."; - }, null, {timeout: remaining}); - if (await page.locator("#setup").isVisible()) return; - if (retries >= retryDelays.length) throw new Error("Installer setup remained busy after bounded initialization retries"); - await page.waitForTimeout(Math.min(retryDelays[retries], Math.max(0, deadline - Date.now()))); - if (await page.locator("#setup").isVisible()) return; - const error = await page.locator("#error").textContent(); - if (error?.trim() !== "Another installer operation is still running." || - !await page.locator("#startup-retry").isVisible()) continue; - const clickTimeout = deadline - Date.now(); - if (clickTimeout <= 0) throw new Error("Installer setup did not become ready before its deadline"); - await page.locator("#retry").click({timeout: clickTimeout}); - retries++; - } -} - -async function selectKeyboard(page, keyboard, timeoutMs = 120000, - retryDelays = [1000, 2000, 4000, 8000, 16000, 32000, 32000]) { - const deadline = Date.now() + timeoutMs; - let retries = 0; - while (true) { - let remaining = deadline - Date.now(); - if (remaining <= 0) throw new Error("Installer keyboard selection exceeded its deadline"); - await page.selectOption("#keyboard", keyboard, {timeout: remaining}); - remaining = deadline - Date.now(); - if (remaining <= 0) throw new Error("Installer keyboard selection exceeded its deadline"); - await page.waitForFunction(expected => { - const error = document.querySelector("#error"); - return (document.querySelector("#keyboard").value === expected && - !document.querySelector("#password").disabled) || - (error && !error.hidden && error.textContent.trim() !== ""); - }, keyboard, {timeout: remaining}); - if (await page.inputValue("#keyboard") === keyboard && - !await page.locator("#password").isDisabled()) return; - const error = (await page.locator("#error").textContent())?.trim(); - if (error !== "Another installer operation is still running.") - throw new Error(`Installer keyboard selection failed: ${error || "unknown error"}`); - if (retries >= retryDelays.length) - throw new Error("Installer keyboard selection remained busy after bounded retries"); - await page.waitForTimeout(Math.min(retryDelays[retries], Math.max(0, deadline - Date.now()))); - retries++; - } -} - -async function main() { - const {chromium} = require("playwright-core"); - const input = JSON.parse(fs.readFileSync(0, "utf8")); - secret = input.password; - const url = new URL(input.url); - assert.equal(url.hostname, "127.0.0.1"); - assert.equal(url.pathname, "/cockpit/@localhost/cybexos-installer/index.html"); - assert.match(input.target_disk, /^[A-Za-z0-9_-]+$/); - assert.match(input.target_serial, /^CYBEXOS-QUALIFY$/); - assert.match(input.unused_disk, /^[A-Za-z0-9_-]+$/); - assert.notEqual(input.target_disk, input.unused_disk); - const browser = await chromium.launch({executablePath: input.browser, headless: true, - args: ["--disable-dev-shm-usage"]}); - let page; - const pageErrors = []; - try { - page = await browser.newPage({viewport: {width: 1280, height: 900}}); - page.on("pageerror", error => { - if (pageErrors.length < 4) pageErrors.push(String(error.stack || error).slice(0, 500)); - }); - // The guest remains offline; the browser may only talk to its tunnel. - await page.route("**/*", route => { - const request = new URL(route.request().url()); - return request.hostname === "127.0.0.1" && request.port === url.port - ? route.continue() : route.abort(); - }); - await page.goto(input.url, {waitUntil: "domcontentloaded", timeout: 30000}); - await waitForInitialSetup(page); - await selectKeyboard(page, input.keyboard); - await page.fill("#username", "qualification"); - await page.fill("#password", input.password); - await page.fill("#confirm", input.password); - await page.locator("#account-form details > summary").click(); - await page.selectOption("#locale", input.locale); - await page.selectOption("#timezone", input.timezone); - const sudo = page.locator("#passwordless-wheel"); - if (input.require_policy_controls) { - assert.equal(await sudo.count(), 1, "Sudo policy choice is missing"); - assert.equal(await sudo.isChecked(), false, "Sudo must require a password by default"); - } - if (await sudo.count()) assert.equal(await sudo.isChecked(), false); - await page.locator("#account-form button[type=submit]").click(); - await page.locator("#location").waitFor({state: "visible"}); - const rescan = page.locator("#rescan-disks"); - if (input.require_policy_controls) assert.equal(await rescan.count(), 1, "Disk rescan is missing"); - if (await rescan.count()) { - await rescan.click(); - await page.waitForFunction(() => !document.querySelector("#rescan-disks").disabled); - assert.equal(await page.inputValue("#disk"), ""); - } - const options = await page.locator("#disk option").evaluateAll(items => items.map(item => item.value)); - assert(options.includes(input.target_disk), "Expected target disk is absent from selector"); - assert(options.includes(input.unused_disk), "Unused guard disk is absent from selector"); - await page.selectOption("#disk", input.target_disk); - const details = page.locator("#disk-details"); - if (input.require_policy_controls) { - assert.match(await details.textContent(), /CYBEXOS-QUALIFY/); - assert.match(await details.textContent(), /Existing partitions:/); - } - await page.locator("#disk-form details > summary").click(); - const encrypted = page.locator("#encrypted"); - assert.equal(await encrypted.isChecked(), true); - if (!input.encrypted) await encrypted.uncheck(); - await page.locator("#disk-form button[type=submit]").click(); - await page.locator("#review").waitFor({state: "visible", timeout: 180000}); - const summary = await page.locator("#summary").textContent(); - const bootKeyboard = await page.locator("#summary dt").evaluateAll(items => - items.find(item => item.textContent.trim() === "Boot keyboard")?.nextElementSibling?.textContent.trim() || ""); - assert.match(summary, /qualification/); - if (input.require_policy_controls) { - assert.match(bootKeyboard, /^[a-z0-9_-]+$/, "Planned boot keyboard is missing"); - assert.match(summary, /CYBEXOS-QUALIFY/); - assert.match(summary, /ask for your account password/); - } - assert.match(summary, input.encrypted ? /Encrypted Btrfs/ : /unencrypted/); - assert.equal(await page.locator("#install").isDisabled(), true); - await page.check("#erase"); - await page.click("#install"); - await page.locator("#progress").waitFor({state: "visible"}); - await page.getByRole("heading", {name: "Your workspace is ready."}).waitFor({timeout: input.install_timeout_ms}); - assert.equal(await page.inputValue("#password"), ""); - assert.equal(await page.inputValue("#confirm"), ""); - process.stdout.write(JSON.stringify({check: "graphical-installer", selected_disk: input.target_disk, - encrypted: input.encrypted, keyboard: input.keyboard, - boot_keyboard: bootKeyboard || input.keyboard, locale: input.locale}) + "\n"); - } catch (error) { - let visibleText = "(unavailable)"; - if (page) { - try { - visibleText = await page.locator("body").innerText({timeout: 3000}); - } catch { /* Keep the original browser failure. */ } - } - const details = [`Visible installer text: ${visibleText.slice(0, 1800)}`]; - if (pageErrors.length) details.push(`Page errors: ${pageErrors.join(" | ")}`); - error.stack = `${String(error.stack || error)}\n${details.join("\n")}`; - throw error; - } finally { - await browser.close(); - } -} - -if (require.main === module) main().catch(error => { - const detail = String(error.stack || error); - const redacted = secret ? detail.replaceAll(secret, "[redacted]") : detail; - process.stderr.write(`Graphical installer qualification failed: ${redacted}\n`); - process.exitCode = 1; -}); - -module.exports = {waitForInitialSetup, selectKeyboard}; diff --git a/image/reconstruct-iso b/image/reconstruct-iso deleted file mode 100755 index 7a285ca0..00000000 --- a/image/reconstruct-iso +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -"""Verify release parts and atomically reconstruct an ISO without replacing files.""" -import argparse -import hashlib -import json -import os -from pathlib import Path -import re -import tempfile - - -def digest(value): - return isinstance(value, str) and re.fullmatch(r'[0-9a-f]{64}', value) - - -def reconstruct(manifest, output=None): - manifest = Path(manifest) - if manifest.stat().st_size > 1024 * 1024: - raise ValueError('ISO parts manifest exceeds the size limit') - record = json.loads(manifest.read_text()) - name = record.get('file', '') - if (record.get('format') != 1 or not isinstance(name, str) - or not re.fullmatch(r'[A-Za-z0-9._-]+\.iso', name) - or not digest(record.get('sha256')) - or not isinstance(record.get('bytes'), int) or record['bytes'] <= 0 - or not isinstance(record.get('parts'), list) or not record['parts']): - raise ValueError('Invalid ISO reconstruction manifest') - destination = Path(output) if output is not None else manifest.parent / name - if destination.exists() or destination.is_symlink(): - raise ValueError('ISO output already exists; existing files are preserved') - descriptor, temporary = tempfile.mkstemp(prefix='.cybexos-iso-', dir=destination.parent) - try: - total, complete = 0, hashlib.sha256() - with os.fdopen(descriptor, 'wb') as result: - for index, part in enumerate(record['parts']): - if (part.get('file') != f'{name}.part-{index:03d}' - or not digest(part.get('sha256')) - or not isinstance(part.get('bytes'), int) - or not 0 < part['bytes'] < 2 * 1024 ** 3): - raise ValueError('Invalid or reordered ISO part') - source = manifest.parent / part['file'] - if source.is_symlink() or not source.is_file(): - raise ValueError('ISO parts must be regular files') - size, current = 0, hashlib.sha256() - with source.open('rb') as stream: - while block := stream.read(8 * 1024 * 1024): - size += len(block) - if size > part['bytes']: - raise ValueError('ISO part exceeds its recorded size') - current.update(block) - complete.update(block) - result.write(block) - if size != part['bytes'] or current.hexdigest() != part['sha256']: - raise ValueError('ISO part checksum or size mismatch: ' + source.name) - total += size - if total != record['bytes'] or complete.hexdigest() != record['sha256']: - raise ValueError('Reconstructed ISO checksum or size mismatch') - result.flush() - os.fsync(result.fileno()) - os.fchmod(result.fileno(), 0o644) - # Hard-link publication refuses even a concurrent output creation. - os.link(temporary, destination) - finally: - Path(temporary).unlink(missing_ok=True) - return destination - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('manifest', type=Path) - parser.add_argument('--output', type=Path) - args = parser.parse_args() - try: - print(reconstruct(args.manifest, args.output)) - except (OSError, ValueError, KeyError, TypeError) as error: - parser.exit(1, f'reconstruct-iso: {error}\n') - - -if __name__ == '__main__': - main() diff --git a/image/release-gate b/image/release-gate deleted file mode 100755 index c283fcf5..00000000 --- a/image/release-gate +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -"""Build, publish to local iVentoy, and qualify an ISO release on a trusted PXE runner.""" -import argparse -import json -import os -from pathlib import Path -import re -import shutil -import signal -import subprocess - -from build_support import atomic_json, digest -from browser_qualification import browser_dependencies -from offline_upgrade_inputs import snapshot_raw_dependencies -from vm_testing import require_test_iso - -ROOT = Path(__file__).resolve().parents[1] - - -def interrupted(_signum, _frame): - raise KeyboardInterrupt - - -def run_child(command): - """Let task-owned build/VM helpers clean up on interruption before removal.""" - with subprocess.Popen(command, start_new_session=True) as process: - try: - code = process.wait() - except BaseException: - if process.poll() is None: - try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - pass - try: - process.wait(timeout=30) - except subprocess.TimeoutExpired: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - process.wait() - raise - if code: - raise subprocess.CalledProcessError(code, command) - - -def main(): - for signum in (signal.SIGTERM, signal.SIGHUP): - signal.signal(signum, interrupted) - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--baseline-iso', type=Path, required=True) - parser.add_argument('--tag', required=True) - parser.add_argument('--memory', type=int, default=24576) - parser.add_argument('--upgrade-dependency-rpms', type=Path, - help='Trusted raw third-party RPM directory, snapshotted before the build') - parser.add_argument('--upgrade-dependency-provenance', type=Path, - help='Matching filename-to-{id,url} JSON; requires --upgrade-dependency-rpms and host rpm') - parser.add_argument('--execute', action='store_true') - args = parser.parse_args() - if not args.execute: - parser.error('--execute explicitly authorizes a build and tests on new disposable VM disks') - if bool(args.upgrade_dependency_rpms) != bool(args.upgrade_dependency_provenance): - parser.error('--upgrade-dependency-rpms and --upgrade-dependency-provenance must be supplied together') - if not re.fullmatch(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', args.tag): - parser.error('Use a versioned release tag') - if (ROOT / 'VERSION').read_text().strip() != args.tag[1:]: - parser.error('The release tag must match VERSION in the reviewed checkout') - baseline = require_test_iso(args.baseline_iso) - # This runner must be the actual PXE host, not a workstation with a same-named directory. - if not Path('/data/pxe/README.md').is_file(): - parser.error('Run on the documented PXE host with /data/pxe/README.md') - subprocess.run(['systemctl', 'is-active', '--quiet', 'iventoy.service'], check=True) - browser_dependencies() - output = args.output.resolve() - if output.exists() or output.is_relative_to(Path('/data/pxe/iso')): - parser.error('Output must be a new directory outside the served ISO tree') - if args.upgrade_dependency_rpms and output.is_relative_to(args.upgrade_dependency_rpms.resolve()): - parser.error('Output must be outside the raw dependency input directory') - output.mkdir(parents=True, mode=0o700) - report = {'status': 'running', 'tag': args.tag, 'baseline_iso': str(baseline)} - build = output / 'build' - dependency_staging = output / 'upgrade-dependencies' - try: - fixture = None - if args.upgrade_dependency_rpms: - fixture = snapshot_raw_dependencies(args.upgrade_dependency_rpms, - args.upgrade_dependency_provenance, dependency_staging) - report['upgrade_dependency_fixture'] = fixture.evidence() - run_child([str(ROOT / 'image/build'), '--output', str(build), '--memory', str(args.memory), - '--update-channel', str(ROOT / 'image/channels/stable.json')]) - artifacts = build / 'artifacts' - isos = list(artifacts.glob('*.iso')) - rpms = list(artifacts.glob('cybexos-desktop-*.rpm')) - offline = list(artifacts.glob('cybexos-offline-apps-*.rpm')) - sboms = list(artifacts.glob('*.spdx.json')) - # Publication needs the SBOM; fail before hours of VM qualification. - if len(isos) != 1 or len(rpms) != 1 or len(offline) != 1 or len(sboms) != 1: - raise RuntimeError('Build must deliver exactly one ISO, desktop RPM, offline applications RPM and SPDX SBOM') - if digest(baseline) == digest(isos[0]): - raise RuntimeError('Upgrade baseline must be a different prior ISO, not the candidate image') - dependencies = None - if fixture: - # This build creates a new EVR and new bytes. Bind only now, before - # publication or any VM qualification, to these exact artifacts. - dependencies = fixture.bind(baseline, rpms[0], offline[0]) - report['candidate_dependencies'] = dependencies.evidence() - atomic_json(output / 'upgrade-dependency-manifest.json', dependencies.manifest) - run_child([str(ROOT / 'image/publish-pxe'), str(artifacts), '--execute']) - iso = require_test_iso(Path('/data/pxe/iso') / isos[0].name) - report['testing_iso'] = str(iso) - report['testing_iso_bytes'] = iso.stat().st_size - for scenario in ('encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl'): - command = [str(ROOT / 'image/qualify'), str(iso), '--output', str(output / scenario), - '--execute-vm', '--erase-disposable-disk', '--scenario', scenario] - if scenario.startswith('plain-'): - command.append('--capture-outcomes') - run_child(command) - report['installation_parity'] = [] - for scenario in ('plain-us', 'plain-nl'): - directory = output / ('checkout-' + scenario) - run_child([str(ROOT / 'image/qualify-checkout'), '--artifacts', str(artifacts), - '--iso-results', str(output / scenario), '--scenario', scenario, - '--output', str(directory), '--execute-vm']) - checkout = json.loads((directory / 'checkout-qualification.json').read_text()) - fresh = json.loads((output / scenario / 'qualification.json').read_text()) - if (checkout.get('status') != 'passed' or checkout.get('scenario') != scenario - or not checkout.get('source_content_sha256') - or checkout['source_content_sha256'] != fresh.get('source_content_sha256') - or checkout.get('source_revision') != fresh.get('source_revision')): - raise RuntimeError('Checkout parity did not qualify the exact ISO source') - for profile in ('fresh', 'saved'): - parity = json.loads((directory / ('parity-' + profile + '.json')).read_text()) - if (parity.get('status') != 'passed' or parity.get('source_content_sha256') != checkout['source_content_sha256'] - or parity.get('scenario') != scenario or parity.get('profile') != profile): - raise RuntimeError('Missing or mismatched real installed parity result') - fresh['checkout_parity'] = checkout - fresh['checks'] += ['installed-parity-fresh', 'installed-parity-saved'] - atomic_json(output / scenario / 'qualification.json', fresh) - report['installation_parity'].append({'scenario': scenario, 'source_content_sha256': checkout['source_content_sha256'], - 'checkout_report_sha256': digest(directory / 'checkout-qualification.json')}) - command = [str(ROOT / 'image/qualify'), str(baseline), '--output', str(output / 'upgrade'), - '--execute-vm', '--erase-disposable-disk', '--scenario', 'encrypted-us', - '--candidate-rpm', str(rpms[0]), '--candidate-offline-rpm', str(offline[0]), - '--recovery-check', '--legacy-installer'] - if dependencies: - command += ['--candidate-dependencies', str(dependencies.directory)] - run_child(command) - if dependencies: - evidence = output / 'upgrade/qualification.json' - qualified = json.loads(evidence.read_text()) - actual = qualified.get('candidate_dependencies', {}) - required = {'installed-rpm-upgrade', 'recovery-boot-restore', 'offline-dependency-signatures-and-single-transaction'} - if (qualified.get('status') != 'passed' or actual.get('guest_signatures_verified') is not True - or any(actual.get(key) != value for key, value in dependencies.evidence().items()) - or not required.issubset(qualified.get('checks', [])) - or any(qualified.get(key) != dependencies.manifest[binding] for key, binding in ( - ('iso_sha256', 'baseline_iso_sha256'), ('candidate_rpm_sha256', 'candidate_rpm_sha256'), - ('candidate_offline_rpm_sha256', 'candidate_offline_rpm_sha256')))): - raise RuntimeError('Upgrade did not qualify the exact dependency fixture, artifacts and recovery') - report['upgrade_qualification_sha256'] = digest(evidence) - report['status'] = 'passed' - report['artifacts'] = str(artifacts) - except BaseException as error: - report.update(status='failed', error=str(error) or type(error).__name__) - # QEMU helpers always stop their guests. Preserve only compact evidence - # and a completed published testing ISO needed to diagnose a failed boot. - if build.exists(): - shutil.rmtree(build) - raise - finally: - try: - if args.upgrade_dependency_rpms and dependency_staging.exists(): - shutil.rmtree(dependency_staging) - except BaseException as error: - report.update(status='failed', cleanup_error=f'{dependency_staging}: {error}') - raise - finally: - atomic_json(output / 'release-gate.json', report) - print(json.dumps(report)) - - -if __name__ == '__main__': - main() diff --git a/image/release-repository b/image/release-repository deleted file mode 100755 index cc387049..00000000 --- a/image/release-repository +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 -"""Prepare a signed CybexOS desktop RPM repository without uploading it.""" -from release_repository import main - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/image/release-runner b/image/release-runner deleted file mode 100755 index 6893c0e2..00000000 --- a/image/release-runner +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 -"""Validate and explicitly start one ephemeral runner for a reviewed release job.""" -from release_runner import main - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/image/release_metadata.py b/image/release_metadata.py deleted file mode 100644 index 44648f59..00000000 --- a/image/release_metadata.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Public desktop release metadata shared by the builder and RPM packager.""" - -from datetime import datetime, timezone -import json -from pathlib import Path -import re -import subprocess -import tempfile -from urllib.parse import urlsplit - - -def fingerprint(value): - value = str(value).replace(" ", "").upper() - if not re.fullmatch(r"[0-9A-F]{40}|[0-9A-F]{64}", value): - raise ValueError("Use the complete OpenPGP signing-key fingerprint") - return value - - -def repository_url(value): - if not isinstance(value, str) or any(char.isspace() for char in value) or "\\" in value: - raise ValueError("The desktop repository needs one HTTPS base URL") - parsed = urlsplit(value) - if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password - or parsed.query or parsed.fragment): - raise ValueError("The desktop repository must use HTTPS without credentials, query, or fragment") - remainder = value.replace("$releasever", "").replace("$basearch", "") - if "$" in remainder or any(ord(char) < 32 for char in value): - raise ValueError("Only $releasever and $basearch repository variables are supported") - return value.rstrip("/") - - -def public_key(path, expected): - """Verify one public key in a disposable GnuPG home and return ASCII armor.""" - expected = fingerprint(expected) - path = Path(path).resolve(strict=True) - data = path.read_bytes() - if len(data) > 1024 * 1024 or b"PRIVATE KEY" in data: - raise ValueError("The update channel accepts a public key, never private signing material") - with tempfile.TemporaryDirectory(prefix="cybexos-public-key-") as directory: - command = ["gpg", "--no-options", "--homedir", directory, "--batch", "--no-autostart"] - result = subprocess.run( - [*command, "--with-colons", "--show-keys", str(path)], - check=True, capture_output=True, text=True, - ) - primaries = [] - waiting = False - for line in result.stdout.splitlines(): - fields = line.split(":") - if fields[0] in ("sec", "ssb"): - raise ValueError("Private keys cannot be embedded in an image") - if fields[0] == "pub": - if fields[1] in ("r", "e", "d"): - raise ValueError("The update key is revoked, expired, or disabled") - waiting = True - elif fields[0] == "fpr" and waiting: - primaries.append(fields[9].upper()) - waiting = False - if primaries != [expected]: - raise ValueError("The update public key does not match the single expected fingerprint") - subprocess.run([*command, "--import", str(path)], check=True, capture_output=True) - return subprocess.run( - [*command, "--armor", "--export", expected], check=True, capture_output=True, - ).stdout - - -def stage_update_channel(config_path, destination): - """Return explicit archive additions containing only validated public data.""" - config_path = Path(config_path).resolve(strict=True) - if config_path.stat().st_size > 65536: - raise ValueError("Update channel configuration is too large") - config = json.loads(config_path.read_text()) - if not isinstance(config, dict) or set(config) != {"baseurl", "fingerprint", "key_file"}: - raise ValueError("Update channel requires exactly baseurl, fingerprint, and key_file") - normalized = {"baseurl": repository_url(config["baseurl"]), - "fingerprint": fingerprint(config["fingerprint"])} - key_path = Path(config["key_file"]).expanduser() - if not key_path.is_absolute(): - key_path = config_path.parent / key_path - armor = public_key(key_path, normalized["fingerprint"]) - destination = Path(destination) - destination.mkdir(parents=True, exist_ok=True) - settings = destination / "update-channel.json" - key = destination / "update-key.asc" - settings.write_text(json.dumps(normalized, sort_keys=True, indent=2) + "\n") - key.write_bytes(armor) - settings.chmod(0o644) - key.chmod(0o644) - return {"image/update-channel.json": settings, "image/update-key.asc": key} - - -def rpm_identity(version_file, provenance): - version = Path(version_file).read_text().strip() - if not re.fullmatch(r"\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?", version): - raise ValueError("VERSION must contain major.minor.patch with an optional prerelease") - version = version.replace("-", "~", 1) - revision = provenance.get("source_revision", "") - if not re.fullmatch(r"[0-9a-f]{40,64}", revision): - raise ValueError("Build provenance needs the full source revision") - timestamp = datetime.fromisoformat(provenance["utc"].replace("Z", "+00:00")) - if timestamp.tzinfo is None: - raise ValueError("Build time must include its timezone") - release = "1." + timestamp.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S") - release += ".g" + revision[:12] - if provenance.get("source_dirty"): - release += ".dirty" - return version, release - - -WEEKDAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") -MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") - - -def changelog_entry(version, release, provenance): - """This build's %changelog entry. An ISO installation's Updates view shows - the entries newer than the installed desktop (`dnf changelog --upgrades`), - so each build names its release and where that release's notes are.""" - built = datetime.fromisoformat(provenance["utc"].replace("Z", "+00:00")).astimezone(timezone.utc) - stamp = f"{WEEKDAYS[built.weekday()]} {MONTHS[built.month - 1]} {built.day:02d} {built.year}" - release_name = version.replace("~", "-", 1) - lines = [f"* {stamp} CybexOS - {version}-{release}", - f"- CybexOS desktop {release_name} ({provenance['source_revision'][:12]})"] - if not release_name.endswith("-dev"): - lines.append(f"- Release notes: https://github.com/DigitalPals/CybexOS/releases/tag/v{release_name}") - return "\n".join(lines) + "\n\n" - - -def render_spec(source_spec, version_file, provenance): - version, release = rpm_identity(version_file, provenance) - # Subpackages have independent identities. Only rewrite the main header. - header, separator, body = source_spec.partition("%description\n") - result, count = re.subn(r"(?m)^Version:.*$", f"Version: {version}", header) - if count != 1: - raise ValueError("Desktop spec must contain exactly one Version field") - result, count = re.subn(r"(?m)^Release:.*$", f"Release: {release}%{{?dist}}", result) - if count != 1: - raise ValueError("Desktop spec must contain exactly one Release field") - result += separator + body - entry = changelog_entry(version, release, provenance) - return re.sub(r"(?m)^%changelog\n", lambda match: match.group(0) + entry, result, count=1) - - -def channel_payload(baseurl, expected, armor): - """Canonical enabled-channel bytes, also checked before release signing.""" - url, expected = repository_url(baseurl), fingerprint(expected) - lines = ["# Managed CybexOS desktop release channel.", "[cybexos-desktop]", - "name=CybexOS desktop updates", "gpgcheck=1", "repo_gpgcheck=1", - "skip_if_unavailable=0", "metadata_expire=6h", "enabled=1", - f"baseurl={url}", "gpgkey=file:///usr/share/cybexos/update-key.asc"] - status = {"enabled": True, "baseurl": url, "fingerprint": expected} - return {"etc/yum.repos.d/cybexos-desktop.repo": ("\n".join(lines) + "\n").encode(), - "usr/share/cybexos/update-channel.json": (json.dumps(status, indent=2) + "\n").encode(), - "usr/share/cybexos/update-key.asc": armor} - - -def install_update_channel(source_image_dir, payload_dir): - """Package a disabled channel by default, or the explicitly pinned channel.""" - source_image_dir, payload_dir = Path(source_image_dir), Path(payload_dir) - source = source_image_dir / "update-channel.json" - lines = ["# Managed CybexOS desktop release channel.", "[cybexos-desktop]", - "name=CybexOS desktop updates", "gpgcheck=1", "repo_gpgcheck=1", - "skip_if_unavailable=0", "metadata_expire=6h"] - vendor = payload_dir / "usr/share/cybexos" - vendor.mkdir(parents=True, exist_ok=True) - if source.exists(): - config = json.loads(source.read_text()) - if set(config) != {"baseurl", "fingerprint"}: - raise ValueError("Invalid normalized desktop update channel") - url = repository_url(config["baseurl"]) - expected = fingerprint(config["fingerprint"]) - key = public_key(source_image_dir / "update-key.asc", expected) - for relative, content in channel_payload(url, expected, key).items(): - path = payload_dir / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(content) - path.chmod(0o644) - else: - lines += ["# Configure --update-channel when building a release.", "enabled=0"] - status = {"enabled": False} - repo = payload_dir / "etc/yum.repos.d/cybexos-desktop.repo" - repo.parent.mkdir(parents=True, exist_ok=True) - repo.write_text("\n".join(lines) + "\n") - repo.chmod(0o644) - (vendor / "update-channel.json").write_text(json.dumps(status, indent=2) + "\n") - provenance = source_image_dir / "build-provenance.json" - if not provenance.is_file(): - raise ValueError("Build provenance is missing; build through image/build") - build = json.loads(provenance.read_text()) - (vendor / "build.json").write_text(json.dumps(build, indent=2, sort_keys=True) + "\n") - return build diff --git a/image/release_repository.py b/image/release_repository.py deleted file mode 100644 index edcee2c9..00000000 --- a/image/release_repository.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Create a signed desktop RPM repository in a new local directory. - -Hosting is a separate release action. Original RPMs and the user's RPM keyring -are never changed. Signing keys remain in the operator's GnuPG keyring. -""" - -import argparse -import ctypes -from datetime import datetime, timezone -import hashlib -import json -import os -from pathlib import Path -import re -import shutil -import subprocess -import tempfile -import urllib.error -import urllib.request - -from release_metadata import channel_payload, fingerprint, public_key, repository_url - - -def run(command, **kwargs): - return subprocess.run(command, check=True, **kwargs) - - -def sha256(path): - with Path(path).open("rb") as stream: - return hashlib.file_digest(stream, "sha256").hexdigest() - - -def rename_new_directory(source, destination): - """Atomically publish on Linux without replacing even an empty directory.""" - libc = ctypes.CDLL(None, use_errno=True) - renameat2 = libc.renameat2 - renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] - renameat2.restype = ctypes.c_int - if renameat2(-100, os.fsencode(source), -100, os.fsencode(destination), 1): - error = ctypes.get_errno() - raise OSError(error, os.strerror(error), str(destination)) - - -def package_identity(path): - result = run(["rpm", "-qp", "--qf", "%{NAME}\t%{EPOCHNUM}\t%{VERSION}\t%{RELEASE}\t%{ARCH}", - str(path)], capture_output=True, text=True) - fields = result.stdout.split("\t") - if len(fields) != 5 or fields[0] not in ("cybexos-desktop", "cybexos-offline-apps") or fields[4] != "x86_64": - raise ValueError(f"Expected an x86_64 CybexOS desktop or offline-apps RPM: {path.name}") - if any(not re.fullmatch(r"[A-Za-z0-9.~^+_-]+", field) for field in fields): - raise ValueError("RPM identity contains invalid characters") - return dict(zip(("name", "epoch", "version", "release", "arch"), fields)) - - -def package_payload_identity(path): - """Ignore build timestamps/signatures, but bind file contents and ownership.""" - result = run(['rpm', '-qp', '--qf', - '%{FILEDIGESTALGO}\n[%{FILENAMES}\t%{FILEDIGESTS}\t%{FILELINKTOS}\t%{FILEMODES}\t%{FILEUSERNAME}\t%{FILEGROUPNAME}\t%{FILEFLAGS}\t%{FILECAPS}\n]', - str(path)], capture_output=True, text=True) - return hashlib.sha256('\n'.join(sorted(result.stdout.splitlines())).encode()).hexdigest() - - -def previous_packages(location, key, expected, baseurl, work): - """Authenticate the previous published manifest before checking immutability.""" - if location is None: - return [] - directory = Path(work) / 'previous-release' - directory.mkdir() - for name in ('release.json', 'release.json.asc'): - if str(location).startswith('https://'): - try: - with urllib.request.urlopen(repository_url(str(location)) + '/' + name, timeout=30) as response: - data = response.read(2 * 1024 * 1024 + 1) - except urllib.error.HTTPError as error: - # No repository exists before the first public release. - if error.code == 404 and name == 'release.json': - return [] - raise - else: - data = (Path(location) / name).read_bytes() - if len(data) > 2 * 1024 * 1024: - raise ValueError('Previous release manifest exceeds the size limit') - (directory / name).write_bytes(data) - home = directory / 'keyring' - home.mkdir(mode=0o700) - command = ['gpg', '--no-options', '--homedir', str(home), '--batch', '--no-autostart'] - run([*command, '--import', str(key)], capture_output=True) - run([*command, '--verify', str(directory / 'release.json.asc'), str(directory / 'release.json')], - capture_output=True) - manifest = json.loads((directory / 'release.json').read_text()) - if manifest.get('fingerprint') != expected or manifest.get('baseurl') != baseurl or manifest.get('format') != 1: - raise ValueError('Previous release does not match the reviewed channel') - return manifest['packages'] - - -def verify_offline_identity(identity, payload_sha256, previous): - if identity['name'] != 'cybexos-offline-apps': - return - for item in previous: - if all(item.get(key) == value for key, value in identity.items()): - if item.get('payload_sha256') != payload_sha256: - raise ValueError('Offline application payload changed under an existing NEVRA; advance its independent version') - - -def verify_signed_rpm(path, key, temporary): - database = Path(temporary) / "rpmdb" - database.mkdir(exist_ok=True) - command = ["rpmkeys", "--dbpath", str(database), "--define", "_keyring rpmdb", - "--define", "_pkgverify_level all", "--define", "_pkgverify_flags 0x0"] - run([*command, "--import", str(key)], capture_output=True) - result = run([*command, "--checksig", "--verbose", str(path)], capture_output=True, text=True, - env={**os.environ, "LC_ALL": "C"}) - # Do not mistake digest-only success for a verified release signature. - if not re.search(r"(?im)^\s*.*\b(?:signature|openpgp)\b.*:\s*OK\s*$", result.stdout): - raise ValueError(f"No verified OpenPGP signature on {path.name}") - - -def verify_embedded_channel(path, baseurl, key_id, armor): - # RPM verifies payload/header integrity during signing and the subsequent - # independent signature check. Inspect its file digests without extracting - # or installing a multi-gigabyte application payload. - result = run(["rpm", "-qp", "--qf", "%{FILEDIGESTALGO}\n[%{FILENAMES}\t%{FILEDIGESTS}\n]", - str(path)], capture_output=True, text=True) - lines = result.stdout.splitlines() - if not lines or lines[0] != "8": - raise ValueError("Release RPMs must use SHA-256 file digests") - digests = dict(line.split("\t", 1) for line in lines[1:] if "\t" in line) - for relative, content in channel_payload(baseurl, key_id, armor).items(): - if digests.get("/" + relative) != hashlib.sha256(content).hexdigest(): - raise ValueError("RPM update channel does not match this release. Build it with the same --update-channel first.") - - -def create_repository(packages, output, key_file, key_id, baseurl, gnupghome=None, - packages_baseurl=None, previous_repository=None): - output = Path(output).absolute() - if output.exists() or output.is_symlink(): - raise ValueError("Repository output must be a new directory; existing releases are preserved") - expected = fingerprint(key_id) - url = repository_url(baseurl) - if packages_baseurl: - packages_baseurl = repository_url(packages_baseurl) - if '$' in packages_baseurl: - raise ValueError('RPM download URLs must identify an immutable release') - armor = public_key(key_file, expected) - packages = [Path(path).resolve(strict=True) for path in packages] - if not packages or len({path.name for path in packages}) != len(packages): - raise ValueError("Supply RPMs with unique filenames") - for path in packages: - if path.suffix != ".rpm" or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm", path.name): - raise ValueError("RPM filenames must be ASCII without spaces") - for executable in ("rpm", "rpmkeys", "rpmsign", "createrepo_c", "gpg"): - if not shutil.which(executable): - raise ValueError(f"Missing {executable}; install rpm-sign, createrepo_c, and gnupg2 on Fedora") - identities = [package_identity(path) for path in packages] - names = [item['name'] for item in identities] - if names.count('cybexos-desktop') != 1 or len(set(names)) != len(names): - raise ValueError('Supply exactly one desktop RPM and at most one offline applications RPM') - if len({json.dumps(item, sort_keys=True) for item in identities}) != len(identities): - raise ValueError("Duplicate RPM identities cannot be published in one release") - output.parent.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix=".cybexos-repository-", dir=output.parent) as directory: - work = Path(directory) - stage = work / "repository" - stage.mkdir() - target_packages = stage / "Packages" - target_packages.mkdir() - key = stage / "CYBEXOS-desktop.asc" - key.write_bytes(armor) - previous = previous_packages(previous_repository, key, expected, url, work) - command = ["rpmsign", "--addsign", "--key-id", expected, - "--define", "_openpgp_sign gpg"] - signing = ["gpg", "--batch", "--local-user", expected] - signing_environment = dict(os.environ) - if gnupghome: - home = Path(gnupghome).resolve(strict=True) - signing_environment["GNUPGHOME"] = str(home) - command += ["--undefine", "_gpg_path"] - signing += ["--homedir", str(home)] - records = [] - for original, identity in zip(packages, identities): - payload_sha256 = package_payload_identity(original) - verify_offline_identity(identity, payload_sha256, previous) - destination = target_packages / original.name - shutil.copyfile(original, destination) - if identity["name"] == "cybexos-desktop": - verify_embedded_channel(destination, url, expected, armor) - run([*command, str(destination)], env=signing_environment) - verify_signed_rpm(destination, key, work) - record = {**identity, "file": str(destination.relative_to(stage)), - "sha256": sha256(destination), "unsigned_input_sha256": sha256(original), - "payload_sha256": payload_sha256} - if packages_baseurl: - record['url'] = packages_baseurl + '/' + destination.name - records.append(record) - if packages_baseurl: - # GitHub Pages serves only metadata; large RPMs are release assets. - # Scan Packages itself so relative locations are bare asset names. - run(["createrepo_c", "--checksum", "sha256", "--general-compress-type", "gz", "--outputdir", str(stage), - "--baseurl", packages_baseurl + '/', str(target_packages)]) - else: - run(["createrepo_c", "--checksum", "sha256", "--general-compress-type", "gz", str(stage)]) - metadata = stage / "repodata/repomd.xml" - run([*signing, "--armor", "--detach-sign", "--output", str(metadata) + ".asc", str(metadata)]) - manifest = stage / "release.json" - manifest.write_text(json.dumps({ - "format": 1, "created": datetime.now(timezone.utc).isoformat(), - "fingerprint": expected, "baseurl": url, "packages": records, - }, indent=2, sort_keys=True) + "\n") - run([*signing, "--armor", "--detach-sign", "--output", str(manifest) + ".asc", str(manifest)]) - # Verify detached signatures in an independent keyring containing only - # the reviewed public key; signing success alone is insufficient. - verifier = work / "verify-gpg" - verifier.mkdir(mode=0o700) - verify = ["gpg", "--no-options", "--homedir", str(verifier), "--batch", "--no-autostart"] - run([*verify, "--import", str(key)], capture_output=True) - for signed in (metadata, manifest): - run([*verify, "--verify", str(signed) + ".asc", str(signed)], capture_output=True) - (stage / "update-channel.json").write_text(json.dumps({ - "baseurl": url, "fingerprint": expected, "key_file": key.name, - }, indent=2) + "\n") - files = sorted(path for path in stage.rglob("*") if path.is_file()) - (stage / "SHA256SUMS").write_text("".join( - f"{sha256(path)} {path.relative_to(stage)}\n" for path in files - )) - for path in [stage, *stage.rglob("*")]: - path.chmod(0o755 if path.is_dir() else 0o644) - # Renaming a completed sibling directory never exposes partial RPMs or - # metadata and refuses replacing nonempty previous release directories. - if output.exists() or output.is_symlink(): - raise ValueError("Repository output appeared while signing; refusing to replace it") - rename_new_directory(stage, output) - return output - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("packages", type=Path, nargs="+") - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--public-key", type=Path, required=True) - parser.add_argument("--key", required=True, help="Complete fingerprint of an existing signing key") - parser.add_argument("--baseurl", required=True, help="HTTPS URL where this repository will be hosted") - parser.add_argument("--gnupghome", type=Path) - parser.add_argument("--packages-baseurl", help="HTTPS release-asset directory; keep large RPMs off the metadata host") - parser.add_argument("--previous-repository", help="Previous signed repository directory or HTTPS URL; check offline RPM immutability") - args = parser.parse_args(argv) - try: - destination = create_repository(args.packages, args.output, args.public_key, args.key, - args.baseurl, args.gnupghome, args.packages_baseurl, args.previous_repository) - except (ValueError, OSError, subprocess.CalledProcessError) as error: - parser.exit(1, f"release-repository: {error}\n") - print(f"Signed repository ready for explicit hosting: {destination}") - print(f"Use its update-channel.json with image/build --update-channel {destination / 'update-channel.json'}") - return 0 diff --git a/image/release_runner.py b/image/release_runner.py deleted file mode 100644 index dbfe154d..00000000 --- a/image/release_runner.py +++ /dev/null @@ -1,354 +0,0 @@ -"""Start one verified, ephemeral PXE runner for one already queued release job. - -The operator's gh credentials remain outside the runner environment. This is -not a sandbox: only reviewed workflow code may execute as the PXE operator. -""" -import argparse -import hashlib -import json -import os -from pathlib import Path -import platform -import re -import signal -import shutil -import subprocess -import tarfile -import tempfile -import sys -from urllib.parse import urlsplit -from urllib.request import urlopen -import uuid - -REPOSITORY = 'DigitalPals/CybexOS' -ROOT = Path(__file__).resolve().parents[1] -WORKFLOWS = {'.github/workflows/release.yml', '.github/workflows/desktop-release.yml'} -ARCHIVE_LIMIT = 1024 ** 3 - - -def api(endpoint, *, method='GET', body=None, paginate=False): - command = ['gh', 'api', '--hostname', 'github.com', '--method', method, '-H', 'Accept: application/vnd.github+json', - '-H', 'X-GitHub-Api-Version: 2022-11-28', endpoint] - if paginate: - command += ['--paginate', '--slurp'] - if body is not None: - command += ['--input', '-'] - result = subprocess.run(command, input=json.dumps(body) if body is not None else None, - capture_output=True, text=True, timeout=60, check=False) - if result.returncode: - if method == 'DELETE' and '(HTTP 404)' in result.stderr: - return None # Ephemeral registration already removed itself. - # API payloads can contain registration credentials; never echo them. - raise RuntimeError(f'GitHub API {method} {endpoint} failed; check gh authentication and repository administration access') - return json.loads(result.stdout) if result.stdout.strip() else None - - -def pages(endpoint, field): - return [item for page in api(endpoint, paginate=True) for item in page[field]] - - -def validate_run(run, head): - if (run.get('repository', {}).get('full_name') != REPOSITORY - or run.get('head_repository', {}).get('full_name') != REPOSITORY - or run.get('event') not in ('push', 'workflow_dispatch') - or run.get('path') not in WORKFLOWS - or run.get('head_sha') != head - or run.get('pull_requests') - or run.get('status') not in ('queued', 'in_progress', 'waiting', 'requested')): - raise ValueError('Run must be an unfinished, non-PR release workflow from this exact reviewed checkout') - branch = run.get('head_branch', '') - if branch != 'main' and not re.fullmatch(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', branch): - raise ValueError('Release runners accept only main or a versioned release tag') - return run - - -def validate_job(jobs, run_id, head): - label = f'cybexos-iso-{run_id}' - matching = [job for job in jobs if label in job.get('labels', []) - and job.get('status') in ('queued', 'in_progress', 'waiting', 'pending')] - if len(matching) != 1: - raise ValueError('Exactly one queued qualification job with this run-specific label is required') - job = matching[0] - if (job.get('status') != 'queued' or job.get('run_id') != run_id - or job.get('head_sha') != head or job.get('labels') != [label]): - raise ValueError('The qualification job must be queued and request only its exact run-specific label') - return job - - -def checkout_head(checkout=ROOT): - def git(*args): - return subprocess.run(['git', '-C', str(checkout), *args], check=True, - capture_output=True, text=True, timeout=15).stdout.strip() - if git('status', '--porcelain', '--untracked-files=all'): - raise ValueError('Use a clean, reviewed checkout matching the queued workflow head') - remote = git('remote', 'get-url', 'origin') - if remote not in (f'https://github.com/{REPOSITORY}', f'https://github.com/{REPOSITORY}.git', - f'git@github.com:{REPOSITORY}.git', f'ssh://git@github.com/{REPOSITORY}.git'): - raise ValueError('Checkout origin must be the official CybexOS repository') - head = git('rev-parse', 'HEAD') - if not re.fullmatch(r'[0-9a-f]{40,64}', head): - raise ValueError('Checkout commit identity is invalid') - return head - - -def reviewed_job(run_id, head, own_runner=None): - run = validate_run(api(f'repos/{REPOSITORY}/actions/runs/{run_id}'), head) - job = validate_job(pages(f'repos/{REPOSITORY}/actions/runs/{run_id}/jobs?filter=latest&per_page=100', 'jobs'), run_id, head) - label = f'cybexos-iso-{run_id}' - # A unique-only label avoids the generic self-hosted pool. Refuse a second - # runner for this run, or another pending job explicitly targeting it. - runners = pages(f'repos/{REPOSITORY}/actions/runners?per_page=100', 'runners') - if any(runner.get('id') != own_runner and label in [item.get('name') for item in runner.get('labels', [])] - for runner in runners): - raise ValueError('A runner already exists for this workflow run') - for state in ('queued', 'in_progress', 'waiting', 'pending', 'requested'): - for other in pages(f'repos/{REPOSITORY}/actions/runs?status={state}&per_page=100', 'workflow_runs'): - if other['id'] == run_id: - continue - others = pages(f'repos/{REPOSITORY}/actions/runs/{other["id"]}/jobs?filter=latest&per_page=100', 'jobs') - if any(label in item.get('labels', []) and item.get('status') != 'completed' for item in others): - raise ValueError('Another unfinished workflow requests the same release-runner label') - return run, job - - -def runner_package(releases): - found = [item for item in releases if item.get('os') == 'linux' and item.get('architecture') == 'x64'] - if len(found) != 1: - raise ValueError('GitHub must provide exactly one current Linux x64 runner package') - package = found[0] - checksum = package.get('sha256_checksum', '') - url = urlsplit(package.get('download_url', '')) - if (not re.fullmatch(r'[0-9a-fA-F]{64}', checksum) or url.scheme != 'https' - or url.netloc != 'github.com' or url.query or url.fragment - or not re.fullmatch(r'/actions/runner/releases/download/v[0-9.]+/actions-runner-linux-x64-[0-9.]+\.tar\.gz', url.path)): - raise ValueError('Runner download must have the GitHub API SHA-256 pin and official release URL') - return package['download_url'], checksum.lower() - - -def download(url, checksum, destination): - digest, size = hashlib.sha256(), 0 - with urlopen(url, timeout=60) as response, destination.open('xb') as stream: - if urlsplit(response.url).scheme != 'https': - raise ValueError('Runner download redirected to an insecure URL') - while block := response.read(8 * 1024 * 1024): - size += len(block) - if size > ARCHIVE_LIMIT: - raise ValueError('Runner archive exceeds the size limit') - digest.update(block) - stream.write(block) - if digest.hexdigest() != checksum: - raise ValueError('Runner download SHA-256 does not match the GitHub API pin') - - -def extract(archive, destination): - # Python 3.12+ data filtering rejects escaping paths, device files and - # escaping symlinks. Only checksum-verified official runner bytes reach it. - with tarfile.open(archive, 'r:gz') as bundle: - bundle.extractall(destination, filter='data') - for name in ('config.sh', 'run.sh'): - path = destination / name - if path.is_symlink() or not path.is_file() or not os.access(path, os.X_OK): - raise ValueError('Official runner archive is missing its executable entry points') - - -def child_environment(work): - home, temporary = work / 'home', work / 'tmp' - home.mkdir(mode=0o700) - temporary.mkdir(mode=0o700) - return {'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'), - 'HOME': str(home), 'TMPDIR': str(temporary), 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8', - 'PYTHONDONTWRITEBYTECODE': '1'} - - -def stop(process): - if process.poll() is not None: - return - for signum, deadline in ((signal.SIGINT, 30), (signal.SIGTERM, 15), (signal.SIGKILL, 10)): - try: - os.killpg(process.pid, signum) - except ProcessLookupError: - break - try: - process.wait(timeout=deadline) - return - except subprocess.TimeoutExpired: - continue - process.wait(timeout=10) - - -def run_child(command, work, environment, timeout, *, capture=False): - with subprocess.Popen(command, cwd=work, env=environment, start_new_session=True, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE if capture else None, - stderr=subprocess.PIPE if capture else None) as process: - try: - process.communicate(timeout=timeout) - except BaseException: - stop(process) - raise - if process.returncode: - raise RuntimeError('Ephemeral runner command failed; local credentials and logs will be removed') - - -class ActiveRunnerError(RuntimeError): - """A transient unit could not be proven stopped; retain its task files.""" - - -def manager_environment(): - runtime = f'/run/user/{os.getuid()}' - return {'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'), - 'XDG_RUNTIME_DIR': runtime, 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=' + runtime + '/bus', - 'LANG': 'C.UTF-8'} - - -def check_user_manager(): - result = subprocess.run(['systemctl', '--user', 'show', '--property=Version', '--value'], - env=manager_environment(), capture_output=True, timeout=15, check=False) - if result.returncode: - raise ValueError('A reachable operator systemd user manager is required for bounded runner/VM cleanup') - - -def run_scoped_runner(work, environment, timeout, name): - """A transient cgroup contains build VMs even when helpers call setsid().""" - unit = name + '.service' - manager = manager_environment() - command = ['systemd-run', '--user', '--quiet', '--wait', '--pipe', '--collect', - '--service-type=exec', '--unit=' + unit, '--working-directory=' + str(work), - '--property=RuntimeMaxSec=' + str(timeout) + 's', '--property=KillSignal=SIGINT', - '--property=TimeoutStopSec=120s', '--property=KillMode=mixed', - '--property=SendSIGKILL=yes', '/usr/bin/env', '-i', - *(key + '=' + value for key, value in sorted(environment.items())), str(work / 'run.sh')] - try: - # Give systemd time to complete graceful cancellation at RuntimeMaxSec. - run_child(command, work, manager, timeout + 135) - finally: - try: - subprocess.run(['systemctl', '--user', 'stop', unit], env=manager, - capture_output=True, timeout=135, check=False) - result = subprocess.run(['systemctl', '--user', 'show', unit, '--property=ActiveState', '--value'], - env=manager, capture_output=True, text=True, timeout=15, check=False) - if not ((result.returncode == 0 and result.stdout.strip() in ('inactive', 'failed')) - or (result.returncode == 4 and not result.stdout.strip())): - raise ActiveRunnerError(f'Could not confirm {unit} stopped; inspect that exact user unit before removing {work}') - except (OSError, subprocess.SubprocessError) as error: - raise ActiveRunnerError(f'Could not stop {unit}; inspect that exact user unit before removing {work}') from error - - -def owned_runner(name): - found = [runner for runner in pages(f'repos/{REPOSITORY}/actions/runners?per_page=100', 'runners') - if runner.get('name') == name] - if len(found) > 1: - raise RuntimeError('Ambiguous ephemeral runner registration; refuse to delete another runner') - return found[0]['id'] if found else None - - -def cleanup_registration(name): - # Ephemeral runners normally deregister themselves. Exact unique names also - # recover a partially completed config.sh registration without deleting peers. - identifier = owned_runner(name) - if identifier is not None: - api(f'repos/{REPOSITORY}/actions/runners/{identifier}', method='DELETE') - - -def execute(run_id, head, work_root, timeout): - check_user_manager() - reviewed_job(run_id, head) - url, checksum = runner_package(api(f'repos/{REPOSITORY}/actions/runners/downloads')) - name = f'cybexos-iso-{run_id}-{uuid.uuid4().hex[:12]}' - attempted = False - work = Path(tempfile.mkdtemp(prefix=f'cybexos-runner-{run_id}-', dir=work_root)) - retain = False - try: - download(url, checksum, work / 'runner.tar.gz') - extract(work / 'runner.tar.gz', work) - work.chmod(0o700) - (work / 'runner.tar.gz').unlink() - environment = child_environment(work) - # Recheck after a potentially slow download, immediately before enrolling. - reviewed_job(run_id, head) - registration = api(f'repos/{REPOSITORY}/actions/runners/registration-token', method='POST') - token = registration.get('token') if isinstance(registration, dict) else None - if not isinstance(token, str) or not token: - raise ValueError('GitHub did not return a registration token') - try: - attempted = True - # Runner CommandSettings accepts ACTIONS_RUNNER_INPUT_* and removes - # it from the environment after reading. Never put tokens in argv. - run_child([str(work / 'config.sh'), '--unattended', '--url', f'https://github.com/{REPOSITORY}', - '--name', name, '--ephemeral', '--disableupdate', '--no-default-labels', - '--labels', f'cybexos-iso-{run_id}', '--work', '_work'], - work, {**environment, 'ACTIONS_RUNNER_INPUT_TOKEN': token}, 120, capture=True) - token = None - registration = None - identifier = owned_runner(name) - if identifier is None: - raise RuntimeError('Runner registration was not visible in the repository') - _run, expected_job = reviewed_job(run_id, head, own_runner=identifier) - print(f'Starting ephemeral runner {name} for reviewed run {run_id}; automatic stop after {timeout} seconds.', flush=True) - try: - run_scoped_runner(work, environment, timeout, name) - except ActiveRunnerError: - retain = True - raise - completed = api(f'repos/{REPOSITORY}/actions/jobs/{expected_job["id"]}') - if (completed.get('run_id') != run_id or completed.get('runner_id') != identifier - or completed.get('head_sha') != head or completed.get('status') != 'completed'): - raise RuntimeError('Runner did not complete the reviewed job; inspect the workflow before retrying') - if completed.get('conclusion') != 'success': - raise RuntimeError('The reviewed qualification job failed; inspect its retained qualification reports') - print('Ephemeral runner stopped; removing its registration and task directory.', flush=True) - finally: - if attempted: - try: - cleanup_registration(name) - except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: - raise RuntimeError(f'Registration cleanup failed for {name}; inspect repository Actions runners and the reported transient unit before removing that exact registration') from error - - finally: - if retain: - print(f'Retained task staging {work}: runner unit stop could not be confirmed.', file=sys.stderr) - else: - shutil.rmtree(work) - - -def interrupted(_signal, _frame): - raise KeyboardInterrupt - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--run-id', type=int, required=True, help='Already queued reviewed release workflow run') - parser.add_argument('--execute', action='store_true', help='Register and run one ephemeral runner; otherwise validate only') - parser.add_argument('--work-root', type=Path, default=Path('/data/cybexos-runners')) - parser.add_argument('--timeout', type=int, default=7 * 60 * 60, help='Maximum runner lifetime in seconds (default: 7 hours)') - args = parser.parse_args() - if args.run_id <= 0 or not 60 <= args.timeout <= 8 * 60 * 60: - parser.error('Use a positive run ID and a timeout between 60 seconds and 8 hours') - try: - head = checkout_head() - run, job = reviewed_job(args.run_id, head) - print(f'Reviewed {run["path"]} at {head}; queued job {job["id"]}; label cybexos-iso-{args.run_id}.') - if not args.execute: - print('Validation only. --execute registers an ephemeral runner and starts the reviewed job.') - return 0 - if platform.system() != 'Linux' or platform.machine() != 'x86_64' or os.geteuid() == 0: - raise ValueError('Run as the unprivileged PXE operator on Linux x86_64') - if not Path('/data/pxe/README.md').is_file(): - raise ValueError('Execute on the documented PXE host with /data/pxe/README.md') - work_root = args.work_root.resolve() - if work_root == ROOT or work_root.is_relative_to(ROOT) or work_root.is_relative_to(Path('/data/pxe/iso')): - raise ValueError('Runner staging must be outside the checkout and served ISO tree') - work_root.mkdir(parents=True, exist_ok=True) - for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): - signal.signal(signum, interrupted) - execute(args.run_id, head, work_root, args.timeout) - return 0 - except KeyboardInterrupt: - print('Ephemeral runner interrupted; cleanup completed or was reported above.', file=sys.stderr) - return 130 - except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, tarfile.TarError) as error: - parser.exit(1, f'release-runner: {error}\n') - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/image/repair-installed b/image/repair-installed deleted file mode 100755 index 8360b259..00000000 --- a/image/repair-installed +++ /dev/null @@ -1,195 +0,0 @@ -#!/usr/bin/env python3 -"""Repair an existing ISO installation without replacing its home or desktop ownership.""" -import argparse -from datetime import datetime, timezone -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import pwd -import shutil -import subprocess -import tempfile - -import jinja2 -import yaml - -from desktop_payload import prepare_system_session -from provision_payload import prepare_provision - -ROOT = Path(__file__).resolve().parents[1] - - -def load_applications(): - loader = importlib.machinery.SourceFileLoader('repair_applications', str(ROOT / 'image/applications')) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -def prepare(payload): - prepare_provision(ROOT, payload) - - def copy(source, destination, executable=False): - target = payload / destination - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(ROOT / source, target) - target.chmod(0o755 if executable else 0o644) - - copy('image/installation_policy.py', 'usr/share/cybexos/lib/installation_policy.py') - for relative in ('usr/libexec/cybexos-configure-installed', 'usr/libexec/cybexos-config', - 'usr/bin/cybex', 'usr/bin/hyprland-quickshell', - 'usr/lib/systemd/user/hyprland-session.target', - 'usr/lib/systemd/user/external-monitor-toggle.service', - 'usr/lib/systemd/system/cybexos-hardware-setup.service', - 'usr/lib/systemd/system/cybexos-hardware-setup.timer'): - copy('image/rootfs/' + relative, relative, relative.startswith(('usr/bin/', 'usr/libexec/'))) - copy('roles/apps/files/cybexos-repository-policy', 'usr/libexec/cybexos-repository-policy', True) - for vendor in ('brave-browser', '1password', 'chatgpt', 'tailscale'): - copy(f'roles/apps/files/{vendor}.repo', f'usr/share/cybexos/repository-policy/{vendor}.repo') - contract = json.loads((ROOT / 'assets/desktop-contract.json').read_text()) - for name in contract['userHelpers']: - copy(f'assets/scripts/{name}', f'usr/share/cybexos/bin/{name}', True) - for relative in ('Common/Updates.qml', 'Common/Tailscale.qml', - 'Popovers/TailscalePopover.qml', 'Popovers/WifiPopover.qml'): - target = 'usr/share/cybexos/runtime/quickshell/' + relative - copy('roles/desktop/files/quickshell/' + relative, target) - path = payload / target - path.write_text(path.read_text().replace('/usr/local/libexec/cybexos-', '/usr/libexec/cybexos-')) - environment = jinja2.Environment(undefined=jinja2.StrictUndefined, keep_trailing_newline=True) - environment.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no) - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - # Quickshell's feature-rendered unit, the packaged agent skill and the XPS - # monitor watcher. Provisioning then writes the Kitty fragment directly. - prepare_system_session(ROOT, payload, inventory) - for name in ('features.lua', 'looknfeel.lua'): - content = environment.from_string((ROOT / 'roles/desktop/templates' / (name + '.j2')).read_text()).render( - features=inventory['features'], cybexos_dynamic_hardware=True) - content = content.replace('/usr/local/libexec/cybexos-', '/usr/libexec/cybexos-') - if name == 'looknfeel.lua': - content += '\nhl.window_rule({ match = { class = [[^cybex$]] }, float = true, center = true, size = { 920, 620 } })\n' - target = payload / 'usr/share/cybexos/runtime/hypr' / name - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content) - seed = 'usr/share/cybexos/essential-seed/' - copy('roles/dotfiles/files/fish-config.fish', seed + '.config/fish/conf.d/50-cybexos.fish') - copy('assets/nautilus-localsend.py', seed + '.local/share/nautilus-python/extensions/localsend.py') - for name in ('localsend-share-clipboard', 'localsend-share-file', 'localsend-share-folder'): - path = payload / seed / f'.local/share/applications/{name}.desktop' - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text((ROOT / f'roles/dotfiles/templates/{name}.desktop.j2').read_text() - .replace('{{ primary_home }}/.local/bin/', '')) - path.chmod(0o644) - - -def run(command, **kwargs): - return subprocess.run(command, check=True, **kwargs) - - -def user_command(account, command): - return ['runuser', '-u', account.pw_name, '--', 'env', - 'XDG_RUNTIME_DIR=/run/user/' + str(account.pw_uid), - 'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/' + str(account.pw_uid) + '/bus', - 'HOME=' + account.pw_dir, *command] - - -def apply(payload, account, packages, hardware=False): - if os.geteuid() != 0: - raise PermissionError('Apply with sudo: ./image/repair-installed --apply --user ' + account.pw_name) - if not Path('/usr/share/cybexos/build.json').is_file(): - raise RuntimeError('This repair is for installed CybexOS images') - if Path('/run/cybexos-live').exists(): - raise RuntimeError('Do not repair a live installation session') - backup = Path('/var/lib/cybexos/backups') / ('install-repair-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')) - backup.mkdir(parents=True, mode=0o700, exist_ok=False) - print('Repair backup:', backup, flush=True) - files = [path for path in sorted(payload.rglob('*')) if path.is_file()] - changed = [path for path in files if not (Path('/') / path.relative_to(payload)).is_file() - or (Path('/') / path.relative_to(payload)).read_bytes() != path.read_bytes()] - (backup / 'manifest.json').write_text(json.dumps([str(p.relative_to(payload)) for p in changed], indent=2)) - session = Path('/run/user/' + str(account.pw_uid) + '/bus').exists() - shell_active = session and subprocess.run(user_command(account, ['systemctl', '--user', 'is-active', '--quiet', 'quickshell.service'])).returncode == 0 - for relative in ('etc/passwd', 'etc/group', 'etc/sudoers.d/10-wheel-nopasswd', - 'etc/firewalld/zones/cybexos.xml', - str(Path(account.pw_dir).relative_to('/')) + '/.config/fish/conf.d/50-cybexos.fish'): - source = Path('/') / relative - if source.is_file() and not source.is_symlink(): - saved = backup / relative - saved.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, saved) - try: - for path in changed: - relative = path.relative_to(payload) - target = Path('/') / relative - if target.is_symlink(): - raise RuntimeError('Refusing to replace a system symlink: ' + str(target)) - if target.exists(): - saved = backup / relative - saved.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(target, saved) - target.parent.mkdir(parents=True, exist_ok=True) - fd, temporary = tempfile.mkstemp(prefix='.cybexos-repair-', dir=target.parent) - try: - with os.fdopen(fd, 'wb') as output: - output.write(path.read_bytes()) - os.fchmod(output.fileno(), path.stat().st_mode & 0o777) - os.replace(temporary, target) - finally: - Path(temporary).unlink(missing_ok=True) - run(['restorecon', '-RF', '/usr/share/cybexos/provision']) - if changed: - run(['restorecon', *[str(Path('/') / path.relative_to(payload)) for path in changed]]) - # Reload changed shell files immediately; package downloads may take minutes. - if shell_active: - run(user_command(account, ['systemctl', '--user', 'restart', 'quickshell.service'])) - run(['/usr/libexec/cybexos-repository-policy']) - if packages: - run(['dnf', '-y', '--setopt=*.skip_if_unavailable=false', 'install', *packages]) - run(['systemctl', 'daemon-reload']) - run(['/usr/libexec/cybexos-configure-installed', '--user', account.pw_name]) - run(user_command(account, ['/usr/libexec/cybexos-user-init', '--essential'])) - if session: - run(user_command(account, ['systemctl', '--user', 'daemon-reload'])) - run(user_command(account, ['systemctl', '--user', 'start', - 'hypridle.service', 'voxtype.service'])) - finally: - if shell_active: - run(user_command(account, ['systemctl', '--user', 'start', 'quickshell.service'])) - # All machine and user changes have finished before the asynchronous - # hardware timer can begin a second package transaction. - if hardware: - run(['/usr/libexec/cybexos-configure-installed', '--hardware', '--user', account.pw_name]) - run(['systemctl', 'enable', '--now', 'cybexos-hardware-setup.timer']) - print('Repair applied. Open a new terminal for Fish and the CLI aliases.', flush=True) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--apply', action='store_true') - parser.add_argument('--hardware', action='store_true') - parser.add_argument('--user', default=os.environ.get('SUDO_USER', os.environ.get('USER'))) - args = parser.parse_args() - account = pwd.getpwnam(args.user) - if account.pw_uid < 1000 or account.pw_name == 'liveuser' or Path(account.pw_dir).parent != Path('/home'): - parser.error('Select the installed desktop user') - packages, _ = load_applications().package_names() - installed = set(subprocess.check_output(['rpm', '-qa', '--qf', '%{NAME}\n%{NAME}.%{ARCH}\n'], text=True).splitlines()) - missing = sorted(set(packages) - installed) - with tempfile.TemporaryDirectory(prefix='cybexos-install-repair-') as temporary: - payload = Path(temporary) - prepare(payload) - print(json.dumps({'user': account.pw_name, 'missingPackages': missing, - 'actions': ['repair session ordering', 'reconcile signed repositories', - 'apply shared baseline, firewall and account defaults', - 'install missing helpers and LocalSend integration', - 'install the configure command, shell feature unit, agent skill and XPS watcher', - 'configure detected hardware'], - 'apply': args.apply}, indent=2), flush=True) - if args.apply: - apply(payload, account, missing, args.hardware) - - -if __name__ == '__main__': - main() diff --git a/image/rootfs/usr/bin/cybex b/image/rootfs/usr/bin/cybex deleted file mode 100755 index 0abb6e0e..00000000 --- a/image/rootfs/usr/bin/cybex +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -command_name=${1:-help} -[[ $# == 0 ]] || shift -case $command_name in - welcome) exec /usr/bin/cybexos-welcome "$@" ;; - configure) exec /usr/libexec/cybexos-config "$@" ;; - update) exec /usr/share/cybexos/bin/cybexos-update-run run "$@" ;; - upgrade-system) exec sudo /usr/libexec/cybexos-major-upgrade "$@" ;; - update-channel) exec /usr/libexec/cybexos-update-channel "$@" ;; - prepare-apps) exec /usr/libexec/cybexos-user-init --background "$@" ;; - repair) exec sudo /usr/libexec/cybexos-configure-installed --user "${SUDO_USER:-$USER}" "$@" ;; - agent) exec /usr/share/cybexos/bin/cybexos-agent "$@" ;; - webapp) exec /usr/share/cybexos/bin/cybexos-webapp "$@" ;; - plugin|dev|shell) exec /usr/share/cybexos/bin/cybexos-runtime "$command_name" "$@" ;; - backup) exec /usr/share/cybexos/bin/cybexos-backup "$@" ;; - theme) exec /usr/share/cybexos/bin/cybexos-theme "$@" ;; - version|--version) exec rpm -q cybexos-desktop ;; - verify|doctor) exec /usr/libexec/cybexos-doctor "$@" ;; - report) exec /usr/share/cybexos/bin/cybexos-report "$@" ;; - factory-reset) exec sudo /usr/libexec/cybexos-factory-reset reset "$@" ;; - uninstall) - # The desktop is the operating system of an ISO installation; there is no - # separate workstation layer to remove. - echo 'cybex uninstall is not available on a CybexOS system installed from the ISO:' >&2 - echo 'the CybexOS desktop is part of the operating system. Use cybex configure to' >&2 - echo 'change installation choices, or reinstall to use a different desktop.' >&2 - exit 2 - ;; - help|-h|--help) - cat <<'EOF' -Usage: cybex [options] - -Commands: - welcome Open the CybexOS welcome window - configure Change installation choices and apply them (--check prints them) - update Check for and apply CybexOS and system updates - upgrade-system Prepare, perform, or inspect a supported Fedora major upgrade - update-channel Inspect or enroll the desktop RPM update channel - prepare-apps Prepare the offline applications for this account - repair Reapply the installed-system policy to this account - agent Launch or choose the default AI coding agent - webapp Install, remove, list, or launch web apps - plugin Install, update, clone, remove, or configure desktop plugins - shell Inspect shell health, enter safe mode, or retry the full desktop - theme List, apply, install, export, or remove appearance themes - dev Select, inspect, or disable a live development checkout - backup Back up, check, or restore personal files (setup, now, status, restore) - version Print the installed CybexOS package version - verify Check installed-system health (--json for machine-readable output) - doctor Alias for verify - report Collect a redacted diagnostic bundle for a bug report - factory-reset Erase all accounts and home folders and prepare for a new owner -EOF - ;; - *) echo "Unknown command: $command_name" >&2; exit 2 ;; -esac diff --git a/image/rootfs/usr/bin/cybexos-welcome b/image/rootfs/usr/bin/cybexos-welcome deleted file mode 100755 index 15a41e49..00000000 --- a/image/rootfs/usr/bin/cybexos-welcome +++ /dev/null @@ -1,630 +0,0 @@ -#!/usr/bin/env python3 -"""Unprivileged welcome window. Anaconda owns installation and authorization.""" -import json -import os -from pathlib import Path -import sys - -from PySide6.QtCore import QObject, Property, QProcess, QProcessEnvironment, QTimer, QUrl, Signal, Slot -from PySide6.QtGui import QGuiApplication -from PySide6.QtNetwork import QLocalServer, QLocalSocket -from PySide6.QtQml import QQmlApplicationEngine - -# Beside this program in both layouts: /usr/bin → /usr/share when installed, -# image/rootfs/usr/bin → image/rootfs/usr/share when run from a checkout. -QML = Path(__file__).resolve().parents[1] / "share/cybexos/welcome/Main.qml" - -# How long to wait for the desktop shell at login (it can start after this -# window), and how often to ask while a search or download is running. -SHELL_ATTEMPTS = 20 -SHELL_RETRY_MS = 1000 -POLL_MS = 400 -# An attached unattended-installation seed volume opens the installer by -# itself in a live session. USB media can appear a little after login. -SEED_LABELS = ("CYBEXOS-SEED", "CIDATA", "cidata") -SEED_ATTEMPTS = 30 - - -def seed_volume_present(by_label=Path("/dev/disk/by-label")): - return any((by_label / label).exists() for label in SEED_LABELS) - - -class Welcome(QObject): - changed = Signal() - activate = Signal() - wallpapersChanged = Signal() - wallpaperStateChanged = Signal() - setupChanged = Signal() - - # Shell IPC calls the window's buttons make, by the name QML passes. - SHELL_PAGES = { - "appearance": ["settings", "open", "appearance"], - # Settings → Wallpaper, on its Online (Wallhaven) view. - "wallpaper": ["wallpaper", "browse"], - "network": ["settings", "open", "network"], - } - - def __init__(self, install_mode=False): - super().__init__() - self.live = Path("/run/cybexos-live").exists() - # The "Install CybexOS" boot entry: this window is the whole session. - self.install_mode = self.live and install_mode - self.marker = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "cybexos/welcome-complete" - # The runtime helper reaches the shell that is actually running, - # including a development checkout selected with `cybex dev`. - self.runtime = Path.home() / ".local/bin/cybexos-runtime" - self.message = "" - self.process = QProcess(self) - self.process.finished.connect(self.finished) - self.process.errorOccurred.connect(self.failed) - self.process.stateChanged.connect(lambda _: self.changed.emit()) - # Keep diagnostics in the user service's journal, never in a root-owned - # file in the live account's home directory. - self.process.setProcessChannelMode(QProcess.ForwardedChannels) - - # The wallpaper row. `_wallpapers` changes only when the listed images - # do; the current file and download progress are separate properties, - # so polling never rebuilds the row or resets its scroll position. - self._wallpapers = [] - self._source = "loading" - self._note = "" - self._current = "" - self._downloading = "" - self._progress = 0 - self.attempts = 0 - self.local_reason = "" - self.results_pending = False - self.poll_timer = QTimer(self) - self.poll_timer.setSingleShot(True) - self.poll_timer.setInterval(POLL_MS) - self.poll_timer.timeout.connect(self.poll_results) - self.retry_timer = QTimer(self) - self.retry_timer.setSingleShot(True) - self.retry_timer.setInterval(SHELL_RETRY_MS) - self.retry_timer.timeout.connect(self.request_popular) - self._hardware_state = 'pending' - self._hardware_note = '' - self._nvidia_state = '' - self._nvidia_note = '' - self._nvidia_password = '' - self._seed_state = 'pending' - self._network_state = 'checking' - self._reconcile_state = 'pending' - self.setup_timer = QTimer(self) - self.setup_timer.setInterval(4000) - self.setup_timer.timeout.connect(self.refreshSetup) - self.network_process = QProcess(self) - network_environment = QProcessEnvironment.systemEnvironment() - network_environment.insert("LC_ALL", "C") - self.network_process.setProcessEnvironment(network_environment) - self.network_process.finished.connect(self.networkFinished) - self.network_process.errorOccurred.connect(self.networkFailed) - self.reconcile_process = QProcess(self) - self.reconcile_process.finished.connect(self.reconcileFinished) - self.reconcile_process.errorOccurred.connect(self.reconcileFailed) - self.setup_timeout = QTimer(self) - self.setup_timeout.setSingleShot(True) - self.setup_timeout.setInterval(8000) - self.setup_timeout.timeout.connect(self.setupTimedOut) - - def startSetup(self): - # Start only after singleton/autostart checks and successful QML load. - if not self.live: - self.setup_timer.start() - self.refreshSetup() - return - # The installer page validates the seed and shows its countdown; this - # window only opens it, as the Install button would. - self.seed_checks = 0 - self.seed_timer = QTimer(self) - self.seed_timer.setInterval(1000) - self.seed_timer.timeout.connect(self.checkSeed) - self.seed_timer.start() - self.checkSeed() - - def checkSeed(self, by_label=Path("/dev/disk/by-label")): - self.seed_checks += 1 - if seed_volume_present(by_label): - self.seed_timer.stop() - self.install() - elif self.seed_checks >= SEED_ATTEMPTS: - self.seed_timer.stop() - - def stopSetup(self): - self.setup_timer.stop() - self.setup_timeout.stop() - if getattr(self, "seed_timer", None) is not None: - self.seed_timer.stop() - for process in (self.network_process, self.reconcile_process): - # Destruction can emit errors; disconnect before stopping children. - process.finished.disconnect() - process.errorOccurred.disconnect() - if process.state() != QProcess.NotRunning: - process.terminate() - if not process.waitForFinished(200): - process.kill() - process.waitForFinished(200) - - def setupTimedOut(self): - for process in (self.network_process, self.reconcile_process): - if process.state() != QProcess.NotRunning: - process.kill() - - def networkFailed(self, _error): - if self._network_state != 'offline': - self._network_state = 'offline' - self.setupChanged.emit() - - def reconcileFailed(self, _error): - if self._reconcile_state != 'error': - self._reconcile_state = 'error' - self.setupChanged.emit() - - @Slot(str, result="QVariantMap") - def availableWorkArea(self, name): - # QML's ScreenInfo exposes virtual desktop size, which can span - # several monitors. Ask QScreen for this window's actual work area. - screens = QGuiApplication.screens() - screen = next((item for item in screens if item.name() == name), - QGuiApplication.primaryScreen()) - area = screen.availableGeometry() if screen else None - return {"width": max(1, area.width()) if area else 760, - "height": max(1, area.height()) if area else 660} - - @Property(bool, constant=True) - def isLive(self): - return self.live - - @Property(bool, constant=True) - def isInstallMode(self): - return self.install_mode - - @Property(bool, notify=changed) - def busy(self): - return self.process.state() != QProcess.NotRunning - - @Property(str, notify=changed) - def status(self): - return self.message - - @Property(str, notify=setupChanged) - def hardwareState(self): - return self._hardware_state - - @Property(str, notify=setupChanged) - def hardwareNote(self): - return self._hardware_note - - # '' (no NVIDIA GPU) | pending | checking | ready | reboot | enroll | - # skipped | failed; see nvidia_state(). - @Property(str, notify=setupChanged) - def nvidiaState(self): - return self._nvidia_state - - @Property(str, notify=setupChanged) - def nvidiaNote(self): - return self._nvidia_note - - # The one-time Secure Boot enrollment password. Its file is root:wheel - # 0640, so only an administrator's window can show it. - @Property(str, notify=setupChanged) - def nvidiaPassword(self): - return self._nvidia_password - - @Property(str, notify=setupChanged) - def seedState(self): - return self._seed_state - - @Property(str, notify=setupChanged) - def networkState(self): - return self._network_state - - @Property(str, notify=setupChanged) - def reconcileState(self): - return self._reconcile_state - - @staticmethod - def local_json(path): - try: - value = json.loads(path.read_text()) - return value if isinstance(value, dict) else {} - except (OSError, ValueError): - return {} - - @staticmethod - def nvidia_state(policy, status, boot): - """The NVIDIA row's state from the hardware role's status file.""" - state = status.get('state') - if not state or state == 'absent': - return 'pending' if policy.get('nvidia') else '' - # After a restart the boot-time hardware setup confirms the driver - # within minutes; until then the recorded request is out of date. - if state in ('reboot', 'enroll') and status.get('boot_id') not in (None, '', boot): - return 'checking' - if state == 'enroll': - return 'enroll' if status.get('key') == 'pending' else 'skipped' - return state if state in ('ready', 'reboot', 'failed') else 'pending' - - @staticmethod - def enrollment_password(status, enrollment): - password = str(enrollment.get('password', '')) - matches = bool(enrollment.get('key_sha256')) and enrollment.get('key_sha256') == status.get('key_sha256') - return password if matches and password.isdigit() else '' - - @Slot() - def refreshSetup(self): - if self.live: - return - hardware = self.local_json(Path('/var/lib/cybexos/hardware-status.json')) - policy = self.local_json(Path('/etc/cybexos/hardware.json')) - state_dir = Path(os.environ.get('XDG_STATE_HOME', str(Path.home() / '.local/state'))) / 'cybexos' - seed = self.local_json(state_dir / 'seed-progress.json') - nvidia = self.local_json(Path('/var/lib/cybexos/nvidia-status.json')) - try: - boot = Path('/proc/sys/kernel/random/boot_id').read_text().strip() - except OSError: - boot = '' - nvidia_state = self.nvidia_state(policy, nvidia, boot) - values = { - '_hardware_state': 'skipped' if not policy.get('xps_2026') and not policy.get('nvidia') and not hardware - else hardware.get('state', 'pending'), - '_hardware_note': str(hardware.get('reason', '')), - '_nvidia_state': nvidia_state, - '_nvidia_note': str(nvidia.get('reason', '')), - '_nvidia_password': self.enrollment_password( - nvidia, self.local_json(Path('/var/lib/cybexos/nvidia-mok.json'))) if nvidia_state == 'enroll' else '', - '_seed_state': 'ready' if (state_dir / 'offline-apps-seeded').is_file() else seed.get('state', 'pending'), - } - if Path('/var/lib/cybexos/hardware-unsupported').is_file(): - values['_hardware_state'] = 'failed' - if any(getattr(self, key) != value for key, value in values.items()): - for key, value in values.items(): - setattr(self, key, value) - self.setupChanged.emit() - started = False - if self.network_process.state() == QProcess.NotRunning: - started = True - self.network_process.start('/usr/bin/nmcli', ['-t', '-f', 'STATE', 'general']) - if self.reconcile_process.state() == QProcess.NotRunning: - started = True - self.reconcile_process.start('/usr/libexec/cybexos-reconcile', ['--status']) - if started and not self.setup_timeout.isActive(): - self.setup_timeout.start() - - def reconcileFinished(self, code, _status): - output = bytes(self.reconcile_process.readAllStandardOutput()).decode('utf-8', 'replace') - try: - state = json.loads(output).get('state', 'error') if code == 0 else 'error' - except (ValueError, AttributeError): - state = 'error' - if self._reconcile_state != state: - self._reconcile_state = state - self.setupChanged.emit() - - def networkFinished(self, code, _status): - output = bytes(self.network_process.readAllStandardOutput()).decode('utf-8', 'replace').strip() - state = 'connected' if code == 0 and output.splitlines()[:1] == ['connected'] else 'offline' - if self._network_state != state: - self._network_state = state - self.setupChanged.emit() - - @Slot() - def retryApps(self): - if self.live: - return - QProcess.startDetached('/usr/bin/cybex', ['prepare-apps']) - - @Slot() - def retryHardware(self): - if self.live: - return - QProcess.startDetached('/usr/bin/kitty', ['-e', '/usr/bin/cybex', 'repair', '--hardware']) - - @Slot() - def install(self): - if not self.live or self.busy: - return - if not Path("/usr/bin/liveinst").is_file(): - self.message = "The installer is missing. Please use a complete CybexOS live image." - self.changed.emit() - return - self.message = "Opening the installer…" - self.process.start("/usr/bin/liveinst", ["--nosave=all_ks"]) - - def finished(self, code, _status): - self.message = ("The installer has closed. If installation finished, restart and remove the USB drive." - if code == 0 else "The installer could not finish. You can try again; details are in the installer logs.") - self.changed.emit() - - def failed(self, _error): - self.message = "Could not open the installer: " + self.process.errorString() - self.changed.emit() - - @Slot() - def startDesktop(self): - """Leave installation-only mode for the full live desktop.""" - if not self.install_mode or self.busy: - return - success, _ = QProcess.startDetached("systemctl", ["--user", "start", "--no-block", "hyprland-session.target"]) - if not success: - self.message = "The live desktop could not be started." - self.changed.emit() - return - # The desktop session opens its own live welcome window. - QGuiApplication.quit() - - @Slot(str) - def power(self, action): - # Installation-only mode has no shell, so this window offers the power menu. - if not self.install_mode or self.busy or action not in ("reboot", "poweroff"): - return - success, _ = QProcess.startDetached("systemctl", [action]) - if not success: - self.message = "The computer could not be restarted or powered off from here." - self.changed.emit() - - @Slot(str) - def openSettings(self, page): - if page not in self.SHELL_PAGES: - return - success, _ = QProcess.startDetached(str(self.runtime), ["ipc"] + self.SHELL_PAGES[page]) - if not success: - self.message = "The desktop settings could not be opened." - self.changed.emit() - - # Closing the window is how an installed system finishes the welcome; a - # live session never records it. - @Slot() - def markComplete(self): - if self.live: - return - self.marker.parent.mkdir(parents=True, exist_ok=True) - self.marker.touch() - - @Slot() - def finish(self): - if self.busy: - return - self.markComplete() - QGuiApplication.quit() - - # ---- wallpapers --------------------------------------------------- - - @Property("QVariantList", notify=wallpapersChanged) - def wallpapers(self): - return self._wallpapers - - # loading | waiting | online | local | unavailable - @Property(str, notify=wallpaperStateChanged) - def wallpaperSource(self): - return self._source - - @Property(str, notify=wallpaperStateChanged) - def wallpaperNote(self): - return self._note - - @Property(str, notify=wallpaperStateChanged) - def currentFile(self): - return self._current - - @Property(str, notify=wallpaperStateChanged) - def downloadingKey(self): - return self._downloading - - @Property(int, notify=wallpaperStateChanged) - def downloadProgress(self): - return self._progress - - def call(self, arguments, done): - """Runs `cybexos-runtime ipc ARGUMENTS` without blocking the window and - calls done(ok, stdout) exactly once.""" - process = QProcess(self) - settled = [False] - - def settle(ok): - if settled[0]: - return - settled[0] = True - try: - output = bytes(process.readAllStandardOutput()).decode("utf-8", "replace").strip() - process.deleteLater() - except RuntimeError: - # The window, and with it this process, is already gone. - return - done(ok, output) - - process.finished.connect( - lambda code, status: settle(status == QProcess.NormalExit and code == 0)) - # A program that never starts reports only this, never finished(). - process.errorOccurred.connect( - lambda error: settle(False) if error == QProcess.FailedToStart else None) - process.start(str(self.runtime), ["ipc"] + arguments) - - def set_state(self, **values): - changed = False - for name, value in values.items(): - attribute = "_" + name - if getattr(self, attribute) != value: - setattr(self, attribute, value) - changed = True - if changed: - self.wallpaperStateChanged.emit() - - def set_wallpapers(self, entries): - if [entry["key"] for entry in entries] != [entry["key"] for entry in self._wallpapers] \ - or [entry["kind"] for entry in entries] != [entry["kind"] for entry in self._wallpapers]: - self._wallpapers = entries - self.wallpapersChanged.emit() - - @Slot() - def loadWallpapers(self): - """Asks the shell for its Popular Wallhaven listing.""" - if self.live: - return - self.attempts = 0 - self.poll_timer.stop() - self.set_state(source="loading" if not self._wallpapers else self._source, note="") - self.request_popular() - - def request_popular(self): - self.call(["wallpaper", "popular"], self.popular_requested) - - def popular_requested(self, ok, _output): - if ok: - self.poll_results() - return - # The shell may still be starting: the welcome service and the shell - # come up with the same session target. - self.attempts += 1 - if self.attempts < SHELL_ATTEMPTS: - self.set_state(source="waiting", note="") - self.retry_timer.start() - else: - self.set_state(source="unavailable", - note="The desktop shell is not answering, so wallpapers cannot be shown yet.") - - @Slot() - def poll_results(self): - if self.results_pending: - return - self.results_pending = True - self.call(["wallpaper", "results"], self.results_received) - - def results_received(self, ok, output): - self.results_pending = False - state = self.parse(output) if ok else None - if state is None: - self.set_state(source="unavailable", - note="The desktop shell did not answer. Try again in a moment.") - return - busy = bool(state.get("busy")) or not state.get("searched") - items = [item for item in state.get("items") or [] if self.valid_online(item)] - if busy and not items: - self.set_state(source="loading") - self.poll_timer.start() - return - if not items: - # Offline, rate limited or an empty listing: the folder still - # offers something to pick. - self.load_local(state.get("error") or "Wallhaven returned no wallpapers.") - return - self.set_wallpapers([{ - "key": item["id"], - "kind": "online", - "thumb": item["thumb"], - "fileName": item["fileName"], - "label": "{} × {}".format(item.get("width", 0), item.get("height", 0)), - } for item in items]) - downloading = str(state.get("downloading") or "") - self.set_state(source="online", current=str(state.get("current") or ""), - downloading=downloading, - progress=int(state.get("progress") or 0), - note=str(state.get("downloadError") or "")) - if downloading or busy: - self.poll_timer.start() - - @staticmethod - def parse(output): - try: - value = json.loads(output) - except ValueError: - return None - return value if isinstance(value, dict) else None - - @staticmethod - def valid_online(item): - return (isinstance(item, dict) and isinstance(item.get("id"), str) - and isinstance(item.get("thumb"), str) and item["thumb"].startswith("https://th.wallhaven.cc/") - and isinstance(item.get("fileName"), str)) - - def load_local(self, reason=None): - """Lists the wallpaper folder instead; `reason` says why, and None - keeps the reason given when the row first fell back.""" - if reason is not None: - self.local_reason = reason - - def received(ok, output): - state = self.parse(output) if ok else None - if state is None: - self.set_state(source="unavailable", - note="The desktop shell did not answer. Try again in a moment.") - return - folder = Path(str(state.get("dir") or "~/Pictures/Wallpapers")).expanduser() - names = [name for name in state.get("files") or [] - if isinstance(name, str) and name and "/" not in name] - self.set_wallpapers([{ - "key": name, - "kind": "local", - "thumb": QUrl.fromLocalFile(str(folder / name)).toString(), - "fileName": name, - "label": name, - } for name in names]) - self.set_state(source="local", current=str(state.get("current") or ""), - downloading="", progress=0, - note=(self.local_reason + " " if self.local_reason else "") - + "Showing the wallpapers on this computer.") - self.call(["wallpaper", "list"], received) - - @Slot(str) - def pickWallpaper(self, key): - entry = next((entry for entry in self._wallpapers if entry["key"] == key), None) - if entry is None: - return - if entry["kind"] == "online": - self.set_state(downloading=key, progress=0, note="") - self.call(["wallpaper", "pick", key], lambda _ok, _output: self.poll_results()) - else: - self.set_state(current=key, note="") - self.call(["wallpaper", "set", key], - lambda ok, output: self.load_local() if ok and output == "ok" else - self.set_state(note="That wallpaper could not be applied.")) - - @Slot() - def refreshWallpapers(self): - """Catches up with a wallpaper chosen elsewhere (Settings, rotation).""" - if self._source == "online": - self.poll_results() - elif self._source == "local": - self.load_local() - - -def main(): - app = QGuiApplication(sys.argv) - app.setApplicationName("cybex") - app.setDesktopFileName("cybex") - backend = Welcome(install_mode="--install-mode" in sys.argv) - if "--autostart" in sys.argv and not backend.live and backend.marker.exists(): - return 0 - socket_name = str(Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "cybexos-welcome") - client = QLocalSocket() - client.connectToServer(socket_name) - if client.waitForConnected(300): - client.write(b"activate") - client.waitForBytesWritten(300) - return 0 - QLocalServer.removeServer(socket_name) - server = QLocalServer() - server.setSocketOptions(QLocalServer.UserAccessOption) - if not server.listen(socket_name): - raise RuntimeError(server.errorString()) - - def activate(): - connection = server.nextPendingConnection() - if connection: - connection.close() - connection.deleteLater() - backend.activate.emit() - - server.newConnection.connect(activate) - engine = QQmlApplicationEngine() - engine.rootContext().setContextProperty("welcome", backend) - engine.load(QUrl.fromLocalFile(str(QML))) - if not engine.rootObjects(): - return 1 - app.aboutToQuit.connect(backend.stopSetup) - backend.startSetup() - return app.exec() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/image/rootfs/usr/bin/hyprland-quickshell b/image/rootfs/usr/bin/hyprland-quickshell deleted file mode 100755 index c724394d..00000000 --- a/image/rootfs/usr/bin/hyprland-quickshell +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -export XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=Hyprland XDG_SESSION_DESKTOP=Hyprland -export DESKTOP_SESSION=hyprland-quickshell DESKTOP_SHELL=quickshell -export MOZ_ENABLE_WAYLAND=1 QT_QPA_PLATFORM=wayland -# Wayland applications reach IBus through the input-method protocol; -# XWayland applications need its XIM server. -export XMODIFIERS=@im=ibus -export PATH="$HOME/.cargo/bin:$HOME/.local/bin:$HOME/.npm-global/bin:$HOME/Android/Sdk/platform-tools:$HOME/Android/Sdk/cmdline-tools/latest/bin:/usr/local/bin:/usr/bin" -export XDG_DATA_DIRS="$HOME/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" -export ANDROID_HOME="$HOME/Android/Sdk" ANDROID_SDK_ROOT="$HOME/Android/Sdk" -/usr/libexec/cybexos-user-init --essential -# A live boot from the "Install CybexOS" entry starts only the installer. -# Hyprland passes this to cybexos-hyprland-session-start. -if [[ -e /run/cybexos-live && -e /run/cybexos-install-mode ]]; then - export CYBEXOS_SESSION_TARGET=cybexos-install-session.target -else - export CYBEXOS_SESSION_TARGET=hyprland-session.target -fi -CYBEXOS_XPS_2026=$(python3 -c 'import json,pathlib; p=pathlib.Path("/etc/cybexos/hardware.json"); print("1" if p.is_file() and json.loads(p.read_text()).get("xps_2026") else "0")') -# Units gated on the hardware (external-monitor-toggle.service) read it from -# the user manager, so it is imported with the session environment below. -export CYBEXOS_XPS_2026 -# Anaconda writes the system keyboard choice through locale1. Query it on -# every login so the shared image never hardcodes the live account's layout. -keyboard_property() { - busctl --system get-property org.freedesktop.locale1 /org/freedesktop/locale1 \ - org.freedesktop.locale1 "$1" 2>/dev/null | python3 -c \ - 'import shlex,sys; values=shlex.split(sys.stdin.read()); print(values[1] if len(values)>1 else "")' -} -CYBEXOS_KEYBOARD_LAYOUT=$(keyboard_property X11Layout) || true -CYBEXOS_KEYBOARD_VARIANT=$(keyboard_property X11Variant) || true -export CYBEXOS_KEYBOARD_LAYOUT=${CYBEXOS_KEYBOARD_LAYOUT:-us} -export CYBEXOS_KEYBOARD_VARIANT -# NVIDIA session variables, written by the NVIDIA hardware role, apply only -# while the proprietary DRM driver is loaded: a boot that fell back to -# nouveau must not inherit them. Read as data, never sourced. -nvidia_environment=() -if [[ -d /sys/module/nvidia_drm && -r /etc/cybexos/nvidia-session.env ]]; then - while IFS='=' read -r name value; do - case $name in - LIBVA_DRIVER_NAME|__GLX_VENDOR_LIBRARY_NAME|NVD_BACKEND) - [[ $value =~ ^[A-Za-z0-9_.-]+$ ]] || continue - export "$name=$value" - nvidia_environment+=("$name") - ;; - esac - done /dev/null 2>&1 || true' EXIT -# Launch once: compositor failure ends this login and returns to SDDM's -# password prompt. The watchdog's automatic safe-mode restart bypasses it. -Hyprland --config /usr/share/cybexos/runtime/hypr/hyprland.lua "$@" \ - >>"${XDG_STATE_HOME:-$HOME/.local/state}/hyprland/session.log" 2>&1 diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-first-boot.service b/image/rootfs/usr/lib/systemd/system/cybexos-first-boot.service deleted file mode 100644 index c7def9ed..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-first-boot.service +++ /dev/null @@ -1,20 +0,0 @@ -[Unit] -Description=Finish the CybexOS installation or factory reset before the first login -# Only when the installer or a factory reset left work for this boot. -ConditionPathExists=|/var/lib/cybexos/factory-point.pending -ConditionPathExists=|/var/lib/cybexos/factory-reset.json -ConditionPathExists=|/var/lib/cybexos/first-boot -ConditionKernelCommandLine=!rd.live.image -ConditionKernelCommandLine=!cybexos.recovery -After=local-fs.target cybexos-recovery-refresh.service cybexos-update-recover.service -Before=systemd-user-sessions.service display-manager.service cybexos-new-owner.service - -[Service] -Type=oneshot -ExecStart=/usr/libexec/cybexos-first-boot -# Removing a previous owner's home folders can take a while. -TimeoutStartSec=45min -UMask=0077 - -[Install] -WantedBy=multi-user.target diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.service b/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.service deleted file mode 100644 index b071dd76..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Configure detected CybexOS hardware with the shared workstation role -Wants=network-online.target -After=network-online.target -ConditionPathExists=!/run/cybexos-live -ConditionPathExists=/etc/cybexos/hardware.json - -[Service] -Type=oneshot -ExecStart=/usr/libexec/cybexos-configure-installed --hardware --automatic -TimeoutStartSec=45min -Nice=10 - diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.timer b/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.timer deleted file mode 100644 index bbde9997..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-hardware-setup.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Complete detected hardware setup after installation -ConditionPathExists=!/run/cybexos-live - -[Timer] -OnBootSec=2min -OnUnitInactiveSec=15min -RandomizedDelaySec=30s - -[Install] -WantedBy=timers.target diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-new-owner.service b/image/rootfs/usr/lib/systemd/system/cybexos-new-owner.service deleted file mode 100644 index 51622d0e..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-new-owner.service +++ /dev/null @@ -1,32 +0,0 @@ -[Unit] -Description=CybexOS setup for a new owner -ConditionPathExists=/etc/cybexos/oem.json -ConditionKernelCommandLine=!rd.live.image -ConditionKernelCommandLine=!cybexos.recovery -After=cybexos-first-boot.service systemd-vconsole-setup.service -# The login screen and any console login wait until an account exists. -Wants=getty-pre.target -Before=getty-pre.target getty@tty1.service display-manager.service - -[Service] -Type=oneshot -# Free the first console from the boot splash and hide systemd's status lines. -ExecStartPre=-/usr/bin/plymouth quit -ExecStartPre=-/usr/bin/kill -s SIGRTMIN+21 1 -# Shows the graphical setup in cage, as an unprivileged transient user, on -# this console; asks on the console itself when that cannot start. -ExecStart=/usr/libexec/cybexos-new-owner -ExecStopPost=-/usr/bin/kill -s SIGRTMIN+20 1 -StandardInput=tty -StandardOutput=tty -# Diagnostics, cage's included, go to the journal, never onto the console -# the setup is shown on. They never contain an answer. -StandardError=journal -TTYPath=/dev/tty1 -TTYReset=yes -TTYVHangup=yes -TTYVTDisallocate=yes -TimeoutStartSec=infinity - -[Install] -WantedBy=graphical.target diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.service b/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.service deleted file mode 100644 index 03ba5c66..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Reconcile installed CybexOS policy after a desktop upgrade -After=local-fs.target systemd-user-sessions.service -ConditionPathExists=!/run/cybexos-live -ConditionPathExists=/usr/share/cybexos/reconcile-version - -[Service] -Type=oneshot -ExecStart=/usr/libexec/cybexos-reconcile -TimeoutStartSec=45min -Nice=10 -UMask=0022 diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.timer b/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.timer deleted file mode 100644 index 0cdf5039..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.timer +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Apply pending CybexOS desktop policy and initialize new accounts -ConditionPathExists=!/run/cybexos-live - -[Timer] -OnBootSec=2min -OnActiveSec=2min -OnUnitInactiveSec=5min -RandomizedDelaySec=30s - -[Install] -WantedBy=timers.target diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-tailscale-enroll.service b/image/rootfs/usr/lib/systemd/system/cybexos-tailscale-enroll.service deleted file mode 100644 index a74b118f..00000000 --- a/image/rootfs/usr/lib/systemd/system/cybexos-tailscale-enroll.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=Join Tailscale once with the installation's auth key -# Started by cybexos-first-boot only when an unattended seed provided a key, -# so ordinary boots never wait for the network on its account. -ConditionPathExists=/run/cybexos/first-boot/tailscale-authkey -Wants=network-online.target -After=network-online.target tailscaled.service cybexos-first-boot.service - -[Service] -Type=oneshot -ExecStart=/usr/libexec/cybexos-first-boot tailscale -TimeoutStartSec=6min diff --git a/image/rootfs/usr/lib/systemd/user/cybexos-app-seed.service b/image/rootfs/usr/lib/systemd/user/cybexos-app-seed.service deleted file mode 100644 index 97197e3e..00000000 --- a/image/rootfs/usr/lib/systemd/user/cybexos-app-seed.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Prepare offline CybexOS applications -PartOf=hyprland-session.target -After=quickshell.service - -[Service] -Type=oneshot -ExecStart=/usr/libexec/cybexos-user-init --background -TimeoutStartSec=infinity -Nice=10 -IOSchedulingClass=idle diff --git a/image/rootfs/usr/lib/systemd/user/cybexos-session-lock.service b/image/rootfs/usr/lib/systemd/user/cybexos-session-lock.service deleted file mode 100644 index 0f6c582d..00000000 --- a/image/rootfs/usr/lib/systemd/user/cybexos-session-lock.service +++ /dev/null @@ -1,8 +0,0 @@ -[Unit] -Description=Singleton session lock -PartOf=hyprland-session.target -ConditionPathExists=!/run/cybexos-live - -[Service] -ExecStart=/usr/share/cybexos/bin/cybexos-runtime exec hyprlock -TimeoutStopSec=5 diff --git a/image/rootfs/usr/lib/systemd/user/cybexos-welcome.service b/image/rootfs/usr/lib/systemd/user/cybexos-welcome.service deleted file mode 100644 index 3fac2c7c..00000000 --- a/image/rootfs/usr/lib/systemd/user/cybexos-welcome.service +++ /dev/null @@ -1,8 +0,0 @@ -[Unit] -Description=CybexOS welcome -PartOf=hyprland-session.target -After=quickshell.service - -[Service] -ExecStart=/usr/bin/cybexos-welcome --autostart -Restart=no diff --git a/image/rootfs/usr/lib/systemd/user/external-monitor-toggle.service b/image/rootfs/usr/lib/systemd/user/external-monitor-toggle.service deleted file mode 100644 index f8bc8103..00000000 --- a/image/rootfs/usr/lib/systemd/user/external-monitor-toggle.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=Apply XPS lid and external display policy -PartOf=hyprland-session.target -After=graphical-session-pre.target -StartLimitIntervalSec=0 -# hyprland-quickshell exports the detected hardware from -# /etc/cybexos/hardware.json; every other machine skips this unit. -ConditionEnvironment=CYBEXOS_XPS_2026=1 - -[Service] -Type=simple -ExecStart=/usr/libexec/cybexos-external-monitor-toggle -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=hyprland-session.target diff --git a/image/rootfs/usr/lib/systemd/user/hypridle.service.d/50-cybexos.conf b/image/rootfs/usr/lib/systemd/user/hypridle.service.d/50-cybexos.conf deleted file mode 100644 index 18d179bf..00000000 --- a/image/rootfs/usr/lib/systemd/user/hypridle.service.d/50-cybexos.conf +++ /dev/null @@ -1,7 +0,0 @@ -[Unit] -PartOf=hyprland-session.target -ConditionPathExists=!/run/cybexos-live - -[Service] -ExecStart= -ExecStart=/usr/share/cybexos/bin/cybexos-runtime exec hypridle diff --git a/image/rootfs/usr/lib/systemd/user/hyprland-session.target b/image/rootfs/usr/lib/systemd/user/hyprland-session.target deleted file mode 100644 index a6d4f565..00000000 --- a/image/rootfs/usr/lib/systemd/user/hyprland-session.target +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=CybexOS graphical session -# Vendor services start after graphical-session.target; do not order the -# target after every Wanted service, which would close a dependency cycle. -DefaultDependencies=no -BindsTo=graphical-session.target -Wants=graphical-session-pre.target quickshell.service hypridle.service voxtype.service hermes-menubar-bridge.service cybexos-welcome.service cybexos-app-seed.service cybexos-input-method.service external-monitor-toggle.service cybexos-backup.timer -After=graphical-session-pre.target -Before=graphical-session.target diff --git a/image/rootfs/usr/lib/systemd/user/voxtype.service.d/50-cybexos-seed.conf b/image/rootfs/usr/lib/systemd/user/voxtype.service.d/50-cybexos-seed.conf deleted file mode 100644 index 5fce8e68..00000000 --- a/image/rootfs/usr/lib/systemd/user/voxtype.service.d/50-cybexos-seed.conf +++ /dev/null @@ -1,4 +0,0 @@ -[Unit] -# The model is large; let the desktop appear while its offline copy finishes. -Wants=cybexos-app-seed.service -After=cybexos-app-seed.service diff --git a/image/rootfs/usr/libexec/cybexos-config b/image/rootfs/usr/libexec/cybexos-config deleted file mode 100755 index 521add35..00000000 --- a/image/rootfs/usr/libexec/cybexos-config +++ /dev/null @@ -1,370 +0,0 @@ -#!/usr/bin/env python3 -"""Record and change the installation choices saved in /etc/cybexos/config.yml. - -An ISO installation keeps the same schema that ./install writes on a -workstation, so cybexos-configure-installed passes it to provisioning. This -helper never reads, writes or asks for a password. -""" -import argparse -import copy -import grp -import json -import os -from pathlib import Path -import pwd -import re -import subprocess -import sys -import tempfile - -import yaml - -# Source fixtures must exercise this checkout, even on a machine with an older -# packaged policy installed. The installed helper reads its RPM-owned library. -source_image = Path(__file__).resolve().parent.parent.parent.parent -sys.path.insert(0, str(source_image) if (source_image / 'installation_policy.py').is_file() - else '/usr/share/cybexos/lib') -from installation_policy import ( # noqa: E402 - BOOLEANS, FEATURES, apply_choices, choices as validate_choices, defaults as installation_defaults) - - -CONFIG = Path('/etc/cybexos/config.yml') -# Explicit choices for the first account, recorded by the installer from an -# unattended seed or kept by a factory reset. Only a fresh account uses them. -PENDING = 'etc/cybexos/install-choices.json' -INVENTORY = Path('/usr/share/cybexos/provision/inventory/group_vars/all.yml') -APPLY = '/usr/libexec/cybexos-configure-installed' -# Key order and quoting follow ./install's generated file. -STRINGS = ('primary_user', 'primary_group', 'primary_home', 'machine_hostname', 'machine_timezone', - 'machine_locale', 'regional_locale', 'machine_keyboard_layout', 'machine_keyboard_variant') -PATTERNS = { - 'machine_hostname': r'[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}', - 'machine_timezone': r'[A-Za-z0-9_+-]+(?:/[A-Za-z0-9_+-]+)*', - 'machine_locale': r'[A-Za-z][A-Za-z0-9_@.-]+', - 'regional_locale': r'[A-Za-z][A-Za-z0-9_@.-]+', - 'machine_keyboard_layout': r'[A-Za-z0-9_,-]+', - 'machine_keyboard_variant': r'[A-Za-z0-9_,-]*', -} -FALLBACK = {'machine_hostname': 'fedora', 'machine_timezone': 'UTC', 'machine_locale': 'en_US.UTF-8', - 'regional_locale': 'en_US.UTF-8', 'machine_keyboard_layout': 'us', 'machine_keyboard_variant': ''} - - -def group_name(gid): - return grp.getgrgid(gid).gr_name - - -def desktop_accounts(entries): - """The installed desktop accounts that cybexos-configure-installed serves.""" - return [entry for entry in entries - if 1000 <= entry.pw_uid < 65534 and entry.pw_name != 'liveuser' - and Path(entry.pw_dir).parent == Path('/home')] - - -def login_policy(root): - try: - value = json.loads((root / 'etc/cybexos/login.json').read_text()) - except (OSError, ValueError): - return {} - return value if isinstance(value, dict) else {} - - -def assignments(path): - """Read a systemd KEY=value file such as locale.conf or vconsole.conf.""" - try: - lines = path.read_text().splitlines() - except OSError: - return {} - values = {} - for line in lines: - key, separator, value = line.strip().partition('=') - if separator and not key.startswith('#'): - values[key.strip()] = value.strip().strip('"\'') - return values - - -def keyboard(root): - # localed prefers vconsole.conf's XKB keys and otherwise reads the X11 - # file Anaconda writes; hyprland-quickshell reads the same through locale1. - console = assignments(root / 'etc/vconsole.conf') - if console.get('XKBLAYOUT'): - return console['XKBLAYOUT'], console.get('XKBVARIANT', '') - try: - text = (root / 'etc/X11/xorg.conf.d/00-keyboard.conf').read_text() - except OSError: - text = '' - options = dict(re.findall(r'^\s*Option\s+"(Xkb\w+)"\s+"([^"]*)"', text, re.MULTILINE)) - return options.get('XkbLayout', ''), options.get('XkbVariant', '') - - -def identity(root): - try: - hostname = (root / 'etc/hostname').read_text().strip() - except OSError: - hostname = '' - try: - timezone = os.readlink(root / 'etc/localtime').partition('zoneinfo/')[2] - except OSError: - timezone = '' - locale = assignments(root / 'etc/locale.conf').get('LANG', '') - if locale in ('C', 'POSIX') or locale.startswith('C.'): - locale = '' - layout, variant = keyboard(root) - values = {'machine_hostname': hostname, 'machine_timezone': timezone, 'machine_locale': locale, - 'regional_locale': locale, 'machine_keyboard_layout': layout, - 'machine_keyboard_variant': variant if layout else ''} - return {key: value if valid_identity(key, value, root) else FALLBACK[key] - for key, value in values.items()} - - -def valid_identity(key, value, root=Path('/')): - if not isinstance(value, str) or not re.fullmatch(PATTERNS[key], value): - return False - return key != 'machine_timezone' or (root / 'usr/share/zoneinfo' / value).is_file() - - -def pending_choices(root=Path('/')): - """The validated first-account choices, or {} when none were recorded.""" - path = root / PENDING - try: - document = json.loads(path.read_text()) - except FileNotFoundError: - return {} - except (OSError, ValueError) as error: - raise ValueError(f'{path} could not be read: {error}') from None - if not isinstance(document, dict) or document.get('version') != 1: - raise ValueError(f'{path} is not a version 1 choice record.') - try: - return validate_choices(document.get('choices')) - except ValueError as error: - raise ValueError(f'{path}: {error}') from None - - -def detect(root=Path('/'), entries=None, group=group_name, inventory=INVENTORY, fresh_account=False): - """Describe an installed system in ./install's schema, or None if ambiguous.""" - candidates = desktop_accounts(pwd.getpwall() if entries is None else entries) - login = login_policy(root) - account = next((entry for entry in candidates if entry.pw_name == login.get('user')), None) - if account is None and len(candidates) == 1: - account = candidates[0] - if account is None: - return None - values = {'config_schema_version': 1, 'primary_user': account.pw_name, - 'primary_group': group(account.pw_gid), 'primary_home': account.pw_dir, **identity(root)} - values.update(installation_defaults(inventory, fresh_account=fresh_account)) - if not fresh_account: - # Repair may record defaults before its first shared policy application. - # An existing administrator grant is not an explicit opt-out: retain it - # when synthesizing a legacy configuration, until cybex configure changes it. - try: - for line in (root / 'etc/group').read_text().splitlines(): - fields = line.split(':') - if len(fields) == 4 and fields[0] == 'docker': - values['docker_sudoless'] = (account.pw_name in fields[3].split(',') - or str(account.pw_gid) == fields[2]) - except FileNotFoundError: - pass - if fresh_account: - # The same policy function the checkout installer uses for saved - # choices; an older account is never opted into them by repair. - values = apply_choices(values, pending_choices(root)) - # Only the target helper's verified decision (or a saved login policy) - # can enable automatic login; the build host is never an input. - values['desktop_autologin'] = login.get('user') == account.pw_name and login.get('autologin') is True - return values - - -def quoted(value): - return "'" + value.replace("'", "''") + "'" - - -def render(values): - lines = ['---', 'config_schema_version: 1'] - lines += [f'{key}: {quoted(values[key])}' for key in STRINGS] - lines += [f'{key}: {str(values[key]).lower()}' for key in BOOLEANS] - lines += ['features:'] + [f' {key}: {str(values["features"][key]).lower()}' for key in FEATURES] - return '\n'.join(lines) + '\n' - - -def validate(document, root=Path('/'), entries=None, group=group_name): - """Require the complete schema 1 shape and the installed account's identity.""" - if not isinstance(document, dict) or set(document) != {'config_schema_version', 'features', *STRINGS, *BOOLEANS}: - raise ValueError('The configuration does not have the schema 1 keys.') - if type(document['config_schema_version']) is not int or document['config_schema_version'] != 1: - raise ValueError('Only configuration schema 1 is supported.') - features = document['features'] - if not isinstance(features, dict) or set(features) != set(FEATURES): - raise ValueError('The configuration does not have the schema 1 features.') - if not all(type(value) is bool for value in [*features.values(), *(document[key] for key in BOOLEANS)]): - raise ValueError('Configuration choices must be true or false.') - for key in PATTERNS: - if not valid_identity(key, document[key], root): - raise ValueError(f'Invalid {key}: {document[key]!r}') - account = next((entry for entry in desktop_accounts(pwd.getpwall() if entries is None else entries) - if entry.pw_name == document['primary_user']), None) - if account is None or account.pw_dir != document['primary_home'] or group(account.pw_gid) != document['primary_group']: - raise ValueError('primary_user must name an installed desktop account with its home and group.') - return document - - -def parse(text, **options): - try: - document = yaml.safe_load(text) - except yaml.YAMLError as error: - raise ValueError(f'The configuration is not valid YAML: {error}') from None - return validate(document, **options) - - -def write(text, path=CONFIG, replace=True): - """Write atomically as 0644; without replace, never overwrite a saved file.""" - path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) - fd, name = tempfile.mkstemp(prefix='.' + path.name + '.', dir=path.parent) - try: - with os.fdopen(fd, 'w') as stream: - os.fchmod(stream.fileno(), 0o644) - stream.write(text) - stream.flush() - os.fsync(stream.fileno()) - if replace: - os.replace(name, path) - else: - os.link(name, path) - finally: - Path(name).unlink(missing_ok=True) - - -def ensure(root=Path('/'), **options): - """Record a missing configuration; return whether one was written.""" - path = root / 'etc/cybexos/config.yml' - if path.exists() or path.is_symlink(): - return False - values = detect(root, **options) - if values is None: - return False - try: - write(render(values), path, replace=False) - except FileExistsError: - return False - if options.get('fresh_account'): - # config.yml now holds these choices; the record has been consumed. - (root / PENDING).unlink(missing_ok=True) - return True - - -def current(root=Path('/'), entries=None, group=group_name, inventory=INVENTORY): - """Saved choices, completed with detected values for any missing keys.""" - path = root / 'etc/cybexos/config.yml' - values = detect(root, entries, group, inventory) or {} - try: - saved = yaml.safe_load(path.read_text()) - except FileNotFoundError: - if not values: - raise ValueError('No single installed desktop account could be selected.') from None - saved = {} - except (OSError, yaml.YAMLError) as error: - raise ValueError(f'{path} could not be read: {error}') from None - if not isinstance(saved, dict): - raise ValueError(f'{path} does not contain a configuration.') - features = {**values.get('features', {}), **(saved.get('features') or {})} - values.update({key: saved[key] for key in ('config_schema_version', *STRINGS, *BOOLEANS) if key in saved}) - values['features'] = {key: features[key] for key in FEATURES if key in features} - return validate(values, root, entries, group) - - -def ask_bool(prompt, default, read=input): - suffix = ' [Y/n]: ' if default else ' [y/N]: ' - while True: - answer = read(prompt + suffix).strip().lower() - if not answer: - return default - if answer in ('y', 'yes'): - return True - if answer in ('n', 'no'): - return False - print('Please answer yes or no.', file=sys.stderr) - - -def questionnaire(values, read=input): - """Ask only the policy choices that installed-system provisioning applies.""" - result = copy.deepcopy(values) - features = result['features'] - print('CybexOS configuration') - print('Current choices are offered as the default. Hostname, timezone, language and') - print('keyboard are changed in the system settings, not here.') - print() - features['docker'] = ask_bool('Enable Docker?', features['docker'], read) - features['tailscale'] = ask_bool('Enable Tailscale?', features['tailscale'], read) - features['connected_widgets'] = ask_bool('Enable connected-service widgets?', features['connected_widgets'], read) - features['local_network_services'] = ask_bool('Open feature-selected local-network firewall ports?', - features['local_network_services'], read) - print() - print('The following choices relax local authentication. Review each explicitly.') - result['passwordless_wheel'] = ask_bool('Allow the wheel group to use sudo without a password?', - result['passwordless_wheel'], read) - result['passwordless_local_polkit'] = ask_bool( - 'Authorize active local wheel users through Polkit without a password?', - result['passwordless_local_polkit'], read) - result['docker_sudoless'] = features['docker'] and ask_bool( - f"Let {result['primary_user']} use Docker without sudo? (The docker group is equivalent to root access.)", - result['docker_sudoless'], read) - result['desktop_autologin'] = ask_bool( - 'Automatically log this user into Hyprland when the root disk is encrypted?', - result['desktop_autologin'], read) - print() - result['manage_personal_dotfiles'] = ask_bool('Install the opinionated shell and application dotfiles?', - result['manage_personal_dotfiles'], read) - return result - - -def summary(values): - features = values['features'] - print() - print('Configuration summary') - print(f"User: {values['primary_user']} ({values['primary_home']})\nHost: {values['machine_hostname']}") - print(f"Docker: {features['docker']} Tailscale: {features['tailscale']} " - f"Connected widgets: {features['connected_widgets']} " - f"Local-network ports: {features['local_network_services']}") - print(f"Passwordless sudo: {values['passwordless_wheel']} " - f"Passwordless Polkit: {values['passwordless_local_polkit']} " - f"Sudoless Docker: {values['docker_sudoless']} Autologin: {values['desktop_autologin']}") - print(f"Personal dotfiles: {values['manage_personal_dotfiles']}") - if values['desktop_autologin']: - print('Automatic login is saved only after the root filesystem is verified as encrypted,') - print('and every boot verifies it again before logging in.') - - -def main(argv=None, read=input, run=subprocess.run, interactive=None, **options): - parser = argparse.ArgumentParser(prog='cybex configure', - description='Change the saved CybexOS installation choices and apply them.') - parser.add_argument('--check', action='store_true', help='Show the configuration; do not change it') - args = parser.parse_args(argv) - try: - values = current(**options) - except ValueError as error: - print(f'cybex configure: {error}', file=sys.stderr) - return 1 - if args.check: - sys.stdout.write(render(values)) - return 0 - if not (sys.stdin.isatty() if interactive is None else interactive): - print('cybex configure: interactive setup needs a terminal (or use --check)', file=sys.stderr) - return 2 - try: - changed = questionnaire(values, read) - summary(changed) - confirmed = ask_bool('Apply this configuration?', False, read) - except (EOFError, KeyboardInterrupt): - print('\nConfiguration cancelled.') - return 1 - if not confirmed: - print('Configuration cancelled.') - return 0 - # The root helper validates, saves atomically and reapplies the policy. - result = run(['sudo', APPLY, '--user', changed['primary_user'], '--save-config'], - input=render(changed), text=True, check=False) - if result.returncode: - print('cybex configure: the configuration was not applied; see the messages above.', file=sys.stderr) - return result.returncode - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/image/rootfs/usr/libexec/cybexos-configure-installed b/image/rootfs/usr/libexec/cybexos-configure-installed deleted file mode 100755 index 5fc5eff4..00000000 --- a/image/rootfs/usr/libexec/cybexos-configure-installed +++ /dev/null @@ -1,456 +0,0 @@ -#!/usr/bin/env python3 -"""Apply the same baseline/account/hardware tasks used by checkout installs.""" -import argparse -import fcntl -import grp -import hashlib -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import pwd -import signal -import subprocess -import sys -import time -import uuid - - -PROVISION = Path('/usr/share/cybexos/provision') -CONFIG_TOOL = Path('/usr/libexec/cybexos-config') -LOGIN_PREPARE = Path('/usr/libexec/cybexos-login-prepare') -LOGIN = Path('/etc/cybexos/login.json') - - -def load(name, path): - loader = importlib.machinery.SourceFileLoader(name, str(path)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -# A fixed unit also fences an orphaned workload after its caller is killed. -# setsid/double-fork do not escape a systemd cgroup; process groups do. -WORK_UNIT = 'cybexos-provision-work.service' -WORK_CGROUP = Path('/sys/fs/cgroup/system.slice') / WORK_UNIT - - -def populated(): - try: - return 'populated 1' in (WORK_CGROUP / 'cgroup.events').read_text().splitlines() - except FileNotFoundError: - return False - - -def require_manager(): - if not Path('/sys/fs/cgroup/cgroup.controllers').is_file(): - raise RuntimeError('Live provisioning requires the systemd cgroup v2 manager') - subprocess.run(['/usr/bin/systemctl', 'show', '--property=Version', '--value'], - check=True, stdout=subprocess.DEVNULL, timeout=30) - - -def drain_workload(): - """Keep the provisioning lock until the entire previous workload is gone.""" - require_manager() - try: - subprocess.run(['/usr/bin/systemctl', 'stop', WORK_UNIT], check=False, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30) - except subprocess.TimeoutExpired: - pass - warned = False - while populated(): - # cgroup.kill includes sub-cgroups and detached descendants atomically. - # An unkillable kernel task must fence further provisioning/recovery, - # rather than releasing serialization on an unverified timeout. - try: - (WORK_CGROUP / 'cgroup.kill').write_text('1\n') - except FileNotFoundError: - continue - if not warned: - print('Waiting for the provisioning cgroup to become empty; serialization remains held', - file=sys.stderr, flush=True) - warned = True - time.sleep(0.1) - subprocess.run(['/usr/bin/systemctl', 'reset-failed', WORK_UNIT], check=False, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30) - - -def drain(): - # Cancellation cannot unwind the lock's context before teardown finishes. - blocked = {signal.SIGTERM, signal.SIGHUP, signal.SIGINT} - previous = signal.pthread_sigmask(signal.SIG_BLOCK, blocked) - try: - drain_workload() - finally: - signal.pthread_sigmask(signal.SIG_SETMASK, previous) - - -def provision_output(identifier, cursor=None): - """Read a bounded journal batch for this launch, including after collection.""" - command = ['/usr/bin/journalctl', '--boot', '--no-pager', '--output=short-precise', - '--show-cursor', '--lines=100', 'SYSLOG_IDENTIFIER=' + identifier] - if cursor: - command += ['--after-cursor=' + cursor] - try: - result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=5) - output = [] - for line in result.stdout.splitlines(keepends=True): - if line.startswith('-- cursor: '): - cursor = line.removeprefix('-- cursor: ').strip() - else: - output.append(line) - # Bound caller output as well as the number of journal entries. - text = ''.join(output) - if len(text) > 32768: - print('[Provisioning journal batch truncated to its last 32768 characters]', flush=True) - if text: - print(text[-32768:], end='' if text.endswith('\n') else '\n', flush=True) - except (OSError, subprocess.SubprocessError) as error: - # Diagnostic failure must never hide the worker's result or its drain. - print(f'Provisioning journal unavailable ({identifier}): {error}', file=sys.stderr, flush=True) - return cursor - - -def contained(command, *, env, cwd=None, timeout=600): - # Called only while main owns provision.lock. Drain a preceding caller's - # abandoned unit before launching anything or changing saved choices. - drain() - # Journal snapshots preserve service containment without forwarding caller - # descriptors to PID 1. --wait still depends on the public manager bus. - # A fresh identifier prevents the fixed unit's older launches leaking in. - identifier = 'cybexos-provision-' + uuid.uuid4().hex - launch = ['/usr/bin/systemd-run', '--system', '--quiet', '--wait', '--collect', - '--unit=' + WORK_UNIT, '--service-type=exec', - '--property=ExitType=cgroup', '--property=KillMode=control-group', - '--property=StandardOutput=journal', '--property=StandardError=journal', - '--property=SyslogIdentifier=' + identifier, - '--property=TimeoutStopSec=15s', '--property=SendSIGKILL=yes', - '--property=RuntimeMaxSec=' + str(timeout) + 's'] - if cwd: - launch += ['--working-directory=' + str(cwd)] - launch += ['--setenv=' + key + '=' + value for key, value in env.items()] - launch += ['--', *command] - cursor = None - print(f'Provisioning in {WORK_UNIT} (limit {timeout}s); journal identifier: {identifier}', flush=True) - try: - # Poll finite journal snapshots, never an orphanable journal follower. - # Kill the client on cancellation and let its context reap it. Only - # drain() establishes that its service and detached descendants stopped. - with subprocess.Popen(launch, env=env) as client: - deadline = time.monotonic() + timeout + 60 - try: - while True: - try: - result = client.wait(timeout=max(.01, min(15, deadline - time.monotonic()))) - except subprocess.TimeoutExpired: - if time.monotonic() >= deadline: - raise subprocess.TimeoutExpired(launch, timeout + 60) from None - print(f'Provisioning still running in {WORK_UNIT}', flush=True) - cursor = provision_output(identifier, cursor) - else: - if result: - raise subprocess.CalledProcessError(result, launch) - break - finally: - if client.poll() is None: - client.kill() - finally: - # A timed-out systemd-run client is not proof that its unit stopped. - try: - drain() - finally: - provision_output(identifier, cursor) - - -def interrupted(signum, _frame): - raise InterruptedError('Provisioning interrupted by signal ' + str(signum)) - - -def encrypted_root(): - """Reuse the boot-time login check; anything unverifiable is unencrypted.""" - try: - return load('cybexos_login_prepare', LOGIN_PREPARE).encrypted_root() is True - except (OSError, ImportError, SyntaxError): - return False - - -def save_config(settings, text, user): - try: - values = settings.parse(text) - except ValueError as error: - raise SystemExit(f'The configuration was not saved: {error}') from None - if values['primary_user'] != user: - raise SystemExit('The configuration was not saved: it belongs to another account.') - if values['desktop_autologin'] and not encrypted_root(): - raise SystemExit('The configuration was not saved: automatic login requires a root ' - 'filesystem verified as encrypted.') - settings.write(settings.render(values)) - - -def record_login(settings): - """Keep login intent in step with the saved choice, like the finalize role. - - cybexos-login-prepare still verifies root encryption on every boot. - """ - if not settings.CONFIG.is_file(): - return - try: - values = settings.current() - except ValueError as error: - print(f'Login policy unchanged: {error}', file=sys.stderr) - return - policy = {'version': 1, 'user': values['primary_user'], 'autologin': values['desktop_autologin'], 'live': False} - try: - if json.loads(LOGIN.read_text()) == policy: - return - except (OSError, ValueError): - pass - settings.write(json.dumps(policy) + '\n', LOGIN) - - -def accounts(entries, selected=None): - return [entry for entry in entries - if 1000 <= entry.pw_uid < 65534 and entry.pw_name != 'liveuser' - and Path(entry.pw_dir).parent == Path('/home') - and (selected is None or entry.pw_name == selected)] - - -def configure(account, offline=False, hardware=False, reconcile=False, automatic=False, installer_target=False): - # Do not inherit a user's callback plugins, Ansible configuration or Python - # path into a root provisioning process. The payload contains reviewed roles. - environment = { - 'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': '/root', - 'LANG': 'C.UTF-8', 'ANSIBLE_CONFIG': str(PROVISION / 'image/provision.cfg'), - 'ANSIBLE_ROLES_PATH': str(PROVISION / 'roles'), - 'ANSIBLE_STDOUT_CALLBACK': 'default', 'ANSIBLE_FORCE_HANDLERS': 'true', - 'ANSIBLE_PIPELINING': 'true', 'ANSIBLE_LOCALHOST_WARNING': 'false', - 'PYTHONDONTWRITEBYTECODE': '1', - } - values = { - 'primary_user': account.pw_name, 'primary_home': account.pw_dir, - 'primary_group': grp.getgrgid(account.pw_gid).gr_name, - 'config_repo': str(PROVISION), 'cybexos_offline': offline, - 'cybexos_hardware_only': hardware, - # Boot-time setup renews a skipped Secure Boot key request only on - # an explicit cybex repair --hardware. - 'cybexos_hardware_automatic': automatic, - 'cybexos_reconcile': reconcile, - # Repair is not fresh consent for the selected account. In particular, - # another account's saved Docker choice must not become its own. - 'cybexos_docker_direct_install': False, - 'start_optional_hardware_services': not offline, - } - # Seeding a later non-admin account must not promote it to wheel. - wheel = grp.getgrnam('wheel') - values['base_account_groups'] = ['video', 'input'] + ( - ['wheel'] if account.pw_name in wheel.gr_mem or account.pw_gid == wheel.gr_gid else []) - command = ['/usr/bin/ansible-playbook', '-i', 'localhost,', - str(PROVISION / 'image/provision.yml')] - saved = Path('/etc/cybexos/config.yml') - if saved.is_file(): - command += ['--extra-vars', '@' + str(saved)] - command += ['--extra-vars', json.dumps(values)] - if installer_target: - # Only the explicit, verified Anaconda chroot has no target manager. - subprocess.run(command, cwd=PROVISION, env=environment, check=True, timeout=2700) - else: - contained(command, cwd=PROVISION, env=environment, timeout=600 if reconcile else 2700) - - -def retire_legacy_fish(account): - legacy = Path(account.pw_dir) / '.config/fish/conf.d/50-cybex.fish' - vendor = Path('/usr/share/cybexos/essential-seed/.config/fish/conf.d/50-cybex.fish') - # Operate as the owner, never as root through user-controlled directories. - if vendor.is_file(): - subprocess.run(['runuser', '-u', account.pw_name, '--', 'python3', '-c', - 'import pathlib,sys; p=pathlib.Path(sys.argv[1]); v=pathlib.Path(sys.argv[2]); ' - 'p.unlink() if p.is_file() and not p.is_symlink() and p.read_bytes()==v.read_bytes() else None', - str(legacy), str(vendor)], check=True) - - -def boot_id(): - return Path('/proc/sys/kernel/random/boot_id').read_text().strip() - - -def read_hardware_status(path): - try: - value = json.loads(path.read_text()) - return value if isinstance(value, dict) and value.get('state') in ('pending', 'completed', 'failed', 'running') else {} - except (OSError, ValueError): - return {} - - -def write_hardware_status(path, value): - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_suffix('.tmp') - temporary.write_text(json.dumps(value, sort_keys=True) + '\n') - temporary.replace(path) - - -def merge_nvidia(state, reason, nvidia): - """Fold the NVIDIA role's outcome into the hardware state. - - A restart (driver switch or Secure Boot key enrollment) is pending, so - the next boot runs setup again and confirms the driver. A failed driver, - or a key request the user skipped at boot, waits for cybex repair - --hardware; the open-source driver keeps the desktop running meanwhile. - """ - nvidia = nvidia if isinstance(nvidia, dict) else {} - nvidia_state = nvidia.get('state', 'absent') - detail = str(nvidia.get('reason', '')) - needs_retry = nvidia_state == 'failed' or (nvidia_state == 'enroll' and nvidia.get('key') == 'skipped') - if needs_retry and state != 'failed': - return 'failed', detail or 'NVIDIA driver setup failed; run cybex repair --hardware', nvidia - if nvidia_state in ('reboot', 'enroll') and state == 'completed': - # Not "reboot": that word marks the camera transaction's restart. - return 'pending', detail or 'restart required for the NVIDIA driver', nvidia - return state, reason, nvidia - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--installer-target', action='store_true', - help='Provision the offline Anaconda target chroot without a target systemd') - parser.add_argument('--drain', action='store_true', help='Wait for abandoned provisioning workers to stop') - parser.add_argument('--offline', action='store_true') - parser.add_argument('--hardware', action='store_true') - parser.add_argument('--reconcile', action='store_true') - parser.add_argument('--user') - parser.add_argument('--automatic', action='store_true') - parser.add_argument('--save-config', action='store_true', - help='Validate and save a configuration from standard input, then apply it') - args = parser.parse_args() - if os.geteuid() != 0: - parser.error('Run as root in the installed system') - if sum((args.offline, args.hardware, args.reconcile)) > 1: - parser.error('--offline, --hardware, and --reconcile are mutually exclusive') - if args.automatic and not args.hardware: - parser.error('--automatic is only valid for hardware setup') - if args.save_config and (args.offline or args.hardware or args.reconcile or not args.user): - parser.error('--save-config applies one account in the running installed system') - if args.installer_target: - if not args.offline or args.drain or subprocess.run( - ['/usr/bin/systemd-detect-virt', '--chroot', '--quiet'], check=False).returncode != 0: - parser.error('--installer-target requires --offline in a verified installer chroot') - text = sys.stdin.read() if args.save_config else None - lock_directory = Path('/run/lock/cybexos') - lock_directory.mkdir(mode=0o755, exist_ok=True) - with (lock_directory / 'provision.lock').open('a') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - for signum in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT): - signal.signal(signum, interrupted) - if not args.installer_target: - drain() - if args.drain: - return - users = accounts(pwd.getpwall(), args.user) - if args.user and not users: - parser.error('Select an existing installed desktop account') - settings = None if args.hardware or args.reconcile else load('cybexos_config', CONFIG_TOOL) - if args.save_config: - save_config(settings, text, args.user) - elif settings and settings.ensure(fresh_account=args.offline): - # Installation and repair of an older ISO installation record the - # detected choices before provisioning consumes them. - print('Recorded the installation choices in /etc/cybexos/config.yml') - digest = hashlib.sha256() - for path in sorted(PROVISION.rglob('*')): - if path.is_file(): - digest.update(str(path.relative_to(PROVISION)).encode()) - digest.update(path.read_bytes()) - saved_config = Path('/etc/cybexos/config.yml') - if saved_config.is_file(): - digest.update(saved_config.read_bytes()) - identity = digest.hexdigest() + ':' + os.uname().release + ':' + ','.join(user.pw_name for user in users) - marker = Path('/var/lib/cybexos/hardware-configured') - unsupported = marker.with_name('hardware-unsupported') - status_path = marker.with_name('hardware-status.json') - status = read_hardware_status(status_path) if args.hardware else {} - this_boot = boot_id() if args.hardware else '' - same_identity = status.get('identity') == identity - if args.automatic and unsupported.is_file() and unsupported.read_text() == identity: - raise SystemExit('Camera setup needs kernel compatibility review; see /var/lib/xps-hardware/ipu7/abi-failed.log. Automatic retry waits for a changed kernel or provisioning payload.') - if args.automatic and same_identity: - if status['state'] == 'completed': - return - if status['state'] == 'pending' and status.get('boot_id') == this_boot: - return - if status['state'] == 'failed' and status.get('boot_id') == this_boot: - return - if status.get('resume_boot_id') == this_boot: - return - # A legacy marker did not distinguish success from reboot-pending. - # Validate it once and produce a structured outcome before skipping. - if args.hardware and users: - resumed = bool(same_identity and status.get('state') == 'pending' and status.get('boot_id') != this_boot) - write_hardware_status(status_path, {'version': 1, 'state': 'running', 'identity': identity, - 'boot_id': this_boot, 'resume_boot_id': this_boot if resumed else ''}) - marker.with_name('hardware-result.json').unlink(missing_ok=True) - for account in users: - try: - if args.installer_target: - configure(account, args.offline, args.hardware, args.reconcile, args.automatic, - installer_target=True) - else: - configure(account, args.offline, args.hardware, args.reconcile, args.automatic) - except subprocess.CalledProcessError: - diagnostic = Path('/var/lib/xps-hardware/ipu7/abi-failed.log') - # The hardware role deliberately reuses a cached ABI diagnostic - # for an unchanged kernel; its timestamp need not be from this run. - if (args.hardware and diagnostic.is_file() - and 'IPU7_STOCK_ABI_CHANGED' in diagnostic.read_text() - and os.uname().release in diagnostic.read_text()): - unsupported.parent.mkdir(parents=True, exist_ok=True) - unsupported.write_text(identity) - if args.hardware: - write_hardware_status(status_path, {'version': 1, 'state': 'failed', 'identity': identity, - 'boot_id': this_boot, 'reason': 'hardware role failed; run cybex repair --hardware'}) - raise - if args.reconcile: - # Home traversal keeps the account's privileges and shares the - # same containment and serialization as baseline convergence. - contained(['/usr/sbin/runuser', '-u', account.pw_name, '--', '/usr/bin/env', '-i', - 'HOME=' + account.pw_dir, 'USER=' + account.pw_name, - 'PATH=/usr/bin:/bin', 'LANG=C.UTF-8', 'PYTHONDONTWRITEBYTECODE=1', - '/usr/libexec/cybexos-user-init', '--essential'], - env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': '/root', - 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1'}, timeout=120) - if not args.hardware and not args.reconcile: - retire_legacy_fish(account) - # The Anaconda target helper decides login intent after verifying encryption. - if settings and not args.offline: - record_login(settings) - if args.hardware and users: - # The role result is a separate schema, so read it directly. - try: - result = json.loads(marker.with_name('hardware-result.json').read_text()) - except (OSError, ValueError): - result = None - if not isinstance(result, dict) or not isinstance(result.get('reboot_required'), bool): - write_hardware_status(status_path, {'version': 1, 'state': 'failed', 'identity': identity, - 'boot_id': this_boot, 'reason': 'hardware role did not report an outcome'}) - raise SystemExit('Hardware role did not report an outcome; run cybex repair --hardware') - pending_reboot = result['reboot_required'] - preserved = bool(result.get('preserved_previous') and not result.get('camera_ready')) - state = 'pending' if pending_reboot else 'failed' if preserved else 'completed' - reason = ('reboot required for camera validation' if pending_reboot else - 'previous camera stack retained after a failed refresh; run cybex repair --hardware' if preserved else '') - state, reason, nvidia = merge_nvidia(state, reason, result.get('nvidia')) - write_hardware_status(status_path, {'version': 1, 'state': state, - 'identity': identity, 'boot_id': this_boot, - 'reason': reason, - 'camera_ready': result.get('camera_ready', False), - 'preserved_previous': result.get('preserved_previous', False), - 'nvidia_state': nvidia.get('state', 'absent'), - 'nvidia_key': nvidia.get('key', '')}) - if state == 'completed': - pending = marker.with_suffix('.pending') - pending.write_text(identity) - pending.replace(marker) - unsupported.unlink(missing_ok=True) - - -if __name__ == '__main__': - main() diff --git a/image/rootfs/usr/libexec/cybexos-doctor b/image/rootfs/usr/libexec/cybexos-doctor deleted file mode 100755 index 9796aed1..00000000 --- a/image/rootfs/usr/libexec/cybexos-doctor +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python3 -"""Read-only installed-image health summary. Never probes capture hardware.""" -import argparse -import json -import os -from pathlib import Path -import re -import subprocess - - -def run(*argv): - try: - result = subprocess.run(argv, text=True, capture_output=True, timeout=8, check=False, - env={**os.environ, "LC_ALL": "C"}) - return result.returncode, result.stdout.strip() - except (OSError, subprocess.TimeoutExpired): - return 127, '' - - -def read_json(path): - try: - value = json.loads(path.read_text()) - return value if isinstance(value, dict) else {} - except (OSError, ValueError): - return {} - - -def collect(root=Path('/'), home=Path.home(), command=run): - def at(path): - return root / path.lstrip('/') - - checks = [] - def add(name, state, detail, action=''): - checks.append({'name': name, 'state': state, 'detail': detail, 'action': action}) - - build = read_json(at('/usr/share/cybexos/build.json')) - rc, package = command('rpm', '-q', 'cybexos-desktop') - if rc or not re.fullmatch(r'[0-9a-f]{40,64}', str(build.get('source_revision', ''))): - add('build', 'fail', 'Desktop package or build provenance is missing.', 'Reinstall the CybexOS desktop RPM.') - else: - add('build', 'ok', f"{package}; source {str(build['source_revision'])[:12]}") - - rc, channel_text = command('/usr/libexec/cybexos-update-channel', 'status', '--json') - try: - channel = json.loads(channel_text) if rc == 0 else {} - except ValueError: - channel = {} - if not isinstance(channel, dict): - channel = {} - if channel.get('status') == 'desktop-channel-ready': - add('desktop_channel', 'ok', 'Desktop update channel is enabled.') - elif channel.get('status') == 'desktop-channel-disabled': - add('desktop_channel', 'warning', 'Desktop update channel is not enrolled.', - 'Run cybex update-channel status for enrollment details.') - else: - add('desktop_channel', 'fail', 'Desktop update channel cannot be verified.', - 'Run cybex update-channel status.') - - rc, active = command('systemctl', '--user', 'is-active', 'quickshell.service') - add('quickshell', 'ok' if rc == 0 and active == 'active' else 'fail', - 'Quickshell is active.' if rc == 0 and active == 'active' else 'Quickshell is not active.', - '' if rc == 0 and active == 'active' else 'Run systemctl --user status quickshell.service.') - rc, errors = command('hyprctl', 'configerrors') - add('hyprland', 'ok' if rc == 0 and not errors else 'fail', - 'Hyprland configuration has no reported errors.' if rc == 0 and not errors else 'Hyprland reported configuration errors.', - '' if rc == 0 and not errors else 'Run hyprctl configerrors.') - - failed = [] - failed_query_ok = True - for args in (('systemctl', '--failed', '--plain', '--no-legend'), - ('systemctl', '--user', '--failed', '--plain', '--no-legend')): - rc, output = command(*args) - if rc == 0: - failed.extend(line.split()[0] for line in output.splitlines() if line.split()) - else: - failed_query_ok = False - add('failed_units', 'fail' if failed else 'ok' if failed_query_ok else 'warning', - 'Failed units: ' + ', '.join(failed[:8]) if failed else - 'No failed system or user units.' if failed_query_ok else 'Could not query all failed units.', - 'Run systemctl --failed and systemctl --user --failed.' if failed else '') - rc, qml_errors = command('journalctl', '--user', '-b', '-u', 'quickshell.service', '-p', 'warning', '--no-pager', '-o', 'cat') - # Report only a count. Journals may contain private window or widget data. - count = len([line for line in qml_errors.splitlines() if 'qml' in line.lower() or 'qrc:' in line.lower()]) if rc == 0 else 0 - add('qml', 'warning' if count or rc else 'ok', - f'{count} Quickshell QML error lines in this boot.' if count else - 'No Quickshell QML errors in this boot.' if rc == 0 else 'Could not read the Quickshell journal.') - - state_dir = home / '.local/state/cybexos' - seed = read_json(state_dir / 'seed-progress.json') - seed_state = seed.get('state', 'pending') - if (state_dir / 'offline-apps-seeded').is_file(): - seed_state = 'ready' - seed_level = {'ready': 'ok', 'copying': 'pending', 'pending': 'pending', 'error': 'fail'}.get(seed_state, 'warning') - add('seeded_apps', seed_level, - {'ready': 'Offline applications are ready.', 'copying': 'Offline applications are being prepared.', - 'pending': 'Offline applications are pending.', 'error': 'Offline application preparation failed.'}.get(seed_state, 'Offline application status is unknown.'), - 'Run cybex prepare-apps to retry.' if seed_level == 'fail' else '') - - rc, reconciliation_text = command('/usr/libexec/cybexos-reconcile', '--status') - try: - reconciliation = json.loads(reconciliation_text) if rc == 0 else {} - except ValueError: - reconciliation = {} - if not isinstance(reconciliation, dict): - reconciliation = {} - reconcile_state = reconciliation.get('state', 'unknown') - add('reconciliation', {'ready': 'ok', 'pending': 'pending', 'running': 'pending', - 'error': 'fail', 'blocked': 'fail'}.get(reconcile_state, 'warning'), - f'Installed policy reconciliation: {reconcile_state}.', - 'Run sudo /usr/libexec/cybexos-reconcile --retry.' if reconcile_state in ('error', 'blocked') else '') - - hardware_policy = read_json(at('/etc/cybexos/hardware.json')) - hardware = read_json(at('/var/lib/cybexos/hardware-status.json')) - hardware_state = hardware.get('state', 'pending') - if not hardware_policy.get('xps_2026') and not hardware_policy.get('nvidia') and not hardware: - hardware_state = 'skipped' - if at('/var/lib/cybexos/hardware-unsupported').exists(): - hardware_state = 'failed' - add('hardware', {'completed': 'ok', 'pending': 'pending', 'running': 'pending', - 'failed': 'fail', 'skipped': 'skipped'}.get(hardware_state, 'warning'), - f'Hardware setup: {hardware_state}.', - 'Reboot to validate the camera transaction.' if hardware_state == 'pending' and 'reboot' in hardware.get('reason', '') - else 'Restart to finish NVIDIA driver setup.' if hardware_state == 'pending' - and hardware.get('nvidia_state') in ('reboot', 'enroll') - else 'Run cybex repair --hardware.' if hardware_state == 'failed' else '') - - # The helper corrects a request recorded before the last restart. Never - # report the Secure Boot enrollment password here. - rc, nvidia_text = command('/usr/local/libexec/cybexos-nvidia', 'status') - try: - nvidia = json.loads(nvidia_text) if rc == 0 else {} - except ValueError: - nvidia = {} - if not isinstance(nvidia, dict): - nvidia = {} - nvidia_state = nvidia.get('state', 'absent') - if hardware_policy.get('nvidia') or nvidia_state != 'absent': - skipped = nvidia_state == 'enroll' and nvidia.get('key') != 'pending' - add('nvidia', 'fail' if skipped else {'ready': 'ok', 'reboot': 'pending', 'enroll': 'pending', - 'failed': 'fail'}.get(nvidia_state, 'pending'), - {'ready': f"NVIDIA driver {nvidia.get('driver_version', '')} is active.".replace(' ', ' '), - 'reboot': 'NVIDIA driver is installed; a restart switches to it.', - 'enroll': 'Secure Boot key enrollment is pending.' if not skipped - else 'The Secure Boot key was not enrolled; the open-source driver is in use.', - 'failed': 'NVIDIA driver setup failed; the open-source driver is in use.'}.get( - nvidia_state, 'NVIDIA driver setup has not run yet; it needs a network connection.'), - str(nvidia.get('reason', '')) if nvidia_state != 'ready' else '') - - rc, network = command('nmcli', '-t', '-f', 'STATE', 'general') - connected = rc == 0 and network.splitlines()[:1] == ['connected'] - add('network', 'ok' if connected else 'warning', - 'Network is connected.' if connected else 'Network is disconnected or still connecting.', - '' if connected else 'Open network settings to connect.') - - rc, recovery = command('systemctl', 'is-enabled', 'cybexos-recovery-refresh.service') - recovery_files = all(at(path).is_file() for path in ('/etc/grub.d/42_cybexos_recovery', - '/usr/lib/systemd/system/cybexos-recovery-refresh.service')) - add('recovery', 'ok' if recovery_files and rc == 0 and recovery == 'enabled' else 'warning', - 'Recovery refresh is installed and enabled.' if recovery_files and rc == 0 and recovery == 'enabled' - else 'Recovery refresh is not fully enabled.') - - if hardware_policy.get('xps_2026'): - rc, thermal = command('systemctl', 'is-active', 'thermald.service') - add('xps_thermal', 'ok' if rc == 0 and thermal == 'active' else 'warning', - 'XPS thermal service is active.' if rc == 0 and thermal == 'active' else 'XPS thermal service is not active.') - rc, speaker = command('systemctl', '--user', 'is-active', 'xps-speaker-tuning.service') - add('xps_audio', 'ok' if rc == 0 and speaker == 'active' else 'warning', - 'XPS speaker tuning is active.' if rc == 0 and speaker == 'active' else 'XPS speaker tuning is not active.') - - overall = 'fail' if any(item['state'] == 'fail' for item in checks) else \ - 'warning' if any(item['state'] in ('warning', 'pending') for item in checks) else 'ok' - return {'version': 1, 'overall': overall, 'checks': checks} - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--json', action='store_true') - args = parser.parse_args() - result = collect() - if args.json: - print(json.dumps(result, sort_keys=True)) - else: - for item in result['checks']: - print(f"{item['state'].upper():7} {item['name']}: {item['detail']}") - if item['action']: - print(f" {item['action']}") - raise SystemExit(1 if result['overall'] == 'fail' else 0) - - -if __name__ == '__main__': - main() diff --git a/image/rootfs/usr/libexec/cybexos-factory-reset b/image/rootfs/usr/libexec/cybexos-factory-reset deleted file mode 100755 index 3bcdbee6..00000000 --- a/image/rootfs/usr/libexec/cybexos-factory-reset +++ /dev/null @@ -1,339 +0,0 @@ -#!/usr/bin/python3 -"""Return an ISO installation to its factory state for a new owner. - - reset interactive; run through `cybex factory-reset` (sudo) - finish at the next boot, from cybexos-first-boot.service - -`reset` restores the factory recovery point that the installation took at -its first start, after a typed confirmation. On an encrypted disk it first -replaces every disk passphrase with a temporary handover passphrase. At the -next start, `finish` removes every desktop account and everything in /home, -every other recovery point and set-aside system, and update checkpoints. It -keeps the installation's feature choices and hands the machine to the -new-owner setup. Nothing here can be undone. -""" -import contextlib -import getpass -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import shutil -import socket -import subprocess -import sys -import time - -import yaml - -LIBEXEC = Path(__file__).resolve().parent -INSTALLATION = Path('/etc/cybexos/installation.json') -MARKER = 'var/lib/cybexos/factory-reset.json' -TERMINAL = {'committed', 'aborted', 'rolled-back'} - - -class Failure(Exception): - """A refusal, shown without a traceback.""" - - -def load(name, filename): - loader = importlib.machinery.SourceFileLoader(name, str(LIBEXEC / filename)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -def snapshot_module(): - return load('cybexos_snapshot', 'cybexos-system-snapshot') - - -def new_owner_module(): - return load('cybexos_new_owner', 'cybexos-new-owner') - - -def active_transactions(store): - found = [] - for path in sorted((store.path / 'transactions').glob('*/transaction.json')): - try: - state = json.loads(path.read_text()).get('state') - except (OSError, ValueError, AttributeError): - state = 'unreadable' - if state not in TERMINAL: - found.append(path.parent.name) - return found - - -def preflight(snapshot, installation=INSTALLATION): - """The factory point to restore, or the reason a reset is not possible.""" - if not installation.is_file(): - raise Failure('Factory reset is available only on a system installed from the CybexOS ISO.') - layout = snapshot.detect_layout() - if layout.kind == 'recovery': - raise Failure('This is a temporary recovery boot. Restart into the installed system first.') - if not layout.usable: - raise Failure(f'Factory reset needs the standard Btrfs layout ({layout.message or "not found"}).') - if layout.pending_reboot: - raise Failure('A restored recovery point is waiting for a restart. Restart first.') - with snapshot.opened_store(layout, create=False) as store: - point = store.factory_point() if store is not None else '' - if not point: - raise Failure('This installation has no factory recovery point. It is created at the first ' - 'start after a guided installation from the CybexOS ISO.') - if active_transactions(store): - raise Failure('An update has not finished. Let `cybex update` complete or recover it first.') - return layout, point - - -class Console: - def say(self, text=''): - print(text, flush=True) - - def ask(self, prompt): - return input(prompt) - - def secret(self, prompt): - return getpass.getpass(prompt) - - -WARNING = '''Factory reset returns this computer to the state it was installed in and -prepares it for a new owner. When it restarts, it permanently erases: - - - every user account and everything in /home: documents, settings, keys - - all other recovery points and replaced systems - - every change made to the system since installation - -Back up anything you want to keep first, for example with: cybex backup now -''' - - -def confirm(console, hostname): - console.say(WARNING) - answer = console.ask(f'To continue, type the computer name ({hostname}) or "erase": ').strip() - if answer not in (hostname, 'erase'): - raise Failure('The confirmation did not match. Nothing was changed.') - - -def handover(console, devices, run, new_owner): - """The current passphrase (to authorize) and the next owner's temporary one.""" - console.say('\nThis disk is encrypted. The next owner unlocks it once with a temporary') - console.say('passphrase you choose now, then replaces it with their own password.') - current = console.secret('Current disk passphrase: ') - if not all(new_owner.opens(device, current, run) for device in devices): - raise Failure('That passphrase does not unlock this disk. Nothing was changed.') - temporary = console.secret('Temporary passphrase for the next owner: ') - problem = new_owner.validate_password(temporary, console.secret('Confirm the temporary passphrase: ')) - if problem: - raise Failure(problem.replace('password', 'passphrase') + ' Nothing was changed.') - return current, temporary - - -def reset(console=None, run=subprocess.run, snapshot=None, new_owner=None, installation=INSTALLATION, - hostname=None): - if os.geteuid() != 0 and os.environ.get('CYBEXOS_FACTORY_RESET_TESTING') != '1': - raise Failure('Run this through `cybex factory-reset`.') - snapshot = snapshot or snapshot_module() - new_owner = new_owner or new_owner_module() - layout, point = preflight(snapshot, installation) - if console is None: - if not sys.stdin.isatty(): - raise Failure('Factory reset asks for a typed confirmation; run it in a terminal.') - console = Console() - confirm(console, hostname or socket.gethostname()) - devices = new_owner.luks_devices(run) - secrets = handover(console, devices, run, new_owner) if devices else None - console.say('\nResetting. Do not turn the computer off.') - if secrets: - for device in devices: - new_owner.rekey(device, secrets[0], secrets[1], run) - console.say('The disk now opens with the temporary passphrase.') - helper = LIBEXEC / 'cybexos-system-snapshot' - result = run([str(helper), 'restore', point], check=False) - if result.returncode != 0: - raise Failure('The factory state could not be restored; see the message above. ' - + ('The disk passphrase is already the temporary one. ' if secrets else '') - + 'Your accounts and files are unchanged.') - with snapshot.opened_store(layout, create=False) as store: - # Recorded inside the restored root, so only the reset system acts on it. - snapshot.atomic_write(store.top / 'root' / MARKER, - json.dumps({'version': 1, 'point': point, - 'requested': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}) + '\n', - 0o600) - console.say('\nThe factory state is ready. The next start erases all accounts and home folders,') - console.say('then opens the setup for the new owner.') - if console.ask('Restart now? [Y/n]: ').strip().lower() in ('', 'y', 'yes'): - run(['systemctl', 'reboot'], check=False) - return 0 - - -# ---- the next boot --------------------------------------------------------------- - -def device_of(path): - return path.lstat().st_dev - - -def remove_tree(path, run, device_of=device_of): - """Delete a directory tree, including Btrfs subvolumes nested in it. - - A nested subvolume (another st_dev) is deleted as a subvolume; anything - else on another device is refused, never descended into. - """ - if path.is_symlink() or not path.is_dir(): - path.unlink(missing_ok=True) - return - device = device_of(path) - nested = [] - for directory, names, _files in os.walk(path): - keep = [] - for name in names: - child = Path(directory) / name - if not child.is_symlink() and device_of(child) != device: - nested.append(child) - else: - keep.append(name) - names[:] = keep - for child in nested: - result = run(['btrfs', 'subvolume', 'delete', '--recursive', str(child)], check=False, - capture_output=True, timeout=600) - if result.returncode != 0: - raise Failure(f'{child} is a mount, not removable data; nothing below it was touched.') - shutil.rmtree(path) - - -def wipe_home(home, run, device_of=device_of): - """Everything in /home goes; /home itself (a subvolume) stays.""" - for entry in sorted(home.iterdir()): - if not entry.is_symlink() and entry.is_dir() and device_of(entry) != device_of(home): - result = run(['btrfs', 'subvolume', 'delete', '--recursive', str(entry)], check=False, - capture_output=True, timeout=600) - if result.returncode != 0: - raise Failure(f'{entry} is a mount, not removable data.') - continue - remove_tree(entry, run, device_of) - - -def desktop_accounts(root): - rows = [line.split(':') for line in (root / 'etc/passwd').read_text().splitlines() if line.count(':') >= 6] - return [row[0] for row in rows if 1000 <= int(row[2]) < 65534 and row[5].startswith('/home/')] - - -def clear_recovery_history(snapshot): - """Keep only the factory point: every other point holds the old owner's system.""" - layout = snapshot.detect_layout() - if not layout.usable: - return - with snapshot.opened_store(layout, create=False) as store: - if store is None: - return - factory = store.factory_point() - for point in store.ids(): - if point != factory: - snapshot.delete_point(store, point) - running = layout.running.lstrip('/') - for entry in sorted(store.top.iterdir()): - if snapshot.REPLACED_PATTERN.match(entry.name) and entry.name != running: - snapshot.run(['btrfs', 'subvolume', 'delete', '--recursive', str(entry)]) - for directory in (store.replaced, store.path / 'transactions'): - shutil.rmtree(directory, ignore_errors=True) - store.replaced.mkdir(parents=True, exist_ok=True) - if factory: - snapshot.atomic_write(store.path / 'latest', factory + '\n', 0o600) - snapshot.regenerate_quietly(layout, store) - - -def preserved_choices(root, policy): - """The feature choices of the reset installation, in config.yml's schema.""" - try: - saved = yaml.safe_load((root / 'etc/cybexos/config.yml').read_text()) - except (OSError, yaml.YAMLError): - return None, None - if not isinstance(saved, dict): - return None, None - selected = {key: saved[key] for key in policy.CHOICE_BOOLEANS if type(saved.get(key)) is bool} - features = saved.get('features') - if isinstance(features, dict): - selected['features'] = {key: value for key, value in features.items() - if key in policy.FEATURES and type(value) is bool} - wheel = saved.get('passwordless_wheel') - return policy.choices(selected), wheel if type(wheel) is bool else None - - -def installation_policy(): - sys.path.insert(0, '/usr/share/cybexos/lib') - import installation_policy as policy - return policy - - -def write(path, text, mode=0o644): - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_name('.' + path.name + '.factory-reset') - temporary.write_text(text) - temporary.chmod(mode) - temporary.replace(path) - - -def finish(root=Path('/'), run=subprocess.run, snapshot=None, policy=None, device_of=device_of): - """Complete a reset in the restored root; returns whether one was pending.""" - marker = root / MARKER - if not marker.is_file(): - return False - snapshot = snapshot or snapshot_module() - policy = policy or installation_policy() - clear_recovery_history(snapshot) - wipe_home(root / 'home', run, device_of) - for name in desktop_accounts(root): - # Exit 12 only reports that the (already removed) home was missing. - run(['userdel', '--force', '--remove', name], check=False, capture_output=True, timeout=120) - for leftover in (root / 'var/lib/AccountsService/users' / name, root / 'var/lib/systemd/linger' / name): - leftover.unlink(missing_ok=True) - cybexos = root / 'etc/cybexos' - choices, wheel = preserved_choices(root, policy) - if choices is not None: - write(cybexos / 'install-choices.json', json.dumps({'version': 1, 'choices': choices}, sort_keys=True) + '\n') - try: - previous = json.loads((cybexos / 'oem.json').read_text()) - except (OSError, ValueError): - previous = {} - if wheel is None: - wheel = previous.get('passwordless_wheel', False) is True - write(cybexos / 'oem.json', json.dumps({'version': 1, 'passwordless_wheel': wheel}) + '\n') - write(cybexos / 'login.json', json.dumps({'version': 1, 'user': '', 'autologin': False, 'live': False}) + '\n') - (cybexos / 'config.yml').unlink(missing_ok=True) - for leftover in [root / 'etc/sudoers.d/10-wheel-nopasswd', root / 'etc/sddm.conf', - root / 'var/lib/sddm/state.conf', *sorted((root / 'etc/ssh').glob('ssh_host_*'))]: - leftover.unlink(missing_ok=True) - shutil.rmtree(root / 'var/lib/cybexos/first-boot', ignore_errors=True) - try: - details = json.loads((cybexos / 'installation.json').read_text()) - except (OSError, ValueError): - details = {} - details.update(username='', autologin=False, oem=True, - factoryReset=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())) - write(cybexos / 'installation.json', json.dumps(details) + '\n') - marker.unlink() - run(['restorecon', '-RF', '/etc/cybexos', '/home', '/var/lib/cybexos'], check=False, timeout=300) - return True - - -def main(argv): - if argv not in (['reset'], ['finish']): - print('Usage: cybexos-factory-reset reset|finish', file=sys.stderr) - return 2 - os.umask(0o077) - try: - if argv == ['finish']: - finish() - return 0 - return reset() - except Failure as error: - print(f'cybex factory-reset: {error}', file=sys.stderr) - return 1 - except (KeyboardInterrupt, EOFError): - print('\nCancelled.', file=sys.stderr) - return 1 - - -if __name__ == '__main__': - with contextlib.suppress(BrokenPipeError): - sys.exit(main(sys.argv[1:])) diff --git a/image/rootfs/usr/libexec/cybexos-first-boot b/image/rootfs/usr/libexec/cybexos-first-boot deleted file mode 100755 index 65f55c3a..00000000 --- a/image/rootfs/usr/libexec/cybexos-first-boot +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/python3 -"""Finish an installation, or a factory reset, at the start before any login. - -cybexos-first-boot.service runs this only when the installer or a factory -reset left work for this boot: - -1. A pending factory reset is completed (cybexos-factory-reset finish). -2. A seed's Tailscale auth key moves from the disk to memory-backed /run, - so it is never part of a recovery point and is gone after this boot. -3. The installation's factory recovery point is taken once, outside the - normal five-point rotation. - -`tailscale` (cybexos-tailscale-enroll.service, started from step 2) joins -the tailnet once with that key and deletes it, whether or not it worked. -""" -import importlib.machinery -import importlib.util -import os -from pathlib import Path -import shutil -import subprocess -import sys - -LIBEXEC = Path(__file__).resolve().parent -STATE = Path('/var/lib/cybexos') -RUNTIME = Path('/run/cybexos/first-boot') -PENDING = 'factory-point.pending' -SECRETS = 'first-boot' -DESCRIPTION = 'factory state' - - -def log(message): - print(f'cybexos-first-boot: {message}', file=sys.stderr, flush=True) - - -def load(name, filename): - loader = importlib.machinery.SourceFileLoader(name, str(LIBEXEC / filename)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -def move_secrets(state=STATE, runtime=RUNTIME): - """Move staged one-time credentials off the disk; return what moved.""" - source = state / SECRETS - if not source.is_dir(): - return [] - runtime.mkdir(mode=0o700, parents=True, exist_ok=True) - os.chmod(runtime, 0o700) - moved = [] - for path in sorted(source.iterdir()): - if path.is_file() and not path.is_symlink(): - target = runtime / path.name - descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) - with os.fdopen(descriptor, 'wb') as stream: - stream.write(path.read_bytes()) - moved.append(path.name) - path.unlink() - shutil.rmtree(source, ignore_errors=True) - return moved - - -def factory_point(snapshot, state=STATE, run=subprocess.run): - """Take the factory recovery point once; return what happened.""" - marker = state / PENDING - if not marker.is_file(): - return 'none' - layout = snapshot.detect_layout() - if not layout.usable or layout.pending_reboot: - # Without the managed Btrfs layout there are no recovery points at all. - log(f'no factory recovery point: {layout.message or "the root is not ready"}') - marker.unlink() - return 'unsupported' - with snapshot.opened_store(layout, create=False) as store: - existing = store.factory_point() if store is not None else '' - if existing: - # A factory reset restored the point; it still carries this marker. - marker.unlink() - return 'exists' - result = run([str(LIBEXEC / 'cybexos-system-snapshot'), 'create', '--factory', DESCRIPTION], check=False) - if result.returncode != 0: - # Kept for the next start; the unit's failure is visible meanwhile. - raise RuntimeError('the factory recovery point could not be created; it is retried at the next start') - marker.unlink() - return 'created' - - -def enroll_tailscale(runtime=RUNTIME, run=subprocess.run): - key = runtime / 'tailscale-authkey' - if not key.is_file(): - return False - try: - # file: keeps the key off every command line. - result = run(['tailscale', 'up', f'--auth-key=file:{key}', '--timeout=120s'], check=False, timeout=300) - finally: - key.unlink(missing_ok=True) - if result.returncode != 0: - log('Tailscale enrollment failed; the one-time key was deleted. Run `sudo tailscale up` to join manually.') - return result.returncode == 0 - - -def main(argv=None, run=subprocess.run): - arguments = sys.argv[1:] if argv is None else argv - if arguments == ['tailscale']: - return 0 if enroll_tailscale(run=run) else 1 - if arguments: - print('Usage: cybexos-first-boot [tailscale]', file=sys.stderr) - return 2 - if os.geteuid() != 0: - raise SystemExit('cybexos-first-boot must run as root.') - os.umask(0o077) - try: - if (STATE / 'factory-reset.json').is_file(): - if load('cybexos_factory_reset', 'cybexos-factory-reset').finish(): - log('factory reset completed; the new-owner setup starts next') - moved = move_secrets() - status = factory_point(load('cybexos_snapshot', 'cybexos-system-snapshot'), run=run) - except Exception as error: - # Markers stay in place, so the next start retries; the unit shows failed. - log(f'{type(error).__name__}: {error}') - return 1 - if status == 'created': - log('took the factory recovery point') - if 'tailscale-authkey' in moved: - run(['systemctl', 'start', '--no-block', 'cybexos-tailscale-enroll.service'], check=False) - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/image/rootfs/usr/libexec/cybexos-new-owner b/image/rootfs/usr/libexec/cybexos-new-owner deleted file mode 100755 index 720ff1d2..00000000 --- a/image/rootfs/usr/libexec/cybexos-new-owner +++ /dev/null @@ -1,992 +0,0 @@ -#!/usr/bin/python3 -"""Set up a computer prepared for a new owner (OEM mode), before any login. - -An installation made with "Prepare for a new owner", an unattended seed -with `oem: true`, or `cybex factory-reset` leaves /etc/cybexos/oem.json and -no desktop account. cybexos-new-owner.service runs this on the first console -before the login screen starts. The owner chooses a language, keyboard, -account, password and time zone. On an encrypted disk the password also -replaces the temporary disk passphrase: the new passphrase is added, every -other key slot and token is removed, and the old passphrase is checked to no -longer open the disk. The account receives the same provisioning as an -interactive installation, the OEM state is removed, and the computer -restarts into the normal first login and welcome window. - -The questions are asked by a graphical window (cybexos-new-owner-gui) shown -in cage. The window runs as an unprivileged transient user and only returns -answers, as JSON lines on a pipe; this root process checks every answer again -and does all the work. When cage or the window cannot start, stops answering -or breaks the protocol, the same questions are asked as text on this console. - -No password is written to a file, logged, passed in the environment or on a -command line. -""" -import contextlib -import fcntl -import getpass -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import pwd -import re -import select -import struct -import subprocess -import sys -import termios -import time -import unicodedata - -OEM = Path('/etc/cybexos/oem.json') -LIBEXEC = Path(__file__).resolve().parent -CONFIGURE = '/usr/libexec/cybexos-configure-installed' -SEED_USERS = '/usr/libexec/cybexos-seed-installed-users' -RESERVED = {'root', 'liveuser', 'bin', 'daemon', 'adm', 'mail', 'ftp', 'nobody', - 'dbus', 'systemd', 'gdm', 'sddm', 'sshd', 'polkitd', 'chrony'} -USERNAME = re.compile(r'[a-z_][a-z0-9_-]{0,30}') -# `layout` or `layout (variant)`, as the installer and this console write it. -KEYBOARD = re.compile(r'([A-Za-z0-9_-]+)(?: \(([A-Za-z0-9_-]+)\))?') -XKB_RULES = Path('/usr/share/X11/xkb/rules/evdev.lst') -# Written before the account exists and removed with the OEM state, so an -# attempt cut short by a crash or power loss is undone at the next start. -PENDING = 'etc/cybexos/new-owner-pending.json' - -# The graphical window and the program that shows it. -GUI = LIBEXEC / 'cybexos-new-owner-gui' -QML = LIBEXEC.parent / 'share/cybexos/new-owner/Main.qml' -CAGE = Path('/usr/bin/cage') -SYSTEMD_RUN = Path('/usr/bin/systemd-run') -GUI_UNIT = 'cybexos-new-owner-gui.service' -GUI_RUNTIME = '/run/cybexos-new-owner-gui' -CONSOLE_ONLY = 'cybexos.new-owner=console' -# A window that has not drawn by then has failed to start; one that never -# sees a key press is not usable (the console keyboard still is). -READY_TIMEOUT = 60 -INPUT_TIMEOUT = 300 -START_ATTEMPTS = 2 -# The longest line the window may send: generous for 512-character -# passwords escaped as JSON, far below anything that could exhaust memory. -MAX_MESSAGE = 32768 -# What the window may say, with the longest text each field may hold. -MESSAGES = { - 'ready': {}, - 'active': {}, - 'restart': {}, - 'keyboard': {'keyboard': 80, 'locale': 80}, - 'submit': {'locale': 80, 'timezone': 80, 'full_name': 200, 'username': 64, - 'password': 600, 'confirm': 600, 'startup': 600}, -} -# English text for the problem codes the window translates itself. -PROBLEMS = { - 'full-name': 'Use at most 128 characters, without colons.', - 'username-format': 'Use 1–31 lowercase letters, numbers, underscores or dashes, starting with a letter.', - 'username-reserved': 'That name is reserved for the system.', - 'username-taken': 'That name is already in use.', - 'password-length': 'Use a password of 12–512 characters.', - 'password-control': 'The password cannot contain control characters.', - 'password-mismatch': 'The passwords do not match.', - 'startup-wrong': 'That passphrase does not unlock this disk.', - 'boot-keymap': 'This layout cannot be used at the disk unlock screen. Choose another layout.', - 'choice': 'Choose an item from the list.', -} - - -class SetupError(Exception): - """A step failed; the message is safe to show and holds no secret.""" - - -class Unavailable(Exception): - """The graphical setup cannot be used; the console asks instead.""" - - -class NotStarted(Unavailable): - """The window never appeared; a second start may still work.""" - - -class ProtocolError(Unavailable): - """The window sent something it never would; it is not trusted further.""" - - -class FieldError(Exception): - """An answer the window should ask for again; carries no answer text.""" - - def __init__(self, field, code): - super().__init__(f'{field}: {code}') - self.field, self.code = field, code - - -def log(message): - # The unit sends standard error to the journal, never to the console. - print(f'cybexos-new-owner: {message}', file=sys.stderr, flush=True) - - -def load(name, filename): - loader = importlib.machinery.SourceFileLoader(name, str(LIBEXEC / filename)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -def assignments(path): - """KEY=value pairs of a systemd file such as vconsole.conf.""" - try: - lines = path.read_text().splitlines() - except OSError: - return {} - values = {} - for line in lines: - key, separator, value = line.strip().partition('=') - if separator and not key.startswith('#'): - values[key.strip()] = value.strip().strip('"\'') - return values - - -def output(run, command): - return run(command, check=True, capture_output=True, text=True, timeout=60).stdout - - -# ---- disk encryption ----------------------------------------------------------- - -def luks_devices(run=subprocess.run): - """LUKS devices backing the root filesystem, every Btrfs member included.""" - login = load('cybexos_login_prepare', 'cybexos-login-prepare') - mounts = json.loads(output(run, ['findmnt', '--json', '--mountpoint', '/', '--output', - 'SOURCE,TARGET,FSTYPE']))['filesystems'] - source = mounts[0]['source'].split('[', 1)[0] - members = (login.btrfs_members(output(run, ['btrfs', 'filesystem', 'show', '--raw', '/'])) - if mounts[0]['fstype'] == 'btrfs' else [source]) - found = [] - - def visit(nodes, below_crypt): - for node in nodes: - if below_crypt and node.get('fstype') == 'crypto_LUKS' and node.get('path') not in found: - found.append(node['path']) - visit(node.get('children', []), node.get('type') == 'crypt') - - for member in members: - tree = json.loads(output(run, ['lsblk', '--inverse', '--json', '--output', 'PATH,TYPE,FSTYPE', member])) - visit(tree['blockdevices'], False) - return found - - -def opens(device, passphrase, run=subprocess.run, slot=None): - command = ['cryptsetup', 'open', '--test-passphrase', '--key-file=-'] - command += [f'--key-slot={slot}'] if slot is not None else [] - result = run(command + [device], input=passphrase.encode(), capture_output=True, timeout=120, check=False) - return result.returncode == 0 - - -def metadata(device, run=subprocess.run): - """Active LUKS2 key slots and tokens, from the header's JSON metadata.""" - document = json.loads(output(run, ['cryptsetup', 'luksDump', '--dump-json-metadata', device])) - return sorted(document.get('keyslots', {}), key=int), sorted(document.get('tokens', {}), key=int) - - -def rekey(device, current, new, run=subprocess.run): - """Leave exactly one key slot, opened by `new`, and no tokens. - - Re-running after an interruption is safe: an existing `new` slot is kept - and the remaining slots are removed. The volume key itself is unchanged. - """ - if current != new and not opens(device, new, run): - reader, writer = os.pipe() - try: - os.write(writer, new.encode()) - os.close(writer) - writer = None - result = run(['cryptsetup', 'luksAddKey', '--batch-mode', '--key-file=-', device, f'/dev/fd/{reader}'], - input=current.encode(), pass_fds=(reader,), capture_output=True, timeout=300, check=False) - finally: - os.close(reader) - if writer is not None: - os.close(writer) - if result.returncode != 0: - raise SetupError(f'The new passphrase could not be added to {device}. Nothing was removed.') - slots, tokens = metadata(device, run) - keep = next((slot for slot in slots if opens(device, new, run, slot)), None) - if keep is None: - raise SetupError(f'The new passphrase does not open {device}. Nothing was removed.') - for slot in slots: - if slot != keep: - result = run(['cryptsetup', 'luksKillSlot', '--batch-mode', '--key-file=-', device, slot], - input=new.encode(), capture_output=True, timeout=120, check=False) - if result.returncode != 0: - raise SetupError(f'Key slot {slot} of {device} could not be removed.') - for token in tokens: - run(['cryptsetup', 'token', 'remove', '--token-id', token, device], - capture_output=True, timeout=60, check=True) - if current != new and opens(device, current, run): - raise SetupError(f'The previous passphrase still opens {device}.') - - -def initramfs_keymap(root=Path('/'), run=subprocess.run): - """The console keymap in the running kernel's initramfs, or None.""" - image = root / 'boot' / f'initramfs-{os.uname().release}.img' - try: - result = run(['lsinitrd', '--file', 'etc/vconsole.conf', str(image)], - capture_output=True, text=True, timeout=120, check=False) - except (OSError, subprocess.SubprocessError): - return None - if result.returncode != 0: - return None - values = dict(line.strip().partition('=')[::2] for line in result.stdout.splitlines() if '=' in line) - return values.get('KEYMAP', '').strip().strip('"\'') - - -def refresh_boot_keymap(root=Path('/'), run=subprocess.run): - """Rebuild the initramfs when its keymap differs from the chosen one. - - The disk unlock prompt uses the keymap inside the initramfs. Once the - owner's password is the only passphrase, it must be typed with the - layout it was chosen with, so this runs before the re-key. - """ - wanted = assignments(root / 'etc/vconsole.conf').get('KEYMAP', '') - if initramfs_keymap(root, run) == wanted: - return False - run(['dracut', '--regenerate-all', '--force'], check=True, timeout=1800) - return True - - -# ---- the rules every answer follows -------------------------------------------- - -def password_problem(password, confirm): - if not 12 <= len(password) <= 512: - return 'password-length' - if any(ord(char) < 32 or ord(char) == 127 for char in password): - return 'password-control' - if password != confirm: - return 'password-mismatch' - return '' - - -def validate_password(password, confirm): - problem = password_problem(password, confirm) - return PROBLEMS[problem] if problem else '' - - -def full_name_problem(full_name): - if len(full_name) <= 128 and ':' not in full_name and not any(ord(char) < 32 for char in full_name): - return '' - return 'full-name' - - -def username_problem(username, taken=()): - if not USERNAME.fullmatch(username): - return 'username-format' - if username in RESERVED: - return 'username-reserved' - if username in taken: - return 'username-taken' - return '' - - -def derive_username(full_name): - """A suggestion from the first word of a full name: "Jörg Smit" → "jorg".""" - plain = unicodedata.normalize('NFKD', full_name).encode('ascii', 'ignore').decode().lower() - first = next(iter(plain.split()), '') - return re.sub(r'^[^a-z_]+', '', re.sub(r'[^a-z0-9_-]', '', first))[:31] - - -def existing_names(passwd=Path('/etc/passwd')): - return {line.split(':', 1)[0] for line in passwd.read_text().splitlines() if line} - - -def split_keyboard(value): - match = KEYBOARD.fullmatch(value) if isinstance(value, str) else None - return (match[1], match[2] or '') if match else None - - -def keyboard_choices(rules=None, run=subprocess.run): - """Layouts and their variants as `layout`/`layout (variant)` with labels.""" - try: - text = (rules or XKB_RULES).read_text() - except OSError: - text = '' - section, layouts, variants = '', {}, [] - for line in text.splitlines(): - if line.startswith('! '): - section = line[2:].strip() - continue - name, _, description = line.strip().partition(' ') - description = description.strip() - if section == 'layout' and name and description: - layouts[name] = description - elif section == 'variant' and ':' in description: - layout, _, label = description.partition(':') - if layout in layouts and split_keyboard(f'{layout} ({name})'): - variants.append({'id': f'{layout} ({name})', 'label': label.strip()}) - entries = [{'id': name, 'label': label} for name, label in layouts.items() if split_keyboard(name)] + variants - if not entries: - # Without xkeyboard-config's list, localed's own names stand in. - entries = [{'id': name, 'label': name} - for name in output(run, ['localectl', 'list-x11-keymap-layouts']).split() if split_keyboard(name)] - return sorted(entries, key=lambda entry: (entry['label'].casefold(), entry['id'])) - - -def locale_choices(run=subprocess.run): - """The installed UTF-8 locales, the languages the installer offers.""" - return [name for name in output(run, ['localectl', 'list-locales']).split() - if '.utf8' in name.lower().replace('-', '') and not name.startswith('C.')] - - -def timezone_choices(run=subprocess.run): - zones = output(run, ['timedatectl', 'list-timezones']).split() - return sorted(set(zones) | {'UTC'}) - - -def current_settings(root=Path('/'), config=None): - """The installation's keyboard, language and time zone, as defaults.""" - config = config or load('cybexos_config', 'cybexos-config') - layout, variant = config.keyboard(root) - keyboard = f'{layout} ({variant})' if layout and variant else layout - locale = assignments(root / 'etc/locale.conf').get('LANG', '') - try: - timezone = os.readlink(root / 'etc/localtime').partition('zoneinfo/')[2] - except OSError: - timezone = '' - return dict(keyboard=keyboard, locale=locale, timezone=timezone) - - -# ---- the console questions ------------------------------------------------------- - -class Console: - """Terminal input and output; tests replace it with scripted answers.""" - - def say(self, text=''): - print(text, flush=True) - - def ask(self, prompt): - return input(prompt) - - def secret(self, prompt): - return getpass.getpass(prompt) - - -def pick(console, title, values, default, optional=False): - """Choose from a long list by typing a value or the start of one.""" - while True: - answer = console.ask(f'{title} [{default or "none"}]: ').strip() or default - if answer in values or (optional and answer == ''): - return answer - matches = [value for value in values if value.lower().startswith(answer.lower())][:40] - if len(matches) == 1: - return matches[0] - console.say(' ' + (' '.join(matches) if matches else 'No match. Type the start of a name.')) - - -def collect(console, run=subprocess.run, devices=(), passwd=Path('/etc/passwd')): - """Ask the questions; keyboard first, so every later answer is typed with it.""" - console.say('\nWelcome to CybexOS. Set up this computer for its new owner.\n') - layouts = output(run, ['localectl', 'list-x11-keymap-layouts']).split() - layout = pick(console, '1/4 Keyboard layout, for example us, de or nl', layouts, 'us') - listed = run(['localectl', 'list-x11-keymap-variants', layout], capture_output=True, text=True, - timeout=60, check=False) - variants = listed.stdout.split() if listed.returncode == 0 else [] - variant = pick(console, ' Variant, for example dvorak', variants, '', optional=True) if variants else '' - # localed also converts the layout to the console keymap and applies it now. - run(['localectl', 'set-x11-keymap', layout, '', variant], check=True, timeout=60) - keyboard = f'{layout} ({variant})' if variant else layout - locales = output(run, ['localectl', 'list-locales']).split() - locale = pick(console, '2/4 Language, for example en_US.UTF-8 or nl_NL.UTF-8', locales, 'en_US.UTF-8') - console.say('\n3/4 Your account. You will use it to sign in and to run administrator commands.') - full_name = '' - while True: - full_name = console.ask('Full name (optional): ').strip() - if not full_name_problem(full_name): - break - console.say(' ' + PROBLEMS['full-name']) - taken = existing_names(passwd) - while True: - username = console.ask('Username: ').strip() - if not username_problem(username, taken): - break - console.say(' Use 1–31 lowercase letters, numbers, underscores or dashes, starting with a letter. ' - 'System names are reserved.') - while True: - password = console.secret('Password: ') - problem = validate_password(password, console.secret('Confirm password: ')) - if not problem: - break - console.say(' ' + problem) - startup = '' - if devices: - console.say('Your password also unlocks the disk at startup. To replace the temporary disk') - console.say('passphrase, type the passphrase you used to start this computer.') - for _attempt in range(3): - startup = console.secret('Passphrase used at startup: ') - if all(opens(device, startup, run) or opens(device, password, run) for device in devices): - break - console.say(' That passphrase does not unlock this disk.') - else: - raise SetupError('The startup passphrase was not accepted. Starting again.') - zones = output(run, ['timedatectl', 'list-timezones']).split() - timezone = pick(console, '4/4 Timezone, for example Europe/Amsterdam', zones + ['UTC'], 'UTC') - return dict(keyboard=keyboard, locale=locale, timezone=timezone, full_name=full_name, - username=username, password=password, startup=startup) - - -def summary(console, answers, encrypted): - console.say('\nReady to set up:') - console.say(f" Keyboard {answers['keyboard']}") - console.say(f" Language {answers['locale']}") - console.say(f" Timezone {answers['timezone']}") - console.say(f" Account {answers['username']}" + (f" ({answers['full_name']})" if answers['full_name'] else '')) - if encrypted: - console.say(' Disk your password replaces the temporary passphrase') - return console.ask('Create this account? [y/N]: ').strip().lower() in ('y', 'yes') - - -# ---- applying them ----------------------------------------------------------------- - -def read_state(path=OEM): - value = json.loads(path.read_text()) - if not isinstance(value, dict) or value.get('version') != 1 or type(value.get('passwordless_wheel', False)) is not bool: - raise SetupError('The new-owner state is not valid.') - return value - - -def write_text(path, text, mode=0o644): - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_name('.' + path.name + '.new-owner') - temporary.write_text(text) - temporary.chmod(mode) - temporary.replace(path) - - -def discard_account(username, saved_choices, root=Path('/'), run=subprocess.run): - """Undo a half-created account so the next attempt starts clean.""" - pending = root / 'etc/cybexos/install-choices.json' - run(['userdel', '--force', '--remove', username], check=False, timeout=120) - (root / 'var/lib/AccountsService/users' / username).unlink(missing_ok=True) - (root / 'etc/cybexos/config.yml').unlink(missing_ok=True) - if saved_choices is not None and not pending.exists(): - write_text(pending, saved_choices) - - -def recover_interrupted(root=Path('/'), run=subprocess.run, passwd=None): - """Remove an account a crash or power loss left half-provisioned. - - apply() records the account before creating it; only a finished setup - removes the OEM state, so this runs while the setup is still pending. - """ - marker = root / PENDING - if not marker.is_file() or not (root / 'etc/cybexos/oem.json').is_file(): - return '' - try: - record = json.loads(marker.read_text()) - except (OSError, ValueError): - record = {} - username = record.get('username') if isinstance(record, dict) else None - saved = record.get('choices') if isinstance(record, dict) else None - if isinstance(username, str) and USERNAME.fullmatch(username) and username not in RESERVED: - try: - entry = passwd(username) if passwd else pwd.getpwnam(username) - except KeyError: - entry = None - # Only an ordinary account in /home; never a system account. - if entry is None or (entry.pw_uid >= 1000 and entry.pw_dir == f'/home/{username}'): - discard_account(username, saved if isinstance(saved, str) else None, root, run) - log('removed an account an interrupted setup left behind') - else: - username = '' - marker.unlink(missing_ok=True) - return username or '' - - -def apply(answers, state, devices, run=subprocess.run, root=Path('/'), encrypted_root=None, config=None, - progress=None): - """Create and provision the account, then move the disk to its password. - - Any failure removes the new account again, so the next attempt starts - clean. Re-keying is the last disk step and is safe to repeat. `progress` - hears each stage: account, desktop, disk (encrypted only) and finish. - """ - progress = progress or (lambda _stage: None) - username = answers['username'] - pending = root / 'etc/cybexos/install-choices.json' - # Provisioning consumes the saved choices; a failed attempt restores them. - saved_choices = pending.read_text() if pending.is_file() else None - progress('account') - run(['localectl', 'set-locale', f"LANG={answers['locale']}"], check=True, timeout=60) - run(['timedatectl', 'set-timezone', answers['timezone']], check=True, timeout=60) - marker = root / PENDING - write_text(marker, json.dumps({'version': 1, 'username': username, 'choices': saved_choices}) + '\n', 0o600) - try: - run(['useradd', '--create-home', '--groups', 'wheel', '--comment', answers['full_name'], username], - check=True, timeout=120) - except BaseException: - marker.unlink(missing_ok=True) - raise - try: - # chpasswd hashes with the system's configured method (yescrypt). - run(['chpasswd'], input=f"{username}:{answers['password']}\n", text=True, check=True, timeout=60) - # The installer's post-install hook writes the same session record. - write_text(root / 'var/lib/AccountsService/users' / username, - '[User]\nSession=hyprland-quickshell\nXSession=hyprland-quickshell\nSystemAccount=false\n', 0o600) - # Fresh-account provisioning, exactly as Anaconda's in-target hook runs it: - # config.yml is recorded from the installation's saved choices first. - progress('desktop') - run([CONFIGURE, '--offline', '--user', username], check=True) - run([SEED_USERS], check=True) - sudo = root / 'etc/sudoers.d/10-wheel-nopasswd' - if state.get('passwordless_wheel'): - write_text(sudo, '%wheel ALL=(ALL:ALL) NOPASSWD: ALL\n', 0o440) - else: - sudo.unlink(missing_ok=True) - config = config or load('cybexos_config', 'cybexos-config') - if devices: - progress('disk') - # The unlock prompt must read the password with the chosen layout - # before it becomes the only passphrase. - refresh_boot_keymap(root, run) - for device in devices: - rekey(device, answers['startup'], answers['password'], run) - progress('finish') - if encrypted_root is None: - encrypted_root = load('cybexos_login_prepare', 'cybexos-login-prepare').encrypted_root - # As at installation: automatic login only on a verified encrypted root. - autologin = bool(devices) and encrypted_root() is True - values = config.current(root) - values.update(desktop_autologin=autologin, passwordless_wheel=state.get('passwordless_wheel', False)) - config.write(config.render(values), root / 'etc/cybexos/config.yml') - write_text(root / 'etc/cybexos/login.json', - json.dumps({'version': 1, 'user': username, 'autologin': autologin, 'live': False}) + '\n') - except BaseException: - # A half-created account must not survive into the next attempt. - discard_account(username, saved_choices, root, run) - marker.unlink(missing_ok=True) - raise - record = root / 'etc/cybexos/installation.json' - try: - details = json.loads(record.read_text()) - except (OSError, ValueError): - details = {} - details.update(username=username, autologin=autologin, oem=False, - passwordless_wheel=state.get('passwordless_wheel', False), - passwordsInitiallyShared=bool(devices), - newOwnerSetup=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())) - write_text(record, json.dumps(details) + '\n') - # The OEM state goes last: until here, the next start asks again. - (root / 'etc/cybexos/oem.json').unlink(missing_ok=True) - marker.unlink(missing_ok=True) - run(['restorecon', '-RF', '/etc/cybexos', '/etc/sudoers.d', '/var/lib/AccountsService/users', - f'/home/{username}'], check=False, timeout=300) - answers.pop('password', None) - answers.pop('startup', None) - return autologin - - -# ---- the graphical window ------------------------------------------------------- - -# linux/kd.h: the console's display and keyboard modes. -KDSETMODE, KD_TEXT, KD_GRAPHICS = 0x4B3A, 0x00, 0x01 -KDGKBMODE, KDSKBMODE, K_OFF, K_UNICODE = 0x4B44, 0x4B45, 0x04, 0x03 - - -@contextlib.contextmanager -def graphics_mode(fd=0, ioctl=fcntl.ioctl, flush=termios.tcflush): - """Hand the first console's screen and keyboard to the compositor. - - seatd or logind would do this for a login session; the window runs - without one. While the console is in graphics mode with its keyboard - off, nothing typed in the window (a password included) reaches the - console's input buffer. Both are restored afterwards, even after a crash. - """ - try: - saved = struct.unpack('i', ioctl(fd, KDGKBMODE, struct.pack('i', 0)))[0] - ioctl(fd, KDSETMODE, KD_GRAPHICS) - except OSError as error: - raise Unavailable('the first console is not a virtual terminal') from error - try: - ioctl(fd, KDSKBMODE, K_OFF) - yield - finally: - for request, value in ((KDSKBMODE, saved if saved != K_OFF else K_UNICODE), (KDSETMODE, KD_TEXT)): - with contextlib.suppress(OSError): - ioctl(fd, request, value) - with contextlib.suppress(OSError, termios.error): - flush(fd, termios.TCIFLUSH) - - -def parse_message(line): - """One line from the window as a dict, or ProtocolError. Never echoes it.""" - if len(line) > MAX_MESSAGE: - raise ProtocolError('the window sent an oversized message') - try: - message = json.loads(line.decode('utf-8')) - except (UnicodeDecodeError, ValueError): - raise ProtocolError('the window sent a malformed message') from None - if not isinstance(message, dict) or message.get('type') not in MESSAGES: - raise ProtocolError('the window sent an unknown message') - fields = MESSAGES[message['type']] - if set(message) != {'type', *fields}: - raise ProtocolError(f'the window sent an incomplete {message["type"]} message') - for name, limit in fields.items(): - if not isinstance(message[name], str) or len(message[name]) > limit: - raise ProtocolError(f'the window sent an invalid {message["type"]} message') - return message - - -class LineReader: - """Messages from a file descriptor, one JSON object per line.""" - - def __init__(self, fd, limit=MAX_MESSAGE, wait=select.select, read=os.read, clock=time.monotonic): - self.fd, self.limit, self.wait, self.read, self.clock = fd, limit, wait, read, clock - self.buffer = b'' - - def receive(self, timeout=None): - """The next message, None after `timeout` seconds, EOFError at the end.""" - deadline = None if timeout is None else self.clock() + timeout - while b'\n' not in self.buffer: - if len(self.buffer) > self.limit: - raise ProtocolError('the window sent an oversized message') - remaining = None if deadline is None else deadline - self.clock() - if remaining is not None and remaining <= 0: - return None - if not self.wait([self.fd], [], [], remaining)[0]: - return None - chunk = self.read(self.fd, 4096) - if not chunk: - raise EOFError - self.buffer += chunk - line, _, self.buffer = self.buffer.partition(b'\n') - return parse_message(line) - - -def gui_command(keyboard): - """systemd-run for cage and the window as an unprivileged transient user. - - DynamicUser gives a fresh user that owns nothing on the disk, with a - read-only system, private /tmp and no new privileges. The video, input - and render groups open the screen and keyboard (libseat's noop backend: - there is no login session yet). cage reads the keyboard layout from the - XKB variables when it starts, so a new layout means a new session. The - answers travel on the inherited standard input and output, never in - this command or its environment. - """ - layout, variant = split_keyboard(keyboard) or ('us', '') - environment = { - 'XDG_RUNTIME_DIR': GUI_RUNTIME, 'HOME': GUI_RUNTIME, - 'XDG_CACHE_HOME': f'{GUI_RUNTIME}/cache', 'XDG_CONFIG_HOME': f'{GUI_RUNTIME}/config', - 'LIBSEAT_BACKEND': 'noop', 'XKB_DEFAULT_LAYOUT': layout, - # Software rendering works on every graphics device, before any driver - # setup; the window is simple enough not to need more. - 'QT_QPA_PLATFORM': 'wayland', 'QT_QUICK_BACKEND': 'software', - 'QT_WAYLAND_DISABLE_WINDOWDECORATION': '1', 'LANG': 'C.UTF-8', - } - if variant: - environment['XKB_DEFAULT_VARIANT'] = variant - properties = ['DynamicUser=yes', 'SupplementaryGroups=video input render', - f'RuntimeDirectory={Path(GUI_RUNTIME).name}', 'RuntimeDirectoryMode=0700', 'UMask=0077', - 'RestrictAddressFamilies=AF_UNIX AF_NETLINK', 'NoNewPrivileges=yes'] - return [str(SYSTEMD_RUN), '--quiet', '--wait', '--pipe', '--collect', f'--unit={GUI_UNIT}', - '--description=CybexOS setup window for a new owner', - *(f'--property={item}' for item in properties), - *(f'--setenv={name}={value}' for name, value in environment.items()), - '--', str(CAGE), '-d', '--', '/usr/bin/python3', '-I', str(GUI)] - - -class Session: - """One run of cage and the setup window, talking over two pipes.""" - - def __init__(self, keyboard, run=subprocess.run, popen=subprocess.Popen, terminal=graphics_mode): - self.keyboard, self.run, self.popen, self.terminal = keyboard, run, popen, terminal - self.process = self.reader = self.mode = None - - def start(self): - for verb in ('stop', 'reset-failed'): - self.run(['systemctl', verb, GUI_UNIT], check=False, capture_output=True, timeout=60) - mode = self.terminal() - mode.__enter__() - self.mode = mode - try: - self.process = self.popen(gui_command(self.keyboard), stdin=subprocess.PIPE, stdout=subprocess.PIPE) - except OSError as error: - self.stop() - raise NotStarted('systemd-run could not be started') from error - except BaseException: - self.stop() - raise - with contextlib.suppress(OSError, AttributeError): - # The first message carries every list; never block on it. - fcntl.fcntl(self.process.stdin.fileno(), fcntl.F_SETPIPE_SZ, 1 << 20) - self.reader = LineReader(self.process.stdout.fileno()) - - def send(self, message): - """Tell the window something; it may already be gone.""" - if self.process is None: - return - try: - self.process.stdin.write((json.dumps(message, separators=(',', ':')) + '\n').encode()) - self.process.stdin.flush() - except (OSError, ValueError): - pass - - def receive(self, timeout=None): - return self.reader.receive(timeout) - - def stop(self): - if self.process is not None: - with contextlib.suppress(OSError, ValueError): - self.process.stdin.close() - try: - # Closing standard input asks the window to quit. - self.process.wait(timeout=5) - except subprocess.TimeoutExpired: - self.run(['systemctl', 'stop', GUI_UNIT], check=False, capture_output=True, timeout=60) - try: - self.process.wait(timeout=10) - except subprocess.TimeoutExpired: - self.process.kill() - self.process.wait() - with contextlib.suppress(OSError, ValueError): - self.process.stdout.close() - self.process = None - if self.mode is not None: - self.mode.__exit__(None, None, None) - self.mode = None - - -def graphical_unavailable(cmdline=Path('/proc/cmdline'), dev=Path('/dev'), find_spec=importlib.util.find_spec): - """Why the window cannot be shown, or '' when it can.""" - try: - arguments = cmdline.read_text().split() - except OSError: - arguments = [] - if CONSOLE_ONLY in arguments: - return f'{CONSOLE_ONLY} is on the kernel command line' - for program in (CAGE, SYSTEMD_RUN, GUI, QML): - if not program.is_file(): - return f'{program} is missing' - if find_spec('PySide6') is None: - return 'PySide6 is missing' - if not any((dev / 'dri').glob('card[0-9]*')): - return 'there is no graphics device' - if not any((dev / 'input').glob('event[0-9]*')): - return 'there is no input device' - return '' - - -class GraphicalSetup: - """Ask through the window; check and apply its answers as root.""" - - def __init__(self, state, run=subprocess.run, session=Session, devices=None, passwd=Path('/etc/passwd'), - apply=apply, settings=None, root=Path('/'), config=None): - self.state, self.run, self.session_factory, self.passwd = state, run, session, passwd - self.apply, self.root, self.config = apply, root, config - self.devices = luks_devices(run) if devices is None else list(devices) - self.keyboards = keyboard_choices(run=run) - self.locales = locale_choices(run) - self.timezones = timezone_choices(run) - current = settings if settings is not None else current_settings(root, config) - ids = {entry['id'] for entry in self.keyboards} - self.keyboard = current.get('keyboard') if current.get('keyboard') in ids else 'us' - self.locale = next((value for value in (current.get('locale'), 'en_US.UTF-8') if value in self.locales), - next(iter(self.locales), 'en_US.UTF-8')) - self.timezone = current.get('timezone') if current.get('timezone') in self.timezones else 'UTC' - self.active = False - self.done = False - - def init_message(self, focus=''): - return {'type': 'init', 'version': 1, 'encrypted': bool(self.devices), - 'keyboard': self.keyboard, 'locale': self.locale, 'timezone': self.timezone, - 'focus': focus, 'keyboards': self.keyboards, 'locales': self.locales, - 'timezones': self.timezones, 'taken': sorted(existing_names(self.passwd))} - - def run_window(self): - """True once the account exists; Unavailable when the console must ask.""" - focus, failures = '', 0 - while True: - session = self.session_factory(self.keyboard, self.run) - outcome = 'retry' - try: - session.start() - session.send(self.init_message(focus)) - outcome = self.converse(session) - except NotStarted as error: - failures += 1 - # A window that never appeared gets one more try: a graphics - # driver can still be replacing the boot framebuffer. - if failures >= START_ATTEMPTS: - raise - log(f'the setup window did not start ({error}); trying once more') - except Unavailable: - if not self.done: - raise - outcome = 'done' - finally: - session.stop() - if outcome == 'done': - return True - if outcome == 'retry': - time.sleep(2) - else: - # After a new keyboard layout, the window opens where it was. - focus = 'keyboard-test' - - def converse(self, session): - try: - message = session.receive(READY_TIMEOUT) - except EOFError: - message = None - if message is None or message['type'] != 'ready': - raise NotStarted('the setup window did not appear') - while True: - # After setup the window restarts the computer, or this does. - timeout = 60 if self.done else None if self.active else INPUT_TIMEOUT - try: - message = session.receive(timeout) - except EOFError: - if self.done: - return 'done' - raise Unavailable('the setup window closed') from None - if message is None: - if self.done: - return 'done' - raise Unavailable('no key press reached the setup window') - kind = message['type'] - if kind == 'active': - self.active = True - elif kind == 'keyboard' and not self.done: - if self.choose_keyboard(session, message): - return 'restart' - elif kind == 'submit' and not self.done: - self.active = True - if self.submit(session, message): - self.done = True - elif kind == 'restart' and self.done: - return 'done' - - def choose_keyboard(self, session, message): - """Apply a layout to the system now; True when cage must restart.""" - self.locale = message['locale'] if message['locale'] in self.locales else self.locale - keyboard = message['keyboard'] - parts = split_keyboard(keyboard) - if keyboard not in {entry['id'] for entry in self.keyboards} or parts is None: - session.send({'type': 'error', 'field': 'keyboard', 'code': 'choice'}) - return False - if keyboard == self.keyboard: - session.send({'type': 'keyboard', 'keyboard': keyboard}) - return False - try: - # localed also converts the layout to the console keymap. - self.run(['localectl', 'set-x11-keymap', parts[0], '', parts[1]], check=True, timeout=60) - except (OSError, subprocess.SubprocessError): - log('the keyboard layout could not be applied') - session.send({'type': 'error', 'field': 'keyboard', 'code': 'failed'}) - return False - if self.devices and parts[0] != 'us' and not assignments(self.root / 'etc/vconsole.conf').get('KEYMAP'): - # The unlock prompt could not type this password; keep the previous layout. - previous = split_keyboard(self.keyboard) or ('us', '') - self.run(['localectl', 'set-x11-keymap', previous[0], '', previous[1]], check=False, timeout=60) - session.send({'type': 'error', 'field': 'keyboard', 'code': 'boot-keymap'}) - return False - self.keyboard = keyboard - session.send({'type': 'restart'}) - return True - - def validate(self, message): - """The answers apply() takes, or FieldError naming what to ask again.""" - full_name, username = message['full_name'].strip(), message['username'].strip() - password = message['password'] - if full_name_problem(full_name): - raise FieldError('full_name', 'full-name') - problem = username_problem(username, existing_names(self.passwd)) - if problem: - raise FieldError('username', problem) - problem = password_problem(password, message['confirm']) - if problem: - raise FieldError('confirm' if problem == 'password-mismatch' else 'password', problem) - if message['locale'] not in self.locales: - raise FieldError('locale', 'choice') - if message['timezone'] not in self.timezones: - raise FieldError('timezone', 'choice') - startup = message['startup'] if self.devices else '' - if self.devices and not all(opens(device, startup, self.run) or opens(device, password, self.run) - for device in self.devices): - raise FieldError('startup', 'startup-wrong') - return dict(keyboard=self.keyboard, locale=message['locale'], timezone=message['timezone'], - full_name=full_name, username=username, password=password, startup=startup) - - def submit(self, session, message): - try: - answers = self.validate(message) - except FieldError as error: - session.send({'type': 'error', 'field': error.field, 'code': error.code}) - return False - self.locale = answers['locale'] - try: - self.apply(answers, self.state, self.devices, self.run, - progress=lambda stage: session.send({'type': 'progress', 'stage': stage})) - except (SetupError, subprocess.SubprocessError, OSError, ValueError) as error: - # SetupError text is written for people and holds no secret; the - # others are summarized by type, never by their arguments. - detail = str(error) if isinstance(error, SetupError) else '' - log(f'setup did not finish: {detail or type(error).__name__}; nothing was kept') - session.send({'type': 'error', 'field': '', 'code': 'failed', 'detail': detail}) - return False - session.send({'type': 'done'}) - return True - - -def restart(run): - run(['systemctl', '--no-block', 'reboot'], check=False) - return 0 - - -def main(console=None, run=subprocess.run, graphical=GraphicalSetup, unavailable=graphical_unavailable, - root=Path('/')): - oem = root / 'etc/cybexos/oem.json' - if os.geteuid() != 0: - raise SystemExit('cybexos-new-owner must run as root.') - if not oem.exists(): - return 0 - state = read_state(oem) - recover_interrupted(root, run) - reason = unavailable() - if not reason: - try: - if graphical(state, run).run_window(): - return restart(run) - except Unavailable as error: - reason = str(error) - except Exception as error: - # A fault in the window's path must never leave nobody able to sign in. - reason = f'unexpected {type(error).__name__}' - if not oem.exists(): - return restart(run) - log(f'asking on the console: {reason}') - console = console or Console() - failures = 0 - while True: - try: - devices = luks_devices(run) - answers = collect(console, run, devices) - if not summary(console, answers, bool(devices)): - console.say('Nothing was changed. Starting again.') - continue - console.say('\nSetting up your account. This takes a few minutes…') - apply(answers, state, devices, run) - break - except (KeyboardInterrupt, EOFError): - # Never leave the console: without an account nobody could sign in. - failures += 1 - console.say('\nStarting again.') - time.sleep(min(failures, 5)) - except (SetupError, subprocess.SubprocessError, OSError, ValueError) as error: - failures += 1 - console.say(f'\nSetup did not finish: {error}\nNothing was kept. Starting again.') - time.sleep(min(failures, 5)) - except Exception as error: - failures += 1 - console.say(f'\nSetup did not finish ({type(error).__name__}). Nothing was kept. Starting again.') - time.sleep(min(failures, 5)) - console.say('\nDone. Restarting into your new desktop…') - return restart(run) - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/image/rootfs/usr/libexec/cybexos-new-owner-gui b/image/rootfs/usr/libexec/cybexos-new-owner-gui deleted file mode 100755 index c3547c5c..00000000 --- a/image/rootfs/usr/libexec/cybexos-new-owner-gui +++ /dev/null @@ -1,413 +0,0 @@ -#!/usr/bin/python3 -"""The new owner's setup window, shown in cage before the login screen. - -cybexos-new-owner (root) starts this as an unprivileged transient user and -sends one `init` message, with every list to choose from, on standard input. -The window only asks; it changes nothing itself. Answers go back as JSON -lines on the standard output it inherited, and the root service checks each -one again before doing any work. Nothing typed here is written to a file, -a log, the environment or a command line. -""" -import functools -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import select -import sys - -from PySide6.QtCore import QEvent, QLocale, QObject, Property, QSocketNotifier, QUrl, Signal, Slot -from PySide6.QtGui import QFont, QGuiApplication -from PySide6.QtQml import QQmlApplicationEngine -from PySide6.QtQuickControls2 import QQuickStyle - -HERE = Path(__file__).resolve().parent -# Beside this program in both layouts: /usr/libexec → /usr/share when -# installed, image/rootfs/usr/libexec → image/rootfs/usr/share in a checkout. -QML = HERE.parent / 'share/cybexos/new-owner/Main.qml' -MAX_INIT = 4 << 20 -INIT_TIMEOUT = 30 -STAGES = ('account', 'desktop', 'disk', 'finish') - - -def load_rules(): - """The root service's own answer rules, so both check the same way.""" - loader = importlib.machinery.SourceFileLoader('cybexos_new_owner_rules', str(HERE / 'cybexos-new-owner')) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -rules = load_rules() - - -def capitalized(text): - return text[:1].upper() + text[1:] - - -# glibc's script modifiers, as Qt's script codes. -SCRIPTS = {'latin': 'Latn', 'cyrillic': 'Cyrl'} - - -@functools.cache -def language_name(language, script): - """The language in its own words, without a region: "English", not - "American English"; "Nederlands", not "Vlaams". CLDR names some regional - variants, so the name most of the language's locales use wins.""" - names = [locale.nativeLanguageName() for locale in - QLocale.matchingLocales(language, script, QLocale.Country.AnyCountry)] - names = [name for name in names if name] - if not names: - return QLocale.languageToString(language) - return capitalized(max(sorted(set(names)), key=lambda name: (names.count(name), -len(name)))) - - -def locale_entries(names): - """Each locale by its own name, e.g. "Nederlands (Nederland)", English beside it.""" - entries = [] - for name in names: - base, _, modifier = name.partition('@') - code = base.split('.', 1)[0] - if modifier in SCRIPTS and '_' in code: - language, territory = code.split('_', 1) - code, modifier = f'{language}_{SCRIPTS[modifier]}_{territory}', '' - locale = QLocale(code) - if locale.language() in (QLocale.Language.C, QLocale.Language.AnyLanguage): - label, english = name, '' - else: - label = language_name(locale.language(), locale.script()) - english = QLocale.languageToString(locale.language()) - if '_' in code: - label += f' ({locale.nativeTerritoryName() or QLocale.territoryToString(locale.territory())})' - english += f' ({QLocale.territoryToString(locale.territory())})' - if modifier: - label += f' · {modifier}' - english += f' · {modifier}' if english else '' - entries.append({'id': name, 'label': label, 'detail': english if english != label else '', - 'search': f'{label} {english} {name}'.casefold()}) - return sorted(entries, key=lambda entry: (entry['label'].casefold(), entry['id'])) - - -def timezone_entries(zones): - """"Amsterdam" with "Europe" beside it; searchable by either.""" - entries = [] - for zone in zones: - region, _, place = zone.rpartition('/') - label, detail = place.replace('_', ' '), region.replace('_', ' ').replace('/', ' / ') - entries.append({'id': zone, 'label': label, 'detail': detail, - 'search': f'{zone} {label} {detail}'.casefold()}) - return entries - - -def keyboard_entries(keyboards): - return [{'id': item['id'], 'label': item['label'], 'detail': item['id'], - 'search': f"{item['label']} {item['id']}".casefold()} for item in keyboards] - - -def valid_init(message): - def strings(value): - return isinstance(value, list) and all(isinstance(item, str) for item in value) - - return (isinstance(message, dict) and message.get('type') == 'init' and message.get('version') == 1 - and isinstance(message.get('encrypted'), bool) - and all(isinstance(message.get(key), str) for key in ('keyboard', 'locale', 'timezone', 'focus')) - and strings(message.get('locales')) and strings(message.get('timezones')) - and strings(message.get('taken')) and isinstance(message.get('keyboards'), list) - and all(isinstance(item, dict) and isinstance(item.get('id'), str) and isinstance(item.get('label'), str) - for item in message['keyboards'])) - - -def read_init(fd, limit=MAX_INIT, timeout=INIT_TIMEOUT, wait=select.select, read=os.read): - """The first message, and whatever followed it; None when it is unusable.""" - buffer = b'' - while b'\n' not in buffer: - if len(buffer) > limit or not wait([fd], [], [], timeout)[0]: - return None - chunk = read(fd, 65536) - if not chunk: - return None - buffer += chunk - line, _, rest = buffer.partition(b'\n') - try: - message = json.loads(line) - except ValueError: - return None - return (message, rest) if valid_init(message) else None - - -class Channel(QObject): - """JSON lines from the root service on one descriptor, to it on another.""" - - def __init__(self, read_fd, write_fd, buffer=b''): - super().__init__() - self.read_fd, self.write_fd, self.buffer = read_fd, write_fd, buffer - self.on_message = lambda message: None - self.on_closed = lambda: None - self.notifier = QSocketNotifier(read_fd, QSocketNotifier.Type.Read, self) - self.notifier.activated.connect(self.readable) - - def readable(self, *_): - try: - chunk = os.read(self.read_fd, 65536) - except BlockingIOError: - return - except OSError: - chunk = b'' - if not chunk: - self.notifier.setEnabled(False) - self.on_closed() - return - self.buffer += chunk - self.dispatch() - - def dispatch(self): - while b'\n' in self.buffer: - line, _, self.buffer = self.buffer.partition(b'\n') - try: - message = json.loads(line) - except ValueError: - continue - if isinstance(message, dict): - self.on_message(message) - - def send(self, message): - data = (json.dumps(message, separators=(',', ':')) + '\n').encode() - try: - while data: - data = data[os.write(self.write_fd, data):] - except OSError: - self.on_closed() - - -class Setup(QObject): - """What the window shows and may ask; QML reads it as `setup`.""" - - changed = Signal() - languageChanged = Signal() - finished = Signal() - - def __init__(self, init, send): - super().__init__() - self.send = send - self._encrypted = init['encrypted'] - self._keyboards = keyboard_entries(init['keyboards']) - self._locales = locale_entries(init['locales']) - self._timezones = timezone_entries(init['timezones']) - self.taken = set(init['taken']) - self._keyboard = init['keyboard'] - self._locale = init['locale'] - self._timezone = init['timezone'] - self._focus = init['focus'] - # idle | switching (a new keyboard layout) | working | failed | done - self._state = 'idle' - self._stage = '' - self._error = ('', '', '') - self.ready_sent = self.active_sent = False - - def update(self, **values): - for name, value in values.items(): - setattr(self, '_' + name, value) - self.changed.emit() - - @Property(bool, constant=True) - def encrypted(self): - return self._encrypted - - @Property('QVariantList', constant=True) - def keyboards(self): - return self._keyboards - - @Property('QVariantList', constant=True) - def locales(self): - return self._locales - - @Property('QVariantList', constant=True) - def timezones(self): - return self._timezones - - @Property(str, constant=True) - def timezone(self): - return self._timezone - - @Property(str, constant=True) - def focus(self): - return self._focus - - @Property('QVariantList', constant=True) - def stages(self): - return [stage for stage in STAGES if stage != 'disk' or self._encrypted] - - @Property(str, notify=changed) - def keyboard(self): - return self._keyboard - - def get_locale(self): - return self._locale - - def set_locale(self, value): - if value != self._locale and any(entry['id'] == value for entry in self._locales): - self._locale = value - self.languageChanged.emit() - - locale = Property(str, get_locale, set_locale, notify=languageChanged) - - # The window's own text: Dutch for a Dutch locale, English otherwise. - @Property(str, notify=languageChanged) - def language(self): - return 'nl' if self._locale.startswith('nl_') else 'en' - - @Property(str, notify=changed) - def state(self): - return self._state - - @Property(str, notify=changed) - def stage(self): - return self._stage - - @Property(str, notify=changed) - def errorField(self): - return self._error[0] - - @Property(str, notify=changed) - def errorCode(self): - return self._error[1] - - @Property(str, notify=changed) - def errorDetail(self): - return self._error[2] - - # ---- the rules, shared with the root service -------------------------- - - @Slot(str, result=str) - def deriveUsername(self, full_name): - return rules.derive_username(full_name) - - @Slot(str, result=str) - def fullNameProblem(self, full_name): - return rules.full_name_problem(full_name.strip()) - - @Slot(str, result=str) - def usernameProblem(self, username): - return rules.username_problem(username.strip(), self.taken) - - @Slot(str, str, result=str) - def passwordProblem(self, password, confirm): - return rules.password_problem(password, confirm) - - # ---- requests ----------------------------------------------------------- - - @Slot(str) - def chooseKeyboard(self, keyboard): - if self._state != 'idle' or keyboard == self._keyboard: - return - if not any(entry['id'] == keyboard for entry in self._keyboards): - return - self.update(state='switching', error=('', '', '')) - self.send({'type': 'keyboard', 'keyboard': keyboard, 'locale': self._locale}) - - @Slot(str, str, str, str, str, str) - def submit(self, full_name, username, password, confirm, startup, timezone): - if self._state not in ('idle', 'failed'): - return - self.update(state='working', stage='', error=('', '', '')) - self.send({'type': 'submit', 'locale': self._locale, 'timezone': timezone, 'full_name': full_name, - 'username': username, 'password': password, 'confirm': confirm, - 'startup': startup if self._encrypted else ''}) - - @Slot() - def restartNow(self): - if self._state == 'done': - self.send({'type': 'restart'}) - - @Slot(str) - def clearError(self, field): - if self._error[0] == field or (field == '' and self._state == 'failed'): - self.update(state='idle' if self._state == 'failed' else self._state, error=('', '', '')) - - @Slot() - def noteReady(self): - if not self.ready_sent: - self.ready_sent = True - self.send({'type': 'ready'}) - - def noteActive(self): - if not self.active_sent: - self.active_sent = True - self.send({'type': 'active'}) - - # ---- answers from the root service ------------------------------------- - - def receive(self, message): - kind = message.get('type') - if kind == 'restart': - # A new keyboard layout: cage starts again with it. - self.finished.emit() - elif kind == 'keyboard': - self.update(state='idle') - elif kind == 'progress' and isinstance(message.get('stage'), str): - self.update(state='working', stage=message['stage']) - elif kind == 'done': - self.update(state='done', stage='') - elif kind == 'error': - field, code = str(message.get('field', '')), str(message.get('code', '')) - self.update(state='failed' if not field else 'idle', - error=(field, code, str(message.get('detail', '')))) - - -class Activity(QObject): - """Reports the first key press once: proof that the keyboard reaches the window.""" - - def __init__(self, notice): - super().__init__() - self.notice = notice - - def eventFilter(self, watched, event): - if event.type() == QEvent.Type.KeyPress: - self.notice() - return False - - -def main(): - os.umask(0o077) - # Standard output carries answers only. Anything else printed, by Qt or a - # library, goes to standard error, which is the journal. - answers = os.dup(1) - os.dup2(2, 1) - received = read_init(0) - if received is None: - print('cybexos-new-owner-gui: no usable setup data', file=sys.stderr) - return 1 - init, rest = received - app = QGuiApplication(sys.argv[:1]) - app.setApplicationName('cybexos-new-owner') - # Plain QML text takes the application font: the welcome window's face. - app.setFont(QFont('Figtree')) - QQuickStyle.setStyle('Basic') - channel = Channel(0, answers, rest) - setup = Setup(init, channel.send) - channel.on_message = setup.receive - channel.on_closed = app.quit - setup.finished.connect(app.quit) - - def active(): - setup.noteActive() - app.removeEventFilter(activity) - - activity = Activity(active) - app.installEventFilter(activity) - engine = QQmlApplicationEngine() - engine.rootContext().setContextProperty('setup', setup) - engine.load(QUrl.fromLocalFile(str(QML))) - if not engine.rootObjects(): - return 1 - # Ready only once a frame is on the screen; until then the service - # still falls back to the console. - engine.rootObjects()[0].frameSwapped.connect(setup.noteReady) - channel.dispatch() - return app.exec() - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/image/rootfs/usr/libexec/cybexos-reconcile b/image/rootfs/usr/libexec/cybexos-reconcile deleted file mode 100755 index aefc7326..00000000 --- a/image/rootfs/usr/libexec/cybexos-reconcile +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/python3 -"""Deferred, bounded baseline/account convergence for installed RPM upgrades.""" -import argparse -from contextlib import contextmanager -import fcntl -import hashlib -import json -import os -from pathlib import Path -import pwd -import subprocess -import tempfile -import time - -VENDOR = Path('/usr/share/cybexos') -STATE = Path('/var/lib/cybexos/reconcile') -CONFIG = Path('/etc/cybexos/config.yml') -MAX_ATTEMPTS = 3 - - -def write(path, value): - path.parent.mkdir(parents=True, exist_ok=True, mode=0o755) - fd, temporary = tempfile.mkstemp(prefix='.reconcile-', dir=path.parent) - try: - with os.fdopen(fd, 'w') as stream: - json.dump(value, stream, sort_keys=True) - stream.write('\n') - stream.flush() - os.fsync(stream.fileno()) - os.fchmod(stream.fileno(), 0o644) - os.replace(temporary, path) - finally: - Path(temporary).unlink(missing_ok=True) - - -def read(path, default): - return json.loads(path.read_text()) if path.exists() else default - - -def version(): - value = (VENDOR / 'reconcile-version').read_text().strip() - if len(value) != 64 or any(c not in '0123456789abcdef' for c in value): - raise ValueError('Invalid packaged reconciliation version') - return value - - -def accounts(): - return [entry for entry in pwd.getpwall() if 1000 <= entry.pw_uid < 65534 - and entry.pw_name != 'liveuser' and Path(entry.pw_dir).parent == Path('/home')] - - -def identity(account, release): - config = CONFIG.read_bytes() if CONFIG.is_file() else b'' - return hashlib.sha256(json.dumps([release, hashlib.sha256(config).hexdigest(), - account.pw_uid, account.pw_gid, account.pw_dir]).encode()).hexdigest() - - -def status(): - value = read(STATE / 'status.json', {'state': 'pending', 'accounts': {}}) - desired = version() - value['desiredVersion'] = desired - value['pending'] = value.get('version') != desired or value.get('state') != 'ready' - if value.get('version') != desired: - value['state'] = 'pending' - return value - - -@contextmanager -def rpm_idle(): - """Share RPM's POSIX transaction lock; never race a package payload change. - - Baseline reconciliation deliberately contains no package operations. The - lock also prevents DNF from replacing our helper/payload halfway through. - """ - location = next((p for p in (Path('/usr/lib/sysimage/rpm/.rpm.lock'), - Path('/var/lib/rpm/.rpm.lock')) if p.is_file()), None) - if location is None: - raise BlockingIOError('RPM transaction lock is unavailable; retry after boot') - with location.open('rb') as lock: - fcntl.lockf(lock, fcntl.LOCK_SH | fcntl.LOCK_NB) - yield - - -def apply(account): - env = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': '/root', - 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1'} - # The helper bounds and drains its systemd cgroup while holding - # provision.lock. Never kill just that supervisor and begin recovery while - # its Ansible workers still mutate the system. - try: - subprocess.run(['/usr/libexec/cybexos-configure-installed', '--reconcile', - '--user', account.pw_name], env=env, check=True) - finally: - subprocess.run(['/usr/libexec/cybexos-configure-installed', '--drain'], env=env, check=True) - # New packaged unit definitions take effect at the next service start; - # do not disrupt a running desktop or the user's in-flight app state. - if Path(f'/run/user/{account.pw_uid}/bus').exists(): - # The local bus needs no machine bridge (and no caller pipe FDs - # transferred to PID 1 from this confined system service). - user_env = {**env, 'HOME': account.pw_dir, - 'XDG_RUNTIME_DIR': f'/run/user/{account.pw_uid}', - 'DBUS_SESSION_BUS_ADDRESS': f'unix:path=/run/user/{account.pw_uid}/bus'} - # timeout signals the whole process group, including runuser's client; - # killing only runuser can leave that client holding our output pipe. - subprocess.run(['/usr/bin/timeout', '--kill-after=5s', '30s', - '/usr/sbin/runuser', '-u', account.pw_name, '--', - '/usr/bin/env', '-i', *[f'{key}={value}' for key, value in user_env.items()], - '/usr/bin/systemctl', '--user', 'daemon-reload'], - env=env, check=True) - - -def reconcile(retry=False): - release = version() - previous = read(STATE / 'status.json', {'accounts': {}}) - state = {'version': release, 'state': 'pending', 'accounts': {}, 'lastAttempt': int(time.time())} - users = accounts() - for account in users: - key = identity(account, release) - old = previous.get('accounts', {}).get(account.pw_name, {}) - entry = dict(old) if old.get('identity') == key else { - 'identity': key, 'uid': account.pw_uid, 'home': account.pw_dir, - 'version': release, 'attempts': 0, 'state': 'pending'} - if retry and entry['state'] != 'ready': - entry.update(attempts=0, state='pending') - state['accounts'][account.pw_name] = entry - # Empty installations still need their first account reconciled later. - if not users: - state.update(state='pending', error='Waiting for an installed desktop account') - write(STATE / 'status.json', state) - return state - if all(entry['state'] == 'ready' for entry in state['accounts'].values()): - state['state'] = 'ready' - (STATE / 'pending.json').unlink(missing_ok=True) - if previous.get('version') == release and previous.get('state') == 'ready' and previous.get('accounts') == state['accounts']: - return previous - write(STATE / 'status.json', state) - (STATE / 'pending.json').unlink(missing_ok=True) - return state - write(STATE / 'status.json', state) - try: - with rpm_idle(): - for account in users: - entry = state['accounts'][account.pw_name] - if entry['state'] == 'ready' or entry['attempts'] >= MAX_ATTEMPTS: - continue - entry.update(state='running', attempts=entry['attempts'] + 1) - state['state'] = 'running' - write(STATE / 'status.json', state) - try: - apply(account) - except (OSError, subprocess.SubprocessError) as error: - entry.update(state='error', error=str(error)) - else: - entry.update(state='ready', identity=identity(account, release)) - entry.pop('error', None) - write(STATE / 'status.json', state) - except BlockingIOError as error: - state.update(state='pending', error=str(error)) - write(STATE / 'status.json', state) - return state - unfinished = [entry for entry in state['accounts'].values() if entry['state'] != 'ready'] - state['state'] = ('blocked' if all(entry['attempts'] >= MAX_ATTEMPTS for entry in unfinished) - else 'error') if unfinished else 'ready' - if unfinished: - state['error'] = 'Reconciliation failed; inspect journalctl -u cybexos-reconcile and retry with sudo /usr/libexec/cybexos-reconcile --retry' - else: - state.pop('error', None) - (STATE / 'pending.json').unlink(missing_ok=True) - write(STATE / 'status.json', state) - return state - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - choice = parser.add_mutually_exclusive_group() - choice.add_argument('--queue', action='store_true', help='Record pending work only; safe in RPM posttrans') - choice.add_argument('--status', action='store_true', help='Print read-only JSON status') - choice.add_argument('--retry', action='store_true', help='Reset failed attempt budgets and retry now') - args = parser.parse_args() - if args.status: - print(json.dumps(status(), sort_keys=True)) - return - if os.geteuid() != 0: - parser.error('Run as root') - if args.queue: - write(STATE / 'pending.json', {'version': version()}) - return - if Path('/run/cybexos-live').exists(): - return - STATE.mkdir(parents=True, exist_ok=True, mode=0o755) - with (STATE / 'lock').open('a') as lock: - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - return - result = reconcile(args.retry) - if result['state'] in ('error', 'blocked'): - raise SystemExit(1) - - -if __name__ == '__main__': - main() diff --git a/image/rootfs/usr/libexec/cybexos-seed-installed-users b/image/rootfs/usr/libexec/cybexos-seed-installed-users deleted file mode 100755 index 3e8b2c91..00000000 --- a/image/rootfs/usr/libexec/cybexos-seed-installed-users +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -"""Seed installed accounts during installation, before their first login.""" -import os -from pathlib import Path -import pwd -import subprocess - - -def accounts(entries): - return [account for account in entries if 1000 <= account.pw_uid < 65534 - and account.pw_name != "liveuser" - and Path(account.pw_dir).is_absolute() - and Path(account.pw_dir).parent == Path("/home")] - - -def main(): - if os.geteuid() != 0: - raise PermissionError("Run this helper in the installed target as root") - for account in accounts(pwd.getpwall()): - # Run as the owner with a clean environment. No recursive chown, no - # root writes through user-controlled symlinks and no live-user state. - subprocess.run([ - "runuser", "--user", account.pw_name, "--", "env", "-i", - "HOME=" + account.pw_dir, "USER=" + account.pw_name, - "PATH=/usr/local/bin:/usr/bin:/bin", - "/usr/libexec/cybexos-user-init", "--all", - ], check=True) - - -if __name__ == "__main__": - main() diff --git a/image/rootfs/usr/libexec/cybexos-update-channel b/image/rootfs/usr/libexec/cybexos-update-channel deleted file mode 100755 index 62a5fbc1..00000000 --- a/image/rootfs/usr/libexec/cybexos-update-channel +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/python3 -"""Inspect or explicitly enroll an ISO installation in a signed desktop channel.""" -import argparse -import configparser -from datetime import datetime, timezone -import fcntl -import json -import os -from pathlib import Path -import platform -import subprocess -import sys -import tempfile -from urllib.parse import urlsplit -from urllib.request import urlopen - -sys.path.insert(0, '/usr/share/cybexos/lib') -from release_metadata import fingerprint, public_key, repository_url # noqa: E402 - - -REPO = 'etc/yum.repos.d/cybexos-desktop.repo' -KEY = 'etc/pki/rpm-gpg/CYBEXOS-desktop.asc' -CONFIG = 'etc/cybexos/update-channel.json' - - -def read_json(path): - value = json.loads(path.read_text()) - if not isinstance(value, dict): - raise ValueError('Channel configuration must be a JSON object') - return value - - -def status(root=Path('/')): - record = {'available': False, 'availableVersion': '', 'currentVersion': '', - 'managed': True, 'enabled': False, 'configured': False, - 'channel': 'rpm', 'status': 'desktop-channel-disabled', - 'note': 'CybexOS desktop updates are not configured. Fedora and application updates remain available.'} - if root == Path('/'): - result = subprocess.run(['rpm', '-q', 'cybexos-desktop'], capture_output=True, text=True, timeout=10) - if result.returncode == 0: - record['currentVersion'] = result.stdout.strip() - try: - parser = configparser.ConfigParser(interpolation=None) - with (root / REPO).open() as stream: - parser.read_file(stream) - repo = parser['cybexos-desktop'] - if not repo.getboolean('enabled', fallback=True): - return record - record['enabled'] = True - if not all(repo.getboolean(key, fallback=False) for key in ('gpgcheck', 'repo_gpgcheck')): - raise ValueError('Both package and repository signature verification must be enabled') - configured = root / CONFIG - settings = read_json(configured if configured.exists() else root / 'usr/share/cybexos/update-channel.json') - expected = fingerprint(settings.get('fingerprint', '')) - baseurl = repository_url(repo.get('baseurl', '')) - if baseurl != repository_url(settings.get('baseurl', '')): - raise ValueError('Repository URL differs from the enrolled channel') - key_url = urlsplit(repo.get('gpgkey', '')) - if key_url.scheme != 'file' or key_url.netloc or key_url.query or key_url.fragment: - raise ValueError('The enrolled public key must be a local file') - if key_url.path not in ('/' + KEY, '/usr/share/cybexos/update-key.asc'): - raise ValueError('The repository uses an unrecognized public key path') - public_key(root / key_url.path.lstrip('/'), expected) - record.update(configured=True, status='desktop-channel-ready', baseurl=baseurl, - fingerprint=expected, note='CybexOS desktop updates are delivered with system packages.') - except FileNotFoundError: - if record['enabled']: - record.update(status='desktop-channel-invalid', note='The enabled desktop channel is missing its configuration or public key.') - except (OSError, ValueError, KeyError, configparser.Error, subprocess.SubprocessError): - record.update(status='desktop-channel-invalid', note='The desktop update channel is invalid. Run cybex update-channel enroll with its reviewed configuration.') - return record - - -def resolved_url(url, root): - release = {} - for line in (root / 'etc/os-release').read_text().splitlines(): - key, sep, value = line.partition('=') - if sep: - release[key] = value.strip('"\'') - version = release.get('VERSION_ID', '') - if release.get('ID') != 'fedora' or not version.isdecimal() or platform.machine() != 'x86_64': - raise ValueError('Desktop channels require a supported Fedora x86_64 installation') - return url.replace('$releasever', version).replace('$basearch', 'x86_64') - - -def verify_repository(url, armor): - """Verify bounded metadata in an isolated keyring before changing trust.""" - with tempfile.TemporaryDirectory(prefix='cybexos-channel-') as directory: - work = Path(directory) - key = work / 'public.asc' - key.write_bytes(armor) - for name in ('repomd.xml', 'repomd.xml.asc'): - with urlopen(url + '/repodata/' + name, timeout=30) as response: - if urlsplit(response.url).scheme != 'https': - raise ValueError('Repository metadata redirected to an insecure URL') - content = response.read(4 * 1024 * 1024 + 1) - if len(content) > 4 * 1024 * 1024: - raise ValueError('Repository metadata exceeds the size limit') - (work / name).write_bytes(content) - gpg = ['gpg', '--no-options', '--homedir', str(work), '--batch', '--no-autostart'] - subprocess.run([*gpg, '--import', str(key)], check=True, capture_output=True, timeout=15) - subprocess.run([*gpg, '--verify', str(work / 'repomd.xml.asc'), str(work / 'repomd.xml')], - check=True, capture_output=True, timeout=15) - - -def atomic_write(path, content): - path.parent.mkdir(parents=True, exist_ok=True) - if path.is_symlink() or (path.exists() and not path.is_file()): - raise ValueError(f'Refusing to replace a non-regular channel file: {path}') - fd, temporary = tempfile.mkstemp(prefix='.cybexos-channel-', dir=path.parent) - try: - with os.fdopen(fd, 'wb') as stream: - os.fchmod(stream.fileno(), 0o644) - stream.write(content) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - parent = os.open(path.parent, os.O_DIRECTORY) - try: - os.fsync(parent) - finally: - os.close(parent) - finally: - Path(temporary).unlink(missing_ok=True) - - -def enroll(config, expected, root=Path('/'), check=False): - expected = fingerprint(expected) - settings = read_json(config) - if set(settings) != {'baseurl', 'fingerprint', 'key_file'} or fingerprint(settings['fingerprint']) != expected: - raise ValueError('Configuration does not match the explicitly trusted fingerprint') - url = repository_url(settings['baseurl']) - armor = public_key(config.parent / settings['key_file'], expected) - # A broken or temporarily disabled repo does not discard previously pinned - # trust. Read the enrollment record independently of its readiness status. - for pinned in (root / CONFIG, root / 'usr/share/cybexos/update-channel.json'): - if pinned.is_file(): - recorded = read_json(pinned).get('fingerprint') - if recorded and fingerprint(recorded) != expected: - raise ValueError('Signing-key rotation requires a separate reviewed migration') - if recorded: - break - verify_repository(resolved_url(url, root), armor) - if check: - return {'checked': True, 'baseurl': url, 'fingerprint': expected} - repo = ('# Enrolled with cybex update-channel; local policy survives RPM updates.\n' - '[cybexos-desktop]\nname=CybexOS desktop updates\nenabled=1\n' - 'gpgcheck=1\nrepo_gpgcheck=1\nskip_if_unavailable=0\nmetadata_expire=6h\n' - f'baseurl={url}\ngpgkey=file:///{KEY}\n').encode() - values = {KEY: armor, CONFIG: (json.dumps({'enabled': True, 'baseurl': url, 'fingerprint': expected}, indent=2) + '\n').encode(), REPO: repo} - before = {} - for name in values: - path = root / name - if path.is_symlink() or (path.exists() and not path.is_file()): - raise ValueError('Refusing to replace a non-regular channel file') - before[name] = path.read_bytes() if path.exists() else None - if all(before[name] == value for name, value in values.items()): - return status(root) - stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') - backup = root / 'var/lib/cybexos/backups' / ('update-channel-' + stamp) - backup.mkdir(parents=True, mode=0o700) - for name, content in before.items(): - if content is not None: - atomic_write(backup / name, content) - written = [] - try: - for name, content in values.items(): - # atomic_write can fail after rename (for example directory fsync). - # Include the in-flight destination in rollback before publishing. - written.append(name) - atomic_write(root / name, content) - except BaseException: - for name in reversed(written): - if before[name] is None: - (root / name).unlink(missing_ok=True) - else: - atomic_write(root / name, before[name]) - raise - return {**status(root), 'backup': str(backup)} - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - actions = parser.add_subparsers(dest='action', required=True) - state = actions.add_parser('status') - state.add_argument('--json', action='store_true') - enable = actions.add_parser('enroll') - enable.add_argument('configuration', type=Path) - enable.add_argument('--fingerprint', required=True, help='Independently reviewed complete signing fingerprint') - enable.add_argument('--check', action='store_true', help='Verify public key and signed repository without changing configuration') - args = parser.parse_args() - try: - if args.action == 'status': - value = status() - print(json.dumps(value) if args.json else value['note']) - return 0 - if not args.check and os.geteuid() != 0: - parser.error('Enrollment requires sudo; --check is available without root') - if args.check: - value = enroll(args.configuration.resolve(strict=True), args.fingerprint, check=True) - else: - lockdir = Path('/run/lock/cybexos') - lockdir.mkdir(mode=0o755, parents=True, exist_ok=True) - descriptor = os.open(lockdir / 'update-channel.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) - with os.fdopen(descriptor, 'w') as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - value = enroll(args.configuration.resolve(strict=True), args.fingerprint) - print(json.dumps(value, indent=2)) - return 0 - except (OSError, ValueError, subprocess.SubprocessError) as error: - parser.exit(1, f'update-channel: {error}\n') - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/image/rootfs/usr/libexec/cybexos-user-init b/image/rootfs/usr/libexec/cybexos-user-init deleted file mode 100755 index 00b807a8..00000000 --- a/image/rootfs/usr/libexec/cybexos-user-init +++ /dev/null @@ -1,249 +0,0 @@ -#!/usr/bin/env python3 -"""Initialize desktop essentials quickly; copy offline apps safely in background.""" -import argparse -import fcntl -import json -import importlib.machinery -import importlib.util -import os -from pathlib import Path -import shutil -import signal -import subprocess -import sys -import tempfile -import time - - -def directory(path): - """Never adopt a user's symlink or replace an existing non-directory.""" - if path.is_symlink(): - return False - if path.exists(): - return path.is_dir() - if not directory(path.parent): - return False - try: - path.mkdir() - except FileExistsError: - return not path.is_symlink() and path.is_dir() - return True - - -def copy_absent(source, target): - """Publish complete files atomically, retaining reflinks where supported. - - A crash must never leave a partial final pathname that a subsequent login - mistakes for a personal file. link() also refuses a concurrent user edit. - """ - if target.exists() or target.is_symlink(): - return - descriptor, temporary = tempfile.mkstemp(prefix=".cybexos-seed-", dir=target.parent) - temporary = Path(temporary) - try: - with os.fdopen(descriptor, "wb") as output, source.open("rb") as original: - try: - fcntl.ioctl(output.fileno(), 0x40049409, original.fileno()) # FICLONE - except OSError: - shutil.copyfileobj(original, output, length=1024 * 1024) - output.flush() - os.fchmod(output.fileno(), source.stat().st_mode & 0o777) - try: - os.link(temporary, target) - except FileExistsError: - pass - finally: - temporary.unlink(missing_ok=True) - - -def seed_applications(source, destination, progress=None): - if not directory(destination): - return - for item in sorted(source.iterdir()): - target = destination / item.name - if item.is_symlink(): - try: - target.symlink_to(os.readlink(item)) - except FileExistsError: - pass - elif item.is_dir(): - seed_applications(item, target, progress) - elif item.is_file(): - copy_absent(item, target) - if progress: - progress(item.stat().st_size) - - -def write_status(path, status): - descriptor, temporary = tempfile.mkstemp(prefix=".seed-progress-", dir=path.parent) - try: - with os.fdopen(descriptor, "w") as output: - json.dump(status, output) - output.write("\n") - os.replace(temporary, path) - finally: - Path(temporary).unlink(missing_ok=True) - - -def reconcile_defaults(home, vendor, state): - """Update owned fragments independently of the one-time application seed. - - Personal edits, symlinks and deletions survive; originals are backed up - before replacement. A failed publication leaves a recoverable ledger. - """ - manifest = vendor / "managed-defaults.json" - if not manifest.exists(): - return - spec = importlib.util.spec_from_file_location("cybexos_managed_files", vendor / "lib/managed_files.py") - managed = importlib.util.module_from_spec(spec) - spec.loader.exec_module(managed) - release = (vendor / "reconcile-version").read_text().strip() - status_path = state / "defaults-progress.json" - status = json.loads(status_path.read_text()) if status_path.exists() else {} - if status.get("version") == release and status.get("state") == "ready": - return - status = {"version": release, "state": "running", "preserved": []} - write_status(status_path, status) - try: - for relative in json.loads(manifest.read_text()): - path = Path(relative) - if path.is_absolute() or ".." in path.parts: - raise ValueError("Invalid managed default path") - source, target = vendor / "managed-seed" / path, home / path - if not directory(target.parent): - status["preserved"].append(relative) - continue - result = managed.manage(target, source.read_bytes(), state / "defaults/ownership.json", - mode=source.stat().st_mode & 0o777) - if result["preserved"]: - status["preserved"].append(relative) - status["state"] = "ready" - except BaseException: - status["state"] = "error" - write_status(status_path, status) - raise - write_status(status_path, status) - - -def seed_web_apps(vendor, data): - """Install the default web apps once, as `cybexos-webapp seed` does. - - The checkout's dotfiles role runs the same packaged helper. It records - every default it seeds, so a web app the user removed is never added back - by a later login. A failure must never keep the desktop from starting. - """ - helper = vendor / "bin/cybexos-webapp" - # The helper's record of what it seeded: once it exists, a login need not - # load the helper at all (a binding seeds a missing default on first use). - # A release that adds a default must revisit this marker. - if not helper.is_file() or (data / "webapps/defaults.json").exists(): - return - try: - loader = importlib.machinery.SourceFileLoader("cybexos_webapp", str(helper)) - spec = importlib.util.spec_from_loader(loader.name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - module.seed() - except Exception as error: - print(f"cybexos-user-init: default web apps were not seeded: {error}", file=sys.stderr) - - -def notify(message, urgency="normal"): - if shutil.which("notify-send"): - try: - subprocess.run(["notify-send", "--app-name=CybexOS", "--urgency=" + urgency, - "Offline applications", message], check=False, timeout=3, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except (OSError, subprocess.TimeoutExpired): - pass - - -def initialize(home, vendor=Path("/usr/share/cybexos"), mode="all", notifications=False): - config = Path(os.environ.get("XDG_CONFIG_HOME", str(home / ".config"))) / "cybexos" - data = Path(os.environ.get("XDG_DATA_HOME", str(home / ".local/share"))) / "cybexos" - state = Path(os.environ.get("XDG_STATE_HOME", str(home / ".local/state"))) / "cybexos" - for target in (config / "hypr", data / "themes", data / "plugins", home / ".local/bin", state): - if not directory(target): - raise RuntimeError(f"Preserved existing path; cannot initialize directory: {target}") - # Concurrent manual retries and login initialization share one lock. - descriptor = os.open(state / "seed.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) - with os.fdopen(descriptor, "w") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - reconcile_defaults(home, vendor, state) - essential = vendor / "essential-seed" - if essential.is_dir(): - seed_applications(essential, home) - links = {data / "runtime": vendor / "runtime"} - for helper in (vendor / "bin").iterdir(): - if helper.is_file(): - links[home / ".local/bin" / helper.name] = helper - links[home / ".local/bin/cybex"] = Path("/usr/bin/cybex") - for destination, source in links.items(): - try: - destination.symlink_to(source) - except FileExistsError: - pass - seed_web_apps(vendor, data) - seed = vendor / "user-seed" - marker = state / "offline-apps-seeded" - status_path = state / "seed-progress.json" - if marker.exists() or not seed.is_dir(): - write_status(status_path, {"state": "ready", "label": "Offline applications are ready"}) - return - if mode == "essential": - write_status(status_path, {"state": "pending", "label": "Preparing offline applications"}) - return - manifest = vendor / "seed-groups.json" - total = json.loads(manifest.read_text())["totalBytes"] if manifest.exists() else 0 - status = {"state": "copying", "label": "Preparing offline applications", - "completedBytes": 0, "totalBytes": total} - write_status(status_path, status) - if notifications: - notify("The desktop is ready. Offline developer tools are being prepared.") - last_update = 0 - - def progress(size): - nonlocal last_update - status["completedBytes"] += size - now = time.monotonic() - if now - last_update >= 0.5: - write_status(status_path, status) - last_update = now - - try: - seed_applications(seed, home, progress) - # LazyVim's config and rustup/npm/user command shims become - # available after their offline dependencies. Launching them - # during live setup must never start an automatic download. - final = vendor / "final-seed" - if final.is_dir(): - seed_applications(final, home, progress) - marker.touch() - write_status(status_path, {**status, "state": "ready", "label": "Offline applications are ready"}) - except BaseException: - write_status(status_path, {**status, "state": "error", "label": - "Application preparation was interrupted. Run cybex prepare-apps to retry."}) - if notifications: - notify("Preparation was interrupted. Run cybex prepare-apps to retry.", "critical") - raise - if notifications: - notify("Your offline applications and developer tools are ready.") - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - group = parser.add_mutually_exclusive_group() - group.add_argument("--essential", action="store_true") - group.add_argument("--background", action="store_true") - group.add_argument("--all", action="store_true") - args = parser.parse_args() - - def interrupted(*_): - raise InterruptedError("Session ended") - - signal.signal(signal.SIGTERM, interrupted) - initialize(Path.home(), mode="essential" if args.essential else "all", notifications=args.background) - - -if __name__ == "__main__": - main() diff --git a/image/rootfs/usr/share/applications/cybex.desktop b/image/rootfs/usr/share/applications/cybex.desktop deleted file mode 100644 index 6c66c0f6..00000000 --- a/image/rootfs/usr/share/applications/cybex.desktop +++ /dev/null @@ -1,9 +0,0 @@ -[Desktop Entry] -Type=Application -Name=cybex -Comment=Install CybexOS or explore your desktop -Exec=cybex welcome -Icon=computer -Terminal=false -Categories=System; -StartupNotify=true diff --git a/image/rootfs/usr/share/cybexos/new-owner/Main.qml b/image/rootfs/usr/share/cybexos/new-owner/Main.qml deleted file mode 100644 index abbe1fe8..00000000 --- a/image/rootfs/usr/share/cybexos/new-owner/Main.qml +++ /dev/null @@ -1,962 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -// The new owner's first-start setup, shown in cage before the login screen. -// Four steps with one primary action each: language and keyboard, account, -// time zone, finishing. The window only asks: `setup` sends the answers to -// the root service, which checks them again and does the work. The window's -// text follows the chosen language (Dutch or English). -ApplicationWindow { - id: window - - // The welcome window's and installer's palette. - readonly property color ink: "#f4f4f5" - readonly property color inkMid: "#b4b4bb" - readonly property color inkFaint: "#80808a" - readonly property color surface: "#17171a" - readonly property color surfaceHover: "#202024" - readonly property color stroke: "#2a2a30" - readonly property color accent: "#dd0034" - readonly property color accentHover: "#f0164a" - readonly property color accentDown: "#b8002b" - readonly property color accentText: "#ff6b86" - - // cage shows the window on the whole screen at scale 1. One unit grows - // with the screen, so text stays readable on high-density panels. - readonly property real u: Math.max(1, Math.min(3, Math.min(width / 1440, height / 900))) - readonly property real rowHeight: Math.round(42 * u) - - readonly property var pages: ["welcome", "account", "timezone", "finish"] - readonly property var steps: ["Welcome", "Your account", "Time zone", "Finishing"] - property int page: 0 - property bool showErrors: false - property bool usernameEdited: false - property string timezone: setup.timezone - property int countdown: 15 - - width: 1280 - height: 800 - visible: true - title: tr("Set up CybexOS") - color: "#0e0e10" - font.family: "Figtree" - font.pixelSize: Math.round(16 * u) - palette.windowText: ink - palette.text: ink - palette.buttonText: ink - palette.button: surface - palette.base: surface - palette.highlight: accent - palette.highlightedText: "#ffffff" - - // ---- language ----------------------------------------------------------- - - function tr(text) { - return setup.language === "nl" && dutch[text] !== undefined ? dutch[text] : text; - } - - readonly property var dutch: ({ - "Set up CybexOS": "CybexOS instellen", - "Make it yours.": "Maak hem van jou.", - "A few choices and your desktop is ready.": "Een paar keuzes en je bureaublad is klaar.", - "Welcome": "Welkom", - "Your account": "Je account", - "Time zone": "Tijdzone", - "Finishing": "Afronden", - "Welcome.": "Welkom.", - "Let's set up this computer for you. First, choose your language and keyboard.": - "We stellen deze computer voor je in. Kies eerst je taal en toetsenbord.", - "Language": "Taal", - "Keyboard layout": "Toetsenbordindeling", - "Search": "Zoeken", - "No matches": "Geen resultaten", - "Try your keyboard": "Probeer je toetsenbord", - "Type here to test your keyboard": "Typ hier om je toetsenbord te testen", - "Switching keyboard layout…": "Toetsenbordindeling wisselen…", - "Continue": "Doorgaan", - "Back": "Terug", - "Your account.": "Je account.", - "You use it to sign in and to approve administrator tasks.": - "Hiermee meld je je aan en keur je beheerderstaken goed.", - "Full name": "Volledige naam", - "Username": "Gebruikersnaam", - "Lowercase letters, numbers, dashes and underscores.": "Kleine letters, cijfers, streepjes en underscores.", - "Password": "Wachtwoord", - "Confirm password": "Bevestig wachtwoord", - "Use at least 12 characters.": "Gebruik minstens 12 tekens.", - "This password also unlocks the disk when the computer starts.": - "Dit wachtwoord ontgrendelt ook de schijf als de computer start.", - "Temporary disk passphrase": "Tijdelijk schijfwachtwoord", - "The passphrase you typed to start this computer. It stops working once setup is done.": - "Het wachtwoord dat je typte om deze computer te starten. Het werkt niet meer zodra het instellen klaar is.", - "Your time zone.": "Je tijdzone.", - "Choose where you are, so the clock is right.": "Kies waar je bent, zodat de klok klopt.", - "Search for a city": "Zoek een stad", - "Set up": "Instellen", - "Setting up…": "Bezig met instellen…", - "This takes a few minutes. Keep the computer on.": "Dit duurt een paar minuten. Laat de computer aan.", - "Creating your account": "Je account aanmaken", - "Preparing your desktop": "Je bureaublad voorbereiden", - "Securing the disk": "De schijf beveiligen", - "Finishing up": "Afronden", - "Setup did not finish.": "Het instellen is niet gelukt.", - "Nothing was kept. You can try again.": "Er is niets bewaard. Je kunt het opnieuw proberen.", - "Try again": "Opnieuw proberen", - "Ready.": "Klaar.", - "Your account is ready. The computer restarts to finish setting up.": - "Je account is klaar. De computer start opnieuw om het instellen af te ronden.", - "When it starts, unlock the disk with your new password.": - "Ontgrendel de schijf daarna met je nieuwe wachtwoord.", - "Restart now": "Nu opnieuw starten", - "Restarting in %1 seconds": "Opnieuw starten over %1 seconden", - "Use at most 128 characters, without colons.": "Gebruik maximaal 128 tekens, zonder dubbele punt.", - "Choose a username.": "Kies een gebruikersnaam.", - "Use lowercase letters, numbers, dashes or underscores, starting with a letter.": - "Gebruik kleine letters, cijfers, streepjes of underscores, en begin met een letter.", - "That name is reserved for the system.": "Die naam is gereserveerd voor het systeem.", - "That name is already in use.": "Die naam is al in gebruik.", - "Use a password of 12–512 characters.": "Gebruik een wachtwoord van 12 tot 512 tekens.", - "The password cannot contain control characters.": "Het wachtwoord mag geen besturingstekens bevatten.", - "The passwords do not match.": "De wachtwoorden komen niet overeen.", - "Type the passphrase you used at startup.": "Typ het wachtwoord dat je bij het starten gebruikte.", - "That passphrase does not unlock this disk.": "Dit wachtwoord ontgrendelt de schijf niet.", - "This layout cannot be used at the disk unlock screen. Choose another layout.": - "Deze indeling werkt niet op het ontgrendelscherm van de schijf. Kies een andere indeling.", - "Choose an item from the list.": "Kies een item uit de lijst.", - "Something went wrong. Try again.": "Er ging iets mis. Probeer het opnieuw." - }) - - // Problem codes from the shared rules and the root service. - readonly property var problems: ({ - "full-name": "Use at most 128 characters, without colons.", - "username-empty": "Choose a username.", - "username-format": "Use lowercase letters, numbers, dashes or underscores, starting with a letter.", - "username-reserved": "That name is reserved for the system.", - "username-taken": "That name is already in use.", - "password-length": "Use a password of 12–512 characters.", - "password-control": "The password cannot contain control characters.", - "password-mismatch": "The passwords do not match.", - "startup-missing": "Type the passphrase you used at startup.", - "startup-wrong": "That passphrase does not unlock this disk.", - "boot-keymap": "This layout cannot be used at the disk unlock screen. Choose another layout.", - "choice": "Choose an item from the list.", - "failed": "Something went wrong. Try again." - }) - - readonly property var stageLabels: ({ - "account": "Creating your account", - "desktop": "Preparing your desktop", - "disk": "Securing the disk", - "finish": "Finishing up" - }) - - function problemText(code) { - return code === "" ? "" : tr(problems[code] !== undefined ? problems[code] : problems.failed); - } - - // The service's answer about one field, until that field is edited. - function serviceProblem(field) { - return setup.errorField === field ? setup.errorCode : ""; - } - - // ---- the account rules (shared with the service) ------------------------ - - function fullNameProblem() { - return setup.fullNameProblem(fullNameField.text); - } - function usernameProblem() { - return usernameField.text.trim() === "" ? "username-empty" : setup.usernameProblem(usernameField.text); - } - function passwordProblem() { - const code = setup.passwordProblem(passwordField.text, confirmField.text); - return code === "password-mismatch" ? "" : code; - } - function confirmProblem() { - return setup.passwordProblem(passwordField.text, confirmField.text) === "password-mismatch" - ? "password-mismatch" : ""; - } - function startupProblem() { - return setup.encrypted && startupField.text === "" ? "startup-missing" : ""; - } - - function firstInvalid() { - const checks = [[fullNameField, fullNameProblem()], [usernameField, usernameProblem()], - [passwordField, passwordProblem()], [confirmField, confirmProblem()], - [startupField, startupProblem()]]; - for (const check of checks) { - if (check[1] !== "") - return check[0]; - } - return null; - } - - // ---- navigation --------------------------------------------------------- - - function next() { - if (page === 0) { - if (setup.state !== "idle") - return; - page = 1; - fullNameField.input.forceActiveFocus(); - } else if (page === 1) { - showErrors = true; - const invalid = firstInvalid(); - if (invalid !== null) { - invalid.input.forceActiveFocus(); - return; - } - page = 2; - timezoneList.field.forceActiveFocus(); - } else if (page === 2 || (page === 3 && setup.state === "failed")) { - submit(); - } else if (page === 3 && setup.state === "done") { - setup.restartNow(); - } - } - - function back() { - if (page === 3 && setup.state === "failed") { - setup.clearError(""); - page = 1; - } else if (page === 1 || page === 2) { - page -= 1; - } - } - - function submit() { - page = 3; - setup.submit(fullNameField.text, usernameField.text, passwordField.text, confirmField.text, - startupField.text, timezone); - } - - Component.onCompleted: { - if (setup.focus === "keyboard-test") - keyboardTest.forceActiveFocus(); - else - languageList.field.forceActiveFocus(); - } - - Connections { - target: setup - function onChanged() { - const field = setup.errorField; - if (field === "") - return; - // Back to the question the service asks again. - if (["full_name", "username", "password", "confirm", "startup"].indexOf(field) >= 0) { - window.page = 1; - window.showErrors = true; - const inputs = { full_name: fullNameField, username: usernameField, password: passwordField, - confirm: confirmField, startup: startupField }; - inputs[field].input.forceActiveFocus(); - } else if (field === "timezone") { - window.page = 2; - } else { - window.page = 0; - } - } - } - - Timer { - running: setup.state === "done" - interval: 1000 - repeat: true - onTriggered: { - window.countdown -= 1; - if (window.countdown <= 0) { - stop(); - setup.restartNow(); - } - } - } - - // ---- building blocks ---------------------------------------------------- - - component ActionButton: Button { - id: button - property bool primary: false - implicitHeight: Math.round(48 * window.u) - leftPadding: Math.round(26 * window.u) - rightPadding: Math.round(26 * window.u) - font.weight: Font.DemiBold - font.pixelSize: Math.round(16 * window.u) - Accessible.name: text - // Return and Enter press a focused button too, not only Space. - Keys.onReturnPressed: clicked() - Keys.onEnterPressed: clicked() - background: Rectangle { - radius: Math.round(12 * window.u) - color: button.primary - ? (button.down ? window.accentDown : button.hovered ? window.accentHover : window.accent) - : (button.hovered ? window.surfaceHover : window.surface) - opacity: button.enabled ? 1 : 0.5 - border.width: button.visualFocus ? Math.round(2 * window.u) : 0 - border.color: "#ffffff" - } - contentItem: Text { - text: button.text - color: button.primary ? "#ffffff" : window.ink - font: button.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - } - - component Input: TextField { - id: input - Layout.fillWidth: true - implicitHeight: Math.round(48 * window.u) - leftPadding: Math.round(12 * window.u) - rightPadding: Math.round(12 * window.u) - color: window.ink - placeholderTextColor: window.inkFaint - selectionColor: window.accent - selectedTextColor: "#ffffff" - font.pixelSize: Math.round(16 * window.u) - background: Rectangle { - radius: Math.round(12 * window.u) - color: window.surface - border.width: input.activeFocus ? Math.round(2 * window.u) : 1 - border.color: input.activeFocus ? window.ink : input.hovered ? window.inkFaint : window.stroke - } - } - - component Caption: Text { - Layout.fillWidth: true - color: window.ink - font.pixelSize: Math.round(14 * window.u) - wrapMode: Text.WordWrap - } - - component Hint: Text { - Layout.fillWidth: true - color: window.inkMid - font.pixelSize: Math.round(13 * window.u) - wrapMode: Text.WordWrap - lineHeight: 1.3 - } - - component Problem: Text { - Layout.fillWidth: true - visible: text !== "" - color: window.accentText - font.pixelSize: Math.round(13 * window.u) - wrapMode: Text.WordWrap - Accessible.role: Accessible.AlertMessage - Accessible.name: text - } - - // A labelled field with its hint or problem below it. - component Field: ColumnLayout { - id: field - property string label: "" - property string hint: "" - property string problem: "" - property alias text: entry.text - property alias input: entry - property alias echoMode: entry.echoMode - property alias maximumLength: entry.maximumLength - property alias inputMethodHints: entry.inputMethodHints - signal edited() - Layout.fillWidth: true - spacing: Math.round(8 * window.u) - Caption { text: field.label } - Input { - id: entry - objectName: field.objectName + "Input" - Accessible.name: field.label - Accessible.description: field.problem !== "" ? field.problem : field.hint - onTextEdited: field.edited() - Keys.onReturnPressed: window.next() - Keys.onEnterPressed: window.next() - } - Problem { text: field.problem } - Hint { visible: field.problem === "" && field.hint !== ""; text: field.hint } - } - - // A search box over a list; Enter or a click chooses. Long lists - // (languages, layouts, time zones) stay usable from the keyboard alone. - component SearchList: ColumnLayout { - id: list - property string label: "" - property string placeholder: "" - property var entries: [] - property string current: "" - property alias field: search - property alias view: view - readonly property var shown: { - const words = search.text.trim().toLowerCase().split(/\s+/).filter(word => word !== ""); - return words.length === 0 ? entries - : entries.filter(entry => words.every(word => entry.search.indexOf(word) >= 0)); - } - signal chosen(string id) - spacing: Math.round(8 * window.u) - - function choose(index) { - if (index >= 0 && index < shown.length) - chosen(shown[index].id); - } - function reveal() { - const index = shown.findIndex(entry => entry.id === current); - view.currentIndex = index >= 0 ? index : 0; - if (view.count > 0) - view.positionViewAtIndex(view.currentIndex, ListView.Center); - } - onShownChanged: reveal() - Component.onCompleted: reveal() - - Caption { text: list.label } - Input { - id: search - placeholderText: list.placeholder - inputMethodHints: Qt.ImhNoPredictiveText - Accessible.name: list.label - Keys.onDownPressed: view.forceActiveFocus() - Keys.onReturnPressed: list.choose(view.currentIndex) - Keys.onEnterPressed: list.choose(view.currentIndex) - } - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumHeight: 4 * window.rowHeight + Math.round(12 * window.u) - radius: Math.round(12 * window.u) - color: window.surface - border.width: view.activeFocus ? Math.round(2 * window.u) : 1 - border.color: view.activeFocus ? window.ink : window.stroke - - ListView { - id: view - anchors.fill: parent - anchors.margins: Math.round(6 * window.u) - clip: true - model: list.shown - boundsBehavior: Flickable.StopAtBounds - keyNavigationEnabled: true - activeFocusOnTab: true - highlightMoveDuration: 0 - Accessible.role: Accessible.List - Accessible.name: list.label - ScrollBar.vertical: ScrollBar {} - Keys.onReturnPressed: list.choose(currentIndex) - Keys.onEnterPressed: list.choose(currentIndex) - Keys.onSpacePressed: list.choose(currentIndex) - Keys.onUpPressed: event => { - if (currentIndex <= 0) - search.forceActiveFocus(); - else - decrementCurrentIndex(); - } - - delegate: Rectangle { - id: row - required property var modelData - required property int index - readonly property bool selected: modelData.id === list.current - readonly property bool focused: view.activeFocus && view.currentIndex === index - width: view.width - height: window.rowHeight - radius: Math.round(8 * window.u) - color: selected ? Qt.rgba(0.867, 0, 0.204, 0.24) - : rowMouse.containsMouse || focused ? window.surfaceHover : "transparent" - border.width: focused ? Math.round(2 * window.u) : 0 - border.color: window.ink - Accessible.role: Accessible.ListItem - Accessible.name: modelData.label - Accessible.selected: selected - - RowLayout { - anchors.fill: parent - anchors.leftMargin: Math.round(12 * window.u) - anchors.rightMargin: Math.round(12 * window.u) - spacing: Math.round(12 * window.u) - Text { - Layout.fillWidth: true - text: row.modelData.label - color: window.ink - font.pixelSize: Math.round(15 * window.u) - font.weight: row.selected ? Font.DemiBold : Font.Normal - elide: Text.ElideRight - } - Text { - Layout.maximumWidth: row.width * 0.45 - text: row.modelData.detail - color: window.inkFaint - font.pixelSize: Math.round(13 * window.u) - elide: Text.ElideRight - } - } - MouseArea { - id: rowMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - view.currentIndex = row.index; - view.forceActiveFocus(); - list.chosen(row.modelData.id); - } - } - } - } - - Text { - anchors.centerIn: parent - visible: list.shown.length === 0 - text: window.tr("No matches") - color: window.inkFaint - font.pixelSize: Math.round(15 * window.u) - } - } - } - - // ---- the window ----------------------------------------------------------- - - RowLayout { - anchors.fill: parent - spacing: 0 - - Rectangle { - Layout.fillHeight: true - Layout.preferredWidth: Math.round(Math.max(280 * window.u, window.width * 0.3)) - color: window.surface - - Rectangle { - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - width: 1 - color: window.stroke - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: Math.round(40 * window.u) - spacing: 0 - - Image { - source: "../welcome/cybex-wordmark.svg" - Layout.preferredHeight: Math.round(30 * window.u) - Layout.preferredWidth: Math.round(30 * window.u * 198 / 46.6) - sourceSize.height: Math.round(90 * window.u) - fillMode: Image.PreserveAspectFit - smooth: true - Accessible.role: Accessible.Graphic - Accessible.name: "Cybex" - } - Item { Layout.preferredHeight: Math.round(64 * window.u) } - Text { - Layout.fillWidth: true - text: window.tr("Make it yours.") - color: window.ink - font.pixelSize: Math.round(44 * window.u) - font.weight: Font.DemiBold - font.letterSpacing: -1 - wrapMode: Text.WordWrap - } - Item { Layout.preferredHeight: Math.round(18 * window.u) } - Text { - Layout.fillWidth: true - text: window.tr("A few choices and your desktop is ready.") - color: window.inkMid - font.pixelSize: Math.round(16 * window.u) - wrapMode: Text.WordWrap - lineHeight: 1.5 - } - Item { Layout.preferredHeight: Math.round(30 * window.u) } - Repeater { - model: window.steps - delegate: RowLayout { - id: step - required property string modelData - required property int index - Layout.topMargin: Math.round(17 * window.u) - spacing: Math.round(12 * window.u) - Text { - text: "0" + (step.index + 1) - color: step.index === window.page ? window.accentText : window.inkMid - font.pixelSize: Math.round(12 * window.u) - font.letterSpacing: 1 - } - Text { - text: window.tr(step.modelData) - color: step.index === window.page ? window.accentText : window.inkMid - font.pixelSize: Math.round(15 * window.u) - } - } - } - Item { Layout.fillHeight: true } - Text { - text: "CYBEX OPINIONATED SYSTEM" - color: window.inkFaint - font.pixelSize: Math.round(10 * window.u) - font.letterSpacing: 1.6 - } - } - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - ColumnLayout { - id: main - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - anchors.topMargin: Math.round(44 * window.u) - anchors.bottomMargin: Math.round(32 * window.u) - width: Math.min(parent.width - Math.round(2 * 56 * window.u), Math.round(860 * window.u)) - spacing: 0 - - Text { - text: "0" + (window.page + 1) + " / " + window.tr(window.steps[window.page]) - color: window.inkFaint - font.pixelSize: Math.round(12 * window.u) - font.weight: Font.DemiBold - font.letterSpacing: 1.2 - font.capitalization: Font.AllUppercase - } - Text { - objectName: "heading" - Layout.fillWidth: true - Layout.topMargin: Math.round(12 * window.u) - text: window.tr(window.page === 0 ? "Welcome." - : window.page === 1 ? "Your account." - : window.page === 2 ? "Your time zone." - : setup.state === "done" ? "Ready." - : setup.state === "failed" ? "Setup did not finish." : "Setting up…") - color: window.ink - font.pixelSize: Math.round(38 * window.u) - font.weight: Font.DemiBold - font.letterSpacing: -1 - wrapMode: Text.WordWrap - Accessible.role: Accessible.Heading - Accessible.name: text - } - Text { - Layout.fillWidth: true - Layout.topMargin: Math.round(10 * window.u) - text: window.tr(window.page === 0 - ? "Let's set up this computer for you. First, choose your language and keyboard." - : window.page === 1 ? "You use it to sign in and to approve administrator tasks." - : window.page === 2 ? "Choose where you are, so the clock is right." - : setup.state === "done" ? "Your account is ready. The computer restarts to finish setting up." - : setup.state === "failed" ? "Nothing was kept. You can try again." - : "This takes a few minutes. Keep the computer on.") - color: window.inkMid - font.pixelSize: Math.round(16 * window.u) - wrapMode: Text.WordWrap - lineHeight: 1.45 - } - Item { Layout.preferredHeight: Math.round(22 * window.u) } - - // One page at a time, each sized to this area. (A StackLayout - // would take its height from the hidden pages' wrapped text.) - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - // ---- 01 welcome: language and keyboard ---------------- - ColumnLayout { - anchors.fill: parent - visible: window.page === 0 - spacing: Math.round(16 * window.u) - RowLayout { - Layout.fillWidth: true - Layout.fillHeight: true - spacing: Math.round(24 * window.u) - SearchList { - id: languageList - objectName: "languageList" - Layout.fillWidth: true - Layout.fillHeight: true - Layout.preferredWidth: 1 - label: window.tr("Language") - placeholder: window.tr("Search") - entries: setup.locales - current: setup.locale - onChosen: id => setup.locale = id - } - SearchList { - id: keyboardList - objectName: "keyboardList" - Layout.fillWidth: true - Layout.fillHeight: true - Layout.preferredWidth: 1 - enabled: setup.state === "idle" - label: window.tr("Keyboard layout") - placeholder: window.tr("Search") - entries: setup.keyboards - current: setup.keyboard - onChosen: id => setup.chooseKeyboard(id) - } - } - Problem { - objectName: "keyboardProblem" - text: setup.state === "switching" ? "" - : window.problemText(window.serviceProblem("keyboard")) - } - Hint { - visible: setup.state === "switching" - text: window.tr("Switching keyboard layout…") - Accessible.role: Accessible.StaticText - } - Caption { text: window.tr("Try your keyboard") } - Input { - id: keyboardTest - objectName: "keyboardTest" - placeholderText: window.tr("Type here to test your keyboard") - inputMethodHints: Qt.ImhNoPredictiveText - Accessible.name: window.tr("Try your keyboard") - } - } - - // ---- 02 account --------------------------------------- - ColumnLayout { - anchors.fill: parent - visible: window.page === 1 - spacing: Math.round(16 * window.u) - RowLayout { - Layout.fillWidth: true - spacing: Math.round(16 * window.u) - Field { - id: fullNameField - objectName: "fullName" - Layout.preferredWidth: 1 - Layout.alignment: Qt.AlignTop - label: window.tr("Full name") - maximumLength: 128 - problem: window.problemText((window.showErrors ? window.fullNameProblem() : "") - || window.serviceProblem("full_name")) - onTextChanged: { - if (!window.usernameEdited) - usernameField.text = setup.deriveUsername(text); - } - onEdited: setup.clearError("full_name") - } - Field { - id: usernameField - objectName: "username" - Layout.preferredWidth: 1 - Layout.alignment: Qt.AlignTop - label: window.tr("Username") - hint: window.tr("Lowercase letters, numbers, dashes and underscores.") - maximumLength: 31 - inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhNoPredictiveText | Qt.ImhPreferLowercase - problem: window.problemText((window.showErrors ? window.usernameProblem() : "") - || window.serviceProblem("username")) - onEdited: { - window.usernameEdited = text !== ""; - setup.clearError("username"); - } - } - } - RowLayout { - Layout.fillWidth: true - spacing: Math.round(16 * window.u) - Field { - id: passwordField - objectName: "password" - Layout.preferredWidth: 1 - Layout.alignment: Qt.AlignTop - label: window.tr("Password") - echoMode: TextInput.Password - maximumLength: 512 - inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText - hint: window.tr("Use at least 12 characters.") - problem: window.problemText((window.showErrors ? window.passwordProblem() : "") - || window.serviceProblem("password")) - onEdited: setup.clearError("password") - } - Field { - id: confirmField - objectName: "confirm" - Layout.preferredWidth: 1 - Layout.alignment: Qt.AlignTop - label: window.tr("Confirm password") - echoMode: TextInput.Password - maximumLength: 512 - inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText - problem: window.problemText((window.showErrors ? window.confirmProblem() : "") - || window.serviceProblem("confirm")) - onEdited: setup.clearError("confirm") - } - } - // On an encrypted disk the account password replaces the - // temporary passphrase, which authorizes the change. - Rectangle { - visible: setup.encrypted - Layout.fillWidth: true - Layout.topMargin: Math.round(4 * window.u) - Layout.preferredHeight: diskColumn.implicitHeight + Math.round(32 * window.u) - radius: Math.round(16 * window.u) - color: window.surface - border.width: 1 - border.color: window.stroke - ColumnLayout { - id: diskColumn - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: Math.round(16 * window.u) - spacing: Math.round(10 * window.u) - Text { - Layout.fillWidth: true - text: window.tr("This password also unlocks the disk when the computer starts.") - color: window.ink - font.pixelSize: Math.round(15 * window.u) - font.weight: Font.DemiBold - wrapMode: Text.WordWrap - } - Field { - id: startupField - objectName: "startup" - label: window.tr("Temporary disk passphrase") - echoMode: TextInput.Password - maximumLength: 512 - inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText - hint: window.tr("The passphrase you typed to start this computer. It stops working once setup is done.") - problem: window.problemText((window.showErrors ? window.startupProblem() : "") - || window.serviceProblem("startup")) - onEdited: setup.clearError("startup") - } - } - } - Item { Layout.fillHeight: true } - } - - // ---- 03 time zone ------------------------------------- - ColumnLayout { - anchors.fill: parent - visible: window.page === 2 - spacing: Math.round(12 * window.u) - SearchList { - id: timezoneList - objectName: "timezoneList" - Layout.fillWidth: true - Layout.fillHeight: true - label: window.tr("Time zone") - placeholder: window.tr("Search for a city") - entries: setup.timezones - current: window.timezone - onChosen: id => window.timezone = id - } - Problem { text: window.problemText(window.serviceProblem("timezone")) } - } - - // ---- 04 finishing ------------------------------------- - ColumnLayout { - anchors.fill: parent - visible: window.page === 3 - objectName: "finishing" - spacing: Math.round(14 * window.u) - Repeater { - model: setup.state === "working" ? setup.stages : [] - delegate: RowLayout { - id: stageRow - required property string modelData - required property int index - readonly property int reached: setup.stages.indexOf(setup.stage) - spacing: Math.round(14 * window.u) - Rectangle { - implicitWidth: Math.round(14 * window.u) - implicitHeight: implicitWidth - radius: implicitWidth / 2 - color: stageRow.index < stageRow.reached ? window.accent : "transparent" - border.width: Math.round(2 * window.u) - border.color: stageRow.index <= stageRow.reached ? window.accent : window.stroke - } - Text { - text: window.tr(window.stageLabels[stageRow.modelData]) - color: stageRow.index === stageRow.reached ? window.ink : window.inkMid - font.pixelSize: Math.round(16 * window.u) - font.weight: stageRow.index === stageRow.reached ? Font.DemiBold : Font.Normal - } - } - } - ProgressBar { - id: progressBar - visible: setup.state === "working" - Layout.fillWidth: true - Layout.topMargin: Math.round(10 * window.u) - indeterminate: true - background: Rectangle { - implicitHeight: Math.round(4 * window.u) - radius: height / 2 - color: window.stroke - } - contentItem: Item { - implicitHeight: Math.round(4 * window.u) - clip: true - Rectangle { - id: runner - width: parent.width * 0.3 - height: parent.height - radius: height / 2 - color: window.accent - NumberAnimation on x { - from: -runner.width - to: progressBar.width - duration: 1600 - loops: Animation.Infinite - running: progressBar.visible - } - } - } - } - Problem { - objectName: "failure" - text: setup.state === "failed" ? window.problemText("failed") - + (setup.errorDetail !== "" ? "\n" + setup.errorDetail : "") : "" - } - Hint { - visible: setup.state === "done" && setup.encrypted - text: window.tr("When it starts, unlock the disk with your new password.") - font.pixelSize: Math.round(16 * window.u) - color: window.ink - } - Hint { - visible: setup.state === "done" - text: window.tr("Restarting in %1 seconds").arg(window.countdown) - Accessible.role: Accessible.StaticText - } - Item { Layout.fillHeight: true } - } - } - - Rectangle { - Layout.fillWidth: true - Layout.topMargin: Math.round(20 * window.u) - Layout.preferredHeight: 1 - color: window.stroke - visible: primaryButton.visible - } - RowLayout { - Layout.fillWidth: true - Layout.topMargin: Math.round(16 * window.u) - spacing: Math.round(16 * window.u) - Item { Layout.fillWidth: true } - ActionButton { - objectName: "backButton" - visible: window.page === 1 || window.page === 2 - || (window.page === 3 && setup.state === "failed") - text: window.tr("Back") - onClicked: window.back() - } - ActionButton { - id: primaryButton - objectName: "primaryButton" - primary: true - visible: window.page < 3 || setup.state === "failed" || setup.state === "done" - enabled: window.page !== 0 || setup.state === "idle" - text: window.tr(window.page === 2 ? "Set up" - : window.page === 3 && setup.state === "failed" ? "Try again" - : window.page === 3 ? "Restart now" : "Continue") - onClicked: window.next() - } - } - } - } - } -} diff --git a/image/rootfs/usr/share/cybexos/welcome/Main.qml b/image/rootfs/usr/share/cybexos/welcome/Main.qml deleted file mode 100644 index 8c0bbe41..00000000 --- a/image/rootfs/usr/share/cybexos/welcome/Main.qml +++ /dev/null @@ -1,771 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Effects -import QtQuick.Layouts -import QtQuick.Shapes - -// The first-login welcome. An installed system opens on a row of popular -// Wallhaven wallpapers (the shell searches, downloads and applies them; this -// window only asks over IPC) and one way onward, Settings. Closing the window -// finishes the welcome. A live session keeps its install-or-explore choice. -ApplicationWindow { - id: window - - readonly property color ink: "#f4f4f5" - readonly property color inkMid: "#b4b4bb" - readonly property color inkFaint: "#80808a" - readonly property color surface: "#17171a" - readonly property color surfaceHover: "#202024" - readonly property color stroke: "#2a2a30" - // Cybex red, from the wordmark. - readonly property color accent: "#dd0034" - readonly property color accentHover: "#f0164a" - readonly property color accentDown: "#b8002b" - readonly property color accentText: "#ff6b86" - - // Available geometry is logical (after display scaling) and excludes - // reserved desktop space. Keep the familiar size on roomy displays. - readonly property var workArea: { - // Re-evaluate when the screen or its reserved geometry changes. - void Screen.width; void Screen.height; - void Screen.desktopAvailableWidth; void Screen.desktopAvailableHeight; - return welcome.availableWorkArea(screen ? screen.name : ""); - } - readonly property real availableWidth: workArea.width - readonly property real availableHeight: workArea.height - width: Math.min(availableWidth, Math.max(760, Math.round(availableWidth * 0.5))) - height: Math.min(availableHeight, Math.max(660, Math.round(availableHeight * 0.48))) - minimumWidth: Math.min(360, availableWidth) - minimumHeight: Math.min(280, availableHeight) - visible: true - title: "Welcome to CybexOS" - color: "#0e0e10" - font.family: "Figtree" - font.pixelSize: 16 - palette.windowText: ink - palette.text: ink - palette.buttonText: ink - palette.button: surface - palette.highlight: accent - - readonly property real contentMargin: Math.min(40, Math.max(16, width * 0.05)) - - // Sequential focus reveals controls in a long, scrolled welcome too. - onActiveFocusItemChanged: Qt.callLater(revealFocus) - function revealFocus() { - const item = activeFocusItem; - if (!item) return; - let ancestor = item; - while (ancestor && ancestor !== content) ancestor = ancestor.parent; - if (!ancestor) return; - const top = item.mapToItem(viewport.contentItem, 0, 0).y; - const last = top + item.height; - const maxY = Math.max(0, viewport.contentHeight - viewport.height); - if (top < viewport.contentY) - viewport.contentY = Math.max(0, top - 8); - else if (last > viewport.contentY + viewport.height) - viewport.contentY = Math.min(maxY, last - viewport.height + 8); - } - - onClosing: close => { - // Installation-only mode has no desktop behind this window. - if (welcome.busy || welcome.isInstallMode) { - close.accepted = false; - return; - } - welcome.markComplete(); - } - - // A wallpaper picked elsewhere (Settings, rotation) while this window - // was in the background shows up as current when it comes back. - onActiveChanged: { - if (active && !welcome.isLive) - welcome.refreshWallpapers() - } - - Component.onCompleted: { - if (welcome.isInstallMode) - welcome.install(); - else if (!welcome.isLive) - welcome.loadWallpapers(); - } - - Shortcut { - sequence: "Escape" - onActivated: window.close() - } - - Connections { - target: welcome - function onChanged() { - // Keep the process alive to report failures, while giving the - // installer the full desktop instead of tiling two windows. - if (welcome.busy) window.hide(); - else window.show(); - } - function onActivate() { - window.show(); - window.raise(); - window.requestActivate(); - } - } - - component ActionButton: Button { - id: button - property bool primary: false - implicitHeight: 48 - leftPadding: 26 - rightPadding: 26 - font.weight: Font.DemiBold - background: Rectangle { - radius: 12 - color: button.primary - ? (button.down ? window.accentDown : button.hovered ? window.accentHover : window.accent) - : (button.hovered ? window.surfaceHover : window.surface) - opacity: button.enabled ? 1 : 0.5 - border.width: button.visualFocus ? 2 : 0 - border.color: "#ffffff" - } - contentItem: Text { - text: button.text - color: button.primary ? "#ffffff" : window.ink - font: button.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - } - - component SectionLabel: Text { - color: window.inkFaint - font.pixelSize: 12 - font.weight: Font.DemiBold - font.letterSpacing: 1.6 - font.capitalization: Font.AllUppercase - } - - // A wallpaper card: the thumbnail with rounded corners, a red ring and - // check on the current one, and a scrim with progress while it downloads. - component WallpaperCard: Item { - id: card - property string thumb: "" - property bool current: false - property bool downloading: false - property int progress: 0 - // Keyboard focus lives on the row; the row says which card has it. - property bool focused: false - property bool hovered: cardMouse.containsMouse - signal picked() - - width: 256 - height: 144 - readonly property real radius: Math.round(height / 12) - - Rectangle { - anchors.fill: parent - radius: card.radius - color: window.surface - Text { - anchors.centerIn: parent - visible: thumbImage.status !== Image.Ready - text: thumbImage.status === Image.Error ? "Could not load" : "Loading…" - color: window.inkFaint - font.pixelSize: 13 - } - } - - Image { - id: thumbImage - anchors.fill: parent - source: card.thumb - sourceSize.width: 512 - sourceSize.height: 288 - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: true - visible: false - layer.enabled: true - } - - Rectangle { - id: cardMask - anchors.fill: parent - radius: card.radius - visible: false - layer.enabled: true - } - - MultiEffect { - anchors.fill: parent - source: thumbImage - maskEnabled: true - maskSource: cardMask - visible: thumbImage.status === Image.Ready - // A little lift on hover, without moving the card. - brightness: card.hovered && !card.downloading ? 0.06 : 0 - } - - Rectangle { - anchors.fill: parent - radius: card.radius - visible: card.downloading - color: Qt.rgba(0, 0, 0, 0.55) - Text { - anchors.centerIn: parent - text: "Downloading " + card.progress + "%" - color: "#ffffff" - font.pixelSize: 14 - font.weight: Font.DemiBold - } - Rectangle { - anchors.left: parent.left - anchors.bottom: parent.bottom - anchors.margins: 10 - height: 4 - radius: 2 - width: (parent.width - 20) * card.progress / 100 - color: window.accent - } - } - - Rectangle { - anchors.fill: parent - radius: card.radius - color: "transparent" - border.width: card.current ? 3 : card.focused ? 2 : card.hovered ? 1 : 0 - border.color: card.current ? window.accent : "#ffffff" - } - - Rectangle { - visible: card.current - anchors.top: parent.top - anchors.right: parent.right - anchors.margins: 10 - width: 26 - height: 26 - radius: 13 - color: window.accent - // Drawn rather than typed: Figtree has no check mark, and the - // fallback font drew a "V". - Shape { - anchors.fill: parent - preferredRendererType: Shape.CurveRenderer - ShapePath { - strokeColor: "#ffffff" - strokeWidth: 2.4 - fillColor: "transparent" - capStyle: ShapePath.RoundCap - joinStyle: ShapePath.RoundJoin - startX: 7.5; startY: 13.5 - PathLine { x: 11.2; y: 17.2 } - PathLine { x: 18.5; y: 9.2 } - } - } - } - - MouseArea { - id: cardMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: card.picked() - } - } - - ScrollView { - id: scrollView - objectName: "welcomeScroll" - anchors.fill: parent - clip: true - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - - Flickable { - id: viewport - objectName: "welcomeViewport" - contentWidth: width - contentHeight: content.height + window.contentMargin * 2 - boundsBehavior: Flickable.StopAtBounds - } - } - - ColumnLayout { - id: content - parent: viewport.contentItem - x: window.contentMargin - y: window.contentMargin - width: viewport.width - window.contentMargin * 2 - height: Math.max(implicitHeight, viewport.height - window.contentMargin * 2) - spacing: 0 - - RowLayout { - Layout.fillWidth: true - Image { - source: "cybex-wordmark.svg" - Layout.preferredHeight: 30 - Layout.preferredWidth: 30 * 198 / 46.6 - sourceSize.height: 90 - fillMode: Image.PreserveAspectFit - smooth: true - Accessible.role: Accessible.Graphic - Accessible.name: "Cybex" - } - Item { Layout.fillWidth: true } - SectionLabel { - text: welcome.isLive ? "Live preview · Alpha" : "Your desktop" - } - } - - Item { Layout.preferredHeight: 44 } - - Text { - Layout.fillWidth: true - text: welcome.isInstallMode ? "Let’s install CybexOS." : welcome.isLive ? "Make yourself at home." : "Welcome to your new desktop." - color: window.ink - font.pixelSize: 38 - font.weight: Font.DemiBold - wrapMode: Text.WordWrap - } - - Item { Layout.preferredHeight: 10 } - - Text { - Layout.fillWidth: true - text: welcome.isInstallMode - ? "The installer opens by itself. If you close it, open it again here, or start the live desktop instead." - : welcome.isLive - ? "Explore the desktop from your USB drive, or install it when you’re ready." - : "Pick a wallpaper to start: the desktop’s colors follow it. You can come back here any time from the app launcher." - color: window.inkMid - wrapMode: Text.WordWrap - lineHeight: 1.35 - } - - Item { Layout.preferredHeight: welcome.isLive ? 34 : 20 } - - Rectangle { - visible: !welcome.isLive - Layout.fillWidth: true - Layout.preferredHeight: setupRows.implicitHeight + 24 - radius: 12 - color: window.surface - ColumnLayout { - id: setupRows - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 12 - spacing: 7 - RowLayout { - Layout.fillWidth: true - Text { - Layout.fillWidth: true - text: welcome.networkState === "connected" ? "Network connected" - : welcome.networkState === "checking" ? "Checking network…" : "Network connection needed" - color: welcome.networkState === "connected" ? window.inkMid : window.accentText - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - Button { - visible: welcome.networkState === "offline" - text: "Open network settings" - flat: true - onClicked: welcome.openSettings("network") - } - } - RowLayout { - Layout.fillWidth: true - Text { - Layout.fillWidth: true - text: welcome.hardwareState === "completed" ? "Hardware setup complete" - : welcome.hardwareState === "skipped" ? "Hardware setup: no special device support needed" - : welcome.hardwareState === "pending" && welcome.hardwareNote.indexOf("reboot") >= 0 - ? "Camera update installed — reboot to finish validation" - : welcome.hardwareState === "pending" - && (welcome.nvidiaState === "reboot" || welcome.nvidiaState === "enroll") - ? "Hardware setup continues after a restart" - : welcome.hardwareState === "running" ? "Hardware setup in progress" - : welcome.hardwareState === "failed" ? "Hardware setup needs another attempt" - : "Hardware setup pending" - color: welcome.hardwareState === "failed" || welcome.hardwareState === "pending" - ? window.accentText : window.inkMid - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - Button { - visible: welcome.hardwareState === "failed" - text: "Retry hardware" - flat: true - onClicked: welcome.retryHardware() - } - } - // Only on NVIDIA systems. A failure also marks the hardware - // row above as failed, which carries the retry button. - Text { - Layout.fillWidth: true - visible: welcome.nvidiaState !== "" - text: welcome.nvidiaState === "ready" ? "NVIDIA driver ready" - : welcome.nvidiaState === "checking" ? "Checking the NVIDIA driver…" - : welcome.nvidiaState === "reboot" ? "NVIDIA driver installed — restart to start using it" - : welcome.nvidiaState === "enroll" - ? "Secure Boot: restart, choose Enroll MOK at the blue screen, then Continue and Yes, and enter " - + (welcome.nvidiaPassword !== "" ? "the one-time password " + welcome.nvidiaPassword - : "the one-time password from the notification") - : welcome.nvidiaState === "skipped" ? "Secure Boot key not enrolled — the open-source driver is in use" - : welcome.nvidiaState === "failed" ? "NVIDIA driver setup failed — the open-source driver is in use" - : "NVIDIA driver installs when the network is connected" - color: welcome.nvidiaState === "ready" || welcome.nvidiaState === "checking" - ? window.inkMid : window.accentText - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - RowLayout { - Layout.fillWidth: true - Text { - Layout.fillWidth: true - text: welcome.seedState === "ready" ? "Offline applications ready" - : welcome.seedState === "copying" ? "Preparing offline applications…" - : welcome.seedState === "error" ? "Offline application preparation interrupted" - : "Offline applications waiting to prepare" - color: welcome.seedState === "error" ? window.accentText : window.inkMid - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - Button { - visible: welcome.seedState === "error" - text: "Retry apps" - flat: true - onClicked: welcome.retryApps() - } - } - Text { - Layout.fillWidth: true - visible: welcome.reconcileState !== "ready" - text: welcome.reconcileState === "error" || welcome.reconcileState === "blocked" - ? "Desktop policy update needs attention. Run cybex doctor for details." - : "Applying desktop policy updates…" - color: welcome.reconcileState === "error" || welcome.reconcileState === "blocked" - ? window.accentText : window.inkMid - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - } - } - - Item { Layout.preferredHeight: welcome.isLive ? 0 : 14 } - - // ---- installed: make it yours --------------------------------- - ColumnLayout { - visible: !welcome.isLive - Layout.fillWidth: true - // The row takes the free height (up to its largest cards). - Layout.fillHeight: true - spacing: 14 - - RowLayout { - Layout.fillWidth: true - SectionLabel { text: "Make it yours" } - Item { Layout.fillWidth: true } - Text { - text: welcome.wallpaperSource === "online" ? "Popular on wallhaven.cc" - : welcome.wallpaperSource === "local" ? "On this computer" : "" - color: window.inkFaint - font.pixelSize: 13 - } - } - - Item { - id: rowArea - // Cards grow into the window's free height, from 144 to 234 - // pixels tall, so a large window shows larger previews rather - // than an empty band above the footer. - readonly property real cardHeight: Math.max(144, Math.min(234, height - 18)) - readonly property real cardWidth: Math.round(cardHeight * 16 / 9) - Layout.fillWidth: true - Layout.fillHeight: true - // Takes the free height before the spacer below does. - Layout.verticalStretchFactor: 1000 - Layout.minimumHeight: 144 + 18 - Layout.maximumHeight: 234 + 18 - - ListView { - id: wallpaperRow - anchors.fill: parent - orientation: ListView.Horizontal - spacing: 14 - clip: true - boundsBehavior: Flickable.StopAtBounds - model: welcome.wallpapers - activeFocusOnTab: count > 0 - keyNavigationEnabled: true - highlightFollowsCurrentItem: false - Accessible.role: Accessible.List - Accessible.name: "Wallpapers" - // Shown whenever there is more to the side, so the row - // reads as scrollable before anyone tries. - ScrollBar.horizontal: ScrollBar { - policy: wallpaperRow.contentWidth > wallpaperRow.width - ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff - } - - Keys.onReturnPressed: currentItem && currentItem.picked() - Keys.onEnterPressed: currentItem && currentItem.picked() - Keys.onSpacePressed: currentItem && currentItem.picked() - - delegate: WallpaperCard { - required property var modelData - required property int index - width: rowArea.cardWidth - height: rowArea.cardHeight - thumb: modelData.thumb - current: modelData.fileName === welcome.currentFile - downloading: modelData.key === welcome.downloadingKey - progress: welcome.downloadProgress - focused: wallpaperRow.activeFocus && wallpaperRow.currentIndex === index - Accessible.role: Accessible.Button - Accessible.name: "Use wallpaper " + modelData.label - onPicked: { - wallpaperRow.currentIndex = index; - welcome.pickWallpaper(modelData.key); - } - } - - // The rest of Wallhaven lives in Settings → Wallpaper → Online. - footer: Item { - width: moreCard.width + 14 - height: rowArea.cardHeight - Rectangle { - id: moreCard - x: 14 - width: Math.round(rowArea.cardWidth * 0.7) - height: rowArea.cardHeight - radius: Math.round(height / 12) - color: moreMouse.containsMouse ? window.surfaceHover : window.surface - border.width: 1 - border.color: window.stroke - Column { - anchors.centerIn: parent - spacing: 6 - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: "More online →" - color: window.ink - font.weight: Font.DemiBold - } - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: "Search in Settings" - color: window.inkFaint - font.pixelSize: 13 - } - } - MouseArea { - id: moreMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: welcome.openSettings("wallpaper") - } - } - } - } - - // A vertical wheel scrolls the row sideways; clicks and - // hovers still reach the cards beneath. - MouseArea { - anchors.fill: wallpaperRow - acceptedButtons: Qt.NoButton - onWheel: wheel => { - const delta = wheel.pixelDelta.x !== 0 || wheel.pixelDelta.y !== 0 - ? (wheel.pixelDelta.x !== 0 ? wheel.pixelDelta.x : wheel.pixelDelta.y) - : (wheel.angleDelta.x !== 0 ? wheel.angleDelta.x : wheel.angleDelta.y) / 120 * rowArea.cardWidth / 2; - const start = wallpaperRow.originX; - const end = start + Math.max(0, wallpaperRow.contentWidth - wallpaperRow.width); - wallpaperRow.contentX = Math.max(start, Math.min(end, wallpaperRow.contentX - delta)); - } - } - - // Loading and waiting states stand in for the row until it - // has something to show. - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - height: rowArea.cardHeight - radius: 12 - visible: wallpaperRow.count === 0 - color: window.surface - Text { - anchors.centerIn: parent - width: parent.width - 48 - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - text: welcome.wallpaperSource === "waiting" ? "Waiting for the desktop to start…" - : welcome.wallpaperSource === "unavailable" ? welcome.wallpaperNote - : "Finding popular wallpapers…" - color: window.inkMid - } - } - } - - RowLayout { - Layout.fillWidth: true - visible: welcome.wallpaperNote !== "" && wallpaperRow.count > 0 - || welcome.wallpaperSource === "local" || welcome.wallpaperSource === "unavailable" - spacing: 12 - Text { - Layout.fillWidth: true - visible: wallpaperRow.count > 0 - text: welcome.wallpaperNote - color: window.inkMid - font.pixelSize: 14 - wrapMode: Text.WordWrap - Accessible.role: Accessible.AlertMessage - } - Item { Layout.fillWidth: wallpaperRow.count === 0 } - Button { - visible: welcome.wallpaperSource === "local" || welcome.wallpaperSource === "unavailable" - text: "Try again" - flat: true - onClicked: welcome.loadWallpapers() - } - } - - // Height the largest cards cannot use collects here, under the - // row, instead of spreading gaps between the heading and the row. - Item { - Layout.fillHeight: true - Layout.verticalStretchFactor: 1 - } - } - - // ---- live: guided installation -------------------------------- - Rectangle { - visible: welcome.isLive - Layout.fillWidth: true - Layout.preferredHeight: details.implicitHeight + 40 - radius: 16 - color: window.surface - ColumnLayout { - id: details - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 20 - spacing: 14 - Text { - text: "A guided installation" - color: window.ink - font.weight: Font.DemiBold - } - Text { - Layout.fillWidth: true - text: "Create your account, choose a disk, and review. Encryption and automatic login are selected by default." - color: window.inkMid - wrapMode: Text.WordWrap - lineHeight: 1.3 - } - Text { - Layout.fillWidth: true - text: "Your disk and account passwords start out the same. Changing the account password later does not update the disk password." - color: window.accentText - wrapMode: Text.WordWrap - font.pixelSize: 14 - } - } - } - - Text { - Layout.fillWidth: true - Layout.topMargin: 14 - visible: welcome.status.length > 0 - text: welcome.status - color: window.accentText - wrapMode: Text.WordWrap - font.pixelSize: 14 - Accessible.role: Accessible.AlertMessage - } - - Item { Layout.fillHeight: welcome.isLive } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: window.stroke - } - - Item { Layout.preferredHeight: 20 } - - GridLayout { - Layout.fillWidth: true - columns: window.width < 700 ? 1 : 2 - columnSpacing: 16 - rowSpacing: 16 - - ColumnLayout { - visible: !welcome.isLive - Layout.fillWidth: true - spacing: 4 - Text { - Layout.fillWidth: true - text: "Press Super + K to see every keyboard shortcut." - color: window.inkMid - font.pixelSize: 14 - wrapMode: Text.WordWrap - } - Text { - Layout.fillWidth: true - text: "Your app keyring stays encrypted and unlocks with the disk password when they match." - color: window.inkFaint - font.pixelSize: 13 - wrapMode: Text.WordWrap - } - } - - Flow { - Layout.fillWidth: true - Layout.columnSpan: welcome.isLive ? parent.columns : 1 - Layout.minimumWidth: window.width < 700 ? 0 : 170 - Layout.preferredWidth: welcome.isLive ? content.width : 170 - spacing: 8 - layoutDirection: Qt.RightToLeft - ActionButton { - objectName: "welcomePrimaryAction" - primary: true - text: welcome.isLive ? (welcome.busy ? "Installer is open…" : "Install CybexOS") : "Settings" - enabled: !welcome.busy - onClicked: welcome.isLive ? welcome.install() : welcome.openSettings("appearance") - } - - Button { - visible: welcome.isInstallMode - text: "Power off" - enabled: !welcome.busy - flat: true - onClicked: welcome.power("poweroff") - } - - Button { - visible: welcome.isInstallMode - text: "Restart" - enabled: !welcome.busy - flat: true - onClicked: welcome.power("reboot") - } - - Button { - visible: welcome.isInstallMode - text: "Start the live desktop" - enabled: !welcome.busy - flat: true - onClicked: welcome.startDesktop() - } - - Button { - visible: welcome.isLive && !welcome.isInstallMode - text: "Try the desktop first →" - enabled: !welcome.busy - flat: true - onClicked: welcome.finish() - } - } - } - } -} diff --git a/image/rootfs/usr/share/cybexos/welcome/cybex-wordmark.svg b/image/rootfs/usr/share/cybexos/welcome/cybex-wordmark.svg deleted file mode 100644 index be3df348..00000000 --- a/image/rootfs/usr/share/cybexos/welcome/cybex-wordmark.svg +++ /dev/null @@ -1,10 +0,0 @@ - - Cybex wordmark — for dark backgrounds - - - - - - - - diff --git a/image/rootfs/usr/share/wayland-sessions/hyprland-quickshell.desktop b/image/rootfs/usr/share/wayland-sessions/hyprland-quickshell.desktop deleted file mode 100644 index c067dc5a..00000000 --- a/image/rootfs/usr/share/wayland-sessions/hyprland-quickshell.desktop +++ /dev/null @@ -1,7 +0,0 @@ -[Desktop Entry] -Name=CybexOS -Comment=Hyprland with the Quickshell desktop -Exec=/usr/bin/hyprland-quickshell -TryExec=/usr/bin/hyprland-quickshell -Type=Application -DesktopNames=Hyprland diff --git a/image/sbom.py b/image/sbom.py deleted file mode 100644 index 4427d622..00000000 --- a/image/sbom.py +++ /dev/null @@ -1,865 +0,0 @@ -"""SPDX 2.3 software bill of materials for the CybexOS live ISO payload. - -The image compose records raw inventories of the finished image root: every -installed RPM and Flatpak ref. This module combines them with the -checksum-pinned downloads in the inventory and the build provenance into one -deterministic SPDX JSON document. It uses only the standard library; reading -the Ansible inventory additionally needs PyYAML, which every builder and -release host already installs for the rest of the image tooling. -""" -import argparse -from datetime import datetime, timezone -import hashlib -import json -import os -from pathlib import Path -import re -import subprocess -import sys -from urllib.parse import quote, urlsplit -import uuid - -ROOT = Path(__file__).resolve().parents[1] -REPOSITORY = "DigitalPals/CybexOS" -SUPPLIER = "Organization: DigitalPals" -ROOT_LICENSE = "MIT" -ROOT_ID = "SPDXRef-CybexOS" -DOCUMENT_ID = "SPDXRef-DOCUMENT" -DESKTOP_PACKAGE = "cybexos-desktop" -OFFLINE_PACKAGE = "cybexos-offline-apps" -# GitHub's SBOM attestation rejects predicates above 16 MiB; fail at build time. -SIZE_LIMIT = 16 * 1024 * 1024 - -# Raw inventories written beside the build artifacts by image/compose. -RPM_INVENTORY = "rpm-inventory.tsv" -FLATPAK_INVENTORY = "flatpak-inventory.tsv" -RPM_FIELDS = ("name", "epoch", "version", "release", "arch", "license", "sourcerpm", - "vendor", "packager", "url") -# One package per line, tab-separated in RPM_FIELDS order. rpm prints "(none)" -# for an absent tag; the parser treats it as an empty value. -RPM_QUERYFORMAT = ("%{NAME}\t%{EPOCH}\t%{VERSION}\t%{RELEASE}\t%{ARCH}\t%{LICENSE}\t" - "%{SOURCERPM}\t%{VENDOR}\t%{PACKAGER}\t%{URL}\n") -FLATPAK_COLUMNS = "application,arch,branch,origin,version" -FLATPAK_FIELDS = ("kind", "application", "arch", "branch", "origin", "commit", "version") - -# Copies of URLs that live in role scripts rather than the inventory; the -# tests keep them equal to the installers that actually download them. -OMAWRITE_REPOSITORY = "omacom/omawrite" -LAZYVIM_STARTER = "https://github.com/LazyVim/starter.git" - -# purl namespaces for known RPM vendors; the order matters (Copr before Fedora). -RPM_NAMESPACES = ( - (r"fedora copr", "copr"), - (r"^fedora( project)?$", "fedora"), - (r"rpm ?fusion", "rpmfusion"), - (r"brave", "brave"), - (r"1password|agilebits", "1password"), - (r"openai", "openai"), - (r"tailscale", "tailscale"), - (r"cybexos|digitalpals", "cybexos"), -) - -NAME = re.compile(r"[A-Za-z0-9_+][A-Za-z0-9._+-]*") -EVR_PART = re.compile(r"[A-Za-z0-9._+~^]+") -FLATPAK_ID = re.compile(r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+") -FLATPAK_PART = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._-]*") -COMMIT = re.compile(r"[0-9a-f]{64}") -SHA256 = re.compile(r"[0-9a-f]{64}") -REVISION = re.compile(r"[0-9a-f]{40,64}") -SPDX_ID = re.compile(r"SPDXRef-[A-Za-z0-9.-]+") -LICENSE_REF = re.compile(r"LicenseRef-[A-Za-z0-9.-]+") -LICENSE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9.-]*\+?") -# Callaway-era Fedora names and vendor words are syntactically plausible but -# are not SPDX identifiers. Such tags fall back to a LicenseRef. -NOT_SPDX = re.compile(r"(?i)(?:a|l)?gplv\d.*|mplv\d.*|asl|bsd|gpl|lgpl|proprietary|commercial|" - r"freeware|freely|redistributable|distributable|unknown|other|restricted|" - r"shareware|copyright|public|domain") -FEDORA_LICENSE_DATA = "https://gitlab.com/fedora/legal/fedora-license-data" - - -def absent(value): - value = value.strip() - return "" if value in ("", "(none)") else value - - -def sha256_file(path): - with Path(path).open("rb") as stream: - return hashlib.file_digest(stream, "sha256").hexdigest() - - -def checksum(value): - value = str(value).lower().removeprefix("sha256:") - if not SHA256.fullmatch(value): - raise ValueError(f"Expected a SHA-256 digest: {value!r}") - return value - - -def spdx_text(value): - """Collapse whitespace so free-form vendor strings stay single-line.""" - return " ".join(str(value).split()) - - -# ── Raw inventories ─────────────────────────────────────────────────────── - -def parse_rpm_inventory(text): - """Parse RPM_QUERYFORMAT output. Imported gpg-pubkey entries are not packages.""" - packages = [] - for number, line in enumerate(text.split("\n"), 1): - if not line.strip(): - continue - fields = line.split("\t") - if len(fields) != len(RPM_FIELDS): - raise ValueError(f"RPM inventory line {number}: expected {len(RPM_FIELDS)} tab-separated fields") - record = {key: absent(value) for key, value in zip(RPM_FIELDS, fields)} - if record["name"] == "gpg-pubkey": - continue - if not NAME.fullmatch(record["name"]): - raise ValueError(f"RPM inventory line {number}: invalid package name") - if any(not EVR_PART.fullmatch(record[key]) for key in ("version", "release", "arch")): - raise ValueError(f"RPM inventory line {number}: invalid version, release or architecture") - if record["epoch"] and not record["epoch"].isdigit(): - raise ValueError(f"RPM inventory line {number}: invalid epoch") - packages.append(record) - if not packages: - raise ValueError("RPM inventory is empty") - return packages - - -def parse_flatpak_inventory(text): - refs = [] - for number, line in enumerate(text.split("\n"), 1): - if not line.strip(): - continue - fields = line.split("\t") - if len(fields) != len(FLATPAK_FIELDS): - raise ValueError(f"Flatpak inventory line {number}: expected {len(FLATPAK_FIELDS)} tab-separated fields") - record = {key: value.strip() for key, value in zip(FLATPAK_FIELDS, fields)} - if (record["kind"] not in ("app", "runtime") or not FLATPAK_ID.fullmatch(record["application"]) - or not FLATPAK_PART.fullmatch(record["arch"]) or not FLATPAK_PART.fullmatch(record["branch"]) - or not COMMIT.fullmatch(record["commit"]) - or (record["origin"] and not FLATPAK_PART.fullmatch(record["origin"]))): - raise ValueError(f"Flatpak inventory line {number}: invalid ref, origin or commit") - refs.append(record) - return refs - - -def active_commit(root, kind, application, arch, branch): - """Read the full deployed commit; `flatpak list` truncates it to 12 digits.""" - if not FLATPAK_ID.fullmatch(application) or not all( - FLATPAK_PART.fullmatch(part) and part != ".." for part in (arch, branch)): - raise ValueError(f"Unsafe Flatpak ref: {kind}/{application}/{arch}/{branch}") - link = Path(root) / "var/lib/flatpak" / kind / application / arch / branch / "active" - commit = os.readlink(link) if link.is_symlink() else "" - if not COMMIT.fullmatch(commit): - raise ValueError(f"No deployed commit for {kind}/{application}/{arch}/{branch}") - return commit - - -def capture_inventories(root, directory, run=subprocess.run): - """Record the composed image root in the formats parsed above.""" - root, directory = Path(root), Path(directory) - rpms = run(["rpm", "--root", str(root), "-qa", "--queryformat", RPM_QUERYFORMAT], - check=True, capture_output=True, text=True).stdout - lines = sorted(line for line in rpms.split("\n") if line.strip()) - rpm_text = "".join(line + "\n" for line in lines) - parse_rpm_inventory(rpm_text) # Fail the build now rather than at release time. - rows = [] - for kind in ("app", "runtime"): - listing = run(["chroot", str(root), "flatpak", "list", "--system", "--" + kind, - "--columns=" + FLATPAK_COLUMNS], check=True, capture_output=True, text=True, - env={**os.environ, "LC_ALL": "C"}).stdout - lines = [line for line in listing.split("\n") if line.strip()] - # Terminal output starts with a title row; piped output normally has none. - if lines and not FLATPAK_ID.fullmatch(lines[0].split("\t")[0].strip()): - lines.pop(0) - for line in lines: - fields = [field.strip() for field in line.split("\t")] - # Non-terminal output is tab-separated; an empty version may be dropped. - if not 4 <= len(fields) <= 5: - raise ValueError(f"Unexpected flatpak list output: {line!r}") - application, arch, branch, origin, version = fields + [""] * (5 - len(fields)) - commit = active_commit(root, kind, application, arch, branch) - rows.append("\t".join((kind, application, arch, branch, origin, commit, version))) - flatpak_text = "".join(row + "\n" for row in sorted(rows)) - parse_flatpak_inventory(flatpak_text) - (directory / RPM_INVENTORY).write_text(rpm_text) - (directory / FLATPAK_INVENTORY).write_text(flatpak_text) - return directory / RPM_INVENTORY, directory / FLATPAK_INVENTORY - - -# ── Licenses and identifiers ────────────────────────────────────────────── - -def _license_tokens(expression): - return re.findall(r"\(|\)|[^\s()]+", expression) - - -def valid_license_expression(expression): - """Loose SPDX 2.3 grammar check: listed-looking ids, LicenseRefs, AND/OR/WITH.""" - tokens = _license_tokens(expression) - position = 0 - - def peek(): - return tokens[position] if position < len(tokens) else None - - def identifier(token): - if token is None or token in ("AND", "OR", "WITH", "(", ")"): - return False - if token.startswith(("LicenseRef-", "DocumentRef-")): - # A LicenseRef cannot take "+"; external documents are not declared here. - return bool(LICENSE_REF.fullmatch(token)) - return bool(LICENSE_ID.fullmatch(token)) and not NOT_SPDX.fullmatch(token.rstrip("+")) - - def factor(): - nonlocal position - token = peek() - if token == "(": - position += 1 - if not compound() or peek() != ")": - return False - position += 1 - return True - if not identifier(token): - return False - position += 1 - if peek() == "WITH": - position += 1 - exception = peek() - if not identifier(exception) or exception.startswith("LicenseRef-") or exception.endswith("+"): - return False - position += 1 - return True - - def compound(): - nonlocal position - if not factor(): - return False - while peek() in ("AND", "OR"): - position += 1 - if not factor(): - return False - return True - - return bool(tokens) and compound() and position == len(tokens) - - -def license_ref_id(raw): - slug = re.sub(r"[^A-Za-z0-9.]+", "-", raw).strip("-.")[:48].strip("-.") or "unknown" - return f"LicenseRef-rpm-{slug}-{hashlib.sha256(raw.encode()).hexdigest()[:8]}" - - -def declared_license(raw, extracted): - """Return an SPDX expression; record any LicenseRef text in `extracted`.""" - raw = spdx_text(raw) - if raw in ("", "(none)"): - return "NOASSERTION" - if raw in ("NOASSERTION", "NONE"): - return raw - if valid_license_expression(raw): - expression = " ".join(_license_tokens(raw)).replace("( ", "(").replace(" )", ")") - for reference in sorted(set(LICENSE_REF.findall(expression))): - entry = {"licenseId": reference, "name": reference, - "extractedText": "The package's License tag references this identifier; " - "its text is in the package's license files."} - if reference.startswith(("LicenseRef-Fedora-", "LicenseRef-Callaway-")): - entry["seeAlsos"] = [FEDORA_LICENSE_DATA] - extracted.setdefault(reference, entry) - return expression - reference = license_ref_id(raw) - extracted.setdefault(reference, { - "licenseId": reference, "name": raw, "extractedText": raw, - "comment": "Verbatim RPM License tag that is not a valid SPDX license expression."}) - return reference - - -def spdx_id(prefix, *parts): - return re.sub(r"[^A-Za-z0-9.-]+", "-", "-".join(("SPDXRef", prefix, *parts))).rstrip("-") - - -def purl(kind, namespace, name, version=None, qualifiers=None): - """Canonical-enough package URL: lowercase namespace, sorted encoded qualifiers.""" - text = "pkg:" + kind + "/" - if namespace: - text += "/".join(quote(segment, safe="") for segment in namespace.lower().split("/")) + "/" - text += quote(name.lower() if kind == "github" else name, safe="") - if version: - text += "@" + quote(version, safe="") - pairs = sorted((key, str(value)) for key, value in (qualifiers or {}).items() if value) - if pairs: - text += "?" + "&".join(f"{key}={quote(value, safe=':/')}" for key, value in pairs) - return text - - -def purl_reference(locator): - return {"referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl", "referenceLocator": locator} - - -def http_url(value): - parts = urlsplit(value or "") - if parts.scheme in ("http", "https") and parts.netloc and not any(char.isspace() for char in value): - return value - return None - - -def rpm_namespace(record, owners): - vendor = record["vendor"].lower() - for pattern, namespace in RPM_NAMESPACES: - if vendor and re.search(pattern, vendor): - return namespace - if record["name"] == DESKTOP_PACKAGE: - return "cybexos" - if not vendor: - # Inventory-pinned GitHub RPMs carry no Vendor tag; their owner is the vendor. - return owners.get(record["name"]) - return re.sub(r"[^a-z0-9.]+", "-", vendor).strip("-.") or None - - -def supplier(record): - for key in ("vendor", "packager"): - value = spdx_text(record[key]) - if value: - match = re.fullmatch(r"(.*?)\s*<([^<>\s]+@[^<>\s]+)>", value) - return "Organization: " + (f"{match[1]} ({match[2]})" if match and match[1] else value) - return "NOASSERTION" - - -# ── Components ──────────────────────────────────────────────────────────── - -def rpm_package(record, fedora_release, owners, extracted): - evr = f"{record['version']}-{record['release']}" - version = f"{record['epoch']}:{evr}" if record["epoch"] else evr - package = { - "SPDXID": spdx_id("RPM", record["name"], version.replace(":", "-"), record["arch"]), - "name": record["name"], - "versionInfo": version, - "supplier": supplier(record), - # The URL tag is the upstream project, not where this binary came from. - "downloadLocation": "NOASSERTION", - "filesAnalyzed": False, - "licenseConcluded": "NOASSERTION", - "licenseDeclared": declared_license(record["license"], extracted), - "copyrightText": "NOASSERTION", - "externalRefs": [purl_reference(purl( - "rpm", rpm_namespace(record, owners), record["name"], evr, - {"arch": record["arch"], "epoch": record["epoch"], "distro": f"fedora-{fedora_release}"}))], - } - if record["sourcerpm"]: - package["sourceInfo"] = f"built from source RPM {record['sourcerpm']}" - homepage = http_url(record["url"]) - if homepage: - package["homepage"] = homepage - return package - - -def flatpak_package(record): - ref = "/".join((record["kind"], record["application"], record["arch"], record["branch"])) - package = { - "SPDXID": spdx_id("Flatpak", record["kind"], record["application"], record["arch"], record["branch"]), - "name": record["application"], - "versionInfo": record["version"] or record["branch"], - "supplier": "Organization: Flathub" if record["origin"] == "flathub" else "NOASSERTION", - "downloadLocation": "NOASSERTION", - "filesAnalyzed": False, - "licenseConcluded": "NOASSERTION", - "licenseDeclared": "NOASSERTION", - "copyrightText": "NOASSERTION", - "primaryPackagePurpose": "APPLICATION" if record["kind"] == "app" else "FRAMEWORK", - "sourceInfo": f"Flatpak {ref} at OSTree commit {record['commit']} from remote " - f"{record['origin'] or 'unknown'}", - # There is no purl type for Flatpak; the ref and commit identify it exactly. - "externalRefs": [ - {"referenceCategory": "OTHER", "referenceType": "flatpak-ref", "referenceLocator": ref}, - {"referenceCategory": "OTHER", "referenceType": "ostree-commit", "referenceLocator": record["commit"]}, - ], - } - return package - - -def literal_asset(pattern): - """Return the filename an anchored asset regex can match, if it is literal.""" - body = str(pattern).removeprefix("^").removesuffix("$").replace("[.]", ".").replace(r"\.", ".") - return body if re.fullmatch(r"[A-Za-z0-9_.+-]+", body) else None - - -def feature_enabled(value, features): - if isinstance(value, bool): - return value - match = re.fullmatch(r"\{\{\s*features\.([A-Za-z0-9_]+)(?:\s*\|\s*bool)?\s*\}\}", str(value).strip()) - if match: - value = features.get(match[1], False) - if isinstance(value, bool): - return value - text = str(value).strip().lower() - if text in ("true", "yes", "on", "1", "false", "no", "off", "0"): - return text in ("true", "yes", "on", "1") - # Fail closed: an unknown condition could add or hide a payload component. - raise ValueError(f"Unsupported enable condition for the SBOM: {value!r}") - - -def download_assets(manifest): - """Map pinned digests to the exact upstream filenames the builder downloaded.""" - if not manifest: - return {} - manifest = Path(manifest) - if not manifest.is_file(): - return {} - assets = {} - for entry in json.loads(manifest.read_text()).get("entries", []): - path = str(entry.get("path", "")) - name = path.rsplit("/", 1)[-1] - if path.startswith("/var/cache/cybexos-upstream/") and re.fullmatch(r"[A-Za-z0-9_.+-]+", name): - assets[checksum(entry.get("sha256", ""))] = name - return assets - - -def _github(url): - parts = urlsplit(url) - path = parts.path.removesuffix(".git").strip("/").split("/") - if parts.netloc == "github.com" and len(path) == 2: - return path - return None - - -def pinned_inputs(inventory, apps_defaults=None, assets=None): - """Checksum- or commit-pinned downloads that the image payload contains. - - Returns (package, placement) pairs. Placement "desktop" means the bytes - are inside the desktop RPM payload (/opt, /usr/local and the user seed), - "rpm" an inventory-pinned RPM installed as its own image package, and - "root" other files of the image. XPS camera sources, Distrobox images and - the source-build container are not part of the ISO payload: they are - fetched on matching hardware or used only as build environments. - """ - features, assets = inventory.get("features", {}), assets or {} - pins = [] - - def add(name, version, location, digest=None, reference=None, purpose="FILE", placement="desktop", - filename=None): - package = { - "SPDXID": spdx_id("Download", name, str(version)), - "name": name, - "versionInfo": str(version), - "supplier": "NOASSERTION", - "downloadLocation": location, - "filesAnalyzed": False, - "licenseConcluded": "NOASSERTION", - "licenseDeclared": "NOASSERTION", - "copyrightText": "NOASSERTION", - "primaryPackagePurpose": purpose, - } - if digest: - package["checksums"] = [{"algorithm": "SHA256", "checksumValue": checksum(digest)}] - if filename: - package["packageFileName"] = filename - if reference: - package["externalRefs"] = [purl_reference(reference)] - pins.append((package, placement)) - - purposes = {"archive": "ARCHIVE", "binary": "APPLICATION", "rpm": "INSTALL", "font": "ARCHIVE"} - for app in inventory.get("github_release_apps", []) + inventory.get("nerd_font_apps", []): - if not feature_enabled(app.get("enabled", True), features): - continue - owner, name = app["repo"].split("/") - tag, digest = str(app["version"]), checksum(app["checksum"]) - asset = assets.get(digest) or literal_asset(app["asset_regex"]) - base = f"https://github.com/{app['repo']}/releases" - location = (f"{base}/download/{quote(tag, safe='')}/{quote(asset)}" if asset - else f"{base}/tag/{quote(tag, safe='')}") - add(app["name"], tag, location, digest, purl("github", owner, name, tag), - purposes.get(app["kind"], "OTHER"), "rpm" if app["kind"] == "rpm" else "desktop", asset) - for font in inventory.get("pinned_font_archives", []): - if feature_enabled(font.get("enabled", True), features): - add(font["name"], font["version"], font["url"], font["checksum"], - purl("generic", None, font["name"], str(font["version"]), - {"checksum": "sha256:" + checksum(font["checksum"]), "download_url": font["url"]}), - "ARCHIVE") - for font in inventory.get("pinned_font_files", []): - add(font["name"], font["version"], font["url"], font["checksum"], - purl("generic", None, font["name"], str(font["version"]), - {"checksum": "sha256:" + checksum(font["checksum"]), "download_url": font["url"]}), - "FILE", filename=font.get("filename")) - if inventory.get("omawrite_commit"): - commit = inventory["omawrite_commit"] - owner, name = OMAWRITE_REPOSITORY.split("/") - add("omawrite", inventory["omawrite_version"], - f"https://codeload.github.com/{OMAWRITE_REPOSITORY}/tar.gz/{commit}", - inventory["omawrite_checksum"], purl("github", owner, name, commit), "SOURCE") - if feature_enabled(features.get("source_builds", False), features): - for app in inventory.get("source_apps", []): - repository = _github(app["repo_url"]) - reference = (purl("github", *repository, app["commit"]) if repository else - purl("generic", None, app["name"], app["tag"], - {"vcs_url": f"git+{app['repo_url']}@{app['commit']}"})) - add(app["name"], app["tag"], f"git+{app['repo_url']}@{app['commit']}", - reference=reference, purpose="SOURCE") - if inventory.get("lazyvim_starter_commit"): - commit = inventory["lazyvim_starter_commit"] - add("lazyvim-starter", commit, f"git+{LAZYVIM_STARTER}@{commit}", - reference=purl("github", *_github(LAZYVIM_STARTER), commit), purpose="SOURCE") - for key in (apps_defaults or {}).get("apps_vendor_repo_keys", []): - if feature_enabled(key.get("enabled", True), features): - add(key["name"] + "-repository-key", checksum(key["checksum"])[:12], key["url"], key["checksum"], - purl("generic", None, key["name"] + "-repository-key", None, - {"checksum": "sha256:" + checksum(key["checksum"]), "download_url": key["url"]}), - "FILE", "root") - return pins - - -# ── Document ────────────────────────────────────────────────────────────── - -def _unique(packages): - seen = {} - for package in packages: - identifier = package["SPDXID"] - if identifier in seen: - identity = json.dumps(package, sort_keys=True).encode() - package["SPDXID"] = f"{identifier}-{hashlib.sha256(identity).hexdigest()[:8]}" - if package["SPDXID"] in seen: - raise ValueError(f"Duplicate SBOM component: {identifier}") - seen[package["SPDXID"]] = package - - -def created_time(provenance): - timestamp = datetime.fromisoformat(str(provenance["utc"]).replace("Z", "+00:00")) - if timestamp.tzinfo is None: - raise ValueError("Build time must include its timezone") - return timestamp.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def build_document(*, rpms, flatpaks, pins, provenance, version, fedora_release="44", - iso=None, desktop_rpm=None, offline_rpm=None, source_archive_sha256=None, owners=None, - source_build_provenance_sha256=None): - """Assemble the SPDX document. File arguments are (filename, sha256) pairs.""" - revision = str(provenance.get("source_revision", "")) - build = str(provenance.get("build_id", "")) - if not REVISION.fullmatch(revision): - raise ValueError("Build provenance needs the full source revision") - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", build): - raise ValueError("Build provenance needs a safe build identifier") - if not re.fullmatch(r"[0-9A-Za-z.+~-]+", version): - raise ValueError("Invalid CybexOS version") - extracted = {} - details = [f"Built from Git revision {revision}" - + (" with uncommitted changes" if provenance.get("source_dirty") else "")] - if provenance.get("source_content_sha256"): - details.append(f"source content SHA-256 {checksum(provenance['source_content_sha256'])} " - f"over {int(provenance.get('source_file_count', 0))} files") - if source_archive_sha256: - details.append(f"builder source archive SHA-256 {checksum(source_archive_sha256)}") - if source_build_provenance_sha256: - details.append("resolved source toolchains source-build-provenance.json SHA-256 " - + checksum(source_build_provenance_sha256)) - details.append(f"build {build}") - root = { - "SPDXID": ROOT_ID, - "name": "CybexOS", - "versionInfo": version, - "supplier": SUPPLIER, - "downloadLocation": f"git+https://github.com/{REPOSITORY}.git@{revision}", - "filesAnalyzed": False, - "licenseConcluded": "NOASSERTION", - "licenseDeclared": ROOT_LICENSE, - "copyrightText": "NOASSERTION", - "primaryPackagePurpose": "OPERATING-SYSTEM", - "sourceInfo": "; ".join(details), - "externalRefs": [purl_reference(purl("github", *REPOSITORY.split("/"), revision))], - } - if iso: - root["packageFileName"] = iso[0] - root["checksums"] = [{"algorithm": "SHA256", "checksumValue": checksum(iso[1])}] - root["comment"] = "The live ISO built from this source; its components are listed below." - - components = [] # (package, inside the desktop RPM payload?) - installed = {} - desktop = None - offline = None - artifacts = {DESKTOP_PACKAGE: desktop_rpm, OFFLINE_PACKAGE: offline_rpm} - for record in sorted(rpms, key=lambda item: tuple(item[key] for key in RPM_FIELDS)): - package = rpm_package(record, fedora_release, owners or {}, extracted) - if record["name"] in artifacts: - name = record["name"] - if name in installed: - raise ValueError(f"The image contains more than one {name} RPM") - if name == DESKTOP_PACKAGE: - desktop = package - else: - offline = package - artifact = artifacts[name] - if artifact: - expected = f"{record['name']}-{record['version']}-{record['release']}.{record['arch']}.rpm" - if artifact[0] != expected: - raise ValueError(f"RPM artifact {artifact[0]} is not the installed {expected}") - package["packageFileName"] = artifact[0] - package["checksums"] = [{"algorithm": "SHA256", "checksumValue": checksum(artifact[1])}] - package["comment"] = ("SHA-256 of the unsigned build output; release signing adds a " - "signature header (release.json unsigned_input_sha256).") - installed.setdefault(record["name"], []).append(package) - components.append((package, False)) - if desktop_rpm and desktop is None: - raise ValueError("The image root does not contain the desktop RPM") - if offline_rpm and offline is None: - raise ValueError("The image root does not contain the offline applications RPM") - for record in sorted(flatpaks, key=lambda item: tuple(item[key] for key in FLATPAK_FIELDS)): - components.append((flatpak_package(record), False)) - for pin, placement in sorted(pins, key=lambda item: item[0]["SPDXID"]): - matches = installed.get(pin["name"], []) - if placement == "rpm" and len(matches) == 1: - # The installed package is exactly this checksum-pinned download. - matches[0].update({key: pin[key] for key in ("downloadLocation", "checksums", "packageFileName") - if key in pin}) - matches[0]["externalRefs"] = matches[0]["externalRefs"] + pin.get("externalRefs", []) - matches[0]["comment"] = "Installed from the inventory-pinned upstream release asset." - continue - components.append((pin, placement == "desktop")) - _unique([root] + [package for package, _ in components]) - - relationships = [] - for package, bundled in components: - container = offline or desktop - parent = container["SPDXID"] if bundled and container is not None else ROOT_ID - relationships.append({"spdxElementId": parent, "relationshipType": "CONTAINS", - "relatedSpdxElement": package["SPDXID"]}) - relationships.sort(key=lambda item: (item["spdxElementId"], item["relatedSpdxElement"])) - packages = [root] + sorted((package for package, _ in components), key=lambda item: item["SPDXID"]) - name = f"CybexOS-Live-{fedora_release}-{build}" - document = { - "spdxVersion": "SPDX-2.3", - "dataLicense": "CC0-1.0", - "SPDXID": DOCUMENT_ID, - "name": name, - "documentNamespace": (f"https://github.com/{REPOSITORY}/spdx/{name}-" - f"{uuid.uuid5(uuid.NAMESPACE_URL, f'{revision}/{name}')}"), - "creationInfo": {"created": created_time(provenance), - "creators": [f"Tool: cybexos-sbom-{version}"]}, - "comment": ("Installed RPMs and Flatpak refs of the CybexOS live image, and the " - "checksum-pinned downloads it bundles."), - "packages": packages, - "relationships": [{"spdxElementId": DOCUMENT_ID, "relationshipType": "DESCRIBES", - "relatedSpdxElement": ROOT_ID}] + relationships, - } - if extracted: - document["hasExtractedLicensingInfos"] = [extracted[key] for key in sorted(extracted)] - return verify_document(document) - - -def serialize(document): - data = (json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode() - if len(data) > SIZE_LIMIT: - raise ValueError("SBOM exceeds the 16 MiB attestation limit") - return data - - -def verify_document(document): - """Structural SPDX 2.3 checks that release tooling relies on.""" - if not isinstance(document, dict): - raise ValueError("SBOM must be a JSON object") - expected = {"spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", "SPDXID": DOCUMENT_ID} - if any(document.get(key) != value for key, value in expected.items()): - raise ValueError("SBOM must be an SPDX 2.3 document with CC0-1.0 data license") - namespace = urlsplit(str(document.get("documentNamespace", ""))) - if not document.get("name") or namespace.scheme != "https" or not namespace.netloc or namespace.fragment: - raise ValueError("SBOM needs a name and a unique HTTPS document namespace") - creation = document.get("creationInfo") or {} - if (not re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ", str(creation.get("created", ""))) - or not creation.get("creators") - or not all(re.match(r"(Tool|Organization|Person): \S", str(item)) for item in creation["creators"])): - raise ValueError("SBOM creation information is incomplete") - packages = document.get("packages") - if not isinstance(packages, list) or not packages: - raise ValueError("SBOM lists no packages") - identifiers = set() - for package in packages: - identifier = package.get("SPDXID", "") - if not SPDX_ID.fullmatch(identifier) or identifier in identifiers: - raise ValueError(f"Invalid or duplicate SPDX identifier: {identifier!r}") - identifiers.add(identifier) - if not package.get("name") or not package.get("downloadLocation"): - raise ValueError(f"{identifier} lacks a name or download location") - if package.get("filesAnalyzed") is not False and "packageVerificationCode" not in package: - raise ValueError(f"{identifier} must declare filesAnalyzed false") - for item in package.get("checksums", []): - if item.get("algorithm") != "SHA256" or not SHA256.fullmatch(str(item.get("checksumValue", ""))): - raise ValueError(f"{identifier} has an invalid checksum") - defined = {item.get("licenseId") for item in document.get("hasExtractedLicensingInfos", []) - if item.get("extractedText")} - for package in packages: - for key in ("licenseDeclared", "licenseConcluded"): - missing = set(LICENSE_REF.findall(str(package.get(key, "")))) - defined - if missing: - raise ValueError(f"{package['SPDXID']} uses undefined {', '.join(sorted(missing))}") - relationships = document.get("relationships") - if not isinstance(relationships, list): - raise ValueError("SBOM lacks relationships") - described, contained, seen = [], set(), set() - for item in relationships: - source, kind, target = (item.get(key) for key in ("spdxElementId", "relationshipType", "relatedSpdxElement")) - if source not in identifiers | {DOCUMENT_ID} or target not in identifiers: - raise ValueError(f"Relationship references an unknown element: {source} {kind} {target}") - if (source, kind, target) in seen: - raise ValueError("Repeated SBOM relationship") - seen.add((source, kind, target)) - if kind == "DESCRIBES" and source == DOCUMENT_ID: - described.append(target) - elif kind == "CONTAINS": - contained.add(target) - if len(described) != 1: - raise ValueError("SBOM must describe exactly one root package") - orphans = identifiers - contained - set(described) - if orphans: - raise ValueError(f"SBOM components are not related to the root: {', '.join(sorted(orphans)[:5])}") - return document - - -def described_package(document): - root = next(item["relatedSpdxElement"] for item in document["relationships"] - if item["spdxElementId"] == DOCUMENT_ID and item["relationshipType"] == "DESCRIBES") - return next(package for package in document["packages"] if package["SPDXID"] == root) - - -def file_checksum(package): - return next((item["checksumValue"] for item in package.get("checksums", []) - if item.get("algorithm") == "SHA256"), None) - - -def verify_release_sbom(path, iso_name, iso_sha256, rpm_sha256s=()): - """Bind an SBOM to the exact ISO and every unsigned release RPM.""" - path = Path(path) - if path.is_symlink() or not path.is_file() or path.stat().st_size > SIZE_LIMIT: - raise ValueError("Release SBOM must be a regular file below 16 MiB") - document = verify_document(json.loads(path.read_text())) - root = described_package(document) - if root.get("packageFileName") != iso_name or file_checksum(root) != iso_sha256: - raise ValueError("Release SBOM does not describe the exact ISO") - if rpm_sha256s: - expected = rpm_sha256s if isinstance(rpm_sha256s, dict) else {DESKTOP_PACKAGE: set(rpm_sha256s)} - if any(package['name'] == OFFLINE_PACKAGE for package in document['packages']) and OFFLINE_PACKAGE not in expected: - raise ValueError('Release SBOM requires the exact candidate cybexos-offline-apps RPM') - for name, digests in expected.items(): - packages = [package for package in document["packages"] if package["name"] == name] - digests = {digests} if isinstance(digests, str) else set(digests) - if len(packages) != 1 or file_checksum(packages[0]) not in digests: - label = 'desktop' if name == DESKTOP_PACKAGE else name - raise ValueError(f"Release SBOM does not identify the exact candidate {label} RPM") - return document - - -# ── Inputs and command line ─────────────────────────────────────────────── - -def load_yaml(path): - try: - import yaml - except ImportError as error: - raise RuntimeError("Reading the inventory needs PyYAML (python3-pyyaml or python3-yaml)") from error - return yaml.safe_load(Path(path).read_text()) or {} - - -def generate(*, rpm_list, flatpaks, provenance, output, inventory=None, apps_defaults=None, - version_file=None, iso=None, desktop_rpm=None, offline_rpm=None, source_archive_sha256=None, downloads=None, - source_build_provenance_sha256=None): - """Write the SBOM atomically. `iso` and `desktop_rpm` are (filename, sha256).""" - inventory = load_yaml(inventory or ROOT / "inventory/group_vars/all.yml") - defaults = load_yaml(apps_defaults or ROOT / "roles/apps/defaults/main.yml") - owners = {app["name"]: app["repo"].split("/")[0] - for app in inventory.get("github_release_apps", []) if app.get("kind") == "rpm"} - document = build_document( - rpms=parse_rpm_inventory(Path(rpm_list).read_text()), - flatpaks=parse_flatpak_inventory(Path(flatpaks).read_text()), - pins=pinned_inputs(inventory, defaults, download_assets(downloads)), - provenance=json.loads(Path(provenance).read_text()), - version=Path(version_file or ROOT / "VERSION").read_text().strip(), - fedora_release=str(inventory.get("fedora_release", "44")), - iso=iso, desktop_rpm=desktop_rpm, offline_rpm=offline_rpm, - source_archive_sha256=source_archive_sha256, owners=owners, - source_build_provenance_sha256=source_build_provenance_sha256) - output = Path(output) - data = serialize(document) - temporary = output.with_name(f".{output.name}.tmp") - try: - temporary.write_bytes(data) - temporary.replace(output) - finally: - temporary.unlink(missing_ok=True) - return output - - -def sbom_name(iso_name): - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*\.iso", iso_name): - raise ValueError("ISO filename must be ASCII without spaces") - return iso_name.removesuffix(".iso") + ".spdx.json" - - -def write_build_sbom(artifacts, iso_name, checksums=None, downloads=None, checkout=ROOT): - """Generate .spdx.json inside a verified artifact staging directory.""" - artifacts, checksums = Path(artifacts), dict(checksums or {}) - - def digest(name): - return checksums.get(name) or sha256_file(artifacts / name) - - rpms = sorted(path.name for path in artifacts.glob(DESKTOP_PACKAGE + "-*.rpm")) - if len(rpms) != 1: - raise ValueError("Artifacts must contain exactly one desktop RPM") - offline = sorted(path.name for path in artifacts.glob(OFFLINE_PACKAGE + "-*.rpm")) - if len(offline) > 1: - raise ValueError("Artifacts contain more than one offline applications RPM") - installed = parse_rpm_inventory((artifacts / RPM_INVENTORY).read_text()) - if any(record['name'] == OFFLINE_PACKAGE for record in installed) and not offline: - raise ValueError("Artifacts must contain the installed offline applications RPM") - archive = artifacts / "source.tar.gz" - return generate( - rpm_list=artifacts / RPM_INVENTORY, flatpaks=artifacts / FLATPAK_INVENTORY, - provenance=artifacts / "build-provenance.json", output=artifacts / sbom_name(iso_name), - inventory=checkout / "inventory/group_vars/all.yml", - apps_defaults=checkout / "roles/apps/defaults/main.yml", version_file=checkout / "VERSION", - iso=(iso_name, digest(iso_name)), desktop_rpm=(rpms[0], digest(rpms[0])), - offline_rpm=(offline[0], digest(offline[0])) if offline else None, - source_archive_sha256=digest(archive.name) if archive.is_file() else None, downloads=downloads, - source_build_provenance_sha256=digest("source-build-provenance.json")) - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - commands = parser.add_subparsers(dest="command", required=True) - make = commands.add_parser("generate", help="Write an SPDX 2.3 JSON SBOM from raw build inventories") - make.add_argument("--rpm-list", type=Path, required=True, help=f"{RPM_INVENTORY} (rpm --queryformat output)") - make.add_argument("--flatpaks", type=Path, required=True, help=FLATPAK_INVENTORY) - make.add_argument("--provenance", type=Path, required=True, help="build-provenance.json") - make.add_argument("--output", type=Path, required=True) - make.add_argument("--inventory", type=Path, default=ROOT / "inventory/group_vars/all.yml") - make.add_argument("--apps-defaults", type=Path, default=ROOT / "roles/apps/defaults/main.yml") - make.add_argument("--version-file", type=Path, default=ROOT / "VERSION") - make.add_argument("--iso", type=Path, help="Built ISO; recorded with its SHA-256") - make.add_argument("--iso-name", help="Delivered ISO filename when it differs from --iso") - make.add_argument("--desktop-rpm", type=Path, help="Unsigned cybexos-desktop RPM artifact") - make.add_argument("--offline-rpm", type=Path, help="Unsigned cybexos-offline-apps RPM artifact") - make.add_argument("--source-archive", type=Path, help="Builder source archive (source.tar.gz)") - make.add_argument("--downloads", type=Path, help="Download cache manifest naming fetched release assets") - check = commands.add_parser("verify", help="Check an SBOM's structure and its ISO binding") - check.add_argument("sbom", type=Path) - check.add_argument("--iso-checksum", type=Path, help="ISO .sha256 file the SBOM must describe") - args = parser.parse_args(argv) - try: - if args.command == "verify": - if args.iso_checksum: - match = re.fullmatch(r"([0-9a-f]{64}) [ *]([A-Za-z0-9._-]+\.iso)\n?", args.iso_checksum.read_text()) - if not match: - raise ValueError("Invalid ISO checksum file") - verify_release_sbom(args.sbom, match[2], match[1]) - else: - verify_document(json.loads(args.sbom.read_text())) - print(f"Valid SPDX 2.3 SBOM: {args.sbom}") - return 0 - iso = (args.iso_name or args.iso.name, sha256_file(args.iso)) if args.iso else None - rpm = (args.desktop_rpm.name, sha256_file(args.desktop_rpm)) if args.desktop_rpm else None - offline = (args.offline_rpm.name, sha256_file(args.offline_rpm)) if args.offline_rpm else None - archive = sha256_file(args.source_archive) if args.source_archive else None - print(generate(rpm_list=args.rpm_list, flatpaks=args.flatpaks, provenance=args.provenance, - output=args.output, inventory=args.inventory, apps_defaults=args.apps_defaults, - version_file=args.version_file, iso=iso, desktop_rpm=rpm, offline_rpm=offline, - source_archive_sha256=archive, downloads=args.downloads)) - return 0 - except (OSError, ValueError, KeyError, TypeError, RuntimeError, json.JSONDecodeError) as error: - print(f"sbom: {error}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/image/source_build_provenance.py b/image/source_build_provenance.py deleted file mode 100644 index 1d1f2fac..00000000 --- a/image/source_build_provenance.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Export resolved compiler facts only for the source binaries actually shipped.""" -import hashlib -import json -from pathlib import Path -import re - - -def collect(inventory, builds=Path('/opt/cybexos-builds')): - if not inventory['features'].get('source_builds', False): - return [] - records = [] - for app in inventory['source_apps']: - current = builds / app['name'] / 'current' - record = json.loads((current / '.cybexos-build-provenance.json').read_text()) - expected = {'name': app['name'], 'repository': app['repo_url'], 'tag': app['tag'], - 'revision': app['commit'], 'base_image': inventory['source_build_container_image']} - if record.get('schema') != 1 or any(record.get(key) != value for key, value in expected.items()): - raise ValueError(f'Source provenance does not match authorized inputs: {app["name"]}') - builder = record.get('builder_image_id', '') - if (not re.fullmatch(r'sha256:[a-f0-9]{64}', builder) - or (current / '.cybexos-builder').read_text().strip() != builder - or not re.fullmatch(r'[a-f0-9]{64}', record.get('recipe_sha256', ''))): - raise ValueError(f'Incomplete source builder identity: {app["name"]}') - if (not record.get('packages') or not all( - isinstance(record.get('compilers', {}).get(tool), str) and record['compilers'][tool].strip() - for tool in ('rustc', 'cargo', 'gcc', 'g++'))): - raise ValueError(f'Incomplete resolved compiler inventory: {app["name"]}') - for package in record['packages']: - if not all(isinstance(package.get(key), str) and package[key] - for key in ('name', 'epoch', 'version', 'release', 'arch')): - raise ValueError('Incomplete resolved builder package') - if set(record.get('binaries', {})) != set(app['binary_names']): - raise ValueError('Incomplete source binary inventory') - for name, expected_digest in record['binaries'].items(): - path = current / 'bin' / name - if path.is_symlink() or not path.is_file() or not path.stat().st_mode & 0o111: - raise ValueError(f'Missing executable source binary: {name}') - if hashlib.sha256(path.read_bytes()).hexdigest() != expected_digest: - raise ValueError(f'Shipped source binary differs from its provenance: {name}') - records.append(record) - return records diff --git a/image/source_snapshot.py b/image/source_snapshot.py deleted file mode 100644 index eb0188f8..00000000 --- a/image/source_snapshot.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Exact reviewed checkout content shared by image and checkout qualification.""" -import hashlib -import json -from pathlib import Path, PurePosixPath -import subprocess -import tarfile - -GENERATED = {'image/update-channel.json', 'image/update-key.asc', 'image/build-provenance.json'} - - -def content_digest(records): - return hashlib.sha256(json.dumps(records, sort_keys=True, separators=(',', ':')).encode()).hexdigest() - - -def source_files(root): - result = subprocess.run(['git', '-C', str(root), 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], - capture_output=True, check=True) - paths = [] - for name in sorted(set(result.stdout.decode().split('\0')) - {''} - GENERATED): - path = Path(root) / name - if '__pycache__' in path.parts or path.suffix == '.pyc': - continue - if path.is_symlink(): - raise ValueError(f'Source snapshot must contain regular files, not symlinks: {name}') - if path.is_file(): - paths.append((name, path)) - return paths - - -def tree_identity(root): - records = [{'path': name, 'executable': bool(path.stat().st_mode & 0o111), - 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for name, path in source_files(root)] - return {'source_content_sha256': content_digest(records), 'source_file_count': len(records)} - - -def write_archive(root, destination, additions=None): - with tarfile.open(destination, 'w:gz') as archive: - for name, path in source_files(root): - archive.add(path, arcname=name, recursive=False) - for name, path in (additions or {}).items(): - if name not in GENERATED: - raise ValueError('Unexpected generated source input') - archive.add(path, arcname=name, recursive=False) - - -def archive_identity(path): - """Validate before extraction: no traversal, duplicate names or links.""" - records, seen = [], set() - with tarfile.open(path, 'r:gz') as archive: - for member in archive: - name = member.name - if (not member.isfile() or PurePosixPath(name).is_absolute() or '..' in PurePosixPath(name).parts - or name in seen or name.startswith('./') or '\\' in name): - raise ValueError('Unsafe or duplicate source archive entry') - seen.add(name) - if name not in GENERATED: - stream = archive.extractfile(member) - records.append({'path': name, 'executable': bool(member.mode & 0o111), - 'sha256': hashlib.file_digest(stream, 'sha256').hexdigest()}) - for required in ('install', 'site.yml', 'inventory/group_vars/all.yml', 'image/build-provenance.json'): - if required not in seen: - raise ValueError(f'Source archive lacks required input: {required}') - records.sort(key=lambda item: item['path']) - return {'source_content_sha256': content_digest(records), 'source_file_count': len(records)} diff --git a/image/system_packages.py b/image/system_packages.py deleted file mode 100644 index f5050659..00000000 --- a/image/system_packages.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Read the ISO's system and desktop package selections for checkout installs.""" -import argparse -import json -from pathlib import Path -import re - -ROOT = Path(__file__).resolve().parents[1] -LOCAL_PACKAGES = {'cybexos-desktop', 'cybexos-offline-apps', 'cybexos-offline-inputs'} -PACKAGE = re.compile(r'[A-Za-z0-9][A-Za-z0-9+_.:-]*\Z') -DEPENDENCY = re.compile(r'([A-Za-z0-9][A-Za-z0-9+_.:-]*)(?:\s*(>=|<=|=|>|<)\s*([^\s]+))?') - - -def contract(root=ROOT): - kickstart = (root / 'image/live.ks').read_text() - match = re.search(r'(?ms)^%packages([^\n]*)\n(.*?)^%end\s*$', kickstart) - if not match or '--exclude-weakdeps' not in match[1].split(): - raise ValueError('The shared ISO package policy must explicitly exclude weak dependencies') - packages, groups, excluded, constraints = set(), set(), set(), {} - for raw in match[2].splitlines(): - value = raw.split('#', 1)[0].strip() - if not value: - continue - if value.startswith('-') and PACKAGE.fullmatch(value[1:]): - excluded.add(value[1:]) - elif value.startswith('@') and PACKAGE.fullmatch(value[1:]): - groups.add(value) - elif PACKAGE.fullmatch(value): - if value not in LOCAL_PACKAGES: - packages.add(value) - else: - raise ValueError('Unsupported ISO package selection: ' + value) - spec = (root / 'image/cybexos-desktop.spec').read_text().split('%description', 1)[0] - for line in spec.splitlines(): - if not line.startswith('Requires:'): - continue - remaining = line.split(':', 1)[1].strip() - while remaining: - dependency = DEPENDENCY.match(remaining) - if not dependency: - raise ValueError('Unsupported desktop dependency: ' + remaining) - name, operator, version = dependency.groups() - if name not in LOCAL_PACKAGES: - if version and '%' in version: - raise ValueError('Unresolved system dependency macro') - packages.add(name) - if operator: - constraint = name + ' ' + operator + ' ' + version - if name in constraints and constraints[name] != constraint: - raise ValueError('Conflicting desktop dependency: ' + name) - constraints[name] = constraint - remaining = remaining[dependency.end():].strip() - if packages & excluded: - raise ValueError('A system package is both selected and excluded') - return {'packages': sorted(packages), 'groups': sorted(groups), 'excluded': sorted(excluded), - 'selectors': sorted(groups) + [constraints.get(name, name) for name in sorted(packages)], - 'install_weak_deps': False} - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--root', type=Path, default=ROOT) - args = parser.parse_args() - print(json.dumps(contract(args.root))) - - -if __name__ == '__main__': - main() diff --git a/image/test-live b/image/test-live deleted file mode 100755 index ff5e1b6b..00000000 --- a/image/test-live +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -"""Explicit live ISO smoke test. No build, installation or PXE publication.""" -import argparse -from pathlib import Path -import signal -import subprocess -import sys -import time - -from build_support import atomic_json, digest -from vm_testing import TestVM, require_test_iso, run - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('iso', type=Path) - parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--firmware', choices=('bios', 'uefi'), default='uefi') - parser.add_argument('--boot-wait', type=int, default=300, help='Bounded readiness timeout, not a fixed startup sleep') - parser.add_argument('--hold', action='store_true') - parser.add_argument('--keep-artifacts', action='store_true', help='Retain task-owned disk/logs for unresolved diagnostics') - parser.add_argument('--execute-vm', action='store_true', help='Required opt-in to starting a VM') - args = parser.parse_args() - if not args.execute_vm: - parser.error('VM execution requires --execute-vm') - iso = require_test_iso(args.iso) - vm = TestVM(args.output, args.firmware) - report = {'iso_sha256': digest(iso), 'firmware': args.firmware, 'status': 'failed'} - try: - vm.prepare() - vm.start(iso) - # The menu default is the installation-only session; this checks the live desktop. - vm.select_boot_entry('live') - vm.wait_ssh(args.boot_wait) - vm.audit() - run([*vm.ssh, 'test -f /run/cybexos-live && test -x /usr/bin/liveinst && XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user is-active cybexos-welcome.service']) - report['status'] = 'passed' - report['checks'] = ['live-boot', 'offline-applications', 'quickshell'] - print('Live desktop smoke check passed. No installation was performed.', flush=True) - if args.hold: - print(f"VM ready for interaction: {args.output / 'vm.json'}", flush=True) - try: - while vm.process.poll() is None: - time.sleep(1) - except KeyboardInterrupt: - pass # A held, already-passing test ends by interruption. - finally: - if vm.owned: - try: - vm.cleanup(args.keep_artifacts) - except BaseException: - report['status'] = 'failed' - raise - finally: - atomic_json(args.output / 'live-report.json', report) - - -if __name__ == '__main__': - def interrupt(number, _frame): - raise KeyboardInterrupt(number) - for name in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): - signal.signal(name, interrupt) - try: - main() - except KeyboardInterrupt as error: - number = error.args[0] if error.args else signal.SIGINT - print(f"image/test-live: interrupted by {signal.Signals(number).name}", file=sys.stderr) - sys.exit(128 + number) - except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: - print(f"image/test-live: {error}", file=sys.stderr) - sys.exit(1) diff --git a/image/test_build_tools.py b/image/test_build_tools.py deleted file mode 100644 index d7c5f137..00000000 --- a/image/test_build_tools.py +++ /dev/null @@ -1,512 +0,0 @@ -"""Source-only fixtures: no ISO filesystem, QEMU process or PXE server is used.""" -import hashlib -import importlib.machinery -import importlib.util -import json -import os -import socket -from pathlib import Path -import tempfile -import tarfile -from types import SimpleNamespace -import unittest -import urllib.error -from unittest.mock import Mock, patch - -from build_support import (builder_cloud_config, checksum_entries, deliver_artifacts, - select_firmware, wait_for_builder_initialization) -from download_cache import import_cache, merge_cache, verified_entries -from pxe_publish import DEFAULT_CONTRACT, IVentoy, publish, staging_parent, validate_status - - -class SourceArchiveTests(unittest.TestCase): - def test_disposable_builder_receives_skill_license_and_provisioning_helper(self): - loader = importlib.machinery.SourceFileLoader('archive_build', str(Path(__file__).with_name('build'))) - spec = importlib.util.spec_from_loader(loader.name, loader) - builder = importlib.util.module_from_spec(spec) - loader.exec_module(builder) - from desktop_payload import prepare_session - from provision_payload import prepare_provision - import yaml - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - archive = root / 'source.tar.gz' - builder.source_archive(archive) - extracted = root / 'source' - with tarfile.open(archive) as stream: - stream.extractall(extracted, filter='data') - payload = root / 'payload' - inventory = yaml.safe_load((extracted / 'inventory/group_vars/all.yml').read_text()) - # Exercise the actual packager against only what crosses the VM - # boundary; testing against the full checkout hid missing inputs. - prepare_session(extracted, payload, inventory) - prepare_provision(extracted, payload) - self.assertTrue((extracted / 'LICENSE').is_file()) - self.assertTrue((payload / 'usr/share/cybexos/agent-skills/cybexos/SKILL.md').is_file()) - helper = payload / 'usr/share/cybexos/provision/scripts/manage-agent-skills' - self.assertTrue(helper.is_file()) - self.assertTrue(helper.stat().st_mode & 0o111) - - -class BuilderInitializationTests(unittest.TestCase): - def test_hostname_is_set_only_during_final_stage(self): - config = builder_cloud_config('ssh-ed25519 synthetic-public-fixture') - self.assertTrue(config['preserve_hostname']) - self.assertEqual(config['runcmd'], [['hostnamectl', 'set-hostname', 'image-builder']]) - # Package transfers use IPv4 only (stalled IPv6 downloads through QEMU user networking). - self.assertEqual(config['bootcmd'], [['sysctl', '-q', '-w', 'net.ipv6.conf.all.disable_ipv6=1', - 'net.ipv6.conf.default.disable_ipv6=1']]) - self.assertFalse(config['ssh_pwauth']) - self.assertTrue(config['disable_root']) - self.assertEqual(config['users'][0]['ssh_authorized_keys'], ['ssh-ed25519 synthetic-public-fixture']) - - def test_success_verifies_hostname_and_sudo_before_source_transfer(self): - result = SimpleNamespace(returncode=0, stdout='{"status":"done"}\n', stderr='') - with tempfile.TemporaryDirectory() as directory, patch('build_support.subprocess.run', return_value=result) as run: - wait_for_builder_initialization(['ssh', 'builder-fixture'], directory) - self.assertEqual(run.call_args_list[0].args[0][-1], 'cloud-init status --wait --format=json') - command = run.call_args_list[1].args[0][-1] - self.assertIn('test "$(hostname)" = image-builder', command) - self.assertIn('sudo -n true', command) - self.assertTrue(run.call_args_list[1].kwargs['check']) - self.assertEqual((Path(directory) / 'builder-cloud-init-status.log').read_text(), result.stdout) - - def test_degraded_or_failed_cloud_init_never_proceeds_and_keeps_evidence(self): - for code in (1, 2, 255): - result = SimpleNamespace(returncode=code, stdout='{"status":"done","recoverable_errors":["fixture"]}\n', stderr='fixture diagnostic\n') - with self.subTest(code=code), tempfile.TemporaryDirectory() as directory, \ - patch('build_support.subprocess.run', return_value=result) as run: - with self.assertRaisesRegex(RuntimeError, f'cloud-init exited {code}'): - wait_for_builder_initialization(['ssh', 'builder-fixture'], directory) - self.assertEqual(run.call_count, 2) - self.assertIn('tail -n 400 /var/log/cloud-init.log', run.call_args.args[0][-1]) - self.assertNotIn('mkdir', run.call_args.args[0][-1]) - self.assertTrue((Path(directory) / 'builder-cloud-init.log').exists()) - self.assertIn('fixture diagnostic', (Path(directory) / 'builder-cloud-init-status.log').read_text()) - - -class DeliveryTests(unittest.TestCase): - def test_verified_directory_is_published_atomically(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - stage = root / 'stage' - stage.mkdir() - (stage / 'payload.bin').write_bytes(b'synthetic fixture, not an image') - checksum = hashlib.sha256((stage / 'payload.bin').read_bytes()).hexdigest() - (stage / 'SHA256SUMS').write_text(f'{checksum} payload.bin\n') - self.assertEqual(deliver_artifacts(stage, root / 'complete'), {'payload.bin': checksum}) - self.assertFalse(stage.exists()) - self.assertEqual((root / 'complete/payload.bin').stat().st_mode & 0o777, 0o644) - - def test_bad_checksum_and_traversal_never_publish(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / 'payload.bin').write_bytes(b'fixture') - for entry in ('0' * 64 + ' payload.bin\n', '0' * 64 + ' ../payload.bin\n'): - (root / 'SHA256SUMS').write_text(entry) - with self.assertRaises(ValueError): - checksum_entries(root) - - def test_unchecked_artifacts_are_rejected(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / 'payload.bin').write_bytes(b'fixture') - digest = hashlib.sha256(b'fixture').hexdigest() - (root / 'SHA256SUMS').write_text(f'{digest} payload.bin\n') - (root / 'unexpected').touch() - with self.assertRaises(ValueError): - checksum_entries(root) - - -class CacheTests(unittest.TestCase): - def test_verified_cache_reuses_bytes_and_rejects_stale_destinations(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - exported, cached, guest = [root / name for name in ('exported', 'cached', 'guest')] - exported.mkdir() - checksum = hashlib.sha256(b'verified').hexdigest() - (exported / checksum).write_bytes(b'verified') - entry = {'path': '/var/cache/tool/asset', 'sha256': checksum} - (exported / 'manifest.json').write_text(json.dumps({'entries': [entry]})) - contract = [(checksum, '/var/cache/tool/asset', None)] - merge_cache(exported, cached, contract) - import_cache(cached, contract, guest) - self.assertEqual((guest / 'var/cache/tool/asset').read_bytes(), b'verified') - self.assertEqual(verified_entries(cached, []), []) - (cached / checksum).write_bytes(b'corrupt') - self.assertEqual(verified_entries(cached, contract), []) - - def test_cache_never_follows_destination_symlink(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - cache, guest, external = [root / name for name in ('cache', 'guest', 'external')] - for path in (cache, guest, external): - path.mkdir() - checksum = hashlib.sha256(b'verified').hexdigest() - (cache / checksum).write_bytes(b'verified') - (cache / 'manifest.json').write_text(json.dumps({'entries': [{'path': '/var/asset', 'sha256': checksum}]})) - (guest / 'var').symlink_to(external, target_is_directory=True) - with self.assertRaises(ValueError): - import_cache(cache, [(checksum, '/var/asset', None)], guest) - self.assertEqual(list(external.iterdir()), []) - - -class FirmwareTests(unittest.TestCase): - def test_qemu_descriptor_handles_fedora_qcow2_and_ignores_secure_boot(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - code, variables = root / 'code.qcow2', root / 'vars.qcow2' - code.touch() - variables.touch() - descriptor = {'interface-types': ['uefi'], 'features': [], 'targets': [{'architecture': 'x86_64', 'machines': ['pc-q35-*']}], - 'mapping': {'device': 'flash', 'executable': {'filename': str(code), 'format': 'qcow2'}, - 'nvram-template': {'filename': str(variables), 'format': 'qcow2'}}} - (root / 'plain.json').write_text(json.dumps(descriptor)) - descriptor['features'] = ['secure-boot'] - (root / 'first.json').write_text(json.dumps(descriptor)) - selected = select_firmware(root, root / 'missing') - self.assertEqual(selected['code']['format'], 'qcow2') - self.assertTrue(selected['descriptor'].endswith('plain.json')) - - def test_missing_firmware_fails_with_package_guidance(self): - with tempfile.TemporaryDirectory() as temporary: - with self.assertRaisesRegex(RuntimeError, 'edk2-ovmf'): - select_firmware(Path(temporary), Path(temporary)) - - -class PublisherTests(unittest.TestCase): - def fixture_client(self, responses, timeout=180): - clock = [0] - methods = [] - responses = iter(responses) - def request(method): - methods.append(method) - clock[0] += 1 - response = next(responses) - if isinstance(response, Exception): - raise response - return response - def sleep(seconds): - clock[0] += seconds - client = IVentoy('http://127.0.0.1:26000/iventoy/json', DEFAULT_CONTRACT, - request=request, timeout=timeout, sleep=sleep, clock=lambda: clock[0]) - return client, methods, clock - - def test_accepted_refresh_retries_only_status_timeouts(self): - client, methods, _ = self.fixture_client([ - {'status': 'running'}, {'result': 'success'}, TimeoutError('timed out'), - urllib.error.URLError(TimeoutError('timed out')), {'status': 'refreshing'}, - {'status': 'running'}, [{'name': 'fixture.iso'}], - ]) - client.refresh('fixture.iso') - self.assertEqual(methods.count('refresh_img_list'), 1) - self.assertEqual(methods.count('query_status'), 5) - self.assertEqual(methods[-1], 'get_img_tree') - - def test_polling_uses_one_deadline_for_the_whole_refresh(self): - client, methods, clock = self.fixture_client([ - {'status': 'refreshing'}, {'status': 'running'}, {'result': 'success'}, - TimeoutError('timed out')], timeout=8) - with self.assertRaisesRegex(RuntimeError, 'deadline'): - client.refresh('fixture.iso') - self.assertEqual(clock[0], 8) - self.assertEqual(methods, ['query_status', 'query_status', 'refresh_img_list', 'query_status']) - - def test_ambiguous_refresh_timeout_is_never_repeated(self): - client, methods, _ = self.fixture_client([{'status': 'running'}, TimeoutError('timed out')]) - with self.assertRaises(TimeoutError): - client.refresh('fixture.iso') - self.assertEqual(methods, ['query_status', 'refresh_img_list']) - - def test_status_json_and_other_transport_errors_are_not_retried(self): - for error in (json.JSONDecodeError('bad JSON', '!', 0), - urllib.error.URLError(ConnectionRefusedError('refused'))): - with self.subTest(error=type(error).__name__): - client, methods, _ = self.fixture_client([ - {'status': 'running'}, {'result': 'success'}, error]) - with self.assertRaises(type(error)): - client.refresh('fixture.iso') - self.assertEqual(methods, ['query_status', 'refresh_img_list', 'query_status']) - - def test_timeout_recovery_still_requires_running_pxe_and_expected_filename(self): - for remaining, message in (([{'status': 'stopped'}], 'not running'), - ([{'status': 'running'}, [{'name': 'other.iso'}]], 'published filename')): - client, _, _ = self.fixture_client([ - {'status': 'running'}, {'result': 'success'}, TimeoutError('timed out'), *remaining]) - with self.assertRaisesRegex(RuntimeError, message): - client.refresh('fixture.iso') - - def test_http_request_timeout_cannot_exceed_remaining_deadline(self): - client = IVentoy('http://127.0.0.1:26000/iventoy/json', DEFAULT_CONTRACT, clock=lambda: 5) - with patch('pxe_publish.urllib.request.urlopen', side_effect=TimeoutError) as request: - with self.assertRaises(TimeoutError): - client._query('query_status', 8) - self.assertEqual(request.call_args.kwargs['timeout'], 3) - - def test_refresh_polls_known_iventoy_contract_and_verifies_tree(self): - responses = iter([{'status': 'running'}, {'result': 'success'}, {'status': 'refreshing'}, {'status': 'running'}, [{'name': 'fixture.iso'}]]) - methods = [] - def request(method): - methods.append(method) - return next(responses) - client = IVentoy('http://127.0.0.1:26000/iventoy/json', DEFAULT_CONTRACT, request=request, sleep=lambda _: None) - client.refresh('fixture.iso') - self.assertEqual(methods, ['query_status', 'refresh_img_list', 'query_status', 'query_status', 'get_img_tree']) - - def test_http_success_is_not_refresh_success(self): - responses = iter([{'status': 'running'}, {'result': 'failure'}]) - client = IVentoy('http://127.0.0.1:26000/iventoy/json', DEFAULT_CONTRACT, request=lambda _: next(responses)) - with self.assertRaisesRegex(RuntimeError, 'rejected refresh'): - client.refresh('fixture.iso') - - def test_stopped_pxe_and_unknown_schema_fail(self): - for value in ({'status': 'ready'}, {'result': 'success'}, {'status': 'surprise'}): - with self.assertRaises(RuntimeError): - validate_status(value, DEFAULT_CONTRACT) - - def test_verified_publication_is_idempotent_and_preserves_other_files(self): - # These tiny text fixtures are not ISO filesystems or boot tests. - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - served = root / 'served' - served.mkdir() - source = root / 'payload.bin' - source.write_bytes(b'synthetic publication bytes') - previous = served / 'existing.bin' - previous.write_bytes(b'preserve') - first = publish(source, served, 'fixture.iso') - second = publish(source, served, 'fixture.iso') - self.assertEqual(first, second) - self.assertEqual(previous.read_bytes(), b'preserve') - source.write_bytes(b'different') - with self.assertRaises(FileExistsError): - publish(source, served, 'fixture.iso') - self.assertEqual(sorted(p.name for p in root.iterdir()), ['payload.bin', 'served']) - - @unittest.skipIf(os.geteuid() == 0, 'root bypasses directory permissions') - def test_publication_stages_in_nearest_writable_ancestor(self): - # Mirrors a PXE host: root-owned /data/pxe, user-writable /data and /data/pxe/iso. - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - parent = root / 'pxe' - served = parent / 'iso' - served.mkdir(parents=True) - source = root / 'payload.bin' - source.write_bytes(b'synthetic publication bytes') - parent.chmod(0o555) - try: - self.assertEqual(staging_parent(served), root.resolve()) - destination, _ = publish(source, served, 'fixture.iso') - self.assertEqual(destination.read_bytes(), b'synthetic publication bytes') - self.assertEqual(sorted(p.name for p in root.iterdir()), ['payload.bin', 'pxe']) - finally: - parent.chmod(0o755) - - def test_staging_inside_served_tree_is_rejected(self): - with tempfile.TemporaryDirectory() as temporary: - served = Path(temporary) / 'served' - (served / 'incoming').mkdir(parents=True) - for requested in (served, served / 'incoming'): - with self.assertRaises(ValueError): - staging_parent(served, requested) - self.assertEqual(staging_parent(served, Path(temporary)), Path(temporary).resolve()) - - -class QualificationSourceTests(unittest.TestCase): - def test_long_artifact_paths_use_bindable_private_qmp_runtime_across_boots(self): - from vm_testing import TestVM, poweroff_guest - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) / ('a' * 80) / ('b' * 80) - root.mkdir(parents=True) - vm = TestVM(root, firmware='bios') - vm.owned = True - sockets = [] - def launch(arguments, **kwargs): - option = arguments[arguments.index('-qmp') + 1] - path = option.removeprefix('unix:').split(',')[0] - connection = socket.socket(socket.AF_UNIX) - connection.bind(path) # Real kernel pathname-length check. - sockets.append(connection) - process = Mock(pid=12345) - process.poll.return_value = None - def finish(*args, **kwargs): - connection.close() - process.poll.return_value = 0 - return 0 - process.wait.side_effect = finish - process.terminate.side_effect = finish - return process - try: - with patch('vm_testing.subprocess.Popen', side_effect=launch), \ - patch.dict(os.environ, {'TMPDIR': str(root)}): - paths = [] - for poweroff in (True, False): - vm.start() - runtime = vm.runtime - paths.append(runtime) - self.assertEqual(runtime.stat().st_mode & 0o777, 0o700) - self.assertLess(len(os.fsencode(vm.qmp_path)), 108) - self.assertEqual(json.loads((root / 'vm.json').read_text())['qmp'], str(vm.qmp_path)) - if poweroff: - poweroff_guest(vm, 'fixture-password', Mock()) - else: - vm.cleanup(keep_artifacts=True) - self.assertFalse(runtime.exists()) - self.assertIsNone(vm.runtime) - self.assertNotEqual(*paths) - self.assertTrue((root / 'qemu.log').exists(), 'Requested diagnostic outputs stay retained') - finally: - vm.stop() - for connection in sockets: - connection.close() - - def test_failed_vm_launch_cleans_private_qmp_runtime(self): - from vm_testing import TestVM - with tempfile.TemporaryDirectory() as temporary: - vm = TestVM(temporary, firmware='bios') - runtimes = [] - def fail(*args, **kwargs): - runtimes.append(vm.runtime) - raise OSError('fixture launch failure') - with patch('vm_testing.subprocess.Popen', side_effect=fail): - with self.assertRaisesRegex(OSError, 'fixture launch failure'): - vm.start() - self.assertFalse(runtimes[0].exists()) - self.assertIsNone(vm.runtime) - self.assertIsNone(vm.console) - - def test_state_publication_failure_stops_guest_before_removing_runtime(self): - from vm_testing import TestVM - with tempfile.TemporaryDirectory() as temporary: - vm = TestVM(temporary, firmware='bios') - process = Mock(pid=12345) - process.poll.return_value = None - runtimes = [] - def terminate(): - self.assertTrue(vm.runtime.is_dir()) - process.poll.return_value = 0 - process.terminate.side_effect = terminate - process.wait.return_value = 0 - def fail(*args): - runtimes.append(vm.runtime) - raise OSError('fixture state publication failure') - with patch('vm_testing.subprocess.Popen', return_value=process), \ - patch('vm_testing.atomic_json', side_effect=fail): - with self.assertRaisesRegex(OSError, 'fixture state publication failure'): - vm.start() - process.terminate.assert_called_once() - self.assertFalse(runtimes[0].exists()) - self.assertIsNone(vm.runtime) - - def test_installation_requires_both_explicit_execution_flags(self): - import qualification - for flags in ([], ['--execute-vm'], ['--erase-disposable-disk']): - with patch('sys.argv', ['qualify', '/does-not-exist', '--output', '/does-not-exist', *flags]), patch('sys.stderr'), patch.object(qualification.signal, 'signal'), patch.object(qualification, 'TestVM') as vm: - with self.assertRaises(SystemExit): - qualification.main() - vm.assert_not_called() - - def test_cleanup_never_touches_an_unowned_directory(self): - from vm_testing import TestVM - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / 'installed.qcow2').write_bytes(b'some other task') - vm = TestVM(root) - vm.cleanup() - self.assertEqual((root / 'installed.qcow2').read_bytes(), b'some other task') - - def test_qemu_filename_options_cannot_be_injected(self): - from build_support import validate_qemu_path - for path in ('/tmp/example,media=disk', '/tmp/new\nline'): - with self.assertRaises(ValueError): - validate_qemu_path(path) - - def test_backend_protocol_extracts_result_and_never_passes_secrets_in_argv(self): - import qualification - from types import SimpleNamespace - vm = SimpleNamespace(ssh=['ssh', 'fixture']) - response = SimpleNamespace(stdout='{"event":"result","ok":true,"data":{"disk":{"name":"vda"},"token":"fixture-token"}}\n') - with patch.object(qualification, 'run', return_value=response) as run: - plan = qualification.backend(vm, 'plan', {'password': 'private-test-value'}) - self.assertEqual(plan['disk']['name'], 'vda') - self.assertNotIn('private-test-value', ' '.join(run.call_args.args[0])) - self.assertIn('private-test-value', run.call_args.kwargs['input']) - - -class DeliveryRegressionTests(unittest.TestCase): - def fixture(self, root, name='cybexos-desktop-0.0.0~dev^fixture.x86_64.rpm'): - root.mkdir() - (root / name).write_bytes(b'synthetic bytes, not an RPM') - checksum = hashlib.sha256((root / name).read_bytes()).hexdigest() - (root / 'SHA256SUMS').write_text(f'{checksum} {name}\n') - return name, checksum - - def test_prerelease_rpm_names_are_accepted(self): - with tempfile.TemporaryDirectory() as directory: - stage = Path(directory) / 'stage' - name, checksum = self.fixture(stage) - self.assertEqual(checksum_entries(stage), {name: checksum}) - - def test_symlinked_checksum_manifest_is_rejected_without_chmod(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - stage = root / 'stage' - self.fixture(stage) - external = root / 'external' - (stage / 'SHA256SUMS').rename(external) - external.chmod(0o600) - (stage / 'SHA256SUMS').symlink_to(external) - with self.assertRaisesRegex(ValueError, 'never a symlink'): - deliver_artifacts(stage, root / 'delivered') - self.assertEqual(external.stat().st_mode & 0o777, 0o600) - self.assertFalse((root / 'delivered').exists()) - - def test_concurrent_empty_directory_is_preserved(self): - import build_support - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - stage, destination = root / 'stage', root / 'complete' - self.fixture(stage) - original = build_support.rename_new_directory - def concurrent_creation(source, target): - target.mkdir() - return original(source, target) - with patch.object(build_support, 'rename_new_directory', side_effect=concurrent_creation): - with self.assertRaises(FileExistsError): - deliver_artifacts(stage, destination) - self.assertTrue(stage.is_dir()) - self.assertEqual(list(destination.iterdir()), []) - - def test_adjacent_checksum_requires_correct_bytes_and_filename(self): - from build_support import verify_sidecar - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / 'payload.bin' - path.write_bytes(b'fixture') - checksum = hashlib.sha256(b'fixture').hexdigest() - sidecar = path.with_name(path.name + '.sha256') - for entry in (f'{checksum} different.bin\n', '0' * 64 + ' payload.bin\n'): - sidecar.write_text(entry) - with self.assertRaises(ValueError): - verify_sidecar(path) - sidecar.write_text(f'{checksum} payload.bin\n') - self.assertEqual(verify_sidecar(path), checksum) - - def test_guest_audit_streams_the_actual_shared_helper(self): - from vm_testing import audit_script - helper = (Path(__file__).resolve().parents[1] / 'tests/lib/quickshell-live').read_text() - script = audit_script() - self.assertIn(helper, script) - self.assertTrue(script.endswith('qs_live_begin\nqs_live_end\n')) - - def test_imported_qualification_entry_registers_cleanup_signals(self): - import qualification - import signal - with patch('sys.argv', ['qualify', 'fixture', '--output', 'fixture']), patch('sys.stderr'), patch.object(qualification.signal, 'signal') as register: - with self.assertRaises(SystemExit): - qualification.main() - register.assert_any_call(signal.SIGTERM, qualification.interrupt) - register.assert_any_call(signal.SIGHUP, qualification.interrupt) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_console_bootstrap.py b/image/test_console_bootstrap.py deleted file mode 100644 index f5b7bec8..00000000 --- a/image/test_console_bootstrap.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Mocked installed-console transport: no VM or host session is touched.""" -import tempfile -import unittest -from unittest.mock import patch - -from vm_testing import TestVM, console_output, sudo_password_prompt - - -class ConsoleBootstrapTests(unittest.TestCase): - def exercise(self, *, passworded=False, german=False, failure=None, late_greeter=False, refusals=0, - splash_switches=0, dropped_names=0, - sudo_prompt='[sudo] password for qualification:'): - password = 'private-fixture-secret' - state = {'phase': 'login', 'time': 0, 'typed': [], 'greeter': splash_switches > 0, 'keys': [], - 'refusals': refusals, 'splash': splash_switches, 'dropped_names': dropped_names} - def clock(): - state['time'] += 0.1 - return state['time'] - def sleep(seconds): - state['time'] += seconds - def typing(text): - state['typed'].append(text) - if text == 'qualification\n' and state['dropped_names']: - state['dropped_names'] -= 1 - return # The display manager took focus before getty received it. - if text == 'qualification\n' and state['refusals']: - # pam_nologin while systemd-user-sessions has not started. - state['refusals'] -= 1 - state['phase'] = 'refused' if state['phase'] == 'login' else 'refused-again' - elif text == 'qualification\n': - state['phase'] = 'password' - # SDDM starts now and switches the display to its greeter. - state['greeter'] = late_greeter - elif text == password + '\n': - state['phase'] = 'shell' if state['phase'] == 'password' else 'authorized' - elif text.startswith('echo CONSOLEWORKS'): - state['phase'] = 'probe' - elif text == 'clear\n': - state['phase'] = 'shell' - elif text.startswith('sudo echo'): - state['phase'] = 'sudo' if passworded else 'authorized' - elif text.startswith('HISTFILE=') and 'CONSOLE%sREADY' in text: - state['phase'] = 'bash' - def keys(combination): - state['keys'].append(combination) - if combination == 'ctrl+alt+f3': - # The first switches land while the boot splash or a starting - # display manager still owns the screen. - if state['splash']: - state['splash'] -= 1 - else: - state['greeter'] = False - def screen(): - if state['greeter']: - return 'Sessie CybexOS Kies je gebruikersnaam en voer wachtwoord in' - phase = state['phase'] - return { - 'login': 'fedora login:', 'password': 'Password:', - 'refused': 'fedora login: qualification System is booting up. Unprivileged users are not ' - 'permitted to log in yet. fedora login:', - 'refused-again': 'fedora login: qualification System is booting up. fedora login: ' - 'qualification System is booting up. fedora login:', - 'shell': 'a custom Fish prompt without user or host', - 'probe': ('custom prompt echo CONSOLEWORKS y' if failure == 'shell' else - 'custom prompt echo CONSOLEWORKS y\nCONSOLE WORKS ' + ('z' if german else 'y')), - 'sudo': sudo_prompt, - 'authorized': 'CONSOLE AUTH', - 'bash': ('echo ' + password if failure == 'bash' else - "bash-5.3$ printf 'CONSOLE%sREADY' BASH\nCONSOLEBASHREADY"), - }[phase] - with tempfile.TemporaryDirectory() as directory: - vm = TestVM(directory) - vm.key.with_suffix('.pub').write_text('ssh-ed25519 public-fixture') - with patch.object(vm, 'keypress', side_effect=keys), patch.object(vm, 'alive'), \ - patch.object(vm, 'type', side_effect=typing), \ - patch.object(vm, 'screen_text', side_effect=screen), \ - patch.object(vm, 'wait_ssh') as ssh, \ - patch('vm_testing.time.monotonic', side_effect=clock), \ - patch('vm_testing.time.sleep', side_effect=sleep): - if failure: - with self.assertRaises(RuntimeError) as error: - vm.bootstrap_installed_ssh(password, timeout=50) - self.assertNotIn(password, str(error.exception)) - ssh.assert_not_called() - else: - vm.bootstrap_installed_ssh(password, timeout=50) - ssh.assert_called_once() - return state['typed'] if not late_greeter else (state['typed'], state['keys']) - - def test_an_early_console_switch_is_repeated_until_the_login_prompt_shows(self): - typed = self.exercise(splash_switches=2) - self.assertEqual(typed.count('private-fixture-secret\n'), 1) - - def test_a_login_refused_while_booting_is_retried(self): - # pam_nologin refuses logins until systemd-user-sessions has started - # (2026-10-14 plain-nl run, after a first-boot unit ran before it). - typed = self.exercise(refusals=2) - self.assertEqual(typed.count('qualification\n'), 3) - self.assertEqual(typed.count('private-fixture-secret\n'), 1) - - def test_lost_username_is_retried_at_an_empty_login_prompt(self): - typed = self.exercise(dropped_names=1) - self.assertEqual(typed.count('qualification\n'), 2) - self.assertEqual(typed.count('private-fixture-secret\n'), 1) - - def test_empty_prompt_retries_are_bounded_and_never_send_a_secret(self): - typed = self.exercise(dropped_names=99, failure='username') - self.assertEqual(typed.count('qualification\n'), 4) - self.assertNotIn('private-fixture-secret\n', typed) - - def test_a_late_greeter_does_not_hide_the_console_password_prompt(self): - # The display manager can start after the switch to tty3 and take the - # screen (2026-10-14 plain-nl run); tty3 still waits for the password. - typed, keys = self.exercise(late_greeter=True) - self.assertEqual(typed.count('private-fixture-secret\n'), 1) - self.assertGreaterEqual(keys.count('ctrl+alt+f3'), 2) - - def test_passwordless_fish_prompt_and_german_keymap(self): - typed = self.exercise(german=True) - self.assertEqual(typed.count('private-fixture-secret\n'), 1) - self.assertIn('sudo loadkezs us\n', typed) - self.assertIn('exec env HISTFILE=/dev/null bash --noprofile --norc\n', typed) - self.assertTrue(any('authorized_keys' in command for command in typed)) - - def test_passworded_sudo_authenticates_once(self): - typed = self.exercise(passworded=True) - self.assertEqual(typed.count('private-fixture-secret\n'), 2) - self.assertIn('sudo loadkeys us\n', typed) - - def test_dutch_sudo_ocr_misread_authenticates_once(self): - typed = self.exercise(passworded=True, - sudo_prompt='[sudo] uachtwoord voor qualification:') - self.assertEqual(typed.count('private-fixture-secret\n'), 2) - self.assertIn('sudo loadkeys us\n', typed) - - def test_echoed_command_alone_never_proves_shell(self): - typed = self.exercise(failure='shell') - self.assertEqual(typed.count('echo CONSOLEWORKS y\n'), 3) - self.assertFalse(any(command.startswith('sudo') for command in typed)) - - def test_failed_bash_confirmation_never_sends_setup(self): - typed = self.exercise(failure='bash') - self.assertFalse(any('authorized_keys' in command for command in typed)) - - def test_output_only_and_explicit_sudo_prompts(self): - self.assertFalse(console_output('prompt sudo echo CONSOLEAUTH', 'CONSOLEAUTH')) - self.assertTrue(console_output('CONSOLE AUTH', 'CONSOLEAUTH')) - for prompt in ('[sudo] password for qualification:', 'Passwort für qualification:', - 'wachtwoord voor qualification:', '[sudo] uachtwoord voor qualification:'): - self.assertTrue(sudo_password_prompt(prompt)) - for prompt in ('Password:', 'password for john:', 'qualification login:', - 'uachtwoord voor qualification:', '[sudo] uachtwoord voor john:'): - self.assertFalse(sudo_password_prompt(prompt)) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_desktop_payload.py b/image/test_desktop_payload.py deleted file mode 100644 index 42dbd99f..00000000 --- a/image/test_desktop_payload.py +++ /dev/null @@ -1,755 +0,0 @@ -"""No image builds: isolated fixtures for desktop parity and boot appearance.""" -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import re -import subprocess -import tempfile -import unittest -from unittest.mock import patch - -import jinja2 -import yaml - -from boot_branding import brand_boot_menu, grub_install_entries -from desktop_payload import prepare_defaults, split_seed, prepare_quickshell - -ROOT = Path(__file__).resolve().parents[1] - - -def load(name, relative): - loader = importlib.machinery.SourceFileLoader(name, str(ROOT / relative)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -INIT = load("desktop_user_init", "image/rootfs/usr/libexec/cybexos-user-init") -ACCOUNTS = load("desktop_seed_accounts", "image/rootfs/usr/libexec/cybexos-seed-installed-users") - - -class DesktopPayload(unittest.TestCase): - def test_notification_popups_preserve_keyboard_focus_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in ("NotificationToasts.qml", "Common/NotifCard.qml", - "Popovers/Drawer/DrawerNotifications.qml"): - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - toast = (installed / "NotificationToasts.qml").read_text() - self.assertIn("WlrLayershell.keyboardFocus: WlrKeyboardFocus.None", toast) - self.assertIn("keyboardEnabled: false", toast) - self.assertIn("keyboardEnabled: true", (installed / "Popovers/Drawer/DrawerNotifications.qml").read_text()) - - def test_github_and_plugin_failure_recovery_ship_identically_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ("Common/GitHub.qml", "Common/GitHubHelpers.js", "Common/GitHubQueue.js", - "Common/CommandRequest.qml", "Common/ProcHelpers.js", "Common/UserPlugins.qml", - "Popovers/GitHubPopover.qml", "scripts/user-plugins.py", "scripts/plugin_packages.py", - "Common/i18n/nl.js") - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - - def test_settings_navigation_and_input_drafts_ship_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ("SettingsWindow.qml", "LauncherView.qml", "Settings/SettingsView.qml", - "Settings/InputDrafts.qml", "Settings/InputDraftState.qml", "Settings/InputDraft.js", - "Settings/KeyboardPage.qml", "Settings/TouchpadPage.qml", "Settings/qmldir", - "Settings/SettingsPage.qml", "Settings/RecoveryGroup.qml", "Common/SettingsSearchData.js", - "Popovers/Drawer/DrawerNotifications.qml", "Popovers/ActionButton.qml") - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - - def test_workspace_and_notification_runtime_ship_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ("Common/DesktopWorkspaces.qml", "Common/WorkspaceHelpers.js", - "Settings/WorkspacesPage.qml", "Common/NotifPolicy.js", - "Common/NotifSmart.js", "Common/NotifSnoozes.qml", "Common/NotifButton.qml", - "scripts/notification-history.py") - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - self.assertEqual((installed / "scripts/notification-history.py").stat().st_mode & 0o777, 0o644) - tasks = yaml.safe_load((ROOT / "roles/desktop/tasks/main.yml").read_text()) - modules = {"workspaces.lua", "workspace_state.lua", "workspace_model.lua", "workspace_layouts.lua"} - copy = next(t for t in tasks if "bindings.lua" in t.get("loop", [])) - self.assertTrue(modules.issubset(copy["loop"])) - # The RPM and checkout copy the very same source files, and the stale - # runtime cleanup must retain the companion modules on re-convergence. - package = (ROOT / "image/package").read_text() - cleanup = next(t for t in tasks if any("basename not in" in str(w) for w in t.get("when", []))) - for name in modules: - self.assertIn('"' + name + '"', package) - self.assertIn(name, str(cleanup["when"])) - self.assertTrue((ROOT / "roles/desktop/files" / name).is_file()) - - def test_launcher_focus_handoff_ships_identically_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in ("LauncherWindow.qml", "LauncherView.qml", "shell.qml"): - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - window = (installed / "LauncherWindow.qml").read_text() - self.assertIn("root.contentItem.Window.active", window) - self.assertIn("active: Launcher.open && root.dismissalGrabReady", window) - self.assertIn("WlrKeyboardFocus.Exclusive", window) - - def test_model_usage_runtime_matches_checkout_including_rust_live_activity(self): - source = ROOT / "roles/desktop/files/quickshell/ModelUsage" - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell/ModelUsage" - files = {p.relative_to(source): p for p in source.rglob("*") - if p.is_file() and not p.is_symlink() and "__pycache__" not in p.parts and p.suffix != ".pyc"} - self.assertEqual(set(files), {p.relative_to(installed) for p in installed.rglob("*") if p.is_file()}) - for relative, path in files.items(): - expected = path.read_bytes() - if path.suffix in (".qml", ".js"): - expected = expected.replace(b"/usr/local/libexec/cybexos-", b"/usr/libexec/cybexos-") - self.assertEqual((installed / relative).read_bytes(), expected, str(relative)) - self.assertIn(b"def fetch_rust_account", (installed / "scripts/usage-fetch.py").read_bytes()) - self.assertIn(b"normalize_rust_activity", (installed / "scripts/proxy-activity.py").read_bytes()) - self.assertIn(b"additional_headers", (installed / "scripts/proxy-live.py").read_bytes()) - self.assertIn(b"UsageLogic.acquireLive", (installed / "UsageActivityBackend.qml").read_bytes()) - self.assertTrue((installed / "UsageLiveConnection.qml").is_file()) - helpers = payload / "usr/share/cybexos/runtime/quickshell/Common/SettingsHelpers.js" - self.assertIn(b"liveAccountActivity: true", helpers.read_bytes()) - self.assertNotIn(b"showBarActivity", helpers.read_bytes()) - self.assertNotIn(b"showRecentSessions", helpers.read_bytes()) - # Both installation contracts provide the optional transport. - self.assertIn("python3-websockets", (ROOT / "image/cybexos-desktop.spec").read_text()) - self.assertIn("python3-websockets", (ROOT / "roles/desktop/tasks/main.yml").read_text()) - - def test_fusebox_widget_ships_identically_with_the_same_layout_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ["scripts/fusebox.py", "Common/Fusebox.qml", "Common/FuseboxHelpers.js", - "Bar/Modules/Fusebox.qml", "Popovers/FuseboxPopover.qml", "assets/fusebox.svg", - "assets/fusebox-white.svg", "Common/SettingsHelpers.js", "Common/BrandIcons.qml"] - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), (checkout / relative).read_bytes(), relative) - # The checkout and the ISO derive the layout from the same helpers and - # the same connected-widgets flag, so both start Fusebox the same way. - script = ("const H = require(process.argv[1]); " - "console.log(JSON.stringify([false, true].map(connected => " - "H.merge(null, { connectedWidgets: connected }).mods.right.map(m => m.id + ':' + m.on))));") - layouts = [subprocess.run(["node", "-e", script, str(base / "Common/SettingsHelpers.js")], - text=True, capture_output=True, check=True).stdout - for base in (checkout, installed)] - self.assertEqual(layouts[0], layouts[1]) - plain, connected = json.loads(layouts[1]) - self.assertIn("fusebox:false", plain) - self.assertIn("fusebox:true", connected) - self.assertEqual(connected[connected.index("modelusage:true") + 1], "fusebox:true") - unit = (ROOT / "image/provision.yml").read_text() - self.assertIn("CYBEXOS_CONNECTED_WIDGETS", unit) - self.assertIn("CYBEXOS_CONNECTED_WIDGETS", (ROOT / "image/desktop_payload.py").read_text()) - - def test_capture_tools_reach_both_installation_paths(self): - # The capture helpers are user helpers: the checkout copies every one - # into ~/.local/bin, the RPM into /usr/share/cybexos/bin, which - # cybexos-user-init links into ~/.local/bin. `screenshot` finds - # screenshot-stitch beside its resolved self, so both must be helpers. - contract = json.loads((ROOT / "assets/desktop-contract.json").read_text()) - helpers = ("screenshot", "screenshot-stitch", "screen-record", "screen-ocr", "color-picker") - for name in helpers: - self.assertIn(name, contract["userHelpers"]) - self.assertTrue((ROOT / "assets/scripts" / name).read_text().startswith("#!"), name) - screenshot = (ROOT / "assets/scripts/screenshot").read_text() - self.assertIn('SCRIPT_DIR=$(dirname -- "$SCRIPT_PATH")', screenshot) - self.assertIn('python3 "$SCRIPT_DIR/screenshot-stitch"', screenshot) - dotfiles = (ROOT / "roles/dotfiles/tasks/main.yml").read_text() - self.assertIn('loop: "{{ cybexos_desktop_contract.userHelpers }}"', dotfiles) - self.assertIn('contract["userHelpers"]', (ROOT / "image/package").read_text()) - self.assertIn("contract['userHelpers']", (ROOT / "image/repair-installed").read_text()) - - # Packages: the desktop task installs them on a checkout and is part - # of the ISO's compose; the RPM also requires them. - applications = load("desktop_payload_applications", "image/applications") - packages, _ = applications.package_names() - spec = (ROOT / "image/cybexos-desktop.spec").read_text() - requires = {name for line in spec.splitlines() if line.startswith("Requires:") - for name in line.split()[1:]} - for package in ("grim", "slurp", "satty", "wf-recorder", "hyprpicker", "ImageMagick", - "tesseract", "zbar", "qt6-qtmultimedia", "python3", "wl-clipboard"): - self.assertIn(package, packages) - self.assertIn(package, requires) - - # The shell side ships byte-identical, overlay registration included. - checkout = ROOT / "roles/desktop/files/quickshell" - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in ("WebcamOverlay.qml", "qmldir", "shell.qml", "Common/Recorder.qml", - "Common/CaptureHelpers.js", "Bar/Modules/Indicators.qml"): - self.assertEqual((installed / relative).read_bytes(), - (checkout / relative).read_bytes(), relative) - - # Both paths render the same bindings file. - bindings = (ROOT / "roles/desktop/files/bindings.lua").read_text() - for command in ("screenshot scroll", "screen-record --webcam", "color-picker"): - self.assertIn(f'home .. "/.local/bin/{command}"', bindings) - self.assertIn('"bindings.lua"', (ROOT / "image/package").read_text()) - - def test_shell_translations_ship_identically_on_both_paths(self): - """The I18n singleton and its catalogs are shell source: the checkout - deploys the tree and the RPM copies it, so both read the same Dutch.""" - checkout = ROOT / "roles/desktop/files/quickshell" - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in ("Common/I18n.qml", "Common/I18nHelpers.js", "Common/i18n/nl.js", "Common/qmldir"): - self.assertEqual((installed / relative).read_bytes(), (checkout / relative).read_bytes(), relative) - self.assertIn("singleton I18n I18n.qml", (installed / "Common/qmldir").read_text()) - - def test_overview_dock_and_process_list_ship_identically_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ["shell.qml", "qmldir", "Common/qmldir", "Popovers/Drawer/qmldir", - "OverviewWindow.qml", "OverviewView.qml", "DockWindow.qml", - "Common/Overview.qml", "Common/Dock.qml", "Common/ProcessList.qml", - "Common/OverviewHelpers.js", "Common/DockHelpers.js", - "Common/ProcessListHelpers.js", "Common/WindowCommands.js", - "Common/DesktopWorkspaces.qml", "Common/WorkspaceHelpers.js", - "Common/LauncherProviders.js", "Common/SettingsSearchData.js", - "Common/i18n/nl.js", "Settings/WorkspacesPage.qml", - "Popovers/Drawer/DrawerProcesses.qml", "Popovers/Drawer/DrawerOverview.qml", - "Settings/BarLayoutGroups.qml", "scripts/processes"] - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), (checkout / relative).read_bytes(), relative) - # Both paths run the helper through bash; the ISO also marks it executable. - self.assertEqual((installed / "scripts/processes").stat().st_mode & 0o777, 0o755) - # One schema decides the defaults on both: all-layout dock starts off and - # unpinned, Top processes starts shown. - script = ("const H = require(process.argv[1]); const d = H.merge(null); " - "console.log(JSON.stringify([d.dock, d.dockAutoHide, d.dockPinned, d.drawerOverview.processes]));") - defaults = [subprocess.run(["node", "-e", script, str(base / "Common/SettingsHelpers.js")], - text=True, capture_output=True, check=True).stdout - for base in (checkout, installed)] - self.assertEqual(defaults[0], defaults[1]) - self.assertEqual(json.loads(defaults[1]), [False, False, [], True]) - # Exercise visibility and browser matching from both payloads. - probe = ''' -const D = require(process.argv[1]); -const live = {id: 41, name: "41"}; -console.log(JSON.stringify([ - D.showOnWorkspace(false, live, {id: 41, layout: "floating"}), - D.showOnWorkspace(false, live, {id: 41, layout: "dwindle"}), - D.showOnWorkspace(false, live, {id: 41, layout: "scrolling"}), - D.showOnWorkspace(true, live, {id: 41, layout: "dwindle"}), - D.webAppEntryId("brave-web.whatsapp.com__-Default", - [{id: "whatsapp", url: "https://web.whatsapp.com/"}]) -])); -''' - for tree in (checkout, installed): - result = subprocess.run(["node", "-e", probe, str(tree / "Common/DockHelpers.js")], - check=True, capture_output=True, text=True) - self.assertEqual(json.loads(result.stdout), [True, False, False, True, "cybexos-webapp-whatsapp"]) - # Super+Tab and the overview/dock layer rules come from the one - # bindings.lua and looknfeel template that both installs use. - bindings = (ROOT / "roles/desktop/files/bindings.lua").read_text() - self.assertIn('bind(mainMod .. " + TAB", hl.dsp.global("quickshell:overviewToggle")', bindings) - package = (ROOT / "image/package").read_text() - self.assertIn('"bindings.lua"', package) - self.assertIn('"looknfeel.lua"', package) - self.assertIn("- bindings.lua", (ROOT / "roles/desktop/tasks/main.yml").read_text()) - look = (ROOT / "roles/desktop/templates/looknfeel.lua.j2").read_text() - self.assertIn("|wallpaper|overview|dock)$", look) - self.assertIn("|shortcuts|dock)$", look) - - def test_portable_defaults_and_boot_assets_share_workstation_sources(self): - environment = jinja2.Environment(undefined=jinja2.StrictUndefined) - environment.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no) - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - contract = prepare_defaults(ROOT, payload, environment, inventory) - vendor = payload / "usr/share/cybexos" - app = vendor / "user-seed/.rustup/toolchains/default/rustc" - app.parent.mkdir(parents=True) - app.write_bytes(b"offline compiler") - split_seed(vendor, contract) - defaults = json.loads((vendor / "essential-seed/.config/cybexos/shell.json").read_text()) - self.assertEqual(defaults, contract["shell"]) - # Exercise first-login seeding followed by the real shell merger: - # an unversioned seed keeps current defaults and no personal host. - (vendor / "bin").mkdir() - (vendor / "runtime").mkdir() - home = payload / "installed-user" - with patch.dict(os.environ, {}, clear=True): - INIT.initialize(home, vendor, mode="essential") - preferences = home / ".config/cybexos/shell.json" - result = subprocess.run([ - "node", "-e", - "const fs = require('fs'), H = require(process.argv[1]); " - "const s = H.merge(JSON.parse(fs.readFileSync(process.argv[2], 'utf8'))); " - "console.log(JSON.stringify({font: H.FONT_CHOICES.find(f => f.id === s.font).family, " - "remote: s.mods.right.find(m => m.id === 'remote'), options: s.modOpts.remote}));", - str(ROOT / "roles/desktop/files/quickshell/Common/SettingsHelpers.js"), - str(preferences), - ], text=True, capture_output=True, check=True) - settings = json.loads(result.stdout) - self.assertEqual(settings["font"], "JetBrainsMono Nerd Font") - self.assertTrue(settings["remote"]["on"]) - self.assertEqual(settings["options"]["host"], "") - self.assertEqual(settings["options"]["label"], "") - wallpaper = vendor / "essential-seed/Pictures/Wallpapers" / defaults["wall"] - self.assertEqual(wallpaper.read_bytes(), (ROOT / "assets/wallpapers" / defaults["wall"]).read_bytes()) - self.assertTrue(app.is_file()) - self.assertFalse((vendor / "essential-seed/.rustup").exists()) - self.assertEqual(json.loads((vendor / "seed-groups.json").read_text())["totalBytes"], app.stat().st_size) - self.assertIn("x-scheme-handler/t3code=t3code-nightly.desktop", - (vendor / "essential-seed/.config/mimeapps.list").read_text()) - self.assertFalse(list(vendor.rglob("plugins.json"))) - self.assertFalse(list(vendor.rglob("user.lua"))) - for source in (ROOT / "roles/boot/files").iterdir(): - self.assertEqual((payload / "usr/share/plymouth/themes/cybex" / source.name).read_bytes(), source.read_bytes()) - self.assertEqual((payload / "usr/lib/sysctl.d/60-cybexos-hardening.conf").read_bytes(), - (ROOT / "roles/base/files/60-cybexos-hardening.conf").read_bytes()) - self.assertIn('target="DROP"', (payload / "usr/lib/firewalld/zones/cybexos.xml").read_text()) - - def test_gestures_and_game_mode_render_identically_for_checkout_and_iso(self): - """Touchpad gestures and game mode live in input.lua and looknfeel.lua. - - The checkout renders them with the machine's facts; image/package - renders them with dynamic hardware and keyboard lookups. Only those - lines may differ, so both paths share the same gestures, game mode, - bindings and helper. - """ - import json as json_module - import shlex - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - environment = jinja2.Environment(undefined=jinja2.StrictUndefined, keep_trailing_newline=True) - environment.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no, - to_json=json_module.dumps, quote=shlex.quote) - base = {"features": inventory["features"], "cybexos_xps_2026": False, - "machine_keyboard_layout": "us", "machine_keyboard_variant": ""} - for name, allowed in (("input.lua", ("kb_layout", "kb_variant")), ("looknfeel.lua", ("force_zero_scaling",))): - template = environment.from_string((ROOT / "roles/desktop/templates" / (name + ".j2")).read_text()) - checkout = template.render(**base).splitlines() - iso = template.render(**base, cybexos_dynamic_hardware=True) - if name == "input.lua": - # As image/package does for the installed keyboard choice. - iso = iso.replace('kb_layout = "us"', 'kb_layout = os.getenv("CYBEXOS_KEYBOARD_LAYOUT") or "us"') - iso = iso.replace('kb_variant = ""', 'kb_variant = os.getenv("CYBEXOS_KEYBOARD_VARIANT") or ""') - iso = iso.splitlines() - self.assertEqual(len(checkout), len(iso), name) - different = [(a, b) for a, b in zip(checkout, iso) if a != b] - self.assertTrue(all(any(key in a for key in allowed) for a, _ in different), (name, different)) - package = (ROOT / "image/package").read_text() - self.assertIn('"input.lua", "looknfeel.lua"', package) - self.assertIn('"bindings.lua", "autostart.lua", "displays.lua", "input_preferences.lua"', package) - self.assertIn('helpers = contract["userHelpers"]', package) - self.assertIn("game-mode", json.loads((ROOT / "assets/desktop-contract.json").read_text())["userHelpers"]) - - def test_named_themes_ship_and_list_identically_on_both_paths(self): - """Built-in themes, the theme helper and `cybex theme` on both layouts.""" - checkout = ROOT / "roles/desktop/files/quickshell" - contract = json.loads((ROOT / "assets/desktop-contract.json").read_text()) - self.assertIn("cybexos-theme", contract["userHelpers"]) - with tempfile.TemporaryDirectory() as temporary: - base = Path(temporary) - payload = base / "payload" - prepare_quickshell(ROOT, payload) - vendor = payload / "usr/share/cybexos" - installed = vendor / "runtime/quickshell" - themes = sorted(p.relative_to(checkout) for p in (checkout / "themes").rglob("*") if p.is_file()) - self.assertGreaterEqual(len(themes), 8) - for relative in themes + [Path(name) for name in ( - "scripts/theme-packs.py", "scripts/theme_packs.py", "scripts/settings-store", - "Common/Themes.qml", "Common/ThemeHelpers.js", "Common/SettingsHelpers.js")]: - self.assertEqual((installed / relative).read_bytes(), (checkout / relative).read_bytes(), - str(relative)) - self.assertEqual(sorted(p.relative_to(installed) for p in (installed / "themes").rglob("*") - if p.is_file()), themes) - # The RPM's helpers, as image/package installs them, and the - # account link cybexos-user-init makes to the vendor runtime. - (vendor / "bin").mkdir() - for name in ("cybexos-theme", "cybexos-runtime"): - target = vendor / "bin" / name - target.write_bytes((ROOT / "assets/scripts" / name).read_bytes()) - target.chmod(0o755) - iso_data = base / "iso-home/.local/share" - (iso_data / "cybexos").mkdir(parents=True) - (iso_data / "cybexos/runtime").symlink_to(vendor / "runtime") - # The checkout: helpers in ~/.local/bin and the deployed runtime. - checkout_home = base / "checkout-home" - (checkout_home / ".local/bin").mkdir(parents=True) - for name in ("cybexos-theme", "cybexos-runtime"): - target = checkout_home / ".local/bin" / name - target.write_bytes((ROOT / "assets/scripts" / name).read_bytes()) - target.chmod(0o755) - (checkout_home / ".local/share/cybexos/runtime").mkdir(parents=True) - (checkout_home / ".local/share/cybexos/runtime/quickshell").symlink_to(checkout) - - def listing(helper, home, data): - environment = {key: value for key, value in os.environ.items() - if not key.startswith(("XDG_", "CYBEXOS_"))} - environment.update(HOME=str(home), XDG_DATA_HOME=str(data), PYTHONDONTWRITEBYTECODE="1", - XDG_CONFIG_HOME=str(home / ".config")) - result = subprocess.run([str(helper), "list", "--json"], env=environment, - capture_output=True, text=True, check=False) - self.assertEqual(result.returncode, 0, result.stderr) - return json.loads(result.stdout) - iso = listing(vendor / "bin/cybexos-theme", base / "iso-home", iso_data) - source = listing(checkout_home / ".local/bin/cybexos-theme", checkout_home, - checkout_home / ".local/share") - self.assertEqual(iso, source) - self.assertEqual(iso["invalid"], []) - self.assertEqual(iso["themes"][0]["id"], "cybexos") - self.assertEqual(len(iso["themes"]), len(themes)) - self.assertTrue(all(theme["builtin"] for theme in iso["themes"])) - - def test_theme_command_dispatches_identically_on_both_paths(self): - environment = jinja2.Environment(undefined=jinja2.StrictUndefined) - environment.filters["quote"] = lambda value: "'" + str(value).replace("'", "'\\''") + "'" - with tempfile.TemporaryDirectory() as temporary: - base = Path(temporary) - stub = '#!/bin/sh\nprintf "%s|" "$@"\n' - home = base / "home" - (home / ".local/bin").mkdir(parents=True) - (home / ".local/bin/cybexos-theme").write_text(stub) - (home / ".local/bin/cybexos-theme").chmod(0o755) - checkout = base / "cybex-checkout" - checkout.write_text(environment.from_string( - (ROOT / "roles/dotfiles/templates/cybex.j2").read_text()).render(primary_home=str(home))) - vendor = base / "usr/share/cybexos/bin" - vendor.mkdir(parents=True) - (vendor / "cybexos-theme").write_text(stub) - (vendor / "cybexos-theme").chmod(0o755) - iso = base / "cybex-iso" - iso.write_text((ROOT / "image/rootfs/usr/bin/cybex").read_text().replace(" /usr/", f" {base}/usr/")) - for arguments in (["theme", "list", "--json"], ["theme", "apply", "nord"], - ["theme", "install", "https://github.com/a/b"], - ["theme", "export", "CURRENT", "/tmp/out dir", "--name", "Night shift"], - ["theme", "remove", "harbour"], ["theme"]): - results = [subprocess.run(["bash", str(script), *arguments], capture_output=True, - text=True, check=False) for script in (checkout, iso)] - self.assertEqual([r.returncode for r in results], [0, 0], arguments) - self.assertEqual(results[0].stdout, results[1].stdout, arguments) - # printf repeats its format per argument, and runs it once for none. - self.assertEqual(results[0].stdout, "".join(f"{a}|" for a in arguments[1:]) or "|") - for script in (checkout, iso): - usage = subprocess.run(["bash", str(script), "help"], capture_output=True, text=True).stdout - self.assertRegex(usage, r"\n theme +List, apply, install, export, or remove appearance themes\n") - - def test_contract_rejects_seed_escape(self): - with tempfile.TemporaryDirectory() as temporary: - with self.assertRaises(ValueError): - split_seed(Path(temporary), {"essentialPaths": ["../../private"]}) - - def test_personal_portal_is_disabled_without_private_binary(self): - template = jinja2.Environment(undefined=jinja2.StrictUndefined) - template.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no) - features = {key: True for key in ["podman", "developer_tools", "connected_widgets", "proprietary_apps", "private_hooks"]} - text = (ROOT / "roles/desktop/templates/features.lua.j2").read_text() - self.assertIn("private_portal = false", template.from_string(text).render(features=features, cybexos_xps_2026=False)) - self.assertIn("private_portal = true", template.from_string(text).render( - features=features, private_services={"portal_binary": "/opt/private/portal"}, cybexos_xps_2026=False)) - - def test_vpn_and_hotspot_ship_identically_with_their_runtime_on_both_paths(self): - checkout = ROOT / "roles/desktop/files/quickshell" - files = ["scripts/network-tool.py", "scripts/network_vpn_agent.py", "scripts/system_settings_network.py", - "Settings/VpnGroup.qml", "Settings/HotspotGroup.qml", "Settings/FolderDialog.qml", - "Settings/NetworkPage.qml", "Popovers/Drawer/DrawerNetwork.qml", "Common/NetworkDetails.qml", - "Common/NetworkHelpers.js", "NetworkOverlayWindow.qml"] - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - prepare_quickshell(ROOT, payload) - installed = payload / "usr/share/cybexos/runtime/quickshell" - for relative in files: - self.assertEqual((installed / relative).read_bytes(), (checkout / relative).read_bytes(), relative) - # The helpers run nmcli, qrencode and PyGObject (the VPN sign-in - # agent); NetworkManager's shared mode needs dnsmasq for the hotspot's - # clients. Both installation contracts name each one. - requires = {name for line in (ROOT / "image/cybexos-desktop.spec").read_text().splitlines() - if line.startswith("Requires:") for name in line.split(":", 1)[1].split()} - tasks = yaml.safe_load((ROOT / "roles/desktop/tasks/main.yml").read_text()) - packages = set() - for task in tasks: - names = (task.get("ansible.builtin.dnf") or {}).get("name", []) - packages.update(names if isinstance(names, list) else []) - for package in ("NetworkManager", "qrencode", "python3-gobject", "dnsmasq", "nm-connection-editor"): - self.assertIn(package, requires, package) - self.assertIn(package, packages, package) - - -class SeedLifecycle(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.environment = patch.dict(os.environ, {}, clear=True) - self.environment.start() - self.addCleanup(self.environment.stop) - self.root = Path(self.temporary.name) - self.vendor = self.root / "vendor" - self.home = self.root / "alice" - (self.vendor / "bin").mkdir(parents=True) - (self.vendor / "runtime").mkdir() - self.essential = self.vendor / "essential-seed/.config/cybexos/shell.json" - self.essential.parent.mkdir(parents=True) - self.essential.write_text('{"wall":"mountain.jpg"}') - self.binary = self.vendor / "user-seed/.rustup/toolchains/default/bin/rustc" - self.binary.parent.mkdir(parents=True) - self.binary.write_bytes(b"complete offline compiler") - self.binary.chmod(0o755) - (self.vendor / "seed-groups.json").write_text(json.dumps({"totalBytes": self.binary.stat().st_size})) - - def status(self): - return json.loads((self.home / ".local/state/cybexos/seed-progress.json").read_text()) - - def test_desktop_essentials_do_not_copy_toolchains(self): - INIT.initialize(self.home, self.vendor, mode="essential") - self.assertEqual((self.home / ".config/cybexos/shell.json").read_text(), self.essential.read_text()) - self.assertFalse((self.home / ".rustup").exists()) - self.assertEqual(self.status()["state"], "pending") - self.assertTrue((self.home / ".local/share/cybexos/runtime").is_symlink()) - INIT.initialize(self.home, self.vendor) - output = self.home / self.binary.relative_to(self.vendor / "user-seed") - self.assertEqual(output.read_bytes(), self.binary.read_bytes()) - self.assertEqual(output.stat().st_mode & 0o777, 0o755) - self.assertEqual(self.status()["state"], "ready") - self.assertEqual(self.status()["completedBytes"], self.binary.stat().st_size) - - def test_interrupted_copy_has_no_partial_final_file_and_resumes(self): - INIT.initialize(self.home, self.vendor, mode="essential") - output = self.home / self.binary.relative_to(self.vendor / "user-seed") - - def interrupted(_source, target, **_kwargs): - target.write(b"partial") - raise InterruptedError("fixture interruption") - - with patch.object(INIT.fcntl, "ioctl", side_effect=OSError("no reflink")), \ - patch.object(INIT.shutil, "copyfileobj", side_effect=interrupted): - with self.assertRaises(InterruptedError): - INIT.initialize(self.home, self.vendor) - self.assertFalse(output.exists()) - self.assertFalse(list(self.home.rglob(".cybexos-seed-*"))) - self.assertEqual(self.status()["state"], "error") - self.assertFalse((self.home / ".local/state/cybexos/offline-apps-seeded").exists()) - INIT.initialize(self.home, self.vendor) - self.assertEqual(output.read_bytes(), self.binary.read_bytes()) - - def test_editor_activation_follows_complete_offline_dependencies(self): - config = self.vendor / "final-seed/.config/nvim/init.lua" - config.parent.mkdir(parents=True) - config.write_text("-- activate offline plugins") - INIT.initialize(self.home, self.vendor, mode="essential") - self.assertFalse((self.home / ".config/nvim/init.lua").exists()) - original = INIT.seed_applications - - def ordered(source, destination, progress=None): - if source == self.vendor / "final-seed": - self.assertEqual((self.home / self.binary.relative_to(self.vendor / "user-seed")).read_bytes(), - self.binary.read_bytes()) - return original(source, destination, progress) - - with patch.object(INIT, "seed_applications", side_effect=ordered): - INIT.initialize(self.home, self.vendor) - self.assertEqual((self.home / ".config/nvim/init.lua").read_text(), config.read_text()) - - def test_wallpaper_replacement_preserves_saved_old_selection_and_personal_files(self): - # New seed names never authorize deleting an earlier installed wallpaper - # or overwriting a personal file that happens to share a new name. - directory = self.vendor / "essential-seed/Pictures/Wallpapers" - directory.mkdir(parents=True, exist_ok=True) - (directory / "moon-mountains.png").write_bytes((ROOT / "assets/wallpapers/moon-mountains.png").read_bytes()) - personal = self.home / "Pictures/Wallpapers" - personal.mkdir(parents=True) - old = personal / "snow-capped-mountains-with-full-moon-lo.jpg" - old.write_bytes(b"user retained earlier wallpaper") - replacement = personal / "moon-mountains.png" - replacement.write_bytes(b"personal image with same new filename") - settings = self.home / ".config/cybexos/shell.json" - settings.parent.mkdir(parents=True) - saved = json.dumps({"wall": old.name, "wallDir": "~/Pictures/Wallpapers", "accent": "#112233"}) - settings.write_text(saved) - INIT.initialize(self.home, self.vendor, mode="essential") - self.assertEqual(old.read_bytes(), b"user retained earlier wallpaper") - self.assertEqual(replacement.read_bytes(), b"personal image with same new filename") - self.assertEqual(settings.read_text(), saved) - # Checkout uses the same source directory with copy-if-absent semantics; - # actual task execution and both-path parity are checked in managed CI. - tasks = yaml.safe_load((ROOT / "roles/dotfiles/tasks/wallpapers.yml").read_text()) - copy = next(task["ansible.builtin.copy"] for task in tasks if task.get("name") == "Install bundled wallpapers") - self.assertFalse(copy["force"]) - self.assertEqual(copy["src"], "{{ config_repo }}/assets/wallpapers/") - - def test_existing_personal_file_and_symlink_are_preserved(self): - INIT.initialize(self.home, self.vendor, mode="essential") - settings = self.home / ".config/cybexos/shell.json" - settings.write_text('{"personal":true}') - external = self.root / "external" - external.mkdir() - (external / "sentinel").write_text("keep") - (self.home / ".rustup").symlink_to(external) - INIT.initialize(self.home, self.vendor) - self.assertEqual(settings.read_text(), '{"personal":true}') - self.assertEqual(list(external.iterdir()), [external / "sentinel"]) - self.assertTrue((self.home / ".rustup").is_symlink()) - - def test_installed_seeding_uses_only_real_accounts_as_their_owner(self): - from types import SimpleNamespace - def account(name, uid, home): - return SimpleNamespace(pw_name=name, pw_uid=uid, pw_dir=home) - users = [account("root", 0, "/root"), account("liveuser", 1000, "/home/liveuser"), - account("alice", 1001, "/home/alice"), account("service", 1002, "/var/lib/service")] - with patch.object(ACCOUNTS.os, "geteuid", return_value=0), \ - patch.object(ACCOUNTS.pwd, "getpwall", return_value=users), \ - patch.object(ACCOUNTS.subprocess, "run") as run: - ACCOUNTS.main() - self.assertEqual(run.call_count, 1) - command = run.call_args.args[0] - self.assertEqual(command[:5], ["runuser", "--user", "alice", "--", "env"]) - self.assertIn("HOME=/home/alice", command) - self.assertEqual(command[-1], "--all") - - -class BootMenu(unittest.TestCase): - def test_menu_styling_keeps_boot_and_recovery_commands(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - iso, install = root / "staging", root / "install" - grub = iso / "EFI/BOOT/grub.cfg" - grub.parent.mkdir(parents=True) - original = """set timeout=3 -search --set=root -l CYBEXOS-LIVE-44 -menuentry 'Start CybexOS' --class fedora { - linuxefi /vmlinuz root=live:LABEL=CYBEXOS-LIVE-44 quiet rhgb - initrdefi /initrd.img -} -menuentry 'Check media' { linux /vmlinuz rd.live.check; } -menuentry 'Basic graphics' { linux /vmlinuz nomodeset; } -""" - grub.write_text(original) - bios = iso / "isolinux/isolinux.cfg" - bios.parent.mkdir() - bios.write_text("menu background splash.jpg\nmenu title Linux\nlabel linux0\n menu label ^Start CybexOS\n" - " kernel /vmlinuz\n append initrd=/initrd.img root=live:CDLABEL=CYBEXOS-LIVE-44 quiet \n" - " menu default\nmenu separator\nlabel basic0\n menu label Start CybexOS in ^basic graphics mode.\n" - " kernel /vmlinuz\n append initrd=/initrd.img nomodeset\nlabel memtest\n menu label Run a ^memory test.\n" - " kernel memtest\n") - art = install / "usr/share/plymouth/themes/cybex/logo.png" - art.parent.mkdir(parents=True) - art.write_bytes(b"logo fixture") - brand_boot_menu(iso, install) - branded = grub.read_text() - self.assertTrue(branded.startswith(grub_install_entries(original))) - # Installation-only entries come first, so the default boot installs; - # every generated live entry, check and recovery entry remains. - self.assertEqual(re.findall(r"menuentry '([^']+)'", branded), - ["Install CybexOS", "Try CybexOS live", "Check media", "Basic graphics"]) - installer, live = re.findall(r"(?m)^\tlinuxefi (.*)$", branded) - self.assertTrue(installer.endswith(" cybexos.install")) - self.assertEqual(installer.removesuffix(" cybexos.install"), live.rstrip()) - menu = bios.read_text() - self.assertEqual(re.findall(r"(?m)^label (\S+)", menu), ["install-linux0", "linux0", "install-basic0", "basic0", "memtest"]) - self.assertEqual(re.findall(r"(?m)^\s*menu label (.*)", menu), - ["^Install CybexOS", "^Try CybexOS live", "Install CybexOS in basic graphics mode.", - "Try CybexOS live in ^basic graphics mode.", "Run a ^memory test."]) - self.assertEqual(menu.count("menu default"), 1) - self.assertLess(menu.index("menu default"), menu.index("label linux0")) - appends = re.findall(r"(?m)^\s*append (.*)", menu) - self.assertEqual([line.endswith("cybexos.install") for line in appends], [True, False, True, False]) - self.assertIn("menu title CybexOS", bios.read_text()) - self.assertIn("root=live:CDLABEL=CYBEXOS-LIVE-44", bios.read_text()) - self.assertNotIn("menu background", bios.read_text()) - first = grub.read_text(), bios.read_text() - brand_boot_menu(iso, install) - self.assertEqual(first, (grub.read_text(), bios.read_text())) - - -class T3Callback(unittest.TestCase): - def test_offline_launch_waits_for_seed_and_never_downloads_on_failure(self): - with tempfile.TemporaryDirectory() as temporary: - directory = Path(temporary) - bindir = directory / "bin" - bindir.mkdir() - vendor = directory / "vendor" - vendor.mkdir() - launcher = directory / "t3-desktop" - launcher.write_text((ROOT / "roles/dotfiles/templates/t3code-desktop.j2").read_text().replace( - "/usr/share/cybexos/user-seed", str(vendor))) - updater = directory / ".local/bin/t3code-update" - updater.parent.mkdir(parents=True) - updater.write_text('#!/bin/sh\ntouch "$HOME/download-attempted"\nexit 91\n') - updater.chmod(0o755) - systemctl = bindir / "systemctl" - systemctl.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$HOME/service-request"\nexit 1\n') - systemctl.chmod(0o755) - env = {"PATH": str(bindir) + ":/usr/bin", "HOME": str(directory)} - result = subprocess.run(["bash", str(launcher)], env=env, capture_output=True, text=True) - self.assertEqual(result.returncode, 1) - self.assertIn("not ready", result.stderr) - self.assertFalse((directory / "download-attempted").exists()) - self.assertIn("cybexos-app-seed.service", (directory / "service-request").read_text()) - # A successful service response still cannot fall back to the - # network when the expected offline binary is missing. - systemctl.write_text("#!/bin/sh\nexit 0\n") - result = subprocess.run(["bash", str(launcher)], env=env, capture_output=True, text=True) - self.assertEqual(result.returncode, 1) - self.assertIn("unavailable", result.stderr) - self.assertFalse((directory / "download-attempted").exists()) - # Once seeding produces the app, launch it with the original URI. - app = directory / ".local/share/t3code-nightly/T3-Code-Nightly-x86_64.AppImage" - app.parent.mkdir(parents=True) - app.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$HOME/app-args"\n') - app.chmod(0o755) - subprocess.run(["bash", str(launcher), "t3code://fixture"], env=env, check=True) - self.assertIn("t3code://fixture", (directory / "app-args").read_text()) - self.assertFalse((directory / "download-attempted").exists()) - - def test_oauth_redirect_uses_active_runtime_before_appimage(self): - with tempfile.TemporaryDirectory() as temporary: - directory = Path(temporary) - bindir = directory / ".local/bin" - bindir.mkdir(parents=True) - record = directory / "node-args" - runtime = bindir / "cybexos-runtime" - runtime.write_text('#!/bin/sh\nprintf "%s\\n" "$FIXTURE_RUNTIME"\n') - node = bindir / "node" - node.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$FIXTURE_RECORD"\n') - for file in (runtime, node): - file.chmod(0o755) - env = dict(os.environ, PATH=str(bindir) + ":/usr/bin", HOME=str(directory), - FIXTURE_RUNTIME=str(directory / "runtime"), FIXTURE_RECORD=str(record)) - subprocess.run(["bash", str(ROOT / "roles/dotfiles/templates/t3code-desktop.j2"), - "t3code://app/oauth?fixture=1"], env=env, check=True) - self.assertEqual(record.read_text().splitlines(), [str(directory / "runtime/scripts/t3-cloud.mjs"), - "oauth-callback", "t3code://app/oauth?fixture=1"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/image/test_display_policy.py b/image/test_display_policy.py deleted file mode 100644 index a6278000..00000000 --- a/image/test_display_policy.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Execute the shared XPS display tasks against disposable EDIDs and kernels.""" -import json -import os -from pathlib import Path -import subprocess -import tempfile -import unittest - -import yaml - -from provision_payload import prepare_provision - - -ROOT = Path(__file__).resolve().parents[1] -GRUBBY = '''#!/usr/bin/python3 -import json, os, sys -from pathlib import Path -root = Path(os.environ['DISPLAY_FIXTURE']) -state = root / 'kernels.json' -entries = json.loads(state.read_text()) -with (root / 'grubby.jsonl').open('a') as log: - log.write(json.dumps(sys.argv[1:]) + '\\n') -if sys.argv[1:] == ['--info=ALL']: - for entry in entries: - print('args="' + entry + '"') -elif sys.argv[1:] == ['--update-kernel=ALL', '--args=xe.enable_psr=0']: - if os.environ.get('DISPLAY_IGNORE_UPDATE') != '1': - entries = [' '.join([arg for arg in entry.split() - if not arg.startswith('xe.enable_psr=')] + ['xe.enable_psr=0']) - for entry in entries] - state.write_text(json.dumps(entries)) -else: - raise SystemExit('unexpected grubby invocation: ' + repr(sys.argv)) -''' - - -class PanelRefreshParity(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory(prefix='cybex-panel-refresh.') - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - payload = self.root / 'payload' - prepare_provision(ROOT, payload) - self.sources = (ROOT, payload / 'usr/share/cybexos/provision') - for relative in ('roles/xps-2026/tasks/main.yml', 'roles/xps-2026/tasks/display.yml', - 'roles/xps-2026/defaults/main.yml'): - self.assertEqual((self.sources[0] / relative).read_bytes(), - (self.sources[1] / relative).read_bytes()) - - def fixture(self, source, *, supported=True, sku='0DB9', edid='30e4c607', opt_out=False): - root = Path(tempfile.mkdtemp(dir=self.root)) - binaries = root / 'bin' - binaries.mkdir() - binary = binaries / 'grubby' - binary.write_text(GRUBBY) - binary.chmod(0o755) - panel = root / 'drm/card0-eDP-1/edid' - panel.parent.mkdir(parents=True) - if edid is not None: - panel.write_bytes(b'\x00\xff\xff\xff\xff\xff\xff\x00' + bytes.fromhex(edid) + bytes(116)) - entries = ['root=UUID=fixture quiet xe.enable_psr=0', - 'root=UUID=fixture quiet video=DP-2:d xe.enable_dpcd_backlight=1'] - (root / 'kernels.json').write_text(json.dumps(entries)) - # Substitute only the sysfs root. Run the actual probe, guard and - # convergence tasks; no host EDID, boot entry or privilege is used. - display = root / 'display.yml' - display.write_text((source / 'roles/xps-2026/tasks/display.yml').read_text() - .replace('/sys/class/drm/', str(root / 'drm') + '/')) - main = yaml.safe_load((source / 'roles/xps-2026/tasks/main.yml').read_text()) - gate = dict(next(task for task in main if task.get('ansible.builtin.import_tasks') == 'display.yml')) - gate['ansible.builtin.import_tasks'] = str(display) - variables = yaml.safe_load((source / 'roles/xps-2026/defaults/main.yml').read_text()) - variables.update(xps_2026_is_supported=supported, xps_2026_product_sku=sku) - if opt_out: - variables['xps_2026_psr_disabled_panels'] = [] - playbook = root / 'playbook.yml' - playbook.write_text(yaml.safe_dump([{ - 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, 'become': False, - 'vars': variables, 'tasks': [gate], - 'environment': {'PATH': str(binaries) + ':/usr/bin:/bin', 'DISPLAY_FIXTURE': str(root), - 'DISPLAY_IGNORE_UPDATE': "{{ fixture_ignore_update | default('0') }}"}, - }])) - return root, entries - - def run_play(self, root, *arguments, succeeds=True): - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(root / 'playbook.yml'), - *arguments], text=True, capture_output=True, timeout=30, - env={**os.environ, 'ANSIBLE_STDOUT_CALLBACK': 'default', - 'ANSIBLE_NOCOLOR': '1', 'ANSIBLE_FORCE_COLOR': '0'}) - self.assertEqual(result.returncode == 0, succeeds, result.stdout + result.stderr) - - def test_both_paths_preserve_arguments_and_update_all_kernels_once(self): - outcomes = [] - for source in self.sources: - with self.subTest(source=source): - root, before = self.fixture(source) - self.run_play(root, '--check') - self.assertEqual(json.loads((root / 'kernels.json').read_text()), before) - self.run_play(root) - self.run_play(root) - after = json.loads((root / 'kernels.json').read_text()) - self.assertEqual(after, [before[0], before[1] + ' xe.enable_psr=0']) - calls = [json.loads(line) for line in (root / 'grubby.jsonl').read_text().splitlines()] - self.assertEqual(sum('--update-kernel=ALL' in call for call in calls), 1) - outcomes.append(after) - self.assertEqual(outcomes[0], outcomes[1]) - - def test_other_panels_machines_missing_edid_and_saved_opt_out_are_untouched(self): - cases = ({'edid': '30e4c707'}, {'edid': '1234c607'}, {'edid': None}, - {'sku': '0DBA'}, {'supported': False}, {'opt_out': True}) - for source in self.sources: - for case in cases: - with self.subTest(source=source, case=case): - root, before = self.fixture(source, **case) - self.run_play(root) - self.assertEqual(json.loads((root / 'kernels.json').read_text()), before) - self.assertFalse((root / 'grubby.jsonl').exists()) - - def test_silent_boot_entry_update_failure_is_reported_on_both_paths(self): - for source in self.sources: - with self.subTest(source=source): - root, before = self.fixture(source) - self.run_play(root, '-e', 'fixture_ignore_update=1', succeeds=False) - self.assertEqual(json.loads((root / 'kernels.json').read_text()), before) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_doctor.py b/image/test_doctor.py deleted file mode 100644 index 5d02cb8d..00000000 --- a/image/test_doctor.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Fixture checks for the installed doctor; no live services or capture devices.""" -import importlib.machinery -import importlib.util -import json -from pathlib import Path -import tempfile -import unittest - -SCRIPT = Path(__file__).parent / 'rootfs/usr/libexec/cybexos-doctor' -loader = importlib.machinery.SourceFileLoader('cybexos_doctor', str(SCRIPT)) -spec = importlib.util.spec_from_loader(loader.name, loader) -doctor = importlib.util.module_from_spec(spec) -loader.exec_module(doctor) - - -class DoctorTests(unittest.TestCase): - def fixture(self, root, home): - def write(path, value): - target = root / path.lstrip('/') - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(json.dumps(value)) - write('/usr/share/cybexos/build.json', {'source_revision': 'a' * 40}) - write('/etc/cybexos/hardware.json', {'xps_2026': True}) - write('/var/lib/cybexos/hardware-status.json', {'state': 'pending', 'reason': 'reboot required for camera validation'}) - for path in ('/etc/grub.d/42_cybexos_recovery', - '/usr/lib/systemd/system/cybexos-recovery-refresh.service'): - target = root / path.lstrip('/') - target.parent.mkdir(parents=True, exist_ok=True) - target.touch() - state = home / '.local/state/cybexos' - state.mkdir(parents=True) - (state / 'seed-progress.json').write_text('{"state":"copying"}') - - def command(self, *argv): - if argv[0] == 'rpm': - return 0, 'cybexos-desktop-1.0' - if argv[0].endswith('cybexos-update-channel'): - return 0, '{"status":"desktop-channel-disabled"}' - if argv[0].endswith('cybexos-reconcile'): - return 0, '{"state":"ready"}' - if argv[:3] == ('systemctl', '--user', 'is-active'): - return 0, 'active' - if argv[:2] == ('systemctl', 'is-active'): - return 0, 'active' - if argv[:2] == ('systemctl', 'is-enabled'): - return 0, 'enabled' - if argv[0] == 'nmcli': - return 0, 'connected' - return 0, '' - - def test_pending_reboot_and_seed_are_explicit_warnings(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) / 'root' - home = Path(temporary) / 'home' - self.fixture(root, home) - result = doctor.collect(root, home, self.command) - checks = {entry['name']: entry for entry in result['checks']} - self.assertEqual(result['overall'], 'warning') - self.assertEqual(checks['hardware']['state'], 'pending') - self.assertIn('Reboot', checks['hardware']['action']) - self.assertEqual(checks['seeded_apps']['state'], 'pending') - self.assertEqual(checks['desktop_channel']['state'], 'warning') - - def test_malformed_helper_json_is_reported_and_retry_uses_installed_path(self): - with tempfile.TemporaryDirectory() as temporary: - root, home = Path(temporary) / 'root', Path(temporary) / 'home' - self.fixture(root, home) - def command(*argv): - if argv[0].endswith('cybexos-update-channel'): - return 0, '[]' - if argv[0].endswith('cybexos-reconcile'): - return 0, '{"state":"blocked"}' - return self.command(*argv) - checks = {entry['name']: entry for entry in doctor.collect(root, home, command)['checks']} - self.assertEqual(checks['desktop_channel']['state'], 'fail') - self.assertIn('/usr/libexec/cybexos-reconcile --retry', checks['reconciliation']['action']) - def malformed(*argv): - if argv[0].endswith('cybexos-reconcile'): - return 0, '[]' - return self.command(*argv) - checks = {entry['name']: entry for entry in doctor.collect(root, home, malformed)['checks']} - self.assertEqual(checks['reconciliation']['state'], 'warning') - - def test_missing_package_failure_and_qml_error_are_reported_without_journal_text(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) / 'root' - home = Path(temporary) / 'home' - self.fixture(root, home) - secret = 'private-widget-value' - def command(*argv): - if argv[0] == 'rpm': - return 1, '' - if argv[0] == 'journalctl': - return 0, 'QML error ' + secret - if argv[:2] == ('systemctl', '--failed'): - return 0, 'bad.service loaded failed failed' - return self.command(*argv) - result = doctor.collect(root, home, command) - checks = {entry['name']: entry for entry in result['checks']} - self.assertEqual(result['overall'], 'fail') - self.assertEqual(checks['build']['state'], 'fail') - self.assertEqual(checks['failed_units']['state'], 'fail') - self.assertEqual(checks['qml']['state'], 'warning') - self.assertNotIn(secret, json.dumps(result)) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_fedora_release.py b/image/test_fedora_release.py deleted file mode 100644 index e1cd1e8c..00000000 --- a/image/test_fedora_release.py +++ /dev/null @@ -1,227 +0,0 @@ -"""One targeted Fedora release, and no release literal that can drift from it. - -inventory/group_vars/all.yml fedora_release names the release both installation -paths target; release-manifest.json supportedFedora must include it. Files that -used to repeat the number derive it instead. The literals that remain are -checksum-pinned inputs (the Fedora Cloud builder image), published paths, or -names reviewed with the release (the required CI check); they must equal it. -The next-release CI preview must stay unable to block or slow a release. -These are source checks: they qualify no Fedora release. -""" -import configparser -import json -from pathlib import Path -import re -import tempfile -import unittest - -import yaml - -from fedora_target import fedora_release - -ROOT = Path(__file__).resolve().parents[1] - -# Release numbers in the forms these files spell them. -RELEASE_TOKEN = re.compile( - r"Fedora (\d+)\b|\bfedora:(\d+)\b|\bfc(\d+)\b|/releases/(\d+)/|Generic-(\d+)-" - r"|\bfedora-(\d+)-x86_64|RPM-GPG-KEY-fedora-(\d+)|repo=fedora(?:-cisco-openh264)?-(\d+)" - r"|updates-released-f(\d+)|--version F(\d+)\b|/(\d+)/x86_64|CybexOS-Live-(\d+)|CYBEXOS-LIVE-(\d+)") - -# These derive the release (inventory, $releasever or a CI build argument). -DERIVED = ( - ".github/actions/fedora-source-environment/action.yml", - ".github/workflows/live-image.yml", - "image/build-in-guest", - "image/build.repo", - "image/live.ks", -) -# These spell it out and are reviewed when it changes. Each must name it. -PINNED = ( - ".github/workflows/tests.yml", # required check name and its container - ".github/workflows/fedora-vm-convergence.yml", - ".github/workflows/desktop-release.yml", # RPM signing container - "image/build", # checksum-pinned Fedora Cloud builder image - "image/qualify-checkout", - "tests/fedora-vm-convergence", - "image/compose", # the livecd-tools behaviour its adapter compensates for - "image/channels/stable.json", # published update channel path - "image/github_release.py", - "inventory/group_vars/all.yml", # mdview fcNN asset, source-build base image -) - - -def release_tokens(relative): - for number, line in enumerate((ROOT / relative).read_text().splitlines(), 1): - for match in RELEASE_TOKEN.finditer(line): - yield number, match.group(0), next(group for group in match.groups() if group) - - -def workflow(name): - document = yaml.safe_load((ROOT / ".github/workflows" / name).read_text()) - # YAML 1.1 reads the bare key `on` as true. - document["on"] = document.pop(True, document.get("on")) - return document - - -def steps_using(job, action): - return [step for step in job["steps"] if step.get("uses", "").split("@")[0] == action] - - -class TargetedReleaseTests(unittest.TestCase): - def test_inventory_release_is_supported_by_the_release_manifest(self): - inventory = yaml.safe_load((ROOT / "inventory/group_vars/all.yml").read_text()) - manifest = json.loads((ROOT / "release-manifest.json").read_text()) - self.assertIsInstance(inventory["fedora_release"], str) - self.assertIn(inventory["fedora_release"], manifest["supportedFedora"]) - self.assertEqual(fedora_release(), inventory["fedora_release"]) - - def test_helper_refuses_an_unsupported_or_ambiguous_declaration(self): - cases = { - "unsupported": ('fedora_release: "45"\n', ["44"]), - "unquoted": ("fedora_release: 44\n", ["44"]), - "repeated": ('fedora_release: "44"\nfedora_release: "44"\n', ["44"]), - "manifest without a list": ('fedora_release: "44"\n', "44"), - } - for label, (inventory, supported) in cases.items(): - with self.subTest(label), tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / "inventory/group_vars").mkdir(parents=True) - (root / "inventory/group_vars/all.yml").write_text(inventory) - (root / "release-manifest.json").write_text(json.dumps({"supportedFedora": supported})) - with self.assertRaises(ValueError): - fedora_release(root) - - -class ReleaseLiteralTests(unittest.TestCase): - def test_derived_files_name_no_release(self): - for relative in DERIVED: - with self.subTest(relative): - self.assertEqual([], list(release_tokens(relative)), - "derive the release from inventory instead of spelling it") - - def test_pinned_release_literals_match_inventory(self): - release = fedora_release() - for relative in PINNED: - with self.subTest(relative): - tokens = list(release_tokens(relative)) - self.assertTrue(tokens, "names no release any more; move it to DERIVED") - stale = [f"{relative}:{line}: {text}" for line, text, number in tokens if number != release] - self.assertEqual([], stale, f"review these for Fedora {release} (see docs/fedora-major-upgrade.md)") - - def test_image_tooling_derives_the_release(self): - compose = (ROOT / "image/compose").read_text() - self.assertIn("release = fedora_release()", compose) - self.assertIn('releasever=release,', compose) - self.assertIn('f"CybexOS-Live-{fedora_release()}"', (ROOT / "image/build").read_text()) - self.assertIn('fedora_release=$(python3 "$source_root/image/fedora_target.py")', - (ROOT / "image/build-in-guest").read_text()) - self.assertIn("releases/$releasever/", (ROOT / "image/live.ks").read_text()) - # CI passes the release as a build argument; the default serves local builds. - containerfile = (ROOT / "image/Containerfile.tests").read_text().splitlines() - self.assertEqual([f"ARG FEDORA_RELEASE={fedora_release()}", "FROM fedora:${FEDORA_RELEASE}"], - [line for line in containerfile if line.startswith(("ARG ", "FROM "))]) - - def test_image_repositories_expand_to_the_targeted_release(self): - release = fedora_release() - repositories = configparser.ConfigParser(interpolation=None) - repositories.read(ROOT / "image/build.repo") - for section in ("fedora", "updates", "fedora-cisco-openh264"): - entry = repositories[section] - for key in ("metalink", "gpgkey"): - self.assertIn("$releasever", entry[key], f"{section} {key}") - # image/compose substitutes exactly these two variables. - expanded = entry[key].replace("$releasever", release).replace("$basearch", "x86_64") - self.assertNotIn("$", expanded, f"{section} {key}") - self.assertEqual(repositories["fedora"]["gpgkey"], - "file:///etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-$releasever-x86_64") - # The image composes from the COPR chroot the workstation template uses. - template = configparser.ConfigParser(interpolation=None) - template.read(ROOT / "roles/desktop/templates/hyprland-copr.repo.j2") - workstation = template["copr:copr.fedorainfracloud.org:sdegler:hyprland"]["baseurl"] - self.assertEqual(repositories["hyprland"]["baseurl"], workstation.replace("$basearch", "x86_64")) - - def test_fedora_cloud_builder_pins_agree(self): - def assigned(source, pattern): - return re.search(pattern, source, re.MULTILINE).group(1) - - pins = {} - for relative in ("image/build", "image/qualify-checkout"): - source = (ROOT / relative).read_text() - name = assigned(source, r"^IMAGE = ['\"]([^'\"]+)['\"]$") - pins[relative] = (assigned(source, r"^IMAGE_URL = ['\"]([^'\"]+)['\"] \+ IMAGE$") + name, name, - assigned(source, r"^IMAGE_SHA256 = ['\"]([0-9a-f]{64})['\"]$")) - runner = (ROOT / "tests/fedora-vm-convergence").read_text() - url = assigned(runner, r"^image_url=\$\{FEDORA_VM_IMAGE_URL:-([^}]+)\}$") - pins["tests/fedora-vm-convergence"] = (url, url.rsplit("/", 1)[1], - assigned(runner, r"^image_sha256=\$\{FEDORA_VM_IMAGE_SHA256:-([0-9a-f]{64})\}$")) - # The image name and its checksum change together, in every consumer. - self.assertEqual(1, len(set(pins.values())), pins) - url, name, _ = pins["image/build"] - release = fedora_release() - self.assertTrue(name.startswith(f"Fedora-Cloud-Base-Generic-{release}-"), name) - self.assertTrue(url.startswith(f"https://download.fedoraproject.org/pub/fedora/linux/releases/{release}/"), url) - - -class ContinuousIntegrationTests(unittest.TestCase): - def test_required_source_contract_tracks_inventory(self): - release = fedora_release() - tests = workflow("tests.yml") - # release.yml gates a release on this workflow, so every job is required. - self.assertIn("workflow_call", tests["on"]) - for job in tests["jobs"].values(): - self.assertNotIn("continue-on-error", job) - # Branch protection names the job; keep its id when the release changes. - job = tests["jobs"]["fedora-44"] - self.assertEqual(f"Fedora {release} source contract", job["name"]) - self.assertEqual(f"fedora:{release}", job["container"]["image"]) - [environment] = steps_using(job, "./.github/actions/fedora-source-environment") - # Without inputs the shared step asserts the inventory release strictly. - self.assertNotIn("with", environment) - self.assertEqual("./tests/run", job["steps"][-1]["run"]) - action = yaml.safe_load((ROOT / ".github/actions/fedora-source-environment/action.yml").read_text()) - self.assertEqual("false", action["inputs"]["best-effort"]["default"]) - self.assertIn("python3 image/fedora_target.py", action["runs"]["steps"][-1]["run"]) - - def test_preview_cannot_block_or_slow_a_release(self): - release = fedora_release() - supported = json.loads((ROOT / "release-manifest.json").read_text())["supportedFedora"] - preview = workflow("fedora-preview.yml") - self.assertLessEqual(set(preview["on"]), {"pull_request", "push", "schedule", "workflow_dispatch"}) - self.assertEqual({"branches": ["main"]}, preview["on"]["push"]) - # Nothing calls the preview, release.yml included. - for path in sorted((ROOT / ".github/workflows").glob("*.yml")): - self.assertNotRegex(path.read_text(), r"uses:\s*\S*fedora-preview", path.name) - tests = workflow("tests.yml")["jobs"]["fedora-44"] - for job in preview["jobs"].values(): - self.assertIs(True, job["continue-on-error"]) - self.assertIs(False, job["strategy"]["fail-fast"]) - releases = job["strategy"]["matrix"]["fedora"] - self.assertTrue(releases) - for next_release in releases: - # A qualified release belongs in tests.yml, not in a preview. - self.assertRegex(next_release, r"^[1-9][0-9]*$") - self.assertGreater(int(next_release), int(release)) - self.assertNotIn(next_release, supported) - self.assertEqual("fedora:${{ matrix.fedora }}", job["container"]["image"]) - self.assertEqual(tests["env"], job["env"]) - [environment] = steps_using(job, "./.github/actions/fedora-source-environment") - self.assertEqual({"fedora-release": "${{ matrix.fedora }}", "best-effort": "true"}, - environment["with"]) - self.assertEqual(steps_using(tests, "actions/checkout")[0]["uses"], - steps_using(job, "actions/checkout")[0]["uses"]) - # Every check runs; what the release lacked still fails the job. - runs = [step.get("run", "") for step in job["steps"]] - self.assertIn("./tests/run", runs) - self.assertIn("steps.environment.outputs.unavailable != ''", job["steps"][-1]["if"]) - self.assertIn("exit 1", job["steps"][-1]["run"]) - - def test_image_source_contract_builds_the_inventory_release(self): - job = workflow("live-image.yml")["jobs"]["welcome-and-accounts"] - commands = "\n".join(step.get("run", "") for step in job["steps"]) - self.assertIn("release=$(python3 image/fedora_target.py)", commands) - self.assertIn('--build-arg FEDORA_RELEASE="$FEDORA_RELEASE"', commands) - self.assertEqual(2, commands.count('--version "F$FEDORA_RELEASE"')) - - -if __name__ == "__main__": - unittest.main() diff --git a/image/test_github_release.py b/image/test_github_release.py deleted file mode 100644 index 5ae3732c..00000000 --- a/image/test_github_release.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Offline release staging gates, metadata integrity and ISO reconstruction.""" -import gzip -import importlib.machinery -import importlib.util -import json -from pathlib import Path -import subprocess -import tempfile -import unittest -from unittest.mock import patch - -import github_release as release -import sbom -from qualification_contract import SCHEMA, required_checks - -loader = importlib.machinery.SourceFileLoader('reconstruct_iso', str(Path(__file__).with_name('reconstruct-iso'))) -spec = importlib.util.spec_from_loader(loader.name, loader) -reconstruct = importlib.util.module_from_spec(spec) -loader.exec_module(reconstruct) -FINGERPRINT = 'A' * 40 -REPOSITORY = 'DigitalPals/CybexOS' -TAG = 'v1.2.3' -BASE = 'https://digitalpals.github.io/CybexOS/44/x86_64' - - -def checksums(root): - paths = sorted(path for path in root.rglob('*') if path.is_file() and path.name != 'SHA256SUMS') - (root / 'SHA256SUMS').write_text(''.join(f'{release.sha256(path)} {path.relative_to(root)}\n' for path in paths)) - - -class GitHubRelease(unittest.TestCase): - def setUp(self): - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - self.signed, self.artifacts = self.root / 'signed', self.root / 'artifacts' - self.output = self.root / 'result' - (self.signed / 'Packages').mkdir(parents=True) - (self.signed / 'repodata').mkdir() - self.artifacts.mkdir() - self.rpm = self.signed / 'Packages/cybexos-desktop.rpm' - self.rpm.write_bytes(b'fixture signed RPM bytes') - self.iso = self.artifacts / 'CybexOS-fixture.iso' - self.iso.write_bytes(b'fixture ISO image bytes') - checksums(self.artifacts) - self.package = {'name': 'cybexos-desktop', 'epoch': '1', 'version': '1.2.3', - 'release': '1.fc44', 'arch': 'x86_64', - 'file': 'Packages/' + self.rpm.name, - 'sha256': release.sha256(self.rpm), 'unsigned_input_sha256': 'b' * 64, - 'url': release.github_url(REPOSITORY, TAG) + '/' + self.rpm.name} - self.manifest = {'format': 1, 'fingerprint': FINGERPRINT, 'baseurl': BASE, - 'packages': [self.package]} - self.sbom = self.write_sbom() - checksums(self.artifacts) - (self.signed / 'release.json.asc').write_text('fixture detached signature') - (self.signed / 'CYBEXOS-desktop.asc').write_text('fixture public key') - (self.signed / 'update-channel.json').write_text(json.dumps({ - 'baseurl': BASE, 'fingerprint': FINGERPRINT, 'key_file': 'CYBEXOS-desktop.asc'})) - self.metadata() - self.reports = [] - for scenario in ('encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl'): - self.report(scenario, release.sha256(self.iso), ['graphical-installer']) - self.report('upgrade', 'c' * 64, ['installed-rpm-upgrade', 'recovery-boot-restore'], 'b' * 64) - - def write_sbom(self, iso_digest=None, rpm_digest='b' * 64, name=None): - record = '\t'.join(('cybexos-desktop', '1', '1.2.3', '1.fc44', 'x86_64', 'MIT', - '(none)', '(none)', '(none)', '(none)')) + '\n' - document = sbom.build_document( - rpms=sbom.parse_rpm_inventory(record), flatpaks=[], pins=[], version='1.2.3', - provenance={'utc': '2026-09-26T00:00:00Z', 'build_id': 'fixture', 'source_revision': 'a' * 40}, - iso=(self.iso.name, iso_digest or release.sha256(self.iso)), - desktop_rpm=('cybexos-desktop-1.2.3-1.fc44.x86_64.rpm', rpm_digest)) - path = self.artifacts / (name or sbom.sbom_name(self.iso.name)) - path.write_bytes(sbom.serialize(document)) - return path - - def metadata(self, package_url=None): - package = self.package - primary = ('' - 'cybexos-desktopx86_64' - '' - f'{package["sha256"]}' - f'') - metadata = self.signed / 'repodata/primary.xml.gz' - metadata.write_bytes(gzip.compress(primary.encode())) - repomd = ('' - f'{release.sha256(metadata)}' - '') - (self.signed / 'repodata/repomd.xml').write_text(repomd) - (self.signed / 'repodata/repomd.xml.asc').write_text('fixture detached signature') - self.save_manifest() - - def save_manifest(self): - (self.signed / 'release.json').write_text(json.dumps(self.manifest)) - checksums(self.signed) - - def report(self, scenario, iso, checks, rpm=None): - path = self.root / (scenario + '.json') - kind = 'upgrade' if rpm else 'fresh' - actual_scenario = 'encrypted-us' if scenario == 'upgrade' else scenario - report = {'scenario': actual_scenario, 'iso_sha256': iso, 'status': 'passed', - 'checks': sorted(required_checks(actual_scenario, kind)), - 'evidence_schema': SCHEMA, 'execution_mode': 'qemu', 'qualification_kind': kind, - 'network': 'outbound-blocked', 'firmware': 'uefi', 'boot_entry': 'install', - 'legacy_installer': bool(rpm), 'source_revision': 'a' * 40, - 'source_content_sha256': 'd' * 64, 'candidate_rpm_sha256': rpm} - if scenario.startswith('plain-'): - report.update(source_revision='a' * 40, source_content_sha256='d' * 64, - checkout_parity={'status': 'passed', 'scenario': scenario, 'source_revision': 'a' * 40, - 'source_content_sha256': 'd' * 64, 'checks': ['full-checkout-installation', - 'installed-parity-fresh', 'installed-parity-saved', 'full-graphical-session']}) - report['checks'] += ['installed-parity-fresh', 'installed-parity-saved'] - path.write_text(json.dumps(report)) - self.reports.append(path) - return path - - def test_checkout_parity_cannot_be_missing_stale_or_fixture_only(self): - target = self.root / 'plain-us.json' - original = json.loads(target.read_text()) - for replacement in ({}, {'status': 'passed', 'source_content_sha256': 'e' * 64}, - {**original['checkout_parity'], 'checks': ['source-fixtures']}, - {**original['checkout_parity'], 'source_revision': 'b' * 40}): - with self.subTest(replacement=replacement): - target.write_text(json.dumps({**original, 'checkout_parity': replacement})) - with self.assertRaisesRegex(ValueError, 'same-source real checkout/ISO parity'): - self.prepare() - self.assertFalse(self.output.exists()) - target.write_text(json.dumps(original)) - - def prepare(self): - # These fixtures are deliberately not signed releases or VM evidence. - # Other tests cover the fail-closed signature subprocess boundary. - with patch.object(release, 'verify_release') as signatures, patch.object(release, 'verify_signed_rpm') as rpm: - result = release.prepare(self.signed, self.artifacts, self.output, - REPOSITORY, TAG, FINGERPRINT, self.reports) - self.assertEqual(signatures.call_count, 2) - rpm.assert_called_once() - return result - - def test_stages_qualified_release_and_metadata_without_rpm_on_pages(self): - self.assertEqual(self.prepare(), self.output) - self.assertTrue((self.output / 'assets' / self.rpm.name).is_file()) - self.assertFalse(list((self.output / 'pages').rglob('*.rpm'))) - self.assertTrue((self.output / 'assets/reconstruct-iso.py').is_file()) - self.assertEqual(len(json.loads((self.output / 'assets/qualification-reports.json').read_text())), 5) - self.assertEqual((self.output / 'assets/CybexOS-fixture.spdx.json').read_bytes(), self.sbom.read_bytes()) - self.assertIn('CybexOS-fixture.spdx.json', - release.verify_checksums(self.output / 'assets', 'desktop-SHA256SUMS')) - self.assertNotIn('CybexOS-fixture.spdx.json', release.verify_checksums(self.output / 'pages/44/x86_64')) - - def test_sbom_must_describe_the_exact_iso_and_candidate_rpm(self): - for label, change, message in ( - ('missing', lambda: self.sbom.unlink(), 'SPDX SBOM'), - ('second', lambda: self.write_sbom(name='CybexOS-other.spdx.json'), 'SPDX SBOM'), - ('other ISO', lambda: self.write_sbom(iso_digest='0' * 64), 'exact ISO'), - ('other RPM', lambda: self.write_sbom(rpm_digest='0' * 64), 'candidate desktop RPM'), - ('not SPDX', lambda: self.sbom.write_text('{"spdxVersion": "SPDX-2.2"}'), 'SPDX 2.3'), - ): - with self.subTest(label=label): - change() - checksums(self.artifacts) - with self.assertRaisesRegex(ValueError, message): - self.prepare() - self.assertFalse(self.output.exists()) - for extra in self.artifacts.glob('*.spdx.json'): - extra.unlink() - self.sbom = self.write_sbom() - checksums(self.artifacts) - self.assertEqual(self.prepare(), self.output) - - def test_checksum_inventory_cannot_hide_unlisted_files_or_symlinks(self): - extra = self.signed / 'repodata/unlisted' - extra.write_text('unlisted') - with self.assertRaisesRegex(ValueError, 'inventory'): - self.prepare() - extra.unlink() - extra.symlink_to(self.iso) - with self.assertRaisesRegex(ValueError, 'regular'): - self.prepare() - self.assertFalse(self.output.exists()) - - def test_checksum_path_escape_is_rejected(self): - (self.signed / 'SHA256SUMS').write_text(f'{release.sha256(self.iso)} ../artifacts/{self.iso.name}\n') - with self.assertRaisesRegex(ValueError, 'Unsafe'): - self.prepare() - - def test_invalid_signature_never_publishes(self): - with patch.object(release, 'verify_release', side_effect=subprocess.CalledProcessError(1, ['gpg'])): - with self.assertRaises(subprocess.CalledProcessError): - release.prepare(self.signed, self.artifacts, self.output, REPOSITORY, TAG, FINGERPRINT, self.reports) - self.assertFalse(self.output.exists()) - self.assertFalse(list(self.root.glob('.cybexos-github-*'))) - - def test_asset_limit_is_enforced_before_signatures_or_copying(self): - with patch.object(release, 'ASSET_LIMIT', self.rpm.stat().st_size): - with self.assertRaisesRegex(ValueError, '2 GiB'): - self.prepare() - self.assertFalse(self.output.exists()) - - def test_release_tag_and_pages_channel_must_match(self): - self.package['version'] = '1.2.4' - self.save_manifest() - with self.assertRaisesRegex(ValueError, 'release tag'): - self.prepare() - self.package['version'] = '1.2.3' - self.manifest['baseurl'] = 'https://unrelated.example/repo' - self.save_manifest() - with self.assertRaisesRegex(ValueError, 'GitHub Pages'): - self.prepare() - - def test_authenticated_metadata_must_reference_the_exact_uploaded_rpm(self): - self.metadata(package_url='https://unrelated.example/package.rpm') - with self.assertRaisesRegex(ValueError, 'package URL'): - self.prepare() - self.assertFalse(self.output.exists()) - - def test_repodata_hash_is_verified_independently_of_checksum_inventory(self): - (self.signed / 'repodata/primary.xml.gz').write_bytes(b'corrupt metadata') - checksums(self.signed) - with self.assertRaisesRegex(ValueError, 'metadata checksum'): - self.prepare() - - def test_missing_fresh_scenario_and_wrong_iso_are_blocked(self): - self.reports.pop(0) - with self.assertRaisesRegex(ValueError, 'four fresh'): - self.prepare() - self.report('encrypted-us', 'd' * 64, ['graphical-installer']) - with self.assertRaisesRegex(ValueError, 'four fresh'): - self.prepare() - - def test_upgrade_requires_different_iso_exact_candidate_and_recovery(self): - upgrade = self.reports[-1] - base = json.loads(upgrade.read_text()) - for changes in ({'iso_sha256': release.sha256(self.iso)}, - {'candidate_rpm_sha256': 'd' * 64}, - {'checks': ['installed-rpm-upgrade']}): - upgrade.write_text(json.dumps({**base, **changes})) - with self.assertRaisesRegex(ValueError, 'prior ISO|required scenario evidence'): - self.prepare() - upgrade.write_text(json.dumps({**base, 'status': 'failed'})) - with self.assertRaisesRegex(ValueError, 'must have passed'): - self.prepare() - - def test_every_mandatory_scenario_check_is_required_by_release_admission(self): - for path in self.reports: - original = json.loads(path.read_text()) - mandatory = required_checks(original['scenario'], original['qualification_kind']) - for check in sorted(mandatory): - with self.subTest(report=path.name, omitted=check): - report = {**original, 'checks': [item for item in original['checks'] if item != check]} - path.write_text(json.dumps(report)) - with self.assertRaisesRegex(ValueError, 'required scenario evidence'): - self.prepare() - self.assertFalse(self.output.exists()) - path.write_text(json.dumps(original)) - - def test_execution_and_schema_omissions_cannot_claim_passed_guest_evidence(self): - path = self.reports[0] - original = json.loads(path.read_text()) - for key in ('evidence_schema', 'execution_mode', 'qualification_kind', 'firmware', - 'boot_entry', 'network', 'legacy_installer', 'source_revision', 'source_content_sha256'): - for replacement in (None, 'fixture', True): - with self.subTest(key=key, replacement=replacement): - report = {**original, key: replacement} - if replacement is None: - report.pop(key) - path.write_text(json.dumps(report)) - with self.assertRaises(ValueError): - self.prepare() - self.assertFalse(self.output.exists()) - path.write_text(json.dumps(original)) - - def test_candidate_source_binding_cannot_disagree_between_scenarios(self): - path = self.reports[-1] - report = json.loads(path.read_text()) - report['source_revision'] = 'e' * 40 - path.write_text(json.dumps(report)) - with self.assertRaisesRegex(ValueError, 'same candidate source'): - self.prepare() - - def test_signed_verification_uses_the_expected_key_and_both_signatures(self): - work = self.root / 'verify' - work.mkdir() - with patch.object(release, 'public_key') as key, patch.object(release.subprocess, 'run') as run: - release.verify_release(self.signed, FINGERPRINT, work) - key.assert_called_once_with(self.signed / 'CYBEXOS-desktop.asc', FINGERPRINT) - self.assertEqual(len(run.call_args_list), 3) - signatures = [call.args[0] for call in run.call_args_list[1:]] - self.assertTrue(all('--verify' in command for command in signatures)) - self.assertIn(str(self.signed / 'release.json.asc'), signatures[0]) - self.assertIn(str(self.signed / 'repodata/repomd.xml.asc'), signatures[1]) - - -class IsoReconstruction(unittest.TestCase): - def test_chunks_reconstruct_exact_bytes_and_preserve_existing_output(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source, assets = root / 'CybexOS.iso', root / 'assets' - assets.mkdir() - source.write_bytes(bytes(range(256)) * 17) - record = release.split_iso(source, assets, chunk_size=127) - self.assertTrue(all(part['bytes'] <= 127 for part in record['parts'])) - manifest = assets / 'CybexOS.iso.parts.json' - result = reconstruct.reconstruct(manifest) - self.assertEqual(result.read_bytes(), source.read_bytes()) - self.assertEqual(release.sha256(result), record['sha256']) - with self.assertRaisesRegex(ValueError, 'already exists'): - reconstruct.reconstruct(manifest) - self.assertFalse(list(assets.glob('.cybexos-iso-*'))) - - def test_corruption_missing_parts_and_path_traversal_leave_no_partial_iso(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source, assets = root / 'CybexOS.iso', root / 'assets' - assets.mkdir() - source.write_bytes(b'123456789') - release.split_iso(source, assets, chunk_size=3) - manifest = assets / 'CybexOS.iso.parts.json' - record = json.loads(manifest.read_text()) - part = assets / record['parts'][0]['file'] - part.write_bytes(b'bad') - with self.assertRaisesRegex(ValueError, 'checksum'): - reconstruct.reconstruct(manifest) - part.unlink() - with self.assertRaisesRegex(ValueError, 'regular'): - reconstruct.reconstruct(manifest) - record['parts'][0]['file'] = '../outside' - manifest.write_text(json.dumps(record)) - with self.assertRaisesRegex(ValueError, 'Invalid'): - reconstruct.reconstruct(manifest) - self.assertFalse((assets / 'CybexOS.iso').exists()) - self.assertFalse(list(assets.glob('.cybexos-iso-*'))) - - def test_invalid_chunk_sizes_fail_before_creating_parts(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source = root / 'CybexOS.iso' - source.write_bytes(b'image') - for size in (0, -1, release.ASSET_LIMIT): - with self.assertRaisesRegex(ValueError, 'part size'): - release.split_iso(source, root, size) - self.assertEqual(list(root.iterdir()), [source]) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_installation_parity.py b/image/test_installation_parity.py deleted file mode 100644 index b2cabe1a..00000000 --- a/image/test_installation_parity.py +++ /dev/null @@ -1,714 +0,0 @@ -"""Compare real checkout configuration with the ISO's installed policy.""" -import json -import os -from pathlib import Path -import pty -import shlex -import subprocess -import unittest - -import yaml - -from test_reconfigure import Fixture, ROOT, config, target - - -class InstallationParity(Fixture): - def setUp(self): - super().setUp() - self.home = self.root / 'home/alice' - self.home.mkdir(parents=True) - self.bin = self.root / 'bin' - self.bin.mkdir() - self.installer = self.root / 'install' - self.installer.write_text((ROOT / 'install').read_text().replace( - 'repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)', - 'repo_dir=' + shlex.quote(str(ROOT)), - ).replace('[[ -r /etc/fedora-release ]]', 'true')) - for name, body in { - 'getent': f"printf '%s\\n' 'alice:x:1001:1001::{self.home}:/bin/bash'", - 'id': "printf '%s\\n' alice", - 'hostnamectl': "printf '%s\\n' studio", - 'timedatectl': "printf '%s\\n' Europe/Amsterdam", - 'locale': "printf '%s\\n' LANG=nl_NL.UTF-8", - 'localectl': 'echo "unsupported installer probe: localectl $*" >&2; exit 98', - 'sudo': 'echo "--check must never elevate or install" >&2; exit 97', - }.items(): - self.command(name, body) - self.environment = dict(os.environ, HOME=str(self.home), SUDO_USER='alice', - CYBEXOS_CONFIG_FILE=str(self.path), - PATH=f"{self.bin}:{os.environ['PATH']}", PYTHONDONTWRITEBYTECODE='1') - self.keyboard('us', 'dvorak') - - def command(self, name, body): - path = self.bin / name - path.write_text('#!/bin/sh\n' + body + '\n') - path.chmod(0o755) - - def keyboard(self, layout, variant): - # busctl get-property emits one typed JSON object per requested - # property, with a scalar string in data (not the call command's array). - response = '\n'.join(json.dumps({'type': 's', 'data': value}) - for value in (layout, variant)) - arguments = ('--system --json=short get-property ' - 'org.freedesktop.locale1 /org/freedesktop/locale1 ' - 'org.freedesktop.locale1 X11Layout X11Variant') - self.discovery_log = self.root / 'keyboard-discovery.log' - self.command('busctl', - 'printf "%s\\n" "$*" >> ' + shlex.quote(str(self.discovery_log)) + '\n' - '[ "$*" = ' + shlex.quote(arguments) - + ' ] || { echo "unsupported busctl arguments: $*" >&2; exit 98; }\n' - 'printf "%s\\n" ' + shlex.quote(response)) - - def encryption(self, encrypted, *, mixed=False, fail=False): - self.command('findmnt', "printf '%s\\n' " + shlex.quote(json.dumps({'filesystems': [ - {'source': '/dev/mapper/root[/root]', 'target': '/', 'fstype': 'btrfs'}]}))) - self.command('btrfs', 'exit 1' if fail else "cat <<'EOF'\n" - "Label: none uuid: 01234567-89ab-cdef-0123-456789abcdef\n" - "\tTotal devices 2 FS bytes used 4096\n" - "\tdevid 1 size 65536 used 4096 path /dev/mapper/first\n" - "\tdevid 2 size 65536 used 4096 path /dev/mapper/second\nEOF") - first = {'blockdevices': [{'type': 'crypt' if encrypted else 'part'}]} - second = {'blockdevices': [{'type': 'part' if mixed or not encrypted else 'crypt'}]} - self.command('lsblk', 'case "$*" in\n*first) printf "%s\\n" ' + shlex.quote(json.dumps(first)) - + ';;\n*) printf "%s\\n" ' + shlex.quote(json.dumps(second)) + ';;\nesac') - - def test_both_paths_select_graphical_boot_without_changing_the_running_session(self): - tasks = yaml.safe_load((ROOT / 'roles/base/tasks/services.yml').read_text()) - selected = next(task for task in tasks if task.get('name') == 'Select graphical login for the next boot') - policy = selected['ansible.builtin.file'] - self.assertEqual(policy['path'], '/etc/systemd/system/default.target') - self.assertEqual(policy['src'], '/usr/lib/systemd/system/graphical.target') - self.assertIn('systemctl set-default graphical.target', (ROOT / 'image/live.ks').read_text()) - for path in ('roles/base/tasks/main.yml', 'roles/base/tasks/image.yml'): - self.assertIn('services.yml', (ROOT / path).read_text()) - # Run the actual shared Ansible module against only disposable paths. - units = self.root / 'units' - units.mkdir() - graphical, console = units / 'graphical.target', units / 'multi-user.target' - graphical.write_text('graphical fixture\n') - console.write_text('console fixture\n') - default = units / 'default.target' - default.symlink_to(console) - active = units / 'active.target' - active.symlink_to(console) - policy.update(path=str(default), src=str(graphical)) - playbook = self.root / 'graphical-boot.yml' - playbook.write_text(yaml.safe_dump([{'hosts': 'localhost', 'connection': 'local', - 'gather_facts': False, 'become': False, 'tasks': [selected]}])) - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - text=True, capture_output=True, timeout=60) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertEqual(default.resolve(), graphical) - self.assertEqual(active.resolve(), console, 'the running session is not isolated or restarted') - - def checkout(self, *extra, interactive=False, terminal=False, answers='', warning=None): - source = self.installer - if interactive and not terminal: - # Replace terminal detection only; feed the actual questionnaire. - source = self.root / 'interactive-install' - source.write_text(self.installer.read_text().replace( - '[[ $non_interactive == false && ! -t 0 ]]', 'false')) - command = ['bash', str(source), '--check', - *([] if interactive else ['--non-interactive']), *extra] - if terminal: - # Exercise the terminal guard and real read prompts unchanged. - master_fd, slave_fd = pty.openpty() - try: - os.write(master_fd, answers.encode()) - result = subprocess.run(command, stdin=slave_fd, env=self.environment, - text=True, capture_output=True, timeout=20) - finally: - os.close(master_fd) - os.close(slave_fd) - else: - result = subprocess.run(command, env=self.environment, input=answers, - text=True, capture_output=True, timeout=20) - self.assertEqual(result.returncode, 0, result.stderr) - if terminal: - self.assertIn('Keyboard variant', result.stderr) - self.assertNotIn('Invalid value:', result.stderr) - elif warning is None: - self.assertEqual(result.stderr, '') - else: - self.assertIn(warning, result.stderr) - return yaml.safe_load(result.stdout[result.stdout.index('---\n'):]) - - def installed_iso(self, encrypted): - self.path.unlink(missing_ok=True) - self.generate() - (self.root / 'etc/passwd').write_text('alice:x:1001:1001::/home/alice:/usr/bin/fish\n') - (self.root / 'etc/shadow').write_text('root:!:1::::::\n') - target.finalize(self.root, {'account': {'username': 'alice', 'encrypted': encrypted}}, encrypted) - value = yaml.safe_load(self.path.read_text()) - self.path.unlink() - # Same account-scoped policy; the checkout fixture's existing home is - # under its disposable root instead of the host's real /home/alice. - value['primary_home'] = str(self.home) - return value - - def test_fresh_interactive_and_noninteractive_defaults_match_iso(self): - for encrypted in (False, True): - with self.subTest(encrypted=encrypted): - self.encryption(encrypted) - expected = self.installed_iso(encrypted) - self.assertEqual(self.checkout(), expected) - self.assertEqual(self.checkout(interactive=True, answers='\n' * 30), expected) - self.assertIs(expected['passwordless_wheel'], False) - self.assertIs(expected['manage_personal_dotfiles'], True) - self.assertIs(expected['desktop_autologin'], encrypted) - self.assertFalse((self.root / 'etc/sudoers.d/10-wheel-nopasswd').exists()) - self.assertFalse(self.path.exists()) - - def test_supported_keyboard_discovery_matches_iso_and_preserves_variant_positions(self): - self.encryption(False) - for layout, variant in (('nl', ''), ('us', ''), ('us', 'dvorak'), - ('nl,us', ',dvorak'), ('us,nl', 'intl,'), - ('us,nl,de', ',,nodeadkeys'), ('', ''), ('', 'dvorak')): - with self.subTest(layout=layout, variant=variant): - self.keyboard(layout, variant) - (self.root / 'etc/vconsole.conf').write_text( - f'XKBLAYOUT="{layout}"\nXKBVARIANT="{variant}"\n') - expected = self.installed_iso(False) - self.assertEqual(self.checkout(), expected) - self.assertEqual(self.checkout(interactive=True, answers='\n' * 30), expected) - self.assertEqual(expected['machine_keyboard_layout'], layout or 'us') - self.assertEqual(expected['machine_keyboard_variant'], variant if layout else '') - - def test_keyboard_discovery_failure_is_reported_and_keeps_first_install_fallback(self): - self.encryption(False) - failures = ( - 'echo "Failed to connect to bus" >&2; exit 1', - 'echo "busctl: command not found" >&2; exit 127', - # A partial response must not mix a discovered layout with a - # variant obtained from an unsuccessful query. - 'printf \'%s\\n\' \'{"type":"s","data":"nl"}\'; exit 1', - 'exit 0', - 'printf \'%s\\n\' \'not JSON\'', - 'printf \'%s\\n\' \'{"type":"s","data":"nl"}\'', - 'printf \'%s\\n\' \'{"type":"s","data":["nl"]}\' \'{"type":"s","data":""}\'', - 'printf \'%s\\n\' \'{"type":"as","data":["nl"]}\' \'{"type":"s","data":""}\'', - 'printf \'%s\\n\' \'{"type":"s","data":"nl"}\' \'{"type":"s","data":"bad variant"}\'', - ) - for failure in failures: - with self.subTest(failure=failure): - self.command('busctl', failure) - value = self.checkout(warning='could not discover the system X11 keyboard') - self.assertEqual((value['machine_keyboard_layout'], value['machine_keyboard_variant']), - ('us', '')) - self.assertIs(value['manage_system_identity'], False) - - def test_interactive_layout_change_resets_the_detected_variant(self): - self.encryption(False) - for detected_layout, detected_variant, layout, answer, variant in ( - ('us', 'dvorak', 'nl', '', ''), - ('us,nl', 'dvorak,', 'nl,us', '', ''), - ('us,nl', 'dvorak,', 'us', '', ''), - ('us', 'dvorak', 'nl,us', ',dvorak', ',dvorak')): - with self.subTest(detected_layout=detected_layout, layout=layout, answer=answer): - self.keyboard(detected_layout, detected_variant) - value = self.checkout(interactive=True, terminal=True, - answers='\n' * 5 + layout + '\n' + answer + '\n' * 30) - self.assertEqual((value['machine_keyboard_layout'], value['machine_keyboard_variant']), - (layout, variant)) - self.assertIs(value['manage_system_identity'], True) - detected = self.checkout() - self.assertEqual((detected['machine_keyboard_layout'], detected['machine_keyboard_variant']), - (detected_layout, detected_variant)) - - def test_interactive_variant_can_be_cleared_without_changing_the_layout(self): - self.encryption(False) - for layout, variant in (('us', 'dvorak'), ('us,nl', 'dvorak,')): - with self.subTest(layout=layout, variant=variant): - self.keyboard(layout, variant) - # Keep the username, host, timezone, locales and keyboard - # layout, then explicitly clear the variant with '-'. - value = self.checkout(interactive=True, terminal=True, - answers='\n' * 6 + '-\n' + '\n' * 30) - self.assertEqual((value['machine_keyboard_layout'], value['machine_keyboard_variant']), - (layout, '')) - self.assertIs(value['manage_system_identity'], True) - kept = self.checkout(interactive=True, terminal=True, answers='\n' * 30) - self.assertEqual((kept['machine_keyboard_layout'], kept['machine_keyboard_variant']), - (layout, variant)) - self.assertIs(kept['manage_system_identity'], False) - - def test_saved_keyboard_choices_are_reused_without_discovery(self): - self.encryption(False) - saved = self.checkout() - saved.update(machine_keyboard_layout='nl,us', machine_keyboard_variant=',dvorak') - original = config.render(saved) - self.path.write_text(original) - self.discovery_log.unlink(missing_ok=True) - self.command('busctl', 'touch ' + shlex.quote(str(self.discovery_log)) + '; exit 1') - self.assertEqual(self.checkout(), saved) - self.assertEqual(self.path.read_text(), original) - self.assertFalse(self.discovery_log.exists(), 'saved choices need no system discovery') - - def test_generic_checkout_selects_the_isos_shared_hardware_packages(self): - from test_installed_policy import repair - applications = repair.load_applications() - main = yaml.safe_load((ROOT / 'roles/xps-2026/tasks/main.yml').read_text()) - imports = [dict(task) for task in main if task.get('ansible.builtin.import_tasks') == 'packages.yml'] - self.assertEqual(len(imports), 1) - package_tasks = yaml.safe_load((ROOT / 'roles/xps-2026/tasks/packages.yml').read_text()) - # Execute the actual import/feature conditions through Ansible. Replace - # only DNF's side effect with recording the requested package names. - for task in package_tasks: - selected = task.pop('ansible.builtin.dnf')['name'] - task['ansible.builtin.set_fact'] = { - 'selected_hardware_packages': '{{ selected_hardware_packages | default([]) + ' - + json.dumps(selected if isinstance(selected, list) else [selected]) + ' }}' - } - fixture = self.root / 'hardware-packages.yml' - fixture.write_text(yaml.safe_dump(package_tasks)) - imports[0]['ansible.builtin.import_tasks'] = str(fixture) - packages, _ = applications.package_names() - for proprietary in (True, False): - output = self.root / 'selected-hardware-packages.json' - play = self.root / 'generic-hardware.yml' - play.write_text(yaml.safe_dump([{ - 'hosts': 'localhost', 'gather_facts': False, - 'vars': {'xps_2026_is_supported': False, 'features': {'proprietary_apps': proprietary}}, - 'tasks': imports + [{'ansible.builtin.copy': { - 'dest': str(output), 'content': '{{ selected_hardware_packages | default([]) | to_json }}', - 'mode': '0600'}}], - }])) - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', '-c', 'local', str(play)], - capture_output=True, text=True, timeout=45) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - selected = set(json.loads(output.read_text())) - expected = {name for task in yaml.safe_load((ROOT / 'roles/xps-2026/tasks/packages.yml').read_text()) - if proprietary or 'features.proprietary_apps | bool' not in task.get('when', []) - for name in ([task['ansible.builtin.dnf']['name']] - if isinstance(task['ansible.builtin.dnf']['name'], str) - else task['ansible.builtin.dnf']['name'])} - self.assertEqual(selected, expected) - self.assertLessEqual(selected, set(packages)) - self.assertIn('alsa-sof-firmware', selected) - - def test_explicit_first_account_choices_match_on_both_paths(self): - # An unattended seed's `config` (or a factory reset's kept choices) - # reaches the ISO through install-choices.json; the checkout installer - # receives the same explicit choices as a saved configuration. Both go - # through image/installation_policy.py and must produce one outcome. - choices = {'manage_personal_dotfiles': False, 'passwordless_local_polkit': True, - 'features': {'steam': False, 'connected_widgets': False, 'local_network_services': True}} - pending = self.root / 'etc/cybexos/install-choices.json' - for encrypted in (False, True): - with self.subTest(encrypted=encrypted): - self.encryption(encrypted) - pending.write_text(json.dumps({'version': 1, 'choices': choices})) - iso = self.installed_iso(encrypted) - self.assertFalse(pending.exists(), 'recording config.yml consumes the choices') - # Reconfiguring applies saved choices over the shared defaults. - self.path.write_text(yaml.safe_dump(choices)) - checkout = self.checkout('--reconfigure') - self.path.unlink() - self.assertEqual(checkout, iso) - self.assertIs(iso['manage_personal_dotfiles'], False) - self.assertIs(iso['passwordless_local_polkit'], True) - self.assertIs(iso['features']['steam'], False) - self.assertIs(iso['features']['local_network_services'], True) - self.assertIs(iso['desktop_autologin'], encrypted) - - def test_first_account_choices_are_validated_by_the_shared_policy(self): - from installation_policy import apply_choices, choices - self.encryption(False) - pending = self.root / 'etc/cybexos/install-choices.json' - for invalid in ({'desktop_autologin': True}, {'passwordless_wheel': True}, - {'features': {'unknown': True}}, {'docker_sudoless': 'yes'}): - with self.subTest(invalid=invalid): - pending.write_text(json.dumps({'version': 1, 'choices': invalid})) - self.path.unlink(missing_ok=True) - with self.assertRaises(ValueError): - self.generate() - with self.assertRaises(ValueError): - choices(invalid) - pending.unlink() - defaults = config.installation_defaults(Path(ROOT / 'inventory/group_vars/all.yml')) - # Sudoless Docker is root-equivalent and exists only with Docker selected. - applied = apply_choices(defaults, {'docker_sudoless': True, 'features': {'docker': False}}) - self.assertIs(applied['docker_sudoless'], False) - self.assertIs(defaults['features']['docker'], True, 'the defaults are not modified') - - def test_existing_personal_files_do_not_select_another_product_default(self): - self.encryption(True) - (self.home / '.gitconfig').write_text('[user]\nname = Personal\n') - value = self.checkout(interactive=True, answers='\n' * 30) - self.assertIs(value['manage_personal_dotfiles'], True) - self.assertEqual((self.home / '.gitconfig').read_text(), '[user]\nname = Personal\n') - - def test_mixed_or_unverifiable_btrfs_never_selects_autologin(self): - for options in ({'mixed': True}, {'fail': True}): - with self.subTest(options=options): - self.encryption(True, **options) - self.assertIs(self.checkout()['desktop_autologin'], False) - - def test_saved_choices_survive_reuse_and_reconfiguration_defaults(self): - self.encryption(True) - saved = self.checkout() - saved.update(manage_personal_dotfiles=False, desktop_autologin=False, passwordless_wheel=True) - saved['features'].update(steam=False, connected_widgets=False, podman=False, source_builds=False) - original = config.render(saved) - self.path.write_text(original) - self.assertEqual(self.checkout(), saved) - self.assertEqual(self.checkout('--reconfigure'), saved) - self.assertEqual(self.checkout('--reconfigure', interactive=True, answers='\n' * 30), saved) - self.assertEqual(self.path.read_text(), original) - - def test_source_personal_seeding_preserves_edits_like_iso_seeding(self): - from test_desktop_payload import INIT - selected = {'Install application configuration', 'Install Voxtype configuration', - 'Install Oh My Posh theme from the active configuration', - 'Install feature-aware MIME defaults', 'Configure npm user prefix'} - tasks = [task for task in yaml.safe_load((ROOT / 'roles/dotfiles/tasks/main.yml').read_text()) - if task.get('name') in selected] - self.assertEqual(len(tasks), len(selected)) - for task in tasks: - task['become'] = False - task.pop('notify', None) - options = task.get('ansible.builtin.copy') or task['ansible.builtin.template'] - if 'src' in options and not options['src'].startswith('{{ config_repo }}'): - directory = 'templates' if 'ansible.builtin.template' in task else 'files' - options['src'] = str(ROOT / 'roles/dotfiles' / directory) + '/' + options['src'] - paths = ('.config/fastfetch/config.jsonc', '.config/voxtype/config.toml', - '.config/oh-my-posh/EDM115-newline2.omp.json', '.config/mimeapps.list', '.npmrc') - for relative in paths: - (self.home / relative).parent.mkdir(parents=True, exist_ok=True) - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - playbook = self.root / 'personal-seed.yml' - playbook.write_text(yaml.safe_dump([{ - 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, - 'vars': {'primary_home': str(self.home), 'primary_user': 'fixture', - 'config_repo': str(ROOT), 'manage_personal_dotfiles': True, - 'features': inventory['features']}, 'tasks': tasks, - }])) - - def apply(): - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - env=os.environ, text=True, capture_output=True, timeout=30) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - apply() - # Exercise the ISO's actual merge operation with the freshly rendered - # shared defaults, then edit both accounts before the next application. - seed = self.root / 'seed' - other = self.root / 'iso-account' - for relative in paths: - source = self.home / relative - (seed / relative).parent.mkdir(parents=True, exist_ok=True) - (seed / relative).write_bytes(source.read_bytes()) - INIT.seed_applications(seed, other) - for relative in paths: - self.assertEqual((self.home / relative).read_bytes(), (other / relative).read_bytes()) - for home in (self.home, other): - (home / relative).write_text('Personal configuration survives upgrades\n') - apply() - INIT.seed_applications(seed, other) - for relative in paths: - self.assertEqual((self.home / relative).read_text(), 'Personal configuration survives upgrades\n') - self.assertEqual((self.home / relative).read_bytes(), (other / relative).read_bytes()) - - def test_packaged_policy_is_the_same_source_used_by_checkout(self): - from installation_policy import defaults - import tempfile - from unittest.mock import patch - from test_installed_policy import repair - with tempfile.TemporaryDirectory() as directory: - payload = Path(directory) - repair.prepare(payload) - bundled = payload / 'usr/share/cybexos/lib/installation_policy.py' - self.assertEqual(bundled.read_bytes(), (ROOT / 'image/installation_policy.py').read_bytes()) - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - inventory['features']['new_unhandled_choice'] = True - with patch.object(Path, 'read_text', return_value=yaml.safe_dump(inventory)): - with self.assertRaisesRegex(ValueError, 'both installer schemas'): - defaults(Path('fixture')) - - def test_chatgpt_launcher_matches_checkout_and_iso_at_native_display_scale(self): - from desktop_payload import prepare_app_launchers - from test_desktop_payload import INIT - - launcher = '.local/share/applications/chatgpt.desktop' - (self.home / launcher).parent.mkdir(parents=True) - task = next(task for task in yaml.safe_load( - (ROOT / 'roles/dotfiles/tasks/main.yml').read_text()) - if task.get('name') == 'Install MIME defaults and desktop launchers') - task['become'] = False - task['loop'] = [item for item in task['loop'] if item['name'] == 'chatgpt.desktop'] - task['ansible.builtin.copy']['src'] = str(ROOT / 'roles/dotfiles/files') + '/{{ item.name }}' - playbook = self.root / 'chatgpt-launcher.yml' - playbook.write_text(yaml.safe_dump([{ - 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, - 'vars': {'primary_home': str(self.home), 'primary_user': 'fixture', - 'features': {'proprietary_apps': True}}, - 'tasks': [task], - }])) - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - env=os.environ, text=True, capture_output=True, timeout=30) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - payload = self.root / 'payload' - prepare_app_launchers(ROOT, payload) - iso_home = self.root / 'iso-account' - INIT.seed_applications(payload / 'usr/share/cybexos/user-seed', iso_home) - self.assertEqual((self.home / launcher).read_bytes(), (iso_home / launcher).read_bytes()) - self.assertIn('Exec=chatgpt --ozone-platform=wayland %U', - (iso_home / launcher).read_text().splitlines()) - - def test_default_web_apps_are_seeded_alike_and_a_removed_one_stays_removed(self): - import shutil - from unittest.mock import patch - from test_desktop_payload import INIT - from test_installed_policy import repair - - # Both packaging paths ship every contract helper as the ISO's - # /usr/share/cybexos/bin; the checkout installs it as ~/.local/bin. - contract = json.loads((ROOT / 'assets/desktop-contract.json').read_text()) - self.assertIn('cybexos-webapp', contract['userHelpers']) - self.assertIn('helpers = contract["userHelpers"]', (ROOT / 'image/package').read_text()) - - # Checkout: the dotfiles role's own task, run as the account. - task = next(task for task in yaml.safe_load((ROOT / 'roles/dotfiles/tasks/main.yml').read_text()) - if task.get('name') == 'Seed the default web apps once') - task['become'] = False - task.pop('become_user') - helper = self.home / '.local/bin/cybexos-webapp' - helper.parent.mkdir(parents=True) - shutil.copy2(ROOT / 'assets/scripts/cybexos-webapp', helper) - playbook = self.root / 'webapps.yml' - playbook.write_text(yaml.safe_dump([{ - 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, - 'vars': {'primary_home': str(self.home), 'primary_user': 'fixture'}, 'tasks': [task], - }])) - environment = {key: value for key, value in os.environ.items() if not key.startswith('XDG_')} - - def checkout(): - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - env=environment, text=True, capture_output=True, timeout=60) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - # ISO: the packaged helper, seeded by cybexos-user-init at login. - payload = self.root / 'payload' - repair.prepare(payload) - vendor = payload / 'usr/share/cybexos' - iso_home = self.root / 'home/iso' - iso_home.mkdir(parents=True) - - def iso(): - with patch.dict(os.environ, {'HOME': str(iso_home), 'PATH': os.environ['PATH']}, clear=True): - INIT.initialize(iso_home, vendor, mode='essential') - - def outcome(home): - files = {} - roots = [home / '.local/share/cybexos/webapps', home / '.local/share/applications'] - for path in sorted(path for root in roots for path in root.rglob('*')): - if path.is_file() and path.name != '.lock' and ( - path.parent.name != 'applications' or path.name.startswith('cybexos-webapp-')): - files[str(path.relative_to(home))] = path.read_bytes().replace( - str(home).encode(), b'HOME') - return files - - def remove(home): - subprocess.run([str(home / '.local/bin/cybexos-webapp'), 'remove', 'youtube'], - env={**environment, 'HOME': str(home)}, check=True, - capture_output=True, timeout=30) - - checkout() - iso() - seeded = outcome(self.home) - self.assertEqual(seeded, outcome(iso_home)) - for identifier in ('slack', 'whatsapp', 'youtube', 'google-photos', 'x'): - self.assertIn(f'.local/share/applications/cybexos-webapp-{identifier}.desktop', seeded) - self.assertIn(b'Exec=HOME/.local/bin/cybexos-webapp launch slack', - seeded['.local/share/applications/cybexos-webapp-slack.desktop']) - - # Removing a default is a choice both paths keep across updates. - for home in (self.home, iso_home): - remove(home) - checkout() - iso() - kept = outcome(self.home) - self.assertEqual(kept, outcome(iso_home)) - self.assertNotIn('.local/share/applications/cybexos-webapp-youtube.desktop', kept) - self.assertIn('.local/share/applications/cybexos-webapp-slack.desktop', kept) - - def test_a_failing_web_app_seed_never_blocks_account_initialization(self): - import contextlib - import io - from unittest.mock import patch - from test_desktop_payload import INIT - - vendor = self.root / 'vendor' - (vendor / 'bin').mkdir(parents=True) - (vendor / 'bin/cybexos-webapp').write_text('raise RuntimeError("broken helper")\n') - home = self.root / 'home/broken' - home.mkdir(parents=True) - errors = io.StringIO() - with patch.dict(os.environ, {'HOME': str(home), 'PATH': os.environ['PATH']}, clear=True), \ - contextlib.redirect_stderr(errors): - INIT.initialize(home, vendor, mode='essential') - self.assertIn('default web apps were not seeded: broken helper', errors.getvalue()) - self.assertTrue((home / '.local/bin/cybexos-webapp').is_symlink()) - - def test_firewall_zone_matches_checkout_and_iso_for_each_network_choice(self): - from xml.etree import ElementTree - import jinja2 - from desktop_payload import prepare_defaults - - def outcome(path): - zone = ElementTree.parse(path).getroot() - return (zone.get('target'), - sorted((port.get('port'), port.get('protocol')) for port in zone.iter('port')), - sorted(interface.get('name') for interface in zone.iter('interface'))) - - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - sources = {'checkout': ('roles/base/tasks/main.yml', 'Configure strict firewalld zone'), - 'iso': ('roles/base/tasks/image.yml', 'Apply the shared workstation firewall')} - plays, expected = [], {} - for enabled in (False, True): - features = {**inventory['features'], 'local_network_services': enabled} - tasks = [] - for path, name in sources.values(): - task = next(task for task in yaml.safe_load((ROOT / path).read_text()) - if task.get('name') == name) - for key in ('notify', 'register'): - task.pop(key, None) - template = task['ansible.builtin.template'] - template.pop('owner') - template.pop('group') - template['src'] = str(ROOT / 'roles/base/templates' / template['src']) - template['dest'] = str(self.root / f'{Path(path).stem}-{enabled}.xml') - tasks.append(task) - plays.append({'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, 'become': False, - 'vars': {'features': features, 'firewall_zone': inventory['firewall_zone'], - 'firewall_ports': inventory['firewall_ports']}, - 'tasks': tasks}) - # The ISO package renders the same template into its RPM payload. - environment = jinja2.Environment(undefined=jinja2.StrictUndefined, keep_trailing_newline=True) - environment.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no) - payload = self.root / f'payload-{enabled}' - prepare_defaults(ROOT, payload, environment, {**inventory, 'features': features}) - expected[enabled] = outcome(payload / 'usr/lib/firewalld/zones/cybexos.xml') - playbook = self.root / 'firewall-zone.yml' - playbook.write_text(yaml.safe_dump(plays)) - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - env=os.environ, text=True, capture_output=True, timeout=60) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - for enabled, packaged in expected.items(): - for path, _ in sources.values(): - self.assertEqual(outcome(self.root / f'{Path(path).stem}-{enabled}.xml'), packaged, (path, enabled)) - target, ports, _ = packaged - self.assertEqual(target, 'DROP') - # Printer and scanner discovery needs mDNS replies on every install. - self.assertIn(('5353', 'udp'), ports) - self.assertIn(('53317', 'tcp'), ports) - self.assertEqual(('27015', 'tcp') in ports, enabled) - - def test_hotspot_profiles_keep_networkmanager_shared_zone_on_both_paths(self): - """The Settings hotspot serves DHCP and DNS from NetworkManager's - nm-shared zone. Both paths ship the same helper that creates it there; - the checkout's zone adoption must skip it, and the ISO never rebinds - profiles, so its clients get addresses on either installation.""" - import importlib.util - import jinja2 - - spec = importlib.util.spec_from_file_location( - 'parity_network_tool', ROOT / 'roles/desktop/files/quickshell/scripts/network-tool.py') - network_tool = importlib.util.module_from_spec(spec) - spec.loader.exec_module(network_tool) - settings = dict(zip(network_tool.HOTSPOT_SETTINGS[::2], network_tool.HOTSPOT_SETTINGS[1::2])) - self.assertEqual((settings['ipv4.method'], settings['connection.zone'], settings['connection.autoconnect']), - ('shared', 'nm-shared', 'no')) - - uuids = {'wired': '0b1c2d3e-0000-4000-8000-000000000001', 'wifi': '0b1c2d3e-0000-4000-8000-000000000002', - 'hotspot': network_tool.HOTSPOT_UUID, 'vpn': '0b1c2d3e-0000-4000-8000-000000000004'} - self.command('nmcli', '\n'.join([ - 'case "$*" in', - f" '-t -f UUID,TYPE connection show') printf '%s\\n' {uuids['wired']}:802-3-ethernet " - f"{uuids['wifi']}:802-11-wireless {uuids['hotspot']}:802-11-wireless {uuids['vpn']}:vpn ;;", - f" '-g ipv4.method connection show {uuids['hotspot']}') echo shared ;;", - " '-g ipv4.method connection show '*) echo auto ;;", - f" '-g connection.zone connection show {uuids['wired']}') echo public ;;", - f" '-g connection.zone connection show {uuids['wifi']}') echo cybexos ;;", - f" '-g connection.zone connection show {uuids['hotspot']}') echo nm-shared ;;", - 'esac'])) - tasks = yaml.safe_load((ROOT / 'roles/base/tasks/main.yml').read_text()) - task = next(task for task in tasks if task.get('name') == 'Find Ethernet and Wi-Fi profiles outside the strict zone') - environment = jinja2.Environment() - environment.filters['quote'] = shlex.quote - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - script = environment.from_string(task['ansible.builtin.shell']).render(firewall_zone=inventory['firewall_zone']) - result = subprocess.run(['bash', '-c', script], env=self.environment, text=True, capture_output=True, timeout=20) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.split(), [uuids['wired']], - 'only the physical profile outside the strict zone is adopted; the hotspot keeps nm-shared') - - iso_sources = [path for path in (ROOT / 'image').rglob('*') - if path.is_file() and '__pycache__' not in path.parts - and not path.name.startswith('test_') and path.suffix != '.md'] - iso_sources += [ROOT / 'roles/base/tasks/image.yml', ROOT / 'roles/base/tasks/reconcile.yml'] - rebinding = [str(path.relative_to(ROOT)) for path in iso_sources - if 'connection.zone' in path.read_text(errors='replace')] - self.assertEqual(rebinding, [], 'the ISO path leaves NetworkManager profile zones to NetworkManager') - - def test_sign_in_policy_matches_checkout_and_iso_from_their_starting_points(self): - """Same authselect features and PAM files after either installation path.""" - import importlib.machinery - import importlib.util - loader = importlib.machinery.SourceFileLoader( - 'cybexos_auth_policy', str(ROOT / 'roles/base/files/cybexos-auth-policy')) - spec = importlib.util.spec_from_loader(loader.name, loader) - policy = importlib.util.module_from_spec(spec) - loader.exec_module(policy) - - class Authselect: - def __init__(self, features): - self.features = list(features) - - def __call__(self, argv): - if argv[0] == 'restorecon': - return 0, '', '' - if argv[1] == 'current': - return 0, 'sssd ' + ' '.join(self.features), '' - if argv[1] == 'list-features': - return 0, 'with-fingerprint\nwith-mdns4\nwith-pam-u2f\n', '' - if argv[1] == 'enable-feature': - self.features += [] if argv[2] in self.features else [argv[2]] - return 0, '', '' - raise AssertionError(argv) - - sudo = ('#%PAM-1.0\nauth include system-auth\naccount include system-auth\n' - 'password include system-auth\nsession include system-auth\n') - kickstart = next(line for line in (ROOT / 'image/live.ks').read_text().splitlines() - if line.startswith('authselect select ')) - starts = { - # A Fedora workstation before CybexOS, after the base role's mDNS step. - 'checkout': ['with-silent-lastlog', 'with-mdns4'], - # The ISO's kickstart selection, which installations inherit. - 'iso': [word for word in kickstart.split()[3:] if word.startswith('with')], - } - outcomes = {} - for name, features in starts.items(): - root = self.root / ('sign-in-' + name) - (root / 'etc/pam.d').mkdir(parents=True) - (root / 'etc/pam.d/sudo').write_text(sudo) - (root / 'usr/lib/pam.d').mkdir(parents=True) - (root / 'usr/lib/pam.d/polkit-1').write_text(policy.POLKIT_VENDOR_DEFAULT) - (root / policy.FPRINTD_MODULE).parent.mkdir(parents=True) - (root / policy.FPRINTD_MODULE).write_text('fixture') - authselect = Authselect(features) - policy.Policy(root, authselect).apply() - outcomes[name] = (sorted(authselect.features), - {path.name: path.read_text() for path in sorted((root / 'etc/pam.d').iterdir())}) - self.assertEqual(outcomes['checkout'], outcomes['iso']) - features, files = outcomes['iso'] - self.assertIn('with-fingerprint', features) - self.assertFalse(any(feature.startswith('with-pam-u2f') for feature in features), - 'a profile-wide u2f feature would let a key replace the SDDM password') - self.assertEqual(sorted(files), ['cybexos-lock', 'polkit-1', 'sudo']) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_installed_outcomes.py b/image/test_installed_outcomes.py deleted file mode 100644 index 0b2583fd..00000000 --- a/image/test_installed_outcomes.py +++ /dev/null @@ -1,278 +0,0 @@ -"""Fixtures for the real VM gate; these deliberately do not claim VM execution.""" -import importlib.machinery -import importlib.util -import io -import json -import re -from pathlib import Path -import subprocess -import tarfile -import tempfile -import unittest -from unittest.mock import Mock, patch - -import installed_outcomes as outcomes -import parity_qualification as parity -import source_snapshot as source - -loader = importlib.machinery.SourceFileLoader('checkout_qualification', str(Path(__file__).with_name('qualify-checkout'))) -spec = importlib.util.spec_from_loader(loader.name, loader) -checkout_runner = importlib.util.module_from_spec(spec) -loader.exec_module(checkout_runner) - - -def fixture(installation='iso'): - result = {key: {'fixture': True} for key in outcomes.CAPTURE_FIELDS} - result.update(format=1, installation=installation, scenario='plain-us', profile='fresh', - source_revision='a' * 40, source_content_sha256='b' * 64, hardware={'product_name': 'QEMU'}, - graphical_session=True, sole_managed_shell=True, installed_packages=['quickshell.x86_64=1'], - required_packages={'quickshell': ['quickshell.x86_64=1']}, flatpaks=[], selinux='Enforcing', boot_target='graphical.target') - return result - - -class FlatpakColumns(unittest.TestCase): - def test_capture_asks_only_for_columns_flatpak_118_accepts(self): - """Fedora 44's flatpak 1.18 exits "Unknown column: commit"; the - deployed commit is the `active` column (seen in a 2026-10-14 ISO run).""" - accepted = {'name', 'description', 'application', 'version', 'branch', 'arch', 'runtime', - 'origin', 'installation', 'ref', 'active', 'latest', 'size', 'options', 'all'} - source = Path(outcomes.__file__).read_text() - requested = re.findall(r"'--columns=([a-z,]+)'", source) - self.assertTrue(requested) - for columns in requested: - self.assertLessEqual(set(columns.split(',')), accepted, columns) - - -class OutcomeComparison(unittest.TestCase): - def test_session_target_links_and_user_enablement_have_equivalent_activation(self): - dependencies = {'quickshell.service', 'hypridle.service'} - self.assertEqual(outcomes.effective_unit_state('quickshell.service', 'disabled', 'active', dependencies), - outcomes.effective_unit_state('quickshell.service', 'enabled', 'active', dependencies)) - active = outcomes.effective_unit_state('quickshell.service', 'enabled', 'active', dependencies) - self.assertNotEqual(active, outcomes.effective_unit_state('quickshell.service', 'enabled', 'inactive', dependencies)) - self.assertNotEqual(active, outcomes.effective_unit_state('quickshell.service', 'disabled', 'active', set())) - for invalid in ('masked', 'masked-runtime', 'not-found', 'bad'): - self.assertNotEqual(active, outcomes.effective_unit_state('quickshell.service', invalid, 'active', dependencies)) - self.assertNotEqual(outcomes.effective_unit_state('voxtype.service', 'enabled', 'active', dependencies), - outcomes.effective_unit_state('voxtype.service', 'disabled', 'active', dependencies)) - - def test_only_utc_alias_is_normalized_and_saved_choices_are_not_modified(self): - selected = {'machine_timezone': 'UTC', 'passwordless_wheel': False, 'features': {'steam': False}} - original = dict(selected) - self.assertEqual(outcomes.policy_choices(selected), outcomes.policy_choices({**selected, 'machine_timezone': 'Etc/UTC'})) - self.assertNotEqual(outcomes.policy_choices(selected), outcomes.policy_choices({**selected, 'machine_timezone': 'Europe/Amsterdam'})) - self.assertEqual(selected, original) - self.assertIs(outcomes.policy_choices(selected)['passwordless_wheel'], False) - self.assertEqual(outcomes.policy_choices(selected)['features'], {'steam': False}) - - def test_equal_real_capture_shapes_pass_without_implicit_exclusions(self): - result = outcomes.compare(fixture(), fixture('checkout'), {}) - self.assertEqual(result['status'], 'passed') - self.assertEqual(result['differences'], {}) - - def test_policy_or_effective_behavior_difference_blocks_release(self): - for key in ('settings', 'input', 'choices', 'authentication', 'firewall', 'system_units', - 'user_units', 'recovery', 'default_apps', 'required_packages', 'boot_target'): - changed = fixture('checkout') - if isinstance(changed[key], dict): - changed[key]['difference'] = False - else: - changed[key] = 'rescue.target' - with self.subTest(key=key): - result = outcomes.compare(fixture(), changed, {}) - self.assertEqual(result['status'], 'failed') - self.assertIn(key, result['differences']) - - def test_different_source_hardware_or_profile_cannot_be_compared(self): - for key, value in (('source_content_sha256', 'c' * 64), ('source_revision', 'd' * 40), - ('hardware', {'product_name': 'different'}), ('profile', 'saved'), ('scenario', 'plain-nl')): - changed = fixture('checkout') - changed[key] = value - with self.subTest(key=key), self.assertRaisesRegex(ValueError, 'mismatched ' + key): - outcomes.compare(fixture(), changed, {}) - - def test_sparse_fixture_or_no_live_session_is_not_installed_evidence(self): - for key in outcomes.CAPTURE_FIELDS: - changed = fixture('checkout') - del changed[key] - with self.subTest(key=key), self.assertRaises(ValueError): - outcomes.compare(fixture(), changed, {}) - changed = fixture('checkout') - changed['graphical_session'] = False - with self.assertRaisesRegex(ValueError, 'graphical session'): - outcomes.compare(fixture(), changed, {}) - - def test_extra_packages_require_explicit_reason_without_masking_required_versions(self): - changed = fixture('checkout') - changed['installed_packages'].append('cloud-init.noarch=1') - result = outcomes.compare(fixture(), changed, {}) - self.assertEqual(result['status'], 'failed') - self.assertEqual(result['package_delta']['checkout']['unreviewed'], ['cloud-init.noarch=1']) - self.assertNotIn('package-delta-reviewed', result['checks']) - exceptions = {'checkout': {'cloud-init.noarch': 'Pinned cloud test bootstrap dependency'}} - self.assertEqual(outcomes.compare(fixture(), changed, exceptions)['status'], 'passed') - changed['required_packages']['cloud-init'] = ['cloud-init.noarch=1'] - self.assertEqual(outcomes.compare(fixture(), changed, exceptions)['status'], 'failed') - with self.assertRaisesRegex(ValueError, 'review reason'): - outcomes.compare(fixture(), changed, {'checkout': {'cloud-init.noarch': ''}}) - - def test_normalization_only_unifies_ownership_paths(self): - value = {'wallDir': '/home/qualification/Pictures/Wallpapers', 'runtime': '/usr/share/cybexos/runtime/quickshell', - 'choices': {'passwordless': False}, 'layout': ['nl', 'us']} - normalized = outcomes.normalize(value, '/home/qualification') - self.assertEqual(normalized['wallDir'], '$HOME/Pictures/Wallpapers') - self.assertEqual(normalized['runtime'], '$RUNTIME/quickshell') - self.assertEqual(normalized['choices'], {'passwordless': False}) - self.assertEqual(normalized['layout'], ['nl', 'us']) - - def test_signing_key_exception_is_exact_and_never_masks_required_versions(self): - identity = 'gpg-pubkey.(none)=' + 'a' * 40 + '-12345678' - changed = fixture('checkout') - changed['installed_packages'].append(identity) - exceptions = {'checkout': {identity: 'Exact reviewed vendor key imported by its signed package scriptlet'}} - self.assertEqual(outcomes.compare(fixture(), changed, exceptions)['status'], 'passed') - for unknown in (identity.replace('a' * 40, 'b' * 40), identity.replace('12345678', '87654321')): - with self.subTest(identity=unknown): - changed['installed_packages'][-1] = unknown - result = outcomes.compare(fixture(), changed, exceptions) - self.assertEqual(result['status'], 'failed') - self.assertEqual(result['package_delta']['checkout']['unreviewed'], [unknown]) - changed['installed_packages'][-1] = identity - changed['required_packages']['signing-key'] = [identity] - self.assertEqual(outcomes.compare(fixture(), changed, exceptions)['status'], 'failed') - - def test_signing_key_family_exception_is_rejected(self): - with self.assertRaisesRegex(ValueError, 'exact fingerprint and RPM release'): - outcomes.compare(fixture(), fixture('checkout'), { - 'checkout': {'gpg-pubkey.(none)': 'A family-wide exception would hide unreviewed signing keys'}}) - - def test_reviewed_brave_scriptlet_keys_are_fingerprint_scoped(self): - policy = json.loads(Path(outcomes.__file__).with_name('parity-exceptions.json').read_text()) - keys = {name: reason for name, reason in policy['checkout'].items() if name.startswith('gpg-pubkey.')} - self.assertEqual(len(keys), 3) - for identity, reason in keys.items(): - self.assertRegex(identity, r'^gpg-pubkey\.\(none\)=[0-9a-f]{40}-[0-9a-f]{8}$') - self.assertIn('brave-keyring 1.19-1', reason) - self.assertIn('pinned stable repository', reason) - - -class SourceIdentity(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory(prefix='cybexos-source-test-') - self.addCleanup(self.temp.cleanup) - self.root = Path(self.temp.name) / 'source' - self.root.mkdir() - subprocess.run(['git', 'init', '-q', str(self.root)], check=True) - for name in ('install', 'site.yml', 'inventory/group_vars/all.yml'): - path = self.root / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text('fixture source\n') - self.provenance = Path(self.temp.name) / 'build.json' - self.provenance.write_text('{}') - self.archive = Path(self.temp.name) / 'source.tar.gz' - - def test_archive_has_identical_content_identity_and_changes_are_detected(self): - initial = source.tree_identity(self.root) - source.write_archive(self.root, self.archive, {'image/build-provenance.json': self.provenance}) - self.assertEqual(source.archive_identity(self.archive), initial) - (self.root / 'install').write_text('changed\n') - self.assertNotEqual(source.tree_identity(self.root), initial) - (self.root / 'install').write_text('fixture source\n') - (self.root / 'install').chmod(0o755) - self.assertNotEqual(source.tree_identity(self.root), initial) - - def test_ignored_files_and_generated_provenance_do_not_shape_content_identity(self): - (self.root / '.gitignore').write_text('local-secret\n') - first = source.tree_identity(self.root) - (self.root / 'local-secret').write_text('never archive') - (self.root / 'image').mkdir() - (self.root / 'image/build-provenance.json').write_text('generated') - self.assertEqual(source.tree_identity(self.root), first) - - def test_archive_rejects_traversal_links_duplicate_and_missing_installer(self): - for names in (['../escape'], ['/absolute'], ['same', 'same'], ['ordinary']): - with self.subTest(names=names): - with tarfile.open(self.archive, 'w:gz') as archive: - for name in names: - member = tarfile.TarInfo(name) - member.size = 1 - archive.addfile(member, io.BytesIO(b'x')) - with self.assertRaises(ValueError): - source.archive_identity(self.archive) - with tarfile.open(self.archive, 'w:gz') as archive: - link = tarfile.TarInfo('link') - link.type, link.linkname = tarfile.SYMTYPE, '/outside' - archive.addfile(link) - with self.assertRaises(ValueError): - source.archive_identity(self.archive) - - -class GuestWorkflow(unittest.TestCase): - def test_cloud_os_preparation_reboots_before_pruning_and_is_outside_public_installer(self): - vm = Mock(owned=True, network_restricted=False) - steps = [] - def execute(_vm, script, _password, **_kwargs): - steps.append(script) - return Mock(stdout='DNF output\n{"running_kernel":"current"}\n') - with patch.object(checkout_runner, 'root_script', side_effect=execute), \ - patch.object(checkout_runner, 'poweroff_installed', side_effect=lambda *_: steps.append('poweroff')): - result = checkout_runner.prepare_cloud_baseline(vm, 'fixture') - self.assertEqual(result['running_kernel'], 'current') - self.assertEqual(steps, [checkout_runner.PREPARE_CLOUD_BASELINE, 'poweroff', checkout_runner.FINALIZE_CLOUD_BASELINE]) - vm.start.assert_called_once_with(user='qualification') - vm.wait_ssh.assert_called_once() - self.assertIsNone(vm.seed) - self.assertIs(vm.expect_shell, False) - self.assertIn('--oldinstallonly --limit=1', checkout_runner.FINALIZE_CLOUD_BASELINE) - self.assertIn('fedora-release-cloud fedora-release', checkout_runner.PREPARE_CLOUD_BASELINE) - self.assertLess(checkout_runner.PREPARE_CLOUD_BASELINE.index('fedora-release-identity-cloud fedora-release-identity-basic'), - checkout_runner.PREPARE_CLOUD_BASELINE.index('fedora-release-cloud fedora-release')) - self.assertLess(checkout_runner.PREPARE_CLOUD_BASELINE.index('fedora-release-cloud fedora-release'), - checkout_runner.PREPARE_CLOUD_BASELINE.index('dnf upgrade -y --refresh --best')) - # Prepare only preexisting Cloud packages; never hide dependencies - # introduced by the public installer's intermediate codec choices. - for name in ('fdk-aac-free', 'libavc1394', 'libklvanc', 'librabbitmq', 'xevd-libs'): - self.assertNotIn(name, checkout_runner.PREPARE_CLOUD_BASELINE) - self.assertNotIn('dnf upgrade', checkout_runner.install_script('plain-us')) - self.assertNotIn('oldinstallonly', checkout_runner.install_script('plain-us')) - for script in (checkout_runner.PREPARE_CLOUD_BASELINE, checkout_runner.FINALIZE_CLOUD_BASELINE): - subprocess.run(['bash', '-n'], input=script, text=True, check=True) - for owned, restricted in ((False, False), (True, True)): - with self.assertRaises(ValueError): - checkout_runner.prepare_cloud_baseline(Mock(owned=owned, network_restricted=restricted), 'fixture') - - def test_public_installer_uses_all_defaults_and_no_fixture_feature_optouts(self): - script = checkout_runner.install_script('plain-nl') - self.assertIn('./install --non-interactive', script) - self.assertNotIn('convergence-vars', script) - self.assertNotIn('--tags', script) - self.assertIn('LANG=nl_NL.UTF-8', script) - self.assertIn('rm -f /etc/sudoers.d/90-cloud-init-users', script) - self.assertIn('export SUDO_USER=qualification', script) - self.assertIn('systemctl get-default', script) - self.assertNotIn('systemctl set-default', script) - self.assertNotIn('systemctl enable sddm', script) - self.assertNotIn('set-x11-keymap', checkout_runner.install_script('plain-nl', repeat=True)) - with self.assertRaises(ValueError): - checkout_runner.install_script('encrypted-us') - - def test_desktop_lifecycle_streams_the_real_managed_service_test(self): - with patch.object(parity.subprocess, 'run') as run: - parity.desktop_lifecycle(Mock(ssh=['ssh', 'fixture'])) - script = run.call_args.kwargs['input'] - self.assertIn('qs_live_begin', script) - self.assertIn('qs_live_end', script) - self.assertIn('sound network accounts keyboard touchpad region', script) - self.assertIn("trap cleanup EXIT", script) - subprocess.run(['bash', '-n'], input=script, text=True, check=True) - - def test_capture_source_is_valid_python_and_requires_explicit_cli(self): - source_text = Path(outcomes.__file__).read_text() - compile(source_text, '', 'exec') - with patch.object(outcomes.os, 'geteuid', return_value=1000), self.assertRaisesRegex(RuntimeError, 'disposable qualification'): - outcomes.capture('qualification', 'iso', 'plain-us', 'fresh', '/never', '/never') - compile(parity.SAVED_CHOICES, '', 'exec') - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_installed_policy.py b/image/test_installed_policy.py deleted file mode 100644 index b5c4456c..00000000 --- a/image/test_installed_policy.py +++ /dev/null @@ -1,486 +0,0 @@ -"""Regressions for the installed-image policy gap found on the physical laptop.""" -import configparser -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import subprocess -import tempfile -import unittest -from unittest.mock import patch - -import jinja2 -import yaml - -ROOT = Path(__file__).resolve().parents[1] - - -def load(name, path): - loader = importlib.machinery.SourceFileLoader(name, str(ROOT / path)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -policy = load('repository_policy', 'roles/apps/files/cybexos-repository-policy') -repair = load('repair_installed', 'image/repair-installed') -configure = load('configure_installed', 'image/rootfs/usr/libexec/cybexos-configure-installed') - - -class InstalledPolicy(unittest.TestCase): - def test_automatic_hardware_checks_retry_after_a_kernel_change(self): - import pwd - import types - account = pwd.struct_passwd(('john', 'x', 1000, 1000, '', '/home/john', '/usr/bin/fish')) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - provision = root / 'provision' - provision.mkdir() - (provision / 'policy').write_text('fixture') - (root / 'run/lock').mkdir(parents=True) - def paths(value): - return root / value.lstrip('/') if value.startswith(('/var/', '/run/')) else Path(value) - kernel = types.SimpleNamespace(release='kernel-one') - boot = ['boot-one'] - def applied(*_args): - result = root / 'var/lib/cybexos/hardware-result.json' - result.parent.mkdir(parents=True, exist_ok=True) - result.write_text('{"reboot_required": false, "camera_ready": true}') - with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ - patch.object(configure.os, 'geteuid', return_value=0), patch.object(configure.os, 'uname', return_value=kernel), \ - patch.object(configure, 'boot_id', side_effect=lambda: boot[0]), \ - patch.object(configure.pwd, 'getpwall', return_value=[account]), patch.object(configure, 'drain'), \ - patch.object(configure, 'configure', side_effect=applied) as apply, \ - patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): - configure.main() - configure.main() - self.assertEqual(apply.call_count, 1) - marker = root / 'var/lib/cybexos/hardware-configured' - marker.rename(marker.with_name('hardware-unsupported')) - with self.assertRaises(SystemExit): - configure.main() - self.assertEqual(apply.call_count, 1) - kernel.release = 'kernel-two' - configure.main() - self.assertEqual(apply.call_count, 2) - self.assertFalse(marker.with_name('hardware-unsupported').exists()) - - def test_pending_camera_resumes_once_after_same_kernel_reboot(self): - import pwd - import types - account = pwd.struct_passwd(('john', 'x', 1000, 1000, '', '/home/john', '/usr/bin/fish')) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - provision = root / 'provision' - provision.mkdir() - (provision / 'policy').write_text('fixture') - (root / 'run/lock').mkdir(parents=True) - def paths(value): - return root / value.lstrip('/') if value.startswith(('/var/', '/run/')) else Path(value) - boot = ['boot-one'] - outcomes = [True, False] - def applied(*_args): - result = root / 'var/lib/cybexos/hardware-result.json' - result.parent.mkdir(parents=True, exist_ok=True) - result.write_text(json.dumps({'reboot_required': outcomes.pop(0), 'camera_ready': not outcomes})) - with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ - patch.object(configure.os, 'geteuid', return_value=0), \ - patch.object(configure.os, 'uname', return_value=types.SimpleNamespace(release='same-kernel')), \ - patch.object(configure, 'boot_id', side_effect=lambda: boot[0]), \ - patch.object(configure.pwd, 'getpwall', return_value=[account]), \ - patch.object(configure, 'drain'), \ - patch.object(configure, 'configure', side_effect=applied) as apply, \ - patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): - configure.main() - status = json.loads((root / 'var/lib/cybexos/hardware-status.json').read_text()) - self.assertEqual(status['state'], 'pending') - self.assertFalse((root / 'var/lib/cybexos/hardware-configured').exists()) - configure.main() - self.assertEqual(apply.call_count, 1) - boot[0] = 'boot-two' - configure.main() - self.assertEqual(apply.call_count, 2) - configure.main() - self.assertEqual(apply.call_count, 2) - self.assertEqual(json.loads((root / 'var/lib/cybexos/hardware-status.json').read_text())['state'], 'completed') - - def test_missing_hardware_result_is_failure_not_completion(self): - import pwd - import types - account = pwd.struct_passwd(('john', 'x', 1000, 1000, '', '/home/john', '/usr/bin/fish')) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - provision = root / 'provision' - provision.mkdir() - (root / 'run/lock').mkdir(parents=True) - def paths(value): - return root / value.lstrip('/') if value.startswith(('/var/', '/run/')) else Path(value) - with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ - patch.object(configure.os, 'geteuid', return_value=0), \ - patch.object(configure.os, 'uname', return_value=types.SimpleNamespace(release='kernel')), \ - patch.object(configure, 'boot_id', return_value='boot'), \ - patch.object(configure.pwd, 'getpwall', return_value=[account]), \ - patch.object(configure, 'configure'), \ - patch.object(configure, 'drain'), \ - patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): - with self.assertRaisesRegex(SystemExit, 'did not report an outcome'): - configure.main() - self.assertEqual(json.loads((root / 'var/lib/cybexos/hardware-status.json').read_text())['state'], 'failed') - self.assertFalse((root / 'var/lib/cybexos/hardware-configured').exists()) - - def test_legacy_completion_marker_is_validated_once_and_config_changes_invalidate_it(self): - import pwd - import types - account = pwd.struct_passwd(('john', 'x', 1000, 1000, '', '/home/john', '/usr/bin/fish')) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - provision = root / 'provision' - provision.mkdir() - (root / 'run/lock').mkdir(parents=True) - saved = root / 'etc/cybexos/config.yml' - saved.parent.mkdir(parents=True) - saved.write_text('xps_2026_camera_enabled: true\n') - def paths(value): - return root / value.lstrip('/') if value.startswith(('/var/', '/run/', '/etc/')) else Path(value) - def applied(*_args): - result = root / 'var/lib/cybexos/hardware-result.json' - result.parent.mkdir(parents=True, exist_ok=True) - result.write_text('{"reboot_required":false,"camera_ready":true}') - with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ - patch.object(configure.os, 'geteuid', return_value=0), \ - patch.object(configure.os, 'uname', return_value=types.SimpleNamespace(release='same-kernel')), \ - patch.object(configure, 'boot_id', return_value='same-boot'), \ - patch.object(configure.pwd, 'getpwall', return_value=[account]), \ - patch.object(configure, 'drain'), \ - patch.object(configure, 'configure', side_effect=applied) as apply, \ - patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): - configure.main() - (root / 'var/lib/cybexos/hardware-status.json').unlink() - configure.main() - self.assertEqual(apply.call_count, 2, 'Legacy marker alone cannot prove completion') - configure.main() - self.assertEqual(apply.call_count, 2) - saved.write_text('xps_2026_camera_enabled: false\n') - configure.main() - self.assertEqual(apply.call_count, 3, 'Saved hardware choices invalidate prior completion') - - def test_camera_backup_and_restore_preserve_a_dangling_vendor_symlink(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source = root / 'etc/v4l2-relayd.d/icamerasrc.conf' - backup = root / 'var/lib/cybexos/backups/system/etc/v4l2-relayd.d/icamerasrc.conf' - source.parent.mkdir(parents=True) - backup.parent.mkdir(parents=True) - source.symlink_to('/run/cybexos-fixture-missing-relay') - for filename, task_name in ( - ('camera.yml', "Preserve Fedora's generic camera relay configuration"), - ('camera-disabled.yml', "Restore Fedora's generic camera relay configuration"), - ): - tasks = yaml.safe_load((ROOT / 'roles/xps-2026/tasks' / filename).read_text()) - command = next(task['ansible.builtin.command']['argv'] for task in tasks if task['name'] == task_name) - command = [str(root / arg.lstrip('/')) if arg.startswith(('/etc/', '/var/')) else arg for arg in command] - subprocess.run(command, check=True) - self.assertEqual(os.readlink(backup), '/run/cybexos-fixture-missing-relay') - self.assertEqual(os.readlink(source), os.readlink(backup)) - if filename == 'camera.yml': - source.unlink() - - def test_session_target_can_start_services_ordered_after_graphical_session(self): - with tempfile.TemporaryDirectory() as temporary: - units = Path(temporary) - target = ROOT / 'image/rootfs/usr/lib/systemd/user/hyprland-session.target' - (units / target.name).write_text(target.read_text()) - for unit in ('graphical-session', 'graphical-session-pre'): - (units / (unit + '.target')).write_text('[Unit]\nDescription=Fixture\n') - for name in ('quickshell', 'hypridle', 'voxtype', - 'hermes-menubar-bridge', 'cybexos-welcome', 'cybexos-app-seed'): - (units / (name + '.service')).write_text( - '[Unit]\nAfter=graphical-session.target\n[Service]\nExecStart=/usr/bin/true\n') - runtime = units / 'runtime' - runtime.mkdir(mode=0o700) - environment = {**os.environ, 'SYSTEMD_UNIT_PATH': str(units) + ':/usr/lib/systemd/user', - 'XDG_RUNTIME_DIR': str(runtime)} - def verify(): - return subprocess.run(['systemd-analyze', '--user', 'verify', str(units / target.name)], - env=environment, text=True, capture_output=True) - result = verify() - self.assertEqual(result.returncode, 0, result.stderr) - self.assertNotIn('ordering cycle', result.stderr) - # The same vendor ordering reproduced the pre-fix failure. - (units / target.name).write_text(target.read_text().replace('DefaultDependencies=no', 'DefaultDependencies=yes')) - self.assertIn('ordering cycle', verify().stderr) - - def test_repository_reconciliation_preserves_unknown_entries_and_is_idempotent(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - legacy = root / 'etc/yum.repos.d/cybex-applications.repo' - legacy.parent.mkdir(parents=True) - original = '[openai-chatgpt]\nbaseurl=https://old.example\n[personal]\nbaseurl=https://personal.example\n' - legacy.write_text(original) - policies = ROOT / 'roles/apps/files' - self.assertTrue(policy.configure(root, policies)) - self.assertFalse(policy.configure(root, policies)) - self.assertNotIn('[openai-chatgpt]', legacy.read_text()) - self.assertIn('[personal]', legacy.read_text()) - self.assertEqual((root / 'var/lib/cybexos/backups/repositories/cybex-applications.repo').read_text(), original) - config = configparser.ConfigParser(interpolation=None) - config.read(root / 'etc/dnf/repos.override.d/90-cybexos-vendors.repo') - self.assertTrue(config['openai-chatgpt'].getboolean('repo_gpgcheck')) - self.assertTrue(config['tailscale-stable'].getboolean('repo_gpgcheck')) - self.assertTrue(all(section.getboolean('gpgcheck') for section in config.values() if section.name != 'DEFAULT')) - (root / 'etc/yum.repos.d/chatgpt.repo').write_text('[openai-chatgpt]\ngpgcheck=0\n') - self.assertTrue(policy.configure(root, policies)) - self.assertFalse(policy.configure(root, policies)) - - def test_unsigned_policy_is_rejected_before_writing(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / 'chatgpt.repo').write_text('[openai-chatgpt]\ngpgcheck=0\n') - with self.assertRaises(ValueError): - policy.configure(root / 'target', root, ['chatgpt']) - self.assertFalse((root / 'target').exists()) - - def test_image_includes_every_shared_baseline_package(self): - packages, _ = repair.load_applications().package_names() - baseline = yaml.safe_load((ROOT / 'roles/base/defaults/main.yml').read_text()) - self.assertTrue(set(baseline['base_required_packages']).issubset(packages)) - self.assertTrue({'nano', 'fuzzel', 'tuned-ppd', 'alsa-ucm', 'alsa-utils', 'alsa-sof-firmware', - 'cirrus-audio-firmware', 'intel-vsc-firmware'}.issubset(packages)) - # Weak dependencies are off in the image, so firmware, microcode and - # Fedora Workstation defaults must be named explicitly. - self.assertTrue({'realtek-firmware', 'amd-ucode-firmware', 'microcode_ctl', 'switcheroo-control', - 'xdg-user-dirs', 'gvfs-mtp', 'NetworkManager-openvpn-gnome', 'hplip', - 'gstreamer1-plugin-openh264', 'ibus', 'bind-utils'}.issubset(packages)) - repositories = configparser.ConfigParser(interpolation=None) - repositories.read(ROOT / 'image/build.repo') - self.assertTrue(repositories['fedora-cisco-openh264'].getboolean('gpgcheck')) - - def test_installed_images_share_the_workstation_account_and_service_policy(self): - tasks_dir = ROOT / 'roles/base/tasks' - - def flatten(name): - for task in yaml.safe_load((tasks_dir / name).read_text()): - if 'ansible.builtin.import_tasks' in task: - yield from flatten(task['ansible.builtin.import_tasks']) - else: - yield task - - # The workstation and installed images apply the same task files. - for entry in ('main.yml', 'image.yml'): - imported = {task['ansible.builtin.import_tasks'] for task in yaml.safe_load((tasks_dir / entry).read_text()) - if 'ansible.builtin.import_tasks' in task} - self.assertTrue({'accounts.yml', 'mdns.yml', 'docker-activation.yml', - 'authentication.yml'}.issubset(imported), entry) - # Upgrades of older ISO installations reconcile the sign-in policy too. - reconcile = {task.get('ansible.builtin.import_tasks') for task in - yaml.safe_load((tasks_dir / 'reconcile.yml').read_text())} - self.assertIn('authentication.yml', reconcile) - image = {task['name']: task for task in flatten('image.yml')} - workstation = {task['name']: task for task in flatten('main.yml')} - for name in ('Enable user lingering', 'Enable user lingering in the offline installation target', - 'Create the offline user lingering directory', - 'Configure active local wheel Polkit authorization', - 'Revoke passwordless local Polkit authorization when disabled', - 'Enable Docker socket activation when requested', 'Start Docker on demand rather than at boot', - 'Enable multicast DNS host resolution', - 'Offer fingerprints and security keys beside the password', - 'Report a sign-in policy CybexOS could not apply'): - self.assertIn(name, image) - self.assertEqual(image[name], workstation[name]) - # Anaconda's target has no logind: write the marker loginctl would. - online = image['Enable user lingering'] - self.assertIn('loginctl enable-linger', online['ansible.builtin.command']) - self.assertIn('not cybexos_offline', online['when']) - directory = image['Create the offline user lingering directory']['ansible.builtin.file'] - self.assertEqual((directory['path'], directory['state'], directory['mode']), - ('/var/lib/systemd/linger', 'directory', '0755')) - offline = image['Enable user lingering in the offline installation target']['ansible.builtin.copy'] - self.assertEqual((offline['dest'], offline['owner'], offline['mode']), - ('/var/lib/systemd/linger/{{ primary_user }}', 'root', '0644')) - # A fresh target has no linger directory; the copy module will not - # create it, which aborted the Anaconda post-install script. - order = [task['name'] for task in flatten('image.yml')] - directory = 'Create the offline user lingering directory' - self.assertLess(order.index(directory), order.index('Enable user lingering in the offline installation target')) - self.assertEqual(image[directory]['ansible.builtin.file'], - {'path': '/var/lib/systemd/linger', 'state': 'directory', - 'owner': 'root', 'group': 'root', 'mode': '0755'}) - self.assertEqual(image[directory]['when'], 'cybexos_offline | default(false) | bool') - self.assertEqual(image['Configure active local wheel Polkit authorization']['when'], - 'passwordless_local_polkit | bool') - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - self.assertIs(inventory['passwordless_local_polkit'], False) - self.assertEqual(image['Start Docker on demand rather than at boot']['ansible.builtin.systemd_service'], - {'name': 'docker.service', 'enabled': False}) - self.assertFalse(any(task.get('ansible.builtin.systemd_service', {}).get('name') == 'docker.service' - and task['ansible.builtin.systemd_service'].get('enabled') is True - for task in image.values())) - # cybex configure can deselect services the image installs for everyone. - deselected = image['Turn off deselected image services'] - self.assertEqual({(item['unit'], item['feature']) for item in deselected['loop']}, - {('docker.socket', 'docker'), ('docker.service', 'docker'), - ('tailscaled.service', 'tailscale')}) - self.assertIs(deselected['ansible.builtin.systemd_service']['enabled'], False) - self.assertIn('not features[item.feature] | bool', deselected['when']) - # authselect keeps a custom profile: only an existing feature is added. - mdns = image['Enable multicast DNS host resolution'] - self.assertEqual(mdns['ansible.builtin.command']['argv'], ['authselect', 'enable-feature', 'with-mdns4']) - self.assertIn("'with-mdns4' not in base_authselect_current.stdout.split()", mdns['when']) - self.assertFalse(any('select' in task.get('ansible.builtin.command', {}).get('argv', []) - for task in image.values() if isinstance(task.get('ansible.builtin.command'), dict))) - - def test_fingerprint_and_security_key_policy_reaches_both_paths(self): - # docs/security.md: one helper, applied from the role in every path, and - # installed for the Settings switch at the path each one uses. - tasks = yaml.safe_load((ROOT / 'roles/base/tasks/authentication.yml').read_text()) - apply = tasks[0] - self.assertEqual(apply['ansible.builtin.script']['cmd'], 'cybexos-auth-policy apply') - self.assertEqual(apply['when'], 'not ansible_check_mode') - # A failure is reported, never left to break an installation half-way. - self.assertIs(apply['failed_when'], False) - main = {task['name']: task for task in yaml.safe_load((ROOT / 'roles/base/tasks/main.yml').read_text())} - helper = main['Install the sign-in policy helper']['ansible.builtin.copy'] - self.assertEqual((helper['src'], helper['dest'], helper['mode']), - ('cybexos-auth-policy', '/usr/local/libexec/cybexos-auth-policy', '0755')) - package = (ROOT / 'image/package').read_text() - self.assertIn('copy("roles/base/files/cybexos-auth-policy", "usr/libexec/cybexos-auth-policy", True)', package) - settings = (ROOT / 'roles/desktop/files/quickshell/scripts/system_settings_security.py').read_text() - for path in ('/usr/local/libexec/cybexos-auth-policy', '/usr/libexec/cybexos-auth-policy'): - self.assertIn(path, settings) - # The same packages on both paths, and on upgrades of older images. - packages = {'fprintd', 'fprintd-pam', 'libfprint', 'pam-u2f', 'pamu2fcfg', 'libfido2'} - baseline = yaml.safe_load((ROOT / 'roles/base/defaults/main.yml').read_text())['base_required_packages'] - self.assertTrue(packages.issubset(baseline)) - self.assertTrue(packages.issubset(repair.load_applications().package_names()[0])) - requires = ' '.join(line.split(':', 1)[1] for line in (ROOT / 'image/cybexos-desktop.spec').read_text().splitlines() - if line.startswith('Requires:')).split() - self.assertTrue({'fprintd', 'fprintd-pam', 'pam-u2f', 'pamu2fcfg'}.issubset(requires)) - # The XPS role keeps only its hardware gate and diagnostic; it no - # longer installs packages or changes authselect itself. - xps = (ROOT / 'roles/xps-2026/tasks/fingerprint.yml').read_text() - self.assertNotIn('ansible.builtin.dnf', xps) - self.assertNotIn('enable-feature', xps) - uninstall = (ROOT / 'roles/uninstall/tasks/main.yml').read_text() - self.assertLess(uninstall.index('/usr/local/libexec/cybexos-auth-policy remove'), - uninstall.index('- name: Remove project-owned system files')) - - def test_offline_linger_marker_is_created_when_parent_directory_is_absent(self): - tasks = yaml.safe_load((ROOT / 'roles/base/tasks/accounts.yml').read_text()) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - linger = root / 'var/lib/systemd/linger' - selected = [dict(task) for task in tasks if task['name'] in ( - 'Create the offline user lingering directory', - 'Enable user lingering in the offline installation target')] - self.assertEqual(len(selected), 2) - self.assertEqual(selected[0]['name'], 'Create the offline user lingering directory') - selected[0]['ansible.builtin.file'] = { - **selected[0]['ansible.builtin.file'], 'path': str(linger)} - selected[1]['ansible.builtin.copy'] = { - **selected[1]['ansible.builtin.copy'], 'dest': str(linger / '{{ primary_user }}')} - for task in selected: - module = task.get('ansible.builtin.file') or task['ansible.builtin.copy'] - module.pop('owner', None) - module.pop('group', None) - playbook = root / 'offline-linger.yml' - playbook.write_text(yaml.safe_dump([{'hosts': 'localhost', 'connection': 'local', 'become': False, - 'gather_facts': False, 'vars': { - 'primary_user': 'fixture', 'cybexos_offline': True}, - 'tasks': selected}])) - for _ in range(2): - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], - text=True, capture_output=True) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertTrue((linger / 'fixture').is_file()) - - def test_repair_payload_uses_shared_sources_and_hardware_detection(self): - with tempfile.TemporaryDirectory() as temporary: - payload = Path(temporary) - repair.prepare(payload) - provision = payload / 'usr/share/cybexos/provision' - for relative in ('roles/base/tasks/accounts.yml', 'roles/base/tasks/mdns.yml', - 'roles/base/tasks/authentication.yml', 'roles/base/files/cybexos-auth-policy', - 'roles/base/tasks/docker-activation.yml', 'roles/xps-2026/tasks/camera.yml', - 'roles/dotfiles/files/fish-config.fish', 'roles/base/tasks/btrfs-scrub.yml', - 'roles/apps/tasks/mpv.yml'): - self.assertEqual((provision / relative).read_bytes(), (ROOT / relative).read_bytes()) - # Earlier installations receive the configure command, the - # feature-rendered shell unit, the agent skill and the XPS watcher. - for relative in ('usr/libexec/cybexos-config', 'usr/libexec/cybexos-external-monitor-toggle', - 'usr/lib/systemd/user/external-monitor-toggle.service', - 'usr/share/cybexos/agent-skills/cybexos/SKILL.md'): - self.assertTrue((payload / relative).is_file(), relative) - self.assertIn('CYBEXOS_CONNECTED_WIDGETS=1', - (payload / 'usr/lib/systemd/user/quickshell.service').read_text()) - features = (payload / 'usr/share/cybexos/runtime/hypr/features.lua').read_text() - self.assertIn('os.getenv("CYBEXOS_XPS_2026") == "1"', features) - self.assertNotIn('{{', features) - result = subprocess.run(['ansible-playbook', '-i', 'localhost,', - str(provision / 'image/provision.yml'), '--syntax-check', - '-e', '{"primary_user":"fixture","cybexos_offline":true,"cybexos_hardware_only":false}'], - env={**os.environ, 'ANSIBLE_CONFIG': str(provision / 'image/provision.cfg'), - 'ANSIBLE_ROLES_PATH': str(provision / 'roles')}, - text=True, capture_output=True) - self.assertEqual(result.returncode, 0, result.stderr) - - def test_localsend_and_mdns_ports_stay_open_without_enabling_other_network_services(self): - inventory = yaml.safe_load((ROOT / 'inventory/group_vars/all.yml').read_text()) - env = jinja2.Environment(undefined=jinja2.StrictUndefined) - env.filters['bool'] = bool - source = (ROOT / 'roles/base/templates/cybexos-zone.xml.j2').read_text() - rendered = env.from_string(source).render(**inventory) - self.assertIn('port="53317" protocol="tcp"', rendered) - self.assertIn('port="53317" protocol="udp"', rendered) - self.assertIn('port="5353" protocol="udp"', rendered) - self.assertNotIn('port="27036"', rendered) - - def test_localsend_forwards_selected_files_and_keeps_text_literal(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - binary = root / 'flatpak' - binary.write_text('#!/usr/bin/python3\nimport json,sys\nprint(json.dumps(sys.argv[1:]))\n') - binary.chmod(0o755) - document = root / 'file with spaces.txt' - document.write_text('fixture') - result = subprocess.check_output(['bash', str(ROOT / 'assets/scripts/localsend'), str(document), '--text', 'a message'], - env={**os.environ, 'PATH': str(root) + ':' + os.environ['PATH']}, text=True) - self.assertEqual(json.loads(result), ['run', '--file-forwarding', 'org.localsend.localsend_app', - '@@', str(document), '@@', '--text', 'a message']) - - def test_clipboard_sharing_uses_a_description_without_clipboard_contents(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - for name, body in ( - ('wl-paste', 'print("private fixture")'), - ('systemd-run', 'import json,sys; print(json.dumps(sys.argv[1:]))'), - ): - tool = root / name - tool.write_text('#!/usr/bin/python3\n' + body + '\n') - tool.chmod(0o755) - result = subprocess.check_output(['bash', str(ROOT / 'assets/scripts/localsend-share'), 'clipboard'], - env={**os.environ, 'PATH': str(root) + ':' + os.environ['PATH']}, text=True) - arguments = json.loads(result) - self.assertIn('--description=Share clipboard via LocalSend', arguments) - self.assertEqual(arguments[-3:], ['localsend', '--text', 'private fixture']) - - def test_configuring_a_non_admin_never_adds_wheel(self): - import grp - import pwd - account = pwd.struct_passwd(('guest', 'x', 1001, 1001, '', '/home/guest', '/bin/bash')) - def group_name(name): - self.assertEqual(name, 'wheel') - return grp.struct_group(('wheel', 'x', 10, ['john'])) - with patch.object(configure.grp, 'getgrnam', side_effect=group_name), \ - patch.object(configure.grp, 'getgrgid', return_value=grp.struct_group(('guest', 'x', 1001, []))), \ - patch.object(configure, 'contained') as run: - configure.configure(account, offline=True) - values = json.loads(run.call_args.args[0][-1]) - self.assertNotIn('wheel', values['base_account_groups']) - self.assertTrue(values['cybexos_offline']) - self.assertEqual(run.call_args.kwargs['env']['ANSIBLE_CONFIG'], str(configure.PROVISION / 'image/provision.cfg')) - - -if __name__ == '__main__': - unittest.main() diff --git a/image/test_installer.py b/image/test_installer.py deleted file mode 100644 index e6df3036..00000000 --- a/image/test_installer.py +++ /dev/null @@ -1,1282 +0,0 @@ -"""Source-only fixtures. No real Anaconda, devices, mounts, services or secrets.""" - -import copy -import importlib.machinery -import importlib.util -import json -import os -from pathlib import Path -import subprocess -import tempfile -import types -import unittest -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parent - - -def load(name, relative): - loader = importlib.machinery.SourceFileLoader(name, str(ROOT / relative)) - spec = importlib.util.spec_from_loader(name, loader) - module = importlib.util.module_from_spec(spec) - loader.exec_module(module) - return module - - -backend = load("installer_backend", "live-rootfs/usr/libexec/cybexos-installer-backend") -target = load("installer_target", "live-rootfs/usr/libexec/cybexos-installer-target") -tree = load("installer_tree", "install-live-rootfs") -browser = load("installer_browser", "live-rootfs/usr/bin/cybexos-installer-browser") -DISK = dict( - name="vda", - path="/dev/vda", - size=100 * 1024**3, - model="Fixture disk", - serial="FIXTURE-ONLY", - wwn="", - device_id="disk-vda", -) -CHOICES = dict( - disks=[DISK], - keyboards=[{"id": "us", "label": "English"}, {"id": "nl", "label": "Dutch"}], - locales=["en_US.UTF-8", "nl_NL.UTF-8"], - locale="en_US.UTF-8", - keyboard="us", - timezones=["Europe/Amsterdam", "UTC"], - timezone="UTC", -) -ACCOUNT = dict( - username="alice", - password="fixture phrase never real", - confirm="fixture phrase never real", - keyboard="us", - locale="en_US.UTF-8", - timezone="UTC", - hostname="cybexos", - disk="vda", - encrypted=True, -) - - -class FakeBackend: - def __init__(self): - self.choices = copy.deepcopy(CHOICES) - self.started = False - self.failed = False - self.rescanned = False - self.actions = [{"action-description": "create"}] - self.analysis = {"free": 1024**3, "partitions": [ - {"id": "vda1", "path": "/dev/vda1", "size": 512 * 1024**2, "min_size": 512 * 1024**2, - "shrinkable": False, "filesystem": "efi"}, - {"id": "vda3", "path": "/dev/vda3", "size": 400 * 1024**3, "min_size": 100 * 1024**3, - "shrinkable": True, "filesystem": "ntfs"}, - {"id": "vda4", "path": "/dev/vda4", "size": 20 * 1024**3, "min_size": 2 * 1024**3, - "shrinkable": True, "filesystem": "ntfs"}, - ]} - - def inventory(self): - return self.choices - - def reset(self): - pass - - def rescan(self): - self.rescanned = True - - def keyboard(self, layout): - return {"keyboard": layout, "boot_keyboard": layout} - - def plan(self, account, credentials, disk): - if self.failed: - raise RuntimeError("fixture failure") - self.credentials = dict(credentials) - self.planned = account - return dict( - partitioning="/fixture", - actions=copy.deepcopy(self.actions), - errors=[], - warnings=[], - boot_keyboard="us", - ) - - def alongside(self, disk): - return copy.deepcopy(self.analysis) - - def check_plan(self, state): - pass - - def start_install(self, state): - self.started = True - - def run_install(self, state, progress, store): - progress(1, 2, "Installing fixture") - if self.failed: - raise RuntimeError("fixture failure") - - -class InstallerTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.state = backend.State(Path(self.temporary.name)) - self.adapter = FakeBackend() - self.installer = backend.Installer(self.adapter, self.state) - - def plan(self, **changes): - return self.installer.plan({**ACCOUNT, **changes}) - - def commit(self, plan, **changes): - return self.installer.install( - dict(token=plan["token"], confirmed_disk="vda", erase_confirmed=True, **changes) - ) - - def test_plan_is_non_destructive_and_persists_no_password(self): - data = copy.deepcopy(ACCOUNT) - result = self.installer.plan(data) - self.assertEqual(result["phase"], "review") - self.assertFalse(self.adapter.started) - self.assertNotIn("password", data) - self.assertNotIn(ACCOUNT["password"], (self.state.directory / "state.json").read_text()) - self.assertEqual((self.state.directory / "state.json").stat().st_mode & 0o777, 0o600) - - def test_invalid_account_cannot_reach_review(self): - cases = [ - {"username": "root"}, - {"username": "liveuser"}, - {"username": "sddm"}, - {"username": "alice\nuser"}, - {"password": "short", "confirm": "short"}, - {"confirm": "different"}, - {"keyboard": "invented"}, - {"locale": "invented"}, - {"timezone": "../etc/passwd"}, - {"hostname": "host;command"}, - {"encrypted": "false"}, - ] - for changes in cases: - with self.subTest(changes=list(changes)), self.assertRaises(backend.Invalid): - self.plan(**changes) - self.assertFalse(self.adapter.started) - - def test_locale_keyboard_and_unencrypted_choices_are_preserved(self): - plan = self.plan( - keyboard="nl", locale="nl_NL.UTF-8", timezone="Europe/Amsterdam", encrypted=False - ) - self.assertEqual(plan["account"]["locale"], "nl_NL.UTF-8") - self.assertFalse(plan["account"]["encrypted"]) - self.assertFalse(plan["account"]["passwordless_wheel"]) - - def test_sudo_requires_explicit_boolean_opt_in(self): - self.assertTrue(self.plan(passwordless_wheel=True)["account"]["passwordless_wheel"]) - for invalid in ("true", 1, None): - with self.subTest(invalid=invalid), self.assertRaises(backend.Invalid): - self.plan(passwordless_wheel=invalid) - - def test_rescan_invalidates_confirmation_and_returns_new_inventory(self): - old = self.plan() - self.assertEqual(self.installer.rescan()["disks"][0]["name"], "vda") - self.assertTrue(self.adapter.rescanned) - with self.assertRaises(backend.Invalid): - self.commit(old) - - def test_keyboard_change_invalidates_old_confirmation(self): - plan = self.plan() - self.assertEqual(self.installer.keyboard({"keyboard": "nl"})["boot_keyboard"], "nl") - with self.assertRaises(backend.Invalid): - self.commit(plan) - - def test_live_keyboard_command_runs_as_live_user_with_validated_values(self): - calls = [] - - def run(command, **kwargs): - calls.append(command) - output = ( - json.dumps([{"pid": os.getpid(), "instance": "fixture_instance-1"}]) - if command[-1] == "instances" - else "ok\n" - ) - return types.SimpleNamespace(stdout=output, returncode=0) - - with patch.object( - backend.pwd, "getpwnam", return_value=types.SimpleNamespace(pw_uid=os.getuid()) - ): - backend.apply_live_keyboard("us (dvorak)", run) - # Lua-configured Hyprland refuses `hyprctl keyword`; one eval sets both. - self.assertEqual( - calls[-1][-2:], - ["eval", 'hl.config({ input = { kb_layout = "us", kb_variant = "dvorak" } })'], - ) - self.assertEqual(len(calls), 2) - self.assertTrue( - all(command[:4] == ["runuser", "-u", "liveuser", "--"] for command in calls) - ) - with self.assertRaises(backend.Invalid): - backend.apply_live_keyboard("us; bad", run) - - def test_live_keyboard_rejected_by_compositor_is_reported(self): - def run(command, **kwargs): - if command[-1] == "instances": - return types.SimpleNamespace( - stdout=json.dumps([{"pid": os.getpid(), "instance": "fixture"}]), returncode=0 - ) - return types.SimpleNamespace(stdout=reply, returncode=code) - - for reply, code in ( - ("keyword can't work with non-legacy parsers. Use eval.\n", 0), - ("error: unknown config key 'input.kb_layout'\n", 7), - ): - with patch.object( - backend.pwd, "getpwnam", return_value=types.SimpleNamespace(pw_uid=os.getuid()) - ), self.assertRaises(backend.Invalid): - backend.apply_live_keyboard("nl", run) - - def test_password_control_characters_rejected_without_echo(self): - with self.assertRaises(backend.Invalid) as caught: - self.plan(password="long secret\nvalue", confirm="long secret\nvalue") - self.assertNotIn("secret", str(caught.exception)) - - def test_missing_or_small_disk_is_rejected(self): - with self.assertRaises(backend.Invalid): - self.plan(disk="sda") - self.adapter.choices["disks"][0]["size"] = 1024 - with self.assertRaises(backend.Invalid): - self.plan() - - def test_changed_disk_and_stale_confirmation_are_rejected(self): - plan = self.plan() - self.adapter.choices["disks"][0]["serial"] = "REPLACED" - with self.assertRaises(backend.Invalid): - self.commit(plan) - self.assertFalse(self.adapter.started) - self.adapter.choices["disks"][0]["serial"] = "FIXTURE-ONLY" - new = self.plan() - self.adapter.choices["disks"][0]["partitions"] = [ - {"path": "/dev/vda1", "size": 1024, "filesystem": "ext4"} - ] - with self.assertRaises(backend.Invalid): - self.commit(new) - - def test_status_and_diagnostics_redact_policy_and_worker_text(self): - plan = self.plan(passwordless_wheel=True) - self.commit(plan) - state = self.state.read() - state.update(step=1, total=2, message="private /home/alice fixture phrase never real") - self.state.write(state) - status = backend.installation_status(self.state, lambda *args, **kwargs: types.SimpleNamespace(stdout="active\n")) - self.assertEqual(status["phase"], "installing") - self.assertNotIn("account", status) - self.assertNotIn("private", json.dumps(status)) - report = backend.diagnostics(self.state, lambda *args, **kwargs: types.SimpleNamespace( - stdout="ActiveState=active\nSubState=running\nResult=success\nEnvironment=SECRET=leak\n")) - self.assertEqual(report["worker"], {"ActiveState": "active", "SubState": "running", "Result": "success"}) - self.assertNotIn("alice", json.dumps(report)) - self.assertNotIn("SECRET", json.dumps(report)) - - def test_confirmation_and_replay_protection(self): - plan = self.plan() - with self.assertRaises(backend.Invalid): - self.installer.install({"token": plan["token"], "confirmed_disk": "vda"}) - with self.assertRaises(backend.Invalid): - self.installer.install( - {"token": "other", "confirmed_disk": "vda", "erase_confirmed": True} - ) - self.commit(plan) - self.assertTrue(self.adapter.started) - with self.assertRaises(backend.Invalid): - self.commit(plan) - with self.assertRaises(backend.Invalid): - self.installer.reset() - - def test_failed_plan_retries_but_never_reuses_old_token(self): - old = self.plan() - self.adapter.failed = True - with self.assertRaises(RuntimeError): - self.plan() - with self.assertRaises(backend.Invalid): - self.commit(old) - self.adapter.failed = False - new = self.plan() - self.assertNotEqual(old["token"], new["token"]) - - def test_worker_completion_and_failure_are_durable(self): - self.commit(self.plan()) - self.assertEqual(self.installer.worker()["phase"], "complete") - self.state.write({"phase": "setup"}) - self.commit(self.plan()) - self.adapter.failed = True - with self.assertRaises(RuntimeError): - self.installer.worker() - self.assertEqual(self.state.read()["phase"], "failed-install") - with self.assertRaises(backend.Invalid): - self.plan() - - def test_failed_worker_names_its_step_and_explaining_log(self): - self.commit(self.plan()) - self.adapter.failed = True - self.adapter.run_install = lambda state, progress, store: ( - progress(40, 41, "Running post-installation scripts"), - (_ for _ in ()).throw(RuntimeError("There was an error running the kickstart script at line 1."))) - with self.assertRaises(RuntimeError): - self.installer.worker() - state = self.state.read() - self.assertEqual((state["phase"], state["failed_step"], state["log_source"]), - ("failed-install", "Running post-installation scripts", "post")) - self.assertIn("Running post-installation scripts", state["message"]) - status = backend.public_status(state) - self.assertEqual(status["log_source"], "post") - self.assertEqual(status["failed_step"], "Running post-installation scripts") - steps = (Path(self.temporary.name) / "worker.log").read_text() - self.assertIn("Installing to /dev/vda (Fixture disk), encrypted.", steps) - self.assertIn("[40/41] Running post-installation scripts", steps) - self.assertIn("RuntimeError: There was an error running the kickstart script at line 1.", steps) - self.assertNotIn(ACCOUNT["password"], steps + json.dumps(state)) - - def test_failed_worker_keeps_free_form_text_out_of_status_and_state(self): - self.commit(self.plan()) - private = "private /home/alice fixture phrase never real" - def fail(state, progress, store): - progress(1, 2, private) - raise RuntimeError(private) - self.adapter.run_install = fail - with self.assertRaises(RuntimeError): - self.installer.worker() - state = self.state.read() - self.assertNotIn(private, json.dumps(state)) - self.assertNotIn(private, json.dumps(backend.public_status(state))) - self.assertEqual(backend.public_status(state)["log_source"], "steps") - # Only the explicitly opened log carries free-form diagnostic text. - self.assertIn(private, (Path(self.temporary.name) / "worker.log").read_text()) - - def test_new_installation_starts_a_new_step_log(self): - log = Path(self.temporary.name) / "worker.log" - log.write_text("an earlier attempt\n") - self.commit(self.plan()) - self.installer.worker() - self.assertNotIn("an earlier attempt", log.read_text()) - self.assertTrue(log.read_text().rstrip().endswith("Installation finished.")) - # Other failures point at the step log itself. - self.assertEqual(backend.failure_state({"message": "Installing the software"})["log_source"], "steps") - - def test_disconnected_browser_cannot_allow_retry_after_worker_loss(self): - self.commit(self.plan()) - def active(*args, **kwargs): - return types.SimpleNamespace(stdout="active\n") - def stopped(*args, **kwargs): - return types.SimpleNamespace(stdout="inactive\n") - self.assertEqual(backend.installation_status(self.state, active)["phase"], "installing") - self.assertEqual(backend.installation_status(self.state, stopped)["phase"], "installing") - state = self.state.read() - state["worker_queued_at"] = 0 - self.state.write(state) - self.assertEqual( - backend.installation_status(self.state, stopped)["phase"], "failed-install" - ) - self.assertEqual(self.state.read()["phase"], "installing") - with self.assertRaises(backend.Invalid): - self.plan() - - def test_new_owner_plan_has_no_account_and_keeps_only_the_passphrase_in_memory(self): - data = {**ACCOUNT, "oem": True, "username": "", "password": "temporary handover 1", - "confirm": "temporary handover 1"} - plan = self.installer.plan(data) - self.assertEqual((plan["account"]["oem"], plan["account"]["username"]), (True, "")) - self.assertEqual(self.adapter.credentials, {"passphrase": "temporary handover 1"}) - self.assertNotIn("temporary handover", (self.state.directory / "state.json").read_text()) - # Unencrypted new-owner installations take no passphrase at all. - self.installer.plan({**data, "encrypted": False, "password": "", "confirm": ""}) - self.assertEqual(self.adapter.credentials, {}) - with self.assertRaises(backend.Invalid): - self.installer.plan({**data, "oem": "yes"}) - - def alongside(self, **changes): - return self.installer.plan({**ACCOUNT, "layout": "alongside", **changes}) - - def test_alongside_offers_and_accepts_only_the_largest_shrinkable_partition(self): - options = self.installer.alongside({"disk": "vda"}) - self.assertEqual(options["mode"], "shrink") - self.assertEqual(options["partition"]["id"], "vda3") - self.assertEqual(options["floor"], 105 * 1024**3) - self.assertEqual(options["max_space"], 1024**3 + 400 * 1024**3 - 105 * 1024**3) - self.assertTrue(options["min_space"] <= options["default_space"] <= options["max_space"]) - for shrink in ({"device": "vda4", "size": 10 * 1024**3}, # not the largest - {"device": "vda3", "size": 104 * 1024**3}, # below its floor - {"device": "vda3", "size": 400 * 1024**3}, # not a shrink - {"device": "vda3", "size": 380 * 1024**3}): # too little for CybexOS - with self.subTest(shrink=shrink), self.assertRaises(backend.Invalid): - self.alongside(shrink=shrink) - with self.assertRaises(backend.Invalid): - self.alongside() # Free space alone is not enough here. - plan = self.alongside(shrink={"device": "vda3", "size": 300 * 1024**3}) - self.assertEqual(plan["layout"]["resize"], {"path": "/dev/vda3", "filesystem": "ntfs", - "from": 400 * 1024**3, "to": 300 * 1024**3}) - self.assertEqual(plan["layout"]["space"], 101 * 1024**3) - self.assertEqual(plan["layout"]["kept"], ["/dev/vda1", "/dev/vda4"]) - self.assertEqual(self.adapter.planned["shrink"], {"device": "vda3", "size": 300 * 1024**3}) - - def test_alongside_uses_free_space_without_shrinking_when_it_suffices(self): - self.adapter.analysis["free"] = 80 * 1024**3 - self.assertEqual(self.installer.alongside({"disk": "vda"})["mode"], "free") - with self.assertRaises(backend.Invalid): - self.alongside(shrink={"device": "vda3", "size": 300 * 1024**3}) - plan = self.alongside() - self.assertIsNone(plan["account"]["shrink"]) - self.assertNotIn("resize", plan["layout"]) - - def test_alongside_plan_touching_other_partitions_is_refused(self): - self.adapter.analysis["free"] = 80 * 1024**3 - for action in ({"action-type": "destroy", "device-id": "vda4"}, - {"action-type": "resize", "device-id": "vda4"}, - {"action-type": "create", "object-type": "format", "device-id": "vda1"}, - {"action-type": "create", "object-type": "format", "device-id": "vda"}): - with self.subTest(action=action): - self.adapter.actions = [{"action-type": "create", "device-id": "new-1"}, action] - with self.assertRaises(backend.Invalid): - self.alongside() - self.assertEqual(self.state.read()["phase"], "failed-plan") - self.adapter.actions = [{"action-type": "create", "object-type": "device", "device-id": "new-1"}, - {"action-type": "create", "object-type": "format", "device-id": "new-1"}] - self.assertEqual(self.alongside()["phase"], "review") - - def test_alongside_install_requires_its_own_confirmation(self): - self.adapter.analysis["free"] = 80 * 1024**3 - plan = self.alongside() - with self.assertRaises(backend.Invalid): - self.commit(plan) # erase_confirmed does not confirm a dual-boot install. - self.assertFalse(self.adapter.started) - self.installer.install({"token": plan["token"], "confirmed_disk": "vda", "alongside_confirmed": True}) - self.assertTrue(self.adapter.started) - - -# Strict proxy member sets were checked against the anaconda-44.30 interface -# declarations linked in INSTALLER.md. Unknown property access or assignment -# fails instead of silently creating arbitrary MagicMock methods. -class StrictProxy: - def __init__(self, values=None, methods=None): - object.__setattr__(self, "_values", values or {}) - object.__setattr__(self, "_methods", methods or {}) - - def __getattr__(self, name): - if name in self._values: - return self._values[name] - if name in self._methods: - return self._methods[name] - raise AttributeError(name) - - def __setattr__(self, name, value): - if name not in self._values: - raise AttributeError(name) - self._values[name] = value - - -class Structure: - __slots__ = ( - "name", - "gecos", - "password", - "is_crypted", - "groups", - "partitioning_scheme", - "encrypted", - "luks_version", - ) - - @classmethod - def from_structure(cls, data): - return cls() - - @staticmethod - def to_structure(data): - return { - name.replace("_", "-"): getattr(data, name) - for name in data.__slots__ - if hasattr(data, name) - } - - -LSBLK = {"blockdevices": [ - {"name": "nvme0n1", "path": "/dev/nvme0n1", "type": "disk", "fstype": None, "label": None, "size": 512 * 1024**3, - "hotplug": False, "rm": False, "ro": False, "mountpoints": [None], - "children": [{"name": "nvme0n1p1", "path": "/dev/nvme0n1p1", "type": "part", "fstype": "vfat", "label": None, - "size": 600 * 1024**2, "hotplug": False, "rm": False, "ro": False, "mountpoints": [None]}]}, - {"name": "sda", "path": "/dev/sda", "type": "disk", "fstype": "iso9660", "label": "CYBEXOS-LIVE-44", - "size": 8 * 1024**3, "hotplug": True, "rm": True, "ro": False, "mountpoints": ["/run/initramfs/live"], - "children": [{"name": "sda2", "path": "/dev/sda2", "type": "part", "fstype": "vfat", "label": "ANACONDA", - "size": 10 * 1024**2, "hotplug": True, "rm": True, "ro": False, "mountpoints": [None]}]}, - {"name": "sdb", "path": "/dev/sdb", "type": "disk", "fstype": None, "label": None, "size": 32 * 1024**3, - "hotplug": True, "rm": True, "ro": False, "mountpoints": [None], - "children": [{"name": "sdb1", "path": "/dev/sdb1", "type": "part", "fstype": "exfat", "label": "STICK", - "size": 32 * 1024**3, "hotplug": True, "rm": True, "ro": False, "mountpoints": [None]}, - {"name": "sdb2", "path": "/dev/sdb2", "type": "part", "fstype": "ntfs", "label": "WIN", - "size": 1024**3, "hotplug": True, "rm": True, "ro": False, "mountpoints": [None]}]}, - {"name": "sdc", "path": "/dev/sdc", "type": "disk", "fstype": "vfat", "label": "MOUNTED", "size": 16 * 1024**3, - "hotplug": True, "rm": False, "ro": False, "mountpoints": ["/run/media/liveuser/MOUNTED"]}, - {"name": "vda", "path": "/dev/vda", "type": "disk", "fstype": "ext4", "label": None, "size": 100 * 1024**3, - "hotplug": True, "rm": False, "ro": False, "mountpoints": [None]}, -]} - - -class DiagnosticsTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - - def test_log_reading_is_allow_listed_and_bounded(self): - first, second = self.root / "one.log", self.root / "two.log" - first.write_text("".join(f"line {number}\n" for number in range(100))) - sources = {"one": [first], "both": [first, second], "missing": [self.root / "absent.log"]} - with self.assertRaises(backend.Invalid): - backend.read_log("../../etc/shadow", sources) - with self.assertRaises(backend.Invalid): - backend.read_log("/tmp/sensitive-info.log") - tail = backend.read_log("one", sources, limit=30)["text"] - self.assertTrue(tail.startswith("line ") and tail.endswith("line 99\n")) - self.assertLessEqual(len(tail), 30) - self.assertEqual(backend.read_log("missing", sources), {"source": "missing", "available": False, "text": ""}) - second.write_text("second\n") - both = backend.read_log("both", sources)["text"] - self.assertLess(both.index(f"==> {first} <=="), both.index(f"==> {second} <==")) - self.assertNotIn(Path("/tmp/sensitive-info.log"), [path for paths in backend.LOG_SOURCES.values() for path in paths]) - # Post-install output lands in the target (chroot) and the live system (nochroot). - self.assertEqual(backend.LOG_SOURCES["post"], [Path("/mnt/sysroot/var/log/anaconda/cybexos-cleanup.log"), - Path("/var/log/anaconda/cybexos-target-policy.log")]) - hook = (ROOT / "live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks").read_text() - self.assertIn("--log=/var/log/anaconda/cybexos-cleanup.log", hook) - self.assertIn("--log=/var/log/anaconda/cybexos-target-policy.log", hook) - - def test_page_offers_exactly_the_backend_logs(self): - import re - page = (ROOT / "live-rootfs/usr/share/cockpit/cybexos-installer/index.html").read_text() - select = page[page.index('", page.index(' { setQuery(e.target.value); setSelected(0); }} onKeyDown={e => { if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(i => (i + 1) % Math.max(1, filtered.length)); } if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(i => (i + filtered.length - 1) % Math.max(1, filtered.length)); } if (e.key === 'Enter' && filtered[selected]) { e.preventDefault(); openApp(filtered[selected]); } }} />esc
APPLICATIONS {filtered.length}
{filtered.length ? filtered.map((id, index) => { const Icon = ICONS[id]; return ; }) :

No apps found for “{query}”.

}
↑↓ navigate↵ openCYBEXOS
} diff --git a/web/components/Fastfetch.tsx b/web/components/Fastfetch.tsx index 57c41fcd..f1e657d5 100644 --- a/web/components/Fastfetch.tsx +++ b/web/components/Fastfetch.tsx @@ -1,12 +1,12 @@ -import { DEMO_SYSTEM, FEDORA_LOGO, SYSTEM_DETAILS } from '../lib/terminal'; +import { DEMO_SYSTEM, CYBEXOS_LOGO, SYSTEM_DETAILS } from '../lib/terminal'; /** A semantic rendering of the same sample data returned by the command parser. */ export function Fastfetch() { return ( -
- -
Browser preview
+
Browser preview
- {panel === 'launcher' && setPanel(null)}>
{ setQuery(e.target.value); setSelected(0); }} onKeyDown={e => { if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(i => (i + 1) % Math.max(1, filtered.length)); } if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(i => (i + filtered.length - 1) % Math.max(1, filtered.length)); } if (e.key === 'Enter' && filtered[selected]) { e.preventDefault(); openApp(filtered[selected]); } }} />esc
APPLICATIONS {filtered.length}
{filtered.length ? filtered.map((id, index) => { const Icon = ICONS[id]; return ; }) :

No apps found for “{query}”.

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

No apps found for “{query}”.

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

Hyprland

dwindle · workspace {workspace}

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

{toast}

} + {toast &&
NerdOS

{toast}

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