diff --git a/.github/enable-kvm.sh b/.github/enable-kvm.sh new file mode 100755 index 0000000..37f933f --- /dev/null +++ b/.github/enable-kvm.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# enable-kvm.sh - let the runner user open /dev/kvm. +# +# GitHub-hosted x86_64 Linux runners support nested virtualisation, but the +# device node is not accessible to the unprivileged runner user by default. +# Without this every VM test silently falls back to TCG emulation, which is +# ~10-20x slower and turns timeouts into false failures. +# +# This is a CI-host setting only; it does not change SimulationOS. If the +# runner has no KVM at all the tests still run under TCG (scripts use +# "kvm:tcg"), so this never fails the job. +set -u + +if [ ! -e /dev/kvm ]; then + echo "::notice::no /dev/kvm on this runner - VM tests will use TCG emulation (slow)" + exit 0 +fi +echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules >/dev/null +sudo udevadm control --reload-rules +sudo udevadm trigger --name-match=kvm +sleep 1 +ls -l /dev/kvm +if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then + echo "KVM available to $(id -un)" +else + echo "::notice::/dev/kvm exists but is not accessible - VM tests will use TCG emulation (slow)" +fi diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 5aa95d3..66edfa4 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -1,19 +1,33 @@ # L4b/L5 - runtime acceptance of the built ISO on an x86_64 runner. # -# Two jobs, deliberately separate so a live-desktop regression is -# distinguishable from a security-cleanup regression: +# Three jobs, deliberately separate so each regression class is +# distinguishable at a glance: # # live-health boots the ISO, logs in on serial and asserts on RUNNING -# state: graphical.target, SDDM, Hyprland, session -# components, NetworkManager, DNS, PipeWire, portals. -# deloop-contract extracts the SquashFS, runs simulationos-deloop exactly as -# Calamares does, and asserts the installed-system security -# gates (liveuser gone, autologin gone, NOPASSWD gone, root -# locked, archiso initramfs hook gone). +# state: graphical.target, SDDM, Hyprland (including its own +# config-error list), session components, NetworkManager, +# DNS, PipeWire, portals, and that the installer can load. +# deloop-contract extracts the SquashFS and checks the image-level +# contracts: Calamares libraries resolve, Hyprland's +# verifier accepts the shipped config, simulationos-deloop +# removes every live concession, and mkinitcpio produces a +# normal (non-archiso) initramfs. +# install the real thing: drives the graphical installer on the live +# desktop, installs to a blank disk (UEFI + GPT + ext4 + +# GRUB), powers off, boots the DISK ALONE twice and asserts +# on the installed system. +# +# Manual runs: `gh workflow run acceptance.yml -f run_id=` tests +# the ISO built by an earlier run, so harness changes do not need a rebuild. name: acceptance on: workflow_dispatch: + inputs: + run_id: + description: Run ID of the iso workflow whose ISO artifact should be tested + type: string + required: true workflow_call: inputs: artifact: @@ -23,6 +37,7 @@ on: permissions: contents: read + actions: read jobs: live-health: @@ -31,6 +46,8 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@v4 + - name: Enable KVM + run: ./.github/enable-kvm.sh - name: Install QEMU and OVMF run: | sudo apt-get update -qq @@ -38,13 +55,11 @@ jobs: - name: Download the ISO uses: actions/download-artifact@v4 with: - name: ${{ inputs.artifact }} + pattern: ${{ inputs.artifact || 'simulationos-alpha-*' }} path: out - - name: KVM availability - run: | - # Hosted runners have no nested virtualisation; TCG is correct but - # slow. This is recorded so a timeout can be attributed correctly. - if [ -e /dev/kvm ]; then echo "KVM available"; else echo "no /dev/kvm -> TCG"; fi + merge-multiple: true + run-id: ${{ inputs.run_id || github.run_id }} + github-token: ${{ github.token }} - name: Live health checks env: SIMOS_LIVE_TIMEOUT: '2100' @@ -58,7 +73,7 @@ jobs: if-no-files-found: ignore deloop-contract: - name: De-live security contract + name: Image contracts (installer, Hyprland, de-live, initramfs) runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -70,7 +85,51 @@ jobs: - name: Download the ISO uses: actions/download-artifact@v4 with: - name: ${{ inputs.artifact }} + pattern: ${{ inputs.artifact || 'simulationos-alpha-*' }} path: out - - name: Verify the de-live transformation + merge-multiple: true + run-id: ${{ inputs.run_id || github.run_id }} + github-token: ${{ github.token }} + - name: Verify the image contracts run: sudo ./scripts/deloop-test.sh + + install: + name: Install and boot (UEFI, GPT, ext4, GRUB) + runs-on: ubuntu-latest + timeout-minutes: 150 + steps: + - uses: actions/checkout@v4 + - name: Enable KVM + run: ./.github/enable-kvm.sh + - name: Install QEMU, OVMF and OCR + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq qemu-system-x86 qemu-utils ovmf tesseract-ocr tesseract-ocr-eng + - name: Download the ISO + uses: actions/download-artifact@v4 + with: + pattern: ${{ inputs.artifact || 'simulationos-alpha-*' }} + path: out + merge-multiple: true + run-id: ${{ inputs.run_id || github.run_id }} + github-token: ${{ github.token }} + - name: Install SimulationOS and boot the installed disk + run: ./scripts/install-test.py + - name: Acceptance matrix + if: always() + run: | + if [ -f out/install-test/summary.md ]; then + echo "### Installation acceptance" >> "$GITHUB_STEP_SUMMARY" + cat out/install-test/summary.md >> "$GITHUB_STEP_SUMMARY" + fi + - name: Upload installation evidence (screenshots, logs, matrix) + if: always() + uses: actions/upload-artifact@v4 + with: + name: installation-test-${{ github.sha }} + path: | + out/install-test/ + !out/install-test/*.qcow2 + !out/install-test/OVMF_VARS.fd + retention-days: 14 + if-no-files-found: warn diff --git a/.github/workflows/boot.yml b/.github/workflows/boot.yml index 7b5dcf0..2b5cc0f 100644 --- a/.github/workflows/boot.yml +++ b/.github/workflows/boot.yml @@ -42,11 +42,8 @@ jobs: name: ${{ inputs.artifact }} path: out - - name: Enable KVM if the runner provides it - run: | - # Hosted runners usually have no nested virtualisation; TCG is the - # fallback and is slow but correct. - [ -e /dev/kvm ] && echo "KVM available" || echo "no /dev/kvm; using TCG" + - name: Enable KVM + run: ./.github/enable-kvm.sh - name: Boot test env: diff --git a/.github/workflows/iso.yml b/.github/workflows/iso.yml index 24218f7..1475d98 100644 --- a/.github/workflows/iso.yml +++ b/.github/workflows/iso.yml @@ -16,6 +16,8 @@ on: permissions: contents: read + # acceptance.yml downloads the ISO artifact through the API. + actions: read jobs: validate: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 786d35b..5fe3918 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -38,7 +38,12 @@ jobs: shellcheck -S warning build.sh scripts/*.sh \ airootfs/usr/local/bin/simos-* \ airootfs/usr/local/bin/simulationos-install \ - airootfs/usr/share/simulationos/calamares/scripts/simulationos-deloop + airootfs/usr/share/simulationos/calamares/scripts/simulationos-deloop \ + airootfs/usr/share/simulationos/calamares/scripts/simulationos-verify-target \ + .github/enable-kvm.sh + + - name: Python tooling compiles + run: python3 -m py_compile scripts/install-test.py scripts/make-branding.py - name: Installer config is valid YAML run: | @@ -82,4 +87,28 @@ jobs: if ./scripts/validate-profile.sh >/dev/null 2>&1; then echo "::error::validator did NOT catch an injected kernel-name regression"; exit 1 fi - echo "validator correctly rejected the injected regression" + echo "validator correctly rejected the injected kernel-name regression" + + # Generating the initramfs BEFORE de-living bakes the archiso hooks + # into the installed system. The validator must refuse that order. + git checkout -q -- . + python3 - <<'PY' + import re + p = "airootfs/usr/share/simulationos/calamares/settings.conf" + s = open(p).read() + s = s.replace(" - shellprocess@deloop\n - initcpiocfg\n - initcpio\n", + " - initcpiocfg\n - initcpio\n - shellprocess@deloop\n") + open(p, "w").write(s) + PY + if ./scripts/validate-profile.sh >/dev/null 2>&1; then + echo "::error::validator did NOT catch initramfs-before-deloop ordering"; exit 1 + fi + echo "validator correctly rejected initramfs generation before de-living" + + # A legacy hyprland.conf means config errors on every login. + git checkout -q -- . + printf 'windowrulev2 = float, class:^(x)$\n' > airootfs/etc/skel/.config/hypr/hyprland.conf + if ./scripts/validate-profile.sh >/dev/null 2>&1; then + echo "::error::validator did NOT catch a legacy hyprland.conf"; exit 1 + fi + echo "validator correctly rejected a legacy hyprland.conf" diff --git a/.gitignore b/.gitignore index 1cf22f9..ad7c818 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ /.cache/ /artifacts/ .DS_Store +# Installer compatibility libraries staged at build time by +# scripts/calamares-compat.sh (never committed: they track upstream packages). +/airootfs/usr/lib/simulationos/calamares-compat/ diff --git a/README.md b/README.md index 97f781b..68f7c34 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,14 @@ SimulationOS/ │ ├── validate-profile.sh static consistency checks │ ├── build-iso.sh environment checks + mkarchiso + checksum │ ├── clean-build.sh remove work/ and out/ +│ ├── calamares-compat.sh build step: make sure the installer can load its libraries │ ├── inspect-iso.sh L3 artefact inspection -│ ├── test-iso.sh boot it in QEMU (UEFI or BIOS) -│ └── make-wallpaper.py regenerate the wallpaper asset +│ ├── boot-test.sh L4 headless boot (UEFI + BIOS) +│ ├── live-health.sh L4b live desktop health (renders, no config errors, installer loads) +│ ├── deloop-test.sh L4b image contracts (de-live, initramfs, Hyprland verifier) +│ ├── install-test.py L5 real graphical install + boot of the installed disk +│ ├── test-iso.sh boot it in QEMU yourself (UEFI or BIOS) +│ └── make-branding.py regenerate wallpapers, logos and the fastfetch logo │ ├── .github/workflows/ │ ├── validate.yml L1 static validation (seconds, no container) @@ -208,8 +213,29 @@ build.log ./scripts/test-iso.sh --boot-disk # boot ONLY that disk, no ISO ``` -The last two are the real acceptance test: install to the disk, then boot the -disk with no medium attached. +The last two are the manual acceptance test: install to the disk, then boot +the disk with no medium attached. + +**Graphics in a VM.** Hyprland needs a virtual GPU. `test-iso.sh` uses +virtio-gpu (`virtio-vga-gl` with 3D on Linux, plain `virtio-vga` with software +rendering on macOS). With a plain VGA adapter (`-vga std`, or a hypervisor's +"standard VGA") Hyprland starts but has no renderer and the screen stays +black. In VirtualBox/VMware enable 3D acceleration; in virt-manager use +"Virtio" video. + +### Automated installation test + +```bash +./scripts/install-test.py # needs qemu, OVMF and tesseract +``` + +Drives the whole journey unattended, the way CachyOS tests its installer +(keyboard and mouse through QEMU, OCR to read the screen): boot the ISO, open +"Install SimulationOS" from the desktop, click through Calamares, install to a +blank disk (UEFI + GPT + ext4 + GRUB), power off, **boot the disk without the +ISO**, log in through SDDM, assert on the running system, reboot, and power +off. Screenshots of every step, logs and the result matrix land in +`out/install-test/`. CI runs this on every push (`acceptance.yml`). --- @@ -228,21 +254,35 @@ Firmware → syslinux (BIOS) / systemd-boot (UEFI) **Installation** ``` -Live session → "Install SimulationOS" → Calamares +Live session → "Install SimulationOS" (bar button / application menu) + → simulationos-install: preflight (install source, libraries, display) + → pkexec → Calamares → partition → mount → unpackfs (copy the live SquashFS) - → users → networkcfg → services-systemd → packages (drop the installer) - → removeuser (liveuser) → simulationos-deloop (strip live config) - → initcpiocfg → initcpio (rebuild a NON-archiso initramfs) - → grubcfg → bootloader (GRUB) → umount - → reboot into the installed system + → machineid → fstab → locale → keyboard → localecfg + → removeuser (liveuser) + → simulationos-deloop (strip live config, install the kernel into /boot, + drop the archiso mkinitcpio config, pacman keyring) + → initcpiocfg → initcpio (a NORMAL, non-archiso initramfs) + → users → networkcfg → displaymanager → hwclock → services-systemd + → packages (remove installer-only packages) + → grubcfg → bootloader (GRUB on the target ESP) + → simulationos-verify-target (refuse to report success for an unbootable + or still-live system) + → umount → reboot into the installed system ``` Because the offline installer copies the live filesystem verbatim, the target initially inherits every live concession. `simulationos-deloop` removes them: SDDM autologin, tty autologin, NOPASSWD sudo (replaced by a password-prompting -`%wheel` rule), the permissive polkit rule, the archiso initramfs hook, -archiso-only units, `/home/liveuser` — and it **locks the root account**, which -the live medium leaves passwordless. +`%wheel` rule), the permissive polkit rule, the archiso initramfs +configuration, archiso-only units, `/home/liveuser` — and it **locks the root +account**, which the live medium leaves passwordless. It runs *before* the +initramfs is generated; the reverse order would bake the live-medium hooks +into the installed system. It exits non-zero if a security- or boot-critical +step cannot be completed, which fails the installation visibly. + +The supported ("golden") path for Alpha is deliberately narrow: +**x86_64, UEFI, GPT, ext4, GRUB, offline install.** --- @@ -255,6 +295,14 @@ the live medium leaves passwordless. SquashFS; there is no online package selection. - **Bootloaders:** the installed system always gets GRUB. No systemd-boot, rEFInd or Limine choice. +- **Filesystems:** the installer offers ext4 only until that path has a clean + record; btrfs/xfs/f2fs are not offered. +- **Virtual machines need a virtual GPU** (virtio-gpu, or 3D acceleration + enabled). This is a Hyprland requirement; see "Test in a VM". +- **Installer library compatibility:** `cachyos-calamares` can lag behind an + Arch Boost update. The build detects this and stages the matching, + signature-verified Boost libraries for the installer only + (`scripts/calamares-compat.sh`). - **No disk encryption flow** has been exercised, though the LUKS-capable Calamares modules and `cryptsetup` are present. - **Secure Boot** is not supported. diff --git a/airootfs/etc/skel/.config/hypr/hyprland.conf b/airootfs/etc/skel/.config/hypr/hyprland.conf deleted file mode 100644 index f2fc970..0000000 --- a/airootfs/etc/skel/.config/hypr/hyprland.conf +++ /dev/null @@ -1,201 +0,0 @@ -# ┌──────────────────────────────────────────────────────────────────────────┐ -# │ SimulationOS - Hyprland configuration │ -# │ │ -# │ Deliberately small and flat so it can be debugged. Every command │ -# │ referenced here is provided by a package in packages.x86_64: │ -# │ kitty wofi thunar waybar mako hyprpaper hyprpolkitagent │ -# │ grim slurp wl-clipboard cliphist brightnessctl pavucontrol │ -# │ nm-connection-editor network-manager-applet blueman │ -# │ simos-screenshot simos-powermenu simos-clipboard (airootfs/usr/local) │ -# │ │ -# │ Put personal overrides in ~/.config/hypr/monitors.conf (sourced last). │ -# └──────────────────────────────────────────────────────────────────────────┘ - -$mainMod = SUPER -$terminal = kitty -$launcher = wofi --show drun -$fileManager = thunar - -# ──────────────────────────────────────────────────────────────── environment -env = XDG_CURRENT_DESKTOP,Hyprland -env = XDG_SESSION_TYPE,wayland -env = XDG_SESSION_DESKTOP,Hyprland -env = QT_QPA_PLATFORM,wayland;xcb -env = QT_WAYLAND_DISABLE_WINDOWDECORATION,1 -env = QT_AUTO_SCREEN_SCALE_FACTOR,1 -env = GDK_BACKEND,wayland,x11 -env = SDL_VIDEODRIVER,wayland -env = CLUTTER_BACKEND,wayland -env = MOZ_ENABLE_WAYLAND,1 -env = ELECTRON_OZONE_PLATFORM_HINT,auto -# Allows the session to come up on machines and VMs without a real GPU. -env = WLR_RENDERER_ALLOW_SOFTWARE,1 - -# ────────────────────────────────────────────────────────────────── autostart -# Order matters: the portal/dbus environment must be exported before apps that -# rely on it (file pickers, screensharing) are launched. -exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP -exec-once = systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP - -exec-once = hyprpaper -exec-once = waybar -exec-once = mako -exec-once = systemctl --user start hyprpolkitagent -exec-once = nm-applet --indicator -exec-once = blueman-applet -exec-once = wl-paste --watch cliphist store - -# ─────────────────────────────────────────────────────────────────── monitors -# Default: let Hyprland pick the preferred mode for every output. -monitor = , preferred, auto, 1 - -# ─────────────────────────────────────────────────────────────────────── input -input { - kb_layout = us - follow_mouse = 1 - sensitivity = 0 - touchpad { - natural_scroll = true - tap-to-click = true - disable_while_typing = true - } -} - -gestures { - workspace_swipe = true - workspace_swipe_fingers = 3 -} - -# ───────────────────────────────────────────────────────────────── appearance -general { - gaps_in = 4 - gaps_out = 8 - border_size = 2 - col.active_border = rgba(1793d1ee) rgba(33ccffee) 45deg - col.inactive_border = rgba(2a2a2aaa) - layout = dwindle - resize_on_border = true -} - -decoration { - rounding = 8 - blur { - enabled = true - size = 3 - passes = 1 - } - shadow { - enabled = true - range = 12 - render_power = 3 - color = rgba(00000055) - } -} - -animations { - enabled = true - bezier = easeOutQuint, 0.23, 1, 0.32, 1 - animation = windows, 1, 4, easeOutQuint, popin 90% - animation = fade, 1, 4, easeOutQuint - animation = workspaces, 1, 5, easeOutQuint, slide - animation = border, 1, 6, easeOutQuint -} - -dwindle { - pseudotile = true - preserve_split = true -} - -misc { - disable_hyprland_logo = true - disable_splash_rendering = true - force_default_wallpaper = 0 - font_family = Fira Sans -} - -# Software cursors keep the pointer visible in VMs and on drivers with broken -# hardware cursor planes. SimulationOS is expected to be evaluated in a VM. -cursor { - no_hardware_cursors = true -} - -# ─────────────────────────────────────────────────────────────────── keybinds -bind = $mainMod, Return, exec, $terminal -bind = $mainMod, D, exec, $launcher -bind = $mainMod, E, exec, $fileManager -bind = $mainMod, Q, killactive, -bind = $mainMod SHIFT, E, exit, -bind = $mainMod, V, togglefloating, -bind = $mainMod, P, pseudo, -bind = $mainMod, J, togglesplit, -bind = $mainMod, F, fullscreen, 0 -bind = $mainMod, R, exec, hyprctl reload -bind = $mainMod, X, exec, simos-powermenu -bind = $mainMod, L, exec, hyprlock -bind = $mainMod, C, exec, simos-clipboard -bind = $mainMod SHIFT, N, exec, nm-connection-editor - -# Screenshots -bind = , Print, exec, simos-screenshot full -bind = SHIFT, Print, exec, simos-screenshot area -bind = $mainMod, Print, exec, simos-screenshot window - -# Focus -bind = $mainMod, left, movefocus, l -bind = $mainMod, right, movefocus, r -bind = $mainMod, up, movefocus, u -bind = $mainMod, down, movefocus, d - -# Move windows -bind = $mainMod SHIFT, left, movewindow, l -bind = $mainMod SHIFT, right, movewindow, r -bind = $mainMod SHIFT, up, movewindow, u -bind = $mainMod SHIFT, down, movewindow, d - -# Workspaces -bind = $mainMod, 1, workspace, 1 -bind = $mainMod, 2, workspace, 2 -bind = $mainMod, 3, workspace, 3 -bind = $mainMod, 4, workspace, 4 -bind = $mainMod, 5, workspace, 5 -bind = $mainMod, 6, workspace, 6 -bind = $mainMod, 7, workspace, 7 -bind = $mainMod, 8, workspace, 8 -bind = $mainMod, 9, workspace, 9 -bind = $mainMod, 0, workspace, 10 -bind = $mainMod SHIFT, 1, movetoworkspace, 1 -bind = $mainMod SHIFT, 2, movetoworkspace, 2 -bind = $mainMod SHIFT, 3, movetoworkspace, 3 -bind = $mainMod SHIFT, 4, movetoworkspace, 4 -bind = $mainMod SHIFT, 5, movetoworkspace, 5 -bind = $mainMod SHIFT, 6, movetoworkspace, 6 -bind = $mainMod SHIFT, 7, movetoworkspace, 7 -bind = $mainMod SHIFT, 8, movetoworkspace, 8 -bind = $mainMod SHIFT, 9, movetoworkspace, 9 -bind = $mainMod SHIFT, 0, movetoworkspace, 10 - -bind = $mainMod, mouse_down, workspace, e+1 -bind = $mainMod, mouse_up, workspace, e-1 -bindm = $mainMod, mouse:272, movewindow -bindm = $mainMod, mouse:273, resizewindow - -# Media / hardware keys -bindel = , XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ -bindel = , XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- -bindl = , XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle -bindl = , XF86AudioMicMute, exec, wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle -bindel = , XF86MonBrightnessUp, exec, brightnessctl set 5%+ -bindel = , XF86MonBrightnessDown, exec, brightnessctl set 5%- - -# ────────────────────────────────────────────────────────────────── windowrules -windowrulev2 = float, class:^(pavucontrol)$ -windowrulev2 = float, class:^(nm-connection-editor)$ -windowrulev2 = float, class:^(blueman-manager)$ -windowrulev2 = float, class:^(org.gnome.FileRoller)$ -windowrulev2 = float, title:^(Install SimulationOS)$ -windowrulev2 = size 1024 680, class:^(calamares)$ -windowrulev2 = center, class:^(calamares)$ -windowrulev2 = suppressevent maximize, class:.* - -# ───────────────────────────────────────────────────────── user overrides last -source = ~/.config/hypr/monitors.conf diff --git a/airootfs/etc/skel/.config/hypr/hyprland.lua b/airootfs/etc/skel/.config/hypr/hyprland.lua new file mode 100644 index 0000000..8a95125 --- /dev/null +++ b/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -0,0 +1,231 @@ +-- ┌──────────────────────────────────────────────────────────────────────────┐ +-- │ SimulationOS - Hyprland configuration │ +-- │ │ +-- │ Hyprland >= 0.55 is configured in Lua (~/.config/hypr/hyprland.lua). │ +-- │ Deliberately small and flat so it can be debugged. Every command │ +-- │ referenced here is provided by a package in packages.x86_64: │ +-- │ kitty wofi thunar waybar mako swaybg hyprlock hyprpolkitagent │ +-- │ grim slurp wl-clipboard cliphist brightnessctl pavucontrol │ +-- │ nm-connection-editor network-manager-applet blueman │ +-- │ simos-* helpers (airootfs/usr/local/bin) │ +-- │ │ +-- │ Put personal settings in ~/.config/hypr/user.lua (loaded last). │ +-- │ Check this file with: Hyprland --verify-config │ +-- └──────────────────────────────────────────────────────────────────────────┘ + +local mainMod = "SUPER" +local terminal = "kitty" +local launcher = "wofi --show drun" +local fileManager = "thunar" + +-- ─────────────────────────────────────────────────────────────────── monitors +-- Let Hyprland pick the preferred mode for every output. +hl.monitor({ + output = "", + mode = "preferred", + position = "auto", + scale = 1, +}) + +-- ──────────────────────────────────────────────────────────────── environment +hl.env("XDG_CURRENT_DESKTOP", "Hyprland") +hl.env("XDG_SESSION_TYPE", "wayland") +hl.env("XDG_SESSION_DESKTOP", "Hyprland") +hl.env("QT_QPA_PLATFORM", "wayland;xcb") +hl.env("QT_WAYLAND_DISABLE_WINDOWDECORATION", "1") +hl.env("QT_AUTO_SCREEN_SCALE_FACTOR", "1") +hl.env("GDK_BACKEND", "wayland,x11") +hl.env("SDL_VIDEODRIVER", "wayland") +hl.env("CLUTTER_BACKEND", "wayland") +hl.env("MOZ_ENABLE_WAYLAND", "1") +hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") +hl.env("XCURSOR_SIZE", "24") +hl.env("HYPRCURSOR_SIZE", "24") + +-- ────────────────────────────────────────────────────────────────── autostart +-- One ordered script instead of a list of exec commands: Hyprland runs +-- separate commands concurrently, and the session environment must reach +-- D-Bus/systemd BEFORE anything that depends on it starts. See simos-session. +hl.on("hyprland.start", function() + hl.exec_cmd("simos-session") +end) + +-- ─────────────────────────────────────────────────────────────────── keyboard +-- Use the keyboard chosen in the installer. Calamares records it in +-- /etc/default/keyboard (XKBLAYOUT="de" ...); without this the session would +-- always be "us" no matter what was selected. Falls back to "us" when the +-- file does not exist (the live medium). +local keyboard = { layout = "us", variant = "", model = "", options = "" } +do + local file = io.open("/etc/default/keyboard", "r") + if file then + for line in file:lines() do + local key, value = line:match('^XKB(%u+)="?([^"]*)"?') + if key and value ~= "" then + keyboard[key:lower()] = value + end + end + file:close() + end +end + +-- ────────────────────────────────────────────────────────────────── appearance +hl.config({ + general = { + gaps_in = 4, + gaps_out = 8, + border_size = 2, + col = { + active_border = { colors = { "rgba(1793d1ee)", "rgba(33ccffee)" }, angle = 45 }, + inactive_border = "rgba(2a2a2aaa)", + }, + layout = "dwindle", + resize_on_border = true, + }, + + decoration = { + rounding = 8, + blur = { + enabled = true, + size = 3, + passes = 1, + }, + shadow = { + enabled = true, + range = 12, + render_power = 3, + color = "rgba(00000055)", + }, + }, + + animations = { + enabled = true, + }, + + dwindle = { + preserve_split = true, + }, + + -- No upstream "what's new" / donation pop-ups on first start. + ecosystem = { + no_update_news = true, + no_donation_nag = true, + }, + + misc = { + -- SimulationOS ships its own wallpapers; no upstream mascot/logo. + disable_hyprland_logo = true, + disable_splash_rendering = true, + force_default_wallpaper = 0, + font_family = "Fira Sans", + }, + + -- Software cursors keep the pointer visible in VMs and on drivers with + -- broken hardware cursor planes. + cursor = { + no_hardware_cursors = 1, + }, + + input = { + kb_layout = keyboard.layout, + kb_variant = keyboard.variant, + kb_model = keyboard.model, + kb_options = keyboard.options, + follow_mouse = 1, + sensitivity = 0, + touchpad = { + natural_scroll = true, + tap_to_click = true, + disable_while_typing = true, + }, + }, +}) + +hl.curve("easeOutQuint", { type = "bezier", points = { { 0.23, 1 }, { 0.32, 1 } } }) +hl.animation({ leaf = "windows", enabled = true, speed = 4, bezier = "easeOutQuint", style = "popin 90%" }) +hl.animation({ leaf = "fade", enabled = true, speed = 4, bezier = "easeOutQuint" }) +hl.animation({ leaf = "workspaces", enabled = true, speed = 5, bezier = "easeOutQuint", style = "slide" }) +hl.animation({ leaf = "border", enabled = true, speed = 6, bezier = "easeOutQuint" }) + +hl.gesture({ + fingers = 3, + direction = "horizontal", + action = "workspace", +}) + +-- ─────────────────────────────────────────────────────────────────── keybinds +hl.bind(mainMod .. " + Return", hl.dsp.exec_cmd(terminal)) +hl.bind(mainMod .. " + D", hl.dsp.exec_cmd(launcher)) +hl.bind(mainMod .. " + E", hl.dsp.exec_cmd(fileManager)) +hl.bind(mainMod .. " + Q", hl.dsp.window.close()) +hl.bind(mainMod .. " + SHIFT + E", hl.dsp.exit()) +hl.bind(mainMod .. " + V", hl.dsp.window.float({ action = "toggle" })) +hl.bind(mainMod .. " + P", hl.dsp.window.pseudo()) +hl.bind(mainMod .. " + J", hl.dsp.layout("togglesplit")) +hl.bind(mainMod .. " + F", hl.dsp.window.fullscreen({ mode = "fullscreen" })) +hl.bind(mainMod .. " + R", hl.dsp.exec_cmd("hyprctl reload")) +hl.bind(mainMod .. " + X", hl.dsp.exec_cmd("simos-powermenu")) +hl.bind(mainMod .. " + L", hl.dsp.exec_cmd("hyprlock")) +hl.bind(mainMod .. " + C", hl.dsp.exec_cmd("simos-clipboard")) +hl.bind(mainMod .. " + W", hl.dsp.exec_cmd("simos-wallpaper next")) +hl.bind(mainMod .. " + SHIFT + N", hl.dsp.exec_cmd("nm-connection-editor")) + +-- Screenshots +hl.bind("Print", hl.dsp.exec_cmd("simos-screenshot full")) +hl.bind("SHIFT + Print", hl.dsp.exec_cmd("simos-screenshot area")) +hl.bind(mainMod .. " + Print", hl.dsp.exec_cmd("simos-screenshot window")) + +-- Focus and move +for _, dir in ipairs({ "left", "right", "up", "down" }) do + hl.bind(mainMod .. " + " .. dir, hl.dsp.focus({ direction = dir })) + hl.bind(mainMod .. " + SHIFT + " .. dir, hl.dsp.window.move({ direction = dir })) +end + +-- Workspaces 1-10 (key 0 is workspace 10) +for i = 1, 10 do + local key = i % 10 + hl.bind(mainMod .. " + " .. key, hl.dsp.focus({ workspace = i })) + hl.bind(mainMod .. " + SHIFT + " .. key, hl.dsp.window.move({ workspace = i })) +end + +hl.bind(mainMod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" })) +hl.bind(mainMod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" })) +hl.bind(mainMod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) +hl.bind(mainMod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) + +-- Media / hardware keys +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), { locked = true, repeating = true }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, repeating = true }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true }) +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true }) +hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl set 5%+"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl set 5%-"), { locked = true, repeating = true }) + +-- ──────────────────────────────────────────────────────────────── window rules +hl.window_rule({ + name = "suppress-maximize-events", + match = { class = ".*" }, + suppress_event = "maximize", +}) + +for _, class in ipairs({ "pavucontrol", "org.pulseaudio.pavucontrol", "nm-connection-editor", "blueman-manager" }) do + hl.window_rule({ + name = "float-" .. class, + match = { class = "^(" .. class .. ")$" }, + float = true, + }) +end + +-- The installer is a dialog-sized window: float it, centred, at a size where +-- every Calamares page fits without scrolling. +hl.window_rule({ + name = "installer", + match = { class = "^(calamares|io.calamares.calamares)$" }, + float = true, + center = true, + size = { 1100, 720 }, +}) + +-- ───────────────────────────────────────────────────────── user overrides last +-- pcall: a missing or broken user.lua must never take the session down. +pcall(require, "user") diff --git a/airootfs/etc/skel/.config/hypr/hyprlock.conf b/airootfs/etc/skel/.config/hypr/hyprlock.conf index 5aed749..24bdaaf 100644 --- a/airootfs/etc/skel/.config/hypr/hyprlock.conf +++ b/airootfs/etc/skel/.config/hypr/hyprlock.conf @@ -1,7 +1,7 @@ # SimulationOS - hyprlock background { monitor = - path = /usr/share/backgrounds/simulationos/simulationos-alpha.png + path = /usr/share/backgrounds/simulationos/simos-winter.png blur_passes = 2 blur_size = 6 } @@ -23,7 +23,7 @@ input-field { label { monitor = - text = SimulationOS + text = SimOS color = rgba(230, 230, 230, 1.0) font_size = 30 font_family = Fira Sans @@ -31,3 +31,15 @@ label { halign = center valign = center } + +# "SimOS" in ASCII binary. +label { + monitor = + text = 01010011 01101001 01101101 01001111 01010011 + color = rgba(51, 204, 255, 0.85) + font_size = 12 + font_family = JetBrainsMono Nerd Font + position = 0, 20 + halign = center + valign = center +} diff --git a/airootfs/etc/skel/.config/hypr/hyprpaper.conf b/airootfs/etc/skel/.config/hypr/hyprpaper.conf deleted file mode 100644 index 691e130..0000000 --- a/airootfs/etc/skel/.config/hypr/hyprpaper.conf +++ /dev/null @@ -1,6 +0,0 @@ -# SimulationOS wallpaper. -# The image is shipped in airootfs/usr/share/backgrounds/simulationos/. -preload = /usr/share/backgrounds/simulationos/simulationos-alpha.png -wallpaper = , /usr/share/backgrounds/simulationos/simulationos-alpha.png -splash = false -ipc = on diff --git a/airootfs/etc/skel/.config/hypr/monitors.conf b/airootfs/etc/skel/.config/hypr/monitors.conf deleted file mode 100644 index d8df80e..0000000 --- a/airootfs/etc/skel/.config/hypr/monitors.conf +++ /dev/null @@ -1,11 +0,0 @@ -# SimulationOS - per-machine monitor and override file. -# -# This file is sourced at the END of hyprland.conf, so anything set here wins. -# It is intentionally empty by default: the generic -# monitor = , preferred, auto, 1 -# rule in hyprland.conf already configures every detected output. -# -# Examples: -# monitor = eDP-1, 1920x1080@60, 0x0, 1 -# monitor = HDMI-A-1, preferred, 1920x0, 1 -# monitor = , disable diff --git a/airootfs/etc/skel/.config/hypr/user.lua b/airootfs/etc/skel/.config/hypr/user.lua new file mode 100644 index 0000000..7f94103 --- /dev/null +++ b/airootfs/etc/skel/.config/hypr/user.lua @@ -0,0 +1,10 @@ +-- SimulationOS - personal Hyprland settings. +-- +-- Loaded at the END of hyprland.lua, so anything set here wins. +-- It is intentionally empty by default. +-- +-- Examples: +-- hl.monitor({ output = "eDP-1", mode = "1920x1080@60", position = "0x0", scale = 1 }) +-- hl.monitor({ output = "HDMI-A-1", mode = "preferred", position = "1920x0", scale = 1 }) +-- hl.config({ input = { kb_layout = "us,de" } }) +-- hl.bind("SUPER + B", hl.dsp.exec_cmd("firefox")) diff --git a/airootfs/etc/skel/.config/waybar/config.jsonc b/airootfs/etc/skel/.config/waybar/config.jsonc index a997daa..28903e0 100644 --- a/airootfs/etc/skel/.config/waybar/config.jsonc +++ b/airootfs/etc/skel/.config/waybar/config.jsonc @@ -1,15 +1,15 @@ // SimulationOS - Waybar // -// Built-in modules only, plus one custom module that calls simos-powermenu -// (shipped in airootfs/usr/local/bin). Nothing here depends on an external -// helper script, so a missing script can never blank the bar. +// Built-in modules plus two custom ones: the power menu (simos-powermenu) and, +// on the live medium only, the installer button (simulationos-install). Both +// helpers ship in airootfs/usr/local/bin. { "layer": "top", "position": "top", "height": 34, "spacing": 6, - "modules-left": ["hyprland/workspaces", "hyprland/submap"], + "modules-left": ["custom/install", "hyprland/workspaces", "hyprland/submap"], "modules-center": ["hyprland/window"], "modules-right": ["tray", "pulseaudio", "network", "cpu", "memory", "temperature", "battery", "clock", "custom/power"], @@ -78,6 +78,18 @@ "tooltip-format": "{calendar}" }, + // Shown only while the installer exists, i.e. on the live medium: + // simulationos-deloop deletes the launcher from the installed system, and a + // custom module whose exec-if fails is hidden. + "custom/install": { + "exec-if": "test -x /usr/local/bin/simulationos-install", + "exec": "echo 'Install SimulationOS'", + "interval": "once", + "format": "󰋊 {}", + "tooltip": false, + "on-click": "simulationos-install" + }, + "custom/power": { "format": "󰐥", "tooltip": false, diff --git a/airootfs/etc/skel/.config/waybar/style.css b/airootfs/etc/skel/.config/waybar/style.css index f721b13..9160b19 100644 --- a/airootfs/etc/skel/.config/waybar/style.css +++ b/airootfs/etc/skel/.config/waybar/style.css @@ -72,3 +72,17 @@ window#waybar { #battery.warning { color: #f0c674; } + +/* Live medium only: the primary call to action. */ +#custom-install { + background: #1793d1; + color: #0b0e13; + font-weight: bold; + padding: 0 14px; + margin: 4px 6px 4px 4px; + border-radius: 6px; +} + +#custom-install:hover { + background: #33ccff; +} diff --git a/airootfs/etc/xdg/fastfetch/config.jsonc b/airootfs/etc/xdg/fastfetch/config.jsonc new file mode 100644 index 0000000..c0d536f --- /dev/null +++ b/airootfs/etc/xdg/fastfetch/config.jsonc @@ -0,0 +1,40 @@ +// SimulationOS - fastfetch +// +// The logo is "SimOS" as a dot matrix of binary digits, followed by the name +// in ASCII binary. It is generated by scripts/make-branding.py. +{ + "$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json", + "logo": { + "type": "file", + "source": "/usr/share/simulationos/fastfetch/logo.txt", + "color": { + "1": "bright_cyan", + "2": "38;5;238", + "3": "cyan" + }, + "padding": { "top": 1, "left": 2, "right": 3 } + }, + "display": { + "separator": " ", + "color": { "keys": "cyan" } + }, + "modules": [ + "title", + "separator", + "os", + "host", + "kernel", + "uptime", + "packages", + "shell", + "wm", + "terminal", + "cpu", + "gpu", + "memory", + "disk", + "localip", + "break", + "colors" + ] +} diff --git a/airootfs/usr/local/bin/simos-session b/airootfs/usr/local/bin/simos-session new file mode 100755 index 0000000..b8bcad0 --- /dev/null +++ b/airootfs/usr/local/bin/simos-session @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# SimulationOS session startup, run once by Hyprland (hyprland.lua). +# +# Everything is started from ONE script because order matters and Hyprland +# runs separate exec commands concurrently: +# +# 1. export the session environment to D-Bus and the systemd user manager +# and WAIT for that to finish. Services activated before this point +# (notification daemon, polkit agent, portals) start without +# WAYLAND_DISPLAY and fail - which is exactly what an unordered +# autostart list does on a slow machine. +# 2. only then start the desktop components. +# +# Every command here is provided by packages.x86_64 or airootfs/usr/local/bin. +set -u + +dbus-update-activation-environment --systemd \ + WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE HYPRLAND_INSTANCE_SIGNATURE +# Units that failed in an earlier session (e.g. after a compositor restart) +# must be allowed to start again. +systemctl --user reset-failed mako.service hyprpolkitagent.service 2>/dev/null + +start() { setsid -f "$@" >/dev/null 2>&1; } + +start simos-wallpaper start +start waybar +start mako +systemctl --user start hyprpolkitagent.service +start nm-applet --indicator +start blueman-applet +start wl-paste --watch cliphist store +# First-session greeting (and, on the live medium, how to install). +start simos-welcome diff --git a/airootfs/usr/local/bin/simos-wallpaper b/airootfs/usr/local/bin/simos-wallpaper new file mode 100755 index 0000000..980ce65 --- /dev/null +++ b/airootfs/usr/local/bin/simos-wallpaper @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# SimulationOS wallpaper. +# +# simos-wallpaper start show the selected wallpaper (session startup) +# simos-wallpaper next cycle to the next shipped wallpaper (Super+W) +# simos-wallpaper throne | winter | fire +# simos-wallpaper list +# +# The wallpaper is drawn by swaybg. It was chosen over hyprpaper on purpose: +# swaybg uses plain shared-memory buffers, so it works identically on real +# GPUs and on software-rendered machines (VMs without 3D acceleration), where +# hyprpaper >= 0.8 cannot allocate its buffers and crashes. +# +# The selection is stored per user and survives re-login. +set -euo pipefail + +DIR=/usr/share/backgrounds/simulationos +STATE="${XDG_CONFIG_HOME:-$HOME/.config}/simulationos/wallpaper" +NAMES=(throne winter fire) + +current="$(cat "$STATE" 2>/dev/null || true)" +[ -f "$DIR/simos-$current.png" ] || current="${NAMES[0]}" + +case "${1:-next}" in + list) printf '%s\n' "${NAMES[@]}"; exit 0 ;; + start) want="$current" ;; + next) + want="${NAMES[0]}" + for i in "${!NAMES[@]}"; do + if [ "${NAMES[$i]}" = "$current" ]; then + want="${NAMES[$(( (i + 1) % ${#NAMES[@]} ))]}" + fi + done ;; + *) want="$1" ;; +esac + +path="$DIR/simos-$want.png" +[ -f "$path" ] || { printf 'simos-wallpaper: no such wallpaper: %s\n' "$want" >&2; exit 1; } + +mkdir -p "$(dirname "$STATE")" +printf '%s\n' "$want" > "$STATE" + +# Start the new instance before stopping the old one, so the screen never +# flashes to an empty background. +old="$(pgrep -u "$(id -u)" -x swaybg || true)" +setsid -f swaybg -m fill -i "$path" >/dev/null 2>&1 +if [ -n "$old" ]; then + sleep 0.6 + # shellcheck disable=SC2086 # a list of PIDs + kill $old 2>/dev/null || true +fi diff --git a/airootfs/usr/local/bin/simos-welcome b/airootfs/usr/local/bin/simos-welcome new file mode 100755 index 0000000..a547b91 --- /dev/null +++ b/airootfs/usr/local/bin/simos-welcome @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# SimulationOS session greeting, started once per Hyprland session. +# +# On the live medium it tells the user how to install; on an installed system +# it shows the essential shortcuts the first time a user logs in. +set -u + +# Wait for the notification daemon rather than racing it. +for _ in $(seq 1 50); do + busctl --user status org.freedesktop.Notifications >/dev/null 2>&1 && break + sleep 0.2 +done + +if [ -x /usr/local/bin/simulationos-install ]; then + notify-send -a SimulationOS -i simulationos -t 20000 \ + "Welcome to SimOS (live session)" \ + "Click 'Install SimulationOS' in the top bar to install. +Super+Return terminal · Super+D apps · Super+W wallpaper" + exit 0 +fi + +stamp="${XDG_STATE_HOME:-$HOME/.local/state}/simulationos/welcomed" +[ -e "$stamp" ] && exit 0 +mkdir -p "$(dirname "$stamp")" && : > "$stamp" +notify-send -a SimulationOS -i simulationos -t 20000 \ + "Welcome to SimOS" \ + "Super+Return terminal · Super+D apps · Super+E files +Super+W wallpaper · Super+X power menu · Super+Q close window" diff --git a/airootfs/usr/local/bin/simulationos-install b/airootfs/usr/local/bin/simulationos-install index 0f31103..f5f0850 100755 --- a/airootfs/usr/local/bin/simulationos-install +++ b/airootfs/usr/local/bin/simulationos-install @@ -1,48 +1,250 @@ #!/usr/bin/env bash # -# SimulationOS graphical installer launcher. +# simulationos-install - launch the SimulationOS installer (Calamares). # -# Starts Calamares against SimulationOS's own configuration tree. That tree -# lives at a SimulationOS-owned path rather than /etc/calamares because the -# cachyos-calamares package already owns /etc/calamares/modules/*.conf and has -# no backup= array, so shipping our own files there would be a hard pacstrap -# file conflict. Calamares' -c/--config flag overrides appDataDir, which -# relocates settings.conf, modules/ AND branding/ into that one directory. +# This is what the "Install SimulationOS" launcher (Waybar button, application +# menu entry) runs. It works in two stages: # -set -euo pipefail +# 1. as the desktop user: check prerequisites, then re-execute itself as root +# through pkexec/polkit, handing over ONLY the variables a GUI needs to +# reach the running Wayland session. +# 2. as root (--as-root): verify the install source, start Calamares with the +# SimulationOS configuration, and keep logs and diagnostics. +# +# Privilege model: polkit, not sudo. On the live medium a polkit rule lets the +# passwordless live user authorise this without a prompt; that rule, this +# script and its menu entry are all removed from the installed system by +# simulationos-deloop. +# +# Every failure is reported in the desktop (notification) AND in the log; the +# user is never left with a launcher that silently does nothing. +# +set -uo pipefail +SELF="/usr/local/bin/simulationos-install" CONFIG_DIR="/usr/share/simulationos/calamares" -LOG="${HOME:-/tmp}/simulationos-install.log" - -die() { printf 'simulationos-install: %s\n' "$1" >&2; exit 1; } - -[ -x /usr/bin/calamares ] || die "Calamares is not installed (/usr/bin/calamares missing)." -[ -r "$CONFIG_DIR/settings.conf" ] || die "SimulationOS installer config missing: $CONFIG_DIR/settings.conf" - -# Re-exec with privileges if we are not root yet. -if [ "$(id -u)" -ne 0 ]; then - if command -v pkexec >/dev/null 2>&1; then - exec pkexec env \ - XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" \ - WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" \ - DISPLAY="${DISPLAY:-}" \ - XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-Hyprland}" \ - QT_QPA_PLATFORM='wayland;xcb' \ - SIMOS_INSTALL_LOG="$LOG" \ - /usr/local/bin/simulationos-install +COMPAT_DIR="/usr/lib/simulationos/calamares-compat" +SFS_CANDIDATES=( + /run/archiso/bootmnt/arch/x86_64/airootfs.sfs + /run/archiso/copytoram/airootfs.sfs +) + +say() { printf 'simulationos-install: %s\n' "$*" >&2; } + +# notify TITLE BODY - desktop notification as the session user, best effort. +notify() { + command -v notify-send >/dev/null 2>&1 || return 0 + if [ "$(id -u)" -eq 0 ] && [ -n "${SIMOS_SESSION_UID:-}" ]; then + runuser -u "$(id -nu "$SIMOS_SESSION_UID")" -- env \ + XDG_RUNTIME_DIR="/run/user/$SIMOS_SESSION_UID" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$SIMOS_SESSION_UID/bus" \ + notify-send -u critical -a "SimulationOS Installer" -i simulationos "$1" "$2" \ + >/dev/null 2>&1 || true + else + notify-send -u critical -a "SimulationOS Installer" -i simulationos "$1" "$2" \ + >/dev/null 2>&1 || true + fi +} + +die() { + say "$1" + [ -n "${LOG:-}" ] && printf '===== FATAL: %s\n' "$1" >>"$LOG" 2>/dev/null + notify "The installer could not start" "$1${LOG:+ + +Log: $LOG}" + exit 1 +} + +# ============================================================ stage 1: user === +if [ "${1:-}" != "--as-root" ]; then + LOG="${HOME:-/tmp}/simulationos-install.log" + : >>"$LOG" 2>/dev/null || LOG="/tmp/simulationos-install.log" + + [ -x /usr/bin/calamares ] || die "Calamares is not installed (/usr/bin/calamares is missing)." + [ -r "$CONFIG_DIR/settings.conf" ] || die "The installer configuration is missing ($CONFIG_DIR/settings.conf)." + + if pgrep -x calamares >/dev/null 2>&1; then + notify "The installer is already running" "Look for the SimulationOS Installer window." + exit 0 + fi + + if [ "$(id -u)" -eq 0 ]; then + export SIMOS_SESSION_UID="${SUDO_UID:-0}" SIMOS_INSTALL_LOG="$LOG" + exec "$SELF" --as-root fi - die "Need root privileges and pkexec is unavailable. Try: sudo -E simulationos-install" + + command -v pkexec >/dev/null 2>&1 || die "pkexec (polkit) is not available, so the installer cannot obtain administrator rights." + + runtime="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + [ -n "${WAYLAND_DISPLAY:-}" ] || [ -n "${DISPLAY:-}" ] \ + || die "No graphical session found (neither WAYLAND_DISPLAY nor DISPLAY is set)." + + # pkexec starts the target with a minimal environment. Pass through exactly + # what Qt needs to open a window in this session, nothing else. + pkexec env \ + XDG_RUNTIME_DIR="$runtime" \ + WAYLAND_DISPLAY="${WAYLAND_DISPLAY:-}" \ + DISPLAY="${DISPLAY:-}" \ + XAUTHORITY="${XAUTHORITY:-}" \ + XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-Hyprland}" \ + XDG_SESSION_TYPE="${XDG_SESSION_TYPE:-wayland}" \ + SIMOS_SESSION_UID="$(id -u)" \ + SIMOS_INSTALL_LOG="$LOG" \ + "$SELF" --as-root + rc=$? + case "$rc" in + 0) exit 0 ;; + 126) die "Administrator authorisation was dismissed, so the installer was not started." ;; + 127) die "Administrator authorisation failed (polkit refused the request)." ;; + *) say "installer exited with status $rc (details in $LOG)"; exit "$rc" ;; + esac fi -LOG="${SIMOS_INSTALL_LOG:-$LOG}" +# ============================================================ stage 2: root === +[ "$(id -u)" -eq 0 ] || { say "--as-root requires root"; exit 1; } + +LOG="${SIMOS_INSTALL_LOG:-/var/log/simulationos-install.log}" +DIAG="${LOG%.log}-diagnostics.txt" +SESSION_UID="${SIMOS_SESSION_UID:-0}" +export HOME=/root + +own_logs() { + [ "$SESSION_UID" != "0" ] && chown "$SESSION_UID" "$LOG" "$DIAG" 2>/dev/null + return 0 +} -# Make sure the clock is sane before any signature check happens. -timedatectl set-ntp true 2>/dev/null || true +# collect_diagnostics - everything needed to debug a failed installation +# without asking the user to run commands. +collect_diagnostics() { + { + printf '===== SimulationOS installer diagnostics: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + printf '\n===== lsblk -f\n'; lsblk -f 2>&1 + printf '\n===== blkid\n'; blkid 2>&1 + printf '\n===== findmnt\n'; findmnt 2>&1 + for root in /tmp/calamares-root-*; do + [ -d "$root" ] || continue + printf '\n===== target %s\n' "$root" + printf -- '----- etc/fstab\n'; cat "$root/etc/fstab" 2>&1 + printf -- '----- boot\n'; ls -la "$root/boot" "$root/boot/grub" 2>&1 | head -60 + printf -- '----- ESP\n'; find "$root/boot/efi" -maxdepth 4 2>&1 | head -60 + printf -- '----- mkinitcpio config\n'; grep -H '^HOOKS' "$root/etc/mkinitcpio.conf" "$root"/etc/mkinitcpio.conf.d/* 2>&1 + printf -- '----- tree summary\n'; du -xsh "$root" 2>&1; ls "$root" 2>&1 + done + printf '\n===== efibootmgr -v\n'; efibootmgr -v 2>&1 + printf '\n===== journal (this boot, last 300 lines)\n' + journalctl -b --no-pager -n 300 2>&1 + } >"$DIAG" 2>&1 +} { - printf '===== SimulationOS installation started: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + printf '\n===== SimulationOS installation started: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" printf '===== ISO version: %s\n' "$(sed -n 's/^DISTRIB_RELEASE=//p' /etc/simulationos-release 2>/dev/null || echo unknown)" - command -v inxi >/dev/null 2>&1 && inxi -Fxz 2>/dev/null || true + printf '===== firmware: %s\n' "$([ -d /sys/firmware/efi ] && echo UEFI || echo BIOS)" +} >>"$LOG" 2>&1 +own_logs + +# --- install source ---------------------------------------------------------- +# The SquashFS path is a property of how the medium was booted (normal boot vs +# copytoram), so it is verified at runtime instead of assumed. +SFS="" +for candidate in "${SFS_CANDIDATES[@]}"; do + [ -r "$candidate" ] && { SFS="$candidate"; break; } +done +[ -n "$SFS" ] || die "The installation image (airootfs.sfs) was not found. This installer only works when SimulationOS is booted from its live medium." +printf '===== install source: %s (%s bytes)\n' "$SFS" "$(stat -c %s "$SFS")" >>"$LOG" + +UNPACK_CONF="$CONFIG_DIR/modules/unpackfs.conf" +if ! grep -qF "\"$SFS\"" "$UNPACK_CONF"; then + sed -i -E "s|^([[:space:]]*-[[:space:]]*source:[[:space:]]*)\"[^\"]*airootfs\.sfs\"|\1\"$SFS\"|" "$UNPACK_CONF" \ + || die "Could not point the installer at $SFS." + grep -qF "\"$SFS\"" "$UNPACK_CONF" || die "Could not point the installer at $SFS." + printf '===== unpackfs source rewritten to %s\n' "$SFS" >>"$LOG" +fi + +# --- libraries --------------------------------------------------------------- +# cachyos-calamares can lag behind an Arch soname bump (see +# scripts/calamares-compat.sh). The build stages the libraries it needs in +# COMPAT_DIR; they are visible to the installer only, never system-wide. +if [ -d "$COMPAT_DIR" ]; then + export LD_LIBRARY_PATH="$COMPAT_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +fi +missing="$(ldd /usr/bin/calamares 2>/dev/null | awk '/not found/{print $1}' | sort -u | tr '\n' ' ')" +[ -z "$missing" ] || die "Calamares cannot start: missing libraries: $missing" + +# --- display ----------------------------------------------------------------- +if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -S "${XDG_RUNTIME_DIR:-/nonexistent}/$WAYLAND_DISPLAY" ]; then + export QT_QPA_PLATFORM="wayland;xcb" +elif [ -n "${DISPLAY:-}" ]; then + export QT_QPA_PLATFORM="xcb" +else + die "Cannot reach the graphical session as administrator (no Wayland socket at ${XDG_RUNTIME_DIR:-?}/${WAYLAND_DISPLAY:-?})." +fi +[ -n "${XAUTHORITY:-}" ] || unset XAUTHORITY + +# A crashed Calamares leaves its single-instance lock behind and every later +# start exits immediately with status 87. Clear it when nothing is running. +if ! pgrep -x calamares >/dev/null 2>&1; then + rm -f /tmp/kdsingleapp-*calamares* 2>/dev/null +fi + +timedatectl set-ntp true >/dev/null 2>&1 || true + +# --- run --------------------------------------------------------------------- +# systemd-inhibit: no suspend/idle in the middle of writing a disk. +# dbus-run-session: root has no session bus of its own; KDE libraries used by +# the partitioning module expect one. +systemd-inhibit --what=sleep:idle --who="SimulationOS Installer" \ + --why="Installing SimulationOS" \ + dbus-run-session -- calamares -c "$CONFIG_DIR" -D6 >>"$LOG" 2>&1 +rc=$? + +# Calamares APPENDS to its session log, so a retry after a failed attempt still +# contains the earlier failure. Judge only the run that just ended. +SESSION_LOG=/root/.cache/calamares/session.log +THIS_RUN="$(mktemp)" +if [ -r "$SESSION_LOG" ]; then + awk '/=== START CALAMARES/ { buf = "" } { buf = buf $0 "\n" } END { printf "%s", buf }' \ + "$SESSION_LOG" > "$THIS_RUN" +fi + +result="closed" +failed_job="" +if grep -q 'Installation failed' "$THIS_RUN"; then + result="failed" + # The job that was running when the failure was reported. + failed_job="$(awk '/Starting job "/ { job = $0 } /Installation failed/ { print job; exit }' "$THIS_RUN" \ + | sed -n 's/.*Starting job "\([^"]*\)".*/\1/p')" +elif grep -q 'installed system verified' "$THIS_RUN"; then + result="completed" +fi + +{ + printf '===== Calamares exited with status %s: %s\n' "$rc" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + case "$result" in + failed) + printf '===== RESULT: INSTALLATION FAILED in job: %s\n' "${failed_job:-unknown}" + grep -A12 'Installation failed' "$THIS_RUN" | head -40 ;; + completed) printf '===== RESULT: INSTALLATION COMPLETED\n' ;; + *) printf '===== RESULT: installer closed before an installation completed\n' ;; + esac } >>"$LOG" 2>&1 +rm -f -- "$THIS_RUN" + +if [ "$result" = "failed" ] || [ "$rc" -ne 0 ]; then + collect_diagnostics + own_logs + if [ "$result" = "failed" ]; then + notify "Installation failed" "Failed step: ${failed_job:-unknown} + +Log: $LOG +Diagnostics: $DIAG" + else + notify "The installer stopped unexpectedly" "Exit status $rc. -exec calamares -c "$CONFIG_DIR" -D6 >>"$LOG" 2>&1 +Log: $LOG +Diagnostics: $DIAG" + fi + exit 1 +fi +own_logs +exit 0 diff --git a/airootfs/usr/share/applications/simulationos-install.desktop b/airootfs/usr/share/applications/simulationos-install.desktop index 6777efc..c55920a 100644 --- a/airootfs/usr/share/applications/simulationos-install.desktop +++ b/airootfs/usr/share/applications/simulationos-install.desktop @@ -3,10 +3,12 @@ Type=Application Version=1.0 Name=Install SimulationOS GenericName=System Installer -Comment=Install SimulationOS to a disk +Comment=Install SimulationOS to this computer Exec=/usr/local/bin/simulationos-install +TryExec=/usr/local/bin/simulationos-install Icon=simulationos Terminal=false -Categories=System;Settings; -Keywords=install;installer;calamares;simulationos; -StartupNotify=true +Categories=System; +Keywords=install;installer;calamares;simulationos;setup; +StartupNotify=false +StartupWMClass=calamares diff --git a/airootfs/usr/share/backgrounds/simulationos/simos-fire.png b/airootfs/usr/share/backgrounds/simulationos/simos-fire.png new file mode 100644 index 0000000..21e4bfd Binary files /dev/null and b/airootfs/usr/share/backgrounds/simulationos/simos-fire.png differ diff --git a/airootfs/usr/share/backgrounds/simulationos/simos-throne.png b/airootfs/usr/share/backgrounds/simulationos/simos-throne.png new file mode 100644 index 0000000..073c6fb Binary files /dev/null and b/airootfs/usr/share/backgrounds/simulationos/simos-throne.png differ diff --git a/airootfs/usr/share/backgrounds/simulationos/simos-winter.png b/airootfs/usr/share/backgrounds/simulationos/simos-winter.png new file mode 100644 index 0000000..45ad560 Binary files /dev/null and b/airootfs/usr/share/backgrounds/simulationos/simos-winter.png differ diff --git a/airootfs/usr/share/backgrounds/simulationos/simulationos-alpha.png b/airootfs/usr/share/backgrounds/simulationos/simulationos-alpha.png deleted file mode 100644 index 15779f7..0000000 Binary files a/airootfs/usr/share/backgrounds/simulationos/simulationos-alpha.png and /dev/null differ diff --git a/airootfs/usr/share/pixmaps/simulationos.svg b/airootfs/usr/share/pixmaps/simulationos.svg index bf9acb5..fb7115d 100644 --- a/airootfs/usr/share/pixmaps/simulationos.svg +++ b/airootfs/usr/share/pixmaps/simulationos.svg @@ -1,15 +1,48 @@ + - + - - - - - + + + 0 + 1 + 1 + 1 + 0 + 1 + 0 + 0 + 0 + 1 + 1 + 0 + 0 + 0 + 0 + 0 + 1 + 1 + 1 + 0 + 0 + 0 + 0 + 0 + 1 + 1 + 0 + 0 + 0 + 1 + 0 + 1 + 1 + 1 + 0 + diff --git a/airootfs/usr/share/simulationos/calamares/branding/simulationos/branding.desc b/airootfs/usr/share/simulationos/calamares/branding/simulationos/branding.desc index 839812c..564cc5d 100644 --- a/airootfs/usr/share/simulationos/calamares/branding/simulationos/branding.desc +++ b/airootfs/usr/share/simulationos/calamares/branding/simulationos/branding.desc @@ -24,15 +24,15 @@ strings: releaseNotesUrl: "https://github.com/HaiderBassem/SimulationOS" images: - productLogo: "logo.svg" - productIcon: "icon.svg" - productWelcome: "logo.svg" + productLogo: "logo.png" + productIcon: "icon.png" + productWelcome: "welcome.png" slideshow: "show.qml" slideshowAPI: 2 style: - sidebarBackground: "#14161a" - sidebarText: "#e6e6e6" - sidebarTextSelect: "#1793d1" - sidebarTextHighlight: "#33ccff" + SidebarBackground: "#0b0e13" + SidebarText: "#e6e6e6" + SidebarTextCurrent: "#0b0e13" + SidebarBackgroundCurrent: "#33ccff" diff --git a/airootfs/usr/share/simulationos/calamares/branding/simulationos/icon.png b/airootfs/usr/share/simulationos/calamares/branding/simulationos/icon.png new file mode 100644 index 0000000..1e3abbb Binary files /dev/null and b/airootfs/usr/share/simulationos/calamares/branding/simulationos/icon.png differ diff --git a/airootfs/usr/share/simulationos/calamares/branding/simulationos/icon.svg b/airootfs/usr/share/simulationos/calamares/branding/simulationos/icon.svg deleted file mode 100644 index bf9acb5..0000000 --- a/airootfs/usr/share/simulationos/calamares/branding/simulationos/icon.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - diff --git a/airootfs/usr/share/simulationos/calamares/branding/simulationos/logo.png b/airootfs/usr/share/simulationos/calamares/branding/simulationos/logo.png new file mode 100644 index 0000000..1af57da Binary files /dev/null and b/airootfs/usr/share/simulationos/calamares/branding/simulationos/logo.png differ diff --git a/airootfs/usr/share/simulationos/calamares/branding/simulationos/logo.svg b/airootfs/usr/share/simulationos/calamares/branding/simulationos/logo.svg deleted file mode 100644 index bf9acb5..0000000 --- a/airootfs/usr/share/simulationos/calamares/branding/simulationos/logo.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - diff --git a/airootfs/usr/share/simulationos/calamares/branding/simulationos/welcome.png b/airootfs/usr/share/simulationos/calamares/branding/simulationos/welcome.png new file mode 100644 index 0000000..ddb6b7d Binary files /dev/null and b/airootfs/usr/share/simulationos/calamares/branding/simulationos/welcome.png differ diff --git a/airootfs/usr/share/simulationos/calamares/modules/displaymanager.conf b/airootfs/usr/share/simulationos/calamares/modules/displaymanager.conf new file mode 100644 index 0000000..88babbf --- /dev/null +++ b/airootfs/usr/share/simulationos/calamares/modules/displaymanager.conf @@ -0,0 +1,10 @@ +--- +# SDDM is the only display manager SimulationOS ships. Autologin is never +# requested (users.conf: doAutologin false), so this writes an empty +# [Autologin] block to /etc/sddm.conf: the installed system shows the greeter. +displaymanagers: + - sddm +basicSetup: false +sysconfigSetup: false +sddm: + configuration_file: "/etc/sddm.conf" diff --git a/airootfs/usr/share/simulationos/calamares/modules/fstab.conf b/airootfs/usr/share/simulationos/calamares/modules/fstab.conf index 687b169..a6d75da 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/fstab.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/fstab.conf @@ -1,14 +1,13 @@ --- -mountOptions: - default: defaults,noatime - btrfs: defaults,noatime,compress=zstd - xfs: defaults,noatime - f2fs: defaults,noatime - -ssdExtraMountOptions: - ext4: discard - btrfs: discard=async - xfs: discard - -efiMountOptions: defaults,umask=0077 +# Writes /etc/fstab for the target from the partitions Calamares created, +# using the mount options defined in mount.conf (UUID based). crypttabOptions: luks + +# /tmp is left to systemd's default tmp.mount. +tmpOptions: + default: + tmpfs: false + options: "" + ssd: + tmpfs: false + options: "" diff --git a/airootfs/usr/share/simulationos/calamares/modules/grubcfg.conf b/airootfs/usr/share/simulationos/calamares/modules/grubcfg.conf index d1e134e..0c7696a 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/grubcfg.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/grubcfg.conf @@ -1,9 +1,15 @@ --- +# Edits /etc/default/grub in the target before grub-mkconfig runs. overwrite: false +prefer_grub_d: false +keep_distributor: false +kernel_params: + - "quiet" + - "loglevel=3" defaults: GRUB_TIMEOUT: 5 GRUB_TIMEOUT_STYLE: "menu" - GRUB_DISTRIBUTOR: "SimulationOS" - GRUB_CMDLINE_LINUX_DEFAULT: "quiet loglevel=3" - GRUB_CMDLINE_LINUX: "" - GRUB_ENABLE_CRYPTODISK: false + GRUB_DEFAULT: "0" + GRUB_DISABLE_SUBMENU: true + GRUB_DISABLE_RECOVERY: true +always_use_defaults: true diff --git a/airootfs/usr/share/simulationos/calamares/modules/initcpio.conf b/airootfs/usr/share/simulationos/calamares/modules/initcpio.conf index 31589df..a3ee7c7 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/initcpio.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/initcpio.conf @@ -1,5 +1,7 @@ --- -# Runs `mkinitcpio -P` in the target, regenerating -# /boot/initramfs-linux-cachyos.img (and -fallback) from the target's own -# /etc/mkinitcpio.conf. -kernel: "*" +# Runs `mkinitcpio -P` in the target ("all" = every preset in +# /etc/mkinitcpio.d), producing /boot/initramfs-linux-cachyos.img from the +# target's own /etc/mkinitcpio.conf. simulationos-deloop has already installed +# /boot/vmlinuz-linux-cachyos and removed the archiso drop-in. +kernel: all +be_unsafe: false diff --git a/airootfs/usr/share/simulationos/calamares/modules/initcpiocfg.conf b/airootfs/usr/share/simulationos/calamares/modules/initcpiocfg.conf index fcffd2f..afcdd2d 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/initcpiocfg.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/initcpiocfg.conf @@ -1,3 +1,6 @@ --- -# Writes a clean /etc/mkinitcpio.conf for the installed system. +# Writes a normal /etc/mkinitcpio.conf HOOKS line for the installed system. # The archiso drop-in has already been removed by simulationos-deloop. +# useSystemdHook matches the stock Arch mkinitcpio.conf (base systemd ...). +useSystemdHook: true +source: "/etc/mkinitcpio.conf" diff --git a/airootfs/usr/share/simulationos/calamares/modules/keyboard.conf b/airootfs/usr/share/simulationos/calamares/modules/keyboard.conf index ade14d1..c7f29eb 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/keyboard.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/keyboard.conf @@ -1,4 +1,14 @@ --- +# Persists the keyboard chosen in the installer: +# /etc/vconsole.conf console keymap +# /etc/X11/xorg.conf.d/00-keyboard.conf SDDM greeter / X11 +# /etc/default/keyboard read by SimulationOS's hyprland.lua, +# so the Hyprland session uses the +# same layout the user picked here xOrgConfFileName: "/etc/X11/xorg.conf.d/00-keyboard.conf" -convertedKeymapPath: "/usr/share/kbd/keymaps/xkb" +convertedKeymapPath: "/lib/kbd/keymaps/xkb" writeEtcDefaultKeyboard: true +useLocale1: true +configure: + kwin: false + gnome: false diff --git a/airootfs/usr/share/simulationos/calamares/modules/locale.conf b/airootfs/usr/share/simulationos/calamares/modules/locale.conf index c54e660..64e6ec3 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/locale.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/locale.conf @@ -1,4 +1,5 @@ --- -# Offline installer: no GeoIP lookup, the user picks a region on the map. -region: "Europe" -zone: "London" +# Starting point of the timezone picker. No GeoIP lookup: the installer is an +# offline installer and must behave the same with or without a network. +region: "America" +zone: "New_York" diff --git a/airootfs/usr/share/simulationos/calamares/modules/mount.conf b/airootfs/usr/share/simulationos/calamares/modules/mount.conf index 43d64c6..ffb0179 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/mount.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/mount.conf @@ -1,4 +1,7 @@ --- +# Mounts the target (root + ESP) and the API filesystems the later chrooted +# jobs need. Schema of Calamares >= 3.3: mountOptions is a LIST of +# { filesystem, options } entries; fstab is generated from the same options. extraMounts: - device: proc fs: proc @@ -21,17 +24,9 @@ extraMounts: efi: true mountOptions: - default: defaults,noatime - btrfs: defaults,noatime,compress=zstd - xfs: defaults,noatime - f2fs: defaults,noatime - -btrfsSubvolumes: - - mountPoint: / - subvolume: /@ - - mountPoint: /home - subvolume: /@home - - mountPoint: /var/log - subvolume: /@log - - mountPoint: /var/cache/pacman/pkg - subvolume: /@pkg + - filesystem: default + options: [ defaults, noatime ] + - filesystem: efi + options: [ defaults, umask=0077 ] + - filesystem: ext4 + options: [ defaults, noatime ] diff --git a/airootfs/usr/share/simulationos/calamares/modules/packages.conf b/airootfs/usr/share/simulationos/calamares/modules/packages.conf index 56c81a6..0500abd 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/packages.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/packages.conf @@ -1,17 +1,30 @@ --- backend: pacman +# Offline install: never touch the network from here. +skip_if_no_internet: false +update_db: false +update_system: false + pacman: - num_retries: 1 + num_retries: 0 disable_download_timeout: true needed_only: true -update_db: false -update_system: false - operations: - # The installer itself and the live-only partitioning GUI have no place on - # the installed system. try_remove never fails the installation. + # Installer-only and live-medium-only packages have no place on the + # installed system. try_remove never fails the installation. + # cachyos-calamares the installer itself + # gparted live partitioning GUI + # mkinitcpio-archiso archiso initramfs hooks (live boot only) + # livecd-sounds live accessibility sound helper + # syslinux boots the ISO in BIOS mode; the target uses GRUB + # memtest86+* boot-menu entries of the ISO - try_remove: - cachyos-calamares - gparted + - mkinitcpio-archiso + - livecd-sounds + - syslinux + - memtest86+ + - memtest86+-efi diff --git a/airootfs/usr/share/simulationos/calamares/modules/partition.conf b/airootfs/usr/share/simulationos/calamares/modules/partition.conf index 104884e..0daa701 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/partition.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/partition.conf @@ -1,12 +1,15 @@ --- +# Alpha golden path: UEFI + GPT + ESP + ext4 root, GRUB. +# Other filesystems are deliberately not offered until this path is proven +# end to end (see docs/adr/0009-grub-for-installed-system.md). efiSystemPartition: "/boot/efi" efiSystemPartitionSize: 512M efiSystemPartitionName: EFI +defaultPartitionTableType: gpt + userSwapChoices: - none - - small - - suspend - file drawNestedPartitions: false @@ -19,6 +22,5 @@ initialSwapChoice: none defaultFileSystemType: "ext4" availableFileSystemTypes: - "ext4" - - "btrfs" - - "xfs" - - "f2fs" + +luksGeneration: luks2 diff --git a/airootfs/usr/share/simulationos/calamares/modules/services-systemd.conf b/airootfs/usr/share/simulationos/calamares/modules/services-systemd.conf index 58948c2..937cdfa 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/services-systemd.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/services-systemd.conf @@ -1,4 +1,6 @@ --- +# Units for the INSTALLED system. Live-only units are deleted outright by +# simulationos-deloop, so they are not listed here. units: - name: "NetworkManager.service" action: "enable" @@ -15,16 +17,3 @@ units: - name: "fstrim.timer" action: "enable" mandatory: false - # Live-medium services that must not run on an installed system. - - name: "pacman-init.service" - action: "disable" - mandatory: false - - name: "livecd-talk.service" - action: "disable" - mandatory: false - - name: "livecd-alsa-unmuter.service" - action: "disable" - mandatory: false - - name: "vboxservice.service" - action: "disable" - mandatory: false diff --git a/airootfs/usr/share/simulationos/calamares/modules/shellprocess_deloop.conf b/airootfs/usr/share/simulationos/calamares/modules/shellprocess_deloop.conf index a93224f..2d4e848 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/shellprocess_deloop.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/shellprocess_deloop.conf @@ -1,9 +1,9 @@ --- +# Runs simulationos-deloop inside the target chroot. A non-zero exit aborts +# the installation with the failing step shown in the Calamares error dialog; +# a half-cleaned system must never be reported as a successful install. dontChroot: false -timeout: 600 +timeout: 1200 verbose: true script: - "/usr/share/simulationos/calamares/scripts/simulationos-deloop" - # Runs only after the script above has fully exited, so the script never - # deletes the file it is executing from. - - "rm -rf /usr/share/simulationos" diff --git a/airootfs/usr/share/simulationos/calamares/modules/shellprocess_verify.conf b/airootfs/usr/share/simulationos/calamares/modules/shellprocess_verify.conf new file mode 100644 index 0000000..8635924 --- /dev/null +++ b/airootfs/usr/share/simulationos/calamares/modules/shellprocess_verify.conf @@ -0,0 +1,11 @@ +--- +# Last job before umount: assert the target is a bootable, de-lived +# SimulationOS. Failing here turns "Installation Complete" into an explicit +# error naming the broken property, instead of a system that fails on reboot. +dontChroot: false +timeout: 300 +verbose: true +script: + - "/usr/share/simulationos/calamares/scripts/simulationos-verify-target" + # The installer configuration is only removed once nothing needs it. + - "rm -rf /usr/share/simulationos/calamares /usr/lib/simulationos/calamares-compat" diff --git a/airootfs/usr/share/simulationos/calamares/modules/umount.conf b/airootfs/usr/share/simulationos/calamares/modules/umount.conf index eb9ce0a..00c450d 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/umount.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/umount.conf @@ -1,2 +1,3 @@ --- -srcLog: "/var/log/Calamares.log" +# Unmount the target even after a failed job, so a retry starts clean. +emergency: true diff --git a/airootfs/usr/share/simulationos/calamares/modules/users.conf b/airootfs/usr/share/simulationos/calamares/modules/users.conf index 753ac90..697cc3e 100644 --- a/airootfs/usr/share/simulationos/calamares/modules/users.conf +++ b/airootfs/usr/share/simulationos/calamares/modules/users.conf @@ -26,11 +26,11 @@ doReusePassword: false availableShells: /bin/bash passwordRequirements: - nonempty: true minLength: 6 user: shell: /bin/bash + home_permissions: "o700" forbidden_names: - root - liveuser @@ -38,3 +38,5 @@ user: hostname: location: EtcFile writeHostsFile: true + template: "simulationos" + forbidden_names: [ localhost ] diff --git a/airootfs/usr/share/simulationos/calamares/qml b/airootfs/usr/share/simulationos/calamares/qml new file mode 120000 index 0000000..4d48974 --- /dev/null +++ b/airootfs/usr/share/simulationos/calamares/qml @@ -0,0 +1 @@ +/usr/share/calamares/qml \ No newline at end of file diff --git a/airootfs/usr/share/simulationos/calamares/scripts/simulationos-deloop b/airootfs/usr/share/simulationos/calamares/scripts/simulationos-deloop index 5fee79d..81c2401 100755 --- a/airootfs/usr/share/simulationos/calamares/scripts/simulationos-deloop +++ b/airootfs/usr/share/simulationos/calamares/scripts/simulationos-deloop @@ -4,80 +4,144 @@ # proper installed system. # # Runs INSIDE the target chroot, from Calamares' shellprocess@deloop instance, -# after users/removeuser and BEFORE initcpiocfg/initcpio. +# after removeuser and BEFORE initcpiocfg/initcpio. # # Why this exists: the offline installer copies the live SquashFS verbatim, so # the target initially inherits every live-medium concession - passwordless -# root, NOPASSWD sudo, permissive polkit, SDDM autologin, tty autologin and the -# archiso initramfs hooks. Each of those is removed here. +# root, NOPASSWD sudo, permissive polkit, SDDM autologin, tty autologin, the +# archiso initramfs configuration and an empty /boot. Each is corrected here. # -# Deliberately NOT using `set -e`: a missing file must never abort a running -# installation. Every step is individually guarded and logged. +# Contract: +# - idempotent: running it twice gives the same result and still exits 0 +# - a path that is already absent is not an error +# - a SECURITY- or BOOT-critical step that cannot be completed exits +# non-zero, which fails the installation loudly in Calamares instead of +# handing the user a half-cleaned system # - set -u -log() { printf '[deloop] %s\n' "$*"; } +FAILED=0 +log() { printf '[deloop] %s\n' "$*"; } +fail() { printf '[deloop] ERROR: %s\n' "$*" >&2; FAILED=$((FAILED + 1)); } + +# Refuse to run against a live system: this script deletes accounts and +# rewrites boot configuration. Calamares runs it in the target chroot, where +# the archiso boot medium is not mounted. +if [ "${SIMOS_DELOOP_FORCE:-0}" != "1" ] && [ -d /run/archiso/bootmnt/arch ]; then + printf '[deloop] ERROR: refusing to run on the live medium itself\n' >&2 + exit 2 +fi +# drop PATH... - remove live-only paths; absence is fine, failure is not. drop() { local path for path in "$@"; do if [ -e "$path" ] || [ -L "$path" ]; then - rm -rf -- "$path" && log "removed $path" || log "FAILED to remove $path" + if rm -rf -- "$path"; then log "removed $path"; else fail "could not remove $path"; fi fi done } log "starting live-configuration removal" -# ---------------------------------------------------------------- 1. autologin +# ------------------------------------------------------------- 1. live account +# Calamares' removeuser has normally done this already; do not rely on it. +if getent passwd liveuser >/dev/null 2>&1; then + userdel -f -r liveuser >/dev/null 2>&1 || userdel -f liveuser >/dev/null 2>&1 + getent passwd liveuser >/dev/null 2>&1 && fail "liveuser account still exists" +fi +drop /home/liveuser /var/spool/mail/liveuser +# Live-only groups (passwordless/auto login) and a stale primary group. +for grp in nopasswdlogin autologin liveuser; do + if getent group "$grp" >/dev/null 2>&1; then + groupdel "$grp" >/dev/null 2>&1 || true + fi +done +# Calamares' users module recreates "autologin" on demand (users.conf). +if grep -q '^liveuser:' /etc/passwd /etc/shadow 2>/dev/null; then + fail "liveuser still present in /etc/passwd or /etc/shadow" +fi +if grep -Eq '(^|[:,])liveuser(,|$)' /etc/group /etc/gshadow 2>/dev/null; then + sed -i -E 's/([:,])liveuser(,|$)/\1/; s/,$//' /etc/group /etc/gshadow \ + || fail "could not strip liveuser from group files" +fi + +# ---------------------------------------------------------------- 2. autologin # SDDM autologin and tty1 autologin are live-medium only. drop /etc/sddm.conf.d/20-simulationos-live-autologin.conf drop /etc/systemd/system/getty@tty1.service.d/autologin.conf rmdir /etc/systemd/system/getty@tty1.service.d 2>/dev/null -# ------------------------------------------------------- 2. privilege policies +# ------------------------------------------------------- 3. privilege policies # Replace live NOPASSWD sudo with a normal password-prompting wheel rule. drop /etc/sudoers.d/10-simulationos-live drop /etc/sudoers.d/g_wheel +umask 077 cat > /etc/sudoers.d/10-simulationos-wheel <<'EOSUDO' ## SimulationOS: members of the wheel group may use sudo after authenticating. %wheel ALL=(ALL:ALL) ALL EOSUDO +umask 022 chmod 0440 /etc/sudoers.d/10-simulationos-wheel chown 0:0 /etc/sudoers.d/10-simulationos-wheel -if command -v visudo >/dev/null 2>&1; then - if visudo -cf /etc/sudoers.d/10-simulationos-wheel >/dev/null 2>&1; then - log "installed password-prompting wheel sudo rule" - else - log "FAILED sudoers validation; removing the new rule to avoid a broken sudo" - rm -f /etc/sudoers.d/10-simulationos-wheel - fi +if visudo -c >/dev/null 2>&1; then + log "sudo policy: wheel must authenticate (visudo -c passed)" +else + fail "visudo -c rejected the installed sudoers configuration" +fi +# Commented-out examples in the stock /etc/sudoers do not count. +if grep -rhsE '^[^#]*NOPASSWD' /etc/sudoers /etc/sudoers.d >/dev/null; then + fail "a NOPASSWD sudo rule survived" fi # The live polkit rule grants every wheel member every action without a # password. That must not survive onto a real system. drop /etc/polkit-1/rules.d/49-simulationos-live-nopasswd.rules -# ------------------------------------------------------------- 3. root account -# The live medium ships root with an EMPTY password. Calamares is configured -# with setRootPassword:false (sudo-only model), so lock root explicitly. -if passwd --lock root >/dev/null 2>&1; then +# ------------------------------------------------------------- 4. root account +# archiso ships root with an empty password. Lock it: administration on the +# installed system goes through sudo (users.conf: setRootPassword false). +if passwd --lock root >/dev/null 2>&1 || usermod --lock root >/dev/null 2>&1; then log "locked the root account" else - log "WARNING: could not lock root via passwd; patching /etc/shadow directly" - sed -i 's/^root::/root:!:/' /etc/shadow 2>/dev/null \ - && log "patched /etc/shadow" \ - || log "FAILED to patch /etc/shadow" + sed -i -E 's/^root:[^:]*:/root:!:/' /etc/shadow || true fi +case "$(awk -F: '$1=="root"{print $2}' /etc/shadow 2>/dev/null)" in + '!'*|'*'*) : ;; + *) fail "root account is not locked" ;; +esac + +# ------------------------------------------------- 5. kernel image + initramfs +# mkarchiso empties /boot inside the SquashFS (the ISO boots its own copy), so +# the target starts with NO kernel in /boot. Install it exactly the way the +# mkinitcpio pacman hook does: from /usr/lib/modules//vmlinuz, named +# after the pkgbase. This does not depend on the boot medium still being +# mounted (copytoram) and always matches the modules that were unpacked. +kernels=0 +for moddir in /usr/lib/modules/*; do + [ -f "$moddir/vmlinuz" ] && [ -f "$moddir/pkgbase" ] || continue + read -r pkgbase < "$moddir/pkgbase" + if install -Dm644 "$moddir/vmlinuz" "/boot/vmlinuz-$pkgbase"; then + log "installed /boot/vmlinuz-$pkgbase from $moddir" + kernels=$((kernels + 1)) + else + fail "could not install /boot/vmlinuz-$pkgbase" + fi +done +[ "$kernels" -gt 0 ] || fail "no kernel found under /usr/lib/modules" -# ------------------------------------------------------------ 4. archiso layer -# This is the load-bearing one: leaving archiso.conf in place makes mkinitcpio -# build an archiso initramfs, and the installed system will not boot without -# the ISO present. +# The archiso drop-in replaces HOOKS with the live-medium set (memdisk, +# archiso, archiso_loop_mnt). An initramfs built with it cannot mount a normal +# root filesystem. It MUST be gone before Calamares runs initcpiocfg/initcpio. drop /etc/mkinitcpio.conf.d/archiso.conf rmdir /etc/mkinitcpio.conf.d 2>/dev/null +if grep -rqs 'archiso' /etc/mkinitcpio.conf /etc/mkinitcpio.conf.d /etc/mkinitcpio.d; then + fail "archiso is still referenced by the mkinitcpio configuration" +fi +# Stale live initramfs images must never be mistaken for the installed one. +drop /boot/initramfs-linux-cachyos.img /boot/initramfs-linux-cachyos-fallback.img +# ------------------------------------------------------- 6. live-only services drop /etc/systemd/system/livecd-talk.service \ /etc/systemd/system/livecd-alsa-unmuter.service \ /etc/systemd/system/multi-user.target.wants/livecd-talk.service \ @@ -90,46 +154,85 @@ drop /etc/systemd/system/livecd-talk.service \ /root/.automated_script.sh \ /root/.zlogin -# archiso masks the GPT auto-generator; an installed system wants it back. +# Hypervisor guest agents are enabled unconditionally on the live medium so it +# works in any VM. On an installed system they only make sense where the +# matching hypervisor exists; elsewhere they are failed units on every boot. +virt="$(systemd-detect-virt 2>/dev/null || echo none)" +keep_guest="" +case "$virt" in + oracle) keep_guest="vboxservice.service" ;; + vmware) keep_guest="vmtoolsd.service vmware-vmblock-fuse.service" ;; + microsoft) keep_guest="hv_fcopy_daemon.service hv_kvp_daemon.service hv_vss_daemon.service" ;; +esac +for unit in vboxservice.service vmtoolsd.service vmware-vmblock-fuse.service \ + hv_fcopy_daemon.service hv_kvp_daemon.service hv_vss_daemon.service; do + case " $keep_guest " in *" $unit "*) continue ;; esac + drop "/etc/systemd/system/multi-user.target.wants/$unit" +done +log "hypervisor: $virt (kept guest units: ${keep_guest:-none})" + +# archiso masks systemd-gpt-auto-generator so the live medium never +# auto-mounts host partitions. An installed system should use the default. if [ -L /etc/systemd/system-generators/systemd-gpt-auto-generator ]; then drop /etc/systemd/system-generators/systemd-gpt-auto-generator rmdir /etc/systemd/system-generators 2>/dev/null fi -# Live-only systemd tuning: volatile journal and "never suspend". +# Volatile journal storage is a live-medium choice (no persistent disk). drop /etc/systemd/journald.conf.d/volatile-storage.conf rmdir /etc/systemd/journald.conf.d 2>/dev/null + +# "Never suspend" is appropriate while installing, not on a real machine. drop /etc/systemd/logind.conf.d/do-not-suspend.conf rmdir /etc/systemd/logind.conf.d 2>/dev/null -# sshd is not enabled on the live medium either, but clear any leftover -# enablement and the upstream archiso drop-in if an older build left one. +# The live medium waits for network time because pacman's keyring needs a sane +# clock. An installed desktop must not: without a reachable time server the +# unit times out and every boot ends "degraded". timesyncd itself stays enabled. +drop /etc/systemd/system/sysinit.target.wants/systemd-time-wait-sync.service + +# sshd must not be enabled by default on an installed desktop. drop /etc/systemd/system/multi-user.target.wants/sshd.service \ /etc/ssh/sshd_config.d/10-archiso.conf -# ------------------------------------------------------------- 5. the live user -# removeuser has already deleted the account; clear anything left behind. -drop /home/liveuser -for grp in nopasswdlogin autologin; do - if command -v gpasswd >/dev/null 2>&1; then - gpasswd -d liveuser "$grp" >/dev/null 2>&1 +# ------------------------------------------------------------ 7. pacman keyring +# On the live medium /etc/pacman.d/gnupg is a tmpfs filled at boot by +# pacman-init.service (both removed above). The installed system needs a real +# keyring or every signed package would be rejected. Signature checking is +# never relaxed instead. +if [ ! -s /etc/pacman.d/gnupg/pubring.gpg ] && [ ! -s /etc/pacman.d/gnupg/pubring.kbx ]; then + rm -rf /etc/pacman.d/gnupg + if pacman-key --init >/dev/null 2>&1 \ + && pacman-key --populate archlinux cachyos >/dev/null 2>&1; then + log "initialised the pacman keyring (archlinux, cachyos)" + else + fail "could not initialise the pacman keyring" fi -done + # gpg-agent/dirmngr started by pacman-key would keep the target busy at umount. + gpgconf --homedir /etc/pacman.d/gnupg --kill all >/dev/null 2>&1 || true +else + log "pacman keyring already present" +fi -# --------------------------------------------------------------- 6. installer -# The installer must not be offered from an installed system. The -# cachyos-calamares package itself is removed by the `packages` module; -# /usr/share/simulationos is removed by the command after this script. +# ------------------------------------------------------- 8. installer leftovers +# The launcher is removed now; the Calamares configuration and its library +# compatibility directory are removed by shellprocess@verify, once the last +# job that needs them has run. drop /usr/local/bin/simulationos-install \ - /usr/share/applications/simulationos-install.desktop + /usr/share/applications/simulationos-install.desktop \ + /root/.cache/calamares +find /home /root -maxdepth 2 -name 'simulationos-install*.log' -delete 2>/dev/null -# ------------------------------------------------------------------ 7. banners -# Replace the live console banner with an installed-system one. +# ---------------------------------------------------------------------- 9. motd cat > /etc/motd <<'EOMOTD' SimulationOS EOMOTD +if [ "$FAILED" -gt 0 ]; then + printf '[deloop] FAILED: %s critical step(s) could not be completed\n' "$FAILED" >&2 + exit 1 +fi log "finished live-configuration removal" exit 0 diff --git a/airootfs/usr/share/simulationos/calamares/scripts/simulationos-verify-target b/airootfs/usr/share/simulationos/calamares/scripts/simulationos-verify-target new file mode 100755 index 0000000..6e85c90 --- /dev/null +++ b/airootfs/usr/share/simulationos/calamares/scripts/simulationos-verify-target @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# simulationos-verify-target - last Calamares job before umount. +# +# Runs INSIDE the target chroot and asserts the properties the installed +# system needs in order to boot without the ISO and to be free of live-medium +# state. Any failure exits non-zero, so Calamares shows an error naming the +# broken property instead of "Installation Complete". +# +set -u + +FAILED=0 +ok() { printf '[verify] ok %s\n' "$*"; } +bad() { printf '[verify] FAILED %s\n' "$*" >&2; FAILED=$((FAILED + 1)); } +check() { # description, command... + local what="$1"; shift + if "$@" >/dev/null 2>&1; then ok "$what"; else bad "$what"; fi +} + +# ------------------------------------------------------------------------ boot +check "kernel present in /boot" test -s /boot/vmlinuz-linux-cachyos +check "initramfs present in /boot" test -s /boot/initramfs-linux-cachyos.img +check "GRUB configuration generated" test -s /boot/grub/grub.cfg +check "grub.cfg boots linux-cachyos" grep -q 'vmlinuz-linux-cachyos' /boot/grub/grub.cfg +check "grub.cfg loads the initramfs" grep -q 'initramfs-linux-cachyos.img' /boot/grub/grub.cfg +check "grub.cfg names root by UUID" grep -Eq 'root=UUID=[0-9a-fA-F-]+' /boot/grub/grub.cfg +if [ -d /sys/firmware/efi ]; then + check "GRUB EFI binary on the ESP" sh -c 'find /boot/efi/EFI -iname "grubx64.efi" | grep -q .' + check "EFI fallback loader on the ESP" sh -c 'find /boot/efi/EFI -ipath "*/boot/bootx64.efi" | grep -q .' +fi + +# The initramfs must be a normal one. lsinitcpio lists the image contents; an +# archiso hook inside it means the system can only boot from the ISO. +if command -v lsinitcpio >/dev/null 2>&1 && [ -s /boot/initramfs-linux-cachyos.img ]; then + if lsinitcpio /boot/initramfs-linux-cachyos.img 2>/dev/null | grep -q 'archiso'; then + bad "initramfs is free of archiso hooks" + else + ok "initramfs is free of archiso hooks" + fi +fi +check "no archiso mkinitcpio configuration" \ + sh -c '! grep -rqs archiso /etc/mkinitcpio.conf /etc/mkinitcpio.conf.d /etc/mkinitcpio.d' + +# ----------------------------------------------------------------------- fstab +check "fstab has a root entry" grep -Eq '^UUID=[^[:space:]]+[[:space:]]+/[[:space:]]' /etc/fstab +check "fstab is free of live mounts" sh -c '! grep -Eq "archiso|airootfs|/run/|cowspace|loop" /etc/fstab' + +# -------------------------------------------------------------------- security +check "liveuser account absent" sh -c '! getent passwd liveuser' +check "/home/liveuser absent" sh -c '! test -e /home/liveuser' +check "no NOPASSWD sudo rule" sh -c '! grep -rhsE "^[^#]*NOPASSWD" /etc/sudoers /etc/sudoers.d' +check "sudoers configuration valid" visudo -c +check "live polkit rule absent" sh -c '! test -e /etc/polkit-1/rules.d/49-simulationos-live-nopasswd.rules' +check "live SDDM autologin absent" sh -c '! grep -rqsE "^User=liveuser" /etc/sddm.conf /etc/sddm.conf.d' +check "tty autologin absent" sh -c '! test -e /etc/systemd/system/getty@tty1.service.d/autologin.conf' +check "root account locked" \ + sh -c 'case "$(awk -F: "\$1==\"root\"{print \$2}" /etc/shadow)" in "!"*|"*"*) exit 0;; *) exit 1;; esac' + +# ------------------------------------------------------------------------ user +# The account the person just created: a regular user with a home directory +# and wheel membership (how they administer the system). +user="$(awk -F: '$3>=1000 && $3<60000 {print $1; exit}' /etc/passwd)" +if [ -n "$user" ]; then + ok "installed user exists ($user)" + check "installed user has a home directory" test -d "/home/$user" + check "installed user is in wheel" sh -c "id -nG '$user' | tr ' ' '\n' | grep -qx wheel" + check "installed user owns their home" sh -c "[ \"\$(stat -c %U /home/$user)\" = '$user' ]" + check "desktop configuration copied from skel" test -f "/home/$user/.config/hypr/hyprland.lua" +else + bad "installed user exists" +fi + +# -------------------------------------------------------------------- services +check "SDDM enabled" systemctl is-enabled sddm.service +check "NetworkManager enabled" systemctl is-enabled NetworkManager.service +check "default target is graphical" sh -c '[ "$(systemctl get-default)" = graphical.target ]' +check "pacman keyring initialised" sh -c 'test -s /etc/pacman.d/gnupg/pubring.gpg || test -s /etc/pacman.d/gnupg/pubring.kbx' +check "SimulationOS branding" grep -q '^NAME="SimulationOS"' /etc/os-release + +if [ "$FAILED" -gt 0 ]; then + printf '[verify] %s check(s) failed: this installation would not be a working SimulationOS\n' "$FAILED" >&2 + exit 1 +fi +printf '[verify] installed system verified\n' +exit 0 diff --git a/airootfs/usr/share/simulationos/calamares/settings.conf b/airootfs/usr/share/simulationos/calamares/settings.conf index 1d4244b..8616c5c 100644 --- a/airootfs/usr/share/simulationos/calamares/settings.conf +++ b/airootfs/usr/share/simulationos/calamares/settings.conf @@ -17,7 +17,28 @@ instances: - id: deloop module: shellprocess config: shellprocess_deloop.conf +- id: verify + module: shellprocess + config: shellprocess_verify.conf +# Order follows the current CachyOS offline sequence (settings_offline.conf) +# with one deliberate difference: SimulationOS de-lives the target BEFORE the +# initramfs is generated. +# +# unpackfs copy the live SquashFS onto the target +# removeuser delete the live account (userdel -r liveuser) +# shellprocess@deloop strip every live-only concession, install the kernel +# image into /boot and delete the archiso mkinitcpio +# drop-in. MUST precede initcpiocfg/initcpio: generating +# the initramfs first would bake the archiso hooks into +# the installed system and make it unbootable without +# the ISO. +# initcpiocfg/initcpio normal mkinitcpio configuration and image +# users ... services the installed user, network, display manager, units +# packages remove installer-only packages +# grubcfg/bootloader GRUB into the TARGET (ESP + grub.cfg) +# shellprocess@verify refuse to report success for a system that cannot boot +# or still carries live-medium state sequence: - show: - welcome @@ -35,21 +56,19 @@ sequence: - locale - keyboard - localecfg + - removeuser + - shellprocess@deloop + - initcpiocfg + - initcpio - users - networkcfg + - displaymanager - hwclock - services-systemd - packages - - removeuser - # deloop MUST run before initcpio: it deletes - # /etc/mkinitcpio.conf.d/archiso.conf, which would otherwise force archiso - # hooks into the installed system's initramfs and make it unbootable - # without the ISO. - - shellprocess@deloop - - initcpiocfg - - initcpio - grubcfg - bootloader + - shellprocess@verify - umount - show: - finished @@ -61,4 +80,7 @@ dont-chroot: false oem-setup: false disable-cancel: false disable-cancel-during-exec: true + +# Required by Calamares >= 3.4: hide Back/Next while jobs run. +hide-back-and-next-during-exec: true quit-at-end: false diff --git a/airootfs/usr/share/simulationos/fastfetch/logo.txt b/airootfs/usr/share/simulationos/fastfetch/logo.txt new file mode 100644 index 0000000..7d18500 --- /dev/null +++ b/airootfs/usr/share/simulationos/fastfetch/logo.txt @@ -0,0 +1,8 @@ +$200000000000000001001000101001001000100001001 +$200$1111111$200$111$20001100000010010$11111$2000100$1111111 +$111$2100001010001$11111$200$111$20100$111$20000$111$200$111$2000110 +$201$11111$20000$111$200$111$200$111$201$111$200$111$21000$111$20100$11111$200 +$2100000$111$201$111$210$111$200$111$210$111$201$111$20000$111$210000010$111 +$1111111$20001$111$200$111$200$111$200$111$20000$11111$20000$1111111$201 +$201010010010010001010100001011001001001001000 +$301010011 01101001 01101101 01001111 01010011 diff --git a/docs/adr/0004-hyprland-desktop.md b/docs/adr/0004-hyprland-desktop.md index fc5b518..118bf37 100644 --- a/docs/adr/0004-hyprland-desktop.md +++ b/docs/adr/0004-hyprland-desktop.md @@ -8,7 +8,7 @@ lock, paper) has coupled ABI expectations across releases. ## Decision Use only official Arch repository packages. No `-git`, no AUR in the base OS. -One component per job: `hyprland`, `hyprpaper`, `hyprlock`, +One component per job: `hyprland`, `swaybg`, `hyprlock`, `hyprpolkitagent`, `waybar`, `wofi`, `kitty`, `thunar`, `mako`. ## Alternatives @@ -19,8 +19,16 @@ One component per job: `hyprland`, `hyprpaper`, `hyprlock`, ## Consequences We lag upstream Hyprland slightly. In exchange the desktop is reproducible and every referenced binary is guaranteed present — enforced by the validator, -which fails if `hyprland.conf` launches anything not in `packages.x86_64`. +which fails if `hyprland.lua` or `simos-session` launches anything not in +`packages.x86_64`. ## Sources `docs/research/sources.md` § Hyprland. Note `swww` and `rofi-wayland` are not -in the Arch repositories, which is why `hyprpaper` and `wofi` were chosen. +in the Arch repositories, which is why `wofi` was chosen. + +## Amendment · 2026-10-03 +- Hyprland 0.56 is configured in Lua: `hyprland.conf` became `hyprland.lua`. +- `hyprpaper` was replaced by `swaybg`. hyprpaper >= 0.8 needs a hardware GPU + and crashes on software-rendered machines; SimulationOS is routinely + evaluated in VMs, and one wallpaper path that works everywhere beats a + native one that does not. diff --git a/docs/architecture.md b/docs/architecture.md index 55950ae..806f3db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -278,22 +278,49 @@ laptop setup and was replaced wholesale rather than patched. It contained: - both `monitor.conf` and `monitors.conf`, plus several config files that were shipped but never sourced -**SIMULATIONOS DECISION:** one `hyprland.conf` plus a deliberately empty -`monitors.conf` for per-machine overrides. Every `exec-once` and every keybind -target maps to a package in `packages.x86_64` or to a script in -`airootfs/usr/local/bin/`. Waybar uses **built-in modules only** — no custom -scripts — so a missing helper can never blank the bar. One wallpaper daemon -(`hyprpaper`), one launcher (`wofi`), one terminal (`kitty`), one file manager -(`thunar`), one notification daemon (`mako`), one polkit agent +**SIMULATIONOS DECISION:** one `hyprland.lua` (Hyprland >= 0.55 is configured +in Lua; a legacy `hyprland.conf` produces a wall of config errors at login) +plus a deliberately empty `user.lua` for personal overrides, loaded last +through `pcall`. Every command the session starts maps to a package in +`packages.x86_64` or to a script in `airootfs/usr/local/bin/`. One wallpaper +daemon (`swaybg`), one launcher (`wofi`), one terminal (`kitty`), one file +manager (`thunar`), one notification daemon (`mako`), one polkit agent (`hyprpolkitagent`) and one screen locker (`hyprlock`). -The validator enforces all of this: sourced files must exist, exec targets must -be shipped, the wallpaper must exist, only one wallpaper daemon may autostart, -and `brightnessctl set 0%` is a hard error. - -The wallpaper is a real generated asset -(`airootfs/usr/share/backgrounds/simulationos/simulationos-alpha.png`, -1920x1080), reproducible via `scripts/make-wallpaper.py`. +Three things were learned by running the session instead of reading it: + +- **Startup order.** Hyprland runs separate exec commands concurrently. The + session environment must reach D-Bus and the systemd user manager before + anything that depends on it, or D-Bus-activated services (mako, the polkit + agent, portals) start without `WAYLAND_DISPLAY` and fail. The whole + autostart is therefore one ordered script, `simos-session`. +- **Wallpaper daemon.** `hyprpaper` >= 0.8 cannot allocate its buffers on + software-rendered machines (VMs without 3D acceleration) and segfaults. + `swaybg` uses plain shared memory and behaves the same everywhere. +- **Keyboard.** The layout is read from `/etc/default/keyboard`, which the + installer writes, so the layout chosen at install time is the layout of the + session. It used to be hardcoded to `us`. + +Waybar uses built-in modules plus two custom ones that call shipped helpers: +the power menu, and an "Install SimulationOS" button that hides itself once the +installer has been removed from the installed system. + +The validator enforces this: no legacy `hyprland.conf`, required Lua modules +must be shipped, every started command must be shipped, the session must go +through `simos-session`, wallpapers and the fastfetch logo must exist, and +`brightnessctl set 0%` is a hard error. The definitive check is Hyprland's own +`--verify-config`, run against the built image in CI (`deloop-test.sh`), and +`hyprctl configerrors` in the running session (`live-health.sh`, +`install-test.py`). + +**Virtual machines.** Hyprland needs a DRM device it can create a renderer on. +On a plain VGA adapter it starts and every process runs, but nothing is drawn. +All VM tests therefore use virtio-gpu, and assert on the renderer and on the +bar/wallpaper layers rather than on process names. + +The wallpapers, logos and the fastfetch logo are generated assets +(`scripts/make-branding.py`): the SimOS wordmark as a dot matrix of binary +digits, captioned with the name in ASCII binary. `.bashrc` was also fixed: it aliased `rm` to `trash-put` and `n` to `nvim` without shipping either, so `rm` was broken in the live session. diff --git a/packages.x86_64 b/packages.x86_64 index df33414..fb7422e 100644 --- a/packages.x86_64 +++ b/packages.x86_64 @@ -68,7 +68,7 @@ vulkan-tools ## ------------------------------------------------------------ Hyprland desktop hyprland -hyprpaper +swaybg hyprpolkitagent hyprlock waybar diff --git a/profiledef.sh b/profiledef.sh index 10ff17c..f60c4a0 100644 --- a/profiledef.sh +++ b/profiledef.sh @@ -51,5 +51,9 @@ file_permissions=( ["/usr/local/bin/simos-screenshot"]="0:0:755" ["/usr/local/bin/simos-powermenu"]="0:0:755" ["/usr/local/bin/simos-clipboard"]="0:0:755" + ["/usr/local/bin/simos-session"]="0:0:755" + ["/usr/local/bin/simos-wallpaper"]="0:0:755" + ["/usr/local/bin/simos-welcome"]="0:0:755" ["/usr/share/simulationos/calamares/scripts/simulationos-deloop"]="0:0:755" + ["/usr/share/simulationos/calamares/scripts/simulationos-verify-target"]="0:0:755" ) diff --git a/scripts/boot-test.sh b/scripts/boot-test.sh index 26c13cc..6645b55 100755 --- a/scripts/boot-test.sh +++ b/scripts/boot-test.sh @@ -28,9 +28,11 @@ command -v qemu-system-x86_64 >/dev/null || { red "qemu-system-x86_64 not instal mkdir -p "$OUT" [ "${SIMOS_BOOT_ANALYZE_ONLY:-0}" = "1" ] || : > "$LOG" +ACCEL_ARGS=() +for a in $(printf '%s' "${SIMOS_QEMU_ACCEL:-kvm:tcg}" | tr ':' ' '); do ACCEL_ARGS+=(-accel "$a"); done # shellcheck disable=SC2054 # commas are part of QEMU option values QEMU=(qemu-system-x86_64 - -machine "q35,accel=${SIMOS_QEMU_ACCEL:-kvm:tcg}" + -machine q35 "${ACCEL_ARGS[@]}" -cpu max -smp 2 -m "$RAM" -display none -vga std -serial "file:$LOG" diff --git a/scripts/build-iso.sh b/scripts/build-iso.sh index dcf9bf8..25c3dd0 100755 --- a/scripts/build-iso.sh +++ b/scripts/build-iso.sh @@ -114,6 +114,13 @@ rm -rf -- "$_case_probe" green " work directory is case-sensitive" # -------------------------------------------------------------------- 5. build +# cachyos-calamares is ABI-coupled to one exact Boost release. When Arch moves +# Boost ahead of the CachyOS rebuild the installer cannot even start, while the +# ISO builds and boots normally. Detect that now and stage the (signature +# verified) libraries the installer needs - or fail the build. +"$REPO/scripts/calamares-compat.sh" \ + || die "calamares-compat.sh failed: the installer in this ISO would not start." + info "Running mkarchiso (this takes a while and needs ~20 GB free)" BUILD_LOG="$OUT/build.log" BUILD_START="$(date -u +%s)" diff --git a/scripts/calamares-compat.sh b/scripts/calamares-compat.sh new file mode 100755 index 0000000..f425898 --- /dev/null +++ b/scripts/calamares-compat.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# +# calamares-compat.sh - make sure the installer can actually start. +# +# cachyos-calamares links against Boost with a full-version soname +# (libboost_python314.so.1.91.0). Boost changes that soname on every release, +# and the CachyOS repository does not always rebuild Calamares the moment Arch +# ships a new Boost. When that happens every Calamares binary and module fails +# to load: +# +# calamares: error while loading shared libraries: +# libboost_python314.so.1.91.0: cannot open shared object file +# +# The ISO still builds and boots - the installer just never opens. This script +# runs before mkarchiso and closes that gap: +# +# 1. download the cachyos-calamares and boost-libs packages the build is +# about to install (pacman verifies their signatures); +# 2. work out which Boost sonames Calamares needs and whether the current +# boost-libs provides them; +# 3. if not, fetch the matching boost-libs release from the Arch Linux +# Archive, verify its signature against the Arch keyring, and stage ONLY +# the missing libraries in +# airootfs/usr/lib/simulationos/calamares-compat/ +# +# That directory is put on LD_LIBRARY_PATH by simulationos-install for the +# installer process alone; it is never in the system library path, and it is +# deleted from the installed system. When CachyOS has rebuilt Calamares the +# script stages nothing and the directory does not exist. +# +# Signature verification is never bypassed. If a needed library cannot be +# obtained and verified, the build FAILS here rather than shipping an ISO +# whose installer cannot run. +# +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PACCONF="${SIMOS_PACMAN_CONF:-$REPO/pacman.conf}" +DEST="$REPO/airootfs/usr/lib/simulationos/calamares-compat" +ARCHIVE="https://archive.archlinux.org/packages/b/boost-libs" + +red() { printf '\033[31m%s\033[0m\n' "$*" >&2; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +info() { printf '\033[36m==>\033[0m %s\n' "$*"; } +die() { red "ERROR: $*"; exit 1; } + +command -v pacman >/dev/null || die "pacman not found: run this inside the Arch builder" +command -v bsdtar >/dev/null || die "bsdtar not found" +command -v curl >/dev/null || die "curl not found" + +TMP="$(mktemp -d)" +trap 'rm -rf -- "$TMP"' EXIT +mkdir -p "$TMP/db" "$TMP/cache" "$TMP/cal" "$TMP/boost" "$TMP/old" +# pacman downloads as an unprivileged sandbox user, which must be able to +# reach the cache directory; mktemp -d creates it 0700. +chmod 755 "$TMP" "$TMP/cache" + +# Always start from a clean slate: a stale compat directory from an earlier +# build must not survive a Calamares rebuild that made it unnecessary. +rm -rf -- "$DEST" + +info "Checking that cachyos-calamares can load against the current Boost" +pacman --config "$PACCONF" --dbpath "$TMP/db" --cachedir "$TMP/cache" \ + -Sywdd --noconfirm cachyos-calamares boost-libs >/dev/null \ + || die "could not download cachyos-calamares/boost-libs through $PACCONF" + +CAL_PKG="$(find "$TMP/cache" -name 'cachyos-calamares-*.pkg.tar.*' ! -name '*.sig' | head -1)" +BOOST_PKG="$(find "$TMP/cache" -name 'boost-libs-*.pkg.tar.*' ! -name '*.sig' | head -1)" +[ -n "$CAL_PKG" ] && [ -n "$BOOST_PKG" ] || die "downloaded packages not found in $TMP/cache" + +bsdtar -xf "$CAL_PKG" -C "$TMP/cal" usr/bin usr/lib +bsdtar -tf "$BOOST_PKG" | sed -n 's|^usr/lib/||p' > "$TMP/boost.files" + +# Sonames are plain strings in an ELF's dynamic string table, so they can be +# listed without binutils (which the minimal builder image does not ship). +boost_needs() { # file... -> unique libboost sonames referenced + grep -aohE 'libboost_[A-Za-z0-9_]+\.so\.[0-9]+\.[0-9]+\.[0-9]+' "$@" 2>/dev/null | sort -u +} + +mapfile -t ELFS < <(find "$TMP/cal/usr/bin/calamares" "$TMP/cal/usr/lib" -type f \( -name '*.so*' -o -name calamares \)) +mapfile -t NEEDED < <(boost_needs "${ELFS[@]}") +[ "${#NEEDED[@]}" -gt 0 ] || { green " cachyos-calamares does not link against Boost; nothing to do"; exit 0; } + +MISSING=() +for so in "${NEEDED[@]}"; do + grep -qxF "$so" "$TMP/boost.files" || MISSING+=("$so") +done + +if [ "${#MISSING[@]}" -eq 0 ]; then + green " cachyos-calamares matches boost-libs ($(basename "$BOOST_PKG")); no compatibility libraries needed" + exit 0 +fi + +# All missing sonames must agree on one Boost version. +WANT="$(printf '%s\n' "${MISSING[@]}" | sed -E 's/.*\.so\.//' | sort -u)" +[ "$(printf '%s\n' "$WANT" | wc -l)" -eq 1 ] || die "Calamares needs Boost libraries from several versions: $WANT" +info "cachyos-calamares was built against Boost $WANT; the repository ships $(basename "$BOOST_PKG")" + +# Newest pkgrel of that exact Boost version in the Arch Linux Archive. +OLD_NAME="$(curl -fsSL "$ARCHIVE/" \ + | grep -oE "boost-libs-${WANT//./\\.}-[0-9]+-x86_64\.pkg\.tar\.zst" | sort -uV | tail -1)" +[ -n "$OLD_NAME" ] || die "boost-libs $WANT is not in the Arch Linux Archive ($ARCHIVE/)" + +info "Fetching $OLD_NAME from the Arch Linux Archive" +curl -fsSL -o "$TMP/$OLD_NAME" "$ARCHIVE/$OLD_NAME" +curl -fsSL -o "$TMP/$OLD_NAME.sig" "$ARCHIVE/$OLD_NAME.sig" +pacman-key --verify "$TMP/$OLD_NAME.sig" "$TMP/$OLD_NAME" >/dev/null 2>&1 \ + || die "signature verification FAILED for $OLD_NAME; refusing to ship it" +green " signature verified against the Arch Linux keyring" +bsdtar -xf "$TMP/$OLD_NAME" -C "$TMP/old" usr/lib + +# Stage the missing libraries and, transitively, any old Boost library they +# need in turn (libboost_python pulls in nothing else today, but libboost_graph +# and friends reference each other). +mkdir -p "$DEST" +queue=("${MISSING[@]}") +while [ "${#queue[@]}" -gt 0 ]; do + so="${queue[0]}"; queue=("${queue[@]:1}") + [ -e "$DEST/$so" ] && continue + [ -f "$TMP/old/usr/lib/$so" ] || die "$OLD_NAME does not contain $so" + install -m755 "$TMP/old/usr/lib/$so" "$DEST/$so" + while read -r dep; do + [ -n "$dep" ] || continue + grep -qxF "$dep" "$TMP/boost.files" && continue + [ -e "$DEST/$dep" ] || queue+=("$dep") + done < <(boost_needs "$DEST/$so") +done + +cat > "$DEST/README" </dev/null || true +fi + info "Clean" diff --git a/scripts/deloop-test.sh b/scripts/deloop-test.sh index 344f920..6814d6b 100755 --- a/scripts/deloop-test.sh +++ b/scripts/deloop-test.sh @@ -10,8 +10,13 @@ # # This extracts the SquashFS from the real ISO, runs deloop inside it exactly # as Calamares would, and asserts the security properties of the result. It -# covers the same gates as the post-install checks without needing to drive -# the graphical installer. +# then builds the initramfs the way the installer does and proves it is a +# normal one. Finally it checks, against the real binaries in the image, that +# the installer can load its libraries and that the shipped Hyprland +# configuration is valid. +# +# It complements install-test.py (the real graphical installation): this runs +# in a couple of minutes and pinpoints which contract broke. # set -uo pipefail @@ -36,7 +41,29 @@ case "$WORK" in *) red "refusing to use work directory $WORK (must be under /var/tmp or /tmp)"; exit 1 ;; esac -rm -rf -- "$WORK" +# remove_workdir - delete the scratch directory, but NEVER while anything is +# mounted beneath it. A recursive delete that crosses a leftover bind mount of +# /dev or /sys would reach into the host. So: stop processes still rooted in +# the chroot, unmount, verify with findmnt, and refuse to delete otherwise. +# --one-file-system is the second line of defence. +remove_workdir() { + [ -d "$WORK" ] || return 0 + local pid mp + for pid in $(find /proc -maxdepth 2 -name root -lname "$WORK/root*" 2>/dev/null | cut -d/ -f3); do + kill -9 "$pid" 2>/dev/null + done + findmnt -rno TARGET 2>/dev/null | grep -F "$WORK/" | sort -r | while read -r mp; do + umount "$mp" 2>/dev/null || umount -l "$mp" 2>/dev/null + done + if findmnt -rno TARGET 2>/dev/null | grep -qF "$WORK/"; then + red "refusing to delete $WORK: filesystems are still mounted beneath it:" + findmnt -rno TARGET | grep -F "$WORK/" >&2 + return 1 + fi + rm -rf --one-file-system -- "$WORK" +} + +remove_workdir || exit 1 mkdir -p "$WORK/root" info "Extracting the SquashFS from $(basename "$ISO")" @@ -63,30 +90,98 @@ check_pre "archiso initramfs hook" "[ -f '$R/etc/mkinitcpio.conf.d/archiso.con check_pre "root has empty password" "grep -q '^root::' '$R/etc/shadow'" check_pre "liveuser account" "grep -q '^liveuser:' '$R/etc/passwd'" check_pre "installer launcher" "[ -x '$R/usr/local/bin/simulationos-install' ]" +check_pre "/boot empty in the SquashFS" "[ -z \"\$(ls -A '$R/boot' 2>/dev/null)\" ]" [ "$pre_bad" -eq 0 ] || { red "extracted rootfs is not in the expected live state"; exit 1; } +ERRORS=0 +gate() { if eval "$2"; then green " ok $1"; else red " FAILED $1"; ERRORS=$((ERRORS+1)); fi; } +inroot() { chroot "$R" /usr/bin/env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/bin HOME=/root "$@"; } + +# API filesystems for the chroot. Deliberately NOT bind mounts of the host's +# /dev and /sys: /proc and /sys are fresh instances (sysfs read-only), and +# /dev is a private tmpfs holding only the nodes the tools need. Nothing the +# chroot - or a cleanup gone wrong - does there can touch the host's devices. +mount -t proc proc "$R/proc" +mount -t sysfs -o ro sysfs "$R/sys" +mount -t tmpfs -o mode=755,nosuid tmpfs "$R/dev" +mknod -m 666 "$R/dev/null" c 1 3 +mknod -m 666 "$R/dev/zero" c 1 5 +mknod -m 666 "$R/dev/full" c 1 7 +mknod -m 666 "$R/dev/random" c 1 8 +mknod -m 666 "$R/dev/urandom" c 1 9 +mknod -m 666 "$R/dev/tty" c 5 0 +ln -s /proc/self/fd "$R/dev/fd" +ln -s /proc/self/fd/0 "$R/dev/stdin" +ln -s /proc/self/fd/1 "$R/dev/stdout" +ln -s /proc/self/fd/2 "$R/dev/stderr" +mkdir -p "$R/dev/shm" "$R/dev/pts" +trap 'remove_workdir' EXIT + +# ------------------------------------------------- live medium: installer runs +# Checked BEFORE deloop, against the image exactly as the live session sees it. +printf '\n== installer and desktop contracts (live image)\n' +COMPAT=/usr/lib/simulationos/calamares-compat +CALDIR=/usr/share/simulationos/calamares +ELFS="/usr/bin/calamares $(cd "$R" && find usr/lib -maxdepth 1 -name 'libcalamares*.so' -printf '/%p ' 2>/dev/null) $(cd "$R" && find usr/lib/calamares/modules -name '*.so' -printf '/%p ' 2>/dev/null)" +MISSING="$(for f in $ELFS; do inroot env LD_LIBRARY_PATH="$COMPAT" ldd "$f" 2>/dev/null; done | awk '/not found/{print $1}' | sort -u | tr '\n' ' ')" +gate "Calamares and every module resolve their libraries${MISSING:+ (missing: $MISSING)}" "[ -z '$MISSING' ]" +# Checked inside the image: qml is an absolute symlink into /usr/share/calamares. +gate "Calamares config has a qml directory" "inroot test -d '$CALDIR/qml/.'" +for mod in $(sed -n '/^sequence:/,/^branding:/p' "$R$CALDIR/settings.conf" \ + | grep -E '^[[:space:]]+-[[:space:]]' | sed 's/^[[:space:]]*-[[:space:]]*//; s/@.*//' | sort -u); do + gate "Calamares module '$mod' exists in the package" "[ -d '$R/usr/lib/calamares/modules/$mod' ]" +done +gate "helper scripts are executable" \ + "[ -x '$R/usr/local/bin/simulationos-install' ] && [ -x '$R/usr/local/bin/simos-welcome' ] && [ -x '$R/usr/local/bin/simos-wallpaper' ] && [ -x '$R$CALDIR/scripts/simulationos-verify-target' ]" + +# Hyprland's own verifier, run on the configuration a new user receives. +mkdir -p "$R/tmp/hyprcheck/.config" "$R/tmp/hyprcheck/run" +cp -a "$R/etc/skel/.config/hypr" "$R/tmp/hyprcheck/.config/" +chmod 700 "$R/tmp/hyprcheck/run" +HYPR_OUT="$(inroot env HOME=/tmp/hyprcheck XDG_RUNTIME_DIR=/tmp/hyprcheck/run \ + Hyprland --i-am-really-stupid --verify-config 2>&1 | sed -n '/Config parsing result/,$p')" +printf '%s\n' "$HYPR_OUT" | sed 's/^/ /' +gate "Hyprland --verify-config reports 'config ok'" "printf '%s' \"\$HYPR_OUT\" | grep -q 'config ok'" +rm -rf "$R/tmp/hyprcheck" +FF_OUT="$(inroot env TERM=xterm-256color fastfetch --pipe false 2>&1 | sed 's/\x1b\[[0-9;]*m//g')" +if ! printf '%s' "$FF_OUT" | grep -q '01010011 01101001 01101101 01001111 01010011'; then + printf '%s\n' "$FF_OUT" | head -12 | sed 's/^/ /' +fi +gate "fastfetch renders the SimOS logo" \ + "printf '%s' \"\$FF_OUT\" | grep -q '01010011 01101001 01101101 01001111 01010011'" + # ----------------------------------------------------------- run the transform info "Running simulationos-deloop inside the chroot (as Calamares does)" -for m in proc sys dev; do mount --rbind "/$m" "$R/$m" 2>/dev/null; done # Calamares removes the live account first (removeuser module); mirror that. chroot "$R" /usr/bin/userdel -r liveuser >/dev/null 2>&1 || true chroot "$R" "$DELOOP" 2>&1 | sed 's/^/ /' DELOOP_RC=${PIPESTATUS[0]} -for m in dev sys proc; do umount -R "$R/$m" 2>/dev/null; done [ "$DELOOP_RC" -eq 0 ] || { red "deloop exited $DELOOP_RC"; exit 1; } +# Idempotence: a second run must change nothing and still succeed. +chroot "$R" "$DELOOP" >/dev/null 2>&1 +DELOOP_RC2=$? + +info "Building the installed-system initramfs (mkinitcpio -P, as Calamares' initcpio does)" +chroot "$R" /usr/bin/mkinitcpio -P 2>&1 | grep -E 'ERROR|WARNING|Image generation|Initcpio image' | sed 's/^/ /' +MKINIT_RC=${PIPESTATUS[0]} # ------------------------------------------------------------------ acceptance printf '\n== installed-system acceptance gates\n' -ERRORS=0 -gate() { if eval "$2"; then green " ok $1"; else red " FAILED $1"; ERRORS=$((ERRORS+1)); fi; } - +gate "deloop is idempotent (second run exits 0)" "[ '$DELOOP_RC2' -eq 0 ]" +gate "kernel installed to /boot" "[ -s '$R/boot/vmlinuz-linux-cachyos' ]" +gate "mkinitcpio -P succeeded" "[ '$MKINIT_RC' -eq 0 ]" +gate "initramfs generated" "[ -s '$R/boot/initramfs-linux-cachyos.img' ]" +gate "initramfs contains no archiso hook" "! chroot '$R' /usr/bin/lsinitcpio /boot/initramfs-linux-cachyos.img 2>/dev/null | grep -q archiso" +gate "pacman keyring initialised" "[ -s '$R/etc/pacman.d/gnupg/pubring.gpg' ] || [ -s '$R/etc/pacman.d/gnupg/pubring.kbx' ]" +gate "sudoers configuration valid (visudo -c)" "chroot '$R' /usr/bin/visudo -c >/dev/null 2>&1" +gate "liveuser absent from group files" "! grep -Eq '(^|[:,])liveuser(,|\$)' '$R/etc/group' '$R/etc/gshadow'" gate "liveuser removed from /etc/passwd" "! grep -q '^liveuser:' '$R/etc/passwd'" gate "/home/liveuser removed" "[ ! -d '$R/home/liveuser' ]" gate "SDDM live autologin removed" "[ ! -f '$R/etc/sddm.conf.d/20-simulationos-live-autologin.conf' ]" gate "tty1 autologin drop-in removed" "[ ! -f '$R/etc/systemd/system/getty@tty1.service.d/autologin.conf' ]" gate "NOPASSWD sudoers removed" "[ ! -f '$R/etc/sudoers.d/10-simulationos-live' ]" gate "password-prompting wheel rule added" "grep -q '^%wheel ALL=(ALL:ALL) ALL' '$R/etc/sudoers.d/10-simulationos-wheel'" -gate "no NOPASSWD rule left in sudoers.d" "! grep -rq 'NOPASSWD' '$R/etc/sudoers.d/'" +gate "no active NOPASSWD rule in sudoers" "! grep -rhsE '^[^#]*NOPASSWD' '$R/etc/sudoers' '$R/etc/sudoers.d/' >/dev/null" gate "permissive polkit rule removed" "[ ! -f '$R/etc/polkit-1/rules.d/49-simulationos-live-nopasswd.rules' ]" gate "root account locked" "grep -qE '^root:[!*]' '$R/etc/shadow'" gate "root no longer has an empty password" "! grep -q '^root::' '$R/etc/shadow'" @@ -98,10 +193,13 @@ gate "installer desktop entry removed" "[ ! -e '$R/usr/share/applications gate "display-manager still enabled" "[ -L '$R/etc/systemd/system/display-manager.service' ]" gate "default.target still graphical" "readlink '$R/etc/systemd/system/default.target' | grep -q graphical" gate "SimulationOS identity intact" "grep -q 'ID=simulationos' '$R/etc/os-release'" -gate "desktop skel intact for new users" "[ -f '$R/etc/skel/.config/hypr/hyprland.conf' ]" +gate "desktop skel intact for new users" "[ -f '$R/etc/skel/.config/hypr/hyprland.lua' ] && [ -f '$R/etc/skel/.config/waybar/config.jsonc' ]" +gate "SimOS wallpapers and logo kept" "[ -f '$R/usr/share/backgrounds/simulationos/simos-throne.png' ] && [ -f '$R/usr/share/simulationos/fastfetch/logo.txt' ]" +gate "NetworkManager still enabled" "[ -L '$R/etc/systemd/system/multi-user.target.wants/NetworkManager.service' ]" printf '\n' -rm -rf -- "$WORK" +trap - EXIT +remove_workdir || ERRORS=$((ERRORS + 1)) if [ "$ERRORS" -gt 0 ]; then red "DELOOP CONTRACT FAILED: $ERRORS gate(s)" exit 1 diff --git a/scripts/install-test.py b/scripts/install-test.py new file mode 100755 index 0000000..599d7f3 --- /dev/null +++ b/scripts/install-test.py @@ -0,0 +1,1213 @@ +#!/usr/bin/env python3 +"""install-test.py - end-to-end installation acceptance for SimulationOS. + +Drives the REAL user journey in QEMU and proves each link of the chain: + + ISO -> live Hyprland -> "Install SimulationOS" -> Calamares (GUI) + -> install to a blank disk -> power off -> ISO DETACHED + -> GRUB -> linux-cachyos -> systemd -> SDDM -> user login -> Hyprland + -> reboot -> boots again -> clean power off + +Method (the same technique CachyOS uses with quickemu/quicktest, without the +extra tooling): the guest is controlled like a person would control it - +QEMU `send-key` for the keyboard, an absolute-pointer tablet for the mouse, +`screendump` + tesseract OCR to read the screen. A serial console is used +only to OBSERVE (live medium: root shell; installed system: a shell obtained +by logging in graphically and running sudo with the user's password - which is +itself the proof that login and sudo authentication work). + +Nothing in the installed system is modified to make the test pass, and the +ISO is not attached during any installed-system boot. + +Usage: + scripts/install-test.py [path/to.iso] full run + scripts/install-test.py --phase install live + install only + scripts/install-test.py --phase boot installed-disk boots only + scripts/install-test.py --keep leave the VM running on failure + +Results: out/install-test/{results.json,summary.md,*.png,*.log} +Exit status is non-zero if any required check is not VERIFIED. + +Needs: qemu-system-x86_64, OVMF/edk2 firmware, tesseract. Python stdlib only. +""" +import argparse +import datetime +import glob +import gzip +import hashlib +import json +import os +import re +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +import zlib + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +TEST_USER = "simulationtest" +TEST_PASS = "Sim0S-alpha-test" +TEST_HOST = "simos-test" + +OVMF_CODE = [ + "/usr/share/OVMF/OVMF_CODE_4M.fd", "/usr/share/OVMF/OVMF_CODE.fd", + "/usr/share/edk2/x64/OVMF_CODE.4m.fd", "/usr/share/edk2-ovmf/x64/OVMF_CODE.fd", + "/opt/homebrew/share/qemu/edk2-x86_64-code.fd", "/usr/local/share/qemu/edk2-x86_64-code.fd", +] +OVMF_VARS = [ + "/usr/share/OVMF/OVMF_VARS_4M.fd", "/usr/share/OVMF/OVMF_VARS.fd", + "/usr/share/edk2/x64/OVMF_VARS.4m.fd", "/usr/share/edk2-ovmf/x64/OVMF_VARS.fd", + "/opt/homebrew/share/qemu/edk2-i386-vars.fd", "/usr/local/share/qemu/edk2-i386-vars.fd", +] + +STATUSES = ("VERIFIED", "FAILED", "BLOCKED", "NOT TESTED") + +# The acceptance matrix, in report order. Every key starts as NOT TESTED. +MATRIX = [ + "Live ISO UEFI boot", "Live graphical.target", "Live SDDM", "Live autologin", "Live Hyprland", + "Live Hyprland config", "Installer launcher", "Calamares UI", "Partition", "Mount", "unpackfs", + "Machine ID", "fstab", "Locale", "Keyboard", "Timezone", "User creation", "removeuser", + "simulationos-deloop", "Normal mkinitcpio", "GRUB install", "NetworkManager enablement", + "SDDM enablement", "Calamares completion", "Shutdown", "ISO detached", "Installed GRUB boot", + "Installed linux-cachyos", "Installed initramfs", "Installed systemd", "Installed SDDM", + "Installed user login", "Installed Hyprland", "Network", "Audio", "liveuser absent", + "live autologin absent", "NOPASSWD absent", "root locked", "sudo requires password", + "pacman", "System health", "second reboot", "shutdown", +] + + +def log(msg): + print(f"[{datetime.datetime.now():%H:%M:%S}] {msg}", flush=True) + + +class Fail(Exception): + """A step failed in a way that makes continuing pointless.""" + + +# ------------------------------------------------------------------- results +class Results: + def __init__(self, outdir): + self.outdir = outdir + self.data = {k: {"status": "NOT TESTED", "evidence": ""} for k in MATRIX} + + def set(self, key, ok, evidence=""): + status = ok if ok in STATUSES else ("VERIFIED" if ok else "FAILED") + self.data.setdefault(key, {}) + self.data[key] = {"status": status, "evidence": str(evidence).strip()[:400]} + mark = {"VERIFIED": "\033[32mok \033[0m", "FAILED": "\033[31mFAILED \033[0m"}.get(status, status + " ") + log(f" {mark} {key}" + (f" ({self.data[key]['evidence'][:110]})" if evidence else "")) + self.save() + return status == "VERIFIED" + + def save(self): + with open(os.path.join(self.outdir, "results.json"), "w") as fh: + json.dump({"generated_utc": datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + "checks": self.data}, fh, indent=2) + with open(os.path.join(self.outdir, "summary.md"), "w") as fh: + fh.write("| Check | Status | Evidence |\n|---|---|---|\n") + for key, val in self.data.items(): + ev = val["evidence"].replace("|", "\\|").replace("\n", " ") + fh.write(f"| {key} | {val['status']} | {ev} |\n") + + def failed(self, keys=None): + keys = keys or list(self.data) + return [k for k in keys if self.data[k]["status"] != "VERIFIED"] + + +# ----------------------------------------------------------------------- QMP +class QMP: + def __init__(self, path, timeout=60): + deadline = time.time() + timeout + while True: + try: + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(path) + break + except OSError: + if time.time() > deadline: + raise + time.sleep(0.3) + self.sock.settimeout(60) + self.buf = b"" + self._read() # greeting + self.cmd("qmp_capabilities") + + def _read(self): + while True: + while b"\n" not in self.buf: + chunk = self.sock.recv(65536) + if not chunk: + raise ConnectionError("QMP connection closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + if line.strip(): + msg = json.loads(line) + if "event" in msg: + continue + return msg + + def cmd(self, name, **args): + req = {"execute": name} + if args: + req["arguments"] = args + self.sock.sendall(json.dumps(req).encode() + b"\n") + msg = self._read() + if "error" in msg: + raise RuntimeError(f"QMP {name}: {msg['error']}") + return msg.get("return") + + def close(self): + try: + self.sock.close() + except OSError: + pass + + +# QEMU key names for characters that need a specific key or Shift. +_KEYS = { + " ": "spc", "-": "minus", "=": "equal", "[": "bracket_left", "]": "bracket_right", + ";": "semicolon", "'": "apostrophe", "`": "grave_accent", "\\": "backslash", + ",": "comma", ".": "dot", "/": "slash", "\n": "ret", "\t": "tab", +} +_SHIFTED = { + "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", "*": "8", "(": "9", + ")": "0", "_": "minus", "+": "equal", "{": "bracket_left", "}": "bracket_right", + ":": "semicolon", '"': "apostrophe", "~": "grave_accent", "|": "backslash", + "<": "comma", ">": "dot", "?": "slash", +} + + +# -------------------------------------------------------------------- screen +def ppm_to_png(ppm_path, png_path, scale=1): + """Convert a binary PPM (P6) to PNG, optionally nearest-neighbour upscaled.""" + with open(ppm_path, "rb") as fh: + data = fh.read() + tokens, pos = [], 0 + while len(tokens) < 4: + while data[pos:pos + 1].isspace(): + pos += 1 + if data[pos:pos + 1] == b"#": + pos = data.index(b"\n", pos) + 1 + continue + end = pos + while not data[end:end + 1].isspace(): + end += 1 + tokens.append(data[pos:end]) + pos = end + pos += 1 + if tokens[0] != b"P6": + raise ValueError("not a P6 PPM") + w, h = int(tokens[1]), int(tokens[2]) + raw = bytearray() + stride = w * 3 + for y in range(h): + row = data[pos + y * stride: pos + (y + 1) * stride] + if scale == 2: + wide = bytearray(stride * 2) + for c in range(3): + wide[c::6] = row[c::3] + wide[c + 3::6] = row[c::3] + row = bytes(wide) + raw += b"\x00" + row + b"\x00" + row + else: + raw += b"\x00" + row + + def chunk(tag, body): + out = struct.pack(">I", len(body)) + tag + body + return out + struct.pack(">I", zlib.crc32(tag + body) & 0xFFFFFFFF) + + png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w * scale, h * scale, 8, 2, 0, 0, 0)) + png += chunk(b"IDAT", zlib.compress(bytes(raw), 3)) + chunk(b"IEND", b"") + with open(png_path, "wb") as fh: + fh.write(png) + return w, h + + +def _norm(text): + return re.sub(r"[^a-z0-9]+", "", text.lower()) + + +class Screen: + """Look at the guest display and act on it.""" + + def __init__(self, qmp, outdir): + self.qmp, self.outdir = qmp, outdir + self.count = 0 + self.size = (1280, 800) + self.have_ocr = shutil.which("tesseract") is not None + + # -- capture + def _dump(self, name): + self.count += 1 + base = os.path.join(self.outdir, f"{self.count:03d}-{name}") + ppm = base + ".ppm" + self.qmp.cmd("screendump", filename=ppm) + for _ in range(50): + if os.path.exists(ppm) and os.path.getsize(ppm) > 100: + break + time.sleep(0.1) + self.size = ppm_to_png(ppm, base + ".png") + return base, ppm + + def shot(self, name): + base, ppm = self._dump(name) + os.remove(ppm) + return base + ".png" + + def words(self, name="ocr"): + """OCR the screen. Returns (png_path, [(text, x, y, w, h, line_id)]).""" + base, ppm = self._dump(name) + big = base + ".x2.png" + ppm_to_png(ppm, big, scale=2) # UI text is small; 2x helps OCR a lot + os.remove(ppm) + out = [] + if self.have_ocr: + res = subprocess.run(["tesseract", big, "stdout", "--psm", "11", "-l", "eng", "tsv"], + capture_output=True, text=True) + for line in res.stdout.splitlines()[1:]: + f = line.split("\t") + if len(f) < 12 or not f[11].strip(): + continue + out.append((f[11].strip(), int(f[6]) // 2, int(f[7]) // 2, int(f[8]) // 2, int(f[9]) // 2, + (f[2], f[3], f[4]))) + os.remove(big) + return base + ".png", out + + def widest_run(self, rgb, rows, name="pixels"): + """Longest horizontal run of exactly `rgb`, in pixels, over the given rows.""" + base, ppm = self._dump(name) + with open(ppm, "rb") as fh: + data = fh.read() + ppm_to_png(ppm, base + ".png") + os.remove(ppm) + m = re.match(rb"P6\s+(\d+)\s+(\d+)\s+\d+\s", data) + if not m: + return 0 + w, h = int(m.group(1)), int(m.group(2)) + best, want = 0, bytes(rgb) + for y in rows: + if y >= h: + break + row = data[m.end() + y * w * 3: m.end() + (y + 1) * w * 3] + run = 0 + for x in range(w): + run = run + 1 if row[x * 3:x * 3 + 3] == want else 0 + best = max(best, run) + return best + + def text(self, name="ocr"): + return " ".join(w[0] for w in self.words(name)[1]) + + def find(self, phrase, name="find"): + """Return the centre (x, y) of `phrase` on screen, or None.""" + want = _norm(phrase) + _, words = self.words(name) + for i in range(len(words)): + acc = "" + for j in range(i, min(i + 8, len(words))): + if words[j][5] != words[i][5]: + break + acc += _norm(words[j][0]) + if acc == want or (len(want) > 5 and want in acc): + x0, y0 = words[i][1], min(w[2] for w in words[i:j + 1]) + x1 = words[j][1] + words[j][3] + y1 = max(w[2] + w[4] for w in words[i:j + 1]) + return (x0 + x1) // 2, (y0 + y1) // 2 + if not want.startswith(acc): + break + return None + + def wait_text(self, phrases, timeout, name="wait", interval=4): + """Wait until any of `phrases` is visible. Returns the phrase or None.""" + if isinstance(phrases, str): + phrases = [phrases] + deadline = time.time() + timeout + while True: + seen = _norm(self.text(name)) + for p in phrases: + if _norm(p) in seen: + return p + if time.time() > deadline: + return None + time.sleep(interval) + + # -- input + def key(self, combo, hold=80): + keys = [{"type": "qcode", "data": k} for k in combo.split("+")] + self.qmp.cmd("send-key", keys=keys, **{"hold-time": hold}) + time.sleep(0.25) + + def type(self, text, delay=0.06): + for ch in text: + if ch in _SHIFTED: + combo = "shift+" + _SHIFTED[ch] + elif ch.isupper(): + combo = "shift+" + ch.lower() + else: + combo = _KEYS.get(ch, ch) + keys = [{"type": "qcode", "data": k} for k in combo.split("+")] + self.qmp.cmd("send-key", keys=keys, **{"hold-time": 60}) + time.sleep(delay) + time.sleep(0.3) + + def move(self, x, y): + w, h = self.size + self.qmp.cmd("input-send-event", events=[ + {"type": "abs", "data": {"axis": "x", "value": int(x * 32767 / w)}}, + {"type": "abs", "data": {"axis": "y", "value": int(y * 32767 / h)}}]) + time.sleep(0.2) + + def click(self, x, y): + self.move(x, y) + for down in (True, False): + self.qmp.cmd("input-send-event", events=[{"type": "btn", "data": {"down": down, "button": "left"}}]) + time.sleep(0.12) + time.sleep(0.4) + + def click_text(self, phrase, name="click", dx=0, dy=0): + pos = self.find(phrase, name) + if pos is None: + return False + self.click(pos[0] + dx, pos[1] + dy) + return True + + +# -------------------------------------------------------------------- serial +class Serial: + """Serial console of the guest: type lines, read what came back. + + Output is read from QEMU's own chardev logfile, so nothing is lost while + no client is connected. + """ + + def __init__(self, sock_path, log_path): + self.sock_path, self.log_path = sock_path, log_path + self.sock = None + self.seq = 0 + + def connect(self, timeout=60): + deadline = time.time() + timeout + while True: + try: + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(self.sock_path) + self.sock.setblocking(False) + return + except OSError: + if time.time() > deadline: + raise + time.sleep(0.3) + + def send(self, line): + self._drain() + self.sock.setblocking(True) + self.sock.sendall(line.encode() + b"\n") + self.sock.setblocking(False) + + def _drain(self): + try: + while self.sock.recv(65536): + pass + except (BlockingIOError, OSError): + pass + + def clean(self): + try: + with open(self.log_path, "rb") as fh: + data = fh.read() + except FileNotFoundError: + return "" + text = data.replace(b"\x00", b"").decode("utf-8", "replace") + text = re.sub(r"\x1b\][^\x07\x1b]*(\x07|\x1b\\)", "", text) # OSC + text = re.sub(r"\x1b\[[0-9;?=]*[a-zA-Z]", "", text) # CSI + return text.replace("\r", "\n") + + def mark(self): + """Position in the console output; pass it to wait_for to ignore what came before.""" + return len(self.clean()) + + def wait_for(self, pattern, timeout, alive=None, since=0): + deadline = time.time() + timeout + rx = re.compile(pattern, re.M) + while True: + self._drain() + m = rx.search(self.clean(), since) + if m: + return m + if alive and not alive(): + return None + if time.time() > deadline: + return None + time.sleep(1.5) + + @staticmethod + def _squeeze(text): + return re.sub(r"(.)\1+", r"\1", text) + + def _marker(self, text, word, tag, start): + """(start, end) of a marker line at or after `start`, or None.""" + want = self._squeeze(f"##{word} {tag}") + for m in re.finditer(r"^#.*$", text[start:], re.M): + if self._squeeze(m.group(0).strip()) == want: + return start + m.start(), start + m.end() + return None + + @staticmethod + def _unhex(block): + """Decode `od -tx1` output, undoing repeated characters; None if it is damaged.""" + out = bytearray() + for tok in block.split(): + if len(tok) != 2: + runs = [m.group(1) for m in re.finditer(r"(.)\1*", tok)] + if len(tok) < 2 or len(runs) > 2: + return None + tok = runs[0] * 2 if len(runs) == 1 else runs[0] + runs[1] + try: + out.append(int(tok, 16)) + except ValueError: + return None + return bytes(out) + + def run(self, cmd, timeout=180): + """Run a shell command in the logged-in serial shell; return (rc, output). + + The emulated UART repeats a character now and then under KVM (seen on + the hosted runners: "wallpapeer", "###END"), so nothing is trusted as it + arrives. The guest keeps the output in a file and sends it gzipped, as + hex bytes, with an MD5 and the exit status; a repeated character is + undone, the sum is checked, and a damaged transfer is asked for again. + (gzip because the UART is paced at its baud rate: a whole Calamares log + as plain hex takes minutes.) The markers + have no doubled characters once squeezed, and are assembled by printf + in the guest, so an echoed command line is never mistaken for output. + """ + self.seq += 1 + base = "T" + ".".join(f"{self.seq:04d}") + deadline = time.time() + timeout + text = "" + for attempt in "abc": + tag = base + attempt + emit = (f"printf '\\n##BEG%s {tag}\\n' IN; od -An -v -tx1 $_f.gz; printf '\\n##SU%s {tag}\\n' M; " + f"{{ md5sum <$_f.gz; echo $_r; }} | od -An -v -tx1; printf '\\n##EN%s {tag}\\n' D") + if attempt == "a": + self.send(f"_f=/tmp/.simos-test.$UID; {{ {cmd} ; }} >$_f 2>&1; _r=$?; gzip -c <$_f >$_f.gz; " + emit) + else: + self.send(emit) + deadline = max(deadline, time.time() + 60) + while True: + self._drain() + text = self.clean() + beg = self._marker(text, "BEGIN", tag, 0) + mid = beg and self._marker(text, "SUM", tag, beg[1]) + end = mid and self._marker(text, "END", tag, mid[1]) + if end: + break + if time.time() > deadline: + return None, text[-1500:] + time.sleep(1.5) + data = self._unhex(text[beg[1]:mid[0]]) + trailer = (self._unhex(text[mid[1]:end[0]]) or b"").decode("ascii", "replace").split() + if (data is not None and len(trailer) >= 3 and trailer[-1].isdigit() + and trailer[0] == hashlib.md5(data).hexdigest()): + body = gzip.decompress(data) if data else b"" + return int(trailer[-1]), body.decode("utf-8", "replace").replace("\r", "").strip() + return None, "serial transfer still damaged after 3 attempts: " + text[-600:] + + +# ------------------------------------------------------------------------ VM +class VM: + def __init__(self, workdir, disk, iso=None, ram=4096, cpus=None, accel=None, tag="vm"): + self.workdir, self.disk, self.iso, self.tag = workdir, disk, iso, tag + self.ram = ram + self.cpus = cpus or min(4, os.cpu_count() or 2) + self.accel = accel or os.environ.get("SIMOS_QEMU_ACCEL", "kvm:tcg") + # Unix socket paths are limited to ~104 bytes; fall back to a short + # temporary directory when the work directory is deep. + sockdir = workdir + if len(os.path.join(workdir, f"{tag}.serial")) > 96: + sockdir = tempfile.mkdtemp(prefix="simos-") + self.qmp_path = os.path.join(sockdir, f"{tag}.qmp") + self.ser_path = os.path.join(sockdir, f"{tag}.serial") + self.ser_log = os.path.join(workdir, f"{tag}-serial.log") + self.vars = os.path.join(workdir, "OVMF_VARS.fd") + self.proc = None + + def start(self): + code = next((p for p in OVMF_CODE if os.path.exists(p)), None) + vars_src = next((p for p in OVMF_VARS if os.path.exists(p)), None) + if not code or not vars_src: + raise Fail("OVMF/edk2 firmware not found") + if not os.path.exists(self.vars): + shutil.copyfile(vars_src, self.vars) # kept across boots: holds the EFI boot entries + for p in (self.qmp_path, self.ser_path, self.ser_log): + if os.path.exists(p): + os.remove(p) + args = [ + "qemu-system-x86_64", + "-machine", "q35", "-cpu", "max", "-smp", str(self.cpus), "-m", str(self.ram), + # virtio-gpu, not "std": Hyprland needs a DRM device it can create a + # renderer on. On plain VGA (bochs) it starts but draws nothing. + "-display", "none", "-vga", os.environ.get("SIMOS_QEMU_VGA", "virtio"), + "-drive", f"if=pflash,format=raw,unit=0,readonly=on,file={code}", + "-drive", f"if=pflash,format=raw,unit=1,file={self.vars}", + "-drive", f"file={self.disk},if=virtio,format=qcow2,cache=unsafe,discard=unmap", + "-device", "virtio-net-pci,netdev=n0", "-netdev", "user,id=n0", + "-audiodev", "none,id=nosnd", "-device", "intel-hda", "-device", "hda-duplex,audiodev=nosnd", + "-device", "qemu-xhci", "-device", "usb-tablet", + "-chardev", f"socket,id=ser0,path={self.ser_path},server=on,wait=off,logfile={self.ser_log}", + "-serial", "chardev:ser0", + "-qmp", f"unix:{self.qmp_path},server=on,wait=off", + ] + # "kvm:tcg" = try KVM, fall back to emulation (hosted runners, macOS). + for accel in self.accel.split(":"): + args += ["-accel", accel] + if self.iso: + args += ["-drive", f"file={self.iso},media=cdrom,readonly=on", "-boot", "order=d,menu=off"] + with open(os.path.join(self.workdir, f"{self.tag}-qemu.cmd"), "w") as fh: + fh.write(" ".join(args) + "\n") + self.proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, + stderr=open(os.path.join(self.workdir, f"{self.tag}-qemu.err"), "w")) + time.sleep(1.5) + if self.proc.poll() is not None: + raise Fail(f"QEMU exited immediately (see {self.tag}-qemu.err)") + + def alive(self): + return self.proc is not None and self.proc.poll() is None + + def wait_exit(self, timeout): + try: + self.proc.wait(timeout=timeout) + return True + except subprocess.TimeoutExpired: + return False + + def kill(self): + if self.alive(): + self.proc.kill() + self.proc.wait() + + +# ================================================================== the test +def kvm_usable(): + return os.path.exists("/dev/kvm") and os.access("/dev/kvm", os.R_OK | os.W_OK) + + +class Timeouts: + """Budgets in seconds. Emulation (no KVM) is roughly 6x slower.""" + + def __init__(self): + k = 1 if kvm_usable() else int(os.environ.get("SIMOS_TCG_FACTOR", "6")) + self.boot = 420 * k # firmware -> login prompt / greeter + self.desktop = 300 * k # greeter/login -> usable desktop + self.ui = 90 * k # one installer page + self.install = 1800 * k # all Calamares jobs + self.shutdown = 120 * k + self.cmd = 60 * k + + +def first_line(text): + return (text or "").strip().splitlines()[0].strip() if (text or "").strip() else "" + + +def lines(text): + return [ln.strip() for ln in (text or "").splitlines() if ln.strip()] + + +class Guest: + """One running VM with its screen and serial console.""" + + def __init__(self, outdir, disk, iso, tag): + self.vm = VM(outdir, disk, iso=iso, tag=tag) + self.vm.start() + self.qmp = QMP(self.vm.qmp_path) + self.screen = Screen(self.qmp, outdir) + self.screen.count = len(glob.glob(os.path.join(outdir, "[0-9][0-9][0-9]-*.png"))) + self.serial = Serial(self.vm.ser_path, self.vm.ser_log) + self.serial.connect() + + def sh(self, cmd, timeout=120): + rc, out = self.serial.run(cmd, timeout) + return rc, out + + def ok(self, cmd, timeout=120): + return self.sh(cmd, timeout)[0] == 0 + + def close(self): + self.qmp.close() + self.vm.kill() + + +# Calamares appends to its session log; $L is only the run that is in progress. +THIS_RUN = ("L=/tmp/calamares-this-run.log; awk '/=== START CALAMARES/ { buf = \"\" } { buf = buf $0 \"\\n\" } " + "END { printf \"%s\", buf }' /root/.cache/calamares/session.log > $L 2>/dev/null; ") + + +# ------------------------------------------------------------- phase: install +def phase_install(args, res, outdir, T): + disk = os.path.join(outdir, "simulationos-test.qcow2") + for stale in (disk, os.path.join(outdir, "OVMF_VARS.fd")): + if os.path.exists(stale): + os.remove(stale) + subprocess.run(["qemu-img", "create", "-q", "-f", "qcow2", disk, "30G"], check=True) + + log(f"Booting the live ISO: {os.path.basename(args.iso)} ({'KVM' if kvm_usable() else 'TCG emulation'})") + g = Guest(outdir, disk, args.iso, "live") + ser, sc = g.serial, g.screen + try: + # ---- live boot + if not ser.wait_for(r"l+o+g+i+n+:", T.boot, alive=g.vm.alive): + res.set("Live ISO UEFI boot", False, "no login prompt on the serial console") + raise Fail("the live system did not boot") + ser.send("") + time.sleep(2) + ser.send("root") + time.sleep(6) + ser.send("export PS1= PAGER=cat SYSTEMD_PAGER=cat SYSTEMD_PAGERSECURE=0 SYSTEMD_COLORS=0 TERM=dumb; stty -echo") + time.sleep(2) + rc, out = g.sh("test -d /sys/firmware/efi && uname -r", T.cmd) + res.set("Live ISO UEFI boot", rc == 0 and "cachyos" in (out or ""), f"UEFI, kernel {first_line(out)}") + if rc != 0: + raise Fail("no usable root shell on the live serial console") + + log("Waiting for the live desktop") + # The instance signature is looked up on every call: hc is first used + # while waiting for Hyprland, before its instance directory exists. + hc = ("hc() { sig=$(ls -t /run/user/1000/hypr 2>/dev/null | head -1); runuser -u liveuser -- env " + "XDG_RUNTIME_DIR=/run/user/1000 HYPRLAND_INSTANCE_SIGNATURE=$sig hyprctl \"$@\"; }; ") + # Ready = drawn: the bar and wallpaper surfaces exist, not merely their processes. + g.sh(hc + "for i in $(seq 1 120); do systemctl is-active --quiet graphical.target && pgrep -x Hyprland >/dev/null " + "&& hc layers 2>/dev/null | grep -q 'namespace: waybar' && hc layers 2>/dev/null | grep -q 'namespace: wallpaper' " + "&& break; sleep 3; done", T.desktop + 60) + time.sleep(5) + rc, out = g.sh("systemctl is-active graphical.target; systemctl get-default") + res.set("Live graphical.target", "active" in lines(out)[:1] and "graphical.target" in (out or ""), " / ".join(lines(out))) + rc, out = g.sh("systemctl is-active sddm.service display-manager.service") + res.set("Live SDDM", lines(out)[:2] == ["active", "active"], "sddm.service " + " / ".join(lines(out))) + rc, out = g.sh("loginctl list-sessions --no-legend | grep -E 'liveuser.*seat0' | head -1") + res.set("Live autologin", rc == 0 and "liveuser" in (out or ""), first_line(out) or "no liveuser session on seat0") + + rc, out = g.sh(hc + "pgrep -x Hyprland >/dev/null && echo RUNNING; " + "grep -qE 'no renderer for gl formats|Failed to initialize renderer state' " + "/run/user/1000/hypr/*/hyprland.log && echo NO_RENDERER; " + "hc layers | grep -oE 'namespace: (waybar|wallpaper)' | sort -u; " + "pgrep -x -u liveuser 'waybar|swaybg|mako|nm-applet|hyprpolkitagent' -l | awk '{print $2}' | sort -u | tr '\\n' ' '") + o = out or "" + res.set("Live Hyprland", "RUNNING" in o and "NO_RENDERER" not in o and "namespace: waybar" in o + and "namespace: wallpaper" in o and all(p in o for p in ("mako", "swaybg", "nm-applet", "hyprpolkitagen")), + " ".join(lines(o))) + rc, out = g.sh(hc + "hc configerrors") + res.set("Live Hyprland config", rc == 0 and not lines(out), "hyprctl configerrors: " + ("none" if not lines(out) else first_line(out))) + sc.shot("live-desktop") + + # ---- installer launcher: the application-menu entry (.desktop file) + log("Launching the installer from the application menu") + # The bar's bold monospace label does not OCR reliably ("Install Sin + # ationos", or nothing), so the button is recognised by what it is: the + # one wide accent-coloured block in the bar. The launcher test below + # proves the entry it starts. + bar_button = sc.widest_run((0x17, 0x93, 0xd1), rows=range(4, 30), name="live-bar") >= 150 + sc.key("meta_l+d") + time.sleep(4 if kvm_usable() else 8) + sc.type("Install Sim") + time.sleep(2) + sc.shot("launcher-menu") + sc.key("ret") + opened = sc.wait_text("Welcome to the SimulationOS", T.ui * 2, "installer-welcome") + rc, out = g.sh("pgrep -a -x calamares") + cmdline_ok = "-c /usr/share/simulationos/calamares" in (out or "") + res.set("Installer launcher", bool(opened) and cmdline_ok and bar_button, + f"menu entry -> pkexec -> {first_line(out)[:70]}; bar button visible: {bar_button}") + if not opened: + rc, out = g.sh("tail -5 /home/liveuser/simulationos-install.log") + raise Fail("Calamares did not open: " + (out or "")[-300:]) + + # ---- Calamares pages + def page(marker, name): + got = sc.wait_text(marker, T.ui, name) + if not got: + res.set("Calamares UI", False, f"page '{name}' not reached") + raise Fail(f"installer page '{name}' not reached") + + sc.key("alt+n") + page(["The system language will be set", "Region:"], "installer-location") + sc.key("alt+n") + page(["Keyboard model", "Type here to test"], "installer-keyboard") + sc.key("alt+n") + page("Erase disk", "installer-partition") + if not sc.click_text("Erase disk", "installer-partition-click"): + raise Fail("could not select 'Erase disk'") + page(["After:", "EFI system"], "installer-partition-erase") + layout = sc.text("installer-partition-layout") + sc.key("alt+n") + page("What is your name", "installer-users") + pos = sc.find("What is your name", "installer-users-label") + sc.click(pos[0], pos[1] + 27) # the name field sits under its label + sc.type("Simulation Test") + sc.key("tab"); sc.key("ctrl+a"); sc.type(TEST_USER) + sc.key("tab"); sc.key("ctrl+a"); sc.type(TEST_HOST) + sc.key("tab"); sc.type(TEST_PASS) + sc.key("tab"); sc.type(TEST_PASS) + time.sleep(2) + sc.shot("installer-users-filled") + sc.key("alt+n") + page("This is an overview", "installer-summary") + summary = sc.text("installer-summary-text") + sc.key("alt+i") + page("not be able to undo", "installer-confirm") + res.set("Calamares UI", True, "welcome, location, keyboard, partition, users, summary navigated") + golden = all(w in _norm(layout + summary) for w in ("ext4", "efi")) + log("Starting the installation" + ("" if golden else " (WARNING: summary does not show EFI + ext4)")) + sc.key("alt+i") + + # ---- wait for the jobs + deadline = time.time() + T.install + last, failed, done = "", "", False + while time.time() < deadline: + time.sleep(20) + rc, out = g.sh(THIS_RUN + "grep -c 'Installation failed' $L; " + "grep 'Starting job' $L | tail -1 | sed 's/.*Starting job //'; " + "grep -c 'installed system verified' $L", T.cmd) + ls = lines(out) if rc is not None else [] + if len(ls) >= 3: + if ls[1] != last: + last = ls[1] + log(f" job {last}") + if ls[0] != "0": + failed = last + break + if sc.wait_text(["All done", "Installation Failed"], 0, "installer-progress") == "All done": + done = True + break + sc.shot("installer-end") + # Only the outline of the log: the serial line manages a few hundred + # characters a second on the hosted runners. + rc, slog = g.sh(THIS_RUN + "grep -E 'Starting job|ERROR|Installation failed|installed system verified' $L " + "| cut -c1-200 | tail -n 80", T.cmd * 2) + with open(os.path.join(outdir, "calamares-session.log"), "w") as fh: + fh.write(slog or "") + if not done: + rc, out = g.sh(THIS_RUN + "grep -A6 'Installation failed' $L | head -12") + res.set("Calamares completion", False, f"failed in job {failed or last}: {' '.join(lines(out))[:260]}") + raise Fail(f"installation failed in job {failed or last}") + res.set("Calamares completion", "installed system verified" in (slog or ""), + "finished page reached; target verification job passed") + + # ---- inspect what was written, from the live system + log("Inspecting the installed disk") + inspect_target(g, res, T) + + # ---- power off + ser.send("systemctl poweroff") + res.set("Shutdown", g.vm.wait_exit(T.shutdown), "live system powered off cleanly after the installation") + finally: + try: + sc.shot("live-final") + except Exception: # the VM may already be gone + pass + g.close() + + +def inspect_target(g, res, T): + """Mount the freshly installed disk read-only and check each installer job's result.""" + sh = g.sh + rc, out = sh("mkdir -p /mnt/t && mount -o ro /dev/vda2 /mnt/t && mount -o ro /dev/vda1 /mnt/t/boot/efi && echo MOUNTED") + if "MOUNTED" not in (out or ""): + res.set("Mount", False, "could not mount /dev/vda2 and /dev/vda1: " + (out or "")[-200:]) + return + rc, out = sh("lsblk -rno NAME,FSTYPE,PARTTYPENAME /dev/vda; blkid -o value -s PTTYPE /dev/vda") + o = out or "" + res.set("Partition", "vda1 vfat EFI" in o.replace("\\x20", " ") and "vda2 ext4" in o and "gpt" in o, " | ".join(lines(o))) + rc, out = sh("findmnt -rno TARGET,SOURCE,FSTYPE /mnt/t; findmnt -rno TARGET,SOURCE,FSTYPE /mnt/t/boot/efi") + res.set("Mount", len(lines(out)) == 2, " | ".join(lines(out))) + rc, out = sh("grep -c '^NAME=\"SimulationOS\"' /mnt/t/etc/os-release; du -sxm /mnt/t | cut -f1; test -x /mnt/t/usr/bin/Hyprland && echo HYPR") + ls = lines(out) + res.set("unpackfs", len(ls) >= 3 and ls[0] == "1" and int(ls[1]) > 3000 and "HYPR" in ls, + f"root filesystem {ls[1] if len(ls) > 1 else '?'} MiB, SimulationOS os-release, Hyprland present") + rc, out = sh("cat /mnt/t/etc/machine-id; cat /etc/machine-id") + ls = lines(out) + res.set("Machine ID", len(ls) == 2 and re.fullmatch(r"[0-9a-f]{32}", ls[0]) is not None and ls[0] != ls[1], + f"target {ls[0] if ls else '?'} differs from the live medium") + rc, out = sh("grep -v '^#' /mnt/t/etc/fstab | grep -v '^$'; echo ---; blkid -o value -s UUID /dev/vda1 /dev/vda2") + body, _, uuids = (out or "").partition("---") + uu = lines(uuids) + fst = lines(body) + res.set("fstab", len(uu) == 2 and all(any(u in ln for ln in fst) for u in uu) and len(fst) == 2 + and not re.search(r"archiso|airootfs|/run/|loop", body), " | ".join(fst)) + rc, out = sh("cat /mnt/t/etc/locale.conf | head -1; grep -c -v '^#' /mnt/t/etc/locale.gen") + res.set("Locale", "LANG=" in (out or ""), " / ".join(lines(out)[:1]) + " (locale.gen entries: " + (lines(out)[-1] if lines(out) else "?") + ")") + rc, out = sh("grep -h -E '^(KEYMAP|XKBLAYOUT)=' /mnt/t/etc/vconsole.conf /mnt/t/etc/default/keyboard") + res.set("Keyboard", "KEYMAP=" in (out or "") and "XKBLAYOUT=" in (out or ""), " ".join(lines(out))) + rc, out = sh("readlink /mnt/t/etc/localtime; readlink /etc/localtime") + res.set("Timezone", "zoneinfo/" in first_line(out), "target /etc/localtime -> " + first_line(out)) + rc, out = sh(f"grep '^{TEST_USER}:' /mnt/t/etc/passwd; stat -c '%u %a' /mnt/t/home/{TEST_USER}; " + f"grep -E '^wheel:.*{TEST_USER}' /mnt/t/etc/group | cut -d: -f1; " + f"awk -F: '$1==\"{TEST_USER}\"{{print substr($2,1,3)}}' /mnt/t/etc/shadow; " + f"test -f /mnt/t/home/{TEST_USER}/.config/hypr/hyprland.lua && echo SKEL; cat /mnt/t/etc/hostname") + o = out or "" + res.set("User creation", f"/home/{TEST_USER}:/bin/bash" in o and "1000 700" in o and "wheel" in o and "$" in o + and "SKEL" in o and TEST_HOST in o, " | ".join(lines(o))) + rc, out = sh("grep -c '^liveuser:' /mnt/t/etc/passwd; test -e /mnt/t/home/liveuser && echo HOME_LEFT; " + "grep -cE '(^|[:,])liveuser(,|$)' /mnt/t/etc/group") + res.set("removeuser", lines(out) == ["0", "0"], "liveuser absent from passwd, group and /home") + rc, out = sh("cd /mnt/t; for f in etc/sudoers.d/10-simulationos-live etc/polkit-1/rules.d/49-simulationos-live-nopasswd.rules " + "etc/sddm.conf.d/20-simulationos-live-autologin.conf etc/systemd/system/getty@tty1.service.d/autologin.conf " + "etc/mkinitcpio.conf.d/archiso.conf usr/local/bin/simulationos-install usr/share/simulationos/calamares " + "usr/lib/simulationos/calamares-compat usr/bin/calamares; do test -e $f && echo LEFT:$f; done; " + "awk -F: '$1==\"root\"{print \"root:\" substr($2,1,1)}' etc/shadow; " + "grep -rhsE '^[^#]*NOPASSWD' etc/sudoers etc/sudoers.d | head -1; cat etc/sudoers.d/10-simulationos-wheel | grep -v '^#'") + o = out or "" + res.set("simulationos-deloop", "LEFT:" not in o and "root:!" in o and "NOPASSWD" not in o and "%wheel ALL=(ALL:ALL) ALL" in o, + "live sudo/polkit/autologin/archiso config and installer removed; root locked; wheel must authenticate" + if "LEFT:" not in o else " ".join(lines(o))) + rc, out = sh("ls -l /mnt/t/boot/vmlinuz-linux-cachyos /mnt/t/boot/initramfs-linux-cachyos.img | awk '{print $5, $9}'; " + "grep '^HOOKS' /mnt/t/etc/mkinitcpio.conf; lsinitcpio /mnt/t/boot/initramfs-linux-cachyos.img | grep -c archiso") + ls = lines(out) + res.set("Normal mkinitcpio", len(ls) == 4 and ls[-1] == "0" and "archiso" not in ls[2], + f"{ls[2] if len(ls) > 2 else ''}; archiso files in the image: {ls[-1] if ls else '?'}") + rc, out = sh("U=$(blkid -o value -s UUID /dev/vda2); grep -c \"root=UUID=$U\" /mnt/t/boot/grub/grub.cfg; " + "grep -c 'vmlinuz-linux-cachyos' /mnt/t/boot/grub/grub.cfg; grep -c 'initramfs-linux-cachyos.img' /mnt/t/boot/grub/grub.cfg; " + "find /mnt/t/boot/efi/EFI -iname '*.efi' | sed 's|/mnt/t/boot/efi/||' | sort | tr '\\n' ' '; echo; " + "efibootmgr | grep -i simulationos | head -1") + ls = lines(out) + res.set("GRUB install", len(ls) >= 5 and all(x != "0" for x in ls[:3]) and "grubx64.efi" in ls[3].lower() + and "boot/bootx64.efi" in ls[3].lower() and "simulationos" in ls[4].lower(), + f"grub.cfg boots linux-cachyos by root UUID; ESP: {ls[3] if len(ls) > 3 else '?'}; {ls[4] if len(ls) > 4 else 'no EFI entry'}") + rc, out = sh("readlink /mnt/t/etc/systemd/system/multi-user.target.wants/NetworkManager.service") + res.set("NetworkManager enablement", "NetworkManager.service" in (out or ""), first_line(out)) + rc, out = sh("readlink /mnt/t/etc/systemd/system/display-manager.service; readlink /mnt/t/etc/systemd/system/default.target; " + "grep -A3 '^\\[Autologin\\]' /mnt/t/etc/sddm.conf | grep -E '^User=' ") + o = out or "" + res.set("SDDM enablement", "sddm.service" in o and "graphical.target" in o and "User=liveuser" not in o, + " | ".join(lines(o))) + sh("umount -R /mnt/t") + + +# ---------------------------------------------------------------- phase: boot +GREETER = ["User name", "Password", "Login"] + + +def greeter_login(g, T, name): + """Log in at the SDDM greeter the way a person would. Returns True once typed.""" + sc = g.screen + if not sc.wait_text(GREETER, T.boot, f"{name}-greeter", interval=5): + return False + time.sleep(3) + sc.shot(f"{name}-greeter-ready") + # Put the cursor in the user-name field explicitly: the greeter remembers + # the last user, so which field has focus differs between boots. + pos = sc.find("User name", f"{name}-greeter-user") + if pos: + sc.click(pos[0] + 40, pos[1] + 30) + sc.key("ctrl+a") + sc.type(TEST_USER) + sc.key("tab") + sc.type(TEST_PASS) + sc.shot(f"{name}-greeter-filled") + sc.key("ret") + return True + + +def desktop_shell(g, T, name): + """Open a terminal in the user's session and get a serial login from it. + + Returns (terminal_seen, sudo_prompt_seen, serial_ok). + """ + sc, ser = g.screen, g.serial + prompt = f"{TEST_USER}@{TEST_HOST}" + terminal = False + deadline = time.time() + T.desktop + while time.time() < deadline and not terminal: + time.sleep(10) + seen = _norm(sc.text(f"{name}-desktop")) + if any(_norm(w) in seen for w in ("User name", "Login failed")): + continue # still at the greeter + sc.key("meta_l+ret") + terminal = bool(sc.wait_text(prompt, T.ui, f"{name}-terminal", interval=5)) + if not terminal: + return False, False, False + sc.shot(f"{name}-terminal-open") + # Real sudo, real password prompt, typed by "the user". + # The console log still holds the prompts of the previous boot: only a + # prompt that appears from here on is the getty being started now. + since = ser.mark() + sc.type("sudo systemctl start serial-getty@ttyS0.service\n") + asked = bool(sc.wait_text("password for", T.ui, f"{name}-sudo-prompt", interval=3)) + sc.type(TEST_PASS + "\n") + if not ser.wait_for(r"l+o+g+i+n+:", T.ui * 2, since=since): + return terminal, asked, False + since = ser.mark() + ser.send(TEST_USER) + ser.wait_for(r"P+a+s+w+o+r+d+:", T.ui, since=since) + time.sleep(1) + ser.send(TEST_PASS) + time.sleep(5) + ser.send("export PS1= PAGER=cat SYSTEMD_PAGER=cat SYSTEMD_PAGERSECURE=0 SYSTEMD_COLORS=0 TERM=dumb SIMOS_FETCH_SHOWN=1; stty -echo") + time.sleep(2) + rc, out = g.sh("id -un", T.cmd) + return terminal, asked, (rc == 0 and first_line(out) == TEST_USER) + + +def phase_boot(args, res, outdir, T): + disk = os.path.join(outdir, "simulationos-test.qcow2") + if not os.path.exists(disk): + raise Fail(f"no installed disk at {disk}; run the install phase first") + + # ============================================================ first boot + log("Booting the installed disk ALONE (no ISO attached)") + g = Guest(outdir, disk, None, "installed") + sc = g.screen + with open(os.path.join(outdir, "installed-qemu.cmd")) as fh: + cmdline = fh.read() + res.set("ISO detached", "media=cdrom" not in cmdline and ".iso" not in cmdline, + "QEMU started with the qcow2 disk only (see installed-qemu.cmd)") + try: + # GRUB's menu is only on screen for a few seconds: capture fast, read later. + grub_shots = [] + t0 = time.time() + while time.time() - t0 < (25 if kvm_usable() else 90): + grub_shots.append(sc.shot("installed-firmware")) + time.sleep(1) + grub_seen = False + for path in grub_shots: + if grub_seen or not sc.have_ocr: + os.remove(path) + continue + out = subprocess.run(["tesseract", path, "stdout", "--psm", "6", "-l", "eng"], capture_output=True, text=True).stdout + if "GRUB" in out and "SimulationOS" in out.replace(" ", ""): + grub_seen = True + os.replace(path, os.path.join(outdir, "installed-grub-menu.png")) + else: + os.remove(path) + + typed = greeter_login(g, T, "boot1") + res.set("Installed SDDM", typed, "SDDM greeter displayed (no autologin)" if typed else "no greeter appeared") + if not typed: + raise Fail("the installed system did not reach the SDDM greeter") + terminal, asked, serial_ok = desktop_shell(g, T, "boot1") + res.set("Installed user login", terminal, f"{TEST_USER} logged in through SDDM; terminal prompt visible" + if terminal else "no user session after entering the credentials") + if not serial_ok: + res.set("Installed Hyprland", terminal, "terminal opened in Hyprland, but no shell could be obtained for further checks") + raise Fail("could not obtain a shell in the installed system") + installed_checks(g, res, T, grub_seen, asked) + + # ========================================================= second boot + log("Rebooting the installed system") + rc, boot1 = g.sh("cat /proc/sys/kernel/random/boot_id") + since = g.serial.mark() + g.serial.send("sudo -n systemctl reboot") + # Stopping the session takes a while, and until it has, the old desktop + # is still on screen. The firmware announces the new boot on the serial + # console; only then is a greeter the new system's greeter. + t_reboot = time.time() + if not g.serial.wait_for(r"B+d+s+D+x+e+", T.shutdown * 3, since=since): + log(" no firmware message after the reboot request; continuing") + log(f" shutdown for the reboot took {time.time() - t_reboot:.0f}s") + time.sleep(5) + typed = greeter_login(g, T, "boot2") + terminal, asked, serial_ok = (False, False, False) + if typed: + terminal, asked, serial_ok = desktop_shell(g, T, "boot2") + ok2, ev = False, "the system did not come back to a usable session after reboot" + if serial_ok: + rc, out = g.sh("cat /proc/sys/kernel/random/boot_id; systemctl is-active graphical.target sddm.service NetworkManager.service; " + "pgrep -x Hyprland >/dev/null && echo HYPR; test -e /run/archiso && echo ARCHISO") + ls = lines(out) + ok2 = bool(ls) and ls[0] != first_line(boot1) and ls[1:4] == ["active"] * 3 and "HYPR" in ls and "ARCHISO" not in ls + ev = "new boot id; graphical.target, SDDM, NetworkManager active; Hyprland session running" if ok2 else " | ".join(ls) + # What, if anything, held up the shutdown of the previous boot. + rc, out = g.sh("journalctl -b -1 --no-pager -o short-monotonic 2>&1 | grep -iE 'timed out|stop job|SIGKILL|Killing process|" + "Failed with result|Reached target.*(Shutdown|Reboot)|System is rebooting' | cut -c1-170 | tail -n 14") + for ln in lines(out): + log(" previous shutdown: " + ln) + res.set("second reboot", ok2, ev) + + # ============================================================ power off + if serial_ok: + g.sh(f"printf '%s\\n' '{TEST_PASS}' | sudo -S -p '' -v") + g.serial.send("sudo -n systemctl poweroff") + t_off = time.time() + off = g.vm.wait_exit(T.shutdown * 3) + res.set("shutdown", off, f"systemctl poweroff completed; the VM exited by itself after {time.time() - t_off:.0f}s" + if off else f"the VM was still running {T.shutdown * 3}s after 'systemctl poweroff'") + finally: + try: + sc.shot("installed-final") + except Exception: + pass + g.close() + + +def installed_checks(g, res, T, grub_seen, sudo_asked): + """Assertions on the running installed system, as the installed user.""" + sh = g.sh + + # sudo must require authentication BEFORE we authenticate. + rc, out = sh("sudo -k; sudo -n true") + refused = rc != 0 + rc, out = sh(f"printf '%s\\n' '{TEST_PASS}' | sudo -S -p '' -v && sudo -n id -u") + res.set("sudo requires password", refused and first_line(out) == "0", + f"'sudo -n true' refused without a password; works after entering it" + + ("; password prompt seen in the terminal" if sudo_asked else "")) + + rc, out = sh("uname -r; cat /proc/cmdline; efibootmgr 2>/dev/null | grep -E '^BootCurrent|SimulationOS' | head -2") + o = out or "" + res.set("Installed linux-cachyos", "cachyos" in first_line(o) and "vmlinuz-linux-cachyos" in o, f"kernel {first_line(o)}") + res.set("Installed GRUB boot", "BOOT_IMAGE=/boot/vmlinuz-linux-cachyos" in o and "root=UUID=" in o, + ("GRUB menu seen on screen; " if grub_seen else "") + "kernel command line set by GRUB: " + + (lines(o)[1] if len(lines(o)) > 1 else "")) + rc, out = sh("findmnt -rno SOURCE,FSTYPE /; findmnt -rno SOURCE,FSTYPE /boot/efi; test -e /run/archiso && echo ARCHISO; " + "sudo -n lsinitcpio /boot/initramfs-linux-cachyos.img | grep -c archiso; " + "grep -rs archiso /etc/mkinitcpio.conf /etc/mkinitcpio.conf.d /etc/mkinitcpio.d | head -1") + ls = lines(out) + res.set("Installed initramfs", len(ls) >= 3 and "/dev/vda2 ext4" in ls[0] and "vfat" in ls[1] and "ARCHISO" not in ls and "0" in ls[2:3], + f"root on {ls[0] if ls else '?'}, ESP {ls[1] if len(ls) > 1 else '?'}; no archiso in image or config; /run/archiso absent") + rc, out = sh("systemctl is-system-running; systemctl get-default; systemctl --failed --no-legend --plain | awk '{print $1}'; " + "echo ---; systemctl --user --failed --no-legend --plain | awk '{print $1}'") + sysp, _, userp = (out or "").partition("---") + sl = lines(sysp) + failed_units = sl[2:] + lines(userp) + res.set("Installed systemd", len(sl) >= 2 and sl[0] in ("running", "degraded", "starting") and sl[1] == "graphical.target", + f"state {sl[0] if sl else '?'}, default target {sl[1] if len(sl) > 1 else '?'}") + why = "" + if failed_units: + rc, out = sh("for u in " + " ".join(failed_units[:3]) + "; do systemctl show -p Result,ExecMainStatus --value $u | tr '\\n' ' '; " + "{ journalctl -b -u $u --no-pager -o cat; journalctl -b --user -u $u --no-pager -o cat; } 2>/dev/null | tail -4; done") + why = " :: " + " | ".join(lines(out))[:600] + res.set("System health", not failed_units and sl[:1] == ["running"], + "no failed system or user units" if not failed_units else "failed units: " + " ".join(failed_units) + why) + rc, out = sh("systemctl is-active sddm.service; systemctl is-enabled sddm.service; " + "grep -rhsE '^User=.+' /etc/sddm.conf /etc/sddm.conf.d | head -1") + ls = lines(out) + res.set("Installed SDDM", ls[:2] == ["active", "enabled"] and len(ls) == 2, + "sddm active and enabled; greeter shown; no autologin user configured") + rc, out = sh(f"id {TEST_USER}; loginctl list-sessions --no-legend | grep -E '{TEST_USER}.*seat0' | head -1; " + f"stat -c '%U %a' /home/{TEST_USER}; uname -n") + o = out or "" + res.set("Installed user login", "wheel" in o and "seat0" in o and f"{TEST_USER} 700" in o and TEST_HOST in o, + " | ".join(lines(o))[:300]) + + hc = "export XDG_RUNTIME_DIR=/run/user/$(id -u); export HYPRLAND_INSTANCE_SIGNATURE=$(command ls -t $XDG_RUNTIME_DIR/hypr | head -1); " + rc, out = sh(hc + "pgrep -x Hyprland >/dev/null && echo RUNNING; hyprctl configerrors | grep -c .; " + "hyprctl layers | grep -oE 'namespace: (waybar|wallpaper)' | sort -u | tr '\\n' ' '; echo; " + "pgrep -x -u $(id -u) 'waybar|swaybg|mako|nm-applet|hyprpolkitagent' -l | awk '{print $2}' | sort -u | tr '\\n' ' '; echo; " + "hyprctl getoption input:kb_layout | head -1; grep -rl -E 'liveuser|/mnt/m\\.2' ~/.config 2>/dev/null | head -1") + ls = lines(out) + o = out or "" + res.set("Installed Hyprland", "RUNNING" in ls[:1] and ls[1:2] == ["0"] and "namespace: waybar" in o and "namespace: wallpaper" in o + and all(p in o for p in ("mako", "swaybg", "nm-applet", "hyprpolkitagen")) and "liveuser" not in ls[-1], + f"Hyprland running for {TEST_USER}, 0 config errors, bar + wallpaper drawn; components: {ls[3] if len(ls) > 3 else '?'}") + + rc, out = sh("systemctl is-enabled NetworkManager.service; systemctl is-active NetworkManager.service; nmcli -t -f STATE general; " + "nmcli -t -f DEVICE,STATE device | head -2 | tr '\\n' ' '; echo; getent hosts archlinux.org | head -1") + ls = lines(out) + res.set("Network", ls[:2] == ["enabled", "active"] and "connected" in (ls[2] if len(ls) > 2 else "") and "archlinux.org" in (out or ""), + " | ".join(ls)) + rc, out = sh("systemctl --user is-active pipewire.service wireplumber.service pipewire-pulse.service | tr '\\n' ' '; echo; " + "wpctl status | grep -E 'PipeWire|Sinks|Dummy|Built-in|Speaker|Audio' | head -4 | tr '\\n' ' '") + ls = lines(out) + res.set("Audio", ls[:1] == ["active active active"] and "PipeWire" in (out or ""), + "pipewire, wireplumber, pipewire-pulse active; " + (ls[1][:160] if len(ls) > 1 else "")) + + rc, out = sh("id liveuser 2>&1 | head -1; test -e /home/liveuser && echo HOME_LEFT; grep -c liveuser /etc/passwd /etc/group /etc/shadow 2>/dev/null") + res.set("liveuser absent", "no such user" in (out or "") and "HOME_LEFT" not in (out or ""), first_line(out)) + rc, out = sh("grep -rhsE 'liveuser' /etc/sddm.conf /etc/sddm.conf.d /etc/systemd/system/getty@tty1.service.d 2>/dev/null | head -2; " + "test -e /etc/sddm.conf.d/20-simulationos-live-autologin.conf && echo LEFT") + res.set("live autologin absent", not lines(out), "no liveuser autologin in SDDM or getty configuration") + rc, out = sh("sudo -n sh -c \"grep -rhsE '^[^#]*NOPASSWD' /etc/sudoers /etc/sudoers.d; ls /etc/polkit-1/rules.d\"") + res.set("NOPASSWD absent", rc == 0 and "NOPASSWD" not in (out or "") and "49-simulationos-live" not in (out or ""), + "no NOPASSWD sudo rule and no live polkit rule") + rc, out = sh("sudo -n passwd -S root") + res.set("root locked", len((out or "").split()) > 1 and (out or "").split()[1] in ("L", "LK"), first_line(out)) + + rc, out = sh("grep -E '^SigLevel' /etc/pacman.conf | head -1; sudo -n pacman -Sy 2>&1 | tail -2 | tr '\\n' ' '; echo; " + "pacman -Si linux-cachyos 2>/dev/null | grep -E '^(Repository|Version)' | tr -s ' ' | tr '\\n' ' '; echo; " + "pacman -Q cachyos-calamares 2>&1 | head -1; pacman -Qk 2>/dev/null | grep -v ' 0 missing' | head -5 | tr '\\n' ';'", T.cmd * 4) + ls = lines(out) + o = out or "" + res.set("pacman", "Required" in ls[0] and "Repository : cachyos" in o and "was not found" in o, + f"{ls[0]}; database sync ok; linux-cachyos resolvable from [cachyos]; installer package removed; " + + ("missing files: " + ls[-1][:160] if "missing" in ls[-1] else "pacman -Qk clean")) + + # Installer choices must have persisted on the running system. + rc, out = sh("timedatectl show -p Timezone --value; cat /etc/locale.conf | head -1; localectl status | grep -E 'Keymap|X11 Layout' | tr -s ' ' | tr '\\n' ' '") + ls = lines(out) + if len(ls) >= 2 and (ls[0] in ("UTC", "") or "LANG=" not in ls[1]): + res.set("Timezone", False, f"running system reports timezone '{ls[0]}'") + log(" installed settings: " + " | ".join(ls)) + + +# ----------------------------------------------------------------------- main +def main(): + ap = argparse.ArgumentParser(description="SimulationOS installation acceptance test") + ap.add_argument("iso", nargs="?", help="ISO to test (default: newest out/simulationos-*.iso)") + ap.add_argument("--phase", choices=("all", "install", "boot"), default="all") + ap.add_argument("--out", default=os.path.join(os.environ.get("SIMOS_OUT_DIR", os.path.join(REPO, "out")), "install-test")) + args = ap.parse_args() + + for tool in ("qemu-system-x86_64", "qemu-img", "tesseract"): + if not shutil.which(tool): + sys.exit(f"install-test: required tool not found: {tool}") + if args.phase != "boot": + if not args.iso: + isos = sorted(glob.glob(os.path.join(os.path.dirname(args.out), "simulationos-*.iso")), key=os.path.getmtime) + args.iso = isos[-1] if isos else None + if not args.iso or not os.path.isfile(args.iso): + sys.exit("install-test: no ISO given and none found in out/") + + outdir = args.out + os.makedirs(outdir, exist_ok=True) + res = Results(outdir) + if args.phase == "boot" and os.path.exists(os.path.join(outdir, "results.json")): + with open(os.path.join(outdir, "results.json")) as fh: + res.data.update(json.load(fh).get("checks", {})) + else: + for old in glob.glob(os.path.join(outdir, "*.png")) + glob.glob(os.path.join(outdir, "*.log")): + os.remove(old) + + T = Timeouts() + log(f"Acceleration: {'KVM' if kvm_usable() else 'TCG emulation (slow; timeouts scaled)'}; results in {outdir}") + stopped = "" + try: + if args.phase in ("all", "install"): + phase_install(args, res, outdir, T) + if args.phase in ("all", "boot"): + phase_boot(args, res, outdir, T) + except Fail as exc: + stopped = str(exc) + log(f"\033[31mSTOPPED: {stopped}\033[0m") + except Exception as exc: # harness error: report, never hide + stopped = f"harness error: {exc!r}" + log(f"\033[31m{stopped}\033[0m") + import traceback + traceback.print_exc() + res.save() + + scope = MATRIX + if args.phase == "install": + scope = MATRIX[:MATRIX.index("ISO detached")] + elif args.phase == "boot": + scope = MATRIX[MATRIX.index("ISO detached"):] + print("\n== installation acceptance matrix") + for key in MATRIX: + val = res.data[key] + colour = {"VERIFIED": "\033[32m", "FAILED": "\033[31m"}.get(val["status"], "\033[33m") + print(f" {colour}{val['status']:<10}\033[0m {key:<28} {val['evidence'][:150]}") + bad = res.failed(scope) + print() + if bad or stopped: + if stopped: + print(f"stopped at: {stopped}") + print("not verified: " + ", ".join(bad)) + print("\033[31mSIMULATIONOS INSTALLATION ACCEPTANCE FAILED\033[0m") + sys.exit(1) + print("\033[32mSIMULATIONOS INSTALLATION ACCEPTANCE PASSED\033[0m" + + ("" if args.phase == "all" else f" (phase: {args.phase})")) + + +if __name__ == "__main__": + main() diff --git a/scripts/live-health.sh b/scripts/live-health.sh index d9feb77..8f50b37 100755 --- a/scripts/live-health.sh +++ b/scripts/live-health.sh @@ -49,11 +49,18 @@ done [ -n "$OVMF_CODE" ] && [ -n "$OVMF_VARS_SRC" ] || { red "OVMF firmware not found"; exit 1; } VARS="$OUT/OVMF_VARS.live-health.fd"; cp -f "$OVMF_VARS_SRC" "$VARS" +# "kvm:tcg" -> "-accel kvm -accel tcg": use KVM when the host offers it. +ACCEL_ARGS="" +for a in $(printf '%s' "$ACCEL" | tr ':' ' '); do ACCEL_ARGS="$ACCEL_ARGS -accel $a"; done + +# Graphics: virtio-gpu, not "std". Hyprland needs a DRM device it can create a +# renderer on; on plain VGA (bochs) every process starts but nothing is drawn, +# which a process check cannot see. The "renders" assertion below guards that. info "Booting $(basename "$ISO") for live health checks (timeout ${TIMEOUT}s)" -# shellcheck disable=SC2054 # commas are part of QEMU option values +# shellcheck disable=SC2054,SC2086 # commas are part of QEMU option values; ACCEL_ARGS is a word list qemu-system-x86_64 \ - -machine "q35,accel=$ACCEL" -cpu max -smp 2 -m "$RAM" \ - -display none -vga std \ + -machine q35 $ACCEL_ARGS -cpu max -smp 2 -m "$RAM" \ + -display none -vga "${SIMOS_QEMU_VGA:-virtio}" \ -drive "if=pflash,format=raw,unit=0,readonly=on,file=$OVMF_CODE" \ -drive "if=pflash,format=raw,unit=1,file=$VARS" \ -serial "pipe:$PIPE" \ @@ -136,8 +143,16 @@ run() { fi } +# hyprctl needs the instance signature of the liveuser session. +# The signature is looked up on EVERY call: this is first used while waiting +# for Hyprland, before its instance directory exists. +HC='hc() { sig=$(ls -t /run/user/1000/hypr 2>/dev/null | head -1); runuser -u liveuser -- env XDG_RUNTIME_DIR=/run/user/1000 HYPRLAND_INSTANCE_SIGNATURE=$sig hyprctl "$@"; }' + +# "Ready" means the desktop is DRAWN: the bar and the wallpaper have mapped +# their surfaces. A process existing is not enough - under emulation Waybar +# can take a long time between starting and showing its bar. info "Waiting for the graphical session to settle (inside the guest)" -run wait-desktop 'for i in $(seq 1 120); do if systemctl is-active --quiet graphical.target && pgrep -f waybar >/dev/null 2>&1 && pgrep -x Hyprland >/dev/null 2>&1; then echo DESKTOP_READY; break; fi; sleep 5; done; systemctl is-active graphical.target' 900 +run wait-desktop "$HC"'; for i in $(seq 1 120); do if systemctl is-active --quiet graphical.target && pgrep -x Hyprland >/dev/null 2>&1 && hc layers 2>/dev/null | grep -q "namespace: waybar" && hc layers 2>/dev/null | grep -q "namespace: wallpaper"; then echo DESKTOP_READY; break; fi; sleep 5; done; systemctl is-active graphical.target' 900 info "Collecting live-system state" run default-target 'systemctl get-default' @@ -147,11 +162,19 @@ run displaymanager 'systemctl is-active display-manager.service; systemctl is- run sessions 'loginctl list-sessions --no-legend; loginctl list-users --no-legend' run liveuser 'id liveuser' run hyprland 'pgrep -a Hyprland || echo NO_HYPRLAND' -run session-procs 'pgrep -a -f "waybar|hyprpaper|mako|hyprpolkitagent|nm-applet" || echo NO_SESSION_PROCS' +run session-procs 'pgrep -a -f "waybar|swaybg|mako|hyprpolkitagent|nm-applet" || echo NO_SESSION_PROCS' run portals 'runuser -u liveuser -- env XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus busctl --user introspect org.freedesktop.portal.Desktop /org/freedesktop/portal/desktop >/dev/null 2>&1 && echo PORTAL_ACTIVATES || echo PORTAL_NO_ACTIVATE; pgrep -a -f xdg-desktop-portal || true' run networkmanager 'systemctl is-active NetworkManager; nmcli -t general status; nmcli -t device status' run dns 'getent hosts archlinux.org || echo DNS_FAIL' run pipewire 'runuser -u liveuser -- env XDG_RUNTIME_DIR=/run/user/1000 wpctl status 2>&1 | head -20 || echo WPCTL_FAIL' +run hypr-config "$HC"'; hc configerrors | grep -v "^[[:space:]]*$" | head -20; hc configerrors | grep -qi "error" && echo HYPR_CONFIG_ERRORS || echo HYPR_CONFIG_CLEAN' +run hypr-render "$HC"'; if grep -qE "no renderer for gl formats|Failed to initialize renderer state" /run/user/1000/hypr/*/hyprland.log; then echo HYPR_NO_RENDERER; else echo HYPR_RENDERS; fi; ls /dev/dri' +run hypr-layers "$HC"'; hc layers | grep -oE "namespace: (waybar|wallpaper)" | sort -u' +# The installer must be able to START, not merely exist: every Calamares +# binary and module has to resolve its shared libraries. +run calamares-libs 'export LD_LIBRARY_PATH=/usr/lib/simulationos/calamares-compat; miss=$(for f in /usr/bin/calamares /usr/lib/libcalamares*.so /usr/lib/calamares/modules/*/*.so; do ldd "$f" 2>/dev/null; done | awk "/not found/{print \$1}" | sort -u | tr "\n" " "); if [ -z "$miss" ]; then echo CALAMARES_LIBS_OK; else echo "CALAMARES_LIBS_MISSING $miss"; fi; unset LD_LIBRARY_PATH' +run calamares-qml 'test -d /usr/share/simulationos/calamares/qml/. && echo CALAMARES_QML_OK || echo CALAMARES_QML_MISSING' +run install-source 'test -r /run/archiso/bootmnt/arch/x86_64/airootfs.sfs && echo SFS_PRESENT || echo SFS_MISSING' run calamares-bin 'test -x /usr/bin/calamares && echo CALAMARES_PRESENT || echo CALAMARES_MISSING' run calamares-cfg 'test -f /usr/share/simulationos/calamares/settings.conf && echo CALAMARES_CFG_PRESENT || echo CALAMARES_CFG_MISSING' run desktop-entry 'test -f /usr/share/applications/simulationos-install.desktop && echo DESKTOP_PRESENT || echo DESKTOP_MISSING' @@ -179,17 +202,27 @@ assert "graphical.target active" graphical '^active$' assert "display-manager active" displaymanager '^active$' assert "liveuser exists" liveuser 'uid=1000' assert "Hyprland running" hyprland 'Hyprland' -assert "session components running" session-procs 'waybar|hyprpaper|mako|hyprpolkitagent' +assert "Hyprland config has no errors" hypr-config '^HYPR_CONFIG_CLEAN' +assert "Hyprland has a renderer" hypr-render '^HYPR_RENDERS' +assert "bar is drawn" hypr-layers 'namespace: waybar' +assert "wallpaper is drawn" hypr-layers 'namespace: wallpaper' +assert "session components running" session-procs 'waybar' +assert "wallpaper daemon running" session-procs 'swaybg' +assert "notification daemon running" session-procs 'mako' +assert "polkit agent running" session-procs 'hyprpolkitagent' assert "xdg-desktop-portal present" portals 'PORTAL_ACTIVATES|xdg-desktop-portal' assert "NetworkManager active" networkmanager '^active$' assert "DNS resolves" dns 'archlinux\.org' assert "PipeWire running" pipewire 'PipeWire|Audio' assert "Calamares installed" calamares-bin 'CALAMARES_PRESENT' +assert "Calamares libraries resolve" calamares-libs '^CALAMARES_LIBS_OK' +assert "Calamares qml directory" calamares-qml 'CALAMARES_QML_OK' +assert "install source readable" install-source 'SFS_PRESENT' assert "installer config present" calamares-cfg 'CALAMARES_CFG_PRESENT' assert "installer launcher present" desktop-entry 'DESKTOP_PRESENT' printf '\n== diagnostics\n' -for s in failed-units sessions sddm-journal hypr-journal networkmanager pipewire; do report "$s"; done +for s in failed-units sessions sddm-journal hypr-journal hypr-config hypr-render hypr-layers calamares-libs networkmanager pipewire; do report "$s"; done printf '\n' if [ "$ERRORS" -gt 0 ]; then diff --git a/scripts/make-branding.py b/scripts/make-branding.py new file mode 100644 index 0000000..2e56081 --- /dev/null +++ b/scripts/make-branding.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +"""Generate the SimulationOS ("SimOS") branding assets. + +Everything SimOS-branded is drawn "in binary": the wordmark is a dot matrix +whose lit cells are 0/1 digits, and the caption is the ASCII encoding of the +name itself: + + S i m O S + 01010011 01101001 01101101 01001111 01010011 + +Outputs (all committed; re-run this script to regenerate them): + + airootfs/usr/share/backgrounds/simulationos/simos-winter.png + airootfs/usr/share/backgrounds/simulationos/simos-fire.png + airootfs/usr/share/backgrounds/simulationos/simos-throne.png + airootfs/usr/share/pixmaps/simulationos.svg (application icon) + airootfs/usr/share/simulationos/fastfetch/logo.txt (fastfetch logo) + airootfs/usr/share/simulationos/calamares/branding/simulationos/{logo,welcome,icon}.png + syslinux/splash.png (BIOS boot menu) + +The three wallpapers are original artwork on fantasy themes in the spirit of +"Game of Thrones" - a wall of ice under a winter moon, a dragon's eye in +fire, and a throne forged from swords. They contain no logos, sigils, stills +or text from the books or the series. + +Requires Pillow (a development-time dependency only; nothing here runs during +the ISO build). Output is deterministic: fixed seeds, no timestamps. +""" +import math +import os +import random +import sys + +from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont, ImageOps + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +W, H = 2560, 1440 +CELL_W, CELL_H = 10, 16 # one binary digit per cell +COLS, ROWS = W // CELL_W, H // CELL_H +NAME = "SimOS" +NAME_BITS = " ".join(f"{ord(c):08b}" for c in NAME) + +# 5x7 dot-matrix glyphs for the wallpaper/logo wordmark. +GLYPHS_7 = { + "S": [".###.", "#...#", "#....", ".###.", "....#", "#...#", ".###."], + "i": [".#.", "...", "##.", ".#.", ".#.", ".#.", "###"], + "m": [".......", ".......", "###.##.", "#..#..#", "#..#..#", "#..#..#", "#..#..#"], + "O": [".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."], +} +# 5-row glyphs for the terminal logo, where every cell is two characters wide. +GLYPHS_5 = { + "S": [".###", "#...", ".##.", "...#", "###."], + "i": ["#", ".", "#", "#", "#"], + "m": [".....", "##.#.", "#.#.#", "#.#.#", "#.#.#"], + "O": [".##.", "#..#", "#..#", "#..#", ".##."], +} + +MONO_FONTS = [ + ("/System/Library/Fonts/Menlo.ttc", 1), + ("/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Bold.ttf", 0), + ("/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf", 0), + ("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 0), +] + + +def mono(size): + for path, index in MONO_FONTS: + if os.path.exists(path): + return ImageFont.truetype(path, size, index=index) + sys.exit("no monospace font found; add one to MONO_FONTS") + + +def wordmark(glyphs, gap=1): + """Return the name as a list of equal-length rows of '#'/'.'.""" + rows = len(next(iter(glyphs.values()))) + out = [""] * rows + for n, ch in enumerate(NAME): + for r in range(rows): + out[r] += ("." * gap if n else "") + glyphs[ch][r] + return out + + +# --------------------------------------------------------------------- scenes +# Each scene paints an 8-bit intensity image; the palette turns intensity into +# colour. Shapes are drawn large and soft because the final picture is sampled +# once per digit cell. + +def vgradient(top, bottom): + g = Image.linear_gradient("L").resize((W, H)) + return g.point(lambda v: int(top + (bottom - top) * v / 255)) + + +def scene_winter(rng): + img = vgradient(26, 8) + # Moon with a wide halo. + mx, my = int(W * 0.80), int(H * 0.20) + halo = Image.new("L", (W, H), 0) + hd = ImageDraw.Draw(halo) + hd.ellipse([mx - 420, my - 420, mx + 420, my + 420], fill=70) + hd.ellipse([mx - 230, my - 230, mx + 230, my + 230], fill=120) + img = ImageChops.add(img, halo.filter(ImageFilter.GaussianBlur(120))) + d = ImageDraw.Draw(img) + d.ellipse([mx - 120, my - 120, mx + 120, my + 120], fill=250) + d.ellipse([mx - 60, my - 150, mx + 150, my + 60], fill=40) # crescent bite + + # The wall of ice: a colossal cliff across the whole horizon. + top = int(H * 0.44) + base = int(H * 0.80) + crest = [] + x = 0 + y = top + while x <= W: + crest.append((x, y)) + x += rng.randint(30, 110) + y = max(top - 40, min(top + 40, y + rng.randint(-22, 22))) + crest.append((W, y)) + d.polygon(crest + [(W, base), (0, base)], fill=96) + # Vertical ice striations, brighter near the crest. + for _ in range(520): + sx = rng.randint(0, W) + sy = top + rng.randint(-20, 60) + length = rng.randint(80, base - top) + shade = rng.randint(110, 215) + d.line([(sx, sy), (sx + rng.randint(-6, 6), min(base, sy + length))], fill=shade, width=rng.randint(2, 9)) + # Snow-lit rim on the crest. + d.line(crest, fill=245, width=7) + # Darkness at the foot of the wall and a forest of pines in front of it. + foot = Image.new("L", (W, H), 0) + ImageDraw.Draw(foot).rectangle([0, base - 120, W, H], fill=150) + img = ImageChops.subtract(img, foot.filter(ImageFilter.GaussianBlur(70))) + d = ImageDraw.Draw(img) + x = -40 + while x < W + 40: + height = rng.randint(140, 330) + width = int(height * rng.uniform(0.30, 0.42)) + ground = H - rng.randint(0, 50) + tiers = 5 + for t in range(tiers): + ty = ground - height + int(height * t / tiers * 0.8) + tw = int(width * (t + 1.6) / tiers) + d.polygon([(x, ty), (x - tw, ty + height // 3), (x + tw, ty + height // 3)], fill=rng.randint(18, 44)) + x += rng.randint(45, 120) + return img.filter(ImageFilter.GaussianBlur(3)) + + +def scene_fire(rng): + img = vgradient(6, 30) + cx, cy = W // 2, int(H * 0.40) + rx, ry = 760, 330 + + # Heat rising from below. + heat = Image.new("L", (W, H), 0) + hd = ImageDraw.Draw(heat) + for _ in range(90): + fx = rng.randint(0, W) + fh = rng.randint(120, 520) + fw = rng.randint(30, 110) + hd.polygon([(fx - fw, H), (fx + fw, H), (fx + rng.randint(-60, 60), H - fh)], fill=rng.randint(90, 200)) + img = ImageChops.add(img, heat.filter(ImageFilter.GaussianBlur(38))) + + # Scaled hide around the eye: overlapping arcs fading with distance. + scales = Image.new("L", (W, H), 0) + sd = ImageDraw.Draw(scales) + for ring in range(1, 9): + count = 18 + ring * 6 + for k in range(count): + a = 2 * math.pi * k / count + ring * 0.21 + px = cx + math.cos(a) * (rx + ring * 105) + py = cy + math.sin(a) * (ry + ring * 92) + r = 62 + ring * 5 + sd.arc([px - r, py - r, px + r, py + r], math.degrees(a) - 70, math.degrees(a) + 70, + fill=max(26, 120 - ring * 12), width=9) + img = ImageChops.add(img, scales.filter(ImageFilter.GaussianBlur(3))) + + # The eye: an almond lens (intersection of two circles). + def lens(shrink=0): + a = Image.new("L", (W, H), 0) + b = Image.new("L", (W, H), 0) + big = 1250 - shrink + off = big - (ry - shrink) + ImageDraw.Draw(a).ellipse([cx - big, cy - off - big, cx + big, cy - off + big], fill=255) + ImageDraw.Draw(b).ellipse([cx - big, cy + off - big, cx + big, cy + off + big], fill=255) + return ImageChops.multiply(a, b) + + lid = lens(-34).filter(ImageFilter.GaussianBlur(6)) + img = ImageChops.subtract(img, lid.point(lambda v: v // 2)) # dark eyelid rim + eye = lens() + iris = Image.new("L", (W, H), 0) + idr = ImageDraw.Draw(iris) + for r in range(ry + 60, 0, -6): # radial glow + idr.ellipse([cx - r * 1.5, cy - r, cx + r * 1.5, cy + r], fill=int(120 + 135 * (1 - r / (ry + 60)))) + for _ in range(420): # iris fibres + a = rng.uniform(0, 2 * math.pi) + r0, r1 = rng.uniform(40, 120), rng.uniform(220, 560) + idr.line([(cx + math.cos(a) * r0 * 1.5, cy + math.sin(a) * r0), + (cx + math.cos(a) * r1 * 1.5, cy + math.sin(a) * r1)], + fill=rng.randint(150, 255), width=rng.randint(2, 6)) + iris = iris.filter(ImageFilter.GaussianBlur(2)) + img = Image.composite(iris, img, eye.filter(ImageFilter.GaussianBlur(2))) + # Vertical slit pupil. + pupil = Image.new("L", (W, H), 0) + pd = ImageDraw.Draw(pupil) + pd.polygon([(cx, cy - ry + 26), (cx + 74, cy), (cx, cy + ry - 26), (cx - 74, cy)], fill=255) + pd.ellipse([cx - 62, cy - 190, cx + 62, cy + 190], fill=255) + img = Image.composite(Image.new("L", (W, H), 0), img, pupil.filter(ImageFilter.GaussianBlur(5))) + return img.filter(ImageFilter.GaussianBlur(2)) + + +def scene_throne(rng): + img = vgradient(20, 6) + cx = W // 2 + seat_y = int(H * 0.47) + + # A shaft of light from a high window. + shaft = Image.new("L", (W, H), 0) + ImageDraw.Draw(shaft).polygon([(cx - 170, 0), (cx + 170, 0), (cx + 820, H), (cx - 820, H)], fill=120) + img = ImageChops.add(img, shaft.filter(ImageFilter.GaussianBlur(90))) + d = ImageDraw.Draw(img) + + def sword(x0, y0, angle, length, shade): + """A blade from (x0, y0) pointing along `angle` (0 = straight up).""" + ux, uy = math.sin(angle), -math.cos(angle) # along the blade + vx, vy = -uy, ux # across the blade + half = max(7, length * 0.022) + tip = (x0 + ux * length, y0 + uy * length) + neck = (x0 + ux * length * 0.90, y0 + uy * length * 0.90) + d.polygon([(x0 - vx * half, y0 - vy * half), (neck[0] - vx * half, neck[1] - vy * half), tip, + (neck[0] + vx * half, neck[1] + vy * half), (x0 + vx * half, y0 + vy * half)], fill=shade) + d.line([(x0, y0), tip], fill=min(255, shade + 60), width=3) # fuller + gx, gy = x0 + ux * length * 0.10, y0 + uy * length * 0.10 # crossguard + guard = half * 4.2 + d.line([(gx - vx * guard, gy - vy * guard), (gx + vx * guard, gy + vy * guard)], + fill=min(255, shade + 30), width=int(half * 1.3)) + d.ellipse([x0 - half * 1.5, y0 - half * 1.5, x0 + half * 1.5, y0 + half * 1.5], fill=shade) # pommel + + # The throne's back: a fan of blades, long in the middle, in three layers. + for layer, (count, spread, lmin, lmax, lo, hi) in enumerate( + [(46, 1.42, 470, 720, 78, 140), (34, 1.20, 400, 640, 128, 196), (22, 0.92, 320, 540, 176, 250)]): + for k in range(count): + t = (k + rng.uniform(-0.3, 0.3)) / (count - 1) * 2 - 1 + angle = t * spread + rng.uniform(-0.05, 0.05) + length = rng.uniform(lmin, lmax) * (1.0 - 0.42 * abs(t)) + x0 = cx + t * (300 - layer * 40) + rng.uniform(-18, 18) + y0 = seat_y + 70 - abs(t) * 60 + rng.uniform(-20, 30) + sword(x0, y0, angle, length, rng.randint(lo, hi)) + + # Seat, arm rests and the dais steps, cut out in shadow. + d.polygon([(cx - 250, seat_y - 20), (cx + 250, seat_y - 20), (cx + 320, seat_y + 170), (cx - 320, seat_y + 170)], fill=30) + d.polygon([(cx - 200, seat_y - 250), (cx + 200, seat_y - 250), (cx + 240, seat_y - 20), (cx - 240, seat_y - 20)], fill=22) + d.rectangle([cx - 360, seat_y - 90, cx - 250, seat_y + 170], fill=44) + d.rectangle([cx + 250, seat_y - 90, cx + 360, seat_y + 170], fill=44) + d.line([(cx - 200, seat_y - 250), (cx + 200, seat_y - 250)], fill=210, width=6) + d.line([(cx - 360, seat_y - 90), (cx - 250, seat_y - 90)], fill=190, width=6) + d.line([(cx + 250, seat_y - 90), (cx + 360, seat_y - 90)], fill=190, width=6) + d.line([(cx - 250, seat_y - 20), (cx + 250, seat_y - 20)], fill=150, width=5) + for step in range(4): + wstep = 430 + step * 190 + y = seat_y + 170 + step * 62 + d.rectangle([cx - wstep, y, cx + wstep, y + 62], fill=74 - step * 12) + d.line([(cx - wstep, y), (cx + wstep, y)], fill=200 - step * 26, width=5) + return img.filter(ImageFilter.GaussianBlur(2)) + + +SCENES = { + # name: (painter, shadow colour, mid colour, highlight colour, wordmark colour, wordmark row) + "winter": (scene_winter, (2, 6, 16), (30, 110, 190), (214, 244, 255), (235, 250, 255), 0.215), + "fire": (scene_fire, (8, 1, 0), (200, 48, 6), (255, 214, 96), (255, 244, 214), 0.690), + "throne": (scene_throne, (5, 5, 7), (168, 150, 120), (255, 222, 150), (255, 236, 180), 0.700), +} + + +def render_wallpaper(name): + painter, shadow, mid, high, mark_rgb, mark_at = SCENES[name] + rng = random.Random(f"simos-{name}") + scene = painter(rng) + + # Background: the scene itself, dimmed, so shapes read between the digits. + base = ImageOps.colorize(scene.filter(ImageFilter.GaussianBlur(10)), shadow, high, mid) + base = Image.blend(Image.new("RGB", (W, H), shadow), base, 0.34) + + grid = scene.resize((COLS, ROWS), Image.BOX) + lut = ImageOps.colorize(Image.frombytes("L", (256, 1), bytes(range(256))), shadow, high, mid).load() + + # Wordmark placement: every dot-matrix pixel is a 3x3 block of digit cells. + rows7 = wordmark(GLYPHS_7, gap=2) + scale = 3 + mark_w, mark_h = len(rows7[0]) * scale, len(rows7) * scale + col0 = (COLS - mark_w) // 2 + row0 = int(ROWS * mark_at) + lit = set() + for r, line in enumerate(rows7): + for c, ch in enumerate(line): + if ch == "#": + for dy in range(scale): + for dx in range(scale): + lit.add((col0 + c * scale + dx, row0 + r * scale + dy)) + # Caption: the name in ASCII binary, centred under the wordmark. + cap_row = row0 + mark_h + 3 + cap_col = (COLS - len(NAME_BITS)) // 2 + caption = {(cap_col + i, cap_row): ch for i, ch in enumerate(NAME_BITS) if ch != " "} + # Keep the area around the wordmark calm so it stays legible. + quiet = (col0 - 5, row0 - 3, col0 + mark_w + 5, cap_row + 3) + + digits = Image.new("RGB", (W, H), (0, 0, 0)) + glow = Image.new("RGB", (W, H), (0, 0, 0)) + dd, gd = ImageDraw.Draw(digits), ImageDraw.Draw(glow) + font = mono(15) + gpx = grid.load() + for row in range(ROWS): + for col in range(COLS): + x, y = col * CELL_W + 1, row * CELL_H - 1 + if (col, row) in lit: + dd.text((x, y), rng.choice("01"), font=font, fill=mark_rgb) + gd.rectangle([x - 1, y + 1, x + CELL_W, y + CELL_H], fill=mark_rgb) + continue + if (col, row) in caption: + dd.text((x, y), caption[(col, row)], font=font, fill=mark_rgb) + gd.text((x, y), caption[(col, row)], font=font, fill=mark_rgb) + continue + v = gpx[col, row] + v = min(255, int(v * rng.uniform(0.72, 1.18)) + rng.randint(0, 10)) + if quiet[0] <= col < quiet[2] and quiet[1] <= row < quiet[3]: + v = int(v * 0.30) + if rng.random() < 0.012: # snow / embers / sparks + v = min(255, v + rng.randint(70, 170)) + if v < 12: + continue + dd.text((x, y), rng.choice("01"), font=font, fill=lut[v, 0]) + if v > 150: + gd.text((x, y), "1", font=font, fill=lut[v, 0]) + + bloom = glow.filter(ImageFilter.GaussianBlur(14)).point(lambda p: int(p * 0.75)) + out = ImageChops.add(ImageChops.add(base, bloom), digits) + # Vignette. + vig = Image.new("L", (W, H), 0) + ImageDraw.Draw(vig).ellipse([-W * 0.18, -H * 0.28, W * 1.18, H * 1.28], fill=255) + vig = vig.filter(ImageFilter.GaussianBlur(260)).point(lambda p: 90 + int(p * 165 / 255)) + out = ImageChops.multiply(out, Image.merge("RGB", (vig, vig, vig))) + return out + + +# ------------------------------------------------------------------ logo marks +def render_mark(rows, cell, bg, on, off, pad, caption=True, radius=0): + """Dot-matrix `rows` drawn with digits: lit cells bright, unlit cells dim.""" + cols, nrows = len(rows[0]), len(rows) + cw, ch = cell, int(cell * 1.6) + width = cols * cw + pad * 2 + height = nrows * ch + pad * 2 + (ch * 2 if caption else 0) + img = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + d.rounded_rectangle([0, 0, width - 1, height - 1], radius=radius, fill=bg) + font = mono(int(cell * 1.45)) + for r, line in enumerate(rows): + for c, chv in enumerate(line): + x, y = pad + c * cw + cw * 0.08, pad + r * ch - ch * 0.08 + d.text((x, y), "1" if chv == "#" else "0", font=font, fill=on if chv == "#" else off) + if caption: + small = mono(max(8, int(cols * cw / len(NAME_BITS) * 1.55))) + tw = d.textlength(NAME_BITS, font=small) + d.text(((width - tw) / 2, pad + nrows * ch + ch * 0.45), NAME_BITS, font=small, fill=on) + return img + + +def write_icon_svg(path): + """Application icon: the 'S' of SimOS as a 5x7 matrix of binary digits.""" + rows = GLYPHS_7["S"] + cw, ch, ox, oy = 36, 30, 38, 23 + parts = [ + '', + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ] + for r, line in enumerate(rows): + for c, chv in enumerate(line): + x, y = ox + c * cw + cw / 2, oy + r * ch + ch + if chv == "#": + parts.append(f' 1') + else: + parts.append(f' 0') + parts += [" ", "", ""] + with open(path, "w") as fh: + fh.write("\n".join(parts)) + + +def write_fastfetch_logo(path): + """Terminal logo: $1 = lit digits, $2 = unlit digits, $3 = caption.""" + rng = random.Random("simos-fastfetch") + rows = wordmark(GLYPHS_5, gap=1) + blank = "." * len(rows[0]) + lines = [] + for line in [blank] + rows + [blank]: + out, colour = "", None + for chv in line: + want = "$1" if chv == "#" else "$2" + if want != colour: + out += want + colour = want + out += "11" if chv == "#" else rng.choice(["00", "01", "10", "00"]) + lines.append(out) + lines.append("$3" + NAME_BITS) + with open(path, "w") as fh: + fh.write("\n".join(lines) + "\n") + + +def save(img, rel, **kw): + path = os.path.join(REPO, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + img.save(path, optimize=True, **kw) + print(f"wrote {rel} ({os.path.getsize(path) // 1024} KiB, {img.size[0]}x{img.size[1]})") + + +def main(): + only = set(sys.argv[1:]) + for name in SCENES: + if only and name not in only: + continue + save(render_wallpaper(name), f"airootfs/usr/share/backgrounds/simulationos/simos-{name}.png") + if only and "marks" not in only: + return + + bg, on, off = (11, 14, 19, 255), (96, 214, 255, 255), (30, 42, 56, 255) + word = wordmark(GLYPHS_7, gap=2) + brand = "airootfs/usr/share/simulationos/calamares/branding/simulationos" + save(render_mark(word, 22, (0, 0, 0, 0), on, off, 18), f"{brand}/logo.png") + # The installer's welcome page has a light background: darker "on" digits + # and barely-there "off" digits keep the word readable there. + save(render_mark(word, 22, (0, 0, 0, 0), (14, 108, 168, 255), (206, 214, 222, 255), 18), f"{brand}/welcome.png") + save(render_mark(GLYPHS_7["S"], 30, bg, on, off, 34, caption=False, radius=44).resize((256, 256), Image.LANCZOS), + f"{brand}/icon.png") + + # BIOS boot-menu splash: syslinux wants exactly 640x480. + splash = Image.new("RGB", (640, 480), (11, 14, 19)) + mark = render_mark(word, 12, (0, 0, 0, 0), on, off, 8) + splash.paste(mark, ((640 - mark.size[0]) // 2, 40), mark) + save(splash, "syslinux/splash.png") + + for rel, writer in (("airootfs/usr/share/pixmaps/simulationos.svg", write_icon_svg), + ("airootfs/usr/share/simulationos/fastfetch/logo.txt", write_fastfetch_logo)): + path = os.path.join(REPO, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + writer(path) + print(f"wrote {rel}") + + +if __name__ == "__main__": + main() diff --git a/scripts/make-wallpaper.py b/scripts/make-wallpaper.py deleted file mode 100755 index f73bb51..0000000 --- a/scripts/make-wallpaper.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the SimulationOS wallpaper. - -Pure stdlib (zlib + struct) so it runs anywhere without Pillow. Re-run to -regenerate airootfs/usr/share/backgrounds/simulationos/simulationos-alpha.png. -""" -import math -import os -import struct -import sys -import zlib - -W, H = 1920, 1080 -BASE = (0x10, 0x12, 0x16) # near-black -ACCENT = (0x17, 0x93, 0xD1) # SimulationOS blue -GLOW = (0x33, 0xCC, 0xFF) - - -def mix(a, b, t): - t = max(0.0, min(1.0, t)) - return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3)) - - -def build_rows(): - cx, cy = W * 0.72, H * 0.28 - maxd = math.hypot(W, H) - rows = [] - for y in range(H): - row = bytearray() - for x in range(W): - # Radial falloff from an off-centre light source. - d = math.hypot(x - cx, y - cy) / maxd - t = max(0.0, 1.0 - d * 1.55) ** 2.2 - r, g, b = mix(BASE, ACCENT, t * 0.85) - - # Diagonal sheen. - sheen = (math.sin((x + y) * math.pi / 900.0) + 1.0) * 0.5 - r, g, b = mix((r, g, b), GLOW, t * sheen * 0.18) - - # Faint 60px "simulation" grid, brighter near the light source. - if x % 60 == 0 or y % 60 == 0: - r, g, b = mix((r, g, b), GLOW, 0.05 + t * 0.10) - - row += bytes((r, g, b)) - rows.append(bytes(row)) - return rows - - -def write_png(path, rows): - raw = b"".join(b"\x00" + r for r in rows) - - def chunk(tag, data): - c = struct.pack(">I", len(data)) + tag + data - return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) - - png = b"\x89PNG\r\n\x1a\n" - png += chunk(b"IHDR", struct.pack(">IIBBBBB", W, H, 8, 2, 0, 0, 0)) - png += chunk(b"IDAT", zlib.compress(raw, 9)) - png += chunk(b"IEND", b"") - with open(path, "wb") as fh: - fh.write(png) - - -if __name__ == "__main__": - out = sys.argv[1] if len(sys.argv) > 1 else os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "airootfs/usr/share/backgrounds/simulationos/simulationos-alpha.png", - ) - os.makedirs(os.path.dirname(out), exist_ok=True) - write_png(out, build_rows()) - print(f"wrote {out} ({os.path.getsize(out)} bytes, {W}x{H})") diff --git a/scripts/test-iso.sh b/scripts/test-iso.sh index 3692142..5863f36 100755 --- a/scripts/test-iso.sh +++ b/scripts/test-iso.sh @@ -70,16 +70,35 @@ if [ "$BOOT_DISK_ONLY" -eq 0 ]; then [ -f "$ISO" ] || die "ISO not found: $ISO" fi -# virtio-vga-gl + gl=on gives Hyprland a GPU to render on. Without it the -# session falls back to llvmpipe, which works but is slow. +# Graphics. Hyprland needs a virtio GPU: on plain VGA ("-vga std") it starts +# but has no renderer, and the result is a black screen. +# Linux: virtio-vga-gl + gl=on gives the guest real 3D acceleration. +# macOS: Homebrew QEMU has neither the GTK display nor virgl, so use plain +# virtio-vga with the native window; the guest renders with llvmpipe +# (correct, just slower - and the whole VM is emulated there anyway). +# Override with SIMOS_QEMU_GFX, e.g. SIMOS_QEMU_GFX="-device virtio-vga -display sdl". +if [ -n "${SIMOS_QEMU_GFX:-}" ]; then + # shellcheck disable=SC2206 # deliberate word splitting of a user-supplied option list + GFX=($SIMOS_QEMU_GFX) +elif [ "$(uname -s)" = "Darwin" ]; then + GFX=(-device virtio-vga -display cocoa) +elif qemu-system-x86_64 -device help 2>/dev/null | grep -q '"virtio-vga-gl"'; then + # shellcheck disable=SC2054 + GFX=(-device virtio-vga-gl -display gtk,gl=on) +else + GFX=(-device virtio-vga) +fi + +ACCEL=() +for a in $(printf '%s' "${SIMOS_QEMU_ACCEL:-kvm:tcg}" | tr ':' ' '); do ACCEL+=(-accel "$a"); done + # shellcheck disable=SC2054 # commas are part of QEMU option values, not separators QEMU=(qemu-system-x86_64 - -machine q35,accel=kvm:tcg + -machine q35 "${ACCEL[@]}" -cpu max -smp "$CPUS" -m "$RAM" - -device virtio-vga-gl - -display gtk,gl=on + "${GFX[@]}" -audiodev "${SIMOS_QEMU_AUDIODEV:-none},id=snd0" -device intel-hda -device hda-duplex,audiodev=snd0 -device virtio-net-pci,netdev=n0 -netdev user,id=n0 diff --git a/scripts/validate-profile.sh b/scripts/validate-profile.sh index 7984118..43de6df 100755 --- a/scripts/validate-profile.sh +++ b/scripts/validate-profile.sh @@ -89,6 +89,16 @@ else rm -f .validate_fperm_fail fi ok + + # mkarchiso copies airootfs WITHOUT preserving modes, so a script is only + # executable in the ISO if file_permissions says so. A helper missing here + # ships as 0644 and fails with "permission denied" at runtime. + while IFS= read -r f; do + head -1 "$f" | grep -q '^#!' || continue + grep -qF "[\"${f#airootfs}\"]=\"0:0:755\"" profiledef.sh \ + || err "profiledef.sh file_permissions has no 0:0:755 entry for ${f#airootfs}" + done < <(find airootfs/usr/local/bin airootfs/usr/share/simulationos/calamares/scripts -type f 2>/dev/null) + ok fi # --------------------------------------------------------------------------- @@ -234,33 +244,44 @@ ok # --------------------------------------------------------------------------- section "Hyprland session" # --------------------------------------------------------------------------- -HYPR=airootfs/etc/skel/.config/hypr/hyprland.conf +# Hyprland >= 0.55 is configured in Lua. A leftover hyprland.conf is parsed by +# the legacy loader and greets the user with a wall of config errors. +HYPRDIR=airootfs/etc/skel/.config/hypr +HYPR="$HYPRDIR/hyprland.lua" +[ -f "$HYPRDIR/hyprland.conf" ] \ + && err "$HYPRDIR/hyprland.conf exists: the shipped Hyprland is configured through hyprland.lua (old syntax = config errors at login)" if [ ! -f "$HYPR" ]; then err "$HYPR is missing" else - # Every sourced file must exist. - grep -E '^[[:space:]]*source[[:space:]]*=' "$HYPR" | sed 's/.*=[[:space:]]*//' | while read -r src; do - rel="$(printf '%s' "$src" | sed 's|^~/.config/|airootfs/etc/skel/.config/|')" - if [ ! -f "$rel" ]; then - red " ERROR hyprland.conf sources missing file: $src" - echo x >>"$REPO/.validate_src_fail" - fi - done - if [ -f .validate_src_fail ]; then - ERRORS=$((ERRORS + $(wc -l < .validate_src_fail))); rm -f .validate_src_fail + # Legacy hyprlang keywords have no meaning in the Lua config. + if grep -nE '^[[:space:]]*(exec-once|windowrulev2|windowrule|bind[a-z]*|source|env|monitor)[[:space:]]*=' "$HYPR" >/dev/null; then + err "$HYPR contains legacy hyprlang syntax (key = value lines)" fi + + # Every require()d module must be shipped next to hyprland.lua. + for mod in $(grep -oE 'require[,(][[:space:]]*"[^"]+"' "$HYPR" | sed 's/.*"\(.*\)"/\1/' | sort -u); do + [ -f "$HYPRDIR/$(printf '%s' "$mod" | tr . /).lua" ] \ + || err "hyprland.lua requires '$mod' but $HYPRDIR/$mod.lua is not shipped" + done ok - # Every exec-once / exec target must be a shipped package binary or a - # script we ship in airootfs/usr/local/bin. - CMDS="$(grep -E '^[[:space:]]*(exec-once|exec)[[:space:]]*=' "$HYPR" \ - | sed 's/.*=[[:space:]]*//' | awk '{print $1}' | sort -u)" - # Map binary -> providing package for the ones that differ in name. + # Every command started from the config must be a shipped package binary + # or a script in airootfs/usr/local/bin. Lua locals (terminal, launcher, + # fileManager) are resolved to their string values first. + CMDS="$( { grep -oE 'exec_cmd\("[^"]+"' "$HYPR" | sed 's/exec_cmd("//; s/"$//' + # simos-session is the ordered autostart list: "start ..." + sed -n 's/^start[[:space:]]\+//p' airootfs/usr/local/bin/simos-session 2>/dev/null + grep -E '^local[[:space:]]+[A-Za-z]+[[:space:]]*=[[:space:]]*"' "$HYPR" \ + | grep -vE '^local[[:space:]]+mainMod' | sed 's/^[^"]*"//; s/".*//' + } | awk '{print $1}' | sort -u)" for cmd in $CMDS; do case "$cmd" in - dbus-update-activation-environment|systemctl) continue ;; # from dbus/systemd in base + dbus-update-activation-environment|systemctl|hyprctl) continue ;; # dbus/systemd/hyprland esac - if [ -f "airootfs/usr/local/bin/$cmd" ]; then continue; fi + if [ -f "airootfs/usr/local/bin/$cmd" ]; then + [ -x "airootfs/usr/local/bin/$cmd" ] || err "airootfs/usr/local/bin/$cmd is not executable" + continue + fi prov="$cmd" case "$cmd" in nm-applet) prov=network-manager-applet ;; @@ -268,42 +289,61 @@ else wl-paste) prov=wl-clipboard ;; wpctl) prov=wireplumber ;; esac - has_pkg "$prov" || err "hyprland.conf runs '$cmd' but package '$prov' is not in packages.x86_64" + has_pkg "$prov" || err "hyprland.lua runs '$cmd' but package '$prov' is not in packages.x86_64" done ok - # Keybind targets that are our own scripts must exist. - for s in simos-screenshot simos-powermenu simos-clipboard; do - if grep -q "$s" "$HYPR"; then - [ -x "airootfs/usr/local/bin/$s" ] \ - || err "hyprland.conf binds '$s' but airootfs/usr/local/bin/$s is missing or not executable" - fi + if command -v luac >/dev/null 2>&1; then + luac -p "$HYPR" 2>/dev/null || err "Lua syntax error in $HYPR" + ok + fi +fi + +# hyprpaper >= 0.8 needs a hardware GPU and crashes on software-rendered +# machines; the wallpaper is drawn by swaybg (see simos-wallpaper). +has_pkg hyprpaper && err "packages.x86_64 lists hyprpaper; SimulationOS draws the wallpaper with swaybg" +has_pkg swaybg || err "packages.x86_64 is missing 'swaybg' (used by simos-wallpaper)" +[ -f "$HYPRDIR/hyprpaper.conf" ] && err "$HYPRDIR/hyprpaper.conf is dead configuration (no hyprpaper is shipped)" +if [ -f "$HYPRDIR/hyprlock.conf" ]; then + for img in $(sed -n 's/^[[:space:]]*path[[:space:]]*=[[:space:]]*\(\/[^[:space:]]*\).*/\1/p' "$HYPRDIR/hyprlock.conf" | sort -u); do + [ -f "airootfs${img}" ] || err "hyprlock.conf references missing image: $img" done - ok fi +ok -# Wallpaper referenced by hyprpaper must exist. -HP=airootfs/etc/skel/.config/hypr/hyprpaper.conf -if [ -f "$HP" ]; then - grep -E '^[[:space:]]*(preload|wallpaper)[[:space:]]*=' "$HP" \ - | sed 's/.*[=,][[:space:]]*//' | grep '^/' | sort -u | while read -r img; do - if [ ! -f "airootfs${img}" ]; then - red " ERROR hyprpaper references missing image: $img" - echo x >>"$REPO/.validate_wp_fail" - fi +# The session must be started through the ordered startup script. +grep -q 'exec_cmd("simos-session")' "$HYPR" 2>/dev/null \ + || err "hyprland.lua does not start simos-session (unordered autostart races the session environment)" +ok + +# Every wallpaper the switcher can select must be shipped. +if [ -f airootfs/usr/local/bin/simos-wallpaper ]; then + for n in $(sed -n 's/^NAMES=(\(.*\))/\1/p' airootfs/usr/local/bin/simos-wallpaper); do + [ -f "airootfs/usr/share/backgrounds/simulationos/simos-$n.png" ] \ + || err "simos-wallpaper offers '$n' but simos-$n.png is not shipped" done - if [ -f .validate_wp_fail ]; then - ERRORS=$((ERRORS + $(wc -l < .validate_wp_fail))); rm -f .validate_wp_fail - fi fi ok -# Exactly one wallpaper daemon. -WPD=0 -for d in hyprpaper swww swaybg wpaperd; do - grep -rqE "exec-once[[:space:]]*=[[:space:]]*$d" airootfs/etc/skel/.config/hypr 2>/dev/null && WPD=$((WPD+1)) -done -[ "$WPD" -gt 1 ] && err "more than one wallpaper daemon is started from the Hyprland config" +# The system-wide fastfetch config (every user, root included) and its logo. +[ -f airootfs/etc/xdg/fastfetch/config.jsonc ] || err "airootfs/etc/xdg/fastfetch/config.jsonc is missing (fastfetch would show the Arch logo)" +FF=airootfs/etc/xdg/fastfetch/config.jsonc +if [ -f "$FF" ]; then + LOGO="$(sed -n 's/.*"source":[[:space:]]*"\([^"]*\)".*/\1/p' "$FF" | head -1)" + [ -n "$LOGO" ] && [ ! -f "airootfs$LOGO" ] && err "fastfetch logo $LOGO is not shipped" +fi +ok + +# Waybar must only call helpers that exist. +WB=airootfs/etc/skel/.config/waybar/config.jsonc +if [ -f "$WB" ]; then + for h in $(grep -oE '"on-click":[[:space:]]*"[^"]+"' "$WB" | sed 's/.*"\([^"]*\)"$/\1/' | awk '{print $1}' | sort -u); do + [ -x "airootfs/usr/local/bin/$h" ] && continue + case "$h" in nm-connection-editor|pavucontrol) has_pkg "$h" && continue ;; esac + [ "$h" = "activate" ] && continue + err "waybar runs '$h' on click, which is neither a shipped helper nor a known package binary" + done +fi ok # --------------------------------------------------------------------------- @@ -430,6 +470,52 @@ else fi ok + # Calamares refuses to start with -c unless /qml exists. + { [ -e "$CALDIR/qml" ] || [ -L "$CALDIR/qml" ]; } \ + || err "$CALDIR/qml is missing: 'calamares -c' aborts without a qml directory" + grep -qE '^hide-back-and-next-during-exec:' "$CALDIR/settings.conf" \ + || err "settings.conf lacks 'hide-back-and-next-during-exec' (required by Calamares >= 3.4)" + ok + + # Order of the exec phase. Each pair is "A must run before B". + EXEC_SEQ="$(sed -n '/^- exec:/,/^- show:/p' "$CALDIR/settings.conf" \ + | grep -E '^[[:space:]]+-[[:space:]]' | sed 's/^[[:space:]]*-[[:space:]]*//')" + pos() { printf '%s\n' "$EXEC_SEQ" | grep -nx -- "$1" | head -1 | cut -d: -f1; } + for pair in \ + 'unpackfs:shellprocess@deloop' \ + 'removeuser:shellprocess@deloop' \ + 'shellprocess@deloop:initcpiocfg' \ + 'initcpiocfg:initcpio' \ + 'shellprocess@deloop:users' \ + 'removeuser:users' \ + 'fstab:initcpio' \ + 'initcpio:bootloader' \ + 'grubcfg:bootloader' \ + 'packages:bootloader' \ + 'bootloader:shellprocess@verify' \ + 'shellprocess@verify:umount'; do + first="${pair%%:*}"; second="${pair##*:}" + pa="$(pos "$first")"; pb="$(pos "$second")" + if [ -z "$pa" ]; then err "settings.conf exec sequence is missing '$first'"; continue; fi + if [ -z "$pb" ]; then err "settings.conf exec sequence is missing '$second'"; continue; fi + [ "$pa" -lt "$pb" ] || err "settings.conf: '$first' must run before '$second'" + done + for need in displaymanager services-systemd networkcfg machineid localecfg; do + [ -n "$(pos "$need")" ] || err "settings.conf exec sequence is missing '$need'" + done + ok + + # initcpio must build every preset; a literal '*' is passed to mkinitcpio -p. + grep -qE '^kernel:[[:space:]]*all[[:space:]]*$' "$CALDIR/modules/initcpio.conf" 2>/dev/null \ + || err "initcpio.conf must set 'kernel: all'" + # Golden path: GRUB, with the ESP at /boot/efi. + grep -qE '^efiBootLoader:[[:space:]]*"?grub"?' "$CALDIR/modules/bootloader.conf" 2>/dev/null \ + || err "bootloader.conf does not select GRUB" + for need in grub efibootmgr dosfstools e2fsprogs; do + has_pkg "$need" || err "packages.x86_64 is missing '$need', required to install the golden path" + done + ok + # The unpackfs source path must match install_dir/arch from profiledef.sh. IDIR="$(sed -n 's/^install_dir="\(.*\)"/\1/p' profiledef.sh | head -1)" IARCH="$(sed -n 's/^arch="\(.*\)"/\1/p' profiledef.sh | head -1)" @@ -461,7 +547,8 @@ else # The live medium must not leak into the installed system. DELOOP="$CALDIR/scripts/simulationos-deloop" if [ -f "$DELOOP" ]; then - for must in 'mkinitcpio.conf.d/archiso.conf' 'live-autologin' '10-simulationos-live' 'passwd --lock root' '/home/liveuser'; do + for must in 'mkinitcpio.conf.d/archiso.conf' 'live-autologin' '10-simulationos-live' 'passwd --lock root' '/home/liveuser' \ + '49-simulationos-live-nopasswd.rules' 'vmlinuz-$pkgbase' 'pacman-key --init' 'getty@tty1.service.d/autologin.conf'; do grep -q -- "$must" "$DELOOP" \ || err "simulationos-deloop does not handle '$must'" done diff --git a/syslinux/splash.png b/syslinux/splash.png index 64b959a..3e8a656 100644 Binary files a/syslinux/splash.png and b/syslinux/splash.png differ