From 12fee342cb14a10aeed64784ff2001e576d5e96a Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:19:02 -0600 Subject: [PATCH 1/3] Build native P105 ARMv7 Omarchy desktops for USB NFS --- .github/workflows/p105.yml | 60 ++++ BUILD.md | 35 ++ README.md | 13 + cli | 2 +- desktop/p105/README.md | 263 ++++++++++++++ desktop/p105/build-apps | 86 +++++ desktop/p105/build-keyboard | 112 ++++++ desktop/p105/build-support | 140 ++++++++ desktop/p105/firefox-defaults.json | 37 ++ desktop/p105/firefox-sources.json | 12 + desktop/p105/hw-display | 12 + desktop/p105/labwc-autostart | 10 + desktop/p105/launch-browser | 12 + desktop/p105/launch-terminal | 4 + desktop/p105/launch-webapp | 4 + desktop/p105/login-session | 22 ++ desktop/p105/packages/omacalc/PKGBUILD | 37 ++ desktop/p105/packages/omafox/PKGBUILD | 28 ++ desktop/p105/scale-output | 11 + desktop/p105/sign-apps | 80 +++++ desktop/p105/smoke-headless | 135 ++++++++ desktop/p105/stage.py | 299 ++++++++++++++++ desktop/p105/system-action | 8 + docs/p105-validation.md | 66 ++++ hardware | 2 +- release/p105/__init__.py | 1 + release/p105/audit.py | 145 ++++++++ release/p105/build.py | 55 +++ release/p105/prepare_nfs.py | 129 +++++++ release/p105/smoke_desktop.py | 69 ++++ system/build-rootfs | 4 + system/p105/Containerfile | 17 + system/p105/archlinuxarm-build.asc | 114 ++++++ system/p105/bootstrap.json | 11 + system/p105/build | 131 +++++++ system/p105/build-rootfs | 82 +++++ .../rules.d/60-p105-tablet-network.rules | 6 + .../etc/sudoers.d/90-tablet-passwordless | 1 + .../overlay/etc/sysctl.d/60-p105-zram.conf | 2 + .../etc/systemd/journald.conf.d/20-p105.conf | 4 + .../systemd/logind.conf.d/60-p105-power.conf | 5 + .../etc/systemd/sleep.conf.d/60-p105.conf | 5 + .../20-p105-platform.conf | 3 + .../etc/udev/rules.d/70-p105-backlight.rules | 1 + .../usr/lib/systemd/system/p105-zram.service | 10 + .../p105/overlay/usr/local/libexec/p105-zram | 10 + system/p105/packages.txt | 69 ++++ system/p105/prepare-root | 112 ++++++ system/p105/receipt.py | 45 +++ system/p105/with-arm.c | 32 ++ tests/test_p105_nfs.py | 66 ++++ tests/test_p105_profile.py | 326 ++++++++++++++++++ tests/test_p105_release.py | 88 +++++ uv.lock | 4 +- 54 files changed, 3033 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/p105.yml create mode 100644 desktop/p105/README.md create mode 100755 desktop/p105/build-apps create mode 100755 desktop/p105/build-keyboard create mode 100755 desktop/p105/build-support create mode 100644 desktop/p105/firefox-defaults.json create mode 100644 desktop/p105/firefox-sources.json create mode 100755 desktop/p105/hw-display create mode 100755 desktop/p105/labwc-autostart create mode 100755 desktop/p105/launch-browser create mode 100755 desktop/p105/launch-terminal create mode 100755 desktop/p105/launch-webapp create mode 100755 desktop/p105/login-session create mode 100644 desktop/p105/packages/omacalc/PKGBUILD create mode 100644 desktop/p105/packages/omafox/PKGBUILD create mode 100755 desktop/p105/scale-output create mode 100755 desktop/p105/sign-apps create mode 100755 desktop/p105/smoke-headless create mode 100644 desktop/p105/stage.py create mode 100755 desktop/p105/system-action create mode 100644 docs/p105-validation.md create mode 100644 release/p105/__init__.py create mode 100644 release/p105/audit.py create mode 100644 release/p105/build.py create mode 100644 release/p105/prepare_nfs.py create mode 100644 release/p105/smoke_desktop.py create mode 100644 system/p105/Containerfile create mode 100644 system/p105/archlinuxarm-build.asc create mode 100644 system/p105/bootstrap.json create mode 100755 system/p105/build create mode 100755 system/p105/build-rootfs create mode 100644 system/p105/overlay/etc/polkit-1/rules.d/60-p105-tablet-network.rules create mode 100644 system/p105/overlay/etc/sudoers.d/90-tablet-passwordless create mode 100644 system/p105/overlay/etc/sysctl.d/60-p105-zram.conf create mode 100644 system/p105/overlay/etc/systemd/journald.conf.d/20-p105.conf create mode 100644 system/p105/overlay/etc/systemd/logind.conf.d/60-p105-power.conf create mode 100644 system/p105/overlay/etc/systemd/sleep.conf.d/60-p105.conf create mode 100644 system/p105/overlay/etc/systemd/system/getty@tty1.service.d/20-p105-platform.conf create mode 100644 system/p105/overlay/etc/udev/rules.d/70-p105-backlight.rules create mode 100644 system/p105/overlay/usr/lib/systemd/system/p105-zram.service create mode 100755 system/p105/overlay/usr/local/libexec/p105-zram create mode 100644 system/p105/packages.txt create mode 100755 system/p105/prepare-root create mode 100644 system/p105/receipt.py create mode 100644 system/p105/with-arm.c create mode 100644 tests/test_p105_nfs.py create mode 100644 tests/test_p105_profile.py create mode 100644 tests/test_p105_release.py diff --git a/.github/workflows/p105.yml b/.github/workflows/p105.yml new file mode 100644 index 0000000..17cd5d9 --- /dev/null +++ b/.github/workflows/p105.yml @@ -0,0 +1,60 @@ +name: P105 ARMv7 desktop +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + inputs: + build_root: + description: Build and audit the signed P105 NFS desktop artifact + type: boolean + default: false +permissions: + contents: read +jobs: + p105-and-j71b-tests: + runs-on: ubuntu-24.04 + strategy: + matrix: + python: ['3.9', '3.12'] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install uv + - run: uv sync --locked --python '${{ matrix.python }}' + - run: uv run --locked python -m unittest discover -s tests -v + p105-root: + if: github.event_name == 'workflow_dispatch' && inputs.build_root + needs: p105-and-j71b-tests + runs-on: ubuntu-24.04-arm + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install uv + - run: sudo apt-get update && sudo apt-get install -y podman + - run: uv sync --locked + - name: Build a signed Arch ARMv7 package snapshot and desktop + run: uv run --locked python system/p105/build --release 'preview-${{ github.run_id }}' --output out/p105-build + - name: Assemble and audit the public NFS root + run: | + uv run --locked python -m release.p105.build --base out/p105-build/result --output out/p105-release + uv run --locked python -m release.p105.audit out/p105-release + uv run --locked python -m release.p105.smoke_desktop --release out/p105-release --builder-image "$(cat out/p105-build/work/builder-image.id)" --output out/p105-desktop-check + - uses: actions/upload-artifact@v4 + with: + name: p105-nfs-root + path: out/p105-release/* + compression-level: 0 + if-no-files-found: error + retention-days: 7 diff --git a/BUILD.md b/BUILD.md index c6307cc..682a566 100644 --- a/BUILD.md +++ b/BUILD.md @@ -101,3 +101,38 @@ Choose new evidence directories. `wlrctl` is a test-only native AArch64 helper, ## Publish Follow [Publishing Omarchy A](docs/releasing.md). Build/audit success is not physical-device qualification: test a fresh installation and a cold-restart `orchard launch` on supported hardware before announcing the release. The experimental desktop auto-logs in and grants passwordless sudo; this is disclosed in the README and must not be described as a secure daily-use system. + + +## P105 ARMv7 desktop and NFS root + +The P105 producer uses the companion CLI/hardware commits recorded by the +editable workspace. Initialize them with `./orchard --version`. The hardware +package must be 0.1.11 or later. An ARM64 Linux container host with Podman is +required for the Cortex-A9 build environment; Apple Silicon Macs can use +Podman's VM. The P105 container registers its ARM emulator in its own build +namespace. + +```sh +uv run python system/p105/build --release YOUR_NEW_VERSION --output out/p105-build +uv run python -m release.p105.build --base out/p105-build/result --output out/p105-release +uv run python -m release.p105.audit out/p105-release +``` + +`system/build-rootfs p105` dispatches to the same producer when its Python +environment contains the active hardware package. `--bootstrap` and +`--bootstrap-signature` select cached copies of the pinned official archive. +`--package-cache` accepts a directory of previously downloaded Arch packages; +each selected package is checked against the synchronized database and its +signature. Source-built components retain their source archives and licenses. + +The release contains a public ARMv7 root, a complete catalog and source +receipts. Its `rootfs.json` describes an NFS root and the P105 kernel/runtime +contract. The owner's private boot manifest remains separate. The installed +root stays on the NFS host throughout the session. + +Prepare the host directory with numeric guest ownership and explicit private +owner inputs as described in [the P105 desktop instructions](desktop/p105/README.md). +Qualification requires boots from this finished artifact through the native +Orchard CLI and physical input checks. The desktop enables automatic console +login and passwordless sudo for `tablet`, following the project's experimental +image policy. diff --git a/README.md b/README.md index 323b99a..4fa6c48 100644 --- a/README.md +++ b/README.md @@ -116,3 +116,16 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for bring-up guidance and [BUILD.md](BUIL ## Troubleshooting See [Troubleshooting](docs/troubleshooting.md) for USB connectivity, diagnostics, and failed installations. + + +## Original iPad mini Wi-Fi (P105) + +The P105 contribution adds an ARMv7 Arch Linux ARM desktop builder with +Labwc/Pixman and Qt software rendering. Its companion Orchard adapter boots +private RAM recovery and hands off to the desktop over USB/NFS. The host +serves the root filesystem throughout the Linux session. + +See [the P105 desktop instructions](desktop/p105/README.md) for the native +builder and owner preparation. This profile covers `iPad2,5` / P105AP. The +measured touch latency and calibration issue remain open; internal storage +and startup without a host belong to later device work. diff --git a/cli b/cli index 4f96e58..47e6c79 160000 --- a/cli +++ b/cli @@ -1 +1 @@ -Subproject commit 4f96e5805975d00ec4b89fe4064e8c1acbb1bcc4 +Subproject commit 47e6c79a26ed77a1e6860be448d5a7d25acf3e16 diff --git a/desktop/p105/README.md b/desktop/p105/README.md new file mode 100644 index 0000000..96a3c6b --- /dev/null +++ b/desktop/p105/README.md @@ -0,0 +1,263 @@ +# P105 desktop staging + +This directory stages Omarchy A's Labwc and Quickshell desktop for the original +iPad mini Wi-Fi, iPad2,5 / P105AP. It uses an Arch Linux ARM armv7h userspace and +the 768 x 1024 panel at scale 1. + +Boot access, kernel drivers, firmware preparation and the P105 runtime are +supplied separately. The current local bring-up uses Cascadia and an NFS root +on the host. This desktop payload contains no Apple firmware or device +calibration. + +## Build the keyboard + +Run in an armv7h Arch Linux ARM build environment. Install the build dependencies: + +~~~sh +pacman -S --needed base-devel python wayland libxkbcommon pango scdoc pkgconf patch +python desktop/p105/build-keyboard --output out/p105-keyboard +~~~ + +The recipe downloads the source archive pinned in desktop/sources.json, applies +the existing Omarchy A keyboard patch and checks the ELF32 hard-float ABI. It +retains the archive, patch, upstream license and a build receipt. + +Omafox and Omacalc have armv7h recipes under packages/. Build both as an +unprivileged user: + +~~~sh +python desktop/p105/build-apps --output out/p105-apps +~~~ + +The helper runs makepkg and Omacalc's upstream offscreen tests, retaining +source archives, package metadata and ABI checks. Sign the resulting local +packages with an explicitly trusted local build key before installing them +with pacman -U when LocalFileSigLevel requires signatures. The browser +extension staged below is the same pinned, signed XPI used by the existing +desktop. + +## Build desktop support + +After preparing the synchronized ARMv7h package snapshot, build the existing +pinned Labwc source with Omarchy A's Super-key patch and the startup cursor +helper. The same command prepares the pinned Python Bluetooth library: + +~~~sh +python desktop/p105/build-support --output out/p105-support --jobs 2 +~~~ + +Additional compiler dependencies are meson, ninja, wlroots0.20, +wayland-protocols, libsfdo, libxml2, glib2, cairo, librsvg, xcb-util-wm, +xorg-xwayland and gettext. The build retains source archives and licenses, +then records their hashes, compiler identity and complete package list. +It performs no package installation. + +## Stage and verify + +Check out Omarchy at the commit in desktop/upstream.lock before staging: + +~~~sh +python desktop/p105/stage.py \ + --output out/p105-desktop \ + --upstream out/pinned-omarchy \ + --keyboard out/p105-keyboard/wvkbd-mobintl \ + --support out/p105-support \ + --keyboard-sha256 "$(sha256sum out/p105-keyboard/wvkbd-mobintl | cut -d' ' -f1)" +python desktop/p105/stage.py --verify out/p105-desktop +~~~ + +The target must be a new directory. Staging verifies the Omarchy commit and clean asset paths, +keyboard and support binary hashes/ABIs, and the pinned browser extension. The final manifest +covers the complete payload after the P105 overrides. Verify this directory +before merging it into a populated root filesystem. + +The root builder must create the tablet user with UID 1000, set ownership of +/home/tablet, install the Arch packages and configure PAM/logind, user D-Bus +and seatd. The staged getty override starts that user's console session. +The login helper requires that session and starts Labwc with Pixman and Qt +Quick's software renderer. The staged payload supplies the source-built Labwc +binary and cursor helper, Bluetooth pairing agent, and a backlight observer. +Source-built Labwc must remain paired with its recorded wlroots ABI during +hardware qualification. + +The active menu keeps Apps, theme/background selection, browser learning +links, About, the native display/network/Bluetooth/battery panels, and the +shutdown operation. Its receipt lists every retained and omitted +source entry. PC package installation, update/configuration tools and +Hyprland controls remain outside this profile. Suspend and hibernate stay +absent while their kernel contracts lack hardware qualification. Cascadia +currently powers off when asked to reboot, so the Reboot entry is also +absent. Starting Linux again requires the Mac boot flow. + +The P105 stage adds a read-only keyboard visibility signal to the built-in +Network panel's scoped facade. This lets its password field regain focus +when the on-screen keyboard changes state. The callback returns a boolean +for that built-in entry; the shared desktop source stays unchanged. + +The P105 override leaves volume, the side switch and Hold to Cascadia's +p105-keys service. Labwc maps KEY_HOMEPAGE / XF86HomePage to the application +grid. Cascadia's stock button daemon consumes Home events, so delivery to +the compositor requires an external runtime forwarder. The opt-in +--desktop-home forwarder lives in the hardware work outside this desktop +change. The user confirmed that physical Home opens the application grid +with that runtime forwarder installed. + +The generic desktop readiness helper captures one compositor frame to +/dev/null and records its boot ID, shell PID and Wayland socket. It does not +write an image or announce an Orchard installation. + +## Firefox defaults for 512 MiB + +The P105 policy adds experimental defaults for Firefox 157. It disables +content-process prelaunch and new-tab preloading, sets the shared content +pool to two processes, and caps each isolated origin's pool at one process. +Startup and new tabs open blank, while the welcome page stays closed. The +policy keeps the packaged Omafox extension and existing unrelated settings. + +Every preference uses Status: default with an explicit type, so user values +can override it. Site isolation and sandbox settings retain their original +values. Separate sites and Firefox's helper processes still consume memory; +the content-pool preferences provide no global process or memory cap. + +Mozilla documents these controls in its +[Preferences policy](https://firefox-admin-docs.mozilla.org/reference/policies/preferences/), +[Firefox 157 process defaults](https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/modules/libpref/init/all.js) +and [first-run behavior](https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/browser/components/asrouter/docs/first-run.md). +The complete source pointers are in firefox-sources.json. Compare process +counts and memory on the iPad after restarting Firefox. + +## Check the desktop in a build container + +After merging the staged payload and installing its signed application +packages, run as an unprivileged user: + +~~~sh +python desktop/p105/smoke-headless \ + --stage out/p105-desktop \ + --output out/p105-headless-check +~~~ + +The helper gives Labwc a temporary home and D-Bus session, creates a +768 x 1024 headless output and checks the actual shell IPC, application grid +and theme carousel. Its output includes screenshots and result.json. The +first thumbnail scan can delay the carousel, so the check waits for a +changed viewport. The emulated session uses a longer IPC ceiling; physical +response times require a separate check. + +Hardware services, charging and input access must be verified on the iPad. + +## Local physical qualification + +The original iPad mini Wi-Fi, iPad2,5 / P105AP with an A5 and 512 MiB RAM, +ran this desktop as native Arch Linux ARM armv7h code on Cascadia Linux +6.12 through a USB/NFS root. A repeat boot reached graphical.target in +27.478 seconds. Both the system manager and tablet user manager reported +zero failed units after FUSE was built in, and the document portal ran. +The user confirmed touch input with 2 + 2 = 4 in Omacalc. Foot and Firefox +launched on the iPad. + +The external kernel/runtime contract used systemd namespaces and cgroups, +built-in FUSE, uinput and a 256 MiB LZ4 zram device. Kernel builds, hardware +services and the Home forwarder are supplied outside this desktop change. +The user confirmed physical Home opens the application grid. Wi-Fi scanning +and association succeeded, with the password entered through the on-iPad UI. +Bluetooth pairing awaits a device test, and audio playback remains unverified. + +One settled Firefox sample with three restored tabs showed 208 MiB RAM +available and 99 MiB logical swap in use. These values describe that saved +session and workload. Content-pool defaults retain separate site/helper +processes and provide no global memory cap. + +## Scope + +The payload reuses the actual Omarchy A shell, app grid, menus, theme scripts, +Labwc decorations and Wayland keyboard. The existing desktop port has a +limited Omarchy CLI. This staging path does not add the full desktop PC +installer or a persistent installation on the iPad's NAND. + +Runtime packages also include inotify-tools, libvips, wl-clipboard and +imagemagick for device watching, theme thumbnails and image metadata. + +This directory covers experimental desktop staging. Supported releases +require separate hardware/boot integration and further qualification. +Staging verification establishes file integrity and target ABI; the local +physical observations above cover the tested device and workload. + + +## Native Omarchy A root build + +`system/p105/build` composes a clean ARMv7 root from signed Arch Linux ARM +packages. It builds the keyboard and desktop support in the same synchronized +package environment as Quickshell and Qt, then stages this profile. The +hardware overlay comes from the active hardware package. Private firmware and +calibration arrive from the owner's RAM recovery during NFS handoff. + +```sh +uv run python system/p105/build --release YOUR_NEW_VERSION --output out/p105-build +uv run python -m release.p105.build --base out/p105-build/result --output out/p105-release +uv run python -m release.p105.audit out/p105-release +``` + +The generic `system/build-rootfs p105` entry accepts the same arguments with a +configured Python environment. `build-state.json` retains the image identity +and work volume; package and source receipts accompany the result. A manual +P105 workflow can retain the audited public artifact without publishing it. +Existing J71b producers keep their own build path. + +An owner can expand a verified release on the NFS host with administrator +access to preserve UID/GID and executable modes: + +```sh +sudo .venv/bin/python -m release.p105.prepare_nfs \ + --release out/p105-release --destination /absolute/new/nfs-root \ + --owner-inputs /private/p105/owner-inputs +``` + +Owner inputs are explicit files with hashes in `owner-inputs.json`. They can +supply the tablet's authorized SSH keys, an owner SSH host key and Wi-Fi +connection files. Keep this directory private. The prepared root has its own +private receipt and can be exported over NFSv3/TCP to `10.55.0.2`. Its path +belongs to the host's NFS configuration. + +Boot the private RAM bundle with the companion Orchard CLI and select that +export: + +```sh +./orchard --profile p105 boot /private/p105/boot/boot.toml \ + --nfs-root 10.55.0.1:/absolute/new/nfs-root +``` + +The Mac supplies the root for the entire desktop session. The native image +retains the 256 MiB LZ4 zram setup and software rendering policy. Bluetooth +pairing and audio playback await physical qualification. The slow touch and +intermittent calibration failure recorded during the recovery work remain +open hardware issues. + + +The NFS host must provide a case-sensitive filesystem. On macOS, a separate +case-sensitive APFS disk image can hold the prepared root while keeping the +working export available. Keep that image mounted for the entire session. +The preparer checks case sensitivity before extracting files. + +An owner input receipt has this structure, with each digest computed from +its corresponding private file: + +```json +{"schema": 1, "profile": "p105", "files": {"home/tablet/.ssh/authorized_keys": "SHA256_HEX"}} +``` + +The finished artifact can also run in a disposable headless Cortex-A9 test +container. The test adds its emulator and a temporary machine identity to +the validation container. The public archive retains its original bytes: + +```sh +uv run python -m release.p105.smoke_desktop \ + --release out/p105-release \ + --builder-image "$(cat out/p105-build/work/builder-image.id)" \ + --output out/p105-desktop-check +``` + +The result records the audited root hash and whether the app grid and theme +carousel responded. Screenshots accompany the result. The P105 workflow runs +this check for manually requested root builds. Physical input and radio +behavior require their own device checks. diff --git a/desktop/p105/build-apps b/desktop/p105/build-apps new file mode 100755 index 0000000..97b0813 --- /dev/null +++ b/desktop/p105/build-apps @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Build P105's pinned Omacalc and Omafox packages without package installation.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import tempfile + +import stage + +HERE = Path(__file__).resolve().parent + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + output = args.output.resolve() + if output.exists(): + parser.error("Choose a new output directory") + if os.geteuid() == 0 or platform.machine() not in {"armv7l", "armv8l"}: + parser.error("Run as an unprivileged user inside the ARMv7h build environment") + for tool in ("makepkg", "cc", "readelf", "qmake6", "bsdtar", "pacman"): + if not shutil.which(tool): + parser.error("Missing build tool: " + tool) + output.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(prefix="p105-apps-") as temporary: + work = Path(temporary) + result = work / "result" + result.mkdir() + components = {} + for name, executable in (("omacalc", "usr/bin/omacalc"), ("omafox", "usr/lib/omafox/omafox-native-host")): + recipe = HERE / "packages" / name / "PKGBUILD" + source = work / name + source.mkdir() + shutil.copy2(recipe, source / "PKGBUILD") + component = result / name + component.mkdir() + (component / "sources").mkdir() + environment = {**os.environ, "PKGDEST": str(component), + "SRCDEST": str(component / "sources"), "MAKEFLAGS": "-j2"} + subprocess.run(["makepkg", "--cleanbuild", "--noconfirm"], cwd=source, env=environment, check=True) + archives = [path for path in component.iterdir() if path.is_file() and ".pkg.tar." in path.name] + if len(archives) != 1: + raise ValueError("Unexpected package output for " + name) + package = archives[0] + binary = work / (name + "-elf") + with binary.open("wb") as stream: + subprocess.run(["bsdtar", "-xOf", str(package), executable], stdout=stream, check=True) + stage.check_armhf(binary, name) + shutil.copy2(recipe, component / "PKGBUILD") + for metadata in (".PKGINFO", ".BUILDINFO"): + (component / metadata[1:]).write_bytes(subprocess.check_output(["bsdtar", "-xOf", str(package), metadata])) + (component / "SRCINFO").write_text(subprocess.check_output(["makepkg", "--printsrcinfo"], cwd=source, env=environment, text=True)) + components[name] = {"package": package.relative_to(result).as_posix(), "package_sha256": sha(package), + "recipe_sha256": sha(recipe), "elf_sha256": sha(binary)} + (result / "packages.lock").write_text(subprocess.check_output(["pacman", "-Q"], text=True)) + record = { + "schema": 1, "architecture": "armv7h", "components": components, + "compiler": subprocess.check_output(["cc", "--version"], text=True).splitlines()[0], + "compiler_target": subprocess.check_output(["cc", "-dumpmachine"], text=True).strip(), + "files": {path.relative_to(result).as_posix(): sha(path) + for path in sorted(result.rglob("*")) if path.is_file()}, + } + (result / "manifest.json").write_text(json.dumps(record, indent=2) + "\n") + with tempfile.TemporaryDirectory(prefix=".p105-export-", dir=output.parent) as export_work: + staged = Path(export_work) / "result" + shutil.copytree(result, staged) + os.replace(staged, output) + print("Built ARMv7h application packages:", output) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() diff --git a/desktop/p105/build-keyboard b/desktop/p105/build-keyboard new file mode 100755 index 0000000..a3f27bc --- /dev/null +++ b/desktop/p105/build-keyboard @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Build the patched Omarchy A Wayland keyboard in Arch Linux ARM armv7h.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import struct +import subprocess +import sys +import tarfile +import tempfile +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + +HERE = Path(__file__).resolve().parent +DESKTOP = HERE.parent + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def fetch(entry, output): + for url in entry["urls"]: + try: + with urlopen(url, timeout=60) as response, output.open("wb") as stream: + shutil.copyfileobj(response, stream) + except (HTTPError, URLError, TimeoutError) as error: + output.unlink(missing_ok=True) + print("Source unavailable:", error, flush=True) + continue + if sha(output) != entry["sha256"]: + output.unlink() + raise ValueError("Keyboard source hash differs") + return url + raise ValueError("Pinned keyboard source is unavailable") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--jobs", type=int, default=2) + args = parser.parse_args() + output = args.output.resolve() + if output.exists(): + parser.error("Choose a new output directory") + if platform.machine() not in {"armv7l", "armv8l"}: + parser.error("Run inside an armv7h Arch Linux ARM build environment") + if args.jobs < 1: + parser.error("--jobs must be positive") + if sys.version_info < (3, 12): + parser.error("Python 3.12 or newer is required for filtered archive extraction") + for tool in ("cc", "make", "patch", "pkg-config", "readelf"): + if not shutil.which(tool): + parser.error("Missing build tool: " + tool) + pins = json.loads((DESKTOP / "sources.json").read_text())["wvkbd"] + patch = DESKTOP / "keyboard/wvkbd-0.20.patch" + output.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(prefix="p105-keyboard-") as temporary: + work = Path(temporary) + archive = work / "wvkbd-v0.20.tar.gz" + url = fetch(pins, archive) + source = work / "source" + source.mkdir() + with tarfile.open(archive, "r:gz") as tar: + tar.extractall(source, filter="data") + directories = [path for path in source.iterdir() if path.is_dir()] + if len(directories) != 1: + raise ValueError("Unexpected keyboard source archive layout") + source = directories[0] + subprocess.run(["patch", "--batch", "--fuzz=0", "-p1", "-i", str(patch)], cwd=source, check=True) + subprocess.run(["make", "-j" + str(args.jobs), "wvkbd-mobintl"], cwd=source, check=True) + binary = source / "wvkbd-mobintl" + header = binary.read_bytes()[:52] + if (len(header) != 52 or header[:6] != b"\x7fELF\x01\x01" + or struct.unpack_from("=3.12 and a positive --jobs value are required") + for tool in ("cc", "meson", "ninja", "patch", "pkg-config", "readelf", "wayland-scanner", "pacman"): + if not shutil.which(tool): + parser.error("Missing build tool: " + tool) + output.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.TemporaryDirectory(prefix="p105-support-") as temporary: + work = Path(temporary) + result = work / "result" + result.mkdir() + components = { + "labwc": compile_labwc(work, result / "labwc", args.jobs), + "cursor": compile_cursor(work, result / "cursor"), + } + subprocess.run([sys.executable, str(DESKTOP / "build-bluetooth-tools"), + "--output", str(result / "bluetooth")], check=True) + components["bluetooth"] = {"source": json.loads((DESKTOP / "bluetooth/dbus-next.json").read_text())} + (result / "packages.lock").write_text(subprocess.check_output(["pacman", "-Q"], text=True)) + record = { + "schema": 1, "architecture": "armv7h", "components": components, + "compiler": subprocess.check_output(["cc", "--version"], text=True).splitlines()[0], + "compiler_target": subprocess.check_output(["cc", "-dumpmachine"], text=True).strip(), + "files": {path.relative_to(result).as_posix(): sha(path) + for path in sorted(result.rglob("*")) if path.is_file()}, + } + (result / "manifest.json").write_text(json.dumps(record, indent=2) + "\n") + with tempfile.TemporaryDirectory(prefix=".p105-export-", dir=output.parent) as export_work: + staged = Path(export_work) / "result" + shutil.copytree(result, staged) + os.replace(staged, output) + print("Built ARMv7h desktop support:", output) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() diff --git a/desktop/p105/firefox-defaults.json b/desktop/p105/firefox-defaults.json new file mode 100644 index 0000000..44750b6 --- /dev/null +++ b/desktop/p105/firefox-defaults.json @@ -0,0 +1,37 @@ +{ + "dom.ipc.processPrelaunch.enabled": { + "Value": false, + "Status": "default", + "Type": "boolean" + }, + "dom.ipc.processCount": { + "Value": 2, + "Status": "default", + "Type": "number" + }, + "dom.ipc.processCount.webIsolated": { + "Value": 1, + "Status": "default", + "Type": "number" + }, + "browser.newtab.preload": { + "Value": false, + "Status": "default", + "Type": "boolean" + }, + "browser.aboutwelcome.enabled": { + "Value": false, + "Status": "default", + "Type": "boolean" + }, + "browser.startup.page": { + "Value": 0, + "Status": "default", + "Type": "number" + }, + "browser.newtabpage.enabled": { + "Value": false, + "Status": "default", + "Type": "boolean" + } +} diff --git a/desktop/p105/firefox-sources.json b/desktop/p105/firefox-sources.json new file mode 100644 index 0000000..aa20803 --- /dev/null +++ b/desktop/p105/firefox-sources.json @@ -0,0 +1,12 @@ +{ + "validated_firefox_version": "157.0", + "policy_documentation": "https://firefox-admin-docs.mozilla.org/reference/policies/preferences/", + "versioned_sources": { + "process_pool_defaults": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/modules/libpref/init/all.js", + "prelaunch_and_onboarding_defaults": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/browser/app/profile/firefox.js", + "prelaunch_memory_policy": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/dom/ipc/PreallocatedProcessManager.cpp", + "prelaunch_pool_size": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/modules/libpref/init/StaticPrefList.yaml", + "preferences_policy_implementation": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/browser/components/enterprisepolicies/Policies.sys.mjs", + "onboarding_behavior": "https://github.com/mozilla-firefox/firefox/blob/FIREFOX_157_0_RELEASE/browser/components/asrouter/docs/first-run.md" + } +} diff --git a/desktop/p105/hw-display b/desktop/p105/hw-display new file mode 100755 index 0000000..aa28b64 --- /dev/null +++ b/desktop/p105/hw-display @@ -0,0 +1,12 @@ +#!/bin/bash +# Name the sole kernel backlight for the shell's physical-key observer. +set -euo pipefail +base=${IPADLINUX_BACKLIGHT_DIR:-/sys/class/backlight} +selected='' +for device in "$base"/*; do + [[ -d $device && -r $device/brightness && -r $device/max_brightness ]] || continue + [[ -z $selected ]] || exit 1 + selected=${device##*/} +done +[[ -n $selected ]] || exit 1 +printf '%s\n' "$selected" diff --git a/desktop/p105/labwc-autostart b/desktop/p105/labwc-autostart new file mode 100755 index 0000000..63eb5d2 --- /dev/null +++ b/desktop/p105/labwc-autostart @@ -0,0 +1,10 @@ +#!/bin/bash +set -u +/usr/libexec/omarchy-wtype -M alt -M logo h -m logo -m alt > "$XDG_RUNTIME_DIR/omarchy-startup-cursor.log" 2>&1 || true +/usr/local/libexec/arch-ipad-scale-output > "$XDG_RUNTIME_DIR/arch-ipad-display.log" 2>&1 || true +systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE \ + > "$XDG_RUNTIME_DIR/arch-ipad-user-env.log" 2>&1 +export OMARCHY_PATH="$HOME/.local/share/omarchy-ipad" +printf '0\n' > "$XDG_RUNTIME_DIR/omarchy-keyboard-visible" +"$OMARCHY_PATH/bin/omarchy-keyboard" run > "$XDG_RUNTIME_DIR/omarchy-keyboard.log" 2>&1 & +qs -p "$OMARCHY_PATH/shell/shell.qml" > "$XDG_RUNTIME_DIR/omarchy-shell.log" 2>&1 & diff --git a/desktop/p105/launch-browser b/desktop/p105/launch-browser new file mode 100755 index 0000000..71befa5 --- /dev/null +++ b/desktop/p105/launch-browser @@ -0,0 +1,12 @@ +#!/bin/bash +# Labwc adapter for the browser installed by the P105 package profile. +set -euo pipefail +arguments=() +for argument in "$@"; do + if [[ $argument == --private ]]; then + arguments+=(--private-window) + else + arguments+=("$argument") + fi +done +exec /usr/bin/firefox "${arguments[@]}" diff --git a/desktop/p105/launch-terminal b/desktop/p105/launch-terminal new file mode 100755 index 0000000..5007395 --- /dev/null +++ b/desktop/p105/launch-terminal @@ -0,0 +1,4 @@ +#!/bin/bash +# Enter the native Wayland terminal without a compositor-specific launcher. +set -euo pipefail +exec /usr/bin/foot "$@" diff --git a/desktop/p105/launch-webapp b/desktop/p105/launch-webapp new file mode 100755 index 0000000..d62979b --- /dev/null +++ b/desktop/p105/launch-webapp @@ -0,0 +1,4 @@ +#!/bin/bash +# Open Omarchy's learning links with the installed browser. +set -euo pipefail +exec /usr/local/bin/omarchy-launch-browser "$@" diff --git a/desktop/p105/login-session b/desktop/p105/login-session new file mode 100755 index 0000000..14fa9c3 --- /dev/null +++ b/desktop/p105/login-session @@ -0,0 +1,22 @@ +#!/bin/bash +# Enter from the tablet user's PAM/logind console session. +set -euo pipefail +[[ $(tty) == /dev/tty1 && ${USER:-} == tablet && -z ${SSH_CONNECTION:-} ]] || exit 1 +[[ -n ${XDG_SESSION_ID:-} && -d ${XDG_RUNTIME_DIR:-/nonexistent} && -O $XDG_RUNTIME_DIR ]] || { + echo 'No active logind session/runtime directory' >&2 + exit 1 +} +[[ -S $XDG_RUNTIME_DIR/bus && -O $XDG_RUNTIME_DIR/bus ]] || { + echo 'No PAM/systemd user D-Bus socket' >&2 + exit 1 +} +export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" +export OMARCHY_PATH="$HOME/.local/share/omarchy-ipad" +export XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=labwc +export OMARCHY_TOUCH_MODE=1 +export WLR_RENDERER=pixman QT_QUICK_BACKEND=software QT_QPA_PLATFORM=wayland +export LIBGL_ALWAYS_SOFTWARE=1 LIBSEAT_BACKEND=seatd MOZ_ENABLE_WAYLAND=1 +export PATH=/usr/local/bin:/usr/bin +systemctl --user import-environment DBUS_SESSION_BUS_ADDRESS XDG_SESSION_TYPE XDG_CURRENT_DESKTOP +/usr/local/libexec/arch-ipad-first-theme +exec /usr/bin/labwc diff --git a/desktop/p105/packages/omacalc/PKGBUILD b/desktop/p105/packages/omacalc/PKGBUILD new file mode 100644 index 0000000..3bef396 --- /dev/null +++ b/desktop/p105/packages/omacalc/PKGBUILD @@ -0,0 +1,37 @@ +# P105 build of Omacalc's pinned upstream source. +pkgname=omacalc +pkgver=0.3.1 +pkgrel=1 +pkgdesc="Omarchy calculator" +arch=('armv7h') +url='https://github.com/omacom/omacalc' +license=('MIT' 'OFL-1.1') +depends=('qt6-base' 'qt6-declarative' 'xdg-desktop-portal') +makedepends=('base-devel') +_commit=573d7106f5c6c0225864eb8634ae37ddcb657f63 +source=("$pkgname-$pkgver.tar.gz::https://codeload.github.com/omacom/omacalc/tar.gz/$_commit") +sha256sums=('6a4ad731acb4f68226b975e1b817b2907fea1534d52d1e769564a76a48f3eafc') + +build() { + cd "$srcdir/$pkgname-$_commit" + mkdir -p build + cd build + qmake6 ../omacalc.pro + make + LC_ALL=C readelf -h omacalc | grep -q 'Machine:.*ARM' + LC_ALL=C readelf -h omacalc | grep -q 'hard-float ABI' +} + +check() { + cd "$srcdir/$pkgname-$_commit" + QT_QPA_PLATFORM=offscreen bin/test +} + +package() { + cd "$srcdir/$pkgname-$_commit" + install -Dm755 build/omacalc "$pkgdir/usr/bin/omacalc" + install -Dm644 pkgbuild/omacalc.desktop "$pkgdir/usr/share/applications/omacalc.desktop" + install -Dm644 pkgbuild/omacalc.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/omacalc.svg" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/omacalc/LICENSE" + install -Dm644 fonts/OFL.txt "$pkgdir/usr/share/licenses/omacalc/OFL.txt" +} diff --git a/desktop/p105/packages/omafox/PKGBUILD b/desktop/p105/packages/omafox/PKGBUILD new file mode 100644 index 0000000..add695b --- /dev/null +++ b/desktop/p105/packages/omafox/PKGBUILD @@ -0,0 +1,28 @@ +# Maintainer: Fabio Cantoni + +pkgname=omafox +pkgver=1.1.0 +pkgrel=1 +pkgdesc='Synchronize Firefox and Zen Browser themes with Omarchy' +arch=('armv7h') +url='https://github.com/cover/omafox' +license=('MIT') +depends=('bash' 'glibc') +optdepends=( + 'firefox: runs the separately distributed Omafox extension' + 'zen-browser-bin: runs the separately distributed Omafox extension with Zen integration' +) +source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz") +sha256sums=('b610174e19edf2940127761f2883a14e5ba8644bc42c75ead8148305c7caa4f9') + +build() { + cd "$srcdir/${pkgname}-${pkgver}" + make + LC_ALL=C readelf -h build/omafox-native-host | grep -q 'Machine:.*ARM' + LC_ALL=C readelf -h build/omafox-native-host | grep -q 'hard-float ABI' +} + +package() { + cd "$srcdir/${pkgname}-${pkgver}" + make DESTDIR="$pkgdir" PREFIX=/usr install +} diff --git a/desktop/p105/scale-output b/desktop/p105/scale-output new file mode 100755 index 0000000..cf80d23 --- /dev/null +++ b/desktop/p105/scale-output @@ -0,0 +1,11 @@ +#!/bin/bash +# Preserve the P105 panel's native timing and use one logical pixel per pixel. +set -euo pipefail +state=$(wlr-randr) +mapfile -t outputs < <(awk '/^[^[:space:]]/ {print $1}' <<< "$state") +if (( ${#outputs[@]} != 1 )) || ! grep -q ' Enabled: yes' <<< "$state" || + ! grep -Eq '^[[:space:]]+(768x1024|1024x768) px, .*current' <<< "$state"; then + echo 'P105 native single-panel mode unavailable; scale left unchanged' >&2 + exit 0 +fi +wlr-randr --output "${outputs[0]}" --scale 1 diff --git a/desktop/p105/sign-apps b/desktop/p105/sign-apps new file mode 100755 index 0000000..d90a269 --- /dev/null +++ b/desktop/p105/sign-apps @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Sign verified local ARMv7 packages with a temporary key; do not install them.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--apps", type=Path, required=True, help="output from p105/build-apps") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + apps, output = args.apps.resolve(), args.output.resolve() + if output.exists(): + parser.error("Choose a new signed-package output directory") + if not shutil.which("gpg") or not shutil.which("gpgconf"): + parser.error("GnuPG is required") + try: + record = json.loads((apps / "manifest.json").read_text()) + if (record.get("schema"), record.get("architecture")) != (1, "armv7h"): + raise ValueError("Applications need an ARMv7h build receipt") + files = {path.relative_to(apps).as_posix(): sha(path) + for path in sorted(apps.rglob("*")) + if path.is_file() and not path.is_symlink() and path != apps / "manifest.json"} + if files != record["files"]: + raise ValueError("Application files differ from their build receipt") + if set(record["components"]) != {"omacalc", "omafox"}: + raise ValueError("Expected the P105 Omacalc and Omafox package set") + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="p105-signing-") as key_directory: + key_home = Path(key_directory) + command = ["gpg", "--homedir", str(key_home), "--batch", "--pinentry-mode", "loopback", "--passphrase", ""] + try: + subprocess.run([*command, "--quick-generate-key", "Omarchy P105 local build", "ed25519", "sign", "0"], check=True) + listing = subprocess.check_output([*command, "--with-colons", "--list-secret-keys"], text=True) + fingerprint = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:")) + with tempfile.TemporaryDirectory(prefix=".p105-signed-", dir=output.parent) as export_work: + result = Path(export_work) / "result" + result.mkdir() + public_key = result / "local-build-public.asc" + public_key.write_bytes(subprocess.check_output([*command, "--armor", "--export", fingerprint])) + packages = {} + for name, component in record["components"].items(): + source = (apps / component["package"]).resolve() + if not source.is_relative_to(apps) or sha(source) != component["package_sha256"]: + raise ValueError("Package differs from its build receipt") + package = result / source.name + shutil.copy2(source, package) + signature = result / (package.name + ".sig") + subprocess.run([*command, "--local-user", fingerprint, "--detach-sign", "--output", str(signature), str(package)], check=True) + subprocess.run([*command, "--verify", str(signature), str(package)], check=True) + packages[name] = package.name + receipt = { + "schema": 1, "architecture": "armv7h", "key_fingerprint": fingerprint, + "key_scope": "Local P105 source builds; private signing key deleted after this invocation", + "apps_manifest_sha256": sha(apps / "manifest.json"), "packages": packages, + "files": {path.name: sha(path) for path in sorted(result.iterdir())}, + } + (result / "manifest.json").write_text(json.dumps(receipt, indent=2) + "\n") + os.replace(result, output) + finally: + subprocess.run(["gpgconf", "--homedir", str(key_home), "--kill", "gpg-agent"], check=False) + print("Signed local ARMv7h packages:", output) + print("Public build-key fingerprint:", fingerprint) + except (OSError, ValueError, KeyError, StopIteration, subprocess.CalledProcessError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() diff --git a/desktop/p105/smoke-headless b/desktop/p105/smoke-headless new file mode 100755 index 0000000..388f814 --- /dev/null +++ b/desktop/p105/smoke-headless @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Exercise an installed P105 desktop on a temporary headless Wayland display.""" + +import argparse +import json +import os +from pathlib import Path +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--stage", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--in-session", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + if os.geteuid() == 0: + parser.error("Run as an unprivileged user in the prepared ARMv7h container") + for command in ("labwc", "qs", "grim", "wlr-randr", "omarchy-shell", "dbus-run-session"): + if not shutil.which(command): + parser.error("Install the staged desktop and its packages first: " + command) + if not args.in_session: + return subprocess.call(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), + "--stage", str(args.stage.resolve()), "--output", str(args.output.resolve()), "--in-session"]) + output = args.output.resolve() + if output.exists(): + parser.error("Choose a new output directory") + output.mkdir(parents=True) + result = {"schema": 1, "test": "headless P105 desktop", "physical_hardware": False, "passed": False} + try: + with tempfile.TemporaryDirectory(prefix="p105-ui-smoke-") as temporary: + work = Path(temporary) + home, runtime = work / "home", work / "runtime" + shutil.copytree(args.stage.resolve() / "home/tablet", home, symlinks=True) + runtime.mkdir(mode=0o700) + environment = { + **os.environ, "HOME": str(home), "XDG_RUNTIME_DIR": str(runtime), + "XDG_CONFIG_HOME": str(home / ".config"), "XDG_CACHE_HOME": str(home / ".cache"), + "XDG_DATA_HOME": str(home / ".local/share"), "XDG_STATE_HOME": str(home / ".local/state"), + "OMARCHY_PATH": str(home / ".local/share/omarchy-ipad"), "OMARCHY_TOUCH_MODE": "1", + "XDG_SESSION_TYPE": "wayland", "XDG_CURRENT_DESKTOP": "labwc", "PATH": "/usr/local/bin:/usr/bin", + "WLR_BACKENDS": "headless", "WLR_HEADLESS_OUTPUTS": "1", "WLR_RENDERER": "pixman", + "QT_QUICK_BACKEND": "software", "QT_QPA_PLATFORM": "wayland", "LIBGL_ALWAYS_SOFTWARE": "1", + "OMARCHY_SHELL_IPC_TIMEOUT": "15s", + } + subprocess.run(["dbus-update-activation-environment", "HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CURRENT_DESKTOP"], + env=environment, check=True) + with (output / "first-theme.log").open("w") as log: + subprocess.run(["/usr/local/libexec/arch-ipad-first-theme"], env=environment, cwd=home, + stdout=log, stderr=subprocess.STDOUT, timeout=45, check=True) + with (output / "labwc.log").open("w") as log: + compositor = subprocess.Popen(["labwc", "-C", str(home / ".config/labwc"), "-d"], + env=environment, cwd=home, stdout=log, stderr=subprocess.STDOUT, + start_new_session=True) + try: + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + if compositor.poll() is not None: + raise RuntimeError("Labwc exited before its Wayland socket was ready") + sockets = [path for path in runtime.glob("wayland-*") if stat.S_ISSOCK(path.stat().st_mode)] + if sockets: + environment["WAYLAND_DISPLAY"] = sockets[0].name + break + time.sleep(0.25) + else: + raise RuntimeError("Headless Wayland socket did not appear") + subprocess.run(["dbus-update-activation-environment", "WAYLAND_DISPLAY", "QT_QPA_PLATFORM"], + env=environment, check=True) + state = subprocess.check_output(["wlr-randr"], env=environment, text=True, timeout=10) + name = next(line.split()[0] for line in state.splitlines() if line and not line[0].isspace()) + subprocess.run(["wlr-randr", "--output", name, "--custom-mode", "768x1024@60Hz"], + env=environment, timeout=10, check=True) + result["output"] = subprocess.check_output(["wlr-randr"], env=environment, text=True, timeout=10) + while time.monotonic() < deadline: + ping = subprocess.run(["omarchy-shell", "shell", "ping"], env=environment, + text=True, capture_output=True, timeout=20) + if ping.returncode == 0 and ping.stdout.strip() == "ok": + break + if compositor.poll() is not None: + raise RuntimeError("Labwc exited while loading the shell") + time.sleep(0.5) + else: + raise RuntimeError("Omarchy shell did not answer IPC: " + ping.stderr.strip()) + time.sleep(8) + subprocess.run(["grim", str(output / "desktop.png")], env=environment, timeout=10, check=True) + subprocess.run(["grim", "-g", "0,96 768x832", str(output / "desktop-center.png")], + env=environment, timeout=10, check=True) + subprocess.run(["omarchy-shell", "omarchy.dock", "apps"], env=environment, timeout=20, check=True) + time.sleep(3) + subprocess.run(["grim", str(output / "apps.png")], env=environment, timeout=10, check=True) + subprocess.run(["omarchy-shell", "omarchy.dock", "apps"], env=environment, timeout=20, check=True) + subprocess.run(["omarchy-shell", "shell", "summon", "omarchy.image-picker", '{"source":"themes"}'], + env=environment, timeout=20, check=True) + theme_deadline = time.monotonic() + 40 + while time.monotonic() < theme_deadline: + time.sleep(2) + subprocess.run(["grim", "-g", "0,96 768x832", str(output / "themes-center.png")], + env=environment, timeout=10, check=True) + if (output / "themes-center.png").read_bytes() != (output / "desktop-center.png").read_bytes(): + break + else: + raise RuntimeError("Theme picker did not change the viewport") + subprocess.run(["grim", str(output / "themes.png")], env=environment, timeout=10, check=True) + result["plugins"] = json.loads(subprocess.check_output(["omarchy-shell", "shell", "listPlugins"], + env=environment, text=True, timeout=10)) + result["shell_ping"] = "ok" + result["passed"] = True + finally: + if compositor.poll() is None: + os.killpg(compositor.pid, signal.SIGTERM) + try: + compositor.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(compositor.pid, signal.SIGKILL) + compositor.wait() + for log_path in runtime.glob("*.log"): + shutil.copy2(log_path, output / log_path.name) + except (OSError, ValueError, RuntimeError, StopIteration, subprocess.SubprocessError) as error: + result["error"] = str(error) + (output / "result.json").write_text(json.dumps(result, indent=2) + "\n") + print("Headless desktop", "passed:" if result["passed"] else "failed:", output) + if not result["passed"]: + print(result.get("error", "Unknown failure"), file=sys.stderr) + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktop/p105/stage.py b/desktop/p105/stage.py new file mode 100644 index 0000000..659a73b --- /dev/null +++ b/desktop/p105/stage.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Stage Omarchy A's P105 desktop into a new directory.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import xml.etree.ElementTree as ET + +HERE = Path(__file__).resolve().parent +DESKTOP = HERE.parent +PROJECT = DESKTOP.parent +PAYLOAD = Path("home/tablet/.local/share/omarchy-ipad") +KEYBOARD = PAYLOAD / "trials/wvkbd-mobintl-v0.20-font18-special15-state" +MANIFEST = "arch-ipad-ui-manifest.json" +RECEIPT = "p105-desktop.json" + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def check_armhf(path, label="Keyboard"): + """Check the target ABI without executing the binary on the build host.""" + header = Path(path).read_bytes()[:52] + if len(header) != 52 or header[:6] != b"\x7fELF\x01\x01": + raise ValueError(label + " must be a little-endian ELF32 ARM executable") + if struct.unpack_from("&2; exit 2; } +case "$1" in + shutdown) exec /usr/bin/systemctl poweroff --no-wall --no-ask-password ;; + *) echo 'Usage: omarchy-system shutdown' >&2; exit 2 ;; +esac diff --git a/docs/p105-validation.md b/docs/p105-validation.md new file mode 100644 index 0000000..bfcd62f --- /dev/null +++ b/docs/p105-validation.md @@ -0,0 +1,66 @@ +# P105 native desktop validation + +The phase 4 contribution builds the original iPad mini Wi-Fi desktop through +Omarchy A's own producers. Its target is Arch Linux ARM `armv7h`, with the +P105 runtime supplied by hardware 0.1.11. A host supplies the NFS root for the +entire Linux session. + +## Build and artifact checks on 27 October 2026 + +The candidate `0.1.1-p105.1` was assembled from the pinned official ARMv7 +bootstrap and a synchronized set of signed Arch packages. The producer +checked detached package signatures and retained the repository databases, +resolved package records and package inventory. Source builds ran as an +unprivileged account inside the disposable build environment. + +| Check | Result | +| --- | --- | +| Runtime architecture | ARM ELF32 hard float for the desktop and systemd | +| Desktop components | Rebuilt keyboard, Labwc 0.20.2, cursor helper, Omafox and Omacalc | +| ABI pairing | Labwc and wlroots 0.20.2 from the same package snapshot | +| Clean root | 81,542 archive members; 2.29 GiB expanded file data | +| Public artifact | Complete catalog and compatibility receipt verified | +| Owner data | Separate scan passed over 65,666 regular files using the known private inputs | +| Finished-root desktop | Headless Cortex-A9 session passed at 768 × 1024 and scale 1 | +| Desktop interaction | App grid and theme carousel responded; screenshots retained | +| Experimental privilege policy | UID 1000 `tablet`, automatic console login and passwordless sudo; package sudo ownership and mode preserved | + +The headless test runs the finished archive in its own disposable container. +Its temporary emulator and machine identity remain in that validation +container. Initial checks through a nested emulated chroot hit a QEMU crash; +direct execution of the artifact passed. The public root's archive bytes +remain unchanged by validation. + +The Omarchy A suite runs 95 tests on Python 3.9.6 and 3.14.6, with 91 passing +and four skipped on this host. The merged CLI +passes 225 tests on both versions. Minimal Linux's merged suite passes 56. +The editable workspace starts with the compatible P105 dependency pair. + +## Source and upstream integration + +The desktop profile came from the source preserved during phase 1. Original +checkout heads and Git status still match that snapshot. The contribution +starts at Omarchy A main `3d1a09d4b1`; CLI and Minimal Linux integrate their +latest shared-cache and experimental RAM workflow changes. Their local P105 +commits supply the workspace submodule pins. Publish those companion commits +before publishing parent commits that reference them. + +Existing README text is preserved. README changes add the P105 sections and +instructions. J71b keeps its producer and release path; a separate P105 +workflow checks the profiles and can build the NFS artifact on manual dispatch. + +## Physical qualification + +The fresh root awaits a device boot after the Mac prepares its private NFS +export. A separate case-sensitive APFS image is mounted for that root. Owner +Wi-Fi and SSH inputs stay private, with the prior working export retained. +The macOS administrator prompt is required to apply numeric guest ownership +and add the export. + +Repeated native CLI boots and physical keyboard/password-entry checks remain +pending. Touch latency and the intermittent calibration startup failure from +the earlier hardware qualification remain open. Bluetooth pairing and audio +playback await their own device tests. + +Build receipts and test evidence remain in the owner's local phase 4 directory. +See [the P105 build instructions](../desktop/p105/README.md). diff --git a/hardware b/hardware index d60da6b..867b87b 160000 --- a/hardware +++ b/hardware @@ -1 +1 @@ -Subproject commit d60da6b31a07f84a76fe24df27fcd398e60363ee +Subproject commit 867b87beab50b88ade85194321b7c7e0b1156b1c diff --git a/release/p105/__init__.py b/release/p105/__init__.py new file mode 100644 index 0000000..c897647 --- /dev/null +++ b/release/p105/__init__.py @@ -0,0 +1 @@ +"""P105 ARMv7 desktop artifacts for a host-served NFS root.""" diff --git a/release/p105/audit.py b/release/p105/audit.py new file mode 100644 index 0000000..6e30d86 --- /dev/null +++ b/release/p105/audit.py @@ -0,0 +1,145 @@ +"""Audit the finished public P105 root and its compatibility receipt.""" +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import posixpath +import re +import tarfile + +FORBIDDEN = { + 'lib/firmware/mtcal.bin', 'usr/lib/firmware/mtcal.bin', + 'usr/lib/firmware/P105.mtprops', 'usr/lib/firmware/apple/sgx543.fw', + 'usr/lib/firmware/brcm/brcmfmac4334.bin', 'usr/lib/firmware/brcm/BCM4334B0.hcd', + 'etc/orchard/p105.json', 'root/.ssh/authorized_keys', +} + + +def sha(path): + digest=hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda:stream.read(1024*1024),b''): + digest.update(chunk) + return digest.hexdigest() + + +def name(value): + path=PurePosixPath(value) + if path.is_absolute() or '..' in path.parts: + raise ValueError('Unsafe P105 root path: '+value) + return str(path) + + +def inventory(path): + catalog={} + captured={} + wanted={'etc/passwd','etc/shadow','etc/machine-id','etc/cascadia/systemd-root', + 'etc/cascadia/p105-hardware-input.json','etc/sudoers.d/90-tablet-passwordless', + 'home/tablet/.config/labwc/autostart','home/tablet/.config/labwc/rc.xml', + 'usr/local/libexec/arch-ipad-login-session'} + with tarfile.open(path,'r:gz') as archive: + for member in archive: + key=name(member.name) + if key in catalog: + raise ValueError('Duplicate P105 root member: '+key) + if member.islnk() or not (member.isfile() or member.isdir() or member.issym()): + raise ValueError('Unsupported P105 root member: '+key) + if (key in FORBIDDEN or key.startswith(('root/.ssh/','home/tablet/.ssh/', + 'etc/NetworkManager/system-connections/')) or + key.startswith('etc/ssh/ssh_host_')): + if not member.isdir(): + raise ValueError('Private input in public P105 root: '+key) + item={'mode':member.mode,'uid':member.uid,'gid':member.gid} + if member.isdir(): + item['type']='directory' + elif member.issym(): + target=member.linkname + resolved=posixpath.normpath(target.lstrip('/') if target.startswith('/') else + posixpath.join(posixpath.dirname(key),target)) + if resolved=='..' or resolved.startswith('../'): + raise ValueError('Root symlink escapes the guest: '+key) + item.update(type='symlink',target=target) + else: + digest=hashlib.sha256() + head=b'' + tail=b'' + saved=bytearray() + with archive.extractfile(member) as stream: + for block in iter(lambda:stream.read(1024*1024),b''): + digest.update(block) + if not head: + head=block[:52] + window=tail+block + # Signed programs such as gnutls-cli embed public test + # keys. Credential files are checked as text here; + # owner-specific bytes receive a separate build audit. + if not head.startswith(b'\x7fELF') and re.search(rb'-----BEGIN (?:OPENSSH|RSA|EC|DSA|PGP) PRIVATE KEY(?: BLOCK)?-----[\r\n]+[A-Za-z0-9+/=\r\n]{64,}',window): + raise ValueError('Private key in public P105 root: '+key) + tail=window[-128:] + if key in wanted: + if len(saved)+len(block)>1024*1024: + raise ValueError('Oversized P105 policy file: '+key) + saved.extend(block) + if head.startswith(b'\x7fELF'): + arm32=head[:7]==b'\x7fELF\x01\x01\x01' and head[18:20]==b'\x28\x00' + # v4l-utils supplies architecture-independent eBPF objects + # for its remote-control protocols. + bpf=head[:7]==b'\x7fELF\x02\x01\x01' and head[16:20]==b'\x01\x00\xf7\x00' + if not (arm32 or bpf): + raise ValueError('Non-ARM32 ELF in P105 root: '+key) + item.update(type='file',bytes=member.size,sha256=digest.hexdigest()) + if key in wanted: + captured[key]=bytes(saved) + catalog[key]=item + return catalog,captured + + +def audit(directory): + directory=Path(directory) + root=json.loads((directory/'rootfs.json').read_text()) + if (root.get('kind'),root.get('profile'),root.get('architecture'))!=('omarchy-a.nfs-root','p105','armv7h'): + raise ValueError('Expected a P105 ARMv7 NFS root receipt') + asset=root['root'] + if asset['file']!='rootfs.tar.gz' or asset['compression']!='gzip': + raise ValueError('Unexpected P105 root stream') + path=directory/asset['file'] + if path.stat().st_size!=asset['bytes'] or sha(path)!=asset['sha256']: + raise ValueError('P105 root differs from its receipt') + catalog,files=inventory(path) + expected=json.loads((directory/'rootfs.catalog.json').read_text()) + if catalog!=expected or sha(directory/'rootfs.catalog.json')!=root['catalog_sha256']: + raise ValueError('P105 root inventory differs from its receipt') + if len(catalog)!=asset['members'] or sum(v.get('bytes',0) for v in catalog.values())!=asset['expanded_bytes']: + raise ValueError('P105 root dimensions differ') + if files.get('etc/cascadia/systemd-root')!=b'p105-systemd-v1\n' or files.get('etc/machine-id')!=b'': + raise ValueError('P105 root has incompatible hardware policy or machine identity') + if not any(row.startswith(b'tablet:x:1000:1000:') for row in files['etc/passwd'].splitlines()): + raise ValueError('P105 root needs the tablet account at UID 1000') + sudo=catalog.get('usr/bin/sudo',{}) + if sudo.get('uid')!=0 or sudo.get('mode',0)&0o4777!=0o4755: + raise ValueError('P105 root must retain the package sudo owner and mode') + if catalog.get('etc/sudoers.d/90-tablet-passwordless',{}).get('mode')!=0o440: + raise ValueError('P105 sudo policy mode differs') + if any(row.split(b':')[1].strip(b'!*') for row in files['etc/shadow'].splitlines() if row): + raise ValueError('Password material in public P105 root') + if b'WLR_RENDERER=pixman QT_QUICK_BACKEND=software' not in files['usr/local/libexec/arch-ipad-login-session']: + raise ValueError('P105 software rendering policy differs') + hardware=json.loads(files['etc/cascadia/p105-hardware-input.json']) + for key,digest in hardware['files'].items(): + if catalog.get(key,{}).get('sha256')!=digest: + raise ValueError('P105 hardware runtime differs: '+key) + if root['compatibility']['runtime']!='p105-systemd-v1' or root['compatibility']['flash_controller']!='disabled': + raise ValueError('P105 compatibility fence differs') + print('Verified public P105 ARMv7 NFS root:',directory) + return root + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('directory',type=Path) + args=parser.parse_args() + audit(args.directory) + + +if __name__=='__main__': + main() diff --git a/release/p105/build.py b/release/p105/build.py new file mode 100644 index 0000000..40b01f1 --- /dev/null +++ b/release/p105/build.py @@ -0,0 +1,55 @@ +"""Assemble a public P105 NFS root release from the native userspace producer.""" +import argparse +import json +from pathlib import Path +import shutil + +from .audit import audit, inventory, sha + + +def build(base,output): + base,output=Path(base),Path(output) + if output.exists() or output.is_symlink(): + raise ValueError('Choose a fresh P105 release directory') + archive=base/'rootfs.tar.gz' + receipt=json.loads((base/'rootfs.tar.gz.json').read_text()) + if (receipt.get('kind'),receipt.get('profile'),receipt.get('architecture'))!=('omarchy-a.p105.base','p105','armv7h'): + raise ValueError('Expected a native P105 base receipt') + if archive.stat().st_size!=receipt['bytes'] or sha(archive)!=receipt['sha256']: + raise ValueError('P105 base stream differs from its receipt') + if sha(base/'packages.lock')!=receipt['packages_lock_sha256']: + raise ValueError('P105 package inventory differs from its receipt') + catalog,_=inventory(archive) + output.mkdir(parents=True) + for source,target in [('rootfs.tar.gz','rootfs.tar.gz'),('packages.lock','packages.lock'), + ('rootfs.tar.gz.json','build-receipt.json'),('desktop-sources.tar.gz','desktop-sources.tar.gz')]: + shutil.copy2(base/source,output/target) + (output/'rootfs.catalog.json').write_text(json.dumps(catalog,sort_keys=True,separators=(',',':'))+'\n') + record={'schema':1,'kind':'omarchy-a.nfs-root','distro':'omarchy-a','profile':'p105','architecture':'armv7h', + 'release':receipt['release'],'min_orchard':'0.1.11','protocol':1,'boot_method':'jailbreak-kdfu', + 'qualification':'pending-physical-boot','root':{'file':'rootfs.tar.gz','bytes':archive.stat().st_size, + 'sha256':receipt['sha256'],'compression':'gzip','members':len(catalog), + 'expanded_bytes':sum(v.get('bytes',0) for v in catalog.values())}, + 'catalog_sha256':sha(output/'rootfs.catalog.json'), + 'packages_lock_sha256':receipt['packages_lock_sha256'], + 'sources_sha256':sha(output/'desktop-sources.tar.gz'), + 'compatibility':{'board':'apple,p105ap','runtime':'p105-systemd-v1','kernel':'6.12.0', + 'kernel_commit':'adc218676eef25575469234709c2d87185ca223a','flash_controller':'disabled', + 'hardware_package':receipt['hardware']['package'],'hardware_overlay':receipt['hardware']['files']}, + 'desktop':{'renderer':'pixman','qtquick':'software','panel':[768,1024],'scale':1}, + 'root_service':'host NFSv3/TCP over USB for the entire session'} + (output/'rootfs.json').write_text(json.dumps(record,indent=2)+'\n') + audit(output) + return record + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--base',type=Path,required=True,help='result directory from system/build-rootfs p105') + parser.add_argument('--output',type=Path,required=True) + args=parser.parse_args() + build(args.base,args.output) + + +if __name__=='__main__': + main() diff --git a/release/p105/prepare_nfs.py b/release/p105/prepare_nfs.py new file mode 100644 index 0000000..fda9dc4 --- /dev/null +++ b/release/p105/prepare_nfs.py @@ -0,0 +1,129 @@ +"""Expand an audited P105 root and apply explicit owner inputs for an NFS host.""" +import argparse +import json +import os +from pathlib import Path +import posixpath +import shutil +import tarfile + +from .audit import audit, name, sha + + +def extract(archive,root): + """Keep guest links inside the staging root while preserving numeric owners.""" + with tarfile.open(archive,'r:gz') as stream: + for member in stream: + key=name(member.name) + if key=='.': + os.chown(root,member.uid,member.gid) + root.chmod(member.mode) + continue + path=root/key + if not path.parent.resolve().is_relative_to(root.resolve()): + raise ValueError('Guest extraction parent escapes the root: '+key) + path.parent.mkdir(parents=True,exist_ok=True) + if member.isdir(): + if path.is_symlink(): + raise ValueError('Directory would replace a link: '+key) + path.mkdir(exist_ok=True) + os.chown(path,member.uid,member.gid) + path.chmod(member.mode) + elif member.issym(): + target=member.linkname + if target.startswith('/'): + target=posixpath.relpath(target.lstrip('/'),posixpath.dirname(key) or '.') + destination=posixpath.normpath(posixpath.join(posixpath.dirname(key),target)) + if destination=='..' or destination.startswith('../'): + raise ValueError('Guest extraction link escapes the root: '+key) + path.symlink_to(target) + os.lchown(path,member.uid,member.gid) + elif member.isfile(): + fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'wb') as output,stream.extractfile(member) as source: + shutil.copyfileobj(source,output) + os.fchown(output.fileno(),member.uid,member.gid) + os.fchmod(output.fileno(),member.mode) + else: + raise ValueError('Unsupported guest extraction member') + + +def owner_files(directory): + record=json.loads((directory/'owner-inputs.json').read_text()) + if record.get('schema')!=1 or record.get('profile')!='p105': + raise ValueError('Expected explicit P105 owner inputs') + allowed={'etc/ssh/ssh_host_ed25519_key','etc/ssh/ssh_host_ed25519_key.pub', + 'home/tablet/.ssh/authorized_keys'} + files={} + for key,digest in record['files'].items(): + if name(key)!=key or (key not in allowed and not key.startswith('etc/NetworkManager/system-connections/')): + raise ValueError('Unexpected private owner input') + source=directory/key + if (source.is_symlink() or not source.resolve().is_relative_to(directory.resolve()) or + not source.is_file() or source.stat().st_size>1024*1024 or sha(source)!=digest): + raise ValueError('Owner input differs from its receipt') + files[key]=source + return files + + +def prepare(release,destination,owner=None): + release,destination=Path(release),Path(destination).expanduser().absolute() + if destination.exists() or destination.is_symlink() or destination==Path('/'): + raise ValueError('Choose a new private NFS root') + root=audit(release) + files=owner_files(Path(owner)) if owner else {} + if os.geteuid()!=0: + raise ValueError('NFS staging needs administrator access to preserve guest UID/GID and sudo modes') + destination.mkdir(parents=True,mode=0o755) + probe=destination/'.p105-case-probe' + probe.write_bytes(b'') + sensitive=not (destination/'.P105-CASE-PROBE').exists() + probe.unlink() + if not sensitive: + destination.rmdir() + raise ValueError('Stage the Linux root on a case-sensitive filesystem') + extract(release/'rootfs.tar.gz',destination) + for key,source in files.items(): + target=destination/key + if not target.parent.resolve().is_relative_to(destination.resolve()) or target.is_symlink(): + raise ValueError('Owner destination escapes the prepared root') + target.parent.mkdir(parents=True,exist_ok=True) + shutil.copyfile(source,target) + tablet=key.startswith('home/tablet/') + os.chown(target,1000 if tablet else 0,1000 if tablet else 0) + target.chmod(0o644 if key.endswith('.pub') else 0o600) + if tablet: + os.chown(target.parent,1000,1000) + target.parent.chmod(0o700) + if 'etc/ssh/ssh_host_ed25519_key' in files: + config=destination/'etc/ssh/sshd_config.d/10-p105-owner.conf' + config.parent.mkdir(parents=True,exist_ok=True) + config.write_text('HostKey /etc/ssh/ssh_host_ed25519_key\nPasswordAuthentication no\n' + 'KbdInteractiveAuthentication no\nPubkeyAuthentication yes\n' + 'PermitRootLogin prohibit-password\nAllowUsers root tablet\nUsePAM yes\n') + config.chmod(0o644) + wants=destination/'etc/systemd/system/multi-user.target.wants' + wants.mkdir(parents=True,exist_ok=True) + (wants/'sshd.service').symlink_to('../../../../usr/lib/systemd/system/sshd.service') + (destination/'etc/hostname').write_text('p105-omarchy\n') + (destination/'etc/hosts').write_text('127.0.0.1 localhost\n::1 localhost\n127.0.1.1 p105-omarchy\n') + result={'schema':1,'private':True,'publishable':False,'profile':'p105','release':root['release'], + 'public_root_sha256':root['root']['sha256'],'owner_files':list(files), + 'link_policy':'guest absolute links normalized to equivalent relative staging links'} + receipt=destination.parent/(destination.name+'.private.json') + receipt.write_text(json.dumps(result,indent=2)+'\n');receipt.chmod(0o600) + print('Prepared private P105 NFS root:',destination) + return result + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--release',type=Path,required=True) + parser.add_argument('--destination',type=Path,required=True) + parser.add_argument('--owner-inputs',type=Path) + args=parser.parse_args() + prepare(args.release,args.destination,args.owner_inputs) + + +if __name__=='__main__': + main() diff --git a/release/p105/smoke_desktop.py b/release/p105/smoke_desktop.py new file mode 100644 index 0000000..7b9ad92 --- /dev/null +++ b/release/p105/smoke_desktop.py @@ -0,0 +1,69 @@ +"""Exercise a finished P105 artifact in a disposable ARMv7 desktop session.""" +import argparse +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import subprocess + +from .audit import audit + +PROJECT=Path(__file__).resolve().parents[2] + + +def smoke(release,builder_image,output): + release,output=Path(release),Path(output).absolute() + if output.exists() or output.is_symlink(): + raise ValueError('Choose a fresh P105 desktop validation directory') + if not re.fullmatch(r'(?:sha256:)?[0-9a-f]{64}',builder_image): + raise ValueError('Provide the exact image ID from the P105 producer') + root=audit(release) + output.mkdir(parents=True) + context=output/'context';context.mkdir() + try: + os.link(release/'rootfs.tar.gz',context/'rootfs.tar.gz') + except OSError: + shutil.copy2(release/'rootfs.tar.gz',context/'rootfs.tar.gz') + shutil.copy2(PROJECT/'desktop/p105/smoke-headless',context/'smoke-headless') + (context/'Containerfile').write_text('FROM '+builder_image+' AS tools\nFROM scratch\n' + 'ADD rootfs.tar.gz /\nCOPY --from=tools /usr/libexec/omarchy-build /usr/libexec/omarchy-build\n' + 'COPY --chmod=0755 smoke-headless /usr/libexec/omarchy-build/smoke-headless\n' + 'ENTRYPOINT ["/usr/libexec/omarchy-build/with-arm"]\n') + image_id=output/'test-image.id' + with (output/'image-build.log').open('w') as log: + subprocess.run(['podman','build','--iidfile',str(image_id),'-f',str(context/'Containerfile'),str(context)], + stdout=log,stderr=subprocess.STDOUT,check=True) + image=image_id.read_text().strip() + container='p105-desktop-test-'+secrets.token_hex(8) + command=['podman','run','--name',container,'--privileged','--network','none','-e','QEMU_CPU=cortex-a9',image, + '/bin/bash','--noprofile','--norc','-c', + 'dbus-uuidgen > /etc/machine-id; exec runuser -u tablet -- python ' + '/usr/libexec/omarchy-build/smoke-headless --stage / --output /tmp/p105-desktop-validation'] + with (output/'desktop.log').open('w') as log: + process=subprocess.run(command,stdout=log,stderr=subprocess.STDOUT) + subprocess.run(['podman','cp',container+':/tmp/p105-desktop-validation',str(output/'evidence')],check=True) + subprocess.run(['podman','rm',container],check=True,stdout=subprocess.DEVNULL) + evidence=json.loads((output/'evidence/result.json').read_text()) + result={'schema':1,'profile':'p105','architecture':'armv7h','physical_hardware':False, + 'root_sha256':root['root']['sha256'],'passed':process.returncode==0 and evidence.get('passed') is True, + 'test_image':image,'exit':process.returncode} + (output/'result.json').write_text(json.dumps(result,indent=2)+'\n') + if not result['passed']: + raise RuntimeError('P105 artifact desktop failed; inspect '+str(output/'evidence')) + print('Verified finished P105 desktop artifact:',output) + return result + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--release',type=Path,required=True) + parser.add_argument('--builder-image',required=True) + parser.add_argument('--output',type=Path,required=True) + args=parser.parse_args() + smoke(args.release,args.builder_image,args.output) + + +if __name__=='__main__': + main() diff --git a/system/build-rootfs b/system/build-rootfs index 8a70b99..e643b56 100755 --- a/system/build-rootfs +++ b/system/build-rootfs @@ -4,6 +4,10 @@ umask 077 here=$(cd "$(dirname "$0")" && pwd) project=$(cd "$here/.." && pwd) +if [[ ${1:-} == p105 ]]; then + shift + exec python3 "$here/p105/build" "$@" +fi device=${1:-} [[ $# -gt 0 ]] && shift output='' desktop='' release='' diff --git a/system/p105/Containerfile b/system/p105/Containerfile new file mode 100644 index 0000000..6ecaf70 --- /dev/null +++ b/system/p105/Containerfile @@ -0,0 +1,17 @@ +FROM docker.io/tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 AS emulator +FROM docker.io/library/alpine@sha256:e311ae748be7f56ae5cba304bc488f0786df68ee507513f654a5cc8597cffb42 AS bootstrap +RUN apk add --no-cache build-base gnupg tar gzip +COPY bootstrap.tar.gz bootstrap.tar.gz.sig archlinuxarm-build.asc with-arm.c /inputs/ +RUN mkdir -m 700 /inputs/gnupg && \ + gpg --homedir /inputs/gnupg --batch --import /inputs/archlinuxarm-build.asc && \ + gpg --homedir /inputs/gnupg --batch --status-fd 1 --verify /inputs/bootstrap.tar.gz.sig /inputs/bootstrap.tar.gz > /inputs/signature.status && \ + grep -q '^\[GNUPG:\] VALIDSIG 68B3537F39A313B3E574D06777193F152BDBE6A6 ' /inputs/signature.status && \ + echo 'ebe0fa119dea45e3af0aa61785676bdbf417bc5f754d529b87255dbf0ca4e3f0 /inputs/bootstrap.tar.gz' | sha256sum -c - && \ + mkdir /armroot && tar -xpf /inputs/bootstrap.tar.gz -C /armroot --exclude='./dev/*' && \ + cc -static -O2 /inputs/with-arm.c -o /inputs/with-arm +FROM scratch +COPY --from=bootstrap /armroot/ / +COPY --from=bootstrap /inputs/with-arm /usr/libexec/omarchy-build/with-arm +COPY --from=emulator /usr/bin/qemu-arm /usr/libexec/omarchy-build/qemu-arm +LABEL org.orchard.target.architecture="armv7h" +ENTRYPOINT ["/usr/libexec/omarchy-build/with-arm"] diff --git a/system/p105/archlinuxarm-build.asc b/system/p105/archlinuxarm-build.asc new file mode 100644 index 0000000..c551dd1 --- /dev/null +++ b/system/p105/archlinuxarm-build.asc @@ -0,0 +1,114 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: Hockeypuck 2.2 +Comment: Hostname: + +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 +=lA9r +-----END PGP PUBLIC KEY BLOCK----- diff --git a/system/p105/bootstrap.json b/system/p105/bootstrap.json new file mode 100644 index 0000000..2458986 --- /dev/null +++ b/system/p105/bootstrap.json @@ -0,0 +1,11 @@ +{ + "schema": 1, + "architecture": "armv7h", + "sha256": "ebe0fa119dea45e3af0aa61785676bdbf417bc5f754d529b87255dbf0ca4e3f0", + "signer": "68B3537F39A313B3E574D06777193F152BDBE6A6", + "urls": [ + "https://ca.us.mirror.archlinuxarm.org/os/multi/ArchLinuxARM-2026.08-armv7-rootfs.tar.gz", + "https://ca.us.mirror.archlinuxarm.org/os/ArchLinuxARM-armv7-latest.tar.gz" + ], + "signer_reference": "https://archlinuxarm.org/about/downloads" +} diff --git a/system/p105/build b/system/p105/build new file mode 100755 index 0000000..14ecb18 --- /dev/null +++ b/system/p105/build @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Build the P105 ARMv7 desktop and clean NFS root from signed Arch packages.""" +import argparse +import hashlib +from importlib.metadata import version +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +from urllib.request import urlopen + +HERE = Path(__file__).resolve().parent +PROJECT = HERE.parents[1] + + +def sha(path): + digest = hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def bootstrap(context, archive=None, signature=None): + pin = json.loads((HERE/'bootstrap.json').read_text()) + if bool(archive) != bool(signature): + raise ValueError('Provide the bootstrap and its detached signature together') + if archive: + if sha(archive) != pin['sha256']: + raise ValueError('ARMv7 bootstrap differs from its pin') + shutil.copyfile(archive, context/'bootstrap.tar.gz') + shutil.copyfile(signature, context/'bootstrap.tar.gz.sig') + else: + failures = [] + for url in pin['urls']: + try: + for suffix in ('', '.sig'): + with urlopen(url+suffix, timeout=60) as source, (context/('bootstrap.tar.gz'+suffix)).open('wb') as target: + shutil.copyfileobj(source, target) + if sha(context/'bootstrap.tar.gz') != pin['sha256']: + raise ValueError('ARMv7 bootstrap differs from its pin') + break + except (OSError, ValueError) as error: + failures.append(str(error)) + else: + raise ValueError('Pinned ARMv7 bootstrap is unavailable: '+'; '.join(failures)) + for name in ('archlinuxarm-build.asc', 'with-arm.c', 'Containerfile'): + shutil.copy2(HERE/name, context/name) + + +def hardware_overlay(target): + import orchard_hardware + if tuple(int(part) for part in version('orchard-hardware').split('.')[:3]) < (0, 1, 11): + raise ValueError('P105 needs hardware 0.1.11 or later') + resources = Path(orchard_hardware.__file__).parent/'runtime/p105/overlay' + shutil.copytree(resources, target) + for directory in [target,*[p for p in target.rglob('*') if p.is_dir()]]: + directory.chmod(0o755) + for path in target.rglob('*'): + if path.is_file(): + path.chmod(0o755 if path.read_bytes().startswith(b'#!') else 0o644) + return {'package': version('orchard-hardware'), 'runtime': 'p105-systemd-v1', + 'files': {str(p.relative_to(target)): sha(p) for p in sorted(target.rglob('*')) if p.is_file()}} + + +def build(args): + output = args.output.expanduser().absolute() + if output.exists() or output.is_symlink(): + raise ValueError('Choose a fresh P105 build output') + if args.jobs < 1 or not args.release or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-' for c in args.release): + raise ValueError('Supply a release identifier and a positive job count') + output.mkdir(parents=True) + work = output/'work' + work.mkdir() + context = work/'bootstrap-context' + context.mkdir() + bootstrap(context, args.bootstrap, args.bootstrap_signature) + overlay = work/'hardware-overlay' + hardware = hardware_overlay(overlay) + (overlay/'p105-hardware-input.json').write_text(json.dumps(hardware, indent=2)+'\n') + image_id = work/'builder-image.id' + subprocess.run(['podman', 'build', '--iidfile', str(image_id), '-f', str(context/'Containerfile'), str(context)], check=True) + image = image_id.read_text().strip() + volume = 'omarchy-p105-'+os.urandom(12).hex() + subprocess.run(['podman','volume','create',volume],check=True,stdout=subprocess.DEVNULL) + state = {'schema':1,'image':image,'volume':volume,'release':args.release,'hardware':hardware, + 'bootstrap':json.loads((HERE/'bootstrap.json').read_text()),'status':'building'} + (output/'build-state.json').write_text(json.dumps(state,indent=2)+'\n') + command = ['podman','run','--rm','--privileged','-e','QEMU_CPU=cortex-a9', + '-v',volume+':/work:rw','-v',str(PROJECT)+':/repo:ro', + '-v',str(overlay)+':/hardware:ro'] + if args.package_cache: + command += ['-v',str(args.package_cache.resolve())+':/package-cache:ro'] + command += [image,'/bin/bash','--noprofile','--norc','/repo/system/p105/build-rootfs', + '--release',args.release,'--jobs',str(args.jobs)] + subprocess.run(command,check=True) + result = output/'result' + result.mkdir() + with tempfile.TemporaryDirectory(prefix='p105-export-', dir=work) as temporary: + archive=Path(temporary)/'result.tar' + # The native wrapper mounts binfmt; a fresh container needs that setup + # even when it only exports the target architecture's completed files. + with archive.open('wb') as stream: + subprocess.run(['podman','run','--rm','--privileged','--network','none','-e','QEMU_CPU=cortex-a9', + '-v',volume+':/work:ro',image,'/usr/bin/tar','-C','/work/result','-cf','-','.'], + stdout=stream,check=True) + subprocess.run(['tar','-xf',str(archive),'-C',str(result)],check=True) + state['status']='complete' + (output/'build-state.json').write_text(json.dumps(state,indent=2)+'\n') + print('P105 userspace and desktop:',result) + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--release',required=True) + parser.add_argument('--bootstrap',type=Path) + parser.add_argument('--bootstrap-signature',type=Path) + parser.add_argument('--package-cache',type=Path) + parser.add_argument('--jobs',type=int,default=2) + args=parser.parse_args() + try: + build(args) + except (OSError,ValueError,subprocess.CalledProcessError) as error: + parser.exit(1,'P105 build: '+str(error)+'\n') + + +if __name__=='__main__': + main() diff --git a/system/p105/build-rootfs b/system/p105/build-rootfs new file mode 100755 index 0000000..ea1bed0 --- /dev/null +++ b/system/p105/build-rootfs @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-2.0-or-later +# Run in the disposable ARMv7 build environment, with a new /work volume. +set -euo pipefail +release= jobs=2 +while (($#)); do + case "$1" in + --release) release=$2; shift 2;; + --jobs) jobs=$2; shift 2;; + *) echo "Unknown P105 build argument: $1" >&2; exit 2;; + esac +done +[[ $EUID == 0 && $(uname -m) == armv7l && -n "$release" && ! -e /work/root ]] || exit 1 +repo=$(cd "$(dirname "$0")/../.." && pwd) +cache=() +[[ ! -d /package-cache ]] || cache=(--package-cache /package-cache) +bash "$repo/system/p105/prepare-root" --snapshot /work/snapshot "${cache[@]}" +useradd --create-home --uid 1001 --user-group --shell /bin/bash builder +install -d -o builder -g builder /work/components /work/desktop /work/upstream +runuser -u builder -- python "$repo/desktop/p105/build-keyboard" --output /work/components/keyboard --jobs "$jobs" +runuser -u builder -- python "$repo/desktop/p105/build-support" --output /work/components/support --jobs "$jobs" +runuser -u builder -- python "$repo/desktop/p105/build-apps" --output /work/components/apps +runuser -u builder -- python "$repo/desktop/p105/sign-apps" --apps /work/components/apps --output /work/components/signed-apps +commit=$(sed -n 's/^omarchy_commit=//p' "$repo/desktop/upstream.lock") +url=$(sed -n 's/^omarchy_url=//p' "$repo/desktop/upstream.lock") +runuser -u builder -- git -C /work/upstream init -q +runuser -u builder -- git -C /work/upstream fetch --depth 1 "$url" "$commit" +runuser -u builder -- git -C /work/upstream checkout -q --detach FETCH_HEAD +keyboard_hash=$(sha256sum /work/components/keyboard/wvkbd-mobintl | cut -d' ' -f1) +runuser -u builder -- python "$repo/desktop/p105/stage.py" --output /work/desktop/payload \ + --upstream /work/upstream --keyboard /work/components/keyboard/wvkbd-mobintl \ + --keyboard-sha256 "$keyboard_hash" --support /work/components/support + +# Resolve the clean runtime from the exact database used by source builds. +root=/work/root +install -d "$root/var/lib/pacman/sync" "$root/var/cache/pacman/pkg" "$root/etc" +cp /work/snapshot/sync/*.db "$root/var/lib/pacman/sync/" +mapfile -t requested < <(sed '/^# Build the patched/,$d; s/[[:space:]]*#.*$//; /^[[:space:]]*$/d' "$repo/system/p105/packages.txt") +pacman --root "$root" --gpgdir /etc/pacman.d/gnupg --cachedir /work/snapshot/cache \ + -S --noconfirm "${requested[@]}" +fingerprint=$(python -c 'import json; print(json.load(open("/work/components/signed-apps/manifest.json"))["key_fingerprint"])') +pacman-key --add /work/components/signed-apps/local-build-public.asc +pacman-key --lsign-key "$fingerprint" +mapfile -t apps < <(python -c 'import json; print("\n".join("/work/components/signed-apps/"+p for p in json.load(open("/work/components/signed-apps/manifest.json"))["packages"].values()))') +pacman --root "$root" --gpgdir /etc/pacman.d/gnupg --noconfirm -U "${apps[@]}" +useradd --root "$root" --create-home --uid 1000 --user-group --shell /bin/bash tablet +usermod --root "$root" --append --groups seat,video,input,audio,wheel tablet +usermod --root "$root" --password '*' root +usermod --root "$root" --password '*' tablet +cp -a --no-preserve=ownership /work/desktop/payload/. "$root/" +cp -a --no-preserve=ownership /hardware/. "$root/" +cp -a --no-preserve=ownership "$repo/system/p105/overlay/." "$root/" +chmod 755 "$root/usr/local/libexec/p105-zram" +chmod 440 "$root/etc/sudoers.d/90-tablet-passwordless" +install -d "$root/etc/cascadia" "$root/etc/ipadlinux" +mv "$root/p105-hardware-input.json" "$root/etc/cascadia/p105-hardware-input.json" +printf 'p105-systemd-v1\n' > "$root/etc/cascadia/systemd-root" +printf '%s\n' "$release" > "$root/etc/ipadlinux/release" +chown -R 1000:1000 "$root/home/tablet" +for unit in sleep suspend hibernate hybrid-sleep suspend-then-hibernate; do + ln -sf /dev/null "$root/etc/systemd/system/$unit.target" +done +for unit in serial-getty@ttyGS0.service systemd-oomd.service systemd-networkd.service systemd-networkd.socket systemd-networkd-wait-online.service systemd-resolved.service; do + ln -sf /dev/null "$root/etc/systemd/system/$unit" +done +systemctl --root="$root" enable getty@tty1.service NetworkManager.service bluetooth.service seatd.service p105-zram.service p105-hardware.target p105-acm-console.service +systemctl --root="$root" --global enable pipewire.socket pipewire-pulse.socket wireplumber.service +systemctl --root="$root" set-default graphical.target +: > "$root/etc/machine-id" +rm -f "$root/var/lib/dbus/machine-id" "$root/etc/ssh"/ssh_host_* +rm -rf "$root/etc/pacman.d/gnupg" +install -d /work/result +pacman --root "$root" -Q > /work/result/packages.lock +tar --hard-dereference --numeric-owner --one-file-system -C "$root" \ + --exclude='./dev/*' --exclude='./proc/*' --exclude='./sys/*' \ + --exclude='./run/*' --exclude='./tmp/*' --exclude='./var/tmp/*' \ + --exclude='./var/cache/pacman/pkg/*' --exclude='./var/log/*' \ + -cf - . | gzip -1 > /work/result/rootfs.tar.gz +python "$repo/system/p105/receipt.py" --root "$root" --result /work/result --release "$release" \ + --snapshot /work/snapshot --components /work/components --desktop /work/desktop/payload +tar -czf /work/result/desktop-sources.tar.gz -C /work components snapshot/sync snapshot/resolved.tsv snapshot/sync.sha256 snapshot/packages.lock +echo 'P105 ARMv7 root and desktop complete' diff --git a/system/p105/overlay/etc/polkit-1/rules.d/60-p105-tablet-network.rules b/system/p105/overlay/etc/polkit-1/rules.d/60-p105-tablet-network.rules new file mode 100644 index 0000000..7c45482 --- /dev/null +++ b/system/p105/overlay/etc/polkit-1/rules.d/60-p105-tablet-network.rules @@ -0,0 +1,6 @@ +polkit.addRule(function(action, subject) { + if (subject.user == "tablet" && subject.local && subject.active && + action.id.indexOf("org.freedesktop.NetworkManager.") == 0) { + return polkit.Result.YES; + } +}); diff --git a/system/p105/overlay/etc/sudoers.d/90-tablet-passwordless b/system/p105/overlay/etc/sudoers.d/90-tablet-passwordless new file mode 100644 index 0000000..027b8dd --- /dev/null +++ b/system/p105/overlay/etc/sudoers.d/90-tablet-passwordless @@ -0,0 +1 @@ +tablet ALL=(ALL:ALL) NOPASSWD: ALL diff --git a/system/p105/overlay/etc/sysctl.d/60-p105-zram.conf b/system/p105/overlay/etc/sysctl.d/60-p105-zram.conf new file mode 100644 index 0000000..29ca208 --- /dev/null +++ b/system/p105/overlay/etc/sysctl.d/60-p105-zram.conf @@ -0,0 +1,2 @@ +vm.swappiness=100 +vm.page-cluster=0 diff --git a/system/p105/overlay/etc/systemd/journald.conf.d/20-p105.conf b/system/p105/overlay/etc/systemd/journald.conf.d/20-p105.conf new file mode 100644 index 0000000..7c1a36f --- /dev/null +++ b/system/p105/overlay/etc/systemd/journald.conf.d/20-p105.conf @@ -0,0 +1,4 @@ +[Journal] +Storage=volatile +RuntimeMaxUse=4M +RuntimeKeepFree=8M diff --git a/system/p105/overlay/etc/systemd/logind.conf.d/60-p105-power.conf b/system/p105/overlay/etc/systemd/logind.conf.d/60-p105-power.conf new file mode 100644 index 0000000..4c3908a --- /dev/null +++ b/system/p105/overlay/etc/systemd/logind.conf.d/60-p105-power.conf @@ -0,0 +1,5 @@ +[Login] +HandlePowerKey=ignore +HandleSuspendKey=ignore +HandleHibernateKey=ignore +IdleAction=ignore diff --git a/system/p105/overlay/etc/systemd/sleep.conf.d/60-p105.conf b/system/p105/overlay/etc/systemd/sleep.conf.d/60-p105.conf new file mode 100644 index 0000000..02b3548 --- /dev/null +++ b/system/p105/overlay/etc/systemd/sleep.conf.d/60-p105.conf @@ -0,0 +1,5 @@ +[Sleep] +AllowSuspend=no +AllowHibernation=no +AllowHybridSleep=no +AllowSuspendThenHibernate=no diff --git a/system/p105/overlay/etc/systemd/system/getty@tty1.service.d/20-p105-platform.conf b/system/p105/overlay/etc/systemd/system/getty@tty1.service.d/20-p105-platform.conf new file mode 100644 index 0000000..a2901d4 --- /dev/null +++ b/system/p105/overlay/etc/systemd/system/getty@tty1.service.d/20-p105-platform.conf @@ -0,0 +1,3 @@ +[Unit] +Requires=p105-hardware.target p105-zram.service +After=p105-hardware.target p105-zram.service diff --git a/system/p105/overlay/etc/udev/rules.d/70-p105-backlight.rules b/system/p105/overlay/etc/udev/rules.d/70-p105-backlight.rules new file mode 100644 index 0000000..c4ec7c1 --- /dev/null +++ b/system/p105/overlay/etc/udev/rules.d/70-p105-backlight.rules @@ -0,0 +1 @@ +ACTION=="add", SUBSYSTEM=="backlight", KERNEL=="apple-pmu-wled", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness" diff --git a/system/p105/overlay/usr/lib/systemd/system/p105-zram.service b/system/p105/overlay/usr/lib/systemd/system/p105-zram.service new file mode 100644 index 0000000..a2d0d91 --- /dev/null +++ b/system/p105/overlay/usr/lib/systemd/system/p105-zram.service @@ -0,0 +1,10 @@ +[Unit] +Description=Compressed RAM swap for the 512 MB P105 +After=systemd-udev-trigger.service +Before=getty@tty1.service +[Service] +Type=oneshot +ExecStart=/usr/local/libexec/p105-zram +RemainAfterExit=yes +[Install] +WantedBy=multi-user.target diff --git a/system/p105/overlay/usr/local/libexec/p105-zram b/system/p105/overlay/usr/local/libexec/p105-zram new file mode 100755 index 0000000..8072466 --- /dev/null +++ b/system/p105/overlay/usr/local/libexec/p105-zram @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +[ -d /sys/block/zram0 ] +grep -q '^/dev/zram0 ' /proc/swaps && exit 0 +if [ "$(cat /sys/block/zram0/disksize)" = 0 ]; then + printf 'lz4\n' > /sys/block/zram0/comp_algorithm + printf '268435456\n' > /sys/block/zram0/disksize +fi +/usr/bin/mkswap /dev/zram0 +/usr/bin/swapon --priority 100 /dev/zram0 diff --git a/system/p105/packages.txt b/system/p105/packages.txt new file mode 100644 index 0000000..0b843a0 --- /dev/null +++ b/system/p105/packages.txt @@ -0,0 +1,69 @@ +# ARMv7h userspace. Cascadia supplies the p105 kernel and hardware support. +base +archlinuxarm-keyring +pacman +systemd +sudo +python +openssh +labwc +quickshell +qt6-base +qt6-declarative +qt6-wayland +qt6-svg +qt6-imageformats +foot +ttf-dejavu +ttf-jetbrains-mono-nerd +ttf-nerd-fonts-symbols +firefox +fastfetch +starship +seatd +networkmanager +bluez +bluez-utils +upower +grim +wlr-randr +xdg-desktop-portal +xdg-desktop-portal-wlr +pipewire +pipewire-pulse +wireplumber +socat +jq +pango +cairo +wayland +libxkbcommon +inotify-tools +libvips +wl-clipboard +imagemagick + +# Build the patched keyboard, Labwc, Omafox and Omacalc in this ARMv7h container. +base-devel +scdoc +pkgconf +patch +git +cmake +ninja +meson +wlroots0.20 +libsfdo +libxml2 +glib2 +librsvg +xcb-util-wm +xorg-xwayland +gettext +qt6-shadertools +wayland-protocols +curl +tar +gzip +xz +zstd diff --git a/system/p105/prepare-root b/system/p105/prepare-root new file mode 100755 index 0000000..911c817 --- /dev/null +++ b/system/p105/prepare-root @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Run inside the verified Arch Linux ARM armv7h container. This changes its /. +set -euo pipefail + +snapshot= +package_cache= +packages_file=$(cd "$(dirname "$0")" && pwd)/packages.txt +while (($#)); do + case "$1" in + --snapshot) [[ $# -ge 2 ]] || exit 2; snapshot=$2; shift 2;; + --packages) [[ $# -ge 2 ]] || exit 2; packages_file=$2; shift 2;; + --package-cache) [[ $# -ge 2 ]] || exit 2; package_cache=$2; shift 2;; + *) printf 'Unknown argument: %s\n' "$1" >&2; exit 2;; + esac +done +[[ -n "$snapshot" ]] || { echo 'Usage: prepare-root --snapshot NEW_DIRECTORY [--packages FILE]' >&2; exit 2; } +[[ $EUID == 0 && $(uname -m) == armv7l ]] || { echo 'Requires root inside an ARMv7 Arch Linux ARM container' >&2; exit 1; } +[[ -e /run/.containerenv || -e /.dockerenv ]] || { echo 'Run this script inside the build container' >&2; exit 1; } +for command in pacman pacman-key pacman-conf sha256sum base64 install; do + command -v "$command" >/dev/null || { printf 'Missing command: %s\n' "$command" >&2; exit 1; } +done +[[ -f "$packages_file" && ! -e "$snapshot" ]] || { echo 'Package list must exist and snapshot directory must be new' >&2; exit 1; } +snapshot=$(realpath -m -- "$snapshot") +[[ "$snapshot" != / ]] || exit 1 +mapfile -t requested < <(sed -E 's/[[:space:]]*#.*$//; /^[[:space:]]*$/d' "$packages_file") +[[ ${#requested[@]} -gt 0 ]] || { echo 'Package list is empty' >&2; exit 1; } +for package in "${requested[@]}"; do + [[ "$package" =~ ^[a-z0-9][a-z0-9@._+-]*$ ]] || { printf 'Invalid package name: %s\n' "$package" >&2; exit 1; } + case "$package" in + linux|linux-headers|linux-lts|linux-lts-headers|linux-armv7*|linux-aarch64*|linux-rpi*|linux-raspberrypi*) + printf 'Cascadia supplies the kernel; remove %s from the package list\n' "$package" >&2; exit 1;; + esac +done + +install -d "$snapshot/cache" "$snapshot/sync" +if [[ -n "$package_cache" ]]; then + [[ -d "$package_cache" ]] || { echo 'Package cache must be an existing directory' >&2; exit 1; } + find "$package_cache" -maxdepth 1 -type f -name '*.pkg.tar.*' -exec cp -n --reflink=auto -t "$snapshot/cache" -- {} + +fi +cp -- "$packages_file" "$snapshot/packages.requested.txt" +cp -- /etc/pacman.conf "$snapshot/pacman.conf.before" +pacman -Q > "$snapshot/packages.before.lock" + +# Use official ARM repositories and retain mandatory signature verification. +# Pacman's guest downloader cannot install its syscall/filesystem sandbox under +# QEMU user emulation. The enclosing Podman container keeps its own seccomp policy. +cat > /etc/pacman.conf <<'PACMAN' +[options] +Architecture = armv7h +CheckSpace +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Required +RemoteFileSigLevel = Required +DisableSandboxFilesystem +DisableSandboxSyscalls +IgnorePkg = linux linux-headers linux-lts linux-lts-headers linux-armv7 linux-armv7-headers linux-armv7-rc linux-armv7-rc-headers linux-aarch64 linux-aarch64-headers linux-rpi linux-rpi-headers linux-raspberrypi linux-raspberrypi-headers + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist + +[alarm] +Include = /etc/pacman.d/mirrorlist +PACMAN +# shellcheck disable=SC2016 # Pacman expands these repository placeholders. +printf 'Server = https://ca.us.mirror.archlinuxarm.org/$arch/$repo\n' > /etc/pacman.d/mirrorlist +[[ $(pacman-conf Architecture) == armv7h ]] || exit 1 +cp -- /etc/pacman.conf "$snapshot/pacman.conf" +cp -- /etc/pacman.d/mirrorlist "$snapshot/mirrorlist" + +# Remove the generic bootstrap kernel before any package hooks can rebuild it. +mapfile -t kernels < <(pacman -Qq | sed -nE '/^(linux|linux-headers|linux-lts|linux-lts-headers|linux-armv7(-.*)?|linux-aarch64(-.*)?|linux-rpi(-.*)?|linux-raspberrypi(-.*)?)$/p') +printf '%s\n' "${kernels[@]}" > "$snapshot/kernels.removed.txt" +if ((${#kernels[@]})); then + pacman -R --noconfirm "${kernels[@]}" +fi + +pacman-key --init +pacman-key --populate archlinuxarm +pacman -Sy --noconfirm +cp -a /var/lib/pacman/sync/*.db "$snapshot/sync/" +(cd /var/lib/pacman/sync && sha256sum ./*.db) > "$snapshot/sync.sha256" + +# Resolve, download and install against the same database files. Include every +# upgrade from the bootstrap so Qt and Quickshell stay in one package snapshot. +printf 'name\tversion\tarchitecture\tfilename\tbytes\tsha256\tpgp_signature_base64\turl\n' > "$snapshot/resolved.tsv" +pacman -Sup --noconfirm --print-format $'%n\t%v\t%a\t%f\t%s\t%h\t%g\t%l' "${requested[@]}" >> "$snapshot/resolved.tsv" +pacman -Suw --noconfirm --cachedir "$snapshot/cache" "${requested[@]}" +(cd /var/lib/pacman/sync && sha256sum --check "$snapshot/sync.sha256") + +# Keep detached signatures beside packages for independent offline verification. +while IFS=$'\t' read -r name version architecture filename bytes digest signature url; do + [[ "$name" == name ]] && continue + [[ "$architecture" == armv7h || "$architecture" == any ]] || { printf 'Unexpected package architecture: %s\n' "$architecture" >&2; exit 1; } + [[ "$filename" != */* && "$digest" =~ ^[[:xdigit:]]{64}$ && -n "$signature" ]] || { printf 'Incomplete receipt for %s\n' "$name" >&2; exit 1; } + printf '%s %s\n' "$digest" "$snapshot/cache/$filename" | sha256sum --check --status + printf '%s' "$signature" | base64 --decode > "$snapshot/cache/$filename.sig" + pacman-key --verify "$snapshot/cache/$filename.sig" "$snapshot/cache/$filename" +done < "$snapshot/resolved.tsv" + +pacman -Su --noconfirm --cachedir "$snapshot/cache" "${requested[@]}" +(cd /var/lib/pacman/sync && sha256sum --check "$snapshot/sync.sha256") +pacman -Q > "$snapshot/packages.lock" +pacman -Dk +if pacman -Qq | sed -nE '/^(linux|linux-lts|linux-armv7(-.*)?|linux-aarch64(-.*)?|linux-rpi(-.*)?|linux-raspberrypi(-.*)?)$/p' | grep -q .; then + echo 'A generic kernel remains in the prepared root' >&2 + exit 1 +fi +date -u +'%Y-%m-%dT%H:%M:%SZ' > "$snapshot/complete" +printf 'Prepared ARMv7h container; signed package receipt: %s\n' "$snapshot" diff --git a/system/p105/receipt.py b/system/p105/receipt.py new file mode 100644 index 0000000..4569e3f --- /dev/null +++ b/system/p105/receipt.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Record the P105 root, package snapshot and source-built desktop inputs.""" +import argparse +import hashlib +import json +from pathlib import Path +import struct + + +def sha(path): + digest=hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda:stream.read(1024*1024),b''): + digest.update(chunk) + return digest.hexdigest() + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + for name in ('root','result','snapshot','components','desktop'): + parser.add_argument('--'+name,type=Path,required=True) + parser.add_argument('--release',required=True) + args=parser.parse_args() + binaries=['usr/lib/systemd/systemd','usr/bin/qs','usr/bin/labwc','usr/bin/omacalc', + 'usr/libexec/omarchy-wtype','home/tablet/.local/share/omarchy-ipad/trials/wvkbd-mobintl-v0.20-font18-special15-state'] + for name in binaries: + header=(args.root/name).read_bytes()[:52] + if (len(header)!=52 or header[:7]!=b'\x7fELF\x01\x01\x01' or header[18:20]!=b'\x28\x00' or + not struct.unpack_from(' +#include +#include +#include + +int main(int argc, char **argv) { + if (argc < 2) return 2; + if (mkdir("/run/omarchy-binfmt", 0700)) { + perror("create build binfmt directory"); + return 1; + } + if (mount("binfmt_misc", "/run/omarchy-binfmt", "binfmt_misc", + MS_NOSUID | MS_NODEV, 0)) { + perror("mount build binfmt"); + return 1; + } + FILE *stream = fopen("/run/omarchy-binfmt/register", "w"); + if (!stream) { perror("open build binfmt"); return 1; } + const char *entry = ":omarchy-armv7:M::" + "\\x7fELF\\x01\\x01\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x02\\x00\\x28\\x00:" + "\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\x00\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xfe\\xff\\xff\\xff:" + "/usr/libexec/omarchy-build/qemu-arm:PF"; + if (fputs(entry, stream) < 0 || fclose(stream)) { + perror("register build ARM handler"); + return 1; + } + execvp(argv[1], argv + 1); + perror("execute ARM build"); + return 1; +} diff --git a/tests/test_p105_nfs.py b/tests/test_p105_nfs.py new file mode 100644 index 0000000..0ac68f4 --- /dev/null +++ b/tests/test_p105_nfs.py @@ -0,0 +1,66 @@ +"""Private P105 staging preserves guest paths and rejects unrelated owner files.""" +import hashlib +import io +import json +import os +from pathlib import Path +import tarfile +import tempfile +import unittest + +from release.p105 import prepare_nfs + + +class P105NFS(unittest.TestCase): + def test_absolute_guest_link_is_staged_as_equivalent_relative_link(self): + with tempfile.TemporaryDirectory() as temporary: + base=Path(temporary);root=base/'root';root.mkdir();archive=base/'root.tar.gz' + with tarfile.open(archive,'w:gz') as stream: + m=tarfile.TarInfo('usr/local/bin/app');m.type=tarfile.SYMTYPE + m.linkname='/home/tablet/app';m.uid=os.getuid();m.gid=os.getgid();stream.addfile(m) + prepare_nfs.extract(archive,root) + link=root/'usr/local/bin/app' + self.assertEqual(link.resolve(),root.resolve()/'home/tablet/app') + self.assertFalse(os.readlink(link).startswith('/')) + + def test_extraction_refuses_an_outside_parent_link(self): + with tempfile.TemporaryDirectory() as temporary: + base=Path(temporary);root=base/'root';root.mkdir();outside=base/'outside';outside.mkdir() + (root/'etc').symlink_to(outside,target_is_directory=True) + archive=base/'root.tar.gz' + with tarfile.open(archive,'w:gz') as stream: + m=tarfile.TarInfo('etc/file');m.size=1;stream.addfile(m,io.BytesIO(b'x')) + with self.assertRaisesRegex(ValueError,'parent escapes'): + prepare_nfs.extract(archive,root) + self.assertEqual(list(outside.iterdir()),[]) + + def record(self,root,key,data=b'owner key'): + path=root/key;path.parent.mkdir(parents=True,exist_ok=True);path.write_bytes(data) + record={'schema':1,'profile':'p105','files':{key:hashlib.sha256(data).hexdigest()}} + (root/'owner-inputs.json').write_text(json.dumps(record)) + + def test_owner_profile_accepts_an_integrity_checked_tablet_key(self): + with tempfile.TemporaryDirectory() as temporary: + root=Path(temporary);key='home/tablet/.ssh/authorized_keys';self.record(root,key) + self.assertEqual(list(prepare_nfs.owner_files(root)),[key]) + (root/key).write_text('changed') + with self.assertRaisesRegex(ValueError,'differs'): + prepare_nfs.owner_files(root) + + def test_owner_profile_cannot_copy_unrelated_system_files(self): + with tempfile.TemporaryDirectory() as temporary: + root=Path(temporary);self.record(root,'etc/sudoers') + with self.assertRaisesRegex(ValueError,'Unexpected'): + prepare_nfs.owner_files(root) + + def test_owner_profile_rejects_a_linked_parent(self): + with tempfile.TemporaryDirectory() as temporary: + base=Path(temporary);root=base/'inputs';root.mkdir();outside=base/'outside';outside.mkdir() + key='home/tablet/.ssh/authorized_keys';self.record(root,key) + path=root/'home';path.rename(outside/'home');path.symlink_to(outside/'home') + with self.assertRaisesRegex(ValueError,'differs'): + prepare_nfs.owner_files(root) + + +if __name__=='__main__': + unittest.main() diff --git a/tests/test_p105_profile.py b/tests/test_p105_profile.py new file mode 100644 index 0000000..25d3b57 --- /dev/null +++ b/tests/test_p105_profile.py @@ -0,0 +1,326 @@ +"""P105 ABI, payload integrity and build-container safeguards.""" +import contextlib +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import platform +import re +import shutil +import struct +import subprocess +import tempfile +import unittest + + +PROJECT = Path(__file__).resolve().parents[1] +PROFILE = PROJECT / 'desktop/p105' +PREPARE = PROJECT / 'system/p105/prepare-root' +spec = importlib.util.spec_from_file_location('p105_stage', PROFILE / 'stage.py') +stage = importlib.util.module_from_spec(spec) +spec.loader.exec_module(stage) + + +def arm_elf(machine=40, flags=0x05000400): + ident = b'\x7fELF\x01\x01\x01' + bytes(9) + return struct.pack('<16sHHIIIIIHHHHHH', ident, 3, machine, 1, 0, 52, 0, + flags, 52, 32, 1, 40, 0, 0) + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +class P105ProfileTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + + def payload(self): + files = { + stage.KEYBOARD.as_posix(): arm_elf(), + 'usr/bin/labwc': arm_elf(), + 'usr/bin/labwc-copy': arm_elf(), + 'usr/libexec/omarchy-wtype': arm_elf(), + 'usr/share/doc/omarchy-p105/support-build.json': b'{"schema":1}\n', + 'home/tablet/.config/labwc/autostart': b'#!/bin/sh\nquickshell\n', + } + record = { + 'schema': 1, 'profile': 'p105', 'architecture': 'armv7h', + 'keyboard_sha256': digest(files[stage.KEYBOARD.as_posix()]), + 'support_manifest_sha256': digest(files['usr/share/doc/omarchy-p105/support-build.json']), + 'symlinks': {'usr/local/bin/compositor': '../../bin/labwc'}, + } + files[stage.RECEIPT] = json.dumps(record).encode() + for name, data in files.items(): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + link = self.root / 'usr/local/bin/compositor' + link.parent.mkdir(parents=True) + link.symlink_to(record['symlinks']['usr/local/bin/compositor']) + (self.root / stage.MANIFEST).write_text(json.dumps({name: digest(data) for name, data in files.items()})) + + def verify(self): + with contextlib.redirect_stdout(io.StringIO()): + stage.verify(self.root) + + def rewrite_receipt(self, field, value): + path = self.root / stage.RECEIPT + record = json.loads(path.read_text()) + record[field] = value + path.write_text(json.dumps(record)) + manifest_path = self.root / stage.MANIFEST + manifest = json.loads(manifest_path.read_text()) + manifest[stage.RECEIPT] = digest(path.read_bytes()) + manifest_path.write_text(json.dumps(manifest)) + + def test_only_little_endian_elf32_arm_hard_float_is_accepted(self): + binary = self.root / 'program' + valid = arm_elf() + binary.write_bytes(valid) + stage.check_armhf(binary) + for label, content in ( + ('truncated', valid[:51]), + ('ELF64', valid[:4] + b'\x02' + valid[5:]), + ('big endian', valid[:5] + b'\x02' + valid[6:]), + ('AArch64', arm_elf(machine=183)), + ('x86', arm_elf(machine=3)), + ('unspecified float ABI', arm_elf(flags=0x05000000)), + ('soft float', arm_elf(flags=0x05000200)), + ('conflicting float flags', arm_elf(flags=0x05000600)), + ): + with self.subTest(format=label): + binary.write_bytes(content) + with self.assertRaises(ValueError): + stage.check_armhf(binary) + + def test_payload_changes_missing_files_and_added_files_are_rejected(self): + self.payload() + self.verify() + path = self.root / 'home/tablet/.config/labwc/autostart' + original = path.read_bytes() + for content in (b'changed launcher', None): + with self.subTest(content=content): + if content is None: + path.unlink() + else: + path.write_bytes(content) + with self.assertRaisesRegex(ValueError, 'files differ'): + self.verify() + path.write_bytes(original) + (self.root / 'unexpected-helper').write_bytes(b'extra') + with self.assertRaisesRegex(ValueError, 'files differ'): + self.verify() + + def test_symlink_retargeting_and_unrecorded_dangling_links_are_rejected(self): + self.payload() + link = self.root / 'usr/local/bin/compositor' + link.unlink() + # Both destinations contain identical bytes; the link itself is pinned. + link.symlink_to('../../bin/labwc-copy') + with self.assertRaisesRegex(ValueError, 'symlinks differ'): + self.verify() + link.unlink() + link.symlink_to('../../bin/labwc') + (self.root / 'unrecorded-link').symlink_to('missing-file') + with self.assertRaisesRegex(ValueError, 'symlinks differ'): + self.verify() + + def test_receipt_profile_and_component_hashes_are_checked(self): + self.payload() + original = json.loads((self.root / stage.RECEIPT).read_text()) + for field, value in (('architecture', 'aarch64'), ('profile', 'j71b'), + ('keyboard_sha256', '0' * 64), + ('support_manifest_sha256', '0' * 64)): + with self.subTest(field=field): + self.rewrite_receipt(field, value) + with self.assertRaises(ValueError): + self.verify() + self.rewrite_receipt(field, original[field]) + + def test_menu_routes_supported_panels_and_excludes_unavailable_power_actions(self): + menu = self.root / 'menu.jsonc' + shutil.copyfile(PROJECT / 'desktop/shell/default/omarchy/omarchy-menu.jsonc', menu) + stage.adapt_menu(menu) + entries = json.loads(menu.read_text()) + for panel in ('monitor', 'network', 'bluetooth', 'power'): + key = 'setup.monitors' if panel == 'monitor' else 'setup.' + panel + self.assertEqual(entries[key]['action'], 'omarchy-shell omarchy.%s toggle' % panel) + power = {name: row['action'] for name, row in entries.items() if name.startswith('system.')} + self.assertEqual(power, {'system.shutdown': 'omarchy-system shutdown'}) + for action in ('reboot', 'suspend', 'hibernate', 'logout'): + with self.subTest(action=action): + result = subprocess.run(['bash', str(PROFILE / 'system-action'), action], + text=True, capture_output=True, timeout=5) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertNotIn('install', entries) + self.assertNotIn('update', entries) + + def test_firefox_defaults_preserve_omafox_and_other_policies_and_preferences(self): + source = PROJECT / 'desktop/configuration/firefox-policies.json' + original = source.read_bytes() + document = json.loads(original) + extensions = document['policies']['ExtensionSettings'] + homepage = {'URL': 'https://example.invalid/', 'Locked': False} + retained = { + 'browser.custom.pref': {'Value': 'keep', 'Status': 'default', 'Type': 'string'}, + 'fission.autostart': {'Value': True, 'Status': 'default', 'Type': 'boolean'}, + 'security.sandbox.content.level': {'Value': 4, 'Status': 'default', 'Type': 'number'}, + } + document['policies'].update(Homepage=homepage, Preferences=retained) + target = self.root / 'policies.json' + target.write_text(json.dumps(document)) + receipt = stage.adapt_firefox(target) + policies = json.loads(target.read_text())['policies'] + self.assertEqual(source.read_bytes(), original) + self.assertEqual(set(policies), set(document['policies'])) + self.assertEqual(policies['ExtensionSettings'], extensions) + self.assertEqual(policies['Homepage'], homepage) + for name, entry in retained.items(): + self.assertEqual(policies['Preferences'][name], entry) + expected = { + 'dom.ipc.processPrelaunch.enabled': False, + 'dom.ipc.processCount': 2, + 'dom.ipc.processCount.webIsolated': 1, + 'browser.newtab.preload': False, + 'browser.aboutwelcome.enabled': False, + 'browser.startup.page': 0, + 'browser.newtabpage.enabled': False, + } + defaults = {name: entry for name, entry in policies['Preferences'].items() if name not in retained} + self.assertEqual(set(defaults), set(expected)) + for name, value in expected.items(): + self.assertEqual(defaults[name], { + 'Value': value, 'Status': 'default', + 'Type': 'boolean' if type(value) is bool else 'number', + }) + self.assertEqual(receipt['preferences'], defaults) + self.assertEqual(receipt['policy_sha256'], digest(target.read_bytes())) + self.assertEqual(receipt['defaults_sha256'], digest((PROFILE / 'firefox-defaults.json').read_bytes())) + + def test_firefox_rejects_malformed_policy_objects_before_writing(self): + target = self.root / 'policies.json' + for document in ({}, {'policies': []}, {'policies': None}, + {'policies': {'Preferences': []}}): + with self.subTest(document=document): + original = json.dumps(document).encode() + target.write_bytes(original) + with self.assertRaises(ValueError): + stage.adapt_firefox(target) + self.assertEqual(target.read_bytes(), original) + + def bridge_fixture(self, name='payload'): + payload = self.root / name + originals = {} + for relative in ('shell/shell.qml', 'shell/services/PluginShellApi.qml'): + source = PROJECT / 'desktop/shell' / relative + originals[relative] = source.read_bytes() + target = payload / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(originals[relative]) + return payload, originals + + def test_keyboard_bridge_scopes_both_facades_and_preserves_shared_sources(self): + payload, originals = self.bridge_fixture() + receipt = stage.adapt_keyboard_bridge(payload) + self.assertEqual(receipt['entry'], 'omarchy.network') + self.assertEqual(receipt['surface'], 'read-only keyboardVisible') + self.assertEqual(set(receipt['files']), set(originals)) + for relative, original in originals.items(): + self.assertEqual((PROJECT / 'desktop/shell' / relative).read_bytes(), original) + copied = (payload / relative).read_bytes() + self.assertNotEqual(copied, original) + self.assertEqual(receipt['files'][relative], digest(copied)) + api = (payload / 'shell/services/PluginShellApi.qml').read_text() + self.assertIn('readonly property bool keyboardVisible:', api) + shell = (payload / 'shell/shell.qml').read_text() + callbacks = re.findall(r'_keyboardVisibleLookup:\s*function\(\)\s*\{(.*?)\n\s*\}', + shell, flags=re.DOTALL) + self.assertEqual(len(callbacks), 2) + for identifier, body in zip(('key', 'target'), callbacks): + self.assertIn('currentManifest()', body) + self.assertIn(identifier + ' === "omarchy.network"', body) + self.assertIn('entry && entry.__isFirstParty && shell.keyboardVisible', body) + self.assertIn('return !!(', body) + + def test_keyboard_bridge_rejects_missing_anchors_before_writing_either_file(self): + for index, (relative, anchor) in enumerate(( + ('shell/services/PluginShellApi.qml', ' property var _serviceLookup: null\n'), + ('shell/shell.qml', ' pluginId: key,\n appLibrary:'), + ('shell/shell.qml', ' pluginId: target,\n barConfig:'), + )): + with self.subTest(anchor=anchor): + payload, originals = self.bridge_fixture('broken-' + str(index)) + changed = payload / relative + changed.write_text(changed.read_text().replace(anchor, '', 1)) + before = {name: (payload / name).read_bytes() for name in originals} + with self.assertRaisesRegex(ValueError, 'facade changed'): + stage.adapt_keyboard_bridge(payload) + self.assertEqual({name: (payload / name).read_bytes() for name in originals}, before) + for name, original in originals.items(): + self.assertEqual((PROJECT / 'desktop/shell' / name).read_bytes(), original) + + @unittest.skipUnless(shutil.which('node'), 'node is required to exercise the facade callbacks') + def test_keyboard_bridge_callbacks_gate_builtin_identity_and_visible_state(self): + payload, _ = self.bridge_fixture() + stage.adapt_keyboard_bridge(payload) + callbacks = re.findall(r'_keyboardVisibleLookup:\s*function\(\)\s*\{(.*?)\n\s*\}', + (payload / 'shell/shell.qml').read_text(), flags=re.DOTALL) + script = r''' +const assert = require('node:assert/strict'); +const callbacks = JSON.parse(process.argv[1]); +assert.equal(callbacks.length, 2); +for (const [index, body] of callbacks.entries()) { + const lookup = new Function('key', 'target', 'currentManifest', 'shell', body); + for (const id of ['omarchy.network', 'omarchy.network.clone', 'other.plugin']) { + for (const entry of [null, {}, {__isFirstParty: false}, {__isFirstParty: true}]) { + for (const visible of [false, true]) { + const key = index === 0 ? id : 'other.plugin'; + const target = index === 1 ? id : 'other.plugin'; + const result = lookup(key, target, () => entry, {keyboardVisible: visible}); + assert.equal(typeof result, 'boolean'); + assert.equal(result, id === 'omarchy.network' && entry?.__isFirstParty === true && visible); + } + } + } +} +''' + result = subprocess.run(['node', '-e', script, json.dumps(callbacks)], + text=True, capture_output=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + @unittest.skipIf(os.geteuid() == 0 and platform.machine() == 'armv7l', + 'this case exercises refusal outside the supported build environment') + def test_prepare_root_refuses_the_host_before_creating_output(self): + destination = self.root / 'snapshot' + env = dict(os.environ) + env.pop('BASH_ENV', None) + # A missing package file also prevents writes if the host guard regresses. + result = subprocess.run(['bash', str(PREPARE), '--snapshot', str(destination), + '--packages', str(self.root / 'missing-packages')], + env=env, text=True, capture_output=True, timeout=5) + self.assertNotEqual(result.returncode, 0) + self.assertIn('Requires root inside an ARMv7', result.stderr) + self.assertFalse(destination.exists()) + + def test_preparation_policy_keeps_kernel_and_signature_guards_before_writes(self): + source = PREPARE.read_text() + preflight = source.split('install -d "$snapshot/cache"', 1)[0] + self.assertIn('$EUID == 0', preflight) + self.assertIn('/run/.containerenv', preflight) + self.assertIn('Cascadia supplies the kernel', preflight) + self.assertIn('linux-armv7*', preflight) + self.assertIn('linux-aarch64*', preflight) + configuration = source.split("<<'PACMAN'\n", 1)[1].split('\nPACMAN', 1)[0] + options = dict(line.split(' = ', 1) for line in configuration.splitlines() if ' = ' in line) + self.assertEqual(options['SigLevel'], 'Required DatabaseOptional') + self.assertEqual(options['LocalFileSigLevel'], 'Required') + self.assertEqual(options['RemoteFileSigLevel'], 'Required') + self.assertIn('linux-armv7', options['IgnorePkg'].split()) + requested = (PROJECT / 'system/p105/packages.txt').read_text().splitlines() + packages = {line.split('#', 1)[0].strip() for line in requested} + self.assertFalse(packages & {'linux', 'linux-armv7', 'linux-aarch64'}) diff --git a/tests/test_p105_release.py b/tests/test_p105_release.py new file mode 100644 index 0000000..3356d18 --- /dev/null +++ b/tests/test_p105_release.py @@ -0,0 +1,88 @@ +"""P105 public-root boundaries using disposable archives.""" +import io +from pathlib import Path +import struct +import tarfile +import tempfile +import unittest + +from release.p105 import audit + + +class P105Release(unittest.TestCase): + def archive(self, root, entries): + archive=root/'root.tar.gz' + with tarfile.open(archive,'w:gz') as stream: + for name,kind,value in entries: + member=tarfile.TarInfo(name) + if kind=='file': + member.size=len(value) + stream.addfile(member,io.BytesIO(value)) + else: + member.type=kind + if kind in (tarfile.SYMTYPE,tarfile.LNKTYPE):member.linkname=value + stream.addfile(member) + return archive + + def rejects(self, entries, message): + with tempfile.TemporaryDirectory() as temporary: + path=self.archive(Path(temporary),entries) + with self.assertRaisesRegex(ValueError,message):audit.inventory(path) + + def test_member_traversal_is_rejected(self): + for name in ('../outside','/etc/outside','usr/../../outside'): + with self.subTest(name=name):self.rejects([(name,'file',b'x')],'Unsafe') + + def test_link_traversal_and_special_members_are_rejected(self): + self.rejects([('usr/link',tarfile.SYMTYPE,'../../outside')],'escapes') + self.rejects([('dev/disk',tarfile.CHRTYPE,'')],'Unsupported') + self.rejects([('usr/hard',tarfile.LNKTYPE,'usr/file')],'Unsupported') + + def test_duplicate_normalized_paths_are_rejected(self): + self.rejects([('./etc/value','file',b'a'),('etc/value','file',b'b')],'Duplicate') + + def test_owner_inputs_are_rejected(self): + for name in ('etc/ssh/ssh_host_ed25519_key','home/tablet/.ssh/authorized_keys', + 'usr/lib/firmware/mtcal.bin','etc/orchard/p105.json', + 'etc/NetworkManager/system-connections/owner.nmconnection'): + with self.subTest(name=name):self.rejects([(name,'file',b'private input')],'Private input') + + def test_private_key_body_is_rejected(self): + key=b'-----BEGIN OPENSSH PRIVATE KEY-----\n'+b'A'*80+b'\n' + self.rejects([('unexpected/key','file',key)],'Private key') + + def test_documented_key_header_is_ordinary_source(self): + with tempfile.TemporaryDirectory() as temporary: + path=self.archive(Path(temporary),[('usr/lib/parser.py','file',b'header = "-----BEGIN OPENSSH PRIVATE KEY-----"\n')]) + catalog,_=audit.inventory(path) + self.assertEqual(catalog['usr/lib/parser.py']['type'],'file') + + def test_non_arm_executable_is_rejected(self): + header=bytearray(52);header[:7]=b'\x7fELF\x02\x01\x01';struct.pack_into(' Date: Tue, 27 Oct 2026 20:31:30 -0600 Subject: [PATCH 2/3] Pin P105 hardware with additive README support --- hardware | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hardware b/hardware index 867b87b..407c910 160000 --- a/hardware +++ b/hardware @@ -1 +1 @@ -Subproject commit 867b87beab50b88ade85194321b7c7e0b1156b1c +Subproject commit 407c9101c0066ecdfa1a1ad2750ef91947450141 From ef1f1919399869b3339b143f8fd0256a32b6f9aa Mon Sep 17 00:00:00 2001 From: Shayne Legault <8741557+shaynelegault@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:05:53 -0600 Subject: [PATCH 3/3] Record repeated P105 native desktop boots and Wi-Fi input validation --- desktop/p105/README.md | 6 ++++++ docs/p105-validation.md | 46 ++++++++++++++++++++++++++++++++--------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/desktop/p105/README.md b/desktop/p105/README.md index 96a3c6b..42a54ac 100644 --- a/desktop/p105/README.md +++ b/desktop/p105/README.md @@ -261,3 +261,9 @@ The result records the audited root hash and whether the app grid and theme carousel responded. Screenshots accompany the result. The P105 workflow runs this check for manually requested root builds. Physical input and radio behavior require their own device checks. + +Candidate `0.1.1-p105.1` passed two native Orchard CLI boots on the original +iPad mini Wi-Fi. Physical Home and Omacalc worked, and the owner entered a +Wi-Fi password with the on-screen keyboard. The saved connection returned +after reboot. See [the P105 validation record](../../docs/p105-validation.md) +for the tested root hash and remaining issues. diff --git a/docs/p105-validation.md b/docs/p105-validation.md index bfcd62f..672d62a 100644 --- a/docs/p105-validation.md +++ b/docs/p105-validation.md @@ -51,16 +51,42 @@ workflow checks the profiles and can build the NFS artifact on manual dispatch. ## Physical qualification -The fresh root awaits a device boot after the Mac prepares its private NFS -export. A separate case-sensitive APFS image is mounted for that root. Owner -Wi-Fi and SSH inputs stay private, with the prior working export retained. -The macOS administrator prompt is required to apply numeric guest ownership -and add the export. - -Repeated native CLI boots and physical keyboard/password-entry checks remain -pending. Touch latency and the intermittent calibration startup failure from -the earlier hardware qualification remain open. Bluetooth pairing and audio -playback await their own device tests. +The candidate booted twice on the original iPad mini Wi-Fi through the installed +Orchard CLI and its NFS handoff. Each test started from iOS after a clean Linux +shutdown and had a distinct Linux boot identity. All five source checkouts and +the old workspace were denied to the host Python process during both boots. +The new root lives on a separate case-sensitive host image. The prior working +export is retained. +Owner Wi-Fi and SSH inputs were applied privately after the public archive +passed its audit. + +| Device check | Measured result | +| --- | --- | +| Running system | Arch Linux ARM, `armv7l`, kernel `6.12.0`, release `0.1.1-p105.1` | +| Root and packages | New native NFS root; package inventory exactly matches the release | +| System and user managers | Zero failed units on both boots | +| Hardware runtime | Platform, touch, keys and zram active on both boots; all eight payload checks passed each time | +| Recovery | One USB serial reader in the current root on each boot; rescue executable verified in RAM | +| Storage fence | Flash controller disabled and MTD inventory empty on both boots | +| Desktop | Physical 768 × 1024 frames captured with the complete shell after both boots | +| Owner input | Home opened the app grid; Omacalc displayed 4 for 2 + 2 during the first session | +| Network password | Owner entered the password with the on-screen keyboard and connected during the first session | +| Network access | Saved Wi-Fi reconnected after reboot; certificate-verified HTTPS passed in each session | +| LAN SSH | Existing host identity and authorized owner key worked for `tablet` after both boots; sudo passed | + +The shell took time to finish drawing after system services reported ready, +with partial bars visible in the first captures. + +Touch latency and the intermittent calibration startup failure from the earlier +hardware qualification remain open. Both native desktop boots started their +touch service without intervention. Automatic time synchronization remains +open; `systemd-timesyncd` was inactive in the first session. Bluetooth pairing and audio playback +await their own device tests. + +The tested public root has SHA-256 +`66de9098b3b12caccc3ca21d2e8b5b3723afecfeded75a8cce5acc95e53f4e0c`. +Its local qualification receipt records the device results against that hash. +The producer marks each newly built candidate as awaiting physical testing. Build receipts and test evidence remain in the owner's local phase 4 directory. See [the P105 build instructions](../desktop/p105/README.md).