diff --git a/.forgejo/workflows/calamares.yml b/.forgejo/workflows/calamares.yml index 80637f9..26b90b0 100644 --- a/.forgejo/workflows/calamares.yml +++ b/.forgejo/workflows/calamares.yml @@ -24,7 +24,12 @@ jobs: kcoreaddons kpmcore libpwquality qt6-declarative qt6-svg yaml-cpp useradd -m builder git config --global --add safe.directory '*' - git clone --depth 1 "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src + # Clone the branch/tag that triggered this run (not the default + # branch) — same as bibata.yml/powerlevel10k.yml/yay-bin.yml, so a + # push to a feature branch (or a release tag) builds and publishes + # from that ref, not whatever happens to be on the default branch. + git clone --depth 1 --branch "${GITHUB_REF_NAME}" \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src chown -R builder:builder /home/builder/src su builder -c "cd /home/builder/src/packaging/calamares && makepkg -f --noconfirm --nocheck" PKG=$(find /home/builder/src/packaging/calamares -name '*.pkg.tar.zst' | head -1) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index 6a2f9ab..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); - # --prune deletes GitHub refs that no longer exist on Forgejo. - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bos.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml deleted file mode 100644 index aaf7eb7..0000000 --- a/.forgejo/workflows/package.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Build and publish package - -on: - push: - tags: ['v*'] - -jobs: - package: - runs-on: [self-hosted, hestia] - container: - image: archlinux:latest - steps: - # Note: no actions/checkout — the archlinux image has no Node, which JS - # actions require. Everything runs as shell steps and clones manually. - - name: Build and publish - env: - PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" - pacman -Syu --noconfirm base-devel git rust cargo gtk4 glib2 - useradd -m builder - git config --global --add safe.directory '*' - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src - cd /home/builder/src - git archive --format=tar.gz --prefix="bos-settings-${VERSION}/" HEAD \ - > packaging/arch/bos-settings-${VERSION}.tar.gz - SHA=$(sha256sum packaging/arch/bos-settings-${VERSION}.tar.gz | awk '{print $1}') - sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD - sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - chown -R builder:builder /home/builder/src - # --nocheck: packaging builds the artifact; tests belong in a CI job. - su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" - PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) - curl -fsS -X PUT \ - -H "Authorization: token ${PUBLISH_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary "@${PKG}" \ - "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.forgejo/workflows/python-pywal.yml b/.forgejo/workflows/python-pywal.yml new file mode 100644 index 0000000..f5f7d61 --- /dev/null +++ b/.forgejo/workflows/python-pywal.yml @@ -0,0 +1,39 @@ +name: Build and publish python-pywal + +# python-pywal was dropped from Arch's [extra] repo (AUR-only now), but the ISO +# needs the `wal` binary (bread-theme extracts the wallpaper palette with it). +# BOS keeps an in-house PKGBUILD and publishes to the [breadway] repo — same +# pattern as calamares / bibata / powerlevel10k / yay-bin. +on: + push: + paths: + - 'packaging/python-pywal/**' + workflow_dispatch: + +jobs: + python-pywal: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + steps: + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + pacman -Syu --noconfirm base-devel git \ + python python-build python-installer python-wheel python-setuptools imagemagick + useradd -m builder + git config --global --add safe.directory '*' + # Clone the ref that triggered this run (not the default branch) — + # same as the other packaging workflows. + git clone --depth 1 --branch "${GITHUB_REF_NAME}" \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src + chown -R builder:builder /home/builder/src + su builder -c "cd /home/builder/src/packaging/python-pywal && makepkg -f --noconfirm" + PKG=$(find /home/builder/src/packaging/python-pywal -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.forgejo/workflows/release-iso.yml b/.forgejo/workflows/release-iso.yml index aff0bef..162fa16 100644 --- a/.forgejo/workflows/release-iso.yml +++ b/.forgejo/workflows/release-iso.yml @@ -1,14 +1,21 @@ name: Build and release ISO -# Builds the BOS ISO on the hestia self-hosted runner (native Arch container), -# downloads all bakery ecosystem binaries from their GitHub releases, compiles -# bread-theme from source, and uploads the resulting ISO to a Forgejo pre-release. -# A matching GitHub release is created that points to Forgejo for the download +# Builds the BOS ISO on the hestia self-hosted runner (native Arch container). +# Stages bakery desktop apps from the *minisign-verified* stable index at +# https://dl.breadway.dev/index.json (see iso/bread-lockfile.toml), then runs +# build-local.sh and uploads the ISO to a Forgejo release. A matching GitHub +# release is created best-effort and points at Forgejo for the download # (GitHub releases cannot host files larger than 2 GB). # # Required secrets: -# RELEASE_TOKEN — Forgejo API token with write:repository scope -# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml) +# RELEASE_TOKEN — Forgejo API token with write:repository scope +# MIRROR_TOKEN — GitHub personal access token with repo scope +# GPG_PRIVATE_KEY — armoured secret key for the dedicated "BOS Release Signing" +# identity (releases@breadway.dev); public half is committed +# at KEYS.asc. Signs ISO SHA256SUMS here; the same secret +# signs the [breadway] repo in signed-repo.yml. No passphrase +# (CI-only key, access controlled via the Forgejo secret +# store). on: push: @@ -23,6 +30,8 @@ jobs: release-iso: runs-on: [self-hosted, hestia] container: + # Floating tag: this environment cannot pin a reproducible digest of + # archlinux:latest. Do not invent one. image: archlinux:latest # --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod # --network=host: gives localhost:3002 access to Forgejo (avoids the @@ -32,7 +41,10 @@ jobs: steps: - name: Install build dependencies run: | - pacman -Syu --noconfirm archiso curl python git rust + # grub is required by profiledef.sh bootmodes=('uefi.grub'): + # mkarchiso validates grub-install on the *builder*, not the image. + # archiso pulls syslinux/squashfs-tools/libisoburn; it does not pull grub. + pacman -Syu --noconfirm archiso grub curl python git minisign - name: Determine tag and version id: vars @@ -50,72 +62,17 @@ jobs: git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos - - name: Download bakery ecosystem binaries + - name: Stage bakery ecosystem from signed stable index run: | set -euo pipefail - mkdir -p /build-home/.local/bin \ - /build-home/.local/state/bakery \ - /build-home/.cache/bakery + cd /bos + LAPTOP_HOME=/build-home python3 scripts/ci-stage-bakery.py - # Fetch the canonical bakery index - curl -fsSL "https://dl.breadway.dev/index.json" \ - -o /build-home/.cache/bakery/index.json - - # Download each binary from dl.breadway.dev (canonical source; github_url - # is not always published for dev/patch releases) and generate the - # installed.json that bakery expects in ~/.local/state. - python3 << 'PYEOF' - import json, urllib.request, os - - with open('/build-home/.cache/bakery/index.json') as f: - idx = json.load(f) - - BIN_DIR = '/build-home/.local/bin' - installed = {} - - for pkg_name, pkg in idx['packages'].items(): - bins = [] - for b in pkg['binaries']: - dest_name = b['name'].removesuffix('-x86_64') - dest = os.path.join(BIN_DIR, dest_name) - url = b['dl_url'] - print(f' {dest_name} <- {url}', flush=True) - urllib.request.urlretrieve(url, dest) - os.chmod(dest, 0o755) - bins.append(dest_name) - - # installed.json services field is a flat list of unit-name strings - services = [ - (s['unit'] if isinstance(s, dict) else s) - for s in pkg.get('services', []) - ] - installed[pkg_name] = { - 'name': pkg_name, - 'version': pkg['version'], - 'binaries': bins, - 'services': services, - 'installed_at': '2024-01-01T00:00:00+00:00', - } - - with open('/build-home/.local/state/bakery/installed.json', 'w') as f: - json.dump({'packages': installed}, f, indent=2) - print('installed.json written', flush=True) - PYEOF - - - name: Build bread-theme from source + - name: Verify staged bakery bake inputs run: | set -euo pipefail - # bread-theme is not in the bakery index; build it at the tag pinned - # in bos-settings/Cargo.toml so the CLI matches the library version. - THEME_TAG=$(grep 'bread-theme.*tag' /bos/bos-settings/Cargo.toml \ - | grep -oP '"v[^"]+"' | tr -d '"') - echo "Building bread-theme @ $THEME_TAG" - git clone --branch "$THEME_TAG" --depth 1 \ - https://github.com/Breadway/bread-ecosystem /bread-ecosystem - cd /bread-ecosystem - cargo build --release -p bread-theme - install -m 755 target/release/bread-theme /build-home/.local/bin/bread-theme - echo "bread-theme built OK" + cd /bos + LAPTOP_HOME=/build-home bash scripts/ci-verify-bake.sh - name: Build ISO run: | @@ -129,14 +86,35 @@ jobs: bash build-local.sh ls -lh /bos-out/*.iso - - name: Create Forgejo release and upload ISO + - name: Checksum and sign + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + run: | + set -euo pipefail + VERSION="${{ steps.vars.outputs.version }}" + ISO=$(ls /bos-out/*.iso | head -1) + ISO_NAME="bos-${VERSION}-x86_64.iso" + cd /bos-out + mv "$(basename "$ISO")" "$ISO_NAME" + + sha256sum "$ISO_NAME" > SHA256SUMS + cat SHA256SUMS + + pacman -S --noconfirm --needed gnupg + export GNUPGHOME=/tmp/gnupg-release + mkdir -m 700 -p "$GNUPGHOME" + echo "$GPG_PRIVATE_KEY" | gpg --batch --import + gpg --batch --yes --local-user releases@breadway.dev \ + --detach-sign --armor -o SHA256SUMS.asc SHA256SUMS + echo "Signed SHA256SUMS -> SHA256SUMS.asc" + + - name: Create Forgejo release and upload assets env: FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | set -euo pipefail TAG="${{ steps.vars.outputs.tag }}" VERSION="${{ steps.vars.outputs.version }}" - ISO=$(ls /bos-out/*.iso | head -1) ISO_NAME="bos-${VERSION}-x86_64.iso" # Use an existing release for this tag if one exists (e.g. created @@ -157,35 +135,41 @@ jobs: \"tag_name\": \"${TAG}\", \"name\": \"BOS ${TAG}\", \"prerelease\": false, - \"body\": \"ISO image attached below.\\n\\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.\" + \"body\": \"ISO image attached below. Verify with SHA256SUMS + SHA256SUMS.asc (signed by the BOS Release Signing key — see KEYS.asc in the repo).\\n\\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.\" }") RELEASE_ID=$(echo "${RELEASE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") fi echo "Using release ID: ${RELEASE_ID}" - # Remove any existing asset with the same name before uploading - ASSET_ID=$(curl -sf \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" \ - | python3 -c " + upload_asset() { + local file="$1" name + name="$(basename "$file")" + local asset_id + asset_id=$(curl -sf \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" \ + | python3 -c " import json,sys assets=json.load(sys.stdin) - match=[a['id'] for a in assets if a['name']=='${ISO_NAME}'] + match=[a['id'] for a in assets if a['name']=='${name}'] print(match[0] if match else '') " 2>/dev/null || true) - - if [ -n "${ASSET_ID}" ]; then - curl -fsS -X DELETE \ + if [ -n "${asset_id}" ]; then + curl -fsS -X DELETE \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets/${asset_id}" + echo "Removed existing ${name} asset" + fi + curl -fsS -X POST \ -H "Authorization: token ${FORGEJO_TOKEN}" \ - "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets/${ASSET_ID}" - echo "Removed existing ${ISO_NAME} asset" - fi + -F "attachment=@${file};filename=${name}" \ + "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" + echo "Uploaded: ${name}" + } - curl -fsS -X POST \ - -H "Authorization: token ${FORGEJO_TOKEN}" \ - -F "attachment=@${ISO};filename=${ISO_NAME}" \ - "http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" - echo "Uploaded: ${ISO_NAME}" + upload_asset "/bos-out/${ISO_NAME}" + upload_asset "/bos-out/SHA256SUMS" + upload_asset "/bos-out/SHA256SUMS.asc" - name: Create GitHub release env: @@ -196,12 +180,27 @@ jobs: VERSION="${{ steps.vars.outputs.version }}" FORGEJO_URL="https://git.breadway.dev/${GITHUB_REPOSITORY}/releases/tag/${TAG}" - printf '**Download ISO:** %s\n\nGitHub releases cannot host files >2 GB; the `bos-%s-x86_64.iso` (~2.5 GB) is on Forgejo.\n\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.' \ + printf '**Download ISO:** %s\n\nGitHub releases cannot host files >2 GB; the `bos-%s-x86_64.iso` (~2.5 GB), SHA256SUMS, and SHA256SUMS.asc (signed by the BOS Release Signing key — public half at [KEYS.asc](https://github.com/Breadway/bos/blob/main/KEYS.asc)) are all on Forgejo.\n\nSee the [README](https://github.com/Breadway/bos#testing-in-a-vm) for VM testing instructions.' \ "${FORGEJO_URL}" "${VERSION}" > /tmp/gh-release-notes.md gh release create "${TAG}" \ --repo "Breadway/bos" \ --title "BOS ${TAG}" \ - \ --notes-file /tmp/gh-release-notes.md \ - 2>/dev/null || echo "GitHub release already exists — skipping" + || echo "skip: GitHub release failed (MIRROR_TOKEN historically broken)" + + # `stable` is a marker branch only — CI fast-forwards it to whatever + # commit the latest real (non-RC) release tag points at. Never merged + # into by hand, so unlike the old dev/beta/main model it can't rot: + # nobody has to remember to move it, a bot always does. Lets you + # `git diff stable..main` before a build to see what's new since the + # last release, without a human-maintained promotion step. + - name: Fast-forward stable branch to this tag + if: ${{ !contains(steps.vars.outputs.tag, '-rc.') }} + env: + FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + set -euo pipefail + cd /bos + git push "https://oauth2:${FORGEJO_TOKEN}@git.breadway.dev/${GITHUB_REPOSITORY}.git" \ + "HEAD:refs/heads/stable" --force diff --git a/.forgejo/workflows/signed-repo.yml b/.forgejo/workflows/signed-repo.yml new file mode 100644 index 0000000..caa5d15 --- /dev/null +++ b/.forgejo/workflows/signed-repo.yml @@ -0,0 +1,53 @@ +name: Publish signed [breadway] repo + +# Host job on hestia (no container:) so it can write /srv/breadway-dl, same +# as bakery releases. breadlock package.yml uses archlinux:latest and cannot +# see host /srv — do not add container: here. +# +# Collects breadlock + the ISO AUR republishes from the Forgejo Arch +# registry, detach-signs each .pkg.tar.zst, repo-add -s, publishes +# https://dl.breadway.dev/arch/x86_64/. Does not PUT to the registry +# (existing packaging workflows keep doing that). Does not flip ISO SigLevel. +# +# Required secret: GPG_PRIVATE_KEY (same BOS release key as release-iso.yml). + +on: + workflow_dispatch: + repository_dispatch: + types: [publish-signed-repo] + workflow_run: + workflows: + - Build and publish calamares + - Build and publish bibata-cursor-theme + - Build and publish powerlevel10k + - Build and publish yay-bin + - Build and publish python-pywal + types: [completed] + +concurrency: + group: signed-repo + cancel-in-progress: false + +jobs: + publish: + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: [self-hosted, hestia] + steps: + - name: Clone repository + run: | + set -euo pipefail + REF="${GITHUB_REF_NAME:-main}" + rm -rf src + git clone --depth 1 --branch "$REF" \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src + + - name: Sign packages and publish repo + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + run: | + set -euo pipefail + if [ -z "${GPG_PRIVATE_KEY:-}" ]; then + echo "GPG_PRIVATE_KEY secret is missing; refusing to publish an unsigned [breadway] repo." >&2 + exit 1 + fi + bash src/scripts/ci-publish-signed-repo.sh diff --git a/.forgejo/workflows/yay-bin.yml b/.forgejo/workflows/yay-bin.yml new file mode 100644 index 0000000..c31ad58 --- /dev/null +++ b/.forgejo/workflows/yay-bin.yml @@ -0,0 +1,36 @@ +name: Build and publish yay-bin + +# yay (and every AUR helper) is AUR-only — not in Arch's official repos — so +# BOS maintains an in-house PKGBUILD and publishes the built package to the +# [breadway] repo, same as bibata-cursor-theme and calamares. Prebuilt +# release tarball, no build step. +on: + push: + paths: + - 'packaging/yay-bin/**' + workflow_dispatch: + +jobs: + yay-bin: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + steps: + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + pacman -Syu --noconfirm base-devel git + useradd -m builder + git config --global --add safe.directory '*' + git clone --depth 1 --branch "${GITHUB_REF_NAME}" \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src + chown -R builder:builder /home/builder/src + su builder -c "cd /home/builder/src/packaging/yay-bin && makepkg -f --noconfirm --nocheck" + PKG=$(find /home/builder/src/packaging/yay-bin -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.gitignore b/.gitignore index 25b3b3b..6e38d88 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,10 @@ logs/ # Wallpaper source drop (baked copy lives in airootfs/usr/share/backgrounds) /Bread Background.png + +# Local hygiene notes (not for commit) +CLAUDE.md + +# Python +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8019d5e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# AGENTS.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. Product shape is [README.md](README.md). + +## What this repo is + +ISO + Calamares + skel. **No Cargo workspace. No `bos-settings/` member.** +bos-settings (Tauri 2 + Svelte) and breadhelp are standalone bakery +products. breadlock is the only bread\* pacman package. + +Live skel is `iso/airootfs/etc/skel`. `dotfiles/` is stale. + +## Branch model + +Single-trunk: + +- `main` — integration + release trunk. Land work via short-lived + `feature/*` / `fix/*` branches, then merge. +- `stable` — marker only. CI fast-forwards it to the latest non-RC release + tag. Never merge into it by hand. + +There is no `dev` integration branch. + +## Remotes + +- `origin` — Forgejo (`ssh://git@100.66.238.26:2222/Breadway/bos.git`) — authoritative. +- `github` — GitHub (`https://github.com/Breadway/bos.git`) mirror. Push + origin (and github when mirroring). + +`origin` is **not** GitHub. + +## CI + +- `.forgejo/workflows/*.yml` trigger on `push: tags: ['v*']` (or + path-scoped packaging triggers) — ordinary pushes to `main` run nothing + except those path filters. Tag a release to build the ISO. +- `stable` is moved by the release-iso workflow, not by humans. +- No build/lint/test CI runs on ordinary commits or PRs — test locally + before merging to `main`. + +## Cleanup + +- Delete feature/fix branches (local + remote) once merged. Check with + `git branch --merged main`. +- Don't let merged branches accumulate. + +## Don't + +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't leave the default branch pointed at a feature branch on + GitHub/Forgejo. +- Don't bake an ISO (`sudo ./build-local.sh`) unless asked — lockfile/docs + work does not require it. +- Don't tell users to `snapper rollback` blindly; GRUB pins + `rootflags=subvol=@`. Recovery is grub-btrfs reboot. Bakery desktop + apps on BOS are system-prefix `/usr/local` (`/etc/bakery/config.toml`); + snapper `@` snapshots include them. Do not move those bits back to + `~/.local` on the image (hermes / default bakery stay user-layout). diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index c88efb6..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,1039 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" - -[[package]] -name = "bos-settings" -version = "0.4.1" -dependencies = [ - "async-channel", - "bread-theme", - "glib", - "gtk4", - "serde", - "serde_json", - "toml 0.8.23", - "toml_edit 0.22.27", -] - -[[package]] -name = "bread-theme" -version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b" -dependencies = [ - "dirs", - "gtk4", - "serde", - "serde_json", -] - -[[package]] -name = "cairo-rs" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" -dependencies = [ - "bitflags", - "cairo-sys-rs", - "glib", - "libc", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "cfg-expr" -version = "0.20.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-macro", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk4" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk4-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk4-sys" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "gio" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "pin-project-lite", - "smallvec", -] - -[[package]] -name = "gio-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "windows-sys 0.59.0", -] - -[[package]] -name = "glib" -version = "0.22.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" -dependencies = [ - "bitflags", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "smallvec", -] - -[[package]] -name = "glib-macros" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "glib-sys" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "gobject-sys" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "graphene-rs" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" -dependencies = [ - "glib", - "graphene-sys", - "libc", -] - -[[package]] -name = "graphene-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" -dependencies = [ - "glib-sys", - "libc", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gsk4" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" -dependencies = [ - "cairo-rs", - "gdk4", - "glib", - "graphene-rs", - "gsk4-sys", - "libc", - "pango", -] - -[[package]] -name = "gsk4-sys" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" -dependencies = [ - "cairo-sys-rs", - "gdk4-sys", - "glib-sys", - "gobject-sys", - "graphene-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk4" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" -dependencies = [ - "cairo-rs", - "field-offset", - "futures-channel", - "gdk-pixbuf", - "gdk4", - "gio", - "glib", - "graphene-rs", - "gsk4", - "gtk4-macros", - "gtk4-sys", - "libc", - "pango", -] - -[[package]] -name = "gtk4-macros" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "gtk4-sys" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "graphene-sys", - "gsk4-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libredox" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" -dependencies = [ - "libc", -] - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "pango" -version = "0.22.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" -dependencies = [ - "gio", - "glib", - "libc", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom", - "libredox", - "thiserror", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "system-deps" -version = "7.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" -dependencies = [ - "cfg-expr", - "heck", - "pkg-config", - "toml 1.1.2+spec-1.1.0", - "version-compare", -] - -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 1.0.3", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" -dependencies = [ - "indexmap", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.3", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.3", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" -dependencies = [ - "memchr", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index af9532d..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,3 +0,0 @@ -[workspace] -members = ["bos-settings"] -resolver = "2" diff --git a/DESIGN.md b/DESIGN.md index d6d6585..6a075a8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,4 +1,26 @@ -# BOS — Bread Operating System Plan +# BOS — historical design plan + +## Current architecture + +**Read [README.md](README.md) for how this repo actually ships.** This file +is the original plan. Several sections below are historical and must not be +taken as current: + +| Plan said | What the tree does now | +|-----------|------------------------| +| Cargo workspace with a `bos-settings/` member | This repo is ISO + Calamares + skel only. No Cargo workspace. | +| `bos-settings` as an in-tree GTK4 app | Standalone bakery product, **Tauri 2 + Svelte**. | +| bakery install in Calamares post-install | bakery binaries + breadhelp content are **baked into `/etc/skel` at ISO build time** from `iso/bread-lockfile.toml`. Missing bins fail the bake. | +| `dotfiles/` is the live skel | Live defaults are `iso/airootfs/etc/skel`. `dotfiles/` is stale. | +| A/B root swapping | **Future.** Today: btrfs + snapper + **grub-btrfs**. GRUB pins `rootflags=subvol=@`, so `snapper rollback` is not the user-facing recovery path. | +| Work on `dev`; origin = GitHub | Single-trunk `main`; `stable` is a CI marker. `origin` = Forgejo, `github` = GitHub. | +| `[breadway]` provides bakery/breadbar/bos-settings | `[breadway]` is breadlock + AUR republishes. Desktop apps are bakery. **Not shipped:** breadcast, breadarr. | +| NVIDIA / A/B / Secure Boot / LUKS2 | NVIDIA proprietary is **unsupported**. A/B root swapping is **not implemented**. Secure Boot is **Setup Mode only** (self-signed `sbctl`). Disk encryption is **LUKS1** because GRUB cannot unlock LUKS2+Argon2id. | +| `SigLevel = Required` on `[breadway]` | **Yes, as of the signed repo.** `[breadway]` points at `https://dl.breadway.dev/arch` where `scripts/ci-publish-signed-repo.sh` detach-signs every `.pkg.tar.zst` and the db with the BOS release key (`56203B86…`, `KEYS.asc`). That key is trusted in the pacman keyring at build time, on the live medium, and on the installed target. | + +--- + +# Original plan (kept for history) ## Context @@ -7,51 +29,25 @@ The bread ecosystem (bread, breadbar, breadbox, breadcrumbs, breadpad/breadman, Goals: - **Install and be done**: Calamares GUI installer → reboot → working Hyprland + full bread stack - **Rollback safety**: Btrfs subvolumes + snapper + snap-pac; every pacman transaction is snapshotted -- **Unified config**: `bos-settings` GTK4 app surfaces all app configs + snapshot management + bakery updates +- **Unified config**: `bos-settings` surfaces all app configs + snapshot management + bakery updates - **Future-compatible**: Btrfs layout is designed to allow A/B partition migration later (SteamOS model) --- ## Repo Structure -Single new repo: `Breadway/bos` — a Cargo workspace. +Single new repo: `Breadway/bos` — *planned as* a Cargo workspace. **That is +not what landed**; see Current architecture. ``` bos/ -├── Cargo.toml # Workspace (members: [bos-settings]) -├── bos-settings/ # GTK4 unified settings app -│ ├── Cargo.toml -│ └── src/ -│ ├── main.rs -│ ├── state.rs -│ ├── theme.rs -│ ├── ui/ -│ │ ├── window.rs # Sidebar + content shell (port breadman pattern) -│ │ ├── sidebar.rs -│ │ └── views/ -│ │ ├── bread.rs -│ │ ├── breadbar.rs -│ │ ├── breadbox.rs -│ │ ├── breadcrumbs.rs -│ │ ├── breadpad.rs -│ │ ├── snapshots.rs -│ │ ├── packages.rs -│ │ └── hyprland.rs -│ └── config/ -│ └── mod.rs # Per-app config loaders -├── iso/ # archiso profile +├── Cargo.toml # Workspace (members: [bos-settings]) — NOT in tree +├── bos-settings/ # planned GTK4 app — now its own bakery repo +├── iso/ # archiso profile (this is the repo) │ ├── profiledef.sh -│ ├── packages.x86_64 # Live ISO + installed system package list -│ ├── airootfs/ # Files overlaid onto live ISO root -│ │ └── etc/ -│ │ ├── calamares/ # Calamares YAML configuration -│ │ └── skel/ # Default user dotfiles -└── dotfiles/ # Default configs deployed at install time - ├── hyprland/ # hyprland.conf, keybinds, autostart - ├── bread/ # breadd.toml, init.lua, devices.lua - ├── breadbar/ # (no config needed; zero-config by default) - ├── breadbox/ # config.toml with default context priorities - └── breadcrumbs/ # breadcrumbs.toml with default home profile +│ ├── packages.x86_64 +│ └── airootfs/ +└── dotfiles/ # planned install-time configs — NOT the live skel ``` --- @@ -70,7 +66,11 @@ bos/ Mount options: `noatime,compress=zstd,space_cache=v2` on all subvolumes. -**A/B compatibility note:** The `@` subvolume is self-contained and can be swapped atomically — this is the design property needed for a future A/B upgrade path. The layout does not need to change to adopt it. +**A/B compatibility note (future):** The `@` subvolume is self-contained and +could be swapped atomically. This is a design property for a later upgrade +path. It is **not** implemented. Recovery today is reboot into a grub-btrfs +snapshot; GRUB's `rootflags=subvol=@` means a raw `snapper rollback` is the +wrong instruction to give users. ### Snapshot tooling (installed + configured during post-install) @@ -96,100 +96,79 @@ No user-facing CLI needed for this component — `bos-settings` is the interface ### archiso profile (`iso/`) - Derives from `/usr/share/archiso/configs/releng/` (the standard baseline) -- `packages.x86_64` includes: base, linux, grub, btrfs-progs, snapper, snap-pac, grub-btrfs, hyprland, pipewire, wireplumber, networkmanager, gtk4, gtk4-layer-shell, iw, librsvg, libpulse, bluez, bluez-utils, calamares, calamares-qt6 -- `airootfs/etc/skel/` contains the default dotfiles (symlinked from `dotfiles/`) +- `packages.x86_64` is the live + installed pacman set (Hyprland, Calamares, + breadlock, WebKitGTK 4.1 for Tauri bos-settings, …). bakery apps are not + listed here. +- `airootfs/etc/skel/` contains the default user configs (this is the live + skel — not `dotfiles/`). - Live session autologs into a `liveuser` and launches Calamares automatically ### Calamares modules (in order) +The historical list below included a post-install `bakery install` and +Calamares `bootloader`/`grubcfg` installing GRUB. What shipped instead: +binaries are already in skel; `post-install.sh` runs `grub-install` + +`grub-mkconfig` (Calamares' bootloader modules leave the ESP empty here). + 1. **welcome** — system checks (RAM ≥ 2GB, internet, disk space) 2. **locale** — timezone + locale selection 3. **keyboard** — layout selection 4. **partition** — custom `btrfs` mode: creates EFI partition + single btrfs pool with the subvolume layout above 5. **users** — create main user, set password 6. **packages** — install package list (reuses `packages.x86_64`) -7. **bootloader** — install GRUB to EFI, `grub-mkconfig` with grub-btrfs hook -8. **shellprocess (post-install)** — runs `iso/post-install.sh`: - - Configures snapper root config - - Enables services: `NetworkManager`, `bluetooth`, `breadd` (user), `breadbox-sync` (user) - - Runs `bakery install bread breadbar breadbox breadcrumbs breadpad` (or `bakery install --all`) - - Copies `dotfiles/` into `/home/$USER/.config/` (skips any file that already exists) +7. **bootloader** — *planned*; actual GRUB install is in `post-install.sh` +8. **shellprocess (post-install)** — snapper, services, copy skel; does **not** run bakery 9. **finished** — reboot prompt --- -## Component 3: `bos-settings` GTK4 App +## Component 3: `bos-settings` (planned as GTK4) + +### Tech choices (original) -### Tech choices - **gtk4-rs** (v0.11, v4_12 feature), no relm4 — plain GTK4 following breadman's pattern -- **bread-theme** for palette + CSS (git dep: `github.com/Breadway/bread-ecosystem`) -- Reads/writes each tool's own config file directly (no unified intermediate config) -- Window: 960×640, sidebar 190px, `gtk4::Stack` for view switching — identical structure to breadman + +**What shipped:** Tauri 2 + Svelte in its own repo +(`git.breadway.dev/Breadway/bos-settings`), distributed by bakery. This +repo does not build it. ### Sidebar sections + views -| Section | View | What it does | -|---------|------|--------------| -| **Apps** | bread | Edit `~/.config/bread/breadd.toml` | -| | breadbar | Edit `~/.config/breadbar/` (style.css override, no TOML needed) | -| | breadbox | Edit `~/.config/breadbox/config.toml` (context priority lists) | -| | breadcrumbs | Edit `~/.config/breadcrumbs/breadcrumbs.toml` (profiles, networks) | -| | breadpad | Edit `~/.config/breadpad/breadpad.toml` (model, reminders, calendar) | -| **System** | Snapshots | `snapper list` output; rollback button calls `snapper rollback N` | -| | Packages | `bakery list --installed`; update buttons call `bakery update ` | -| | Hyprland | "Open config in editor" + monitor list from `bread.state.monitors()` | - -### Config loading pattern - -Each view has a dedicated `load_config(path) -> Result` and `save_config(path, T) -> Result<()>` using `toml` crate. Config structs mirror each app's existing types (no duplication — import the `*-shared` crate where it exists, e.g. `breadpad-shared`). For apps without a shared crate (breadbox, breadcrumbs), define minimal local structs. - -### Snapshots view specifics - -- On open: runs `snapper list --output-cols number,date,description,pre-post` via `std::process::Command`, parses into table rows -- Rollback: confirmation dialog → `snapper rollback ` → notify user to reboot -- Delete: `snapper delete ` -- No write access to `/` needed for list/rollback since snapper is configured with `ALLOW_USERS` for the main user - -### Packages view specifics - -- On open: reads `~/.local/state/bakery/installed.json` directly (no network) -- "Check for updates": runs `bakery list` (triggers index refresh), compares versions -- "Update all": runs `bakery update --all` in a subprocess, streams stdout to a log TextView +The panel list is still roughly accurate; see README. Snapshots recovery +should send users through **grub-btrfs reboot**, not `snapper rollback N`. ### Distribution -`bos-settings` gets a `bakery.toml` and is added to the `bread-ecosystem` registry — installable standalone on any Arch/Hyprland system via `bakery install bos-settings`, not only as part of a BOS install. +`bos-settings` has its own `bakery.toml` and is installable via +`bakery install bos-settings` on any Arch/Hyprland system, not only as part +of a BOS install. --- ## Component 4: Default Dotfiles -Minimal but functional defaults deployed at install time. These are opinionated starting points, not locked configs — users edit freely after install. +Minimal but functional defaults. These live in `iso/airootfs/etc/skel` +(`hyprland.lua` + JSON binds, not `dotfiles/hyprland/*.conf`). -| File | Key content | -|------|-------------| -| `dotfiles/hyprland/hyprland.conf` | Monitor auto-detect, default keybinds, `exec-once` for breadd/breadbar/breadbox-sync | -| `dotfiles/hyprland/keybinds.conf` | `$mod+Space` → breadbox, `$mod+N` → breadpad, `$mod+M` → breadman, `$mod+S` → bos-settings | -| `dotfiles/bread/breadd.toml` | All adapters enabled, log_level=info | -| `dotfiles/bread/init.lua` | Minimal: activates "default" profile on startup | -| `dotfiles/breadbox/config.toml` | Single default context with common apps | -| `dotfiles/breadcrumbs/breadcrumbs.toml` | Placeholder home profile (user fills in SSIDs) | +Zero-config bakery apps survive with no extra skel files. breadcrumbs +networks are user-filled after install — do not invent a full +`breadcrumbs.toml` in-tree. --- ## Build Order -1. **Dotfiles** — write default configs; these unblock installer testing immediately -2. **Btrfs + snapper config** — write `post-install.sh`; test in a VM with `archiso` livecdbase -3. **ISO profile** — archiso profiledef + package list + Calamares YAML; iterate in a VM -4. **bos-settings** — start with Snapshots and Packages views (highest value, no app-specific config parsing needed), then add per-app views one at a time +Historical. The ISO profile + skel + Calamares path is what this repo +iterates on. bos-settings is developed in its own repo. --- ## Verification -- **ISO**: Build with `mkarchiso -v -w /tmp/bos-work -o /tmp/bos-out iso/`; boot in QEMU (`qemu-system-x86_64 -cdrom bos.iso -m 4G -enable-kvm`); complete install; reboot into installed system; confirm all services running and bakery packages present +- **ISO**: `sudo ./build-local.sh` (not a raw `mkarchiso iso/` — the bake + step is required). Boot in QEMU; complete install; confirm bakery bins and + `~/.local/share/breadhelp/content`. - **btrfs layout**: `btrfs subvolume list /` after install; confirm `@`, `@home`, `@snapshots`, `@log`, `@cache` exist - **snapper**: `snapper list`; run `pacman -Syu` and confirm two new snapshots appear - **grub-btrfs**: Reboot and confirm snapshot submenu in GRUB -- **bos-settings**: `cargo build --release`; launch; confirm each view loads its config file; edit a value, save, re-open and confirm persistence; test rollback button in Snapshots view +- **bos-settings**: built and tested in the bos-settings repo, not here diff --git a/KEYS.asc b/KEYS.asc new file mode 100644 index 0000000..fe380fd --- /dev/null +++ b/KEYS.asc @@ -0,0 +1,15 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEakhwGhYJKwYBBAHaRw8BAQdA/sZ/GYec5M2MD+w20mVF5tMUhGji210Dg7zL +TAhNsg60WUJPUyBSZWxlYXNlIFNpZ25pbmcgKGdpdC5icmVhZHdheS5kZXYvQnJl +YWR3YXkvYm9zIHJlbGVhc2VzIG9ubHkpIDxyZWxlYXNlc0BicmVhZHdheS5kZXY+ +iJYEExYKAD4WIQRWIDuGoRBpWufzEJNK8zI9Z4614gUCakhwGgIbIwUJA8JnAAUL +CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBK8zI9Z4614ggYAQDP8FTZ14i9YPKD +ARvZuP5QaYOUFhQ8uyG0CowXKy9O0AEAqYfjnvyJI3N651pVFSNUXyP16w1kMPSs +K0g3CLsztQ+4OARqSHAaEgorBgEEAZdVAQUBAQdAuJFuy2GHz5m9wXTm/PdSpLE9 +gERwHOLyM1OFuttrJW4DAQgHiH4EGBYKACYWIQRWIDuGoRBpWufzEJNK8zI9Z461 +4gUCakhwGgIbDAUJA8JnAAAKCRBK8zI9Z4614nzLAP9grcIFsAAeCyVKhziHmpXq +E0Hm6FfIr4sdEf63HZkyfwD/XeKeWfb3EWvVsloJrZZ9tDmR67iK52Hwl82wfFAU +cAo= +=Mrh1 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/README.md b/README.md index 930804c..22b0108 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,142 @@ # BOS — Bread Operating System An Arch-based, Hyprland desktop distribution that ships the [bread -ecosystem](https://github.com/Breadway) preconfigured. One Calamares install +ecosystem](https://git.breadway.dev/Breadway) preconfigured. One Calamares install produces a themed, bootable Wayland desktop — no manual Arch bootstrap, no wiring up dotfiles, no per-tool bakery installs. -> Design rationale and the btrfs/A-B roadmap live in [DESIGN.md](DESIGN.md). -> This file is the practical overview: what's in the image, how to build it, -> and how to test it. +> This file is the product as the tree ships it. [DESIGN.md](DESIGN.md) is the +> original plan, kept as history — several of its sections (in-tree GTK +> bos-settings, bakery-at-post-install, A/B as if it were current) are not +> how the ISO works today. ## What you get - **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated keybinds, snappy animations, blur, and pywal-driven colours on a black base. -- **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries - (no network needed at install time): `bread`/`breadd`, `breadbar` (status bar - + notification daemon), `breadbox` (launcher), `breadcrumbs` (Wi-Fi profiles), - `breadpad` (notes/reminders), `breadman`, and the `bakery` package manager. -- **bos-settings**: a GTK4 control panel that configures every bread\* app's - config from a GUI (non-destructively), plus snapshot rollback and bakery - updates. See below. -- **Login**: greetd + tuigreet → Hyprland session. +- **bread ecosystem**, baked into `/usr/local` from bakery-managed binaries + (no network needed at install time; per-user bakery state is seeded in + `/etc/skel`): the `bread`/`breadd` automation daemon + (`bread-emit` / `bread-module-host` when the stable bread release publishes + them), `breadbar` (status bar + notifications), `breadbox` (launcher), + `breadclip` (clipboard history), `breadcrumbs` (Wi-Fi profiles), + `breadpad`/`breadman` (notes), `breadpaper` (wallpaper + theme), + `breadsearch` (system search), `breadmon` (monitor layout TUI), + `breadshot` (screenshots), `bread-theme` (the shared palette engine), + `breadhelp` (onboarding + cheatsheet), `bos-settings` (control panel), + and the `bakery` package manager. Most of those apps are zero-config on + first boot; breadcrumbs networks are user-filled after install. See + [below](#the-bread-ecosystem). +- **breadlock** (lock screen + greeter) is the one bread\* app that ships as + **pacman**, not bakery — it needs a root-owned PAM service. +- **bos-settings**: a **Tauri 2 + Svelte** control panel (standalone bakery + product, not a member of this repo). Configures every bread\* app + non-destructively, plus snapshots, bakery/pacman updates, and day-to-day + machine administration. +- **Login**: greetd + breadgreet (under `cage`) → Hyprland session. - **Boot splash**: Plymouth `bos` theme (logo + spinner, black background). - **Theming**: global dark across GTK3 (Adwaita-dark), GTK4/libadwaita (`color-scheme: prefer-dark`), and Qt (qt5ct/qt6ct Fusion dark); Papirus-Dark icons; Bibata cursor. - **Apps**: kitty, nautilus (+ gvfs), Zen browser, VLC, loupe, gnome-text-editor, gnome-calculator, file-roller, with file associations wired in `mimeapps.list`. + `yay` ships for AUR access beyond bakery + `[breadway]`. - **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing with avahi mDNS discovery, TLP power management, fwupd firmware updates. + Mesa only — **NVIDIA proprietary drivers are not included** and NVIDIA is + unsupported out of the box (see [docs/hardware.md](docs/hardware.md)). - **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every - pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed). + pacman transaction (**root `@` only** — snapper does not cover `@home`); + home backup is **Settings → Backup** (restic, local path or SFTP); zram + swap; ufw firewall (deny-incoming, mDNS allowed). A/B root swapping is + **not** implemented. Recovery is a grub-btrfs reboot, not + `snapper rollback` (GRUB pins `rootflags=subvol=@`). See + [docs/hardware.md](docs/hardware.md). +- **Security**: optional full-disk encryption is **LUKS1** (GRUB cannot unlock + LUKS2 + Argon2id). Secure Boot is **self-signed Setup Mode only** via + `sbctl` — not a Microsoft-signed shim; enrollment is skipped unless the + firmware is already in Setup Mode. + +## What ships vs what does not + +| Channel | What | +|---------|------| +| **Bakery, required** | `bakery`, `bread` / `breadd`, `breadbar`, `breadbox` / `breadbox-sync`, `breadcrumbs`, `breadpad` / `breadman`, `breadpaper`, `bread-theme`, `breadmon`, `breadsearch` / `breadmill`, `breadclip` / `breadclipd`, `breadshot`, `bos-settings`, `breadhelp` (+ breadhelp content under `/usr/local/share/breadhelp/`) | +| **Bakery, optional** | `bread-emit`, `bread-module-host` — baked when the verified stable index publishes them; skipped (not a failed bake) until bread ships them | +| **pacman (`packages.x86_64`)** | `breadlock`, plus the rest of the distro (Hyprland, Calamares, Zen, …) | +| **Not shipped** | `breadcast`, `breadarr` | + +The baked name list is [`iso/bread-lockfile.toml`](iso/bread-lockfile.toml) +(plus optional `[versions]` / `[[pin]]` so CI fetches +`https://dl.breadway.dev///...`). `build-local.sh` fails if any +**required** binary is missing on the builder. ## Repo layout +This is an **ISO + Calamares + skel** repo. There is no Cargo workspace and +no `bos-settings/` member — bos-settings and breadhelp live in their own +repos and arrive via bakery. + ``` bos/ -├── Cargo.toml # workspace (members: bos-settings) -├── bos-settings/ # GTK4 unified settings app (Rust) -│ └── src/ -│ ├── config/mod.rs # non-destructive toml_edit config layer -│ └── ui/{widgets,window,sidebar}.rs, ui/views/*.rs ├── iso/ # archiso profile +│ ├── bread-lockfile.toml # bakery bins + optional version pins │ ├── profiledef.sh -│ ├── packages.x86_64 # live + installed package set +│ ├── packages.x86_64 # live + installed pacman set │ └── airootfs/ # files overlaid onto the image │ └── etc/ -│ ├── skel/ # default user dotfiles (hypr, kitty, gtk, …) +│ ├── skel/ # live user defaults (hypr, kitty, gtk, …) │ └── calamares/ # installer config + post-install.sh ├── packaging/ # in-house PKGBUILDs for AUR-only deps -│ ├── arch/ # bos-settings │ ├── calamares/ -│ └── bibata/ -├── .forgejo/workflows/ # CI: build + publish packages to [breadway] +│ ├── bibata/ +│ ├── powerlevel10k/ +│ └── yay-bin/ +├── dotfiles/ # STALE — not the live skel; see its README +├── scripts/ +│ ├── ci-stage-bakery.py # CI: minisign-verified index → $LAPTOP_HOME +│ ├── ci-verify-bake.sh # CI: read-only checks before mkarchiso +│ ├── ci-publish-signed-repo.sh # CI: signed [breadway] repo → /srv/breadway-dl/arch +│ └── smoke-test.sh +├── docs/ +│ ├── hardware.md # Mesa only, NVIDIA, grub-btrfs recovery +│ └── signed-repo.md # dl.breadway.dev/arch signing +├── .forgejo/workflows/ # CI: AUR republish + signed repo + tagged ISO ├── build-local.sh # native ISO build for this machine -└── DESIGN.md +├── README.md +└── DESIGN.md # historical plan ``` +Live binds are `iso/airootfs/etc/skel/.config/hypr/binds.json` (`Super+L` → +`loginctl lock-session`, breadshot on `Super+Shift+S/C/P`, `Super+U` +breadpad). Do not treat `dotfiles/hypr/keybinds.conf` as current. + +## Branches and remotes + +Single-trunk: work on **`main`** via short-lived `feature/*` / `fix/*` +branches. **`stable`** is a marker branch CI fast-forwards to the latest +non-RC release tag — do not land work there by hand. + +Dual remotes: + +- **`origin`** — Forgejo (`ssh://git@100.66.238.26:2222/Breadway/bos.git`), + authoritative +- **`github`** — GitHub (`https://github.com/Breadway/bos.git`) mirror + +Push `origin` (and `github` when mirroring). Do not treat origin as GitHub. + ## Building the ISO -`build-local.sh` builds the image natively (no container) and bakes this -machine's bakery-installed bread binaries into `/etc/skel`: +`build-local.sh` builds the image natively (no container) and copies this +machine's bakery-installed bread binaries + breadhelp content from the +builder's `~/.local` into the image at `/usr/local` (bins, share/data, +desktop files, licenses) and `/usr/lib/systemd/user` (units). Per-user +bakery state (`installed.json` + index cache) is seeded in `/etc/skel`. +User units are `systemctl --global enable`'d so a later `useradd -m` +starts them on first login. BOS opts in via `/etc/bakery/config.toml` +(`prefix = "/usr/local"`); default bakery without that file is still +`~/.local`. Snapper `@` snapshots include `/usr/local`; recovery is +still grub-btrfs, not `snapper rollback`. ```sh sudo ./build-local.sh # release-quality (xz squashfs) @@ -68,16 +144,47 @@ sudo FAST_BUILD=1 ./build-local.sh # fast dev iteration (zstd squashfs) ``` The ISO lands in `out/bos--x86_64.iso`. The script pins -`SOURCE_DATE_EPOCH` (reproducible UUIDs) and rewrites the `[breadway]` repo URL -to the Tailscale-reachable Forgejo registry for the build. +`SOURCE_DATE_EPOCH` (reproducible UUIDs), rewrites the `[breadway]` repo URL +to the Tailscale-reachable Forgejo registry for the build, and **exits +non-zero** if any **required** lockfile binary (or breadhelp content) is +missing. Optional bins are skipped with a warning. + +CI stages the builder from the **minisign-verified** stable bakery index +(`index.json` + `index.json.minisig`) and prefers lockfile `[versions]` +URLs (`https://dl.breadway.dev///...`) when set, so two bakes +of the same commit fetch the same bits. Local builds still snapshot the +builder. ### Why some packages are in-house -`calamares`, `zen-browser-bin`, and `bibata-cursor-theme` are AUR-only. BOS -keeps a PKGBUILD for each under `packaging/` and republishes the built package -to the `[breadway]` repo via a Forgejo Actions workflow (built on the hestia -self-hosted runner, published with a scoped registry token). `bos-settings` -itself publishes the same way on a `v*` tag. +`calamares`, `zen-browser-bin`, `bibata-cursor-theme`, and `yay-bin` are +AUR-only. BOS keeps a PKGBUILD for each under `packaging/` and republishes +the built package to the `[breadway]` repo via a Forgejo Actions workflow +(built on the hestia self-hosted runner, published with a scoped registry +token). `[breadway]` is **not** where bakery/breadbar/bos-settings live. + +### Verifying a release + +Every tagged release ISO on the [Forgejo releases +page](https://git.breadway.dev/Breadway/bos/releases) ships alongside a +`SHA256SUMS` file and a detached signature `SHA256SUMS.asc`, signed by a +dedicated release-signing key (not reused from anything else): + +``` +5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2 +``` + +The public half is committed at [`KEYS.asc`](KEYS.asc). The same key signs +the ISO checksums **and** the `[breadway]` pacman repo — every package and +the db at `https://dl.breadway.dev/arch` carry a `.sig` from it, and that +section is `SigLevel = Required` (see +[docs/signed-repo.md](docs/signed-repo.md)). To verify a download: + +```sh +gpg --import KEYS.asc +gpg --verify SHA256SUMS.asc SHA256SUMS +sha256sum -c SHA256SUMS +``` ## Testing in a VM @@ -93,43 +200,123 @@ It uses KVM + `-cpu host`, 8 GiB / 8 vCPU, and `virtio-vga-gl` with Hyprland session in QEMU. The disk lives on NVMe (not the tmpfs `/tmp`) to avoid memory pressure. -## bos-settings +Post-install, `scripts/smoke-test.sh` (run as the installed user) checks +subvolumes, services, bakery bins on PATH, breadhelp content under +`/usr/local/share/breadhelp/content`, and that bakery user units are +`--global` enabled (or the preset / wants files exist). -`bos-settings` edits each bread\* app's TOML **non-destructively**: it parses -the file with `toml_edit`, changes only the keys a view exposes, and writes it -back — preserving comments and any keys the UI doesn't model (calendar -passwords, saved-network passwords, model paths). Views: +## Second account -| View | Config | -|------|--------| -| bread | `bread/breadd.toml` — daemon, lua, modules, all adapters, events, notifications | -| breadbar | `breadbar/style.css` override | -| breadbox | `breadbox/config.toml` — launcher contexts | -| breadcrumbs | `breadcrumbs/breadcrumbs.toml` — settings, saved networks, profiles | -| breadpad | `breadpad/breadpad.toml` — settings, model + ollama, reminders, calendar | -| Snapshots | `snapper` list / rollback / delete | -| Packages | `bakery` installed list + updates | -| Hyprland | open config in editor + monitor list | +Bakery desktop apps live in `/usr/local` — shared, already on PATH. A later +account does **not** get a private copy of those binaries. -Build standalone: +`/etc/default/useradd` keeps `SKEL=/etc/skel`. Stock `useradd -m` is enough: ```sh -cargo build --release -p bos-settings -cargo test -p bos-settings # includes config round-trip tests +sudo useradd -m alice +sudo passwd alice ``` -## The bread ecosystem at a glance +- **Apps**: `/usr/local/bin` (and `/usr/local/share`) — already there. +- **Session files**: `useradd -m` copies `/etc/skel` (Hyprland, bread + config, bakery `installed.json` + index cache) so first login has a + session. Skel does not contain bakery binaries. +- **Daemons**: `breadd`, `breadbox-sync`, `breadclipd`, `breadcrumbs`, + `breadmill`, … are `systemctl --global enable`'d at install (and on + the live image). Creating a user starts them on first login. +- **Login**: greetd/breadgreet lists any local user with a login shell + (`SHELL=/usr/bin/zsh` is the useradd default). + +`breadclipd` is WantedBy=`graphical-session.target`. BOS does not activate +that target (no uwsm), so Hyprland still `systemctl --user start`s it after +the compositor is up. `--global enable` still records it for every account. + +Rollback is still the GRUB snapshots submenu (grub-btrfs), not +`snapper rollback`. `/usr/local` rides the `@` snapshot. + +## bos-settings + +Standalone bakery product: **Tauri 2 + Svelte**, not GTK4, and not built +from this repo. Install/update with `bakery`; the ISO just bakes whatever +binary the builder has. + +It aims for GNOME-Settings-style parity: live system state and control, so +day-to-day administration doesn't require a terminal. Bread-ecosystem +configs are edited **non-destructively** (comments and unmodeled keys stay). +Panels with a daemon (bread, breadbox, breadcrumbs, breadsearch, breadclip) +also get live systemd status + Start/Stop/Restart/Logs. + +| Panel | What it does | +|-------|--------------| +| About | System info (OS/kernel/CPU/GPU/memory/disk/uptime) + hostname | +| Network | Wi-Fi scan/connect, Ethernet status, radio toggle | +| Wi-Fi Profiles (breadcrumbs) | `breadcrumbs.toml` — settings, saved networks, profiles | +| Firewall | ufw rules: enable/disable, add/remove, view active rules | +| Sound | PipeWire output/input device + volume via `pactl` | +| Power | Battery status/health, brightness, charge limits (hardware-dependent), TLP profile (read-only) | +| Date & Time | Timezone, NTP sync toggle | +| Display (Hyprland) | Connected monitors + open `hyprland.lua` in editor | +| Users | Add/remove accounts, change passwords | +| Wallpaper (breadpaper) | Set wallpaper, drives the pywal-derived accent palette | +| Bar (breadbar) | `breadbar/style.css` override, live-reloads on save | +| Launcher (breadbox) | `breadbox/config.toml` — launcher contexts | +| Clipboard (breadclip) | breadclipd service control + "open history" | +| Notes (breadpad) | `breadpad/breadpad.toml` — settings, model + ollama, reminders, calendar | +| File Search (breadsearch) | `breadsearch/config.toml` — index/search/model + breadmill service | +| Daemon (bread) | `breadd.toml` — daemon, lua, modules, adapters, events, notifications | +| Packages | `bakery` installed list + updates, pacman system update | +| AUR | Search via `yay`; installing opens a terminal (AUR build scripts need review) | +| Firmware | `fwupd` device list + updates | +| Snapshots | `snapper` list (number / date / description); reboot to pick in GRUB (grub-btrfs); delete — **root (`@`) only** | +| Backup | restic of `$HOME` (`@home`) via Settings → Backup; snapper does not cover home | + +Source and build live in the [bos-settings](https://git.breadway.dev/Breadway/bos-settings) +repo, not here. + +## The bread ecosystem + +Everything below is a separate bakery-distributed project with its own repo +and release cadence, baked into `/usr/local` at ISO build time so a fresh +install has them all with no network round-trip. Some ship more than one +binary from a single package — that's noted where it applies. Most have a +corresponding **bos-settings** panel; this table is about *using* the app +directly. + +**Desktop shell** | Tool | Role | Launch | |------|------|--------| -| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor signals into events dispatched to Lua modules | runs at login | -| `breadbar` | Top status bar (workspaces, clock, stats, tray) **and** the notification daemon | runs at login | -| `breadbox` | Application launcher | `SUPER+Space` | -| `breadpad` | Notes & reminders (AI-classified, optional CalDAV sync) | `SUPER+U` | -| `breadman` | Package-manager UI | `SUPER+M` | -| `breadcrumbs` | Wi-Fi profile state machine (location-aware) | CLI / BOS Settings | -| `bakery` | CLI package manager for the ecosystem | `bakery` | -| `bos-settings` | Unified GTK4 control panel for all of the above + snapshots + updates | `SUPER+,` | +| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). `bread-emit` is the fire-and-forget helper hooks/CLIs use; `bread-module-host` is the sandboxed out-of-process module runtime breadd spawns. Both extra bins are **optional** on the ISO until a stable bread release publishes them. | runs at login (`breadd.service`) | +| `breadbar` | Top status bar: workspaces, clock, system stats, tray, **and** the notification daemon — one process, not two | runs at login | +| `breadbox` | Application launcher (fuzzy search, per-context results via `breadbox-sync`) | `SUPER+Space` | +| `breadlock` | Idle lock screen. Also provides `breadgreet`, the login greeter hosted under `cage` via greetd — same project, two binaries, one visual identity from login to lock. **pacman**, not bakery. | `SUPER+L` (via `loginctl lock-session`, picked up by `hypridle`); `breadgreet` runs automatically at boot | +| `bread-theme` | The shared palette engine every bread app renders through: fixed dark base colors, with only the accent slots following the current wallpaper's pywal palette. `bread-theme generate` regenerates the stylesheet; hyprland.lua calls it automatically on wallpaper change. | invoked automatically, rarely run by hand | + +**Productivity** + +| Tool | Role | Launch | +|------|------|--------| +| `breadpad` | Quick-capture scratchpad/notes popup with AI classification and optional CalDAV calendar sync | `SUPER+U` | +| `breadman` | The fuller notes manager view (browse/organize) — ships from the same `breadpad` package as a second binary | `SUPER+M` | +| `breadclip` | Clipboard history. `breadclipd` is the background daemon that actually records history; `breadclip` is the GTK4 popup that browses it | `SUPER+V` / `SUPER+Shift+V` | +| `breadsearch` | Semantic system-wide search (indexes files/notes, embeds locally — CPU/ROCm/CUDA backend configurable). `breadmill` is its indexing daemon. | via breadbox, or BOS Settings → File Search | +| `breadhelp` | Onboarding + in-session help/cheatsheet. Content lives at `/usr/local/share/breadhelp/content` (bakery `content.tar.gz`, baked into the image). | `SUPER+/` | + +**System** + +| Tool | Role | Launch | +|------|------|--------| +| `breadcrumbs` | Location-aware Wi-Fi profile state machine, with optional Tailscale integration — switches network behavior based on which saved network you're on | CLI, or BOS Settings → Wi-Fi Profiles | +| `breadpaper` | Wallpaper manager — sets the wallpaper via `awww`, generates the pywal accent palette from it, and reloads every bread-theme app | BOS Settings → Wallpaper | +| `breadmon` | TUI monitor layout manager (resolution/position/scaling) — the interactive counterpart to BOS Settings' read-only Display panel | `breadmon` in a terminal | +| `breadshot` | Screenshot utility wrapping `grim`/`slurp`/`wl-copy` with Hyprland-aware geometry (multi-monitor-safe region select) | `breadshot`, or `SUPER+Shift+S/C/P` | + +**Tooling** + +| Tool | Role | Launch | +|------|------|--------| +| `bakery` | CLI package manager for the whole ecosystem — install/update/list, tracks installed binaries + versions independently of pacman | `bakery` | +| `bos-settings` | Unified Tauri 2 + Svelte control panel: live system state + control (network, power, firewall, users, packages, firmware, AUR, snapshots) plus non-destructive config editing for every app above | `SUPER+,` | ## Keyboard shortcuts @@ -141,12 +328,16 @@ cheatsheet in-session; first boot shows a short welcome (once). | `SUPER+Return` | Terminal (kitty) | | `SUPER+Space` | App launcher (breadbox) | | `SUPER+E` / `SUPER+B` | Files (nautilus) / Browser (zen) | -| `SUPER+U` / `SUPER+M` | breadpad / breadman | +| `SUPER+U` / `SUPER+M` | Notes (breadpad) / notes manager (breadman) | | `SUPER+,` / `SUPER+/` | BOS Settings / keybind cheatsheet | | `SUPER+L` / `SUPER+N` | Lock / log out | | `SUPER+Backspace` | Close window | -| `SUPER+F` / `SUPER+V` / `SUPER+T` | Fullscreen / float / toggle split | -| `SUPER+Shift+V` | Clipboard history | +| `SUPER+F` | Fullscreen | +| `SUPER+I` | Toggle floating | +| `SUPER+P` | Toggle pseudotile | +| `SUPER+R` | Resize mode | +| `SUPER+T` | Toggle split direction | +| `SUPER+V` / `SUPER+Shift+V` | Clipboard history (breadclip) | | `SUPER+Tab` | Last window | | `SUPER+Shift+S/C/P` | Screenshot region→file / region→clipboard / screen→file | | `SUPER+arrows` | Move focus | @@ -155,41 +346,75 @@ cheatsheet in-session; first boot shows a short welcome (once). | `SUPER+1..0` | Switch to workspace 1–10 | | `SUPER+Shift+1..0` | Move window to workspace | | `SUPER+[ / ]` | Previous / next workspace | +| `SUPER+Shift+[ / ]` | Move window to previous / next workspace | +| `SUPER+scroll` | Cycle workspaces | | `SUPER+left/right-drag` | Move / resize window with the mouse | +| Volume / brightness / play-pause / next / prev | Media keys — work even on the lock screen | +| Calculator key | Opens gnome-calculator | ## Known limitations +See [docs/hardware.md](docs/hardware.md) (GPUs, NVIDIA, recovery) and +[docs/signed-repo.md](docs/signed-repo.md) (`[breadway]` stays unsigned +until `dl.breadway.dev/arch` exists). + - **GPUs**: ships the generic Mesa stack — AMD and Intel work out of the box. - The **NVIDIA proprietary driver is not included**; NVIDIA users must install - `nvidia`/`nvidia-utils` and set the usual Hyprland env vars after install. + NVIDIA is **unsupported** (no proprietary driver, no NVIDIA firmware). See + [docs/hardware.md](docs/hardware.md). - **Virtual machines**: Hyprland needs GPU acceleration to be smooth. Use `virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is noticeably laggy. - **Wayland-first**: X11-only apps run through XWayland; a few may misbehave. -- **Secure Boot**: not configured. Boot with Secure Boot disabled, or enroll - your own keys. The installer writes both an NVRAM entry and the removable - `EFI/BOOT/BOOTX64.EFI` fallback. +- **Secure Boot**: self-signed only, via `sbctl` — BOS can't ship a + Microsoft-signed shim (that needs going through Microsoft's own paid UEFI + CA process). Post-install enrolls BOS's own keys automatically, but only + when the firmware is already in Setup Mode (no vendor keys installed yet); + otherwise it's skipped and you can run + `sudo sbctl enroll-keys --microsoft && sudo sbctl sign-all -g` yourself + later (after clearing your firmware's existing keys, if any). The installer + writes both an NVRAM entry and the removable `EFI/BOOT/BOOTX64.EFI` fallback + either way. +- **Disk encryption**: full-disk LUKS is available on the installer's "Erase + disk" page (Calamares' own checkbox) and on manually-created partitions — + BOS ships the matching `cryptsetup`/mkinitcpio/GRUB wiring so an encrypted + install actually boots (LUKS1, since GRUB doesn't support LUKS2 + Argon2id). - **Snapshots assume btrfs**: the snapper/grub-btrfs tooling expects the default - btrfs subvolume layout the installer creates. + btrfs subvolume layout the installer creates. Recovery is the GRUB + snapshots submenu, not `snapper rollback` — [docs/hardware.md](docs/hardware.md). +- **`[breadway]` signatures**: `SigLevel = Required` — the signed repo at + `dl.breadway.dev/arch` is live (db + every package `.sig`ned with the BOS + release key). See [docs/signed-repo.md](docs/signed-repo.md). ## Recovery -**An update broke something (system still boots):** open BOS Settings → -Snapshots and roll back, or pick a pre-update snapshot from the **GRUB -“snapshots” submenu** at boot, then run `snapper rollback` from the booted -snapshot. +**An update broke something (system still boots):** reboot → **GRUB +“snapshots” submenu** (grub-btrfs), then boot that entry. + +BOS Settings → Snapshots lists each snapshot’s number, date, and +description so you know which GRUB entry to pick. It does not roll the +running root back in place. Snapper is root only. Home files are +**Settings → Backup** (restic restore into `~/bos-restore-`, not +over `$HOME`). + +Do **not** run `snapper rollback`. BOS GRUB pins `rootflags=subvol=@`, so +a snapper-swapped default subvolume is not what the installed grub.cfg +will boot next. Details: [docs/hardware.md](docs/hardware.md). + +A/B root swapping (SteamOS-style) is a **future** idea in DESIGN.md — it is +not shipped. **The system won't boot (broken GRUB / lost EFI entry):** 1. Boot the BOS ISO and open a terminal (`SUPER+Return`). -2. Mount the installed root and EFI, then chroot: +2. Run `sudo bos-rescue`. It finds the installed btrfs `@` and the ESP, + prints the devices it will use, and asks `YES` before writing. It can + `arch-chroot` and/or reinstall GRUB with the same sequence the + installer uses (NVRAM + `--removable` + `grub-mkconfig`). +3. Manual equivalent, if you would rather type it: ```sh mount -o subvol=@ /dev/sdXN /mnt mount /dev/sdXP /mnt/boot/efi # the EFI partition arch-chroot /mnt - ``` -3. Reinstall the bootloader (the same sequence the installer uses): - ```sh grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=BOS --recheck grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck grub-mkconfig -o /boot/grub/grub.cfg diff --git a/bakery.toml b/bakery.toml deleted file mode 100644 index 632de76..0000000 --- a/bakery.toml +++ /dev/null @@ -1,12 +0,0 @@ -name = "bos-settings" -description = "System settings app for Bread OS" -binaries = ["bos-settings"] -system_deps = ["gtk4", "glib2"] -optional_system_deps = ["snapper"] -bread_deps = [] - -[config] -dir = "~/.config" - -[install] -post_install = [] diff --git a/bos-settings/Cargo.toml b/bos-settings/Cargo.toml deleted file mode 100644 index f024a43..0000000 --- a/bos-settings/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "bos-settings" -version = "0.4.1" -edition = "2021" - -[dependencies] -gtk4 = { version = "0.11", features = ["v4_12"] } -glib = "0.22" -# Shared ecosystem theming — bos-settings loads the same generated stylesheet as -# breadbar/breadbox/breadpad so the whole desktop looks consistent. -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "0.8" -# toml_edit drives non-destructive config editing: it preserves comments and -# any keys the UI doesn't model, so saving a single field never rewrites or -# drops the rest of the user's config file. -toml_edit = "0.22" -async-channel = "2" diff --git a/bos-settings/src/config/mod.rs b/bos-settings/src/config/mod.rs deleted file mode 100644 index 4cc3266..0000000 --- a/bos-settings/src/config/mod.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! Non-destructive config editing. -//! -//! Every bread* app owns a TOML config that may contain keys, sections, and -//! comments this settings app does not model (e.g. breadpad's calendar -//! credentials, breadcrumbs' saved-network passwords). To edit safely we parse -//! the file into a `toml_edit::DocumentMut`, mutate only the specific keys the -//! UI exposes, and write the document back — preserving everything else, -//! formatting and comments included. - -use std::error::Error; -use std::path::{Path, PathBuf}; - -use toml_edit::{value, Array, DocumentMut, Item, Table, Value}; - -/// Load a TOML file into an editable document. A missing file yields an -/// empty document so the UI still renders with defaults — normal for a fresh -/// install. A file that *exists* but fails to parse is far more dangerous: -/// falling back to an empty document there means the next Save (see -/// `save_doc`) overwrites it with only the UI-modelled keys, silently -/// destroying anything else in the file (breadpad's calendar credentials, -/// breadcrumbs' saved network passwords, ...). Back up the unparseable file -/// once before falling back, so a bad edit is always recoverable. -pub fn load_doc(path: &Path) -> DocumentMut { - let Ok(text) = std::fs::read_to_string(path) else { - return DocumentMut::default(); - }; - match text.parse::() { - Ok(doc) => doc, - Err(e) => { - let backup = PathBuf::from(format!("{}.bak", path.display())); - eprintln!( - "bos-settings: {} failed to parse ({e}); backed up to {} before falling back to defaults", - path.display(), - backup.display() - ); - let _ = std::fs::write(&backup, &text); - DocumentMut::default() - } - } -} - -/// Write the document back to disk, creating parent dirs as needed. -pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, doc.to_string())?; - Ok(()) -} - -pub fn config_dir() -> PathBuf { - // Honour XDG_CONFIG_HOME if set; otherwise fall back to $HOME/.config. - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - let p = PathBuf::from(xdg); - if p.is_absolute() { - return p; - } - } - let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); - PathBuf::from(home).join(".config") -} - -// --- typed readers (walk a dotted path, return None if absent/wrong type) --- - -fn get<'a>(doc: &'a DocumentMut, path: &[&str]) -> Option<&'a Item> { - let mut tbl = doc.as_table(); - let (last, parents) = path.split_last()?; - for key in parents { - tbl = tbl.get(key)?.as_table()?; - } - tbl.get(last) -} - -pub fn get_bool(doc: &DocumentMut, path: &[&str]) -> Option { - get(doc, path)?.as_bool() -} -pub fn get_str(doc: &DocumentMut, path: &[&str]) -> Option { - get(doc, path)?.as_str().map(str::to_string) -} -pub fn get_i64(doc: &DocumentMut, path: &[&str]) -> Option { - get(doc, path)?.as_integer() -} -pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option { - let item = get(doc, path)?; - item.as_float().or_else(|| item.as_integer().map(|i| i as f64)) -} -/// Read an array of strings (e.g. modules.disable, contexts[].priority). -pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec { - match get(doc, path).and_then(Item::as_array) { - Some(arr) => arr - .iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect(), - None => Vec::new(), - } -} - -// --- setters (auto-create intermediate tables, replace only the leaf) --- - -fn table_at_mut<'a>(doc: &'a mut DocumentMut, parents: &[&str]) -> &'a mut Table { - let mut tbl = doc.as_table_mut(); - for key in parents { - let entry = tbl.entry(key).or_insert_with(|| Item::Table(Table::new())); - if !entry.is_table() { - *entry = Item::Table(Table::new()); - } - tbl = entry.as_table_mut().expect("just ensured table"); - } - tbl -} - -fn set_item(doc: &mut DocumentMut, path: &[&str], item: Item) { - let Some((last, parents)) = path.split_last() else { - return; - }; - table_at_mut(doc, parents).insert(last, item); -} - -pub fn set_bool(doc: &mut DocumentMut, path: &[&str], v: bool) { - set_item(doc, path, value(v)); -} -pub fn set_str(doc: &mut DocumentMut, path: &[&str], v: &str) { - set_item(doc, path, value(v)); -} -pub fn set_i64(doc: &mut DocumentMut, path: &[&str], v: i64) { - set_item(doc, path, value(v)); -} -pub fn set_f64(doc: &mut DocumentMut, path: &[&str], v: f64) { - set_item(doc, path, value(v)); -} -pub fn set_str_list(doc: &mut DocumentMut, path: &[&str], items: &[String]) { - let mut arr = Array::new(); - for s in items { - arr.push(s.as_str()); - } - set_item(doc, path, Item::Value(Value::Array(arr))); -} - -/// Set a string key, or remove it entirely when the value is empty — keeps -/// optional fields out of the file rather than persisting `key = ""`. -pub fn set_str_or_remove(doc: &mut DocumentMut, path: &[&str], v: &str) { - if v.is_empty() { - remove(doc, path); - } else { - set_str(doc, path, v); - } -} - -pub fn remove(doc: &mut DocumentMut, path: &[&str]) { - if let Some((last, parents)) = path.split_last() { - let mut tbl = doc.as_table_mut(); - for key in parents { - match tbl.get_mut(key).and_then(Item::as_table_mut) { - Some(t) => tbl = t, - None => return, - } - } - tbl.remove(last); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn edits_preserve_unmodelled_keys_and_comments() { - let src = "\ -# a leading comment -[daemon] -log_level = \"info\" - -[calendar] -password = \"secret\" # keep me -"; - let mut doc: DocumentMut = src.parse().unwrap(); - // Modify a single modelled key. - set_str(&mut doc, &["daemon", "log_level"], "debug"); - // A key/section the UI never touches must survive untouched. - let out = doc.to_string(); - assert!(out.contains("log_level = \"debug\"")); - assert!(out.contains("password = \"secret\"")); - assert!(out.contains("# keep me")); - assert!(out.contains("# a leading comment")); - } - - #[test] - fn setters_create_missing_tables() { - let mut doc = DocumentMut::new(); - set_bool(&mut doc, &["adapters", "power", "enabled"], false); - set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], 45); - assert_eq!(get_bool(&doc, &["adapters", "power", "enabled"]), Some(false)); - assert_eq!( - get_i64(&doc, &["adapters", "power", "poll_interval_secs"]), - Some(45) - ); - } - - #[test] - fn empty_string_removes_key() { - let mut doc: DocumentMut = "[calendar]\nurl = \"x\"\n".parse().unwrap(); - set_str_or_remove(&mut doc, &["calendar", "url"], ""); - assert_eq!(get_str(&doc, &["calendar", "url"]), None); - } - - #[test] - fn str_list_roundtrips() { - let mut doc = DocumentMut::new(); - let items = vec!["a".to_string(), "b".to_string()]; - set_str_list(&mut doc, &["modules", "disable"], &items); - assert_eq!(get_str_list(&doc, &["modules", "disable"]), items); - } -} diff --git a/bos-settings/src/main.rs b/bos-settings/src/main.rs deleted file mode 100644 index a5e73fd..0000000 --- a/bos-settings/src/main.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod config; -mod theme; -mod ui; - -use gtk4::prelude::*; - -fn main() { - let app = gtk4::Application::builder() - .application_id("com.breadway.bos-settings") - .build(); - app.connect_activate(ui::window::build_ui); - app.run(); -} diff --git a/bos-settings/src/theme.rs b/bos-settings/src/theme.rs deleted file mode 100644 index 4bd254e..0000000 --- a/bos-settings/src/theme.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Theming for bos-settings. -//! -//! bos-settings deliberately owns almost no styling: it loads the ecosystem's -//! shared stylesheet (the same one breadbar/breadbox/breadpad use, generated by -//! `bread-theme` from the pywal palette) and adds only the few layout rules -//! specific to this app's sidebar + content shell. This keeps it visually -//! identical to the rest of the bread desktop and live-recolouring for free. - -use gtk4::CssProvider; -use std::cell::RefCell; - -// App-specific layout only — everything visual (colours, buttons, entries, -// switches, sidebar/row styling, cards, scrollbars) comes from the shared sheet. -const APP_CSS: &str = "\ -.view-content { padding: 24px; }\n\ -.view-content > label.title { margin-bottom: 16px; }\n\ -"; - -thread_local! { - static APP_PROVIDER: RefCell> = const { RefCell::new(None) }; -} - -pub fn load(_display: >k4::gdk::Display) { - // Shared ecosystem stylesheet (loads the generated file or a rendered - // fallback, and live-reloads when the palette changes). - bread_theme::gtk::apply_shared(); - - // bos-settings layout, layered on top at APPLICATION priority. - APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell)); -} diff --git a/bos-settings/src/ui/mod.rs b/bos-settings/src/ui/mod.rs deleted file mode 100644 index 3867fcd..0000000 --- a/bos-settings/src/ui/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod sidebar; -pub mod views; -pub mod widgets; -pub mod window; diff --git a/bos-settings/src/ui/sidebar.rs b/bos-settings/src/ui/sidebar.rs deleted file mode 100644 index 0ae4e3d..0000000 --- a/bos-settings/src/ui/sidebar.rs +++ /dev/null @@ -1,74 +0,0 @@ -use gtk4::prelude::*; -use gtk4::{Box as GBox, Label, ListBox, ListBoxRow, Orientation}; - -pub struct SidebarItem { - pub id: &'static str, - pub label: &'static str, -} - -pub const APPS_ITEMS: &[SidebarItem] = &[ - SidebarItem { id: "bread", label: "bread" }, - SidebarItem { id: "breadbar", label: "breadbar" }, - SidebarItem { id: "breadbox", label: "breadbox" }, - SidebarItem { id: "breadcrumbs", label: "breadcrumbs" }, - SidebarItem { id: "breadpad", label: "breadpad" }, - SidebarItem { id: "breadpaper", label: "breadpaper" }, - SidebarItem { id: "breadsearch", label: "breadsearch" }, -]; - -pub const SYSTEM_ITEMS: &[SidebarItem] = &[ - SidebarItem { id: "snapshots", label: "Snapshots" }, - SidebarItem { id: "packages", label: "Packages" }, - SidebarItem { id: "hyprland", label: "Hyprland" }, -]; - -pub fn build() -> (GBox, ListBox) { - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("sidebar"); - vbox.set_width_request(190); - - let list = ListBox::new(); - list.set_selection_mode(gtk4::SelectionMode::Single); - list.add_css_class("sidebar"); - - append_section(&list, "Apps", APPS_ITEMS); - append_section(&list, "System", SYSTEM_ITEMS); - - // Select the bread row so it matches the default stack page - let mut i = 0; - loop { - match list.row_at_index(i) { - None => break, - Some(row) if row.widget_name() == "bread" => { - list.select_row(Some(&row)); - break; - } - _ => i += 1, - } - } - - vbox.append(&list); - (vbox, list) -} - -fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) { - let header_row = ListBoxRow::new(); - header_row.set_selectable(false); - header_row.set_activatable(false); - let header_lbl = Label::new(Some(title)); - header_lbl.add_css_class("section-header"); - header_lbl.set_xalign(0.0); - header_row.set_child(Some(&header_lbl)); - list.append(&header_row); - - for item in items { - let row = ListBoxRow::new(); - row.set_widget_name(item.id); - let lbl = Label::new(Some(item.label)); - lbl.set_xalign(0.0); - lbl.set_margin_top(2); - lbl.set_margin_bottom(2); - row.set_child(Some(&lbl)); - list.append(&row); - } -} diff --git a/bos-settings/src/ui/views/bread.rs b/bos-settings/src/ui/views/bread.rs deleted file mode 100644 index 7560e73..0000000 --- a/bos-settings/src/ui/views/bread.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! breadd.toml — the bread daemon config. -//! Schema mirrors breadd/src/core/config.rs (daemon, lua, modules, adapters, -//! events, notifications). Edited non-destructively via the shared document. - -use std::cell::RefCell; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::Box as GBox; - -use crate::config; -use crate::ui::widgets as w; - -fn config_path() -> std::path::PathBuf { - config::config_dir().join("bread/breadd.toml") -} - -pub fn build() -> GBox { - let path = config_path(); - let doc = Rc::new(RefCell::new(config::load_doc(&path))); - - let (outer, c) = w::view_scaffold("bread"); - - c.append(&w::section("Daemon")); - c.append(&w::dropdown_row( - "Log level", - &doc, - &["daemon", "log_level"], - &["error", "warn", "info", "debug", "trace"], - "info", - )); - c.append(&w::entry_row( - "Socket path", - &doc, - &["daemon", "socket_path"], - "default (XDG runtime dir)", - "", - )); - - c.append(&w::section("Lua")); - c.append(&w::entry_row( - "Entry point", - &doc, - &["lua", "entry_point"], - "~/.config/bread/init.lua", - "", - )); - c.append(&w::entry_row( - "Module path", - &doc, - &["lua", "module_path"], - "~/.config/bread/modules", - "", - )); - - c.append(&w::section("Modules")); - c.append(&w::switch_row( - "Load built-in modules", - &doc, - &["modules", "builtin"], - true, - )); - c.append(&w::csv_row( - "Disabled modules", - &doc, - &["modules", "disable"], - "module-a, module-b", - )); - - c.append(&w::section("Adapters")); - c.append(&w::hint( - "Sources breadd normalises into events. Disable any you don't use.", - )); - c.append(&w::switch_row( - "Hyprland", - &doc, - &["adapters", "hyprland", "enabled"], - true, - )); - c.append(&w::switch_row( - "udev (devices)", - &doc, - &["adapters", "udev", "enabled"], - true, - )); - c.append(&w::csv_row( - "udev subsystems", - &doc, - &["adapters", "udev", "subsystems"], - "usb, input, power_supply", - )); - c.append(&w::switch_row( - "Power", - &doc, - &["adapters", "power", "enabled"], - true, - )); - c.append(&w::spin_row( - "Power poll interval (s)", - &doc, - &["adapters", "power", "poll_interval_secs"], - 1.0, - 3600.0, - 1.0, - 30, - )); - c.append(&w::switch_row( - "Network", - &doc, - &["adapters", "network", "enabled"], - true, - )); - c.append(&w::switch_row( - "Bluetooth", - &doc, - &["adapters", "bluetooth", "enabled"], - true, - )); - - c.append(&w::section("Events")); - c.append(&w::spin_row( - "Dedup window (ms)", - &doc, - &["events", "dedup_window_ms"], - 0.0, - 10000.0, - 50.0, - 250, - )); - - c.append(&w::section("Notifications")); - c.append(&w::spin_row( - "Default timeout (ms)", - &doc, - &["notifications", "default_timeout_ms"], - 0.0, - 60000.0, - 500.0, - 5000, - )); - c.append(&w::dropdown_row( - "Default urgency", - &doc, - &["notifications", "default_urgency"], - &["low", "normal", "critical"], - "normal", - )); - c.append(&w::entry_row( - "notify-send path", - &doc, - &["notifications", "notify_send_path"], - "auto-detected", - "", - )); - - outer.append(&w::save_button(&doc, path)); - outer -} diff --git a/bos-settings/src/ui/views/breadbar.rs b/bos-settings/src/ui/views/breadbar.rs deleted file mode 100644 index 355890a..0000000 --- a/bos-settings/src/ui/views/breadbar.rs +++ /dev/null @@ -1,75 +0,0 @@ -use gtk4::prelude::*; -use gtk4::{Box as GBox, Button, Label, Orientation, ScrolledWindow, TextView}; -use std::path::PathBuf; - -fn css_path() -> PathBuf { - crate::config::config_dir().join("breadbar/style.css") -} - -pub fn build() -> GBox { - let path = css_path(); - let existing_css = std::fs::read_to_string(&path).unwrap_or_default(); - - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("breadbar")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some( - "CSS overrides for breadbar. Leave empty to use the default bread theme.", - )); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(8); - subtitle.set_wrap(true); - vbox.append(&subtitle); - - let buf = gtk4::TextBuffer::new(None); - buf.set_text(&existing_css); - - let text_view = TextView::with_buffer(&buf); - text_view.set_monospace(true); - - let scroll = ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_child(Some(&text_view)); - vbox.append(&scroll); - - let btn_row = GBox::new(Orientation::Horizontal, 12); - btn_row.set_margin_top(12); - - let save_btn = Button::with_label("Save"); - let status_lbl = Label::new(None); - status_lbl.add_css_class("dim-label"); - - { - let path = path.clone(); - let status_lbl = status_lbl.clone(); - save_btn.connect_clicked(move |_| { - let (start, end) = buf.bounds(); - let text = buf.text(&start, &end, false); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - match std::fs::write(&path, text.as_str()) { - Ok(()) => { - status_lbl.set_text("Saved"); - let lbl = status_lbl.clone(); - glib::timeout_add_seconds_local(3, move || { - lbl.set_text(""); - glib::ControlFlow::Break - }); - } - Err(e) => status_lbl.set_text(&format!("Error: {e}")), - } - }); - } - - btn_row.append(&save_btn); - btn_row.append(&status_lbl); - vbox.append(&btn_row); - - vbox -} diff --git a/bos-settings/src/ui/views/breadbox.rs b/bos-settings/src/ui/views/breadbox.rs deleted file mode 100644 index e4356e2..0000000 --- a/bos-settings/src/ui/views/breadbox.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! breadbox config.toml — launcher contexts. -//! Schema mirrors breadbox-shared (`#[serde(rename = "context")]` — the TOML -//! key is `[[context]]`, singular, despite the Rust field being `contexts`), -//! with `name` + `priority`, an ordered list of app/category hints. The -//! context array is rewritten on save; any other top-level keys/comments in -//! the file are preserved. - -use std::cell::RefCell; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::{ - Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, -}; -use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; - -use crate::config; - -#[derive(Clone, Default)] -struct Context { - name: String, - priority: Vec, -} - -fn config_path() -> std::path::PathBuf { - config::config_dir().join("breadbox/config.toml") -} - -fn read_contexts(doc: &DocumentMut) -> Vec { - let Some(aot) = doc.get("context").and_then(Item::as_array_of_tables) else { - return Vec::new(); - }; - aot.iter() - .map(|t| Context { - name: t.get("name").and_then(Item::as_str).unwrap_or("").to_string(), - priority: t - .get("priority") - .and_then(Item::as_array) - .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) - .unwrap_or_default(), - }) - .collect() -} - -/// Rewrite only the `contexts` array-of-tables, leaving the rest of the doc. -fn write_contexts(doc: &mut DocumentMut, ctxs: &[Context]) { - let mut aot = ArrayOfTables::new(); - for ctx in ctxs { - let mut t = Table::new(); - t.insert("name", value(&ctx.name)); - let mut arr = Array::new(); - for p in &ctx.priority { - arr.push(p.as_str()); - } - t.insert("priority", value(arr)); - aot.push(t); - } - doc.as_table_mut().insert("context", Item::ArrayOfTables(aot)); -} - -fn rebuild_list(list: &ListBox, model: &Rc>>) { - while let Some(child) = list.first_child() { - list.remove(&child); - } - for (i, ctx) in model.borrow().iter().enumerate() { - let row = ListBoxRow::new(); - row.set_selectable(false); - - let hbox = GBox::new(Orientation::Horizontal, 8); - hbox.set_margin_top(6); - hbox.set_margin_bottom(6); - hbox.set_margin_start(8); - hbox.set_margin_end(8); - - let name_entry = Entry::new(); - name_entry.set_text(&ctx.name); - name_entry.set_width_chars(14); - name_entry.set_placeholder_text(Some("name")); - - let prio_entry = Entry::new(); - prio_entry.set_text(&ctx.priority.join(", ")); - prio_entry.set_hexpand(true); - prio_entry.set_placeholder_text(Some("firefox, code, Development, ...")); - - let remove_btn = Button::with_label("Remove"); - remove_btn.add_css_class("destructive-action"); - - { - let model = model.clone(); - name_entry.connect_changed(move |e| { - if let Some(c) = model.borrow_mut().get_mut(i) { - c.name = e.text().to_string(); - } - }); - } - { - let model = model.clone(); - prio_entry.connect_changed(move |e| { - if let Some(c) = model.borrow_mut().get_mut(i) { - c.priority = e - .text() - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - } - }); - } - { - let model = model.clone(); - let list = list.clone(); - remove_btn.connect_clicked(move |_| { - model.borrow_mut().remove(i); - rebuild_list(&list, &model); - }); - } - - hbox.append(&name_entry); - hbox.append(&prio_entry); - hbox.append(&remove_btn); - row.set_child(Some(&hbox)); - list.append(&row); - } -} - -pub fn build() -> GBox { - let path = config_path(); - let doc = Rc::new(RefCell::new(config::load_doc(&path))); - let model = Rc::new(RefCell::new(read_contexts(&doc.borrow()))); - - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("breadbox")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some( - "Launcher contexts — each lists, in priority order, the apps/categories surfaced first.", - )); - subtitle.set_xalign(0.0); - subtitle.set_wrap(true); - subtitle.set_margin_bottom(8); - vbox.append(&subtitle); - - let list = ListBox::new(); - list.set_selection_mode(gtk4::SelectionMode::None); - rebuild_list(&list, &model); - - let scroll = ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_child(Some(&list)); - vbox.append(&scroll); - - let btn_row = GBox::new(Orientation::Horizontal, 8); - btn_row.set_margin_top(8); - - let add_btn = Button::with_label("Add context"); - { - let model = model.clone(); - let list = list.clone(); - add_btn.connect_clicked(move |_| { - model.borrow_mut().push(Context { - name: "new".to_string(), - priority: Vec::new(), - }); - rebuild_list(&list, &model); - }); - } - - let save_btn = Button::with_label("Save"); - save_btn.add_css_class("suggested-action"); - let status_lbl = Label::new(None); - status_lbl.add_css_class("dim-label"); - - { - let doc = doc.clone(); - let model = model.clone(); - let path = path.clone(); - let status_lbl = status_lbl.clone(); - save_btn.connect_clicked(move |_| { - write_contexts(&mut doc.borrow_mut(), &model.borrow()); - match config::save_doc(&path, &doc.borrow()) { - Ok(()) => { - status_lbl.set_text("Saved"); - let lbl = status_lbl.clone(); - glib::timeout_add_seconds_local(3, move || { - lbl.set_text(""); - glib::ControlFlow::Break - }); - } - Err(e) => status_lbl.set_text(&format!("Error: {e}")), - } - }); - } - - btn_row.append(&add_btn); - btn_row.append(&save_btn); - btn_row.append(&status_lbl); - vbox.append(&btn_row); - - vbox -} diff --git a/bos-settings/src/ui/views/breadcrumbs.rs b/bos-settings/src/ui/views/breadcrumbs.rs deleted file mode 100644 index 41761ab..0000000 --- a/bos-settings/src/ui/views/breadcrumbs.rs +++ /dev/null @@ -1,479 +0,0 @@ -//! breadcrumbs.toml — Wi-Fi profile state machine. -//! Schema mirrors breadcrumbs/src/config.rs: -//! [settings] scalar tunables -//! [[networks]] saved networks (ssid / password / hidden) -//! [profiles.] per-location profile (networks, tailscale, …) -//! `[settings]` is edited in place; the `networks` array and `profiles` table -//! are rewritten from their editors on save. Other keys/comments are preserved. - -use std::cell::RefCell; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::{ - Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch, -}; -use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; - -use crate::config; -use crate::ui::widgets as w; - -fn config_path() -> std::path::PathBuf { - config::config_dir().join("breadcrumbs/breadcrumbs.toml") -} - -// --- networks --------------------------------------------------------------- - -#[derive(Clone, Default)] -struct Network { - ssid: String, - password: String, - hidden: bool, -} - -fn read_networks(doc: &DocumentMut) -> Vec { - let Some(aot) = doc.get("networks").and_then(Item::as_array_of_tables) else { - return Vec::new(); - }; - aot.iter() - .map(|t| Network { - ssid: t.get("ssid").and_then(Item::as_str).unwrap_or("").to_string(), - password: t - .get("password") - .and_then(Item::as_str) - .unwrap_or("") - .to_string(), - hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false), - }) - .collect() -} - -fn write_networks(doc: &mut DocumentMut, nets: &[Network]) { - let mut aot = ArrayOfTables::new(); - for n in nets { - let mut t = Table::new(); - t.insert("ssid", value(&n.ssid)); - t.insert("password", value(&n.password)); - t.insert("hidden", value(n.hidden)); - aot.push(t); - } - doc.as_table_mut().insert("networks", Item::ArrayOfTables(aot)); -} - -fn rebuild_networks(list: &ListBox, model: &Rc>>) { - while let Some(child) = list.first_child() { - list.remove(&child); - } - for (i, n) in model.borrow().iter().enumerate() { - let row = ListBoxRow::new(); - row.set_selectable(false); - let hbox = GBox::new(Orientation::Horizontal, 8); - hbox.set_margin_top(6); - hbox.set_margin_bottom(6); - hbox.set_margin_start(8); - hbox.set_margin_end(8); - - let ssid = Entry::new(); - ssid.set_text(&n.ssid); - ssid.set_width_chars(16); - ssid.set_placeholder_text(Some("SSID")); - - let pass = Entry::new(); - pass.set_text(&n.password); - pass.set_hexpand(true); - pass.set_visibility(false); - pass.set_input_purpose(gtk4::InputPurpose::Password); - pass.set_placeholder_text(Some("password")); - - let hidden = Switch::new(); - hidden.set_active(n.hidden); - hidden.set_valign(gtk4::Align::Center); - hidden.set_tooltip_text(Some("Hidden network")); - - let remove = Button::with_label("Remove"); - remove.add_css_class("destructive-action"); - - { - let model = model.clone(); - ssid.connect_changed(move |e| { - if let Some(n) = model.borrow_mut().get_mut(i) { - n.ssid = e.text().to_string(); - } - }); - } - { - let model = model.clone(); - pass.connect_changed(move |e| { - if let Some(n) = model.borrow_mut().get_mut(i) { - n.password = e.text().to_string(); - } - }); - } - { - let model = model.clone(); - hidden.connect_active_notify(move |s| { - if let Some(n) = model.borrow_mut().get_mut(i) { - n.hidden = s.is_active(); - } - }); - } - { - let model = model.clone(); - let list = list.clone(); - remove.connect_clicked(move |_| { - model.borrow_mut().remove(i); - rebuild_networks(&list, &model); - }); - } - - hbox.append(&ssid); - hbox.append(&pass); - hbox.append(&Label::new(Some("hidden"))); - hbox.append(&hidden); - hbox.append(&remove); - row.set_child(Some(&hbox)); - list.append(&row); - } -} - -// --- profiles --------------------------------------------------------------- - -#[derive(Clone, Default)] -struct Profile { - name: String, - networks: Vec, - detect_ssids: Vec, - bootstrap: String, - exit_node: String, - tailscale: bool, - include_all_known: bool, -} - -fn read_profiles(doc: &DocumentMut) -> Vec { - let Some(tbl) = doc.get("profiles").and_then(Item::as_table) else { - return Vec::new(); - }; - let str_list = |item: Option<&Item>| -> Vec { - item.and_then(Item::as_array) - .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) - .unwrap_or_default() - }; - tbl.iter() - .filter_map(|(name, item)| { - let p = item.as_table()?; - Some(Profile { - name: name.to_string(), - networks: str_list(p.get("networks")), - detect_ssids: str_list(p.get("detect_ssids")), - bootstrap: p.get("bootstrap").and_then(Item::as_str).unwrap_or("").to_string(), - exit_node: p.get("exit_node").and_then(Item::as_str).unwrap_or("").to_string(), - tailscale: p.get("tailscale").and_then(Item::as_bool).unwrap_or(false), - include_all_known: p - .get("include_all_known") - .and_then(Item::as_bool) - .unwrap_or(false), - }) - }) - .collect() -} - -fn write_profiles(doc: &mut DocumentMut, profiles: &[Profile]) { - let mut tbl = Table::new(); - let to_arr = |items: &[String]| { - let mut a = Array::new(); - for s in items { - a.push(s.as_str()); - } - a - }; - for p in profiles { - if p.name.is_empty() { - continue; - } - let mut t = Table::new(); - t.insert("networks", value(to_arr(&p.networks))); - t.insert("tailscale", value(p.tailscale)); - t.insert("include_all_known", value(p.include_all_known)); - if !p.detect_ssids.is_empty() { - t.insert("detect_ssids", value(to_arr(&p.detect_ssids))); - } - if !p.bootstrap.is_empty() { - t.insert("bootstrap", value(&p.bootstrap)); - } - if !p.exit_node.is_empty() { - t.insert("exit_node", value(&p.exit_node)); - } - tbl.insert(&p.name, Item::Table(t)); - } - doc.as_table_mut().insert("profiles", Item::Table(tbl)); -} - -fn field(label: &str, control: &impl IsA) -> GBox { - let row = GBox::new(Orientation::Horizontal, 12); - let lbl = Label::new(Some(label)); - lbl.set_xalign(0.0); - lbl.set_width_chars(16); - row.append(&lbl); - control.set_hexpand(true); - row.append(control); - row -} - -fn rebuild_profiles(container: &GBox, model: &Rc>>) { - while let Some(child) = container.first_child() { - container.remove(&child); - } - for (i, p) in model.borrow().iter().enumerate() { - let card = GBox::new(Orientation::Vertical, 6); - card.add_css_class("card"); - card.set_margin_top(6); - card.set_margin_bottom(6); - - let header = GBox::new(Orientation::Horizontal, 8); - let name = Entry::new(); - name.set_text(&p.name); - name.set_hexpand(true); - name.set_placeholder_text(Some("profile name (e.g. home)")); - let remove = Button::with_label("Remove"); - remove.add_css_class("destructive-action"); - header.append(&name); - header.append(&remove); - card.append(&header); - - let networks = Entry::new(); - networks.set_text(&p.networks.join(", ")); - networks.set_placeholder_text(Some("SSID1, SSID2")); - card.append(&field("Networks", &networks)); - - let detect = Entry::new(); - detect.set_text(&p.detect_ssids.join(", ")); - detect.set_placeholder_text(Some("SSIDs that auto-select this profile")); - card.append(&field("Detect SSIDs", &detect)); - - let exit_node = Entry::new(); - exit_node.set_text(&p.exit_node); - exit_node.set_placeholder_text(Some("tailscale exit node (optional)")); - card.append(&field("Exit node", &exit_node)); - - let bootstrap = Entry::new(); - bootstrap.set_text(&p.bootstrap); - bootstrap.set_placeholder_text(Some("bootstrap command (optional)")); - card.append(&field("Bootstrap", &bootstrap)); - - let tailscale = Switch::new(); - tailscale.set_active(p.tailscale); - tailscale.set_halign(gtk4::Align::Start); - card.append(&field("Tailscale", &tailscale)); - - let include_all = Switch::new(); - include_all.set_active(p.include_all_known); - include_all.set_halign(gtk4::Align::Start); - card.append(&field("Include all known", &include_all)); - - // bind each control to the in-memory model entry - macro_rules! bind_csv { - ($entry:ident, $f:ident) => {{ - let model = model.clone(); - $entry.connect_changed(move |e| { - if let Some(p) = model.borrow_mut().get_mut(i) { - p.$f = e - .text() - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - } - }); - }}; - } - macro_rules! bind_str { - ($entry:ident, $f:ident) => {{ - let model = model.clone(); - $entry.connect_changed(move |e| { - if let Some(p) = model.borrow_mut().get_mut(i) { - p.$f = e.text().to_string(); - } - }); - }}; - } - macro_rules! bind_bool { - ($sw:ident, $f:ident) => {{ - let model = model.clone(); - $sw.connect_active_notify(move |s| { - if let Some(p) = model.borrow_mut().get_mut(i) { - p.$f = s.is_active(); - } - }); - }}; - } - bind_str!(name, name); - bind_csv!(networks, networks); - bind_csv!(detect, detect_ssids); - bind_str!(exit_node, exit_node); - bind_str!(bootstrap, bootstrap); - bind_bool!(tailscale, tailscale); - bind_bool!(include_all, include_all_known); - { - let model = model.clone(); - let container = container.clone(); - remove.connect_clicked(move |_| { - model.borrow_mut().remove(i); - rebuild_profiles(&container, &model); - }); - } - - container.append(&card); - } -} - -// --- view ------------------------------------------------------------------- - -pub fn build() -> GBox { - let path = config_path(); - let doc = Rc::new(RefCell::new(config::load_doc(&path))); - let nets = Rc::new(RefCell::new(read_networks(&doc.borrow()))); - let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow()))); - - let outer = GBox::new(Orientation::Vertical, 8); - outer.add_css_class("view-content"); - - let title = Label::new(Some("breadcrumbs")); - title.add_css_class("title"); - title.set_xalign(0.0); - outer.append(&title); - - let content = GBox::new(Orientation::Vertical, 8); - let scroll = ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_hscrollbar_policy(gtk4::PolicyType::Never); - scroll.set_child(Some(&content)); - outer.append(&scroll); - - // [settings] — edited in place on the shared doc - content.append(&w::section("Settings")); - content.append(&w::dropdown_row( - "Default profile", - &doc, - &["settings", "default_profile"], - &["home", "away"], - // breadcrumbs' own default_profile_name() is "away", not "home" — - // this was showing the wrong value for an unset key. - "away", - )); - content.append(&w::entry_row("DNS", &doc, &["settings", "dns"], "1.1.1.1", "")); - content.append(&w::entry_row( - "Exit node", - &doc, - &["settings", "exit_node"], - "tailscale exit node", - "", - )); - content.append(&w::entry_row( - "Ping host", - &doc, - &["settings", "ping_host"], - "1.1.1.1", - "", - )); - content.append(&w::entry_row( - "Connectivity URL", - &doc, - &["settings", "connectivity_url"], - "http://connectivitycheck.gstatic.com/generate_204", - "", - )); - content.append(&w::spin_row( - "nmcli wait (s)", - &doc, - &["settings", "nmcli_wait"], - 1.0, - 120.0, - 1.0, - 8, - )); - content.append(&w::spin_row( - "Watch interval (s)", - &doc, - &["settings", "watch_interval"], - 1.0, - 600.0, - 1.0, - 12, - )); - - // [[networks]] - content.append(&w::section("Saved networks")); - let net_list = ListBox::new(); - net_list.set_selection_mode(gtk4::SelectionMode::None); - rebuild_networks(&net_list, &nets); - content.append(&net_list); - let add_net = Button::with_label("Add network"); - add_net.set_halign(gtk4::Align::Start); - { - let nets = nets.clone(); - let net_list = net_list.clone(); - add_net.connect_clicked(move |_| { - nets.borrow_mut().push(Network::default()); - rebuild_networks(&net_list, &nets); - }); - } - content.append(&add_net); - - // [profiles.*] - content.append(&w::section("Profiles")); - let prof_box = GBox::new(Orientation::Vertical, 4); - rebuild_profiles(&prof_box, &profiles); - content.append(&prof_box); - let add_prof = Button::with_label("Add profile"); - add_prof.set_halign(gtk4::Align::Start); - { - let profiles = profiles.clone(); - let prof_box = prof_box.clone(); - add_prof.connect_clicked(move |_| { - profiles.borrow_mut().push(Profile { - name: "new".to_string(), - ..Default::default() - }); - rebuild_profiles(&prof_box, &profiles); - }); - } - content.append(&add_prof); - - // Save — fold the network + profile editors back into the doc, then write. - let btn_row = GBox::new(Orientation::Horizontal, 12); - btn_row.set_margin_top(16); - let save_btn = Button::with_label("Save"); - save_btn.add_css_class("suggested-action"); - let status = Label::new(None); - status.add_css_class("dim-label"); - { - let doc = doc.clone(); - let nets = nets.clone(); - let profiles = profiles.clone(); - let path = path.clone(); - let status = status.clone(); - save_btn.connect_clicked(move |_| { - { - let mut d = doc.borrow_mut(); - write_networks(&mut d, &nets.borrow()); - write_profiles(&mut d, &profiles.borrow()); - } - match config::save_doc(&path, &doc.borrow()) { - Ok(()) => { - status.set_text("Saved"); - let lbl = status.clone(); - glib::timeout_add_seconds_local(3, move || { - lbl.set_text(""); - glib::ControlFlow::Break - }); - } - Err(e) => status.set_text(&format!("Error: {e}")), - } - }); - } - btn_row.append(&save_btn); - btn_row.append(&status); - outer.append(&btn_row); - - outer -} diff --git a/bos-settings/src/ui/views/breadpad.rs b/bos-settings/src/ui/views/breadpad.rs deleted file mode 100644 index 6fe5268..0000000 --- a/bos-settings/src/ui/views/breadpad.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! breadpad.toml — the breadpad notes/reminders config. -//! Schema mirrors breadpad-shared/src/config.rs (settings, model + model.ollama, -//! reminders, calendar). Edited non-destructively (the calendar password and -//! model paths are preserved across saves). - -use std::cell::RefCell; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::Box as GBox; - -use crate::config; -use crate::ui::widgets as w; - -fn config_path() -> std::path::PathBuf { - config::config_dir().join("breadpad/breadpad.toml") -} - -pub fn build() -> GBox { - let path = config_path(); - let doc = Rc::new(RefCell::new(config::load_doc(&path))); - - let (outer, c) = w::view_scaffold("breadpad"); - - c.append(&w::section("Capture")); - c.append(&w::dropdown_row( - "Default note type", - &doc, - &["settings", "default_type"], - &["note", "reminder", "task"], - "note", - )); - c.append(&w::switch_row( - "Tag with active workspace", - &doc, - &["settings", "workspace_tag"], - true, - )); - c.append(&w::csv_row( - "Snooze options", - &doc, - &["settings", "snooze_options"], - "15m, 1h, tomorrow_morning", - )); - c.append(&w::spin_row( - "Archive after (days)", - &doc, - &["settings", "archive_after_days"], - 0.0, - 3650.0, - 1.0, - 30, - )); - - c.append(&w::section("Classifier model")); - c.append(&w::entry_row( - "ONNX model path", - &doc, - &["model", "path"], - "~/.local/share/breadpad/model/classifier.onnx", - "", - )); - c.append(&w::entry_row( - "Tokenizer path", - &doc, - &["model", "tokenizer"], - "~/.local/share/breadpad/model/tokenizer.json", - "", - )); - - c.append(&w::section("Ollama (LLM classifier)")); - c.append(&w::switch_row( - "Use Ollama", - &doc, - &["model", "ollama", "enabled"], - true, - )); - c.append(&w::entry_row( - "Endpoint", - &doc, - &["model", "ollama", "endpoint"], - "http://localhost:11434", - "", - )); - c.append(&w::entry_row( - "Model", - &doc, - &["model", "ollama", "model"], - "e.g. fastflowlm", - "", - )); - c.append(&w::spin_f64_row( - "Confidence threshold", - &doc, - &["model", "ollama", "confidence_threshold"], - 0.0, - 1.0, - 0.05, - 2, - 0.6, - )); - - c.append(&w::section("Reminders")); - c.append(&w::entry_row( - "Default morning time", - &doc, - &["reminders", "default_morning"], - "7:00", - "", - )); - c.append(&w::spin_row( - "Missed grace (minutes)", - &doc, - &["reminders", "missed_grace_minutes"], - 0.0, - 1440.0, - 5.0, - 60, - )); - - c.append(&w::section("Calendar (CalDAV)")); - c.append(&w::switch_row( - "Sync to calendar", - &doc, - &["calendar", "enabled"], - false, - )); - c.append(&w::entry_row( - "CalDAV URL", - &doc, - &["calendar", "url"], - "https://host/remote.php/dav/calendars/...", - "", - )); - c.append(&w::entry_row( - "Username", - &doc, - &["calendar", "username"], - "", - "", - )); - c.append(&w::password_row("Password", &doc, &["calendar", "password"])); - - outer.append(&w::save_button(&doc, path)); - outer -} diff --git a/bos-settings/src/ui/views/breadpaper.rs b/bos-settings/src/ui/views/breadpaper.rs deleted file mode 100644 index e1d7c18..0000000 --- a/bos-settings/src/ui/views/breadpaper.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! breadpaper — wallpaper manager. No config file to edit here; breadpaper -//! takes no persistent settings, just an image path via its CLI (`breadpaper -//! set ` / `breadpaper get`). This panel is a thin GUI front-end for -//! that CLI so wallpaper (and the pywal-driven theme it generates) has a -//! discoverable home in BOS Settings instead of only being reachable from a -//! terminal. - -use std::path::PathBuf; -use std::process::Command; - -use gtk4::prelude::*; -use gtk4::{ - Box as GBox, Button, FileChooserAction, FileChooserDialog, Image, Label, Orientation, - ResponseType, -}; - -use crate::ui::widgets as w; - -fn current_wallpaper() -> Option { - let out = Command::new("breadpaper").arg("get").output().ok()?; - if !out.status.success() { - return None; - } - let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if s.is_empty() { - None - } else { - Some(PathBuf::from(s)) - } -} - -fn refresh_preview(preview: &Image, path_lbl: &Label) { - match current_wallpaper() { - Some(path) => { - path_lbl.set_text(&path.display().to_string()); - preview.set_from_file(Some(&path)); - } - None => { - path_lbl.set_text("No wallpaper set"); - preview.set_icon_name(Some("image-missing")); - } - } -} - -pub fn build() -> GBox { - let (outer, c) = w::view_scaffold("breadpaper"); - - c.append(&w::hint( - "Sets the desktop wallpaper, generates a matching pywal palette, and \ - reloads the shared bread-theme stylesheet — the wallpaper drives \ - the whole desktop's accent colors.", - )); - - let preview = Image::new(); - preview.set_pixel_size(320); - preview.set_margin_top(8); - preview.set_margin_bottom(8); - c.append(&preview); - - let path_lbl = Label::new(None); - path_lbl.set_wrap(true); - path_lbl.add_css_class("dim-label"); - c.append(&path_lbl); - - refresh_preview(&preview, &path_lbl); - - let btn_row = GBox::new(Orientation::Horizontal, 8); - btn_row.set_margin_top(8); - - let choose_btn = Button::with_label("Choose image..."); - let status = Label::new(None); - status.add_css_class("dim-label"); - - { - let preview = preview.clone(); - let path_lbl = path_lbl.clone(); - let status = status.clone(); - choose_btn.connect_clicked(move |btn| { - let window = btn.root().and_then(|r| r.downcast::().ok()); - let dialog = FileChooserDialog::new( - Some("Choose a wallpaper"), - window.as_ref(), - FileChooserAction::Open, - &[("Cancel", ResponseType::Cancel), ("Set", ResponseType::Accept)], - ); - // Restricted to what breadpaper's own validate() actually - // accepts (png/jpg/jpeg/webp/gif/bmp) — add_pixbuf_formats() - // also offers svg/tiff/etc. that breadpaper rejects outright. - let filter = gtk4::FileFilter::new(); - for ext in ["png", "jpg", "jpeg", "webp", "gif", "bmp"] { - filter.add_suffix(ext); - } - filter.set_name(Some("Images")); - dialog.add_filter(&filter); - - let preview = preview.clone(); - let path_lbl = path_lbl.clone(); - let status = status.clone(); - dialog.connect_response(move |dialog, response| { - if response == ResponseType::Accept { - if let Some(file) = dialog.file() { - if let Some(path) = file.path() { - // breadpaper set runs `awww img` + pywal palette - // generation, which is routinely 1-3s (pywal - // spawns Python + an ImageMagick backend) — not - // the "sub-second" call this used to assume. - // GTK widgets aren't Send, so run it in a thread - // and hand the result back over a channel - // (same pattern as snapshots.rs). - status.set_text("Setting..."); - let (tx, rx) = async_channel::bounded::(1); - std::thread::spawn(move || { - let ok = Command::new("breadpaper") - .arg("set") - .arg(&path) - .status() - .map(|s| s.success()) - .unwrap_or(false); - let _ = tx.send_blocking(ok); - }); - - let preview = preview.clone(); - let path_lbl = path_lbl.clone(); - let status = status.clone(); - glib::spawn_future_local(async move { - let ok = rx.recv().await.unwrap_or(false); - if ok { - refresh_preview(&preview, &path_lbl); - status.set_text("Wallpaper set"); - } else { - status.set_text("breadpaper failed — see terminal/journal"); - } - let lbl = status.clone(); - glib::timeout_add_seconds_local(3, move || { - lbl.set_text(""); - glib::ControlFlow::Break - }); - }); - } - } - } - dialog.close(); - }); - dialog.show(); - }); - } - - btn_row.append(&choose_btn); - btn_row.append(&status); - c.append(&btn_row); - - outer -} diff --git a/bos-settings/src/ui/views/breadsearch.rs b/bos-settings/src/ui/views/breadsearch.rs deleted file mode 100644 index 4c292c4..0000000 --- a/bos-settings/src/ui/views/breadsearch.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! breadsearch/config.toml — semantic search indexer (breadmill) + GUI. -//! Schema mirrors breadsearch-shared::Config ([index], [search], [model], [power]). - -use std::cell::RefCell; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::Box as GBox; - -use crate::config; -use crate::ui::widgets as w; - -fn config_path() -> std::path::PathBuf { - config::config_dir().join("breadsearch/config.toml") -} - -pub fn build() -> GBox { - let path = config_path(); - let doc = Rc::new(RefCell::new(config::load_doc(&path))); - - let (outer, c) = w::view_scaffold("breadsearch"); - - c.append(&w::section("Power")); - c.append(&w::hint( - "breadmill's embedding step is CPU/NPU/GPU-heavy. Turn it off entirely, \ - or just pause it on battery — it resumes automatically on AC power.", - )); - c.append(&w::switch_row("Enabled", &doc, &["power", "enabled"], true)); - c.append(&w::switch_row( - "Index while on battery", - &doc, - &["power", "run_on_battery"], - false, - )); - - c.append(&w::section("Model")); - // breadmill supports npu/rocm backends in its own codebase, but the - // prebuilt binary bakery actually ships is CPU-only (no --features - // npu/rocm in its release build) — offering them here would just be a - // dropdown option that silently falls back to cpu. Re-add once a build - // with those features is published. - c.append(&w::dropdown_row( - "Compute backend", - &doc, - &["model", "backend"], - &["cpu"], - "cpu", - )); - c.append(&w::hint( - "The bakery-published breadmill is CPU-only for now. NPU (AMD Ryzen \ - AI) and ROCm backends exist in breadmill's own code but need a \ - separately-built binary with those features enabled.", - )); - - c.append(&w::section("Index")); - c.append(&w::csv_row( - "Roots", - &doc, - &["index", "roots"], - "~/Documents, ~/Projects", - )); - c.append(&w::csv_row( - "Excludes", - &doc, - &["index", "excludes"], - "~/Projects/some-noisy-repo", - )); - c.append(&w::csv_row( - "Extensions", - &doc, - &["index", "extensions"], - "md, txt, org, pdf, odt, docx", - )); - c.append(&w::spin_f64_row( - "Max file size (MB)", - &doc, - &["index", "max_file_mb"], - 0.1, - 500.0, - 0.5, - 1, - 10.0, - )); - - c.append(&w::section("Search")); - c.append(&w::spin_row( - "Result limit", - &doc, - &["search", "limit"], - 1.0, - 100.0, - 1.0, - 10, - )); - c.append(&w::spin_row( - "Snippet length", - &doc, - &["search", "snippet_len"], - 20.0, - 2000.0, - 20.0, - 200, - )); - - c.append(&w::hint( - "Changes take effect after: systemctl --user restart breadmill", - )); - - outer.append(&w::save_button(&doc, path)); - outer -} diff --git a/bos-settings/src/ui/views/hyprland.rs b/bos-settings/src/ui/views/hyprland.rs deleted file mode 100644 index b651b25..0000000 --- a/bos-settings/src/ui/views/hyprland.rs +++ /dev/null @@ -1,95 +0,0 @@ -use gtk4::prelude::*; -use gtk4::{Box as GBox, Button, Label, Orientation}; -use std::process::Command; - -fn get_monitors() -> Vec { - let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else { - return Vec::new(); - }; - let text = String::from_utf8_lossy(&output.stdout); - let Ok(monitors) = serde_json::from_str::>(&text) else { - return Vec::new(); - }; - monitors - .iter() - .filter_map(|m| { - let name = m.get("name")?.as_str()?; - let w = m.get("width")?.as_u64()?; - let h = m.get("height")?.as_u64()?; - let refresh = m.get("refreshRate")?.as_f64()?; - Some(format!("{name} {w}x{h} @ {refresh:.0}Hz")) - }) - .collect() -} - -fn hypr_path(name: &str) -> std::path::PathBuf { - crate::config::config_dir().join("hypr").join(name) -} - -/// Open `path` in $EDITOR (nano if unset) inside a terminal window. Spawning -/// an editor directly (no terminal) is a silent no-op for any TUI editor — -/// there's nothing for it to attach to — so it always needs a terminal -/// wrapper. Uses kitty, which is what BOS actually ships (not foot). -fn open_in_terminal(path: &std::path::Path) { - let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); - if let Ok(mut child) = Command::new("kitty").args(["-e", &editor]).arg(path).spawn() { - std::thread::spawn(move || { let _ = child.wait(); }); - } -} - -pub fn build() -> GBox { - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("Hyprland")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let monitors_lbl = Label::new(Some("Connected monitors")); - monitors_lbl.set_xalign(0.0); - monitors_lbl.set_margin_top(8); - monitors_lbl.set_margin_bottom(4); - vbox.append(&monitors_lbl); - - let monitors = get_monitors(); - if monitors.is_empty() { - let lbl = Label::new(Some("No monitors detected (is Hyprland running?)")); - lbl.set_xalign(0.0); - vbox.append(&lbl); - } else { - for mon in &monitors { - let lbl = Label::new(Some(mon)); - lbl.set_xalign(0.0); - lbl.add_css_class("monospace"); - vbox.append(&lbl); - } - } - - // BOS's Hyprland config is Lua-native (hyprland.lua), not the classic - // hyprland.conf/keybinds.conf pair — those names only ever matched a - // stale, unshipped dotfiles/ directory, so this button opened (or - // silently created) the wrong file entirely. - let open_btn = Button::with_label("Open hyprland.lua in editor"); - open_btn.set_margin_top(16); - open_btn.set_halign(gtk4::Align::Start); - { - let conf_path = hypr_path("hyprland.lua"); - open_btn.connect_clicked(move |_| open_in_terminal(&conf_path)); - } - vbox.append(&open_btn); - - // Keybinds are defined inline in hyprland.lua (no separate file); point - // this at the shipped cheat sheet instead of a keybinds.conf that has - // never existed on BOS. - let keybinds_btn = Button::with_label("View keybinds cheat sheet"); - keybinds_btn.set_margin_top(8); - keybinds_btn.set_halign(gtk4::Align::Start); - { - let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt"); - keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path)); - } - vbox.append(&keybinds_btn); - - vbox -} diff --git a/bos-settings/src/ui/views/mod.rs b/bos-settings/src/ui/views/mod.rs deleted file mode 100644 index ef9936c..0000000 --- a/bos-settings/src/ui/views/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod bread; -pub mod breadbar; -pub mod breadbox; -pub mod breadcrumbs; -pub mod breadpad; -pub mod breadpaper; -pub mod breadsearch; -pub mod hyprland; -pub mod packages; -pub mod snapshots; diff --git a/bos-settings/src/ui/views/packages.rs b/bos-settings/src/ui/views/packages.rs deleted file mode 100644 index e860b80..0000000 --- a/bos-settings/src/ui/views/packages.rs +++ /dev/null @@ -1,258 +0,0 @@ -use async_channel; -use gtk4::prelude::*; -use gtk4::{ - Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView, -}; -use std::collections::HashMap; -use std::io::{BufRead, BufReader}; -use std::process::{Command, Stdio}; - -use crate::ui::widgets as w; - -fn read_installed() -> HashMap { - let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); - let path = std::path::Path::new(&home) - .join(".local/state/bakery/installed.json"); - - let Ok(text) = std::fs::read_to_string(&path) else { - return HashMap::new(); - }; - let Ok(mut parsed) = serde_json::from_str::(&text) else { - return HashMap::new(); - }; - // installed.json is {"packages": {name: {version, binaries, services}}}, - // not a flat map of package name to metadata — without unwrapping this, - // every install shows a single bogus row named "packages". - let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else { - return HashMap::new(); - }; - let Ok(packages) = serde_json::from_value::>(packages) else { - return HashMap::new(); - }; - - packages - .into_iter() - .filter_map(|(name, val)| { - let version = val - .get("version") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - Some((name, version)) - }) - .collect() -} - -fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { - stream_command_then(args, log_buf, || {}); -} - -/// Same as stream_command, but runs `on_done` once the process's output -/// stream ends (i.e. the child has exited) — the channel closes when both -/// the stdout- and stderr-forwarding threads drop their sender, which only -/// happens after `child.wait()` returns. -fn stream_command_then(args: &[&str], log_buf: gtk4::TextBuffer, on_done: impl FnOnce() + 'static) { - let (sender, receiver) = async_channel::bounded::(256); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - - std::thread::spawn(move || { - let mut child = match Command::new(&args[0]) - .args(&args[1..]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - { - Ok(c) => c, - Err(e) => { - let _ = sender.send_blocking(format!("Error: {e}")); - return; - } - }; - - // Merge stderr into the channel too. - // Both are Some because we spawned with Stdio::piped() above. - let stdout = child.stdout.take().expect("stdout piped"); - let stderr = child.stderr.take().expect("stderr piped"); - - let tx2 = sender.clone(); - let stderr_thread = std::thread::spawn(move || { - for line in BufReader::new(stderr).lines().flatten() { - let _ = tx2.send_blocking(line); - } - }); - - for line in BufReader::new(stdout).lines().flatten() { - let _ = sender.send_blocking(line); - } - let _ = child.wait(); - let _ = stderr_thread.join(); - }); - - glib::spawn_future_local(async move { - while let Ok(line) = receiver.recv().await { - let mut end = log_buf.end_iter(); - log_buf.insert(&mut end, &format!("{line}\n")); - } - on_done(); - }); -} - -fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { - while let Some(child) = list.first_child() { - list.remove(&child); - } - - let packages = read_installed(); - if packages.is_empty() { - let row = ListBoxRow::new(); - row.set_selectable(false); - let lbl = Label::new(Some( - "No bakery packages found (~/.local/state/bakery/installed.json)", - )); - lbl.set_margin_top(8); - lbl.set_margin_bottom(8); - lbl.set_margin_start(8); - row.set_child(Some(&lbl)); - list.append(&row); - return; - } - - let mut names: Vec<_> = packages.iter().collect(); - names.sort_by_key(|(k, _)| k.as_str()); - - for (name, version) in names { - let row = ListBoxRow::new(); - row.set_selectable(false); - let hbox = GBox::new(Orientation::Horizontal, 16); - hbox.set_margin_top(6); - hbox.set_margin_bottom(6); - hbox.set_margin_start(8); - hbox.set_margin_end(8); - - let name_lbl = Label::new(Some(name)); - name_lbl.set_hexpand(true); - name_lbl.set_xalign(0.0); - - let ver_lbl = Label::new(Some(version)); - ver_lbl.set_xalign(1.0); - - let pkg_name = name.clone(); - let update_btn = Button::with_label("Update"); - { - let log_buf = log_buf.clone(); - let list = list.clone(); - update_btn.connect_clicked(move |_| { - log_buf.set_text(""); - let list2 = list.clone(); - let log_buf2 = log_buf.clone(); - // Route through stream_command (like the other buttons) so - // output is visible and the row refreshes with the new - // version once the update actually finishes — previously - // this was fire-and-forget with the Err case silently - // swallowed, so a missing `bakery` binary made the button - // look broken with zero feedback either way. - stream_command_then(&["bakery", "update", &pkg_name], log_buf.clone(), move || { - populate_packages(&list2, &log_buf2); - }); - }); - } - - hbox.append(&name_lbl); - hbox.append(&ver_lbl); - hbox.append(&update_btn); - row.set_child(Some(&hbox)); - list.append(&row); - } -} - -pub fn build() -> GBox { - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("Packages")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some("Bread ecosystem packages installed via bakery, and system packages via pacman below.")); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(16); - vbox.append(&subtitle); - - let list = ListBox::new(); - list.set_selection_mode(gtk4::SelectionMode::None); - - let log_buf = gtk4::TextBuffer::new(None); - populate_packages(&list, &log_buf); - - let scroll = ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_child(Some(&list)); - vbox.append(&scroll); - - let log_view = TextView::with_buffer(&log_buf); - log_view.set_editable(false); - log_view.set_monospace(true); - log_view.set_height_request(140); - log_view.set_margin_top(8); - - let btn_row = GBox::new(Orientation::Horizontal, 8); - btn_row.set_margin_top(12); - - // Labeled "List installed", not "Check for updates" — bakery list is a - // listing of installed packages, it doesn't check for available updates. - let check_btn = Button::with_label("List installed"); - let update_all_btn = Button::with_label("Update all"); - - { - let log_buf = log_buf.clone(); - check_btn.connect_clicked(move |_| { - log_buf.set_text(""); - stream_command(&["bakery", "list"], log_buf.clone()); - }); - } - - { - let log_buf = log_buf.clone(); - update_all_btn.connect_clicked(move |_| { - log_buf.set_text(""); - stream_command(&["bakery", "update", "--all"], log_buf.clone()); - }); - } - - btn_row.append(&check_btn); - btn_row.append(&update_all_btn); - vbox.append(&btn_row); - - // --------------------------------------------------------------------- - // System packages (pacman) — the other update channel. bakery only - // covers the userspace bread apps; base system/kernel/bos-settings/AUR - // republished packages come from pacman + the [breadway] repo, and - // bos-update (the CLI) already updates both — this panel previously - // only exposed the bakery half, so a user relying on it alone would - // never get base-system updates through the GUI. - // --------------------------------------------------------------------- - vbox.append(&w::section("System packages (pacman)")); - vbox.append(&w::hint( - "Base system, kernel, bos-settings, and republished AUR packages — \ - the other half of what `bos-update` covers. Needs your password \ - (polkit) since pacman requires root.", - )); - - let pacman_btn_row = GBox::new(Orientation::Horizontal, 8); - pacman_btn_row.set_margin_top(8); - let pacman_update_btn = Button::with_label("Update system (pacman -Syu)"); - { - let log_buf = log_buf.clone(); - pacman_update_btn.connect_clicked(move |_| { - log_buf.set_text(""); - stream_command(&["pkexec", "pacman", "-Syu", "--noconfirm"], log_buf.clone()); - }); - } - pacman_btn_row.append(&pacman_update_btn); - vbox.append(&pacman_btn_row); - - vbox.append(&log_view); - - vbox -} diff --git a/bos-settings/src/ui/views/snapshots.rs b/bos-settings/src/ui/views/snapshots.rs deleted file mode 100644 index dd7bbf7..0000000 --- a/bos-settings/src/ui/views/snapshots.rs +++ /dev/null @@ -1,245 +0,0 @@ -use gtk4::prelude::*; -use gtk4::{ - AlertDialog, Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, -}; -use std::process::Command; - -#[derive(Clone)] -struct SnapshotRow { - number: String, - date: String, - description: String, -} - -fn list_snapshots() -> Vec { - // NOTE: the real flag is --columns, not --output-cols (which snapper - // rejects outright with "Unknown option") — confirmed against snapper - // 0.13's own --help. With the wrong flag this always failed and the - // panel silently showed "No snapshots found" on every install. - let Ok(output) = Command::new("snapper") - .args(["list", "--columns", "number,date,description"]) - .output() - else { - return Vec::new(); - }; - if !output.status.success() { - eprintln!( - "bos-settings: snapper list failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - return Vec::new(); - } - - let text = String::from_utf8_lossy(&output.stdout); - text.lines() - .skip(2) // header + separator - .filter_map(|line| { - let mut cols = line.splitn(3, '|'); - let number = cols.next()?.trim().to_string(); - // Snapshot 0 ("current") always exists, can't be rolled back to - // or deleted, and isn't a real snapshot — filter it out. - if number == "0" { - return None; - } - Some(SnapshotRow { - number, - date: cols.next()?.trim().to_string(), - description: cols.next()?.trim().to_string(), - }) - }) - .collect() -} - -fn populate_list(list: &ListBox) { - while let Some(child) = list.first_child() { - list.remove(&child); - } - let snapshots = list_snapshots(); - if snapshots.is_empty() { - let row = ListBoxRow::new(); - row.set_selectable(false); - let lbl = Label::new(Some("No snapshots found (snapper may not be configured yet)")); - lbl.set_margin_top(8); - lbl.set_margin_bottom(8); - lbl.set_margin_start(8); - row.set_child(Some(&lbl)); - list.append(&row); - return; - } - for snap in &snapshots { - let row = ListBoxRow::new(); - row.set_widget_name(&snap.number); - - let hbox = GBox::new(Orientation::Horizontal, 16); - hbox.set_margin_top(6); - hbox.set_margin_bottom(6); - hbox.set_margin_start(8); - hbox.set_margin_end(8); - - let num_lbl = Label::new(Some(&snap.number)); - num_lbl.set_width_chars(4); - num_lbl.set_xalign(0.0); - - let date_lbl = Label::new(Some(&snap.date)); - date_lbl.set_width_chars(22); - date_lbl.set_xalign(0.0); - - let desc_lbl = Label::new(Some(&snap.description)); - desc_lbl.set_hexpand(true); - desc_lbl.set_xalign(0.0); - - hbox.append(&num_lbl); - hbox.append(&date_lbl); - hbox.append(&desc_lbl); - row.set_child(Some(&hbox)); - list.append(&row); - } -} - -pub fn build() -> GBox { - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("Snapshots")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some( - "System snapshots created by snap-pac on each pacman transaction. \ - Boot into one from the GRUB menu to recover; delete old ones here.", - )); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(16); - vbox.append(&subtitle); - - let list = ListBox::new(); - list.set_selection_mode(gtk4::SelectionMode::Single); - populate_list(&list); - - let scroll = ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_child(Some(&list)); - vbox.append(&scroll); - - let btn_row = GBox::new(Orientation::Horizontal, 8); - btn_row.set_margin_top(12); - - let refresh_btn = Button::with_label("Refresh"); - let rollback_btn = Button::with_label("Boot into selected..."); - let delete_btn = Button::with_label("Delete selected"); - delete_btn.add_css_class("destructive-action"); - - { - let list = list.clone(); - refresh_btn.connect_clicked(move |_| { - populate_list(&list); - }); - } - - { - let list = list.clone(); - rollback_btn.connect_clicked(move |btn| { - let Some(row) = list.selected_row() else { return }; - let number = row.widget_name().to_string(); - if number.is_empty() { return } - - let window = btn - .root() - .and_then(|r| r.downcast::().ok()); - - // BOS boots with root pinned to a named subvolume (grub emits - // rootflags=subvol=@), so `snapper rollback`'s usual mechanism — - // switching the btrfs *default* subvolume — has no effect here; - // grub never consults it. The real, working way to get back to a - // snapshot on this layout is grub-btrfs (already installed + - // running via grub-btrfsd.service): it generates a GRUB submenu - // entry per snapshot, bootable directly. So this button doesn't - // touch the filesystem at all — it just points you at that menu. - let dialog = AlertDialog::builder() - .message(&format!("Boot into snapshot #{number}?")) - .detail("Snapshots on BOS are booted directly from the GRUB \ - menu (under \"BOS snapshots\"), not rolled back in \ - place. Reboot now and pick this snapshot there, or \ - later if you'd rather keep working — the menu entry \ - will still be there.") - .buttons(["Later", "Reboot now"]) - .cancel_button(0) - .default_button(0) - .build(); - - dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { - if result == Ok(1) { - let _ = Command::new("systemctl").args(["reboot"]).spawn(); - } - }); - }); - } - - { - let list = list.clone(); - delete_btn.connect_clicked(move |btn| { - let Some(row) = list.selected_row() else { return }; - let number = row.widget_name().to_string(); - if number.is_empty() { return } - - let window = btn - .root() - .and_then(|r| r.downcast::().ok()); - - let dialog = AlertDialog::builder() - .message(&format!("Delete snapshot #{number}?")) - .detail("This cannot be undone.") - .buttons(["Cancel", "Delete"]) - .cancel_button(0) - .default_button(0) - .build(); - - let window2 = window.clone(); - let list2 = list.clone(); - dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { - if result != Ok(1) { return } - - // snapper's DBus path authorizes via ALLOW_USERS, not pkexec — - // this only works because post-install.sh seeds that config - // key, but if it's ever missing this fails silently unless we - // check the exit status. GTK widgets aren't Send, so hand the - // outcome back over a channel rather than touching them from - // the thread (same pattern as the rollback flow used to). - let (tx, rx) = async_channel::bounded::(1); - std::thread::spawn(move || { - let ok = Command::new("snapper") - .args(["delete", &number]) - .status() - .map(|s| s.success()) - .unwrap_or(false); - let _ = tx.send_blocking(ok); - }); - - let list = list2.clone(); - let window = window2.clone(); - glib::spawn_future_local(async move { - let ok = rx.recv().await.unwrap_or(false); - if ok { - populate_list(&list); - } else { - let err = AlertDialog::builder() - .message("Delete failed") - .detail("snapper delete exited with an error — the \ - snapshot wasn't removed.") - .buttons(["OK"]) - .build(); - err.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, |_| {}); - } - }); - }); - }); - } - - btn_row.append(&refresh_btn); - btn_row.append(&rollback_btn); - btn_row.append(&delete_btn); - vbox.append(&btn_row); - - vbox -} diff --git a/bos-settings/src/ui/widgets.rs b/bos-settings/src/ui/widgets.rs deleted file mode 100644 index ca207da..0000000 --- a/bos-settings/src/ui/widgets.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! Reusable settings rows bound to a shared `toml_edit` document. -//! -//! Every row reads its current value from the document on build and writes the -//! single key it owns back into the document on change. A view collects rows, -//! then a [`save_button`] persists the whole document to disk in one shot — so -//! unmodelled keys and comments are always preserved (see `crate::config`). - -use std::cell::RefCell; -use std::path::PathBuf; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::{ - Adjustment, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation, - SpinButton, StringList, Switch, -}; -use toml_edit::DocumentMut; - -use crate::config; - -/// Shared, mutable config document handed to every row in a view. -pub type Doc = Rc>; - -/// A fixed key path into the document, e.g. `&["adapters", "power", "enabled"]`. -type Path = &'static [&'static str]; - -fn field_label(text: &str) -> Label { - let lbl = Label::new(Some(text)); - lbl.set_hexpand(true); - lbl.set_xalign(0.0); - lbl -} - -fn row(label: &str, control: &impl IsA) -> GBox { - let row = GBox::new(Orientation::Horizontal, 16); - row.append(&field_label(label)); - control.set_halign(gtk4::Align::End); - control.set_valign(gtk4::Align::Center); - row.append(control); - row -} - -/// A bold section heading with spacing above it. -pub fn section(text: &str) -> Label { - let lbl = Label::new(Some(text)); - lbl.add_css_class("heading"); - lbl.set_xalign(0.0); - lbl.set_margin_top(12); - lbl.set_margin_bottom(2); - lbl -} - -/// Small dimmed helper text under a section or row. -pub fn hint(text: &str) -> Label { - let lbl = Label::new(Some(text)); - lbl.add_css_class("dim-label"); - lbl.set_xalign(0.0); - lbl.set_wrap(true); - lbl.set_margin_bottom(4); - lbl -} - -/// Standard view scaffold: an outer vertical box with a title and a scrollable -/// content area. Append setting rows to the returned `content`, then append a -/// [`save_button`] to `outer`. Returns `(outer, content)`. -pub fn view_scaffold(title: &str) -> (GBox, GBox) { - let outer = GBox::new(Orientation::Vertical, 8); - outer.add_css_class("view-content"); - - let title_lbl = Label::new(Some(title)); - title_lbl.add_css_class("title"); - title_lbl.set_xalign(0.0); - outer.append(&title_lbl); - - let content = GBox::new(Orientation::Vertical, 8); - let scroll = gtk4::ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_hscrollbar_policy(gtk4::PolicyType::Never); - scroll.set_child(Some(&content)); - outer.append(&scroll); - - (outer, content) -} - -pub fn switch_row(label: &str, doc: &Doc, path: Path, default: bool) -> GBox { - let cur = config::get_bool(&doc.borrow(), path).unwrap_or(default); - let sw = Switch::new(); - sw.set_active(cur); - let doc = doc.clone(); - sw.connect_active_notify(move |s| { - config::set_bool(&mut doc.borrow_mut(), path, s.is_active()); - }); - row(label, &sw) -} - -pub fn entry_row(label: &str, doc: &Doc, path: Path, placeholder: &str, default: &str) -> GBox { - let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string()); - let entry = Entry::new(); - entry.set_text(&cur); - entry.set_hexpand(true); - entry.set_width_chars(28); - if !placeholder.is_empty() { - entry.set_placeholder_text(Some(placeholder)); - } - let doc = doc.clone(); - entry.connect_changed(move |e| { - config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str()); - }); - row(label, &entry) -} - -pub fn password_row(label: &str, doc: &Doc, path: Path) -> GBox { - let cur = config::get_str(&doc.borrow(), path).unwrap_or_default(); - let entry = Entry::new(); - entry.set_text(&cur); - entry.set_visibility(false); - entry.set_hexpand(true); - entry.set_width_chars(28); - entry.set_input_purpose(gtk4::InputPurpose::Password); - let doc = doc.clone(); - entry.connect_changed(move |e| { - config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str()); - }); - row(label, &entry) -} - -/// A dropdown that stores the selected option string at `path`. -pub fn dropdown_row(label: &str, doc: &Doc, path: Path, options: &[&str], default: &str) -> GBox { - let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string()); - let model = StringList::new(options); - let dd = DropDown::new(Some(model), Expression::NONE); - let sel = options.iter().position(|o| *o == cur).unwrap_or(0) as u32; - dd.set_selected(sel); - let owned: Vec = options.iter().map(|s| s.to_string()).collect(); - let doc = doc.clone(); - dd.connect_selected_notify(move |dd| { - if let Some(opt) = owned.get(dd.selected() as usize) { - config::set_str(&mut doc.borrow_mut(), path, opt); - } - }); - row(label, &dd) -} - -/// An integer spin button storing its value at `path`. -pub fn spin_row( - label: &str, - doc: &Doc, - path: Path, - min: f64, - max: f64, - step: f64, - default: i64, -) -> GBox { - let cur = config::get_i64(&doc.borrow(), path).unwrap_or(default); - let adj = Adjustment::new(cur as f64, min, max, step, step, 0.0); - let spin = SpinButton::new(Some(&adj), step, 0); - let doc = doc.clone(); - spin.connect_value_changed(move |s| { - config::set_i64(&mut doc.borrow_mut(), path, s.value() as i64); - }); - row(label, &spin) -} - -/// A fractional spin button (e.g. 0.0–1.0 confidence) storing a float. -pub fn spin_f64_row( - label: &str, - doc: &Doc, - path: Path, - min: f64, - max: f64, - step: f64, - digits: u32, - default: f64, -) -> GBox { - let cur = config::get_f64(&doc.borrow(), path).unwrap_or(default); - let adj = Adjustment::new(cur, min, max, step, step, 0.0); - let spin = SpinButton::new(Some(&adj), step, digits); - let doc = doc.clone(); - spin.connect_value_changed(move |s| { - config::set_f64(&mut doc.borrow_mut(), path, s.value()); - }); - row(label, &spin) -} - -/// A comma-separated list editor storing an array of strings at `path`. -pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox { - let cur = config::get_str_list(&doc.borrow(), path).join(", "); - let entry = Entry::new(); - entry.set_text(&cur); - entry.set_hexpand(true); - entry.set_width_chars(28); - if !placeholder.is_empty() { - entry.set_placeholder_text(Some(placeholder)); - } - let doc = doc.clone(); - entry.connect_changed(move |e| { - let items: Vec = e - .text() - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - config::set_str_list(&mut doc.borrow_mut(), path, &items); - }); - row(label, &entry) -} - -/// A Save button + transient status label that persists the document to `path`. -pub fn save_button(doc: &Doc, path: PathBuf) -> GBox { - let btn_row = GBox::new(Orientation::Horizontal, 12); - btn_row.set_margin_top(16); - - let save_btn = Button::with_label("Save"); - save_btn.add_css_class("suggested-action"); - let status = Label::new(None); - status.add_css_class("dim-label"); - - let doc = doc.clone(); - let status_c = status.clone(); - save_btn.connect_clicked(move |_| match config::save_doc(&path, &doc.borrow()) { - Ok(()) => { - status_c.set_text("Saved"); - let lbl = status_c.clone(); - glib::timeout_add_seconds_local(3, move || { - lbl.set_text(""); - glib::ControlFlow::Break - }); - } - Err(e) => status_c.set_text(&format!("Error: {e}")), - }); - - btn_row.append(&save_btn); - btn_row.append(&status); - btn_row -} diff --git a/bos-settings/src/ui/window.rs b/bos-settings/src/ui/window.rs deleted file mode 100644 index bdcf89e..0000000 --- a/bos-settings/src/ui/window.rs +++ /dev/null @@ -1,60 +0,0 @@ -use gtk4::prelude::*; -use gtk4::{Application, ApplicationWindow, Orientation, Paned, Stack}; - -use super::sidebar; -use super::views; - -pub fn build_ui(app: &Application) { - let window = ApplicationWindow::builder() - .application(app) - .title("BOS Settings") - .default_width(960) - .default_height(640) - .build(); - - crate::theme::load(&WidgetExt::display(&window)); - - let hpaned = Paned::new(Orientation::Horizontal); - hpaned.set_position(190); - hpaned.set_shrink_start_child(false); - hpaned.set_resize_start_child(false); - - let (sidebar_box, list) = sidebar::build(); - - let stack = Stack::new(); - stack.set_hexpand(true); - stack.set_vexpand(true); - - stack.add_named(&views::snapshots::build(), Some("snapshots")); - stack.add_named(&views::packages::build(), Some("packages")); - stack.add_named(&views::bread::build(), Some("bread")); - stack.add_named(&views::breadbar::build(), Some("breadbar")); - stack.add_named(&views::breadbox::build(), Some("breadbox")); - stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs")); - stack.add_named(&views::breadpad::build(), Some("breadpad")); - stack.add_named(&views::breadpaper::build(), Some("breadpaper")); - stack.add_named(&views::breadsearch::build(), Some("breadsearch")); - stack.add_named(&views::hyprland::build(), Some("hyprland")); - - // Default to the bread panel — Snapshots was previously first, an odd - // first impression for a settings app named after the bread ecosystem. - stack.set_visible_child_name("bread"); - - { - let stack = stack.clone(); - list.connect_row_selected(move |_, row| { - if let Some(row) = row { - let name = row.widget_name(); - if !name.is_empty() { - stack.set_visible_child_name(&name); - } - } - }); - } - - hpaned.set_start_child(Some(&sidebar_box)); - hpaned.set_end_child(Some(&stack)); - - window.set_child(Some(&hpaned)); - window.present(); -} diff --git a/build-local.sh b/build-local.sh index 942476a..4b95661 100755 --- a/build-local.sh +++ b/build-local.sh @@ -25,14 +25,19 @@ OUT="${OUT:-$REPO/out}" STAGE=/tmp/bos-iso-stage rm -rf "$STAGE" && cp -a "$REPO/iso" "$STAGE" -# Rewrite the [breadway] pacman repo URL to the fastest reachable address. -# CI_BUILD=1 — container runs on hestia with --network=host; localhost:3002 is direct -# default — building on hermes; git.breadway.dev is flaky from there, use Tailscale -# Only ever rewrites the staged copy, never the committed pacman.conf. -if [ "${CI_BUILD:-0}" = "1" ]; then - sed -i 's#https://git.breadway.dev/api/packages/Breadway/arch/os#http://localhost:3002/api/packages/Breadway/arch/os#' "$STAGE/pacman.conf" -else - sed -i 's#https://git.breadway.dev/api/packages/Breadway/arch/os#http://100.66.238.26:3002/api/packages/Breadway/arch/os#' "$STAGE/pacman.conf" +# [breadway] now points at the signed public repo https://dl.breadway.dev/arch +# (SigLevel = Required) — no Forgejo-registry URL rewrite needed anymore. +# +# Trust the [breadway] repo key in *this* build host's pacman keyring so +# `pacstrap` can verify [breadway] packages while assembling the airootfs. +# The same key is baked into the image at etc/pacman.d/breadway-repo.asc and +# re-trusted on the live medium / installed target (calamares/post-install.sh). +BREADWAY_KEY_FPR="56203B86A110695AE7F310934AF3323D678EB5E2" +BREADWAY_KEY_SRC="$REPO/iso/airootfs/etc/pacman.d/breadway-repo.asc" +if ! pacman-key --list-keys "$BREADWAY_KEY_FPR" &>/dev/null; then + echo "=== trusting [breadway] repo key ($BREADWAY_KEY_FPR) in the host pacman keyring ===" + pacman-key --add "$BREADWAY_KEY_SRC" + pacman-key --lsign-key "$BREADWAY_KEY_FPR" fi if [ "${FAST_BUILD:-0}" = "1" ]; then @@ -41,89 +46,402 @@ if [ "${FAST_BUILD:-0}" = "1" ]; then fi grep airootfs_image_tool_options "$STAGE/profiledef.sh" -# --- Bake this laptop's bakery-installed bread ecosystem into /etc/skel ------- -# The bread apps are managed by bakery (which fetches release binaries from -# GitHub), not pacman. bakery needs DNS at install time, which the live/installed -# image doesn't have — so instead of running bakery on the target, we copy the -# exact binaries + bakery manifest this laptop already has into skel. Every user -# created from skel (the live user and the installed user) then gets the same -# versions `bakery list` reports here, fully offline. Copied at build time so the -# binaries never bloat the git repo and always track the current bakery state. -BREAD_BINS=(bakery bread breadd breadman breadbar breadbox breadbox-sync breadcrumbs breadpad breadpaper bread-theme breadmon breadsearch breadmill breadclip breadclipd breadshot) +# --- Bake this machine's bakery-installed bread ecosystem into the image ------ +# The bread desktop apps are bakery-managed (release binaries from +# dl.breadway.dev / GitHub), not pacman. bakery needs DNS at install time, +# which the live/installed image doesn't have — so instead of running bakery +# on the target, we copy the binaries + bakery manifest this builder already +# has. Builder home stays user-layout (~/.local); the *image* is system-prefix +# /usr/local so apps live on @ and ride snapper/grub-btrfs snapshots. +# installed.json + index cache stay per-user in skel. Copied at build time +# so the binaries never bloat the git repo. +# +# CI should prefer the stable bakery index when populating the builder home. +# Local builds still snapshot the builder. required_bins fail the bake if +# missing; optional_bins are skipped with a warning (a hollow ISO is worse +# than a failed build). A flat `bins` list is treated as all-required. +LOCKFILE="$REPO/iso/bread-lockfile.toml" +if [[ ! -f "$LOCKFILE" ]]; then + echo "ERROR: bakery lockfile missing: $LOCKFILE" >&2 + exit 1 +fi +eval "$(python3 - "$LOCKFILE" <<'PY' +import sys, tomllib +path = sys.argv[1] +with open(path, "rb") as f: + data = tomllib.load(f) +required = data.get("required_bins") +optional = data.get("optional_bins") or [] +if required is None: + required = data.get("bins") or data.get("binaries") +if not isinstance(required, list) or not required: + sys.exit(f"{path}: missing non-empty required_bins (or bins) list") +if not isinstance(optional, list): + sys.exit(f"{path}: optional_bins must be a list") +blocked = {"breadcast", "breadarr"} +for label, names in (("required_bins", required), ("optional_bins", optional)): + for b in names: + if not isinstance(b, str) or not b or "/" in b or b in (".", ".."): + sys.exit(f"{path}: invalid {label} name {b!r}") + if b in blocked: + sys.exit(f"{path}: {b} is not shipped on the ISO") +def emit(name, values): + print(f"{name}=(") + for v in values: + print(f" {v!r}") + print(")") +emit("REQUIRED_BINS", required) +emit("OPTIONAL_BINS", optional) +PY +)" +if [[ ${#REQUIRED_BINS[@]} -eq 0 ]]; then + echo "ERROR: $LOCKFILE produced an empty required bins list" >&2 + exit 1 +fi + LAPTOP_HOME="${LAPTOP_HOME:-$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)}" BAKERY_BIN="$LAPTOP_HOME/.local/bin" BAKERY_STATE="$LAPTOP_HOME/.local/state/bakery" BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery" -SKEL="$STAGE/airootfs/etc/skel" +BAKERY_SHARE="$LAPTOP_HOME/.local/share" +AIROOTFS="$STAGE/airootfs" +IMAGE_BIN="$AIROOTFS/usr/local/bin" +IMAGE_SHARE="$AIROOTFS/usr/local/share" +IMAGE_UNITS="$AIROOTFS/usr/lib/systemd/user" +SKEL="$AIROOTFS/etc/skel" echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ===" -install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery" -for b in "${BREAD_BINS[@]}"; do - install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b" +echo "lockfile: $LOCKFILE (${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional)" +echo "image prefix: /usr/local (bins $IMAGE_BIN, share $IMAGE_SHARE, units $IMAGE_UNITS)" + +missing=() +for b in "${REQUIRED_BINS[@]}"; do + if [[ ! -x "$BAKERY_BIN/$b" ]]; then + missing+=("$BAKERY_BIN/$b") + fi done -install -m 0644 "$BAKERY_STATE/installed.json" "$SKEL/.local/state/bakery/installed.json" +if [[ ${#missing[@]} -gt 0 ]]; then + echo "ERROR: bakery lockfile requires binaries that are missing on the builder:" >&2 + printf ' %s\n' "${missing[@]}" >&2 + echo "Install them with bakery (or stage them under $BAKERY_BIN) before baking." >&2 + echo "A hollow ISO is worse than a failed build." >&2 + exit 1 +fi + +BREAD_BINS=("${REQUIRED_BINS[@]}") +for b in "${OPTIONAL_BINS[@]}"; do + if [[ -x "$BAKERY_BIN/$b" ]]; then + BREAD_BINS+=("$b") + else + echo "WARN: optional lockfile bin missing, skipping: $BAKERY_BIN/$b" >&2 + fi +done + +install -d -m 0755 "$IMAGE_BIN" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery" +for b in "${BREAD_BINS[@]}"; do + install -m 0755 "$BAKERY_BIN/$b" "$IMAGE_BIN/$b" +done + +# Drop packages that are not in the lockfile (breadcast/breadarr must not +# appear installed when their binaries were deliberately left out). +python3 - "$BAKERY_STATE/installed.json" "$SKEL/.local/state/bakery/installed.json" "${BREAD_BINS[@]}" <<'PY' +import json, sys +src, dest, *bins = sys.argv[1:] +wanted = set(bins) +with open(src) as f: + data = json.load(f) +pkgs = data.get("packages", data) +if not isinstance(pkgs, dict): + sys.exit(f"{src}: expected packages object") +kept = {} +for name, pkg in pkgs.items(): + pbins = pkg.get("binaries") or [] + if name in wanted or any(b in wanted for b in pbins): + kept[name] = pkg +out = {"packages": kept} +if "track" in data: + out["track"] = data["track"] +with open(dest, "w") as f: + json.dump(out, f, indent=2) + f.write("\n") +print("installed.json packages:", ", ".join(sorted(kept)) or "(none)") +PY + # bakery fetches its package index from dl.breadway.dev (then a GitHub fallback), # but falls back to a cached index when both are unreachable. With no network/DNS # in the live/installed image, even `bakery list` errors unless that cache exists, # so bake it in too — then bakery works fully offline (list/info from cache; # install/update still need network, as expected). +if [[ ! -f "$BAKERY_CACHE/index.json" ]]; then + echo "ERROR: bakery index cache missing: $BAKERY_CACHE/index.json" >&2 + exit 1 +fi install -m 0644 "$BAKERY_CACHE/index.json" "$SKEL/.cache/bakery/index.json" -echo "baked: $(ls "$SKEL/.local/bin")" +echo "baked bins: $(ls "$IMAGE_BIN")" + +# --- Bake bakery data dirs the apps need offline ------------------------------ +# bakery extracts data_archive (breadhelp's content.tar.gz) to +# $prefix/share// and writes desktop entries + licenses next to it. +# Builder home is still ~/.local/share; copy into the image at +# /usr/local/share. Never laptop-local state (clipboard history, WebKit +# cache, bread sync-repo, models). +echo "=== baking bakery share/data into /usr/local/share ===" +BREADHELP_CONTENT="$BAKERY_SHARE/breadhelp/content" +if [[ ! -d "$BREADHELP_CONTENT" ]]; then + echo "ERROR: breadhelp content missing: $BREADHELP_CONTENT" >&2 + echo "bakery installs this from content.tar.gz into ~/.local/share/breadhelp/content on the builder" >&2 + echo "A breadhelp binary without content is a hollow ISO." >&2 + exit 1 +fi +install -d -m 0755 "$IMAGE_SHARE" +cp -a "$BAKERY_SHARE/breadhelp" "$IMAGE_SHARE/breadhelp" +echo " baked $IMAGE_SHARE/breadhelp/content" + +python3 - "$BAKERY_CACHE/index.json" "$BAKERY_SHARE" "$IMAGE_SHARE" "${BREAD_BINS[@]}" <<'PY' +import json, os, shutil, sys +index_path, src_share, dest_share, *bins = sys.argv[1:] +wanted = set(bins) +try: + with open(index_path) as f: + idx = json.load(f) + packages = idx.get("packages", {}) +except (OSError, json.JSONDecodeError): + packages = {} + +# Package names we ship: lockfile bin names plus index packages that +# publish at least one of those bins. +pkg_names = set(wanted) +for name, pkg in packages.items(): + pbins = [] + for b in pkg.get("binaries") or []: + n = b["name"] if isinstance(b, dict) else b + pbins.append(str(n).removesuffix("-x86_64")) + if name in wanted or any(b in wanted for b in pbins): + pkg_names.add(name) + +os.makedirs(os.path.join(dest_share, "applications"), exist_ok=True) +os.makedirs(os.path.join(dest_share, "licenses"), exist_ok=True) + +for name in sorted(pkg_names): + pkg = packages.get(name) or {} + if pkg.get("data_archive"): + src = os.path.join(src_share, name) + dest = os.path.join(dest_share, name) + if name == "breadhelp": + continue # already copied above, required + if os.path.isdir(src): + if os.path.exists(dest): + shutil.rmtree(dest) + shutil.copytree(src, dest, symlinks=True) + print(f" baked data dir {dest}") + else: + sys.exit(f"ERROR: bakery data_archive for {name} missing at {src}") + + desktop_src = os.path.join(src_share, "applications", f"{name}.desktop") + desktop_dest = os.path.join(dest_share, "applications", f"{name}.desktop") + if os.path.isfile(desktop_src) and not os.path.isfile(desktop_dest): + shutil.copy2(desktop_src, desktop_dest) + print(f" baked desktop {desktop_dest}") + + lic_src = os.path.join(src_share, "licenses", name) + lic_dest = os.path.join(dest_share, "licenses", name) + if os.path.isdir(lic_src) and not os.path.isdir(lic_dest): + shutil.copytree(lic_src, lic_dest, symlinks=True) + print(f" baked license {lic_dest}") +PY # --- Bake systemd user services for bakery-managed bread packages ----------- # Historically only breadd.service was hand-committed to skel; every other # bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was # silently left out, so those daemons never start on a fresh install/live # boot until the user re-runs `bakery install` (which needs network). -# Generalize from the same source of truth as the binary bake above: read -# the services this laptop's bakery actually installed, copy each unit file -# into skel with ExecStart rewritten from this laptop's literal home path to -# the portable `%h` specifier, and recreate whichever *.target.wants enable -# symlink bakery created locally. Units already committed by hand (breadd.service -# carries a RuntimeDirectoryPreserve=yes fix not yet upstreamed — see bread-release-build -# notes) are left alone rather than overwritten. -echo "=== baking bakery service units into skel ===" +# Units come from installed.json + the bakery index + local unit files +# whose ExecStart is a lockfile binary (installed.json has omitted +# breadcrumbs.service before). Units go to /usr/lib/systemd/user with +# ExecStart rewritten to /usr/local/bin. Recreate whichever +# *.target.wants enable symlink bakery created locally (or that skel +# already ships), and write /etc/systemd/user/*.wants/ (--global). +# Hand-committed skel units (breadd.service carries a +# RuntimeDirectoryPreserve=yes fix not yet upstreamed) are the source +# for that unit and also get their ExecStart rewritten in skel. +echo "=== baking bakery service units into /usr/lib/systemd/user ===" SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user" SKEL_SYSTEMD="$SKEL/.config/systemd/user" -mapfile -t SERVICE_UNITS < <(python3 -c " -import json -with open('$BAKERY_STATE/installed.json') as f: - d = json.load(f) -for pkg in d.get('packages', d).values(): - for s in pkg.get('services', []): - print(s) -") +install -d -m 0755 "$IMAGE_UNITS" +# installed.json on the builder can omit a service even when the index and +# the local unit file exist (breadcrumbs has done this). Merge all three +# so every lockfile daemon is baked and can be --global enabled. +mapfile -t SERVICE_UNITS < <(python3 - \ + "$SKEL/.local/state/bakery/installed.json" \ + "$BAKERY_CACHE/index.json" \ + "$SYSTEMD_USER_DIR" \ + "${BREAD_BINS[@]}" <<'PY' +import json, os, sys + +installed_path, index_path, user_dir, *bins = sys.argv[1:] +wanted = set(bins) +units = set() + +def add_svc(svc): + name = svc["unit"] if isinstance(svc, dict) else svc + if not name or str(name).startswith(("breadcast", "breadarr")): + return + units.add(str(name)) + +if os.path.isfile(installed_path): + with open(installed_path) as f: + data = json.load(f) + for pkg in data.get("packages", data).values(): + if isinstance(pkg, dict): + for svc in pkg.get("services") or []: + add_svc(svc) + +if os.path.isfile(index_path): + with open(index_path) as f: + idx = json.load(f) + for name, pkg in (idx.get("packages") or {}).items(): + if not isinstance(pkg, dict): + continue + pbins = [] + for b in pkg.get("binaries") or []: + n = b["name"] if isinstance(b, dict) else b + pbins.append(str(n).removesuffix("-x86_64")) + if name in wanted or any(b in wanted for b in pbins): + for svc in pkg.get("services") or []: + add_svc(svc) + +if os.path.isdir(user_dir): + for fn in os.listdir(user_dir): + if not fn.endswith(".service"): + continue + path = os.path.join(user_dir, fn) + if not os.path.isfile(path): + continue + try: + text = open(path).read() + except OSError: + continue + for line in text.splitlines(): + if line.lstrip().startswith("ExecStart="): + argv0 = line.split("=", 1)[1].split() + if argv0 and os.path.basename(argv0[0]) in wanted: + add_svc(fn) + break + +for unit in sorted(units): + print(unit) +PY +) +if [[ ! " ${SERVICE_UNITS[*]} " =~ " breadd.service " ]]; then + echo "ERROR: breadd.service not in the bakery unit list — refusing to bake" >&2 + exit 1 +fi +rewrite_exec_start() { + local src="$1" dest="$2" + python3 - "$src" "$dest" <<'PY' +import os, sys +src, dest = sys.argv[1], sys.argv[2] +text = open(src).read() +lines = [] +for line in text.splitlines(): + if line.lstrip().startswith("ExecStart="): + key, rest = line.split("=", 1) + argv = rest.split() + if argv: + name = os.path.basename(argv[0]) + argv[0] = "/usr/local/bin/" + name + line = key + "=" + " ".join(argv) + lines.append(line) +out = "\n".join(lines) +if text.endswith("\n"): + out += "\n" +os.makedirs(os.path.dirname(dest), exist_ok=True) +with open(dest, "w") as f: + f.write(out) +PY +} for unit in "${SERVICE_UNITS[@]}"; do + [[ -n "$unit" ]] || continue if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then - echo " $unit already committed in skel, leaving as-is" - continue + src="$SKEL_SYSTEMD/$unit" + echo " $unit using committed skel unit as source" + else + src="$SYSTEMD_USER_DIR/$unit" + if [[ ! -f "$src" ]]; then + echo "ERROR: $unit listed as a bakery service but not found at $src" >&2 + echo "Refusing to bake an image whose daemons will never start." >&2 + exit 1 + fi fi - src="$SYSTEMD_USER_DIR/$unit" - if [[ ! -f "$src" ]]; then - echo " warning: $unit not found at $src, skipping" - continue + rewrite_exec_start "$src" "$IMAGE_UNITS/$unit" + if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then + rewrite_exec_start "$src" "$SKEL_SYSTEMD/$unit" fi - install -d -m 0755 "$SKEL_SYSTEMD" - sed "s#ExecStart=$LAPTOP_HOME/.local/bin/#ExecStart=%h/.local/bin/#" "$src" > "$SKEL_SYSTEMD/$unit" - for wants_dir in "$SYSTEMD_USER_DIR"/*.target.wants; do - [[ -L "$wants_dir/$unit" ]] || continue - target_name="$(basename "$wants_dir")" - install -d -m 0755 "$SKEL_SYSTEMD/$target_name" - ln -sf "../$unit" "$SKEL_SYSTEMD/$target_name/$unit" + for base in "$SYSTEMD_USER_DIR" "$SKEL_SYSTEMD"; do + [[ -d "$base" ]] || continue + for wants_dir in "$base"/*.target.wants; do + [[ -e "$wants_dir" || -L "$wants_dir" ]] || continue + [[ -L "$wants_dir/$unit" ]] || continue + target_name="$(basename "$wants_dir")" + install -d -m 0755 "$IMAGE_UNITS/$target_name" + ln -sf "../$unit" "$IMAGE_UNITS/$target_name/$unit" + done done - echo " baked $unit" + # systemctl --global enable equivalent: /etc/systemd/user/.wants/ + # so the live image and a later useradd inherit the unit without a per-home + # enable. Vendor wants above are extra; this is what --global writes. + python3 - "$IMAGE_UNITS/$unit" "$AIROOTFS/etc/systemd/user" "$unit" <<'PY' +import os, sys +unit_path, etc_user, unit = sys.argv[1:] +in_install = False +targets = [] +for line in open(unit_path): + s = line.strip() + if s.startswith("[") and s.endswith("]"): + in_install = s == "[Install]" + continue + if in_install and s.startswith("WantedBy="): + targets.extend(t for t in s.split("=", 1)[1].split() if t) +for target in targets: + wants = os.path.join(etc_user, f"{target}.wants") + os.makedirs(wants, exist_ok=True) + dest = os.path.join(wants, unit) + if os.path.lexists(dest): + os.remove(dest) + os.symlink(f"/usr/lib/systemd/user/{unit}", dest) + print(f" global enable {unit} -> {dest}") +PY + echo " baked $unit -> $IMAGE_UNITS/$unit" done +# Document the baked set. The committed preset is the fallback; the staged +# copy lists whatever this bake actually shipped. +preset_dest="$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" +install -d -m 0755 "$(dirname "$preset_dest")" +{ + echo "# Bakery systemd --user units baked into this image." + echo "# Applied by systemctl --global enable (post-install + live setup)" + echo "# so a later useradd starts them on first login." + echo "# breadclipd is also started from hyprland.lua: WantedBy=" + echo "# graphical-session.target is not reached on BOS (no uwsm)." + for unit in "${SERVICE_UNITS[@]}"; do + [[ -n "$unit" ]] || continue + printf 'enable %s\n' "$unit" + done +} >"$preset_dest" +echo " wrote $preset_dest" + # mkarchiso resets every airootfs file to 0644, so executables must be declared # in profiledef.sh's file_permissions array or they ship non-executable and the # exec-once launches fail with "permission denied". Inject a 0755 entry for each -# baked binary right after the array opener (keeps the binary list in one place). +# baked bakery binary right after the array opener (bos-* bins are already +# listed; keeps the bakery list in one place — the lockfile). perm_file="$(mktemp)" for b in "${BREAD_BINS[@]}"; do - printf ' ["/etc/skel/.local/bin/%s"]="0:0:755"\n' "$b" >>"$perm_file" + printf ' ["/usr/local/bin/%s"]="0:0:755"\n' "$b" >>"$perm_file" done sed -i "/^file_permissions=(/r $perm_file" "$STAGE/profiledef.sh" rm -f "$perm_file" -echo "=== file_permissions after injection ==="; grep -A14 '^file_permissions=(' "$STAGE/profiledef.sh" +echo "=== file_permissions after injection ==="; grep -A40 '^file_permissions=(' "$STAGE/profiledef.sh" # Pin one timestamp for the whole build. Without this, mkarchiso derives the # boot-config UUID (%ARCHISO_UUID%) when it starts and the iso9660 volume UUID diff --git a/docs/hardware.md b/docs/hardware.md new file mode 100644 index 0000000..cf6ebbe --- /dev/null +++ b/docs/hardware.md @@ -0,0 +1,46 @@ +# Hardware and recovery + +## GPUs + +BOS ships the generic **Mesa** stack. AMD and Intel work out of the box. + +The proprietary NVIDIA driver is **not on the ISO**. NVIDIA firmware is +not on the image either (`linux-firmware-nvidia` stays commented out in +`packages.x86_64`). Default Hyprland env is vendor-neutral. + +On first graphical login, `bos-first-boot` probes `lspci` / `/proc` and, +if an NVIDIA GPU is present, writes `~/.local/state/bos/nvidia-offer.json` +and notifies that the proprietary driver is not on the ISO. It does **not** +install anything. + +The optional proprietary path is `bos-nvidia-setup` or the Settings → +Updates NVIDIA button. That installs `nvidia` + `nvidia-utils` (never +cuda) and writes `~/.config/hypr/nvidia.lua`. `hyprland.lua` dofiles that +drop-in **only if the file exists**, so Mesa machines stay unchanged. +Reboot after. Installing the packages by hand without the drop-in is not +enough for a working Hyprland session. + +The same probe leaves a HiDPI hint at `~/.local/state/bos/hidpi-hint.json` +when scale > 1 or the panel is dense; it never rewrites `monitors.json`. +A VM without `/dev/dri` gets a notification only. + +## Recovery + +An update that breaks the system is recovered by **reboot → GRUB +“snapshots” submenu** (grub-btrfs). `snapper rollback` will not change +what GRUB boots (`rootflags=subvol=@`). + +`snapper rollback` swaps the default subvolume; the installed `grub.cfg` +still boots `@`. Pick the grub-btrfs entry so the kernel command line +matches the snapshot you want. + +BOS Settings → Snapshots lists snapshot number, date, and description so +you know which GRUB entry to pick. It does not roll the running root back +in place. Bakery desktop apps live under `/usr/local` on `@`, so those +same snapshots include them. + +If the system will not boot (lost EFI entry / broken GRUB), boot the live +ISO and run `sudo bos-rescue`. It mounts `@` + the ESP and offers the same +`grub-install` NVRAM + `--removable` sequence as `post-install.sh`. + +A/B root swapping is not implemented. See the README Recovery section. diff --git a/docs/signed-repo.md b/docs/signed-repo.md new file mode 100644 index 0000000..bda8656 --- /dev/null +++ b/docs/signed-repo.md @@ -0,0 +1,169 @@ +# Signed `[breadway]` repo + +**Status: live.** The ISO's `[breadway]` section is `SigLevel = Required` +and points at `https://dl.breadway.dev/arch/$arch`, where every +`.pkg.tar.zst` and the db carry a detached `.sig` from the BOS release key +(`56203B86…`, `KEYS.asc`, `releases@breadway.dev`). That key is trusted in +the pacman keyring at build time (`build-local.sh`), on the live medium +(`iso/airootfs/root/customize_airootfs.sh`), and on the installed target +(`iso/airootfs/etc/calamares/post-install.sh`). + +Forgejo publishing is unchanged: `package.yml` / packaging workflows still +PUT unsigned `.pkg.tar.zst` to Forgejo's Arch registry. The signed tree at +`dl.breadway.dev/arch` is rebuilt from that registry by +`.forgejo/workflows/signed-repo.yml` + `scripts/ci-publish-signed-repo.sh`. + +The rest of this doc is the original stand-up / verification procedure, +kept for reference and for re-verifying after key rotation. + +## Stand up `dl.breadway.dev/arch` + +CI job: **Publish signed `[breadway]` repo** +(`.forgejo/workflows/signed-repo.yml`), host runner on hestia — **no +container**, so it can write `/srv/breadway-dl` like bakery releases. +breadlock `package.yml` uses `archlinux:latest` and cannot see host `/srv`. + +Use the same release-signing key already in CI: + +- Public half: [`KEYS.asc`](../KEYS.asc) + (`5620 3B86 A110 695A E7F3 1093 4AF3 323D 678E B5E2`, + `releases@breadway.dev`) +- Private half: the `GPG_PRIVATE_KEY` Forgejo secret (armoured secret key, + no passphrase). Same secret `release-iso.yml` uses to sign `SHA256SUMS`. + The workflow **fails** if this secret is missing. + +Layout (example for `x86_64`): + +``` +https://dl.breadway.dev/arch/x86_64/ + breadlock--1-x86_64.pkg.tar.zst + breadlock--1-x86_64.pkg.tar.zst.sig + breadway.db + breadway.db.sig + breadway.files + breadway.files.sig +``` + +On disk: `/srv/breadway-dl/arch/x86_64/` (nginx already serves +`/srv/breadway-dl` as `https://dl.breadway.dev/`). + +The job collects the current ISO `[breadway]` set from the Forgejo Arch +registry (breadlock + calamares, zen-browser-bin, bibata-cursor-theme-bin, +zsh-theme-powerlevel10k, yay-bin, python-pywal). Leftover bakery-channel pacman packages +still sitting in that registry are **not** copied. Optional +`BREADWAY_PKG_DIR` on the runner overrides individual files. + +Then it detach-signs each `.pkg.tar.zst` as a **binary** sidecar (pacman +wants `.sig`, not armoured `.asc`) and builds the database with +`repo-add -s`: + +```sh +export GNUPGHOME=/tmp/gnupg-breadway-repo +mkdir -m 700 -p "$GNUPGHOME" +printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import + +gpg --batch --yes --local-user releases@breadway.dev \ + --detach-sign breadlock--1-x86_64.pkg.tar.zst +# → breadlock--1-x86_64.pkg.tar.zst.sig + +cd /srv/breadway-dl/arch/x86_64 +repo-add -s -k releases@breadway.dev breadway.db.tar.gz *.pkg.tar.zst +``` + +`repo-add -s` writes `breadway.db.tar.gz.sig` (and the `.files` pair). +Pacman fetches `
.db` + `
.db.sig` from `Server`. + +## Dispatch the workflow + +Forgejo UI: **Actions → "Publish signed [breadway] repo" → Run workflow**. +Select `main`. + +API (`workflow_dispatch`): + +```sh +curl -fsS -X POST \ + -H "Authorization: token ${RELEASE_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://git.breadway.dev/api/v1/repos/Breadway/bos/actions/workflows/signed-repo.yml/dispatches" \ + -d '{"ref":"main"}' +``` + +It also runs after the in-repo AUR republish workflows complete +(`calamares` / `bibata` / `powerlevel10k` / `yay-bin` / `python-pywal`). breadlock lives in +another repo; that job can fire this one with `repository_dispatch` event +`publish-signed-repo` (or dispatch from the UI after a breadlock tag). + +## Verify + +Confirm the signed db is actually served **before** touching ISO +`SigLevel` or `Server`: + +```sh +curl -fsSIL https://dl.breadway.dev/arch/x86_64/breadway.db +curl -fsSIL https://dl.breadway.dev/arch/x86_64/breadway.db.sig +``` + +Both must be HTTP 200. A 404 on `breadway.db.sig` means do **not** flip +`SigLevel` to `Required`. + +Import `KEYS.asc` and check the detached signatures: + +```sh +gpg --import KEYS.asc +curl -fsSL -o /tmp/breadway.db https://dl.breadway.dev/arch/x86_64/breadway.db +curl -fsSL -o /tmp/breadway.db.sig https://dl.breadway.dev/arch/x86_64/breadway.db.sig +gpg --verify /tmp/breadway.db.sig /tmp/breadway.db +``` + +On a throwaway Arch box (not the ISO tree): + +```sh +sudo pacman-key --add KEYS.asc +sudo pacman-key --lsign-key 56203B86A110695AE7F310934AF3323D678EB5E2 + +# Temporary /etc/pacman.conf snippet — do not commit this to the ISO: +# [breadway] +# SigLevel = Required +# Server = https://dl.breadway.dev/arch/$arch + +sudo pacman -Sy +``` + +`pacman -Sy` must fetch `breadway.db` + `breadway.db.sig` without +"missing or invalid signature". Then `pacman -Si breadlock` (and the AUR +republishes) should list the `[breadway]` section. + +## breadlock `package.yml` sidecar + +[`breadlock` `package.yml`](https://git.breadway.dev/Breadway/breadlock/src/branch/main/.forgejo/workflows/package.yml) +still `makepkg`s and PUTs the archive at Forgejo's registry. That path +stays; Never installs keep working. The signed tree is rebuilt by the bos +workflow above (registry fetch + sign + `repo-add -s`), not by writing +`/srv` from breadlock's container. + +## The ISO flip (done) + +All three steps have landed: + +1. **Key trusted.** The public key is committed at + `iso/airootfs/etc/pacman.d/breadway-repo.asc`. `build-local.sh` + `pacman-key --add` + `--lsign-key`s it into the build host keyring; + `customize_airootfs.sh` does the same in the airootfs; + `calamares/post-install.sh` re-does it in the target chroot. +2. **`Server`** in `iso/pacman.conf` and `iso/airootfs/etc/pacman.conf` + points at `https://dl.breadway.dev/arch/$arch`, section renamed to + `[breadway]` (matching `breadway.db`). +3. **`SigLevel = Required`** on that section. + +### Re-verify after any build + +In a VM booted from a fresh ISO: + +```sh +sudo pacman -Sy # must fetch breadway.db + .sig, no signature error +sudo pacman -Si breadlock # lists the [breadway] section +sudo pacman -S --noconfirm yay-bin # installs with no key prompt +``` + +Then run the installer and, on the installed system, `sudo pacman -Sy` +again — the target keyring must already trust `56203B86…`. diff --git a/dotfiles/README.md b/dotfiles/README.md new file mode 100644 index 0000000..8e61cc6 --- /dev/null +++ b/dotfiles/README.md @@ -0,0 +1,13 @@ +# `dotfiles/` is not the live skel + +These files are a leftover from an earlier design (Hyprland `.conf` binds). +They are **not** copied into the ISO or the installed system. + +`dotfiles/hypr/keybinds.conf` still mentions `grimblast`. That is not the +screenshot tool BOS ships — live binds use **breadshot** +(`iso/airootfs/etc/skel/.config/hypr/binds.json`). Do not treat grimblast +as current. + +Live user defaults live in [`iso/airootfs/etc/skel`](../iso/airootfs/etc/skel) +(`hyprland.lua` + `binds.json`, breadlock/`loginctl lock-session`, breadshot, +breadpad, …). Edit that tree. diff --git a/dotfiles/hypr/keybinds.conf b/dotfiles/hypr/keybinds.conf index 7cf8cdd..a857962 100644 --- a/dotfiles/hypr/keybinds.conf +++ b/dotfiles/hypr/keybinds.conf @@ -1,3 +1,7 @@ +# STALE — not the live Hyprland binds. Not copied into the ISO. +# Screenshots are breadshot (see iso/airootfs/etc/skel/.config/hypr/binds.json), +# not grimblast. Do not copy from this file. + $mod = SUPER # App launchers diff --git a/iso/airootfs/etc/bakery/config.toml b/iso/airootfs/etc/bakery/config.toml new file mode 100644 index 0000000..996b04a --- /dev/null +++ b/iso/airootfs/etc/bakery/config.toml @@ -0,0 +1,2 @@ +# Bakery desktop apps live under /usr/local so they ride snapper @ snapshots. +prefix = "/usr/local" diff --git a/iso/airootfs/etc/calamares/branding/bos/branding.desc b/iso/airootfs/etc/calamares/branding/bos/branding.desc index 91c929f..4076b93 100644 --- a/iso/airootfs/etc/calamares/branding/bos/branding.desc +++ b/iso/airootfs/etc/calamares/branding/bos/branding.desc @@ -9,10 +9,10 @@ strings: versionedName: "BOS (rolling)" shortVersionedName: "BOS" bootloaderEntryName: "BOS" - productUrl: "https://github.com/Breadway/bos" - supportUrl: "https://github.com/Breadway/bos/issues" - knownIssuesUrl: "https://github.com/Breadway/bos/issues" - releaseNotesUrl: "https://github.com/Breadway/bos/releases" + productUrl: "https://git.breadway.dev/Breadway/bos" + supportUrl: "https://git.breadway.dev/Breadway/bos/issues" + knownIssuesUrl: "https://git.breadway.dev/Breadway/bos/issues" + releaseNotesUrl: "https://git.breadway.dev/Breadway/bos/releases" images: productLogo: "logo.png" diff --git a/iso/airootfs/etc/calamares/modules/packages.conf b/iso/airootfs/etc/calamares/modules/packages.conf index c327cb6..a3646e0 100644 --- a/iso/airootfs/etc/calamares/modules/packages.conf +++ b/iso/airootfs/etc/calamares/modules/packages.conf @@ -1,10 +1,24 @@ --- +# Optional online pacman refresh. The previous packages step used +# update_db:true with no skip/ignore, so `pacman -Sy` aborted offline +# installs (the case bos-netcheck exists for). skip_if_no_internet +# skips the whole module when Calamares sees no network; +# ignore_update_db_error keeps a flake-mirror -Sy from failing the +# install. update_system stays false — this is not a -Syu. +# +# try_install is empty: pipewire-pulse / pipewire-alsa already come +# from packages.x86_64 via unpackfs. No extra packages (and no +# nvidia) are pulled here. backend: pacman -options: - - update_db: true +skip_if_no_internet: true +update_db: true +ignore_update_db_error: true +update_system: false -operations: - - try_install: - - pipewire-pulse - - pipewire-alsa +pacman: + num_retries: 1 + disable_download_timeout: false + needed_only: true + +operations: [] diff --git a/iso/airootfs/etc/calamares/modules/partition.conf b/iso/airootfs/etc/calamares/modules/partition.conf index 13e6e1e..1ea258b 100644 --- a/iso/airootfs/etc/calamares/modules/partition.conf +++ b/iso/airootfs/etc/calamares/modules/partition.conf @@ -19,3 +19,15 @@ userSwapChoices: - small - suspend - file + +# Full-disk encryption (LUKS) is enabled by default in Calamares' partition +# module (enableLuksAutomatedPartitioning defaults to true) — the checkbox +# already shows on the "Erase disk" page with no config needed here. Pin the +# LUKS generation explicitly rather than relying on Calamares' own implicit +# default: GRUB doesn't support LUKS2 + Argon2id, only PBKDF2, and using the +# wrong KDF produces an encrypted install GRUB can't unlock at boot. luks1 +# is unconditionally safe with BOS's plain grub-install setup (no separate +# unencrypted /boot — GRUB itself has to unlock the LUKS container to read +# the kernel). See post-install.sh for the matching cryptsetup/mkinitcpio/ +# GRUB wiring this actually needs to be bootable. +luksGeneration: luks1 diff --git a/iso/airootfs/etc/calamares/modules/welcome.conf b/iso/airootfs/etc/calamares/modules/welcome.conf index 33bf7ad..c31ea71 100644 --- a/iso/airootfs/etc/calamares/modules/welcome.conf +++ b/iso/airootfs/etc/calamares/modules/welcome.conf @@ -3,9 +3,19 @@ showSupportUrl: false showKnownIssuesUrl: false showReleaseNotesUrl: false +# 3.4.2 schema: `check` is shown; only `required` blocks Next. Internet is +# informational so offline installs proceed. Do not probe archlinux.org. requirements: requiredStorage: 20 requiredRam: 2.0 - checkInternet: true - checkPower: true - internetCheckUrl: "https://archlinux.org" + internetCheckUrl: "https://breadway.dev" + check: + - storage + - ram + - power + - internet + - root + required: + - storage + - ram + - root diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index b7309db..d7df9be 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -8,7 +8,18 @@ # Best-effort: do NOT use `set -e`; a single failure here must not abort the rest. set -uo pipefail -MAIN_USER="$(getent passwd 1000 | cut -d: -f1 || true)" +# Whether Calamares encrypted the root partition (LUKS) — checked once here, +# used below to conditionally wire mkinitcpio's encrypt hook and GRUB's +# cryptodisk support. `lsblk TYPE` reports "crypt" for a cryptsetup-opened +# mapper device regardless of what Calamares named it, so this works whether +# the user picked automated "Erase disk" encryption or hand-encrypted a +# partition in manual mode. +ROOT_SRC="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" +if [[ "$(lsblk -no TYPE "$ROOT_SRC" 2>/dev/null)" == "crypt" ]]; then + ROOT_ENCRYPTED=1 +else + ROOT_ENCRYPTED=0 +fi # --------------------------------------------------------------------------- # Strip live-only bits that unpackfs copied verbatim from the live medium. @@ -20,6 +31,40 @@ rm -f /usr/local/bin/bos-live-setup /usr/local/bin/bos-launch-calamares rm -f /etc/sudoers.d/99-bos-live userdel -r liveuser 2>/dev/null || true +# Live ISO creates liveuser as UID 1000; Calamares then creates the real +# account as 1001. Capture AFTER userdel so Snapper ALLOW_USERS and skel +# copy the installed user, not the deleted live account. +MAIN_USER="$(getent passwd 1000 | cut -d: -f1 || true)" +if [[ -z "$MAIN_USER" || "$MAIN_USER" == "liveuser" ]]; then + MAIN_USER="$(getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $1 != "liveuser" { print $1; exit }')" +fi + +# unpackfs copies the entire live squashfs onto the target. Remove live-only +# packages (Calamares + archiso boot chain + memtest/EFI-shell payloads) so +# they do not stay on disk forever. pacman -Rs (not -Rns) keeps /etc configs +# and reaps newly-orphaned KF6/Qt6 deps. Each name is independent so one +# missing package cannot abort the rest. Offline-safe: never touches the +# network. qt6-declarative is NOT reaped — qt6-wayland still needs it. +LIVE_ONLY_PKGS=( + calamares + squashfs-tools + mkinitcpio-archiso + mkinitcpio-nfs-utils + memtest86+ + memtest86+-efi + edk2-shell + syslinux +) +for pkg in "${LIVE_ONLY_PKGS[@]}"; do + pacman -Qq "$pkg" &>/dev/null || continue + pacman -Rs --noconfirm "$pkg" &>/dev/null \ + || echo "WARN: could not remove live-only package $pkg" +done +while orphans="$(pacman -Qtdq 2>/dev/null)" && [[ -n "$orphans" ]]; do + # shellcheck disable=SC2086 + pacman -Rs --noconfirm $orphans &>/dev/null || break +done + # Root used a passwordless entry on the live medium; lock it (sudo model). passwd -l root || true @@ -28,12 +73,23 @@ passwd -l root || true # over to the target (unpackfs may skip it / perms differ), leaving the installed # system unable to verify package signatures — the first `pacman -Syu` then dies # with "keyring is not writable / required key missing". Initialise it here so a -# fresh install can update out of the box. archlinux-keyring is already present; -# [breadway] is SigLevel=Never so it needs no key. +# fresh install can update out of the box. archlinux-keyring verifies official +# Arch packages; the BOS release key (56203B86…, shipped at +# /etc/pacman.d/breadway-repo.asc) verifies the signed [breadway] repo at +# dl.breadway.dev/arch — SigLevel = Required there, every package and the db +# carry a .sig from it. # --------------------------------------------------------------------------- +BREADWAY_KEY_FPR="56203B86A110695AE7F310934AF3323D678EB5E2" if command -v pacman-key &>/dev/null; then pacman-key --init || echo "WARN: pacman-key --init failed" pacman-key --populate archlinux || echo "WARN: pacman-key --populate failed" + if [[ -f /etc/pacman.d/breadway-repo.asc ]]; then + pacman-key --add /etc/pacman.d/breadway-repo.asc \ + && pacman-key --lsign-key "$BREADWAY_KEY_FPR" \ + || echo "WARN: could not trust the [breadway] repo key — pacman -Sy will fail on [breadway]" + else + echo "WARN: /etc/pacman.d/breadway-repo.asc missing — [breadway] (SigLevel=Required) will not verify" + fi fi # --------------------------------------------------------------------------- @@ -52,11 +108,44 @@ if [[ -f /etc/mkinitcpio.conf ]]; then sed -i 's/^\(HOOKS=.*\bautodetect\b\)/\1 microcode/' /etc/mkinitcpio.conf \ || echo "WARN: adding microcode hook failed" fi + + # Current mkinitcpio's own shipped default template (verified against the + # actual package, not assumed) uses the systemd-based hook set + # (`HOOKS=(base systemd autodetect ... sd-vconsole block filesystems + # fsck)`), NOT the classic udev-based one — there is no literal "udev" + # token to match against on a stock install. The two hook sets are + # mutually exclusive alternatives (systemd substitutes for udev as the + # base hook providing the init program), and each has its own + # counterpart for anything that hooks into device/root setup: + # plymouth is the same either way, but LUKS unlocking needs `encrypt` + # under udev and `sd-encrypt` under systemd. Detect which is in play + # once and use the matching hook, instead of assuming udev (which + # silently no-ops the sed on every current install — this was already + # true for the plymouth insertion below before this fix, just never + # surfaced because it fails quietly). + if grep -qE '^HOOKS=.*\bsystemd\b' /etc/mkinitcpio.conf; then + BASE_HOOK="systemd" + ENCRYPT_HOOK="sd-encrypt" + else + BASE_HOOK="udev" + ENCRYPT_HOOK="encrypt" + fi + if command -v plymouth-set-default-theme &>/dev/null \ && ! grep -qE '^HOOKS=.*\bplymouth\b' /etc/mkinitcpio.conf; then - sed -i 's/^\(HOOKS=.*\budev\b\)/\1 plymouth/' /etc/mkinitcpio.conf \ + sed -i "s/^\(HOOKS=.*\b${BASE_HOOK}\b\)/\1 plymouth/" /etc/mkinitcpio.conf \ || echo "WARN: adding plymouth hook failed" fi + # encrypt/sd-encrypt — only when root is actually LUKS-encrypted + # (ROOT_ENCRYPTED, detected above). Must sit after `block` (provides the + # device nodes it opens) and before `filesystems` (mounts the now- + # unlocked root) — both already present in stock mkinitcpio.conf's + # default HOOKS regardless of which base hook is in use. + if [[ "$ROOT_ENCRYPTED" == "1" ]] \ + && ! grep -qE '^HOOKS=.*\bencrypt\b' /etc/mkinitcpio.conf; then + sed -i "s/^\(HOOKS=.*\bblock\b\)/\1 ${ENCRYPT_HOOK}/" /etc/mkinitcpio.conf \ + || echo "WARN: adding ${ENCRYPT_HOOK} hook failed" + fi fi # --------------------------------------------------------------------------- @@ -74,6 +163,17 @@ if command -v plymouth-set-default-theme &>/dev/null; then plymouth-set-default-theme bos || echo "WARN: plymouth-set-default-theme failed" fi +# GRUB needs to unlock LUKS itself to reach the kernel — there's no separate +# unencrypted /boot partition (only /boot/efi is separate). GRUB_ENABLE_CRYPTODISK +# makes grub-mkconfig emit the cryptomount commands grub.cfg needs; the +# --modules flags below (on both grub-install calls) make sure the cryptodisk +# and luks decoders are actually compiled into core.img, not just referenced. +if [[ "$ROOT_ENCRYPTED" == "1" ]] && [[ -f /etc/default/grub ]] \ + && ! grep -q '^GRUB_ENABLE_CRYPTODISK=' /etc/default/grub; then + echo 'GRUB_ENABLE_CRYPTODISK=y' >> /etc/default/grub \ + || echo "WARN: adding GRUB_ENABLE_CRYPTODISK failed" +fi + # Rebuild every preset (default + fallback that bos-copy-kernel wrote) so the # microcode + plymouth HOOKS above are actually baked into the initramfs. mkinitcpio -P || echo "WARN: mkinitcpio -P failed" @@ -95,18 +195,20 @@ mkinitcpio -P || echo "WARN: mkinitcpio -P failed" # BIOS: MBR install onto the disk hosting /. # --------------------------------------------------------------------------- if command -v grub-install &>/dev/null; then + CRYPT_MODULES=() + [[ "$ROOT_ENCRYPTED" == "1" ]] && CRYPT_MODULES=(--modules="cryptodisk luks luks2") if [[ -d /sys/firmware/efi ]]; then grub-install --target=x86_64-efi --efi-directory=/boot/efi \ - --bootloader-id=BOS --recheck \ + --bootloader-id=BOS --recheck "${CRYPT_MODULES[@]}" \ || echo "WARN: grub-install (nvram) failed" grub-install --target=x86_64-efi --efi-directory=/boot/efi \ - --removable --recheck \ + --removable --recheck "${CRYPT_MODULES[@]}" \ || echo "WARN: grub-install (removable) failed" else ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" ROOT_DISK="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null)" if [[ -n "$ROOT_DISK" ]]; then - grub-install --target=i386-pc --recheck "/dev/$ROOT_DISK" \ + grub-install --target=i386-pc --recheck "${CRYPT_MODULES[@]}" "/dev/$ROOT_DISK" \ || echo "WARN: grub-install (BIOS) failed" else echo "WARN: could not determine the disk hosting / (root device: ${ROOT_DEV:-unknown}) — BIOS grub-install skipped" @@ -117,6 +219,33 @@ if command -v grub-mkconfig &>/dev/null; then grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed" fi +# --------------------------------------------------------------------------- +# Secure Boot: self-signed keys via sbctl, only when the firmware is already +# in Setup Mode (no vendor PK enrolled — the state a fresh/never-used +# machine boots in, or one where the user cleared their firmware's keys +# before installing). BOS can't ship a Microsoft-signed shim — that requires +# going through Microsoft's own paid UEFI CA signing process — so this is +# the realistic path for an Arch-based distro: generate our own keys, enroll +# them (plus Microsoft's, so a dual-booted Windows bootmgr and fwupd's +# signed capsule updates still verify), and sign the kernel + GRUB. sbctl's +# own package ships a pacman hook (zz-sbctl.hook) that re-signs everything +# automatically on every future kernel/GRUB update — nothing else to wire up. +# Best-effort and silent-skip (not a WARN) when out of Setup Mode — that's +# the expected state on most real hardware, not a failure. +# --------------------------------------------------------------------------- +if [[ -d /sys/firmware/efi ]] && command -v sbctl &>/dev/null; then + SETUP_MODE="$(sbctl status --json 2>/dev/null | python3 -c \ + 'import json,sys; print(json.load(sys.stdin).get("setup_mode", False))' 2>/dev/null)" + if [[ "$SETUP_MODE" == "True" ]]; then + sbctl create-keys || echo "WARN: sbctl create-keys failed" + sbctl enroll-keys --microsoft || echo "WARN: sbctl enroll-keys failed" + sbctl sign-all -g || echo "WARN: sbctl sign-all failed" + echo "Secure Boot: keys enrolled and boot files signed." + else + echo "Secure Boot: firmware not in Setup Mode — skipped (run 'sudo sbctl enroll-keys --microsoft && sudo sbctl sign-all -g' manually later if desired)." + fi +fi + # --------------------------------------------------------------------------- # Create @snapshots, @log, @cache as top-level btrfs subvolumes (peers of @, # not nested under it — so snapshots of @ don't recursively include @@ -186,38 +315,64 @@ fi # transiently fail inside the Calamares chroot (the same mount unmounts # cleanly moments later once booted normally — a chroot-specific busy-mount # race, not a logic error), which cascades into snapper create-config -# refusing because .snapshots "already exists". Retry a few times, then -# fall back to a lazy unmount (detaches the mountpoint immediately even if -# something still transiently references it) rather than give up. +# refusing because .snapshots "already exists". A single pass — retry the +# umount a few times, then fall back to a lazy unmount — was NOT enough on +# real hardware: also confirmed is a run where every umount attempt in that +# single pass failed (including the lazy fallback settling too slowly for +# the immediately-following rmdir/create-config), leaving +# /etc/snapper/configs/ completely empty and BOS's advertised snapshot/ +# rollback feature silently non-functional on that install. So retry the +# WHOLE dance, not just the umount substep, and verify at the end that the +# config file actually exists before declaring success. # --------------------------------------------------------------------------- if command -v snapper &>/dev/null; then - unmounted=0 - for _ in 1 2 3 4 5; do - if umount /.snapshots 2>/dev/null; then - unmounted=1 - break + for attempt in 1 2 3; do + [[ -f /etc/snapper/configs/root ]] && break + + unmounted=0 + for _ in 1 2 3 4 5; do + if umount /.snapshots 2>/dev/null; then + unmounted=1 + break + fi + sleep 1 + done + if [[ "$unmounted" != "1" ]]; then + echo "WARN: umount /.snapshots failed after retries (attempt $attempt), forcing lazy unmount" + umount -l /.snapshots || echo "WARN: lazy umount /.snapshots also failed (attempt $attempt)" + # Lazy unmount detaches the mountpoint from the namespace right + # away, but whatever was holding it busy may take a moment + # longer to actually let go — rmdir/create-config right after + # this both fail if anything still references /.snapshots. + sleep 2 fi - sleep 1 + rmdir /.snapshots 2>/dev/null || echo "WARN: rmdir /.snapshots failed (attempt $attempt)" + snapper -c root create-config / || echo "WARN: snapper create-config failed (attempt $attempt)" + if [[ -d /.snapshots ]]; then + btrfs subvolume delete /.snapshots || echo "WARN: deleting snapper's own .snapshots subvolume failed (attempt $attempt)" + fi + mkdir -p /.snapshots + mount /.snapshots || echo "WARN: remounting the real @snapshots subvolume failed (attempt $attempt)" + + [[ -f /etc/snapper/configs/root ]] || sleep 2 done - if [[ "$unmounted" != "1" ]]; then - echo "WARN: umount /.snapshots failed after retries, forcing lazy unmount" - umount -l /.snapshots || echo "WARN: lazy umount /.snapshots also failed" - fi - rmdir /.snapshots || echo "WARN: rmdir /.snapshots failed" - snapper -c root create-config / || echo "WARN: snapper create-config failed" - if [[ -d /.snapshots ]]; then - btrfs subvolume delete /.snapshots || echo "WARN: deleting snapper's own .snapshots subvolume failed" - fi - mkdir -p /.snapshots - mount /.snapshots || echo "WARN: remounting the real @snapshots subvolume failed" + if [[ -f /etc/snapper/configs/root ]]; then sed -i 's/TIMELINE_CREATE="yes"/TIMELINE_CREATE="no"/' /etc/snapper/configs/root sed -i 's/NUMBER_CLEANUP="no"/NUMBER_CLEANUP="yes"/' /etc/snapper/configs/root sed -i 's/NUMBER_MIN_AGE="[^"]*"/NUMBER_MIN_AGE="1800"/' /etc/snapper/configs/root sed -i 's/NUMBER_LIMIT="[^"]*"/NUMBER_LIMIT="10"/' /etc/snapper/configs/root sed -i 's/NUMBER_LIMIT_IMPORTANT="[^"]*"/NUMBER_LIMIT_IMPORTANT="5"/' /etc/snapper/configs/root + # set-config (not sed) — snapper's own template text for this line has + # drifted across versions before, and a sed that doesn't match just + # silently no-ops, leaving ALLOW_USERS empty and every non-root + # `snapper` call (bos-settings' Snapshots page included) failing with + # "No permissions." forever. set-config is the stable API regardless + # of template wording. [[ -n "$MAIN_USER" ]] && \ - sed -i "s/ALLOW_USERS=\"\"/ALLOW_USERS=\"$MAIN_USER\"/" /etc/snapper/configs/root + snapper -c root set-config "ALLOW_USERS=$MAIN_USER" + else + echo "ERROR: snapper config for root still missing after 3 attempts — snapshots/rollback will not work on this install" fi fi @@ -229,15 +384,39 @@ fi # greetd — graphical login (shipped disabled; live uses tty autologin) # grub-btrfsd — regenerates GRUB snapshot entries (the unit is grub-btrfsd.service, # NOT grub-btrfs.path, which no longer exists) +# avahi-daemon.service → avahi-daemon.socket: the package ships both; socket +# activation still answers nss-mdns and CUPS discovery but stays off the idle +# RSS until something asks. The host stops announcing itself over mDNS until +# the socket is first touched. # --------------------------------------------------------------------------- for unit in NetworkManager.service bluetooth.service systemd-timesyncd.service \ tlp.service greetd.service snapper-cleanup.timer grub-btrfsd.service \ - fstrim.timer cups.socket avahi-daemon.service ufw.service \ + fstrim.timer cups.socket avahi-daemon.socket ufw.service \ fwupd-refresh.timer reflector.timer; do systemctl enable "$unit" || echo "WARN: failed to enable $unit" done systemctl set-default graphical.target || echo "WARN: set-default graphical failed" +# Arch's 90-systemd.preset enables systemd-homed / userdbd / nsresourced. +# BOS creates classic /etc/passwd accounts and never calls homectl. Mask +# (not disable) so preset-all or a systemd upgrade cannot re-enable them. +for unit in systemd-homed.service systemd-homed-activate.service \ + systemd-userdbd.service systemd-userdbd.socket \ + systemd-nsresourced.service systemd-nsresourced.socket; do + systemctl mask "$unit" || echo "WARN: failed to mask $unit" +done + +# journald defaults SystemMaxUse to 10% of the filesystem holding /var/log. +# /var/log is the @log subvolume of the root pool, so that ceiling is tens +# of GB. Cap it; less history for postmortems. +install -d -m 0755 /etc/systemd/journald.conf.d +cat >/etc/systemd/journald.conf.d/90-bos-journal.conf <<'JOURNALEOF' +[Journal] +SystemMaxUse=256M +SystemMaxFileSize=32M +RuntimeMaxUse=32M +JOURNALEOF + # --------------------------------------------------------------------------- # mDNS resolution (nss-mdns): insert mdns_minimal into the hosts: line so the # resolver answers *.local (network printers, other hosts) via avahi. Idempotent. @@ -259,11 +438,21 @@ if command -v ufw &>/dev/null; then ufw --force enable || echo "WARN: ufw enable failed" fi -# The bread ecosystem (bakery + bread, breadbar, breadbox, breadcrumbs, breadpad) -# is bakery-managed, not pacman: the binaries and bakery manifest live in -# /etc/skel/.local (baked in at ISO build time) and are copied into the user's -# home below, so the install works fully offline with no DNS for bakery/GitHub. -# bos-settings is the only pacman bread package and was installed by unpackfs. +# The whole bread ecosystem (bakery, bread, breadbar, breadbox, breadcrumbs, +# breadpad, bos-settings, breadhelp, ...) is bakery-managed, not pacman: +# binaries, share/data, and user units are baked into /usr/local and +# /usr/lib/systemd/user (system prefix). Per-user bakery state (installed.json +# + index cache) is seeded from /etc/skel/.local and copied into the user's +# home below, so the install works fully offline with no DNS for bakery. +# +# systemd --user units in /usr/lib/systemd/user are not enabled for new +# accounts unless enabled --global (or the user enables them). Do that here +# so a later `useradd -m` starts breadd / breadbox-sync / breadclipd / +# breadcrumbs / breadmill on first login. Safe if the helper is missing. +if [[ -x /usr/local/bin/bos-enable-bakery-user-units ]]; then + /usr/local/bin/bos-enable-bakery-user-units \ + || echo "WARN: enabling bakery user units globally failed" +fi # --------------------------------------------------------------------------- # Deploy dotfiles + the bakery bread ecosystem into the user's home (Calamares diff --git a/iso/airootfs/etc/calamares/settings.conf b/iso/airootfs/etc/calamares/settings.conf index 8872182..1daec33 100644 --- a/iso/airootfs/etc/calamares/settings.conf +++ b/iso/airootfs/etc/calamares/settings.conf @@ -35,12 +35,6 @@ sequence: - users - networkcfg - hwclock - # packages module removed: it set update_db:true with no - # skip_if_no_internet/ignore_update_db_error, so an offline install (the - # exact case bos-welcome's nmtui step exists for) aborted here with a - # fatal pacman -Sy failure. Its only try_install packages (pipewire-pulse, - # pipewire-alsa) are already in packages.x86_64 and installed by - # unpackfs, so the step did nothing useful even when it succeeded. # archiso strips the kernel from the squashfs; stage it, drop the archiso # initramfs config, and write a stock mkinitcpio preset before initcpio runs. - shellprocess@kernel @@ -57,6 +51,12 @@ sequence: # BOS finalization: GRUB install + cleanup + snapper + services + dotfiles. # All fast, and runs after initcpio so /boot has the kernel + initramfs. - shellprocess + # Optional online pacman -Sy. After post-install so the target keyring + # exists. skip_if_no_internet + ignore_update_db_error: an offline + # install (or a flake-mirror -Sy) must not abort. operations is empty — + # pipewire-pulse/alsa already come from unpackfs; nothing extra (and + # no nvidia) is installed here. + - packages - umount - show: - finished diff --git a/iso/airootfs/etc/default/useradd b/iso/airootfs/etc/default/useradd index f16b7d8..4cae86c 100644 --- a/iso/airootfs/etc/default/useradd +++ b/iso/airootfs/etc/default/useradd @@ -3,5 +3,8 @@ GROUP=users HOME=/home INACTIVE=-1 EXPIRE= +# useradd -m copies Hyprland + bakery per-user state from here. Bakery +# binaries live in /usr/local/bin (not skel). User units are enabled +# --global so a second account starts them on first login. SKEL=/etc/skel CREATE_MAIL_SPOOL=no diff --git a/iso/airootfs/etc/greetd/breadgreet.toml b/iso/airootfs/etc/greetd/breadgreet.toml index 311dfad..24b48a4 100644 --- a/iso/airootfs/etc/greetd/breadgreet.toml +++ b/iso/airootfs/etc/greetd/breadgreet.toml @@ -7,8 +7,9 @@ # alongside BOS's own bos.desktop, and breadgreet's session picker matches by # .desktop file stem — with no override it picks "hyprland.desktop" over # "bos.desktop", which skips bos-session's PATH fixup (adds ~/.local/bin for -# the bakery bread apps; greetd starts no login shell, so /etc/profile.d is -# never sourced any other way). Confirmed via breadgreet's own test suite +# per-user tools; bakery apps are in /usr/local/bin). greetd starts no login +# shell, so /etc/profile.d is never sourced any other way. Confirmed via +# breadgreet's own test suite # (sessions.rs: discover_prefers_configured_default_over_first_entry). [sessions] diff --git a/iso/airootfs/etc/os-release b/iso/airootfs/etc/os-release index 4f28e07..f6b380c 100644 --- a/iso/airootfs/etc/os-release +++ b/iso/airootfs/etc/os-release @@ -5,7 +5,7 @@ ID_LIKE=arch BUILD_ID=rolling ANSI_COLOR="38;2;23;147;209" HOME_URL="https://breadway.dev" -DOCUMENTATION_URL="https://wiki.archlinux.org/" -SUPPORT_URL="https://bbs.archlinux.org/" +DOCUMENTATION_URL="https://git.breadway.dev/Breadway/bos" +SUPPORT_URL="https://git.breadway.dev/Breadway/bos/issues" BUG_REPORT_URL="https://git.breadway.dev/Breadway/bos/issues" -PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/" +PRIVACY_POLICY_URL="https://breadway.dev" diff --git a/iso/airootfs/etc/pacman.conf b/iso/airootfs/etc/pacman.conf index 20c5242..2f53c10 100644 --- a/iso/airootfs/etc/pacman.conf +++ b/iso/airootfs/etc/pacman.conf @@ -26,20 +26,23 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist # ----------------------------------------------------------------------- -# Breadway custom repo — provides: bakery and the bread ecosystem packages -# (bread, breadbar, breadbox, breadcrumbs, breadpad, bos-settings). -# (calamares comes from the official extra repo, not here.) +# Breadway custom repo — breadlock plus AUR republishes the ISO needs +# (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin, +# zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp +# are NOT here; they are bakery-baked into /usr/local at ISO build time. # # Packages are published to the Forgejo Arch registry (group "os") by the -# .forgejo/workflows/package.yml workflow in each repo, on tag push. +# .forgejo/workflows/*.yml workflows; scripts/ci-publish-signed-repo.sh then +# collects them, detach-signs each .pkg.tar.zst with the BOS release key +# (releases@breadway.dev), runs `repo-add -s`, and publishes the signed db +# at https://dl.breadway.dev/arch/$arch (signed-repo.yml). # -# Forgejo signs the repo db with a key pacman can't look up, so TrustAll -# fails. SigLevel = Never skips verification (acceptable for this private -# repo over TLS). Future improvement: import Forgejo's signing key and -# switch to SigLevel = Required for full package verification. +# SigLevel = Required: every package AND the db carry a .sig from key +# 56203B86A110695AE7F310934AF3323D678EB5E2 — the same key committed as +# KEYS.asc / etc/pacman.d/breadway-repo.asc, imported into the pacman +# keyring at build time (build-local.sh), on the live medium, and on the +# installed target (calamares/post-install.sh). # ----------------------------------------------------------------------- -# The section name must match Forgejo's served db filename -# ({owner}.{group}.{domain}.db) — pacman fetches "
.db" from Server. -[Breadway.os.git.breadway.dev] -SigLevel = Never -Server = https://git.breadway.dev/api/packages/Breadway/arch/os/$arch +[breadway] +SigLevel = Required +Server = https://dl.breadway.dev/arch/$arch diff --git a/iso/airootfs/etc/pacman.d/breadway-repo.asc b/iso/airootfs/etc/pacman.d/breadway-repo.asc new file mode 100644 index 0000000..fe380fd --- /dev/null +++ b/iso/airootfs/etc/pacman.d/breadway-repo.asc @@ -0,0 +1,15 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEakhwGhYJKwYBBAHaRw8BAQdA/sZ/GYec5M2MD+w20mVF5tMUhGji210Dg7zL +TAhNsg60WUJPUyBSZWxlYXNlIFNpZ25pbmcgKGdpdC5icmVhZHdheS5kZXYvQnJl +YWR3YXkvYm9zIHJlbGVhc2VzIG9ubHkpIDxyZWxlYXNlc0BicmVhZHdheS5kZXY+ +iJYEExYKAD4WIQRWIDuGoRBpWufzEJNK8zI9Z4614gUCakhwGgIbIwUJA8JnAAUL +CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBK8zI9Z4614ggYAQDP8FTZ14i9YPKD +ARvZuP5QaYOUFhQ8uyG0CowXKy9O0AEAqYfjnvyJI3N651pVFSNUXyP16w1kMPSs +K0g3CLsztQ+4OARqSHAaEgorBgEEAZdVAQUBAQdAuJFuy2GHz5m9wXTm/PdSpLE9 +gERwHOLyM1OFuttrJW4DAQgHiH4EGBYKACYWIQRWIDuGoRBpWufzEJNK8zI9Z461 +4gUCakhwGgIbDAUJA8JnAAAKCRBK8zI9Z4614nzLAP9grcIFsAAeCyVKhziHmpXq +E0Hm6FfIr4sdEf63HZkyfwD/XeKeWfb3EWvVsloJrZZ9tDmR67iK52Hwl82wfFAU +cAo= +=Mrh1 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/iso/airootfs/etc/profile.d/bos-local-bin.sh b/iso/airootfs/etc/profile.d/bos-local-bin.sh index 642af43..734db46 100644 --- a/iso/airootfs/etc/profile.d/bos-local-bin.sh +++ b/iso/airootfs/etc/profile.d/bos-local-bin.sh @@ -1,8 +1,8 @@ -# Put the per-user bakery bin dir on PATH. The bread ecosystem (breadd, breadbar, -# breadbox, …) is installed there by bakery, and the Hyprland session launches -# them via `exec-once`, which resolves against the PATH it inherits from the -# login shell. Arch's stock /etc/profile does not add ~/.local/bin, so do it here -# for every login shell (live user and installed user alike). +# Keep ~/.local/bin on PATH for per-user tools. Arch already includes +# /usr/local/bin (where bakery desktop apps live on BOS). The Hyprland +# session resolves exec-once against the PATH it inherits from the login +# shell; Arch's stock /etc/profile does not add ~/.local/bin, so do it +# here for every login shell (live user and installed user alike). case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) export PATH="$HOME/.local/bin:$PATH" ;; diff --git a/iso/airootfs/etc/skel/.cache/wal/wal b/iso/airootfs/etc/skel/.cache/wal/wal new file mode 100644 index 0000000..69adeb9 --- /dev/null +++ b/iso/airootfs/etc/skel/.cache/wal/wal @@ -0,0 +1 @@ +/usr/share/backgrounds/bos/bread-background.png \ No newline at end of file diff --git a/iso/airootfs/etc/skel/.config/bread/modules/breadhelp-suggest.lua b/iso/airootfs/etc/skel/.config/bread/modules/breadhelp-suggest.lua new file mode 100644 index 0000000..c73a66c --- /dev/null +++ b/iso/airootfs/etc/skel/.config/bread/modules/breadhelp-suggest.lua @@ -0,0 +1,16 @@ +-- breadhelp-suggest — nudge breadhelp's Home tab banner when a bread event +-- suggests a relevant guide (e.g. a newly connected monitor -> breadmon +-- setup). Auto-discovered by breadd. If breadhelp isn't already running, +-- `--suggest ` launches it straight to Home with the banner focused; if +-- it's already running, the banner just updates silently (see +-- breadhelp's services/breadd.rs) rather than stealing focus on every event. + +local M = bread.module({ name = "breadhelp-suggest", version = "1.0.0" }) + +function M.on_load() + bread.on("bread.monitor.connected", function(event) + bread.exec("breadhelp --suggest monitor-setup") + end) +end + +return M diff --git a/iso/airootfs/etc/skel/.config/bread/modules/breadhelp-tour.lua b/iso/airootfs/etc/skel/.config/bread/modules/breadhelp-tour.lua new file mode 100644 index 0000000..74a29f3 --- /dev/null +++ b/iso/airootfs/etc/skel/.config/bread/modules/breadhelp-tour.lua @@ -0,0 +1,53 @@ +-- breadhelp-tour — forwards bread/Hyprland events the live guided tour +-- (breadhelp's ui::tour) uses to detect "the user actually did the thing" +-- and auto-advance a step. Always forwards; breadhelp's `--tour-event` +-- handler is a hard no-op unless a tour is currently waiting for that exact +-- id, so this module doesn't need to know whether a tour is even running. +-- +-- `bread.window.opened`/`bread.workspace.changed` are already normalized by +-- breadd. Layer-surface opens/closes (breadbox, breadclip, breadsearch) are +-- NOT normalized — `openlayer`/`closelayer` fall through to the generic +-- `bread.hyprland.event` topic, which `on_raw` filters by raw kind. +-- +-- Fullscreen click-catcher launchers (breadbox et al.) report their surface +-- as covering the whole monitor while open, so the tour can't safely show a +-- clickable callout at the same time — a step targeting one of these should +-- key its success on the *close* event (implying the user picked something +-- and it dismissed), not the open event, so the callout only ever reappears +-- once the launcher's surface is already gone and input contention is moot. + +local M = bread.module({ name = "breadhelp-tour", version = "1.0.0" }) + +-- `bread.exec` only takes a single shell command string — it always runs it +-- as `sh -lc ` (see breadd's Lua runtime), there's no array-exec form +-- that bypasses the shell. `event.data.class` (a Wayland window class) and +-- `event.data.data` (a layer-shell namespace) are both arbitrary strings a +-- client fully controls — a window/surface can name itself +-- `x; rm -rf ~ #` and have that land in a real shell command otherwise. +-- POSIX single-quoting neutralizes that: wrap the value in single quotes, +-- and turn any single quote *inside* it into `'\''` (close the quote, an +-- escaped literal quote, reopen the quote) — the one escaping rule `sh` +-- needs to treat the whole thing as inert data, never command syntax. +local function shell_quote(s) + return "'" .. tostring(s):gsub("'", "'\\''") .. "'" +end + +function M.on_load() + bread.on("bread.window.opened", function(event) + bread.exec("breadhelp --tour-event " .. shell_quote("window:" .. event.data.class)) + end) + + bread.on("bread.workspace.changed", function(event) + bread.exec("breadhelp --tour-event workspace-changed") + end) + + bread.hyprland.on_raw("openlayer", function(event) + bread.exec("breadhelp --tour-event " .. shell_quote("layer:" .. event.data.data)) + end) + + bread.hyprland.on_raw("closelayer", function(event) + bread.exec("breadhelp --tour-event " .. shell_quote("layer-closed:" .. event.data.data)) + end) +end + +return M diff --git a/iso/airootfs/etc/skel/.config/bread/modules/external-monitors.lua b/iso/airootfs/etc/skel/.config/bread/modules/external-monitors.lua new file mode 100644 index 0000000..27a276c --- /dev/null +++ b/iso/airootfs/etc/skel/.config/bread/modules/external-monitors.lua @@ -0,0 +1,228 @@ +-- external-monitors — behave like a normal laptop desktop +-- +-- Plug in any display (HDMI, DisplayPort, USB-C dock, a random TV) and +-- the session just works. No output names to edit. +-- +-- • the laptop panel stays at its preferred (native) mode +-- • each external uses its preferred mode and refresh +-- • new screens clone the laptop (set ARRANGE = "extend" to sit to the right) +-- • closing the lid does not sleep while an external is on +-- • unplug everything and the laptop is the only display again +-- +-- Drop-in: copy to ~/.config/bread/modules/ and `bread reload`. + +local M = bread.module({ + name = "external-monitors", + version = "1.0.0", + after = { "bread.monitors" }, +}) + +-- "mirror" = every external clones the laptop (presentations, TVs) +-- "extend" = extra desktop to the right +local ARRANGE = "mirror" +local SCALE = "auto" + +local INTERNAL_RE = "^eDP" +local INHIBITOR = "/tmp/bread-lid-inhibitor.pid" + +local function inhibit_lid() + if bread.fs.exists(INHIBITOR) then return end + bread.exec( + "bash -c 'systemd-inhibit --what=handle-lid-switch --who=bread " + .. "--why=external-display sleep infinity & echo $! > " + .. INHIBITOR + .. "'" + ) +end + +local function release_lid() + bread.exec( + "bash -c 'kill $(cat " .. INHIBITOR .. " 2>/dev/null) 2>/dev/null; rm -f " .. INHIBITOR .. "'" + ) +end + +local function is_internal(name) + return type(name) == "string" and name:match(INTERNAL_RE) ~= nil +end + +local function drm_status(name) + for card = 0, 5 do + local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/status", card, name)) + if raw then + return raw:match("^%s*(%S+)") + end + end + return nil +end + +local function drm_first_mode(name) + for card = 0, 5 do + local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/modes", card, name)) + if raw then + local w, h = raw:match("(%d+)x(%d+)") + if w then + return tonumber(w), tonumber(h) + end + end + end + return 1920, 1080 +end + +local function list_connectors() + local names = {} + local ok, out = bread.exec_capture("ls /sys/class/drm", { timeout_ms = 500 }) + if not ok or not out then + return names + end + for ent in out:gmatch("[^%s]+") do + local name = ent:match("^card%d+%-(.+)$") + if name and not name:match("^Writeback") then + names[#names + 1] = name + end + end + table.sort(names) + return names +end + +local function connected() + local internal, externals = nil, {} + for _, name in ipairs(list_connectors()) do + if drm_status(name) == "connected" then + if is_internal(name) then + internal = internal or name + else + externals[#externals + 1] = name + end + end + end + return internal or "eDP-1", externals +end + +-- BOS Hyprland talks Lua (`hl.monitor`). Stock Hyprland uses the +-- `monitor=` keyword. Try eval first, then keyword. +local function apply_monitor(opts) + local extra = "" + if opts.mirror and opts.mirror ~= "" then + extra = string.format(", mirror = %q", opts.mirror) + end + local expr = string.format( + "hl.monitor({ output = %q, mode = %q, position = %q, scale = %q%s })", + opts.output, + opts.mode or "preferred", + opts.position or "0x0", + opts.scale or SCALE, + extra + ) + local resp = bread.hyprland.eval(expr) + if type(resp) == "string" and resp:match("error") then + local spec = string.format( + "%s, %s, %s, %s", + opts.output, + opts.mode or "preferred", + opts.position or "0x0", + opts.scale or SCALE + ) + if opts.mirror and opts.mirror ~= "" then + spec = spec .. ", mirror, " .. opts.mirror + end + bread.hyprland.keyword("monitor", spec) + end +end + +local function apply(internal, externals) + apply_monitor({ + output = internal, + mode = "preferred", + position = "0x0", + scale = SCALE, + }) + + if ARRANGE == "mirror" then + for _, name in ipairs(externals) do + apply_monitor({ + output = name, + mode = "preferred", + position = "0x0", + scale = SCALE, + mirror = internal, + }) + end + return + end + + local x = select(1, drm_first_mode(internal)) or 1920 + for _, name in ipairs(externals) do + apply_monitor({ + output = name, + mode = "preferred", + position = x .. "x0", + scale = SCALE, + }) + local w = select(1, drm_first_mode(name)) or 1920 + x = x + w + end +end + +function M.on_load() + local last = nil + local applied = false + + local function evaluate() + local internal, externals = connected() + local sig = internal .. "|" .. table.concat(externals, ",") + if sig == last then + return + end + last = sig + + if #externals == 0 then + if applied then + apply_monitor({ + output = internal, + mode = "preferred", + position = "0x0", + scale = SCALE, + }) + release_lid() + applied = false + end + return + end + + apply(internal, externals) + inhibit_lid() + applied = true + bread.log("[external-monitors] " .. internal .. " + " .. table.concat(externals, ", ")) + end + + local settle = bread.debounce(1500, evaluate) + + bread.on("bread.hyprland.monitor.connected", function(event) + local name = event.data and event.data.name + if name and not is_internal(name) then + bread.notify("Display connected: " .. name, { urgency = "low" }) + end + settle() + end) + + bread.on("bread.hyprland.monitor.disconnected", function() + settle() + end) + + bread.on("bread.device.**", function(event) + local sub = event.data and event.data.subsystem + if sub == "drm" then + settle() + end + end) + + bread.hyprland.on_raw("configreloaded", function() + last = nil + evaluate() + end) + + bread.every(3000, evaluate) + settle() +end + +return M diff --git a/iso/airootfs/etc/skel/.config/hypr/autostart.json b/iso/airootfs/etc/skel/.config/hypr/autostart.json new file mode 100644 index 0000000..be2b4ac --- /dev/null +++ b/iso/airootfs/etc/skel/.config/hypr/autostart.json @@ -0,0 +1,16 @@ +{ + "extra": [ + { "command": "breadbar", "label": "Bar (breadbar)", "enabled": true }, + { "command": "hypridle", "label": "Idle / lock daemon (hypridle)", "enabled": true }, + { "command": "bos-netcheck", "label": "Network connectivity check", "enabled": true }, + { "command": "bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'", "label": "First-boot hardware probe", "enabled": true }, + { "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true }, + { "command": "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", "label": "Wallpaper command bus (breadpaper listen)", "enabled": true }, + { "command": "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", "label": "Screenshot command bus (breadshot listen)", "enabled": true }, + { "command": "bash -c 'command -v breadlock >/dev/null && exec breadlock listen'", "label": "Lock command bus (breadlock listen)", "enabled": true }, + { "command": "bash -c 'command -v breadbox >/dev/null && exec breadbox listen'", "label": "Launcher command bus (breadbox listen)", "enabled": true }, + { "command": "bash -c 'command -v breadhelp >/dev/null && exec breadhelp listen'", "label": "Help command bus (breadhelp listen)", "enabled": true }, + { "command": "bash -c 'command -v breadsearch >/dev/null && exec breadsearch listen'", "label": "Search command bus (breadsearch listen)", "enabled": true }, + { "command": "bash -c 'command -v breadpad >/dev/null && exec breadpad listen'", "label": "Capture command bus (breadpad listen)", "enabled": true } + ] +} diff --git a/iso/airootfs/etc/skel/.config/hypr/binds.json b/iso/airootfs/etc/skel/.config/hypr/binds.json new file mode 100644 index 0000000..67f19f4 --- /dev/null +++ b/iso/airootfs/etc/skel/.config/hypr/binds.json @@ -0,0 +1,89 @@ +{ + "default_mods": ["SUPER"], + "bindings": [ + { "action": "exec", "command": "kitty", "key": "RETURN", "label": "Open a terminal", "category": "apps" }, + { "action": "close", "key": "BACKSPACE", "label": "Close the focused window", "category": "windows" }, + { "action": "exec", "command": "breadbox", "key": "SPACE", "label": "Open the app launcher (breadbox)", "category": "apps", "demo_cmd": "breadbox" }, + { "action": "exec", "command": "nautilus", "key": "E", "label": "Open files (nautilus)", "category": "apps" }, + { "action": "exec", "command": "zen-browser", "key": "B", "label": "Open the browser (zen)", "category": "apps" }, + { "action": "exec", "command": "breadpad", "key": "U", "label": "Notes / reminders (breadpad)", "category": "apps", "demo_cmd": "breadpad" }, + { "action": "exec", "command": "breadman", "key": "M", "label": "Notes / task manager (breadman)", "category": "apps" }, + { "action": "exec", "command": "bos-settings", "key": "comma", "label": "Open BOS Settings", "category": "apps", "demo_cmd": "bos-settings" }, + { "action": "exec", "command": "breadhelp", "key": "slash", "label": "Show this keybind cheatsheet", "category": "apps" }, + { "action": "exec", "command": "loginctl lock-session", "key": "L", "label": "Lock screen", "category": "apps" }, + { "action": "fullscreen", "key": "F", "label": "Toggle fullscreen", "category": "windows" }, + { "action": "float", "key": "I", "label": "Toggle floating", "category": "windows" }, + { "action": "pseudo", "key": "P", "label": "Toggle pseudotile", "category": "windows" }, + { "action": "resize", "key": "R", "label": "Resize mode", "category": "windows" }, + + { "action": "exec", "command": "breadclip", "key": "V", "label": "Clipboard history (breadclip)", "category": "apps", "demo_cmd": "breadclip" }, + { "action": "exec", "command": "breadclip", "key": "V", "mods": ["SUPER", "SHIFT"], "label": "Clipboard history (breadclip) — same as SUPER + V", "category": "apps" }, + { "action": "exec", "command": "breadbar --history", "key": "N", "mods": ["SUPER", "SHIFT"], "label": "Notification history (breadbar)", "category": "apps", "demo_cmd": "breadbar --history" }, + + { "action": "layout", "layout": "togglesplit", "key": "T", "label": "Toggle split direction", "category": "windows" }, + { "action": "focus_last", "key": "Tab", "label": "Focus last window", "category": "windows" }, + { "action": "exit", "key": "N", "label": "Exit Hyprland (log out)", "category": "windows" }, + + { "action": "exec", "command": "breadshot region -o ~/Pictures/Screenshots", "key": "S", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: select region -> file", "category": "screenshots" }, + { "action": "exec", "command": "breadshot region --clipboard-only", "key": "C", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: select region -> clipboard", "category": "screenshots" }, + { "action": "exec", "command": "breadshot active-output -o ~/Pictures/Screenshots", "key": "P", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: whole active screen -> file", "category": "screenshots" }, + + { "action": "focus", "direction": "left", "key": "left", "label": "Move focus left", "category": "focus" }, + { "action": "focus", "direction": "right", "key": "right", "label": "Move focus right", "category": "focus" }, + { "action": "focus", "direction": "up", "key": "up", "label": "Move focus up", "category": "focus" }, + { "action": "focus", "direction": "down", "key": "down", "label": "Move focus down", "category": "focus" }, + + { "action": "move_dir", "direction": "left", "key": "h", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window left", "category": "focus" }, + { "action": "move_dir", "direction": "down", "key": "j", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window down", "category": "focus" }, + { "action": "move_dir", "direction": "up", "key": "k", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window up", "category": "focus" }, + { "action": "move_dir", "direction": "right", "key": "l", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window right", "category": "focus" }, + + { "action": "resize_dir", "x": 30, "y": 0, "key": "right", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow right)", "category": "focus" }, + { "action": "resize_dir", "x": -30, "y": 0, "key": "left", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow left)", "category": "focus" }, + { "action": "resize_dir", "x": 0, "y": -30, "key": "up", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow up)", "category": "focus" }, + { "action": "resize_dir", "x": 0, "y": 30, "key": "down", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow down)", "category": "focus" }, + + { "action": "focus", "workspace": 1, "key": "1", "label": "Switch to workspace 1", "category": "workspaces" }, + { "action": "focus", "workspace": 2, "key": "2", "label": "Switch to workspace 2", "category": "workspaces" }, + { "action": "focus", "workspace": 3, "key": "3", "label": "Switch to workspace 3", "category": "workspaces" }, + { "action": "focus", "workspace": 4, "key": "4", "label": "Switch to workspace 4", "category": "workspaces" }, + { "action": "focus", "workspace": 5, "key": "5", "label": "Switch to workspace 5", "category": "workspaces" }, + { "action": "focus", "workspace": 6, "key": "6", "label": "Switch to workspace 6", "category": "workspaces" }, + { "action": "focus", "workspace": 7, "key": "7", "label": "Switch to workspace 7", "category": "workspaces" }, + { "action": "focus", "workspace": 8, "key": "8", "label": "Switch to workspace 8", "category": "workspaces" }, + { "action": "focus", "workspace": 9, "key": "9", "label": "Switch to workspace 9", "category": "workspaces" }, + { "action": "focus", "workspace": 10, "key": "0", "label": "Switch to workspace 10", "category": "workspaces" }, + + { "action": "move", "workspace": 1, "key": "1", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 1", "category": "workspaces" }, + { "action": "move", "workspace": 2, "key": "2", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 2", "category": "workspaces" }, + { "action": "move", "workspace": 3, "key": "3", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 3", "category": "workspaces" }, + { "action": "move", "workspace": 4, "key": "4", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 4", "category": "workspaces" }, + { "action": "move", "workspace": 5, "key": "5", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 5", "category": "workspaces" }, + { "action": "move", "workspace": 6, "key": "6", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 6", "category": "workspaces" }, + { "action": "move", "workspace": 7, "key": "7", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 7", "category": "workspaces" }, + { "action": "move", "workspace": 8, "key": "8", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 8", "category": "workspaces" }, + { "action": "move", "workspace": 9, "key": "9", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 9", "category": "workspaces" }, + { "action": "move", "workspace": 10, "key": "0", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 10", "category": "workspaces" }, + + { "action": "focus", "workspace": "e+1", "key": "bracketright", "label": "Next workspace", "category": "workspaces" }, + { "action": "focus", "workspace": "e-1", "key": "bracketleft", "label": "Previous workspace", "category": "workspaces" }, + { "action": "move", "workspace": "e+1", "key": "bracketright", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to the next workspace", "category": "workspaces" }, + { "action": "move", "workspace": "e-1", "key": "bracketleft", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to the previous workspace", "category": "workspaces" }, + + { "action": "focus", "workspace": "e+1", "key": "mouse_down", "label": "Cycle to the next workspace (scroll)", "category": "mouse" }, + { "action": "focus", "workspace": "e-1", "key": "mouse_up", "label": "Cycle to the previous workspace (scroll)", "category": "mouse" }, + { "action": "drag", "key": "mouse:272", "options": { "mouse": true }, "label": "Move a window (drag)", "category": "mouse" }, + { "action": "resize", "key": "mouse:273", "options": { "mouse": true }, "label": "Resize a window (drag)", "category": "mouse" }, + + { "action": "exec", "command": "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", "key": "XF86AudioRaiseVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume up", "category": "media" }, + { "action": "exec", "command": "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", "key": "XF86AudioLowerVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume down", "category": "media" }, + { "action": "exec", "command": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle", "key": "XF86AudioMute", "mods": [], "options": { "locked": true }, "label": "Mute", "category": "media" }, + { "action": "exec", "command": "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle", "key": "XF86AudioMicMute", "mods": [], "options": { "locked": true }, "label": "Mic mute", "category": "media" }, + { "action": "exec", "command": "brightnessctl -e4 -n2 set 5%+", "key": "XF86MonBrightnessUp", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Brightness up", "category": "media" }, + { "action": "exec", "command": "brightnessctl -e4 -n2 set 5%-", "key": "XF86MonBrightnessDown", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Brightness down", "category": "media" }, + { "action": "exec", "command": "playerctl next", "key": "XF86AudioNext", "mods": [], "options": { "locked": true }, "label": "Next track", "category": "media" }, + { "action": "exec", "command": "playerctl previous", "key": "XF86AudioPrev", "mods": [], "options": { "locked": true }, "label": "Previous track", "category": "media" }, + { "action": "exec", "command": "playerctl play-pause", "key": "XF86AudioPlay", "mods": [], "options": { "locked": true }, "label": "Play / pause", "category": "media" }, + { "action": "exec", "command": "gnome-calculator", "key": "XF86Calculator", "mods": [], "label": "Open the calculator", "category": "media" } + ] +} diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index 9928148..cfa488b 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -1,61 +1,30 @@ -- BOS Hyprland configuration — native Lua config (Hyprland 0.55+). -- hyprlang (.conf) is deprecated; this uses the built-in `hl` API. --- Single-file and non-modular by design. Reference: https://wiki.hypr.land/ - -local mod = "SUPER" +-- Mostly single-file by design (reference: https://wiki.hypr.land/) — the +-- exceptions are keybinds, appearance settings, monitor layout, and the +-- extra autostart list, each loaded from a JSON file (binds.json, +-- settings.json, monitors.json, autostart.json) so bread* apps can read/edit +-- them as structured data instead of parsing this Lua file. +-- +-- Every loader below is wrapped in `pcall`: this file is loaded as a single +-- Lua chunk, so an uncaught error partway through would abort everything +-- *after* it too (no keybinds, no window rules, no autostart) — a bad or +-- hand-edited JSON file must degrade to that one section's hardcoded +-- defaults, never take down the rest of the session. +local script_dir = os.getenv("HOME") .. "/.config/hypr/scripts/" +local config_home = os.getenv("HOME") .. "/.config/hypr/" -- --------------------------------------------------------------------------- --- Monitors — generic default that works on any hardware. +-- Monitors — from monitors.json, always falls back to a generic +-- any-hardware default (see scripts/display/monitors.lua). -- --------------------------------------------------------------------------- -hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" }) +pcall(dofile, script_dir .. "display/monitors.lua") -- --------------------------------------------------------------------------- --- Core settings. +-- Core appearance/input settings — from settings.json (see +-- scripts/ui/settings.lua). -- --------------------------------------------------------------------------- -hl.config({ - general = { - gaps_in = 5, - gaps_out = 10, - border_size = 2, - col = { - active_border = "rgba(88c0d0ff)", - inactive_border = "rgba(4c566aff)", - }, - layout = "dwindle", - resize_on_border = true, - }, - decoration = { - rounding = 8, - active_opacity = 1.0, - inactive_opacity = 1.0, - blur = { - enabled = true, - size = 6, - passes = 2, - new_optimizations = true, - }, - shadow = { - enabled = true, - range = 12, - render_power = 3, - }, - }, - input = { - kb_layout = "us", - follow_mouse = 1, - touchpad = { natural_scroll = true }, - }, - dwindle = { - preserve_split = true, - }, - animations = { - enabled = true, - }, - misc = { - disable_hyprland_logo = true, - disable_splash_rendering = true, - }, -}) +pcall(dofile, script_dir .. "ui/settings.lua") -- --------------------------------------------------------------------------- -- Animations — snappy curves + per-leaf speeds (matches the reference laptop; @@ -86,12 +55,24 @@ for _, animation in ipairs(animations) do end -- --------------------------------------------------------------------------- --- Window rules — float + centre the onboarding popups (kitty --class …). +-- Window rules — float + centre the onboarding/help popups. +-- breadhelp replaces the old bos-welcome/bos-keybinds kitty+less popups. +-- bos-netsetup (nmtui, from bos-netcheck) is unrelated to breadhelp and +-- still floats the same way. -- --------------------------------------------------------------------------- -hl.window_rule({ name = "bos-keybinds", match = { class = "^(bos-keybinds)$" }, float = true, size = { 760, 720 } }) -hl.window_rule({ name = "bos-welcome", match = { class = "^(bos-welcome)$" }, float = true, size = { 700, 560 } }) +hl.window_rule({ name = "breadhelp", match = { class = "^(com\\.breadway\\.breadhelp)$" }, float = true, size = { 880, 600 } }) hl.window_rule({ name = "bos-netsetup", match = { class = "^(bos-netsetup)$" }, float = true, size = { 700, 560 } }) +-- --------------------------------------------------------------------------- +-- Layer-shell rules — blur / ignore-alpha / per-namespace motion for the +-- bread shell (breadbar island + osd + notif + panel, breadbox launcher, +-- breadclip popup). Theme-driven via ~/.config/hypr/layerrules.json (written +-- by `bread-theme layerrules` in the bootstrap below), with a hardcoded +-- fallback so a missing/broken theme file can never leave the shell +-- unblurred. Without these the launcher and popups get no compositor blur. +-- --------------------------------------------------------------------------- +pcall(dofile, script_dir .. "ui/rules.lua") + -- --------------------------------------------------------------------------- -- Environment (vendor-neutral; no GPU-specific vars so it works on Intel/AMD). -- --------------------------------------------------------------------------- @@ -105,102 +86,73 @@ hl.env("QT_WAYLAND_DISABLE_WINDOWDECORATION", "1") hl.env("SDL_VIDEODRIVER", "wayland") hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") +-- GTK4's default renderer (ngl/vulkan on GTK 4.14+) renders transparent +-- layer-shell surfaces as opaque black on wlroots — the whole bread shell +-- (breadbox launcher, breadclip popup, breadbar, breadhelp, bos-settings) +-- goes black-on-black, and it's worse under a VM's virtio-gpu where no real +-- GL/Vulkan context is available. cairo (software) always composites +-- transparency correctly; the shell is flat colour/text/icons so there's no +-- visible perf cost, and idle memory drops (~40%, no Mesa driver resident). +-- Set GSK_RENDERER=gl in the session to override for a GPU-heavy GTK4 app. +hl.env("GSK_RENDERER", "cairo") + +-- Optional NVIDIA env from bos-nvidia-setup. Mesa machines have no file. +-- bos-nvidia-setup: optional proprietary env; no-op when the file is absent +do + local nvidia = (os.getenv("HOME") or "") .. "/.config/hypr/nvidia.lua" + local f = io.open(nvidia, "r") + if f then + f:close() + pcall(dofile, nvidia) + end +end -- kitty sets its own background_opacity (see kitty.conf), so the global blur -- above blurs behind the terminal while keeping text fully opaque. -- --------------------------------------------------------------------------- --- Standard BOS keybinds (SUPER = mod). --- --------------------------------------------------------------------------- --- Apps / window management -hl.bind(mod .. " + RETURN", hl.dsp.exec_cmd("kitty")) -hl.bind(mod .. " + BACKSPACE", hl.dsp.window.close()) -hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("breadbox")) -hl.bind(mod .. " + E", hl.dsp.exec_cmd("nautilus")) -hl.bind(mod .. " + B", hl.dsp.exec_cmd("zen-browser")) -hl.bind(mod .. " + U", hl.dsp.exec_cmd("breadpad")) -hl.bind(mod .. " + M", hl.dsp.exec_cmd("breadman")) -hl.bind(mod .. " + comma", hl.dsp.exec_cmd("bos-settings")) -hl.bind(mod .. " + slash", hl.dsp.exec_cmd("bos-keybinds")) -hl.bind(mod .. " + L", hl.dsp.exec_cmd("loginctl lock-session")) -hl.bind(mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" })) -hl.bind(mod .. " + I", hl.dsp.window.float({ action = "toggle" })) -hl.bind(mod .. " + P", hl.dsp.window.pseudo({ action = "toggle" })) -hl.bind(mod .. " + R", hl.dsp.window.resize()) --- breadclip (its own gtk4-layer-shell popup — not a TUI, so no terminal --- needed). Previously piped cliphist through fzf directly from the --- compositor with no terminal attached, which was a silent no-op. --- Bound on both V (breadclip's own suggested default, freed up now that --- float toggle moved to I) and SHIFT+V (kept for muscle memory). -hl.bind(mod .. " + V", hl.dsp.exec_cmd("breadclip")) -hl.bind(mod .. " + SHIFT + V", hl.dsp.exec_cmd("breadclip")) -hl.bind(mod .. " + T", hl.dsp.layout("togglesplit")) -hl.bind(mod .. " + Tab", hl.dsp.focus({ urgent_or_last = true })) -hl.bind(mod .. " + N", hl.dsp.exit()) - --- Screenshots (grim + slurp + wl-clipboard) -hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd([[bash -c 'mkdir -p ~/Pictures/Screenshots && grim -g "$(slurp)" ~/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png']])) -hl.bind(mod .. " + SHIFT + C", hl.dsp.exec_cmd([[bash -c 'grim -g "$(slurp)" - | wl-copy']])) -hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd([[bash -c 'mkdir -p ~/Pictures/Screenshots && grim ~/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png']])) - --- Focus (directional) -hl.bind(mod .. " + left", hl.dsp.focus({ direction = "left" })) -hl.bind(mod .. " + right", hl.dsp.focus({ direction = "right" })) -hl.bind(mod .. " + up", hl.dsp.focus({ direction = "up" })) -hl.bind(mod .. " + down", hl.dsp.focus({ direction = "down" })) - --- Move window (directional, vim keys) -hl.bind(mod .. " + SHIFT + h", hl.dsp.window.move({ direction = "left" })) -hl.bind(mod .. " + SHIFT + j", hl.dsp.window.move({ direction = "down" })) -hl.bind(mod .. " + SHIFT + k", hl.dsp.window.move({ direction = "up" })) -hl.bind(mod .. " + SHIFT + l", hl.dsp.window.move({ direction = "right" })) - --- Resize active window (arrows) -hl.bind(mod .. " + SHIFT + right", hl.dsp.window.resize({ x = 30, y = 0, relative = true }), { repeating = true }) -hl.bind(mod .. " + SHIFT + left", hl.dsp.window.resize({ x = -30, y = 0, relative = true }), { repeating = true }) -hl.bind(mod .. " + SHIFT + up", hl.dsp.window.resize({ x = 0, y = -30, relative = true }), { repeating = true }) -hl.bind(mod .. " + SHIFT + down", hl.dsp.window.resize({ x = 0, y = 30, relative = true }), { repeating = true }) - --- Workspaces 1–10 (0 = workspace 10) -for i = 1, 10 do - local key = tostring(i % 10) - hl.bind(mod .. " + " .. key, hl.dsp.focus({ workspace = i })) - hl.bind(mod .. " + SHIFT + " .. key, hl.dsp.window.move({ workspace = i })) -end - --- Workspace cycling -hl.bind(mod .. " + bracketright", hl.dsp.focus({ workspace = "e+1" })) -hl.bind(mod .. " + bracketleft", hl.dsp.focus({ workspace = "e-1" })) -hl.bind(mod .. " + SHIFT + bracketright", hl.dsp.window.move({ workspace = "e+1" })) -hl.bind(mod .. " + SHIFT + bracketleft", hl.dsp.window.move({ workspace = "e-1" })) - --- Mouse -hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" })) -hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" })) -hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) -hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) - --- Media / hardware keys (work locked, i.e. on the lock screen too) -hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), { locked = true, repeating = true }) -hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, repeating = true }) -hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true }) -hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true }) -hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true }) -hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true }) -hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true }) -hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true }) -hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) -hl.bind("XF86Calculator", hl.dsp.exec_cmd("gnome-calculator")) +-- Standard BOS keybinds — data-driven from binds.json (apps, windows, +-- screenshots, focus/move/resize, workspaces, mouse, media keys), loaded via +-- scripts/input/{binds,keybinds}.lua. breadhelp's keybind viewer reads +-- binds.json directly as its source of truth (no separately-maintained +-- cheatsheet to keep in sync), and a future bos-settings editor can +-- read/write the same file. +pcall(function() + local binds = dofile(script_dir .. "input/binds.lua")(config_home .. "binds.json") + dofile(script_dir .. "input/keybinds.lua")({ + default_mods = binds.default_mods, + bindings = binds.bindings, + }) +end) +-- 3-finger horizontal trackpad swipe → workspace switch (1:1 gesture) +hl.gesture({ + fingers = 3, + direction = "horizontal", + action = "workspace", +}) -- --------------------------------------------------------------------------- --- Autostart. polkit agent + the bread ecosystem + idle daemon + wallpaper. +-- Autostart. Core bootstrap sequence (polkit agent, dark theme, wallpaper +-- daemon, breadd's Wayland-env fix, breadclipd) stays hardcoded here — it's +-- timing/order-sensitive infrastructure, not something a settings UI should +-- expose for a user to disable or reorder. The extra, genuinely toggleable +-- apps (breadbar, hypridle, bos-netcheck, breadhelp, breadpaper/breadshot +-- listen) come from autostart.json via scripts/system/autostart.lua, +-- appended after. listen is wrapped with `command -v` so a missing +-- binary does not brick login (Hyprland exec is already fire-and-forget). -- (bos-live-setup appends the live-installer launch below this on the ISO.) -- --------------------------------------------------------------------------- hl.on("hyprland.start", function() - local startup = { + local core_startup = { -- Generate the shared bread GUI stylesheet first, so breadbar/breadbox/ -- bos-settings load it on start (they also live-reload if it changes). "bread-theme generate", + -- Write ~/.config/hypr/layerrules.json from the active shell theme's + -- [compositor] table. scripts/ui/rules.lua (loaded above) already ran + -- with the hardcoded fallback; this refreshes the JSON for the next + -- `hyprctl reload` / theme switch. Harmless if bread-theme lacks the + -- verb on an older build. + "sh -c 'bread-theme layerrules 2>/dev/null || true'", -- Global dark theme: GTK4/libadwaita + GTK3 theme + icon + cursor. "gsettings set org.gnome.desktop.interface color-scheme prefer-dark", "gsettings set org.gnome.desktop.interface gtk-theme Adwaita-dark", @@ -208,18 +160,28 @@ hl.on("hyprland.start", function() "gsettings set org.gnome.desktop.interface cursor-theme Bibata-Modern-Ice", "gsettings set org.gnome.desktop.interface cursor-size 24", -- Clipboard history is breadclipd, a bakery-managed systemd --user - -- service (auto-started via skel — see build-local.sh's service bake) - -- rather than an exec-once here. - "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1", + -- service (auto-started from /usr/lib/systemd/user — see + -- build-local.sh's service bake) rather than an exec-once here. + -- Prefer bread-polkit if it is on PATH (not baked; lockfile does not + -- ship it). Otherwise the ISO's polkit-gnome agent. command -v so a + -- missing binary does not leave the session without an auth agent. + "sh -c 'if command -v bread-polkit >/dev/null; then exec bread-polkit; else exec /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1; fi'", "awww-daemon", - -- Set the default wallpaper once the daemon is up (retry until - -- ready) via `breadpaper set`, not raw `awww img` — breadpaper also - -- generates the pywal palette and reloads bread-theme, and records - -- the path so `breadpaper get` (and its bos-settings panel) show - -- the real default instead of "No wallpaper set" on a fresh install. - [[bash -c 'until breadpaper set /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], - -- breadd runs as a systemd user service (~/.config/systemd/user/breadd.service, - -- enabled in skel). It autostarts at login but before Hyprland exists, so + -- Set the default wallpaper once the daemon is up (retry until ready). + -- Raw `awww img`, NOT `breadpaper set` — breadpaper set also runs real + -- pywal against the image, which would clobber the curated black-base + -- colors.json baked into skel (.cache/wal/colors.json: #0c0c0c bg, + -- bread-toned browns reserved for accent slots only) with colors + -- actually extracted from bread-background.png — which is an all-beige + -- photo, so every bread-theme app (breadbar included) turns brown. + -- `breadpaper get` still works on a fresh install without ever running + -- pywal: .cache/wal/wal (pywal's own "last image" marker, which is all + -- breadpaper reads) is baked into skel too, right beside colors.json. + -- pywal only runs for real once the user picks a wallpaper themselves. + [[bash -c 'until awww img /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']], + -- breadd runs as a systemd user service (/usr/lib/systemd/user/breadd.service, + -- enabled --global so every account starts it). It autostarts at login + -- but before Hyprland exists, so -- push the compositor's Wayland env into the user manager and restart breadd -- to pick it up — that's how it gets HYPRLAND_INSTANCE_SIGNATURE to talk to Hyprland. "dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE", @@ -233,16 +195,38 @@ hl.on("hyprland.start", function() -- Start it directly instead. If more graphical-session.target -- services show up later, add them here too. "systemctl --user start breadclipd.service", - "breadbar", - -- breadbox-sync is a Type=oneshot systemd --user service - -- (WantedBy=default.target, no Hyprland IPC dependency) — it - -- already runs on login via the unit baked into skel; exec'ing it - -- again here would just start it twice. - "hypridle", - -- first-boot onboarding (self-gates after the first run) - "bos-welcome", } - for _, cmd in ipairs(startup) do + for _, cmd in ipairs(core_startup) do + hl.dispatch(hl.dsp.exec_cmd(cmd)) + end + + -- breadbox-sync is a Type=oneshot systemd --user service + -- (WantedBy=default.target, no Hyprland IPC dependency) — it already + -- runs on login via the unit baked into /usr/lib/systemd/user, + -- independent of this list. + local ok, extra = pcall(function() + return dofile(script_dir .. "system/autostart.lua")() + end) + if not ok or type(extra) ~= "table" then + -- autostart.json/its loader broke — fall back to the same apps BOS + -- has always started, so a bad JSON edit degrades to "normal + -- desktop" rather than "no bar, no idle lock, no onboarding". + extra = { + "breadbar", + "hypridle", + "bos-netcheck", + "bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'", + "breadhelp --autostart", + "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", + "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", + "bash -c 'command -v breadlock >/dev/null && exec breadlock listen'", + "bash -c 'command -v breadbox >/dev/null && exec breadbox listen'", + "bash -c 'command -v breadhelp >/dev/null && exec breadhelp listen'", + "bash -c 'command -v breadsearch >/dev/null && exec breadsearch listen'", + "bash -c 'command -v breadpad >/dev/null && exec breadpad listen'", + } + end + for _, cmd in ipairs(extra) do hl.dispatch(hl.dsp.exec_cmd(cmd)) end end) diff --git a/iso/airootfs/etc/skel/.config/hypr/monitors.json b/iso/airootfs/etc/skel/.config/hypr/monitors.json new file mode 100644 index 0000000..400ccb2 --- /dev/null +++ b/iso/airootfs/etc/skel/.config/hypr/monitors.json @@ -0,0 +1,5 @@ +{ + "monitors": [ + { "output": "", "mode": "preferred", "position": "auto", "scale": "auto" } + ] +} diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/display/monitors.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/display/monitors.lua new file mode 100644 index 0000000..0a5aa51 --- /dev/null +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/display/monitors.lua @@ -0,0 +1,56 @@ +-- scripts/display/monitors.lua — loads monitors.json (an array of monitor +-- rules) and applies each via hl.monitor(). Failsafe: an empty or entirely +-- invalid monitors.json is treated the same as a missing one — falling back +-- to the single generic wildcard rule — because applying *zero* monitor +-- rules risks an unconfigured/black-screen session, unlike settings.json or +-- autostart.json where "apply nothing extra" is a legitimate user choice. +local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua") + +local DEFAULT_MONITORS = { + { output = "", mode = "preferred", position = "auto", scale = "auto" }, +} + +local function valid_entry(e) + return type(e) == "table" and type(e.output) == "string" +end + +local function load_monitors() + local path = os.getenv("HOME") .. "/.config/hypr/monitors.json" + local parsed = json.load(path) + if type(parsed) ~= "table" or type(parsed.monitors) ~= "table" then + return DEFAULT_MONITORS + end + + local valid = {} + for _, entry in ipairs(parsed.monitors) do + if valid_entry(entry) then + valid[#valid + 1] = { + output = entry.output, + mode = (type(entry.mode) == "string" and entry.mode) or "preferred", + position = (type(entry.position) == "string" and entry.position) or "auto", + scale = entry.scale ~= nil and entry.scale or "auto", + mirror = type(entry.mirror) == "string" and entry.mirror or nil, + } + end + end + + if #valid == 0 then + return DEFAULT_MONITORS + end + return valid +end + +local monitors = load_monitors() +local applied_any = false +for _, m in ipairs(monitors) do + if pcall(hl.monitor, m) then + applied_any = true + end +end + +-- Every entry failed to apply (e.g. Hyprland rejected values that still +-- passed our type checks) — guarantee a usable session rather than leaving +-- every monitor unconfigured. +if not applied_any then + pcall(hl.monitor, DEFAULT_MONITORS[1]) +end diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/input/binds.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/input/binds.lua new file mode 100644 index 0000000..1dcbfe9 --- /dev/null +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/input/binds.lua @@ -0,0 +1,35 @@ +-- scripts/input/binds.lua — loads binds.json into { default_mods, bindings }. +-- Simpler than the multi-keyboard-layout version some personal configs use +-- (BOS ships one fixed layout, no per-layout bind switching needed): just +-- reads `default_mods` + the flat `bindings` array. + +local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua") + +local function normalize_mods(value, fallback) + local mods = {} + if type(value) == "table" then + for _, item in ipairs(value) do + if type(item) == "string" and item ~= "" then + mods[#mods + 1] = item + end + end + end + if #mods == 0 then + for _, mod in ipairs(fallback or {}) do + mods[#mods + 1] = mod + end + end + return mods +end + +return function(configPath) + local parsed = json.load(configPath) + if type(parsed) ~= "table" then + return { default_mods = { "SUPER" }, bindings = {} } + end + + return { + default_mods = normalize_mods(parsed.default_mods, { "SUPER" }), + bindings = type(parsed.bindings) == "table" and parsed.bindings or {}, + } +end diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/input/keybinds.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/input/keybinds.lua new file mode 100644 index 0000000..b879531 --- /dev/null +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/input/keybinds.lua @@ -0,0 +1,124 @@ +-- scripts/input/keybinds.lua — turns binds.json entries into real hl.bind() +-- calls. Ported from a personal modular Hyprland config using the same +-- `hl` API; action_builders covers every dispatcher shape BOS's keybinds +-- actually use (exec, window management, focus/move/resize, workspaces, +-- mouse, media keys). +return function(ctx) + local function split_mods(value) + local mods = {} + for raw_mod in tostring(value):gmatch("[^+]+") do + local mod = raw_mod:gsub("^%s+", ""):gsub("%s+$", "") + if mod ~= "" then + mods[#mods + 1] = mod + end + end + return mods + end + + -- `allow_empty`: an explicit `"mods": []` in binds.json (media keys, + -- which must bind with no modifier at all) must NOT fall back to + -- default_mods — only an *omitted* `mods` field should. + local function normalize_mods(value, fallback, allow_empty) + if value == nil then + return fallback + end + if type(value) ~= "table" then + return fallback + end + if #value == 0 then + return allow_empty and {} or fallback + end + local mods = {} + for _, item in ipairs(value) do + if type(item) == "string" then + for _, mod in ipairs(split_mods(item)) do + mods[#mods + 1] = mod + end + end + end + if #mods == 0 then + return fallback + end + return mods + end + + local defaultMods = normalize_mods(ctx.default_mods, { "SUPER" }, false) + local bindings = ctx.bindings or {} + + local function bind_string(mods, key) + if type(key) ~= "string" or key == "" then + return nil + end + if #mods == 0 then + return key + end + return table.concat(mods, " + ") .. " + " .. key + end + + local action_builders = { + exec = function(entry) + return hl.dsp.exec_cmd(entry.command) + end, + close = function() + return hl.dsp.window.close() + end, + exit = function() + return hl.dsp.exit() + end, + float = function() + return hl.dsp.window.float({ action = "toggle" }) + end, + fullscreen = function() + return hl.dsp.window.fullscreen({ action = "toggle" }) + end, + pseudo = function() + return hl.dsp.window.pseudo({ action = "toggle" }) + end, + layout = function(entry) + return hl.dsp.layout(entry.layout) + end, + focus = function(entry) + return hl.dsp.focus({ direction = entry.direction, workspace = entry.workspace }) + end, + focus_last = function() + return hl.dsp.focus({ urgent_or_last = true }) + end, + move = function(entry) + return hl.dsp.window.move({ workspace = entry.workspace }) + end, + move_dir = function(entry) + return hl.dsp.window.move({ direction = entry.direction }) + end, + resize = function() + return hl.dsp.window.resize() + end, + resize_dir = function(entry) + return hl.dsp.window.resize({ x = entry.x or 0, y = entry.y or 0, relative = true }) + end, + drag = function() + return hl.dsp.window.drag() + end, + } + + -- One bad entry (unknown action, missing field a builder needs) must + -- never take down every other bind — skip it and keep going instead of + -- asserting/erroring, which would abort binds.json loading entirely. + for _, entry in ipairs(bindings) do + local builder = action_builders[entry.action] + if builder then + local ok, result = pcall(function() + local mods = normalize_mods(entry.mods, defaultMods, true) + local bind = bind_string(mods, entry.key) + local action = builder(entry) + if bind and action then + hl.bind(bind, action, entry.options) + end + end) + if not ok then + print("breadhelp/hyprland: skipping bad bind entry (" .. tostring(entry.action) .. "/" .. tostring(entry.key) .. "): " .. tostring(result)) + end + else + print("breadhelp/hyprland: skipping bind entry with unknown action: " .. tostring(entry.action)) + end + end +end diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/lib/json.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/lib/json.lua new file mode 100644 index 0000000..89a06b0 --- /dev/null +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/lib/json.lua @@ -0,0 +1,180 @@ +-- scripts/lib/json.lua +local M = {} + +function M.parse(str) + local s = str + local pos = 1 + local len = #s + + local function skipws() + while pos <= len and s:sub(pos,pos):match('%s') do pos = pos + 1 end + end + + local function parse_string() + if s:sub(pos,pos) ~= '"' then error('expected string') end + pos = pos + 1 + local out = {} + while pos <= len do + local c = s:sub(pos,pos) + if c == '"' then pos = pos + 1; return table.concat(out) end + if c == '\\' then + local n = s:sub(pos+1,pos+1) + if n == '"' then out[#out+1] = '"'; pos = pos + 2 + elseif n == '\\' then out[#out+1] = '\\'; pos = pos + 2 + elseif n == '/' then out[#out+1] = '/'; pos = pos + 2 + elseif n == 'b' then out[#out+1] = '\b'; pos = pos + 2 + elseif n == 'f' then out[#out+1] = '\f'; pos = pos + 2 + elseif n == 'n' then out[#out+1] = '\n'; pos = pos + 2 + elseif n == 'r' then out[#out+1] = '\r'; pos = pos + 2 + elseif n == 't' then out[#out+1] = '\t'; pos = pos + 2 + elseif n == 'u' then + local hex = s:sub(pos+2, pos+5) + local code = tonumber(hex, 16) + if code then out[#out+1] = utf8.char(code) end + pos = pos + 6 + else + pos = pos + 2 + end + else + out[#out+1] = c + pos = pos + 1 + end + end + error('unclosed string') + end + + local function parse_value() + skipws() + local c = s:sub(pos,pos) + if c == '"' then return parse_string() + elseif c == '{' then + return parse_object() + elseif c == '[' then + return parse_array() + elseif c:match('[%d%-]') then + local start = pos + while s:sub(pos,pos):match('[%d+%-.eE]') do pos = pos + 1 end + local num = tonumber(s:sub(start, pos-1)) + return num + elseif s:sub(pos,pos+3) == 'null' then pos = pos + 4; return nil + elseif s:sub(pos,pos+3) == 'true' then pos = pos + 4; return true + elseif s:sub(pos,pos+4) == 'false' then pos = pos + 5; return false + else + error('unexpected value at ' .. pos) + end + end + + function parse_array() + if s:sub(pos,pos) ~= '[' then error('expected [') end + pos = pos + 1 + skipws() + local arr = {} + if s:sub(pos,pos) == ']' then pos = pos + 1; return arr end + while true do + skipws() + local val = parse_value() + table.insert(arr, val) + skipws() + local c = s:sub(pos,pos) + if c == ']' then pos = pos + 1; break + elseif c == ',' then pos = pos + 1; skipws() + else error('expected , or ]') end + end + return arr + end + + function parse_object() + if s:sub(pos,pos) ~= '{' then error('expected {') end + pos = pos + 1 + skipws() + local obj = {} + if s:sub(pos,pos) == '}' then pos = pos + 1; return obj end + while true do + skipws() + local key = parse_string() + skipws() + if s:sub(pos,pos) ~= ':' then error('expected :') end + pos = pos + 1 + skipws() + local val = parse_value() + obj[key] = val + skipws() + local c = s:sub(pos,pos) + if c == '}' then pos = pos + 1; break + elseif c == ',' then pos = pos + 1; skipws() + else error('expected , or }') end + end + return obj + end + + skipws() + return parse_value() +end + +function M.load(path) + local ok, fh = pcall(io.open, path, "r") + if not ok or not fh then + return nil, "unable to open file" + end + + local content = fh:read("*a") + fh:close() + + local success, parsed = pcall(M.parse, content) + if not success then + return nil, parsed + end + + return parsed +end + +function M.encode(value, indent) + indent = indent or 0 + local indent_str = string.rep(" ", indent) + local next_indent_str = string.rep(" ", indent + 1) + + if value == nil then + return "null" + elseif type(value) == "boolean" then + return value and "true" or "false" + elseif type(value) == "number" then + return tostring(value) + elseif type(value) == "string" then + return '"' .. value:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t') .. '"' + elseif type(value) == "table" then + local is_array = true + local max_idx = 0 + for k in pairs(value) do + if type(k) ~= "number" then + is_array = false + break + end + max_idx = math.max(max_idx, k) + end + + if is_array and max_idx == #value then + if max_idx == 0 then + return "[]" + end + local items = {} + for i = 1, max_idx do + table.insert(items, next_indent_str .. M.encode(value[i], indent + 1)) + end + return "[\n" .. table.concat(items, ",\n") .. "\n" .. indent_str .. "]" + else + local items = {} + for k, v in pairs(value) do + table.insert(items, next_indent_str .. M.encode(tostring(k), 0) .. ": " .. M.encode(v, indent + 1)) + end + if #items == 0 then + return "{}" + end + table.sort(items) + return "{\n" .. table.concat(items, ",\n") .. "\n" .. indent_str .. "}" + end + else + return "null" + end +end + +return M diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua new file mode 100644 index 0000000..63e364f --- /dev/null +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/system/autostart.lua @@ -0,0 +1,55 @@ +-- scripts/system/autostart.lua — loads the *extra* (user-toggleable) +-- autostart list from autostart.json. Returns just the enabled commands, in +-- order. The core bootstrap sequence (theme generation, dark-mode gsettings, +-- polkit agent, wallpaper daemon, breadd's Wayland-env fix, breadclipd) is +-- deliberately NOT exposed here — it's timing/order-sensitive infrastructure, +-- not something a settings UI should let a user disable, so it stays +-- hardcoded in hyprland.lua itself. +-- +-- Failsafe: unlike monitors.json, an empty or all-disabled result here is a +-- legitimate user choice (they don't want breadbar/hypridle/etc), so this +-- only falls back to defaults on a missing/malformed file — never just +-- because the valid result happens to be empty. +local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua") + +-- breadpaper/breadshot `listen` is wrapped so a missing binary (stable +-- does not ship the command-bus verb yet) cannot take down the session. +local DEFAULT_EXTRA = { + { command = "breadbar", enabled = true }, + { command = "hypridle", enabled = true }, + { command = "bos-netcheck", enabled = true }, + { command = "bash -c 'command -v bos-first-boot >/dev/null && exec bos-first-boot'", enabled = true }, + { command = "breadhelp --autostart", enabled = true }, + { command = "bash -c 'command -v breadpaper >/dev/null && exec breadpaper listen'", enabled = true }, + { command = "bash -c 'command -v breadshot >/dev/null && exec breadshot listen'", enabled = true }, + { command = "bash -c 'command -v breadlock >/dev/null && exec breadlock listen'", enabled = true }, + { command = "bash -c 'command -v breadbox >/dev/null && exec breadbox listen'", enabled = true }, + { command = "bash -c 'command -v breadhelp >/dev/null && exec breadhelp listen'", enabled = true }, + { command = "bash -c 'command -v breadsearch >/dev/null && exec breadsearch listen'", enabled = true }, + { command = "bash -c 'command -v breadpad >/dev/null && exec breadpad listen'", enabled = true }, +} + +return function() + local path = os.getenv("HOME") .. "/.config/hypr/autostart.json" + local parsed = json.load(path) + local entries + if type(parsed) ~= "table" or type(parsed.extra) ~= "table" then + entries = DEFAULT_EXTRA + else + entries = parsed.extra + end + + local commands = {} + for _, entry in ipairs(entries) do + if type(entry) == "table" and type(entry.command) == "string" and entry.command ~= "" then + local enabled = entry.enabled + if type(enabled) ~= "boolean" then + enabled = true + end + if enabled then + commands[#commands + 1] = entry.command + end + end + end + return commands +end diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/ui/rules.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/ui/rules.lua new file mode 100644 index 0000000..79e0a2f --- /dev/null +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/ui/rules.lua @@ -0,0 +1,136 @@ +-- scripts/ui/rules.lua — layer-shell (compositor) rules for the bread shell. +-- +-- Blur / ignore-alpha / per-namespace motion for breadbar (island + osd + +-- notif + panel + dismiss), the breadbox launcher, and the breadclip popup. +-- Window rules (float/centre for onboarding popups) live in hyprland.lua; +-- this file is layer rules only. +-- +-- Theme-driven (bos-ui-demos THEME_SYSTEM_PLAN.md §9): `bread-theme +-- layerrules` writes ~/.config/hypr/layerrules.json from the active shell +-- theme's [compositor] table — blur / ignore_alpha / blur_popups / +-- animation / no_anim, keyed by layer-shell namespace. That table owns +-- *appearance* only; placement / workspace / focus are never touched here. +-- If the JSON is missing, unreadable, malformed, or anything goes wrong +-- while parsing it, apply_hardcoded_layer_rules() runs instead — the exact +-- set this file hardcoded before the theme system existed — so a broken or +-- absent theme file can never disable compositor blur. + +local LAYERRULES_JSON = os.getenv("HOME") .. "/.config/hypr/layerrules.json" + +local function apply_hardcoded_layer_rules() + hl.layer_rule({ + name = "breadbar-island", + match = { namespace = "^breadbar$" }, + blur = true, + ignore_alpha = 0.2, + blur_popups = true, + animation = "slide top", + }) + + hl.layer_rule({ + name = "breadbar-osd", + match = { namespace = "^breadbar-osd$" }, + blur = true, + ignore_alpha = 0.2, + animation = "slide bottom", + }) + + hl.layer_rule({ + name = "breadbar-notif", + match = { namespace = "^breadbar-notif$" }, + blur = true, + ignore_alpha = 0.2, + animation = "slide right", + }) + + hl.layer_rule({ + name = "breadbar-panel", + match = { namespace = "^breadbar-panel$" }, + blur = true, + ignore_alpha = 0.2, + animation = "slide right", + }) + + hl.layer_rule({ + name = "breadbar-dismiss", + match = { namespace = "^breadbar-dismiss$" }, + no_anim = true, + }) + + hl.layer_rule({ + match = "breadbox", + blur = true, + ignore_alpha = 0.2, + }) +end + +-- Parses ~/.config/hypr/layerrules.json into a plain array of per-namespace +-- rule tables, sorted by namespace for a deterministic emission order. +-- Returns nil (not an error) for anything short of a well-formed, non-empty +-- JSON object, so the pcall wrapping this only guards against a genuine Lua +-- error (e.g. json.lua failing to load) rather than every malformed case. +local function parsed_layer_rules() + local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua") + local parsed = json.load(LAYERRULES_JSON) + if type(parsed) ~= "table" then + return nil + end + + local namespaces = {} + for ns, rule in pairs(parsed) do + if type(ns) == "string" and type(rule) == "table" then + namespaces[#namespaces + 1] = ns + end + end + if #namespaces == 0 then + return nil + end + table.sort(namespaces) + + local rules = {} + for _, ns in ipairs(namespaces) do + local r = parsed[ns] + rules[#rules + 1] = { + namespace = ns, + blur = r.blur == true, + ignore_alpha = r.ignore_alpha, + blur_popups = r.blur_popups == true, + animation = r.animation, + no_anim = r.no_anim == true, + } + end + return rules +end + +-- Emits one hl.layer_rule per parsed namespace. Built as pure data by +-- parsed_layer_rules() first (no hl.layer_rule calls during parsing), so a +-- parse failure can never leave a partial JSON-derived rule set applied +-- before the caller falls back to apply_hardcoded_layer_rules(). +local function apply_json_layer_rules(rules) + for _, r in ipairs(rules) do + hl.layer_rule({ + name = r.namespace, + match = { namespace = "^" .. r.namespace .. "$" }, + blur = r.blur, + ignore_alpha = r.ignore_alpha, + blur_popups = r.blur_popups, + animation = r.animation, + no_anim = r.no_anim, + }) + end +end + +local ok, rules = pcall(parsed_layer_rules) +if ok and rules then + apply_json_layer_rules(rules) +else + apply_hardcoded_layer_rules() +end + +-- breadclip is not part of the shell theme's [compositor] table yet — always +-- hardcoded, regardless of the JSON above. +hl.layer_rule({ + match = "breadclip", + blur = true, + ignore_alpha = 0.0, +}) diff --git a/iso/airootfs/etc/skel/.config/hypr/scripts/ui/settings.lua b/iso/airootfs/etc/skel/.config/hypr/scripts/ui/settings.lua new file mode 100644 index 0000000..8d3a829 --- /dev/null +++ b/iso/airootfs/etc/skel/.config/hypr/scripts/ui/settings.lua @@ -0,0 +1,121 @@ +-- scripts/ui/settings.lua — loads settings.json over hardcoded defaults and +-- applies via hl.config(). Failsafe in two layers: +-- 1. every leaf value is type-checked against its default individually, so +-- one bad field (wrong type, typo) falls back to just that field, not +-- the whole file; +-- 2. the actual hl.config() application is pcall'd — if Hyprland itself +-- rejects a well-typed-but-semantically-bad value, we fall back to +-- re-applying pure hardcoded defaults, so the session always comes up +-- with a normal, usable layout instead of erroring out mid-config-load. +local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua") + +local DEFAULTS = { + gaps_in = 5, + gaps_out = 10, + border_size = 2, + active_border = "rgba(88c0d0ff)", + inactive_border = "rgba(4c566aff)", + layout = "dwindle", + resize_on_border = true, + rounding = 8, + blur_enabled = true, + blur_size = 6, + blur_passes = 2, + shadow_enabled = true, + shadow_range = 12, + shadow_render_power = 3, + kb_layout = "us", + follow_mouse = 1, + natural_scroll = true, +} + +local function num(v, fallback) + if type(v) == "number" then return v end + return fallback +end + +local function bool(v, fallback) + if type(v) == "boolean" then return v end + return fallback +end + +local function str(v, fallback) + if type(v) == "string" and v ~= "" then return v end + return fallback +end + +local function load_overrides() + local path = os.getenv("HOME") .. "/.config/hypr/settings.json" + local parsed = json.load(path) + if type(parsed) ~= "table" then + return {} + end + return parsed +end + +local o = load_overrides() + +local merged = { + gaps_in = num(o.gaps_in, DEFAULTS.gaps_in), + gaps_out = num(o.gaps_out, DEFAULTS.gaps_out), + border_size = num(o.border_size, DEFAULTS.border_size), + active_border = str(o.active_border, DEFAULTS.active_border), + inactive_border = str(o.inactive_border, DEFAULTS.inactive_border), + layout = str(o.layout, DEFAULTS.layout), + resize_on_border = bool(o.resize_on_border, DEFAULTS.resize_on_border), + rounding = num(o.rounding, DEFAULTS.rounding), + blur_enabled = bool(o.blur_enabled, DEFAULTS.blur_enabled), + blur_size = num(o.blur_size, DEFAULTS.blur_size), + blur_passes = num(o.blur_passes, DEFAULTS.blur_passes), + shadow_enabled = bool(o.shadow_enabled, DEFAULTS.shadow_enabled), + shadow_range = num(o.shadow_range, DEFAULTS.shadow_range), + shadow_render_power = num(o.shadow_render_power, DEFAULTS.shadow_render_power), + kb_layout = str(o.kb_layout, DEFAULTS.kb_layout), + follow_mouse = num(o.follow_mouse, DEFAULTS.follow_mouse), + natural_scroll = bool(o.natural_scroll, DEFAULTS.natural_scroll), +} + +local function build_hl_config(v) + return { + general = { + gaps_in = v.gaps_in, + gaps_out = v.gaps_out, + border_size = v.border_size, + col = { + active_border = v.active_border, + inactive_border = v.inactive_border, + }, + layout = v.layout, + resize_on_border = v.resize_on_border, + }, + decoration = { + rounding = v.rounding, + active_opacity = 1.0, + inactive_opacity = 1.0, + blur = { + enabled = v.blur_enabled, + size = v.blur_size, + passes = v.blur_passes, + new_optimizations = true, + }, + shadow = { + enabled = v.shadow_enabled, + range = v.shadow_range, + render_power = v.shadow_render_power, + }, + }, + input = { + kb_layout = v.kb_layout, + follow_mouse = v.follow_mouse, + touchpad = { natural_scroll = v.natural_scroll }, + }, + dwindle = { preserve_split = true }, + animations = { enabled = true }, + misc = { disable_hyprland_logo = true, disable_splash_rendering = true }, + } +end + +local ok = pcall(hl.config, build_hl_config(merged)) +if not ok then + pcall(hl.config, build_hl_config(DEFAULTS)) +end diff --git a/iso/airootfs/etc/skel/.config/hypr/settings.json b/iso/airootfs/etc/skel/.config/hypr/settings.json new file mode 100644 index 0000000..9e071c0 --- /dev/null +++ b/iso/airootfs/etc/skel/.config/hypr/settings.json @@ -0,0 +1,19 @@ +{ + "gaps_in": 5, + "gaps_out": 10, + "border_size": 2, + "active_border": "rgba(88c0d0ff)", + "inactive_border": "rgba(4c566aff)", + "layout": "dwindle", + "resize_on_border": true, + "rounding": 8, + "blur_enabled": true, + "blur_size": 6, + "blur_passes": 2, + "shadow_enabled": true, + "shadow_range": 12, + "shadow_render_power": 3, + "kb_layout": "us", + "follow_mouse": 1, + "natural_scroll": true +} diff --git a/iso/airootfs/etc/skel/.config/systemd/user/breadd.service b/iso/airootfs/etc/skel/.config/systemd/user/breadd.service index 49d6741..945c09c 100644 --- a/iso/airootfs/etc/skel/.config/systemd/user/breadd.service +++ b/iso/airootfs/etc/skel/.config/systemd/user/breadd.service @@ -3,8 +3,8 @@ Description=Bread Runtime Daemon [Service] Type=simple -# %h = the user's home — works for any account created from this skel. -ExecStart=%h/.local/bin/breadd +# System-prefix bakery install — same path for every account. +ExecStart=/usr/local/bin/breadd Restart=on-failure RestartSec=2 UMask=0077 diff --git a/packaging/arch/bos-settings.desktop b/iso/airootfs/etc/skel/.local/share/applications/bos-settings.desktop similarity index 100% rename from packaging/arch/bos-settings.desktop rename to iso/airootfs/etc/skel/.local/share/applications/bos-settings.desktop diff --git a/iso/airootfs/etc/skel/.local/share/applications/breadhelp.desktop b/iso/airootfs/etc/skel/.local/share/applications/breadhelp.desktop new file mode 100644 index 0000000..c41be36 --- /dev/null +++ b/iso/airootfs/etc/skel/.local/share/applications/breadhelp.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=BOS Help +Comment=Your personal guide to the Bread desktop +Exec=breadhelp +Icon=help-browser +Terminal=false +Type=Application +Categories=Help;System; +StartupWMClass=com.breadway.breadhelp diff --git a/iso/airootfs/etc/skel/.zshrc b/iso/airootfs/etc/skel/.zshrc index 8a2a31e..3de4cc4 100644 --- a/iso/airootfs/etc/skel/.zshrc +++ b/iso/airootfs/etc/skel/.zshrc @@ -81,7 +81,15 @@ alias ip='ip --color=auto' alias update='bos-update' alias pacman='sudo pacman' -# ~/.local/bin holds the bread* binaries baked in at build time. +# Package shortcuts — official repos via pacman, AUR via yay (alt-* prefix). +alias install='sudo pacman -S' +alias uninstall='sudo pacman -R' +alias srchpkg='sudo pacman -Ss' +alias alt-install='yay -S' +alias alt-uninstall='yay -R' +alias alt-srchpkg='yay -Ss' + +# Per-user tools. Bakery desktop apps live in /usr/local/bin (already on PATH). export PATH="$HOME/.local/bin:$PATH" # Powerlevel10k prompt configuration. diff --git a/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service b/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service new file mode 120000 index 0000000..d284527 --- /dev/null +++ b/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service @@ -0,0 +1 @@ +/usr/lib/systemd/user/breadd.service \ No newline at end of file diff --git a/iso/airootfs/root/customize_airootfs.sh b/iso/airootfs/root/customize_airootfs.sh new file mode 100644 index 0000000..149c6c4 --- /dev/null +++ b/iso/airootfs/root/customize_airootfs.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Run by mkarchiso inside the airootfs chroot, after packages are installed +# and before the squashfs is built. (archiso prints a deprecation warning for +# this hook, but there is no non-deprecated replacement for "trust an extra +# pacman repo key in the image keyring", and BOS ships no pacman-init.service.) +# +# Purpose: trust the BOS release key (56203B86…) in the image's pacman +# keyring so the signed [breadway] repo (SigLevel = Required, +# https://dl.breadway.dev/arch) verifies both on the live medium and — via +# calamares' unpackfs, which copies this squashfs to the target — on the +# installed system. calamares/post-install.sh re-does this in the target +# chroot as a fallback (unpackfs can skip /etc/pacman.d/gnupg). +set -euo pipefail + +BREADWAY_KEY_FPR="56203B86A110695AE7F310934AF3323D678EB5E2" +KEY_FILE="/etc/pacman.d/breadway-repo.asc" + +pacman-key --init +pacman-key --populate archlinux + +if [[ -f "$KEY_FILE" ]]; then + pacman-key --add "$KEY_FILE" + pacman-key --lsign-key "$BREADWAY_KEY_FPR" + echo "customize_airootfs: trusted [breadway] repo key $BREADWAY_KEY_FPR" +else + echo "customize_airootfs: WARNING $KEY_FILE missing; [breadway] will not verify" >&2 +fi diff --git a/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset b/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset new file mode 100644 index 0000000..f7f73f9 --- /dev/null +++ b/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset @@ -0,0 +1,12 @@ +# Bakery systemd --user units. `systemctl --global enable` (post-install and +# live setup) applies these so a later `useradd -m` starts them on first login. +# Bake rewrites this list from the units actually copied into the image. +# +# breadclipd is WantedBy=graphical-session.target. BOS does not activate that +# target (no uwsm); Hyprland still `systemctl --user start`s it after the +# compositor is up. --global enable still records it for every account. +enable breadd.service +enable breadbox-sync.service +enable breadclipd.service +enable breadcrumbs.service +enable breadmill.service diff --git a/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units b/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units new file mode 100755 index 0000000..46c128e --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units @@ -0,0 +1,90 @@ +#!/bin/bash +# Enable bakery systemd --user units for every account (current and future). +# +# `systemctl --global enable` writes /etc/systemd/user/.wants/ so a +# later `useradd -m` does not need per-home enablement. Bins live in +# /usr/local; only per-user state comes from skel. +# +# Safe on the live image and in the Calamares post-install chroot. +# Idempotent. Does not start units (no user session required). +# +# breadclipd is WantedBy=graphical-session.target. BOS does not activate +# that target (no uwsm), so Hyprland still `systemctl --user start`s it. +# --global enable still records it for every account / bos-settings. +set -uo pipefail + +UNITS_DIR=/usr/lib/systemd/user +PRESET=/usr/lib/systemd/user-preset/90-bos-bakery.preset + +is_blocked() { + case "$1" in + breadcast*|breadarr*) return 0 ;; + *) return 1 ;; + esac +} + +is_bakery_unit() { + local unit="$1" path="$UNITS_DIR/$unit" + [[ -f "$path" ]] || return 1 + is_blocked "$unit" && return 1 + grep -qE '^ExecStart=/usr/local/bin/' "$path" +} + +list_from_preset() { + [[ -f "$PRESET" ]] || return 0 + awk '/^enable[[:space:]]/ { print $2 }' "$PRESET" +} + +list_from_units_dir() { + [[ -d "$UNITS_DIR" ]] || return 0 + local path unit + for path in "$UNITS_DIR"/*.service; do + [[ -f "$path" ]] || continue + unit="$(basename "$path")" + is_bakery_unit "$unit" && printf '%s\n' "$unit" + done +} + +list_from_installed_json() { + local json=/etc/skel/.local/state/bakery/installed.json + [[ -f "$json" ]] || return 0 + command -v python3 >/dev/null 2>&1 || return 0 + python3 - "$json" <<'PY' +import json, sys +with open(sys.argv[1]) as f: + data = json.load(f) +for pkg in data.get("packages", data).values(): + if not isinstance(pkg, dict): + continue + for svc in pkg.get("services") or []: + name = svc["unit"] if isinstance(svc, dict) else svc + if name and not str(name).startswith(("breadcast", "breadarr")): + print(name) +PY +} + +mapfile -t units < <( + { list_from_preset; list_from_units_dir; list_from_installed_json; } \ + | sed '/^$/d' | sort -u +) + +if [[ ${#units[@]} -eq 0 ]]; then + echo "WARN: no bakery user units found to enable globally" + exit 0 +fi + +if ! command -v systemctl >/dev/null 2>&1; then + echo "WARN: systemctl missing — cannot --global enable bakery user units" + exit 0 +fi + +for unit in "${units[@]}"; do + [[ -f "$UNITS_DIR/$unit" ]] || continue + is_blocked "$unit" && continue + if ! grep -q '^\[Install\]' "$UNITS_DIR/$unit"; then + echo "WARN: $unit has no [Install] section — skip --global enable" + continue + fi + systemctl --global enable "$unit" \ + || echo "WARN: systemctl --global enable $unit failed" +done diff --git a/iso/airootfs/usr/local/bin/bos-first-boot b/iso/airootfs/usr/local/bin/bos-first-boot new file mode 100755 index 0000000..d17fc70 --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-first-boot @@ -0,0 +1,187 @@ +#!/bin/bash +# bos-first-boot — one-shot hardware probe after the first graphical login. +# +# Detects NVIDIA (offer file + notify; never auto-installs a driver), a VM +# without GL, and HiDPI (hint file only — never rewrites monitors.json). +# +# Non-fatal: missing tools, notify-send, or hyprctl must not block login. +# Guarded with `command -v`. Flag: ~/.local/state/bos/first-boot-done. +set -u + +STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/bos" +FLAG="$STATE_DIR/first-boot-done" +NVIDIA_OFFER="$STATE_DIR/nvidia-offer.json" +HIDPI_HINT="$STATE_DIR/hidpi-hint.json" +VM_HINT="$STATE_DIR/vm-gl-hint.json" + +# Never run on the live/installer session — only on an installed system. +[[ "$(id -un)" == "liveuser" ]] && exit 0 + +# Already probed this home. +[[ -f "$FLAG" ]] && exit 0 + +notify() { + local msg="$1" + local urgency="${2:-normal}" + command -v notify-send >/dev/null 2>&1 || return 0 + notify-send -u "$urgency" "BOS" "$msg" 2>/dev/null || true +} + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +iso_now() { + date -Iseconds 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ +} + +# Best-effort: hyprland.start can beat the notification daemon by a beat. +if [[ -z "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]]; then + sleep 1 +fi + +mkdir -p "$STATE_DIR" 2>/dev/null || exit 0 + +# --------------------------------------------------------------------------- +# NVIDIA — hardware only. Do not install nvidia / nvidia-utils. +# --------------------------------------------------------------------------- +nvidia_present=0 +nvidia_pci="" +if command -v lspci >/dev/null 2>&1; then + nvidia_pci="$(lspci -d 10de: -nn 2>/dev/null | grep -iE 'VGA|3D|Display' || true)" + [[ -n "$nvidia_pci" ]] && nvidia_present=1 +fi +if [[ "$nvidia_present" != "1" ]]; then + if [[ -d /proc/driver/nvidia || -d /sys/module/nvidia ]]; then + nvidia_present=1 + nvidia_pci="${nvidia_pci:-module}" + fi +fi +if [[ "$nvidia_present" == "1" ]]; then + cat >"$NVIDIA_OFFER" </dev/null 2>&1; then + virt="$(systemd-detect-virt 2>/dev/null || true)" + [[ -n "$virt" ]] || virt="none" +fi +has_gl=0 +shopt -s nullglob +dri_nodes=(/dev/dri/card* /dev/dri/renderD*) +(( ${#dri_nodes[@]} > 0 )) && has_gl=1 +shopt -u nullglob + +if [[ "$virt" != "none" && "$has_gl" != "1" ]]; then + cat >"$VM_HINT" < 1 from hyprctl, or computed DPI >= 140. +# --------------------------------------------------------------------------- +if command -v hyprctl >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then + # Compositor may still be settling when autostart fires. + mon_json="" + tries=0 + while [[ -z "$mon_json" && "$tries" -lt 5 ]]; do + mon_json="$(hyprctl -j monitors 2>/dev/null || true)" + if [[ -z "$mon_json" || "$mon_json" == "[]" ]]; then + mon_json="" + sleep 1 + fi + tries=$((tries + 1)) + done + if [[ -n "$mon_json" ]]; then + BOS_HYPR_MONITORS="$mon_json" python3 - "$HIDPI_HINT" "$(iso_now)" <<'PY' || true +import json, os, sys +hint_path, noted_at = sys.argv[1], sys.argv[2] +try: + monitors = json.loads(os.environ.get("BOS_HYPR_MONITORS") or "") +except Exception: + sys.exit(0) +if not isinstance(monitors, list): + sys.exit(0) + +hits = [] +for m in monitors: + if not isinstance(m, dict): + continue + name = m.get("name") or m.get("output") or "" + try: + scale = float(m.get("scale") or 1) + except (TypeError, ValueError): + scale = 1.0 + try: + w = int(m.get("width") or 0) + h = int(m.get("height") or 0) + except (TypeError, ValueError): + w = h = 0 + mm_w = mm_h = 0 + phys = m.get("physicalSize") + if isinstance(phys, dict): + mm_w = phys.get("x") or phys.get("width") or 0 + mm_h = phys.get("y") or phys.get("height") or 0 + elif isinstance(phys, (list, tuple)) and len(phys) >= 2: + mm_w, mm_h = phys[0], phys[1] + else: + mm_w = m.get("physicalWidth") or 0 + mm_h = m.get("physicalHeight") or 0 + try: + mm_w = float(mm_w or 0) + mm_h = float(mm_h or 0) + except (TypeError, ValueError): + mm_w = mm_h = 0.0 + dpi = round(w / (mm_w / 25.4), 1) if mm_w and w else 0.0 + px_per_mm = round(w / mm_w, 3) if mm_w and w else 0.0 + # High px/mm (dense panel) or Hyprland already chose scale > 1. + hidpi = scale > 1.01 or dpi >= 140 + if hidpi: + hits.append({ + "name": name, + "width": w, + "height": h, + "scale": scale, + "dpi": dpi, + "px_per_mm": px_per_mm, + }) + +if not hits: + sys.exit(0) +with open(hint_path, "w") as f: + json.dump({ + "suggested": True, + "rewrote_monitors_json": False, + "reason": "scale > 1 or DPI >= 140", + "monitors": hits, + "noted_at": noted_at, + }, f, indent=2) + f.write("\n") +PY + fi +fi + +# Mark done even if every probe was a no-op — do not nag next login. +printf '%s\n' "$(iso_now)" >"$FLAG" 2>/dev/null || true +exit 0 diff --git a/iso/airootfs/usr/local/bin/bos-keybinds b/iso/airootfs/usr/local/bin/bos-keybinds deleted file mode 100644 index 4d036cb..0000000 --- a/iso/airootfs/usr/local/bin/bos-keybinds +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -# Show the BOS keybind cheatsheet in a floating terminal (bound to SUPER+/). -# The bos-keybinds window class is floated/centred by a Hyprland window rule. -exec kitty --class bos-keybinds --title "BOS Keybinds" -- less -R /usr/share/bos/keybinds.txt diff --git a/iso/airootfs/usr/local/bin/bos-live-setup b/iso/airootfs/usr/local/bin/bos-live-setup index f7b1d26..0609cac 100644 --- a/iso/airootfs/usr/local/bin/bos-live-setup +++ b/iso/airootfs/usr/local/bin/bos-live-setup @@ -7,9 +7,17 @@ # bos-launch-calamares). Runs once at boot, before the tty1 autologin getty. set -e +# Bakery user units live in /usr/lib/systemd/user. --global enable writes +# /etc/systemd/user/*.wants/ so liveuser (and any later account) starts +# them on first login. Idempotent; bins are already in /usr/local. +if [[ -x /usr/local/bin/bos-enable-bakery-user-units ]]; then + /usr/local/bin/bos-enable-bakery-user-units \ + || echo "WARN: enabling bakery user units globally failed" +fi + # useradd -m copies /etc/skel, so the live user gets the real BOS desktop -# (breadd + breadbar + breadbox + keybinds) — proper live-media functionality, -# not an installer kiosk. +# (hypr + bread config + bakery state) — proper live-media functionality, +# not an installer kiosk. Binaries are /usr/local, not skel. if ! id liveuser &>/dev/null; then useradd -m -s /usr/bin/zsh liveuser for g in wheel video input audio storage power; do diff --git a/iso/airootfs/usr/local/bin/bos-netcheck b/iso/airootfs/usr/local/bin/bos-netcheck new file mode 100755 index 0000000..e7821cf --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-netcheck @@ -0,0 +1,35 @@ +#!/bin/bash +# Every-login network connectivity check. Split out of the old bos-welcome +# script, which conflated this with one-time onboarding (now breadhelp's +# job) — this half must keep running unconditionally on every login, before +# any GUI toolkit is worth spinning up, and must never be gated by a marker. +set -u + +# Never run in the live/installer session — only on an installed system. +[[ "$(id -un)" == "liveuser" ]] && exit 0 + +# A fresh install usually boots with no connection (Wi-Fi isn't configured +# during install), and the first `bos-update`/pacman run then fails with +# confusing DNS/"could not resolve host" errors. If NetworkManager reports +# we're not fully online, open nmtui so the user can join a network before +# anything else. Best-effort: missing nmcli/nmtui/kitty, or the user quitting +# nmtui, must never block the rest of login. +command -v nmcli &>/dev/null || exit 0 + +conn="$(nmcli networking connectivity check 2>/dev/null)" +# NetworkManager may still be associating right at compositor start — give it +# a few short retries before concluding we're actually offline, so a machine +# with working Wi-Fi doesn't get a spurious nmtui popup. +tries=0 +while [[ "$conn" != "full" && "$tries" -lt 3 ]]; do + sleep 1 + conn="$(nmcli networking connectivity check 2>/dev/null)" + tries=$((tries + 1)) +done + +if [[ "$conn" != "full" ]]; then + notify-send -u normal "BOS" "No internet yet — opening network setup so updates work." 2>/dev/null || true + if command -v nmtui &>/dev/null; then + kitty --class bos-netsetup --title "Connect to a network" -- nmtui connect 2>/dev/null || true + fi +fi diff --git a/iso/airootfs/usr/local/bin/bos-nvidia-setup b/iso/airootfs/usr/local/bin/bos-nvidia-setup new file mode 100755 index 0000000..d287f53 --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-nvidia-setup @@ -0,0 +1,150 @@ +#!/bin/bash +# bos-nvidia-setup — optional proprietary NVIDIA driver + Hyprland env. +# +# Installs nvidia + nvidia-utils only (never cuda). Writes +# ~/.config/hypr/nvidia.lua, which skel hyprland.lua dofiles only when +# the file exists — Mesa machines stay unchanged. Existing installs get +# the same include patched in if it is missing. +# +# Click-to-install from Settings, or run by hand. Not invoked from +# bos-first-boot. Idempotent. Prints "reboot required". +# +# Must run on an installed system. Elevates via pkexec, then sudo. +set -uo pipefail + +usage() { + cat <<'EOF' +Usage: bos-nvidia-setup [--home DIR] + +Install nvidia + nvidia-utils (not cuda) and write the Hyprland NVIDIA +env drop-in for this user. Reboot after. + + --home DIR user home that owns ~/.config/hypr (required under pkexec + if PKEXEC_UID / SUDO_USER cannot be resolved) +EOF +} + +TARGET_HOME="" +while [[ $# -gt 0 ]]; do + case "$1" in + --home) + TARGET_HOME="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "bos-nvidia-setup: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$(id -un)" == "liveuser" || -d /run/archiso ]]; then + echo "bos-nvidia-setup is for an installed system, not the live ISO." >&2 + exit 1 +fi + +if [[ "$(id -u)" -ne 0 ]]; then + home="${TARGET_HOME:-${HOME:-}}" + if [[ -z "$home" ]]; then + echo "bos-nvidia-setup: cannot determine home; pass --home" >&2 + exit 1 + fi + self="$(command -v bos-nvidia-setup 2>/dev/null || true)" + [[ -n "$self" ]] || self="$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")" + if command -v pkexec >/dev/null 2>&1; then + exec pkexec "$self" --home "$home" + fi + if command -v sudo >/dev/null 2>&1; then + exec sudo "$self" --home "$home" + fi + echo "bos-nvidia-setup: need root (pkexec or sudo)" >&2 + exit 1 +fi + +if [[ -z "$TARGET_HOME" ]]; then + if [[ -n "${PKEXEC_UID:-}" ]]; then + TARGET_HOME="$(getent passwd "$PKEXEC_UID" | cut -d: -f6 || true)" + elif [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != root ]]; then + TARGET_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6 || true)" + fi +fi + +if [[ -z "$TARGET_HOME" || "$TARGET_HOME" == /root || ! -d "$TARGET_HOME" ]]; then + echo "bos-nvidia-setup: cannot determine user home (pass --home)" >&2 + exit 1 +fi + +HYPR_DIR="$TARGET_HOME/.config/hypr" +NVIDIA_LUA="$HYPR_DIR/nvidia.lua" +HYPR_LUA="$HYPR_DIR/hyprland.lua" + +# Hyprland 0.56 (Aquamarine). Wiki (https://wiki.hypr.land/Nvidia/): +# LIBVA_DRIVER_NAME + __GLX_VENDOR_LIBRARY_NAME. NVD_BACKEND is the +# current VA-API hint. No WLR_* (not wlroots). No GBM_BACKEND (not +# required; older docs cargo-culted it and it can break Firefox). +NVIDIA_LUA_BODY='-- Written by bos-nvidia-setup. hyprland.lua dofiles this only when it exists. +-- Hyprland 0.56 (Aquamarine) — no WLR_* variables. +-- https://wiki.hypr.land/Nvidia/ +hl.env("LIBVA_DRIVER_NAME", "nvidia") +hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia") +hl.env("NVD_BACKEND", "direct") +' + +# Self-contained so it is safe to append to a hand-edited hyprland.lua. +HYPR_INCLUDE='-- bos-nvidia-setup: optional proprietary env; no-op when the file is absent +do + local nvidia = (os.getenv("HOME") or "") .. "/.config/hypr/nvidia.lua" + local f = io.open(nvidia, "r") + if f then + f:close() + pcall(dofile, nvidia) + end +end +' + +own_as_user() { + local path="$1" + [[ -e "$path" ]] || return 0 + local owner + owner="$(stat -c '%u:%g' "$TARGET_HOME" 2>/dev/null || true)" + [[ -n "$owner" ]] || return 0 + chown "$owner" "$path" 2>/dev/null || true +} + +echo "==> Installing nvidia + nvidia-utils (not cuda)" +if ! command -v pacman >/dev/null 2>&1; then + echo "bos-nvidia-setup: pacman not found" >&2 + exit 1 +fi +if ! pacman -S --needed --noconfirm -- nvidia nvidia-utils; then + echo "bos-nvidia-setup: pacman install failed" >&2 + exit 1 +fi + +echo "==> Writing $NVIDIA_LUA" +mkdir -p "$HYPR_DIR" || { + echo "bos-nvidia-setup: cannot create $HYPR_DIR" >&2 + exit 1 +} +printf '%s' "$NVIDIA_LUA_BODY" >"$NVIDIA_LUA" || { + echo "bos-nvidia-setup: cannot write $NVIDIA_LUA" >&2 + exit 1 +} +own_as_user "$NVIDIA_LUA" + +if [[ -f "$HYPR_LUA" ]] && ! grep -q 'nvidia\.lua' "$HYPR_LUA"; then + echo "==> Including nvidia.lua from $HYPR_LUA" + if [[ -n "$(tail -c1 "$HYPR_LUA" 2>/dev/null || true)" ]]; then + printf '\n' >>"$HYPR_LUA" + fi + printf '%s\n' "$HYPR_INCLUDE" >>"$HYPR_LUA" + own_as_user "$HYPR_LUA" +fi + +echo "reboot required" +exit 0 diff --git a/iso/airootfs/usr/local/bin/bos-rescue b/iso/airootfs/usr/local/bin/bos-rescue new file mode 100755 index 0000000..f814865 --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-rescue @@ -0,0 +1,598 @@ +#!/bin/bash +# bos-rescue — live-ISO helper for an installed BOS that will not boot. +# +# Finds the installed btrfs `@` and the ESP, mounts them, then offers to +# arch-chroot and/or reinstall GRUB using the same sequence as +# post-install.sh / README Recovery: +# UEFI: grub-install NVRAM + --removable, then grub-mkconfig +# BIOS: grub-install i386-pc onto the disk hosting / +# +# Recovery is this script or the GRUB "snapshots" submenu (grub-btrfs). +# GRUB pins rootflags=subvol=@ — a snapper-swapped default subvolume is +# not what the installed grub.cfg will boot. Never snapper-rollback. +# +# Safe: prints the devices it will use and requires YES before writing. +# Best-effort: do not use `set -e`; a failed probe must not abort the rest. +set -uo pipefail + +MNT="${BOS_RESCUE_MNT:-}" +MOUNTED_ROOT=0 +MOUNTED_ESP=0 +ROOT_DEV="" +ESP_DEV="" +ROOT_ENCRYPTED=0 + +bold() { printf '\033[1m%s\033[0m\n' "$1" >&2; } +info() { printf ' %s\n' "$1" >&2; } +warn() { printf 'WARN: %s\n' "$1" >&2; } + +usage() { + cat <<'EOF' +Usage: bos-rescue + +Live-ISO helper: find the installed BOS btrfs @ and ESP, mount them, +then arch-chroot and/or reinstall GRUB. + + UEFI: grub-install (NVRAM) + grub-install --removable + grub-mkconfig + BIOS: grub-install --target=i386-pc onto the disk hosting / + +Prints the devices it will use and asks YES before writing anything. + +Do not snapper-rollback. GRUB pins rootflags=subvol=@. Pick a grub-btrfs +snapshot entry, or reinstall GRUB with this script. + +Must be run as root. Intended from the live ISO (SUPER+Return). +EOF +} + +need_root() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "bos-rescue must run as root (sudo bos-rescue)." >&2 + exit 1 + fi +} + +confirm_yes() { + local prompt="$1" + local reply="" + printf '%s [type YES]: ' "$prompt" >&2 + read -r reply || return 1 + [[ "$reply" == "YES" ]] +} + +is_live_iso() { + [[ -d /run/archiso ]] || [[ -x /usr/local/bin/bos-live-setup ]] +} + +already_on_installed() { + # Installed BOS: / is the @ subvolume and this is not the live medium. + is_live_iso && return 1 + local src opts + src="$(findmnt -no SOURCE / 2>/dev/null | sed 's/\[.*\]//')" + opts="$(findmnt -no OPTIONS / 2>/dev/null || true)" + [[ -n "$src" ]] || return 1 + [[ "$opts" == *subvol=/@* || "$opts" == *subvol=@* ]] || return 1 + [[ -f /etc/os-release ]] && grep -qE '^ID=bos$' /etc/os-release +} + +pick_mnt() { + if [[ -n "$MNT" ]]; then + return + fi + if findmnt -n /mnt >/dev/null 2>&1; then + MNT=/mnt/bos-rescue + info "/mnt is already a mountpoint — using $MNT" + else + MNT=/mnt + fi +} + +lsblk_line() { + lsblk -pnlo NAME,FSTYPE,SIZE,LABEL,UUID,PARTTYPENAME "$1" 2>/dev/null | head -n1 +} + +# Open LUKS containers so a later btrfs scan can see @. +offer_luks() { + command -v cryptsetup >/dev/null || return 0 + local dev name reply + while read -r dev; do + [[ -n "$dev" ]] || continue + [[ -e "$dev" ]] || continue + if lsblk -no TYPE "$dev" 2>/dev/null | grep -qx crypt; then + continue + fi + # Skip already-mapped parents. + if lsblk -nlo TYPE "$dev" 2>/dev/null | grep -qx crypt; then + continue + fi + printf '\nLUKS container: %s\n %s\n' "$dev" "$(lsblk_line "$dev")" >&2 + printf 'Unlock this container? [y/N]: ' >&2 + read -r reply || reply="" + if [[ "$reply" == [yY] ]]; then + name="bos-rescue-$(basename "$dev")" + if cryptsetup open "$dev" "$name"; then + info "opened $dev as /dev/mapper/$name" + else + warn "cryptsetup open failed for $dev" + fi + fi + done < <(lsblk -pnlo NAME,FSTYPE | awk '$2 == "crypto_LUKS" { print $1 }') +} + +# Probe a btrfs device for an @ subvolume that looks like BOS (or any @). +# Prints: DEVICEKINDPRETTY where KIND is bos|other +probe_btrfs_dev() { + local dev="$1" + local tmp pretty kind id + tmp="$(mktemp -d /tmp/bos-rescue.XXXXXX)" || return 1 + kind="other" + pretty="" + if mount -o ro,subvol=@ "$dev" "$tmp" 2>/dev/null; then + if [[ -f "$tmp/etc/os-release" ]]; then + id="$(grep -E '^ID=' "$tmp/etc/os-release" | head -n1 | cut -d= -f2- | tr -d '"')" + pretty="$(grep -E '^PRETTY_NAME=' "$tmp/etc/os-release" | head -n1 | cut -d= -f2- | tr -d '"')" + [[ "$id" == "bos" ]] && kind="bos" + fi + umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true + rmdir "$tmp" 2>/dev/null || true + printf '%s\t%s\t%s\n' "$dev" "$kind" "${pretty:-btrfs @}" + return 0 + fi + # Some volumes only accept a top-level probe first. + if mount -o ro,subvolid=5 "$dev" "$tmp" 2>/dev/null; then + if [[ -d "$tmp/@" ]] || btrfs subvolume show "$tmp/@" &>/dev/null; then + umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true + rmdir "$tmp" 2>/dev/null || true + printf '%s\t%s\t%s\n' "$dev" "other" "btrfs @ (unreadable os-release)" + return 0 + fi + umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true + fi + rmdir "$tmp" 2>/dev/null || true + return 1 +} + +find_root_candidates() { + local dev + while read -r dev; do + [[ -n "$dev" ]] || continue + probe_btrfs_dev "$dev" || true + done < <(lsblk -pnlo NAME,FSTYPE | awk '$2 == "btrfs" { print $1 }') +} + +# Prefer the ESP named in the installed fstab; else EFI type / BOS bits. +find_esp_for_root() { + local root="$1" + local tmp fstab_uuid fstab_dev dev fstype parttype label + tmp="$(mktemp -d /tmp/bos-rescue.XXXXXX)" || return 1 + if mount -o ro,subvol=@ "$root" "$tmp" 2>/dev/null; then + if [[ -f "$tmp/etc/fstab" ]]; then + fstab_uuid="$(awk '$2 == "/boot/efi" { + if ($1 ~ /^UUID=/) { sub(/^UUID=/, "", $1); print $1; exit } + }' "$tmp/etc/fstab")" + fi + umount "$tmp" 2>/dev/null || umount -l "$tmp" 2>/dev/null || true + fi + rmdir "$tmp" 2>/dev/null || true + + if [[ -n "${fstab_uuid:-}" ]]; then + fstab_dev="$(blkid -U "$fstab_uuid" 2>/dev/null || true)" + if [[ -n "$fstab_dev" ]]; then + printf '%s\n' "$fstab_dev" + return 0 + fi + fi + + local best="" scored=0 score + # PARTTYPE is the GPT GUID — no spaces, unlike PARTTYPENAME ("EFI System"). + local efi_guid="c12a7328-f81f-11d2-ba4b-00a716dde993" + while read -r dev fstype parttype; do + [[ -n "$dev" ]] || continue + score=0 + [[ "$fstype" == "vfat" || "$fstype" == "fat32" || "$fstype" == "FAT-32" ]] && score=$((score + 1)) + [[ "${parttype,,}" == "$efi_guid" ]] && score=$((score + 3)) + if (( score > scored )); then + best="$dev" + scored=$score + fi + done < <(lsblk -pnlo NAME,FSTYPE,PARTTYPE) + + # Prefer an ESP that already has BOS or removable fallback bits. + local probe mp + for dev in $best $(lsblk -pnlo NAME,FSTYPE | awk '$2 == "vfat" { print $1 }'); do + [[ -n "$dev" ]] || continue + mp="$(mktemp -d /tmp/bos-rescue.XXXXXX)" || continue + if mount -o ro "$dev" "$mp" 2>/dev/null; then + if [[ -f "$mp/EFI/BOS/grubx64.efi" || -f "$mp/EFI/BOOT/BOOTX64.EFI" ]]; then + umount "$mp" 2>/dev/null || true + rmdir "$mp" 2>/dev/null || true + printf '%s\n' "$dev" + return 0 + fi + umount "$mp" 2>/dev/null || true + fi + rmdir "$mp" 2>/dev/null || true + done + + [[ -n "$best" ]] && printf '%s\n' "$best" +} + +select_from_list() { + local title="$1" + shift + local -a items=("$@") + local i choice + if (( ${#items[@]} == 0 )); then + return 1 + fi + if (( ${#items[@]} == 1 )); then + printf '%s\n' "${items[0]}" + return 0 + fi + bold "$title" + for i in "${!items[@]}"; do + printf ' %d) %s\n' "$((i + 1))" "${items[$i]}" >&2 + done + printf 'Select [1-%d]: ' "${#items[@]}" >&2 + read -r choice || return 1 + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#items[@]} )); then + printf '%s\n' "${items[$((choice - 1))]}" + return 0 + fi + return 1 +} + +discover_and_choose() { + bold "Scanning for an installed BOS (btrfs @) …" + offer_luks + + local -a bos_devs=() other_devs=() + local dev kind pretty line + while IFS=$'\t' read -r dev kind pretty; do + [[ -n "$dev" ]] || continue + line="$dev (${pretty:-$kind})" + if [[ "$kind" == "bos" ]]; then + bos_devs+=("$dev") + else + other_devs+=("$dev") + fi + info "found $line" + done < <(find_root_candidates) + + if (( ${#bos_devs[@]} == 0 && ${#other_devs[@]} == 0 )); then + echo "No btrfs @ subvolume found. Unlock LUKS first if the install is encrypted." >&2 + return 1 + fi + + if (( ${#bos_devs[@]} == 1 )); then + ROOT_DEV="${bos_devs[0]}" + info "Using BOS root $ROOT_DEV" + elif (( ${#bos_devs[@]} > 1 )); then + ROOT_DEV="$(select_from_list "More than one BOS @ found:" "${bos_devs[@]}")" || return 1 + else + warn "No ID=bos os-release on @ — offering every btrfs @ found" + ROOT_DEV="$(select_from_list "Select the installed root device:" "${other_devs[@]}")" || return 1 + fi + + ESP_DEV="$(find_esp_for_root "$ROOT_DEV" || true)" + if [[ -n "$ESP_DEV" ]]; then + info "Using ESP $ESP_DEV" + fi + if [[ -z "$ESP_DEV" ]]; then + local -a esps=() + while read -r dev; do + [[ -n "$dev" ]] && esps+=("$dev") + done < <(lsblk -pnlo NAME,FSTYPE,PARTTYPE | awk ' + $2 == "vfat" || tolower($3) == "c12a7328-f81f-11d2-ba4b-00a716dde993" { print $1 } + ') + if (( ${#esps[@]} == 0 )); then + warn "No ESP found. GRUB reinstall on UEFI will fail; chroot is still available." + else + ESP_DEV="$(select_from_list "Select the EFI System Partition:" "${esps[@]}")" || true + fi + fi +} + +mount_install() { + pick_mnt + mkdir -p "$MNT" + if ! findmnt -n "$MNT" >/dev/null 2>&1; then + if ! mount -o subvol=@ "$ROOT_DEV" "$MNT"; then + warn "failed to mount $ROOT_DEV subvol=@ at $MNT" + return 1 + fi + MOUNTED_ROOT=1 + fi + if [[ -n "$ESP_DEV" ]]; then + mkdir -p "$MNT/boot/efi" + if ! findmnt -n "$MNT/boot/efi" >/dev/null 2>&1; then + if mount "$ESP_DEV" "$MNT/boot/efi"; then + MOUNTED_ESP=1 + else + warn "failed to mount ESP $ESP_DEV at $MNT/boot/efi" + fi + fi + fi + if [[ "$(lsblk -no TYPE "$ROOT_DEV" 2>/dev/null)" == "crypt" ]]; then + ROOT_ENCRYPTED=1 + fi +} + +unmount_install() { + if [[ "$MOUNTED_ESP" == "1" ]]; then + umount "$MNT/boot/efi" 2>/dev/null || umount -l "$MNT/boot/efi" 2>/dev/null || true + MOUNTED_ESP=0 + fi + if [[ "$MOUNTED_ROOT" == "1" ]]; then + umount "$MNT" 2>/dev/null || umount -l "$MNT" 2>/dev/null || true + MOUNTED_ROOT=0 + fi +} + +print_plan() { + echo >&2 + bold "Devices" + info "root: ${ROOT_DEV:-unset} $([[ -n "$ROOT_DEV" ]] && lsblk_line "$ROOT_DEV")" + info "ESP: ${ESP_DEV:-none} $([[ -n "$ESP_DEV" ]] && lsblk_line "$ESP_DEV")" + info "mount: ${MNT:-unset}" + if [[ -d /sys/firmware/efi ]]; then + info "firmware: UEFI" + else + info "firmware: BIOS" + fi + if [[ "$ROOT_ENCRYPTED" == "1" ]]; then + info "root is LUKS (grub-install will include cryptodisk modules)" + fi + echo >&2 + info "Recovery is grub-btrfs (GRUB snapshots submenu) or this GRUB reinstall." + info "GRUB pins rootflags=subvol=@ — do not swap the default subvolume." +} + +run_in_target() { + local cmd="$1" + if command -v arch-chroot >/dev/null; then + arch-chroot "$MNT" bash -c "$cmd" + return $? + fi + # arch-install-scripts is not guaranteed on the ISO — bind the API + # filesystems the same way arch-chroot would, then chroot. + mount --bind /proc "$MNT/proc" 2>/dev/null || mount -t proc proc "$MNT/proc" + mount --bind /sys "$MNT/sys" 2>/dev/null || mount -t sysfs sys "$MNT/sys" + mount --bind /dev "$MNT/dev" 2>/dev/null || mount -t devtmpfs udev "$MNT/dev" + mkdir -p "$MNT/run" + mount --bind /run "$MNT/run" 2>/dev/null || mount -t tmpfs tmpfs "$MNT/run" + if [[ -d /sys/firmware/efi ]]; then + mkdir -p "$MNT/sys/firmware/efi/efivars" + mount -t efivarfs efivarfs "$MNT/sys/firmware/efi/efivars" 2>/dev/null || true + fi + chroot "$MNT" bash -c "$cmd" + local rc=$? + umount "$MNT/sys/firmware/efi/efivars" 2>/dev/null || true + umount "$MNT/run" 2>/dev/null || true + umount "$MNT/dev" 2>/dev/null || true + umount "$MNT/sys" 2>/dev/null || true + umount "$MNT/proc" 2>/dev/null || true + return "$rc" +} + +grub_commands_preview() { + if [[ -d /sys/firmware/efi ]]; then + cat <<'EOF' >&2 + grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=BOS --recheck + grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck + grub-mkconfig -o /boot/grub/grub.cfg +EOF + else + cat <<'EOF' >&2 + grub-install --target=i386-pc --recheck + grub-mkconfig -o /boot/grub/grub.cfg +EOF + fi +} + +reinstall_grub() { + if [[ ! -d "$MNT/boot" ]]; then + warn "target $MNT/boot missing — mount the installed @ first" + return 1 + fi + echo >&2 + bold "This will write a bootloader using:" + info "root ${ROOT_DEV:-/} ESP ${ESP_DEV:-n/a} chroot $MNT" + grub_commands_preview + echo >&2 + if ! confirm_yes "Reinstall GRUB now?"; then + info "skipped" + return 0 + fi + + # Same sequence as post-install.sh (UEFI NVRAM + --removable, or BIOS MBR). + local script + script="$(cat <<'EOS' +set -uo pipefail +ROOT_SRC="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" +if [[ "$(lsblk -no TYPE "$ROOT_SRC" 2>/dev/null)" == "crypt" ]]; then + ROOT_ENCRYPTED=1 +else + ROOT_ENCRYPTED=0 +fi +if [[ "$ROOT_ENCRYPTED" == "1" ]] && [[ -f /etc/default/grub ]] \ + && ! grep -q '^GRUB_ENABLE_CRYPTODISK=' /etc/default/grub; then + echo 'GRUB_ENABLE_CRYPTODISK=y' >> /etc/default/grub \ + || echo "WARN: adding GRUB_ENABLE_CRYPTODISK failed" +fi +if ! command -v grub-install >/dev/null; then + echo "ERROR: grub-install not found in the installed system" >&2 + exit 1 +fi +CRYPT_MODULES=() +[[ "$ROOT_ENCRYPTED" == "1" ]] && CRYPT_MODULES=(--modules="cryptodisk luks luks2") +if [[ -d /sys/firmware/efi ]]; then + grub-install --target=x86_64-efi --efi-directory=/boot/efi \ + --bootloader-id=BOS --recheck "${CRYPT_MODULES[@]}" \ + || echo "WARN: grub-install (nvram) failed" + grub-install --target=x86_64-efi --efi-directory=/boot/efi \ + --removable --recheck "${CRYPT_MODULES[@]}" \ + || echo "WARN: grub-install (removable) failed" +else + ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" + ROOT_DISK="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null)" + if [[ -n "$ROOT_DISK" ]]; then + grub-install --target=i386-pc --recheck "${CRYPT_MODULES[@]}" "/dev/$ROOT_DISK" \ + || echo "WARN: grub-install (BIOS) failed" + else + echo "WARN: could not determine the disk hosting / — BIOS grub-install skipped" + fi +fi +if command -v grub-mkconfig >/dev/null; then + grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed" +else + echo "WARN: grub-mkconfig not found" +fi +EOS +)" + if run_in_target "$script"; then + bold "GRUB reinstall finished." + info "Firmware that lost its NVRAM entry can still boot EFI/BOOT/BOOTX64.EFI." + else + warn "GRUB reinstall returned non-zero — see messages above" + return 1 + fi +} + +do_chroot() { + if [[ ! -d "$MNT/etc" ]]; then + warn "target $MNT is not a mounted system" + return 1 + fi + bold "Entering chroot at $MNT (exit to return)." + if command -v arch-chroot >/dev/null; then + arch-chroot "$MNT" + else + run_in_target "exec bash -l" + fi +} + +menu_live() { + local choice + while true; do + echo + bold "bos-rescue" + print_plan + cat <<'EOF' >&2 + 1) arch-chroot into the installed system + 2) Reinstall GRUB (NVRAM + --removable + grub-mkconfig) + 3) Reinstall GRUB, then chroot + 4) Unmount and quit + q) Quit (leave mounts) +EOF + printf 'Choice: ' >&2 + read -r choice || choice="q" + case "$choice" in + 1) do_chroot ;; + 2) reinstall_grub ;; + 3) reinstall_grub; do_chroot ;; + 4) unmount_install; bold "Unmounted."; return 0 ;; + q|Q) info "Leaving mounts in place at $MNT"; return 0 ;; + *) info "unknown choice" ;; + esac + done +} + +menu_installed() { + ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" + ESP_DEV="$(findmnt -no SOURCE /boot/efi 2>/dev/null || true)" + MNT="/" + if [[ "$(lsblk -no TYPE "$ROOT_DEV" 2>/dev/null)" == "crypt" ]]; then + ROOT_ENCRYPTED=1 + fi + echo + bold "Already running the installed BOS (not the live ISO)." + info "Root and ESP are already mounted — chroot is not needed." + print_plan + if confirm_yes "Reinstall GRUB on this running system?"; then + # Running on the installed root: no extra mount/chroot. + local old_mnt="$MNT" + MNT="/" + # run_in_target would chroot into / — just run locally. + if [[ -d /sys/firmware/efi && -z "$ESP_DEV" ]]; then + warn " /boot/efi is not mounted — refusing to write" + return 1 + fi + bash -c "$(cat <<'EOS' +set -uo pipefail +ROOT_SRC="$(findmnt -no SOURCE / | sed 's/\[.*\]//')" +if [[ "$(lsblk -no TYPE "$ROOT_SRC" 2>/dev/null)" == "crypt" ]]; then + ROOT_ENCRYPTED=1 +else + ROOT_ENCRYPTED=0 +fi +if [[ "$ROOT_ENCRYPTED" == "1" ]] && [[ -f /etc/default/grub ]] \ + && ! grep -q '^GRUB_ENABLE_CRYPTODISK=' /etc/default/grub; then + echo 'GRUB_ENABLE_CRYPTODISK=y' >> /etc/default/grub \ + || echo "WARN: adding GRUB_ENABLE_CRYPTODISK failed" +fi +CRYPT_MODULES=() +[[ "$ROOT_ENCRYPTED" == "1" ]] && CRYPT_MODULES=(--modules="cryptodisk luks luks2") +if [[ -d /sys/firmware/efi ]]; then + grub-install --target=x86_64-efi --efi-directory=/boot/efi \ + --bootloader-id=BOS --recheck "${CRYPT_MODULES[@]}" \ + || echo "WARN: grub-install (nvram) failed" + grub-install --target=x86_64-efi --efi-directory=/boot/efi \ + --removable --recheck "${CRYPT_MODULES[@]}" \ + || echo "WARN: grub-install (removable) failed" +else + ROOT_DISK="$(lsblk -no pkname "$ROOT_SRC" 2>/dev/null)" + if [[ -n "$ROOT_DISK" ]]; then + grub-install --target=i386-pc --recheck "${CRYPT_MODULES[@]}" "/dev/$ROOT_DISK" \ + || echo "WARN: grub-install (BIOS) failed" + fi +fi +grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed" +EOS +)" + MNT="$old_mnt" + else + info "skipped" + fi +} + +main() { + if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 + fi + need_root + local req + for req in mount lsblk blkid findmnt; do + if ! command -v "$req" >/dev/null; then + echo "bos-rescue: missing required tool '$req'" >&2 + exit 1 + fi + done + bold "bos-rescue" + info "Live-ISO recovery helper. Prints devices and asks YES before writing." + info "Use grub-btrfs (GRUB snapshots submenu) for a bootable snapshot." + info "Do not snapper-rollback — GRUB pins rootflags=subvol=@." + echo + + if already_on_installed; then + menu_installed + return 0 + fi + + if ! is_live_iso; then + warn "This does not look like the BOS live ISO (/run/archiso missing)." + info "Continuing anyway — will scan disks for a BOS @." + fi + + discover_and_choose || exit 1 + print_plan + if ! confirm_yes "Mount these devices and continue?"; then + info "nothing mounted" + exit 0 + fi + mount_install || exit 1 + menu_live +} + +main "$@" diff --git a/iso/airootfs/usr/local/bin/bos-session b/iso/airootfs/usr/local/bin/bos-session index d7655fc..5f27974 100644 --- a/iso/airootfs/usr/local/bin/bos-session +++ b/iso/airootfs/usr/local/bin/bos-session @@ -2,11 +2,10 @@ # BOS graphical session launcher, run by greetd on the INSTALLED system after # the user authenticates (see /etc/greetd/config.toml). # -# greetd does not start a login shell, so /etc/profile.d is never sourced — which -# means ~/.local/bin (where bakery installs the bread ecosystem: breadd, breadbar, -# breadbox-sync, …) would be missing from PATH and the Hyprland `exec-once` -# launches would fail. Source the login profile here so PATH is correct, set the -# Wayland session hints, then hand off to Hyprland. +# greetd does not start a login shell, so /etc/profile.d is never sourced. +# Bakery desktop apps live in /usr/local/bin (already on Arch PATH). Source +# the login profile here so ~/.local/bin (per-user tools) is also on PATH, +# set the Wayland session hints, then hand off to Hyprland. # # Launched via start-hyprland (ships with the hyprland package) rather than the # raw Hyprland binary — Hyprland upstream no longer recommends exec'ing it diff --git a/iso/airootfs/usr/local/bin/bos-update b/iso/airootfs/usr/local/bin/bos-update index 53ac3c1..a34f70b 100644 --- a/iso/airootfs/usr/local/bin/bos-update +++ b/iso/airootfs/usr/local/bin/bos-update @@ -2,19 +2,41 @@ # bos-update — update all of BOS in one go. # # BOS packages come from two channels, so a full update touches both: -# 1. pacman — Arch base/desktop + the [breadway] repo (bos-settings, etc.). -# Every transaction is snapshotted by snap-pac, so you can roll -# back from the GRUB "snapshots" submenu or BOS Settings. -# 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list` -# reports as installed — bread, breadbar, breadbox, breadcrumbs, -# breadpad, breadman, bread-theme, breadpaper, breadmon, -# breadsearch, breadclip, breadshot, ...). +# 1. pacman — Arch base/desktop + the [breadway] repo (breadlock + AUR +# republishes: calamares, zen-browser-bin, bibata, yay-bin, +# powerlevel10k). [breadway] does NOT provide bos-settings +# or other bakery desktop apps. Every transaction is +# snapshotted by snap-pac; recover via the GRUB "snapshots" +# submenu (grub-btrfs), not `snapper rollback`. +# 2. bakery — the bread ecosystem apps in /usr/local (whatever `bakery list` +# reports as installed — bakery, bread, breadbar, breadbox, +# breadcrumbs, breadpad, breadman, bread-theme, breadpaper, +# breadmon, breadsearch, breadclip, breadshot, bos-settings, +# breadhelp, ...). Those bits live on @ and ride snapper +# root snapshots; recover via grub-btrfs, not `snapper rollback`. # # Best-effort: a failure in one channel doesn't abort the other. set -uo pipefail bold() { printf '\033[1m%s\033[0m\n' "$1"; } +# Timed snapper pre snapshot before either channel. snap-pac already +# snapshots root around pacman; bakery now writes /usr/local (on @), so +# that root snapshot includes the desktop apps. This extra snapshot is +# still best-effort — a home config if the installer created one (user +# bakery state), plus a root timeline around the whole update. Never +# fail the update if snapper is missing or the create errors. +if command -v snapper >/dev/null; then + if snapper -c home list >/dev/null 2>&1; then + snapper -c home create -t pre -c number \ + -d "bos-update (pre bakery)" \ + || echo "WARN: snapper home pre snapshot failed" + fi + snapper -c root create -t pre -c number \ + -d "bos-update (pre bakery)" \ + || echo "WARN: snapper pre snapshot failed" +fi + bold "==> System packages (pacman -Syu)" if command -v pacman >/dev/null; then sudo pacman -Syu || echo "WARN: pacman update failed" @@ -25,10 +47,22 @@ fi echo bold "==> Bread ecosystem (bakery update --all)" if command -v bakery >/dev/null; then - bakery update --all || echo "WARN: bakery update failed" + # /usr/local is root-owned. Never run bakery as the user against it; + # bakery itself also tries sudo -n then pkexec for privileged writes. + if sudo -n true >/dev/null 2>&1; then + sudo -n bakery update --all || echo "WARN: bakery update failed" + elif command -v pkexec >/dev/null; then + pkexec bakery update --all || echo "WARN: bakery update failed" + else + echo "WARN: bakery update needs sudo -n or pkexec for /usr/local" + fi else echo "bakery not found; skipping" fi echo bold "==> BOS is up to date." +echo +bold "Recovery" +echo "If this update goes badly: reboot → GRUB “snapshots” submenu." +echo "snapper rollback will not change what GRUB boots (rootflags=subvol=@)." diff --git a/iso/airootfs/usr/local/bin/bos-welcome b/iso/airootfs/usr/local/bin/bos-welcome deleted file mode 100644 index 92d2c2c..0000000 --- a/iso/airootfs/usr/local/bin/bos-welcome +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -# First-run welcome + first-run/every-login network check. Launched from the -# Hyprland autostart; the bos-welcome window class is floated/centred by a -# Hyprland window rule. -set -u - -# Never run in the live/installer session — only on an installed system. -[[ "$(id -un)" == "liveuser" ]] && exit 0 - -welcomed_marker="${XDG_CONFIG_HOME:-$HOME/.config}/bos/.welcomed" -mkdir -p "$(dirname "$welcomed_marker")" - -# Network check. A fresh install usually boots with no connection (Wi-Fi -# isn't configured during install), and the first `bos-update`/pacman run -# then fails with confusing DNS/"could not resolve host" errors. If -# NetworkManager reports we're not fully online, open nmtui so the user can -# join a network before anything else. This runs on EVERY login, not just -# the first, and isn't gated by any marker — it keeps re-prompting until the -# machine is actually online, then naturally stops (the "full" check below -# short-circuits). Best-effort: missing nmcli/nmtui/kitty, or the user -# quitting nmtui, must never block the welcome text below. -if command -v nmcli &>/dev/null; then - conn="$(nmcli networking connectivity check 2>/dev/null)" - # NetworkManager may still be associating right at compositor start — - # give it a few short retries before concluding we're actually offline, - # so a machine with working Wi-Fi doesn't get a spurious nmtui popup. - tries=0 - while [[ "$conn" != "full" && "$tries" -lt 3 ]]; do - sleep 1 - conn="$(nmcli networking connectivity check 2>/dev/null)" - tries=$((tries + 1)) - done - - if [[ "$conn" != "full" ]]; then - notify-send -u normal "BOS" "No internet yet — opening network setup so updates work." 2>/dev/null || true - if command -v nmtui &>/dev/null; then - kitty --class bos-netsetup --title "Connect to a network" -- nmtui connect 2>/dev/null || true - fi - fi -fi - -# Welcome text: shown once ever, independent of network status above (an -# offline machine still gets useful onboarding text — it just also keeps -# getting the network prompt on future logins until it connects). -[[ -f "$welcomed_marker" ]] && exit 0 -touch "$welcomed_marker" - -exec kitty --class bos-welcome --title "Welcome to BOS" -- less -R /usr/share/bos/welcome.txt diff --git a/iso/airootfs/usr/share/bos/keybinds.txt b/iso/airootfs/usr/share/bos/keybinds.txt deleted file mode 100644 index 1c1649a..0000000 --- a/iso/airootfs/usr/share/bos/keybinds.txt +++ /dev/null @@ -1,53 +0,0 @@ - - ██████ ██████ ███████ keyboard shortcuts - ██ ██ ██ ██ ██ SUPER is the Windows/Cmd key - ██████ ██ ██ ███████ - ══════════════════════════════════════════════════════════ - - APPS & WINDOWS - SUPER + Return terminal (kitty) - SUPER + Space app launcher (breadbox) - SUPER + E files (nautilus) - SUPER + B browser (zen) - SUPER + U notes / reminders (breadpad) - SUPER + M notes / task manager (breadman) - SUPER + , BOS Settings - SUPER + / this keybind cheatsheet - SUPER + L lock screen - SUPER + Backspace close window - SUPER + F fullscreen - SUPER + I toggle floating - SUPER + P toggle pseudotile - SUPER + R resize mode - SUPER + V / Shift + V clipboard history (breadclip) - SUPER + T toggle split direction - SUPER + Tab last window - SUPER + N exit Hyprland (log out) - - SCREENSHOTS - SUPER + Shift + S select region -> file - SUPER + Shift + C select region -> clipboard - SUPER + Shift + P whole screen -> file - - FOCUS & MOVE - SUPER + arrows move focus - SUPER + Shift + h/j/k/l move window - SUPER + Shift + arrows resize window - - WORKSPACES - SUPER + 1..0 switch to workspace 1..10 - SUPER + Shift + 1..0 move window to workspace - SUPER + [ / ] previous / next workspace - SUPER + Shift + [ / ] move window prev / next workspace - SUPER + scroll cycle workspaces - - MOUSE - SUPER + left-drag move window - SUPER + right-drag resize window - - MEDIA & HARDWARE KEYS - volume / brightness / play-pause / next / prev (work on lock screen) - calculator key opens gnome-calculator - - ────────────────────────────────────────────────────────── - Press q to close. Configure everything in BOS Settings (SUPER + ,). diff --git a/iso/airootfs/usr/share/bos/welcome.txt b/iso/airootfs/usr/share/bos/welcome.txt deleted file mode 100644 index a412f4a..0000000 --- a/iso/airootfs/usr/share/bos/welcome.txt +++ /dev/null @@ -1,24 +0,0 @@ - - Welcome to BOS — the Bread Operating System - ══════════════════════════════════════════════════════════ - - You're running a complete Hyprland desktop with the bread - ecosystem preinstalled. A few things to get you started: - - • SUPER + / show the keybind cheatsheet (any time) - • SUPER + , open BOS Settings — configure bread, the - bar, launcher, Wi-Fi profiles, notes, - snapshots and package updates, all in one - place (no config files needed) - • SUPER + Space the app launcher (breadbox) - • SUPER + Return a terminal - - The bar at the top (breadbar) shows workspaces, the clock, - system stats, and your tray. Notifications appear top-right. - - Your system is snapshotted on every package change — if an - update breaks something, roll back from BOS Settings or pick - a snapshot from the GRUB menu at boot. - - ────────────────────────────────────────────────────────── - Press q to close. This message won't show again. diff --git a/iso/bread-lockfile.toml b/iso/bread-lockfile.toml new file mode 100644 index 0000000..8e21593 --- /dev/null +++ b/iso/bread-lockfile.toml @@ -0,0 +1,63 @@ +# Bakery binaries baked into the live/installed image at /usr/local. +# +# build-local.sh and CI (scripts/ci-stage-bakery.py) read this file. A missing +# *required* binary fails the bake: a hollow ISO is worse than a failed build. +# optional_bins are baked when the verified stable index publishes them, and +# skipped with a warning when it does not. bread 0.8.0 ships bread-emit and +# bread-module-host, so those are required_bins. +# +# A flat `bins` list is still accepted and treated as required_bins. +# +# CI populates the builder from the minisign-verified stable bakery index +# (https://dl.breadway.dev/index.json). Optional [versions] (or [[pin]] +# tables with package + version) pin bakery package versions so two ISO +# bakes of the same git commit fetch the same bits: +# https://dl.breadway.dev///... +# Bump pins after new bakery stables land. Local builds still snapshot +# whatever is installed on the builder. +# +# Not shipped (even if they appear in the index): breadcast, breadarr. +# breadlock is pacman (see packages.x86_64), not bakery. + +required_bins = [ + "bakery", + "bread", + "breadd", + "bread-emit", + "bread-module-host", + "breadman", + "breadbar", + "breadbox", + "breadbox-sync", + "breadcrumbs", + "breadpad", + "breadpaper", + "bread-theme", + "breadmon", + "breadsearch", + "breadmill", + "breadclip", + "breadclipd", + "breadshot", + "bos-settings", + "breadhelp", +] + +# Package name → version. Must exist at dl.breadway.dev/// and +# should match the signed index so CI can verify sha256. +# [[pin]] { package, version } is accepted as well and merged (conflict = bake error). +[versions] +bakery = "0.7.4" +bread = "0.8.0" +bread-theme = "0.7.4" +breadbar = "0.3.2" +breadbox = "0.3.2" +breadcrumbs = "2.1.8" +breadpad = "0.5.2" +breadpaper = "0.1.13" +breadmon = "0.1.4" +breadsearch = "0.3.2" +breadclip = "0.2.3" +breadshot = "0.1.3" +bos-settings = "0.8.1" +breadhelp = "0.2.5" diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index 058aac7..d30488a 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -1,9 +1,26 @@ # Base system base -base-devel linux -linux-firmware -linux-headers +# linux-firmware metapackage pulls every mandatory vendor blob (incl. nvidia). +# List the subpackages we actually need so nvidia (~103 MiB, nouveau-only — +# BOS ships no NVIDIA driver) can stay off the image. Turing+ nouveau needs +# the GSP blobs; reinstall linux-firmware-nvidia when lspci sees NVIDIA. +# linux-firmware +linux-firmware-amdgpu +linux-firmware-atheros +linux-firmware-broadcom +linux-firmware-cirrus +linux-firmware-intel +linux-firmware-mediatek +linux-firmware-realtek +linux-firmware-radeon +linux-firmware-other +# linux-firmware-nvidia +# base-devel + linux-headers are for AUR/DKMS builds. yay needs base-devel, +# but those builds need network anyway — pacman -S base-devel at that point. +# linux-headers is DKMS-only and BOS ships no DKMS packages. +# base-devel +# linux-headers # CPU microcode — applied early by GRUB on the installed system (picked up by # the bootloader module). amd-ucode for the dev laptop's Ryzen; intel-ucode for # Intel targets. bos-copy-kernel also stages these into the live target /boot. @@ -30,12 +47,21 @@ efibootmgr btrfs-progs dosfstools mtools +# LUKS full-disk encryption — Calamares' partition module has encryption +# support built in and enabled by default, but needs cryptsetup actually +# present (live, to create the container; installed, to unlock at boot via +# mkinitcpio's encrypt hook) or the checkbox leads to an unbootable system. +cryptsetup +# Secure Boot key enrollment/signing (self-signed — see post-install.sh). +# Ships its own pacman hook (zz-sbctl.hook) that re-signs the kernel/ +# bootloader automatically on every future update once enrolled. +sbctl # squashfs-tools: provides unsquashfs, which Calamares' unpackfs module uses # to extract airootfs.sfs onto the target during install. squashfs-tools # rsync: unpackfs copies the unpacked rootfs onto the target with rsync. rsync -# Live-ISO boot (archiso bootmodes: bios.syslinux + uefi.systemd-boot) +# Live-ISO boot (archiso bootmodes: bios.syslinux + uefi.grub) # mkinitcpio-archiso provides the initramfs hooks that find and mount # airootfs.sfs and switch root into it — without it the live ISO drops # to emergency mode on boot. @@ -52,6 +78,8 @@ snapper snap-pac grub-btrfs inotify-tools +# Home backup (Settings → Backup). Snapper is root (`@`) only; restic covers $HOME. +restic # Wayland / Hyprland hyprland @@ -90,11 +118,17 @@ bluez-utils # blueman: GUI Bluetooth manager (pair/connect devices; breadbar shows status only). blueman -# GTK4 runtime +# GTK4 runtime (breadbar, breadbox, breadclip, breadhelp, and other bakery apps) gtk4 gtk4-layer-shell librsvg libpulse +hicolor-icon-theme +# Tauri 2 runtime for bakery-baked bos-settings. Arch's WebKitGTK 4.1 package +# is webkit2gtk-4.1 (libwebkit2gtk-4.1.so); libsoup3 and JavaScriptCore 4.1 +# are pulled in as its dependencies. xdg-desktop-portal comes from the +# Hyprland/GTK portal packages listed above. +webkit2gtk-4.1 # GTK3 dark theme (Adwaita-dark); without this package the gtk-theme-name in # skel settings.ini silently falls back to the light theme for GTK3 apps. gnome-themes-extra @@ -116,7 +150,9 @@ wayland-protocols # Fonts noto-fonts -noto-fonts-cjk +# noto-fonts-cjk is ~299 MiB installed / ~196 MiB on the ISO and only useful +# to CJK-locale users. Install on first run for zh/ja/ko. +# noto-fonts-cjk noto-fonts-emoji ttf-jetbrains-mono # Nerd font variant — icons in terminal tools (eza --icons, fastfetch, yazi) @@ -142,13 +178,12 @@ file-roller # GUI applications a general desktop is expected to have out of the box. # gnome-text-editor: graphical editor (terminal editors aside); gnome-calculator: -# calculator; loupe: Wayland-native image viewer (default for image files); -# zathura(+pdf-mupdf): lightweight Wayland PDF viewer (BOS had no PDF reader). +# calculator; loupe: Wayland-native image viewer (default for image files). +# PDF is handled by Zen (skel mimeapps.list maps application/pdf to zen.desktop); +# zathura+zathura-pdf-mupdf would pull libmupdf (~56 MiB) as a never-default viewer. gnome-text-editor gnome-calculator loupe -zathura -zathura-pdf-mupdf # Media player — BOS ships gstreamer codecs but otherwise has no player app. vlc # Web browser (served from the [Breadway] repo; AUR zen-browser-bin republished @@ -161,20 +196,24 @@ mailcap # (calamares 3.4.x is already Qt6; there is no separate calamares-qt6 package) calamares +# AUR helper — yay-bin is AUR-only (no AUR helper ships in the official +# repos), so it's republished to [breadway] the same way (see +# packaging/yay-bin). Lets users reach the wider AUR beyond bakery's bread +# ecosystem + [breadway]'s own small set of republished packages. +yay-bin + # Bread ecosystem. # -# The bread apps themselves (bakery, bread, breadbar, breadbox, breadcrumbs, -# breadpad) are NOT pacman packages here — they are bakery-managed binaries -# baked into /etc/skel/.local/bin at build time (see build-local.sh), so every -# user gets the exact versions from this laptop's bakery install with no -# network/DNS needed at install or runtime. Their runtime system deps are pulled -# in elsewhere in this list (gtk4, gtk4-layer-shell, iw, libpulse, librsvg, -# networkmanager, openssl, zlib, systemd-libs) — keep those even though no bread -# package depends on them. -# -# bos-settings is a BOS-specific pacman package (not part of the bakery index), -# so it stays here, served from the [breadway] repo. -bos-settings +# breadlock is the only bread* pacman package here (it needs a root-owned +# /etc/pam.d/breadlock). Everything else — bakery, bread/breadd/bread-emit/ +# bread-module-host, breadbar, breadbox, breadcrumbs, breadpad, breadpaper, +# bread-theme, breadmon, breadsearch, breadclip, breadshot, bos-settings, +# breadhelp — is bakery-managed and baked into /usr/local at ISO build +# time from iso/bread-lockfile.toml (see build-local.sh). breadcast and +# breadarr are not shipped. bos-settings/breadhelp desktop entries are +# also committed under iso/airootfs/etc/skel/.local/share/applications/. Runtime +# deps stay listed even though no bread package depends on them via pacman +# (gtk4, gtk4-layer-shell, webkit2gtk-4.1, iw, libpulse, librsvg, …). # Input / screen utilities brightnessctl @@ -186,6 +225,8 @@ slurp wl-clipboard playerctl # Wallpaper daemon + pywal (drives the bread* colour palette from the wallpaper). +# python-pywal was dropped from Arch [extra] (AUR-only now) — republished to +# [breadway], see packaging/python-pywal. awww python-pywal # Boot splash (BOS logo + spinner instead of kernel text). @@ -272,6 +313,15 @@ system-config-printer # remote post-install (needs network); the runtime is shipped ready. flatpak +# Graphical alternatives to terminal-only tools, so users who want more +# graphical control aren't funneled to a shell for everyday things. +# gnome-disk-utility: partition/format/SMART-health GUI for gnome-disks. +# gufw: GUI front-end for the ufw firewall bos already enables by default. +# mission-center: graphical task manager (CPU/mem/disk/net + process list). +gnome-disk-utility +gufw +mission-center + # Firewall — ufw, enabled deny-incoming in post-install.sh (mDNS allowed so # printer discovery still works). ufw @@ -298,6 +348,3 @@ qt6ct # hyprland.lua) needs these or Qt apps fall back to (blurry) XWayland. qt5-wayland qt6-wayland - -# Dev tools (for bos-settings standalone install) -rustup diff --git a/iso/pacman.conf b/iso/pacman.conf index 20c5242..abb7e2f 100644 --- a/iso/pacman.conf +++ b/iso/pacman.conf @@ -9,6 +9,23 @@ Architecture = auto CheckSpace ParallelDownloads = 5 +# Optional NoExtract size levers — left disabled. This file is both the ISO +# build config AND the installed system's pacman.conf, so enabling any line +# also stops future pacman -Syu from restoring those files. +# Measured against the 844-package closure (xz squashfs, profiledef.sh opts): +# usr/share/locale (non-en) 405.0 MiB raw -> 92.28 MiB ISO +# usr/share/doc 130.5 MiB raw -> 26.54 MiB ISO +# usr/share/man 41.5 MiB raw -> 38.77 MiB ISO +# usr/share/info 12.9 MiB raw -> 11.12 MiB ISO +# usr/share/gtk-doc 16.0 MiB raw -> 1.18 MiB ISO +# usr/include 193.6 MiB raw -> 25.26 MiB ISO +# Non-en locales make every GUI English-only until the package is reinstalled +# without this NoExtract; dropping man/info means `man` returns nothing. +#NoExtract = usr/share/locale/* !usr/share/locale/en* !usr/share/locale/locale.alias +#NoExtract = usr/share/doc/* usr/share/gtk-doc/* usr/share/info/* +#NoExtract = usr/share/man/* +#NoExtract = usr/include/* + Color VerbosePkgLists ILoveCandy @@ -26,20 +43,23 @@ Include = /etc/pacman.d/mirrorlist Include = /etc/pacman.d/mirrorlist # ----------------------------------------------------------------------- -# Breadway custom repo — provides: bakery and the bread ecosystem packages -# (bread, breadbar, breadbox, breadcrumbs, breadpad, bos-settings). -# (calamares comes from the official extra repo, not here.) +# Breadway custom repo — breadlock plus AUR republishes the ISO needs +# (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin, +# zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp +# are NOT here; they are bakery-baked into /usr/local at ISO build time. # # Packages are published to the Forgejo Arch registry (group "os") by the -# .forgejo/workflows/package.yml workflow in each repo, on tag push. +# .forgejo/workflows/*.yml workflows; scripts/ci-publish-signed-repo.sh then +# collects them, detach-signs each .pkg.tar.zst with the BOS release key +# (releases@breadway.dev), runs `repo-add -s`, and publishes the signed db +# at https://dl.breadway.dev/arch/$arch (signed-repo.yml). # -# Forgejo signs the repo db with a key pacman can't look up, so TrustAll -# fails. SigLevel = Never skips verification (acceptable for this private -# repo over TLS). Future improvement: import Forgejo's signing key and -# switch to SigLevel = Required for full package verification. +# SigLevel = Required: every package AND the db carry a .sig from key +# 56203B86A110695AE7F310934AF3323D678EB5E2 — the same key committed as +# KEYS.asc / airootfs/etc/pacman.d/breadway-repo.asc, imported into the +# pacman keyring at build time (build-local.sh), on the live medium, and +# on the installed target (calamares/post-install.sh). # ----------------------------------------------------------------------- -# The section name must match Forgejo's served db filename -# ({owner}.{group}.{domain}.db) — pacman fetches "
.db" from Server. -[Breadway.os.git.breadway.dev] -SigLevel = Never -Server = https://git.breadway.dev/api/packages/Breadway/arch/os/$arch +[breadway] +SigLevel = Required +Server = https://dl.breadway.dev/arch/$arch diff --git a/iso/profiledef.sh b/iso/profiledef.sh index fc4be34..462f74e 100644 --- a/iso/profiledef.sh +++ b/iso/profiledef.sh @@ -8,7 +8,12 @@ iso_application="Bread Operating System" iso_version="$(date +%Y.%m.%d)" install_dir="arch" buildmodes=('iso') -bootmodes=('bios.syslinux' 'uefi.systemd-boot') +# systemd-boot can only read files from the ESP it was launched from, so +# mkarchiso's _make_bootmode_uefi.systemd-boot copies vmlinuz + initramfs +# INTO the FAT efiboot.img on top of the copy already on ISO9660 (~244 MiB +# duplicate). uefi.grub's ESP is only EFI + shell*.efi — GRUB reads ISO9660 +# directly. iso/grub/{grub,loopback}.cfg are already BOS-branded. +bootmodes=('bios.syslinux' 'uefi.grub') arch="x86_64" pacman_conf="pacman.conf" airootfs_image_type="squashfs" @@ -22,7 +27,10 @@ file_permissions=( ["/usr/local/bin/bos-copy-kernel"]="0:0:755" ["/usr/local/bin/bos-resolve-airootfs"]="0:0:755" ["/usr/local/bin/bos-session"]="0:0:755" - ["/usr/local/bin/bos-keybinds"]="0:0:755" - ["/usr/local/bin/bos-welcome"]="0:0:755" + ["/usr/local/bin/bos-netcheck"]="0:0:755" ["/usr/local/bin/bos-update"]="0:0:755" + ["/usr/local/bin/bos-rescue"]="0:0:755" + ["/usr/local/bin/bos-first-boot"]="0:0:755" + ["/usr/local/bin/bos-nvidia-setup"]="0:0:755" + ["/usr/local/bin/bos-enable-bakery-user-units"]="0:0:755" ) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD deleted file mode 100644 index 6747f88..0000000 --- a/packaging/arch/PKGBUILD +++ /dev/null @@ -1,38 +0,0 @@ -# Maintainer: Breadway - -pkgname=bos-settings -pkgver=0.1.0 -pkgrel=1 -pkgdesc="System settings app for Bread OS" -arch=('x86_64') -url="https://github.com/Breadway/bos" -license=('MIT') -# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's -# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, -# causing undefined-symbol errors. Disable LTO. -options=(!lto !debug) -depends=('gtk4' 'glib2' 'hicolor-icon-theme') -optdepends=( - 'snapper: snapshot management view' -) -makedepends=('rust' 'cargo') -source=("${pkgname}-${pkgver}.tar.gz") -sha256sums=('SKIP') - -build() { - cd "${srcdir}/${pkgname}-${pkgver}" - cargo build --release --locked -p bos-settings -} - -check() { - cd "${srcdir}/${pkgname}-${pkgver}" - cargo test --release --locked -p bos-settings -} - -package() { - cd "${srcdir}/${pkgname}-${pkgver}" - install -Dm755 target/release/bos-settings "${pkgdir}/usr/bin/bos-settings" - install -Dm644 packaging/arch/bos-settings.desktop \ - "${pkgdir}/usr/share/applications/bos-settings.desktop" - install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" -} diff --git a/packaging/arch/README.md b/packaging/arch/README.md index af5acc0..1116280 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -1,25 +1,19 @@ Arch packaging ============== -`PKGBUILD` builds and installs `bos-settings` from source. +This directory only holds `PKGBUILD`s for third-party AUR packages BOS +republishes to the `[breadway]` pacman repo (`calamares`, `bibata`, +`powerlevel10k`, `yay-bin`, `python-pywal`) — not the user's own code. See each +subdirectory's `.forgejo/workflows/.yml` (in this repo) for how each +one publishes on a push to `packaging//**`. -## Local build - -```bash -makepkg -si -``` - -## Before publishing to [breadway] repo - -1. Tag a release on GitHub. -2. Update `pkgver` to match the tag. -3. Update `source` to the release tarball URL. -4. Run `updpkgsums` (or manually set `sha256sums`). - -## Runtime dependencies - -| Package | Required | Notes | -|---------|----------|-------| -| `gtk4` | yes | UI toolkit | -| `glib2` | yes | always | -| `snapper` | optional | snapshot management view | +Every bread-ecosystem app (bakery, bread, breadbar, breadbox, breadcrumbs, +breadpad, breadpaper, breadmon, breadsearch, breadclip, breadshot, +bos-settings, breadhelp, ...) is bakery-managed, not pacman-packaged — see +`iso/bread-lockfile.toml` (`required_bins` + `optional_bins`), which +`build-local.sh` uses as the name list when baking this machine's bakery +install into the ISO's `/etc/skel`. +`breadlock` is the sole deliberate exception (it needs a root-owned +`/etc/pam.d/breadlock` PAM service file, which bakery — by design — has no +privileged-install path for) and stays on pacman only; see +`bread-ecosystem/docs/release-channels.md` for the full policy. diff --git a/packaging/bibata/PKGBUILD b/packaging/bibata/PKGBUILD index b49e382..b713436 100644 --- a/packaging/bibata/PKGBUILD +++ b/packaging/bibata/PKGBUILD @@ -16,7 +16,13 @@ options=('!strip') source=("${pkgname%-bin}-$pkgver.tar.xz::$url/releases/download/v$pkgver/Bibata.tar.xz") sha256sums=('172e33c4ae415278384dcecc7d1a9b7a024266bc944bc751fd86532be1cc6251') +# Upstream tarball has all 12 variants (~322 MiB). BOS only ever selects +# Bibata-Modern-Ice (hyprland.lua XCURSOR_THEME, gsettings, gtk settings.ini). +# Ship that plus its -Right sibling. +_variants=(Bibata-Modern-Ice Bibata-Modern-Ice-Right) package() { install -d "$pkgdir/usr/share/icons" - cp -r Bibata* "$pkgdir/usr/share/icons" + for v in "${_variants[@]}"; do + cp -r "$v" "$pkgdir/usr/share/icons/" + done } diff --git a/packaging/calamares/PKGBUILD b/packaging/calamares/PKGBUILD index 494ae31..7f44bbb 100644 --- a/packaging/calamares/PKGBUILD +++ b/packaging/calamares/PKGBUILD @@ -1,4 +1,4 @@ -# Maintainer: Breadway +# Maintainer: Breadway # In-house copy of the AUR calamares PKGBUILD (Calamares is AUR-only; not in # Arch's official repos). Built by CI and published to the [breadway] repo. # Source of truth: https://aur.archlinux.org/packages/calamares diff --git a/packaging/python-pywal/PKGBUILD b/packaging/python-pywal/PKGBUILD new file mode 100644 index 0000000..b6796f5 --- /dev/null +++ b/packaging/python-pywal/PKGBUILD @@ -0,0 +1,44 @@ +# BOS in-house rebuild of python-pywal. +# +# python-pywal was dropped from Arch's [extra] repo (it is now AUR-only), but +# BOS needs the `wal` binary: bread-theme shells out to it to extract a colour +# palette from the user's wallpaper. Republished to [breadway] so the ISO can +# pull it via pacman, same pattern as calamares / bibata / powerlevel10k / +# yay-bin. Source of truth: https://aur.archlinux.org/packages/python-pywal +# +# Maintainer: Breadway +# Upstream maintainer: Morten Linderud +# Contributor: Sean Haugh + +pkgname=python-pywal +pkgver=3.3.0 +pkgrel=11 +pkgdesc="Generate and change colorschemes on the fly" +arch=('any') +url="https://github.com/dylanaraps/pywal/" +license=('MIT') +depends=('python' 'imagemagick') +makedepends=('python-build' 'python-installer' 'python-wheel' 'python-setuptools') +optdepends=('feh: set wallpaper' + 'nitrogen: set wallpaper') +# BOS PKGBUILDs verify sources by sha256 only (no source PGP), matching +# calamares / powerlevel10k here. +source=("$pkgname-$pkgver.tar.gz::https://github.com/dylanaraps/pywal/archive/${pkgver}.tar.gz") +sha256sums=('fe8fc1c29d1cad1a1a8580293dcfe32e1fac259f9dbfd5c8877439fa5948d189') + +build() { + cd "pywal-${pkgver}" + # setup.py-only project: python-build injects the setuptools backend. + python -m build --wheel --no-isolation +} + +check() { + cd "pywal-${pkgver}" + python -m unittest discover -vs tests +} + +package() { + cd "pywal-${pkgver}" + python -m installer --destdir="$pkgdir" dist/*.whl + install -Dm644 LICENSE.md "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/packaging/yay-bin/PKGBUILD b/packaging/yay-bin/PKGBUILD new file mode 100644 index 0000000..0a3a45f --- /dev/null +++ b/packaging/yay-bin/PKGBUILD @@ -0,0 +1,40 @@ +# BOS in-house rebuild of yay-bin (AUR-only upstream — no AUR helper is in +# the official Arch repos, including yay itself). Republished to the +# [breadway] repo so the ISO build can pull it via pacman (same pattern as +# bibata-cursor-theme and calamares). Prebuilt release tarball — no build step. +# Upstream maintainer: Jguer +pkgname=yay-bin +pkgver=13.0.1 +pkgrel=1 +pkgdesc="Yet another yogurt. Pacman wrapper and AUR helper written in go. Pre-compiled." +arch=('x86_64') +url="https://github.com/Jguer/yay" +license=('GPL-3.0-or-later') +depends=( + 'pacman>6.1' + 'git' +) +optdepends=( + 'sudo: privilege elevation' + 'doas: privilege elevation' +) +provides=('yay') +conflicts=('yay') + +source=("https://github.com/Jguer/yay/releases/download/v${pkgver}/${pkgname/-bin/}_${pkgver}_x86_64.tar.gz") +sha256sums=('1fdfcb5f7f387bc858d3a5754bdf4e4575bfbddac9560535a716d0ed7189c057') + +package() { + _output="${srcdir}/${pkgname/-bin/}_${pkgver}_${CARCH}" + install -Dm755 "${_output}/${pkgname/-bin/}" "${pkgdir}/usr/bin/${pkgname/-bin/}" + install -Dm644 "${_output}/yay.8" "${pkgdir}/usr/share/man/man8/yay.8" + + install -Dm644 "${_output}/bash" "${pkgdir}/usr/share/bash-completion/completions/yay" + install -Dm644 "${_output}/zsh" "${pkgdir}/usr/share/zsh/site-functions/_yay" + install -Dm644 "${_output}/fish" "${pkgdir}/usr/share/fish/vendor_completions.d/yay.fish" + + LANGS="ca cs de en es eu fr_FR he id it_IT ja ko pl_PL pt_BR pt ru_RU ru sv tr uk zh_CN zh_TW" + for lang in ${LANGS}; do + install -Dm644 "${_output}/${lang}.mo" "${pkgdir}/usr/share/locale/${lang}/LC_MESSAGES/yay.mo" + done +} diff --git a/scripts/ci-publish-signed-repo.sh b/scripts/ci-publish-signed-repo.sh new file mode 100755 index 0000000..6ded0e0 --- /dev/null +++ b/scripts/ci-publish-signed-repo.sh @@ -0,0 +1,308 @@ +#!/usr/bin/env bash +# Collect the current [breadway] ISO packages, detach-sign them with the +# BOS release key (releases@breadway.dev), and publish a signed pacman db +# under /srv/breadway-dl/arch/x86_64/ (https://dl.breadway.dev/arch/x86_64/). +# +# Does not change ISO SigLevel and does not write to the Forgejo Arch +# registry — existing package.yml / packaging/*.yml PUTs stay as they are. +# +# Required env: +# GPG_PRIVATE_KEY armoured secret key (same secret as release-iso.yml) +# Optional env: +# BREADWAY_DEST publish dir (default /srv/breadway-dl/arch/x86_64) +# BREADWAY_PKG_DIR extra directory of .pkg.tar.zst to prefer over the registry +# BREADWAY_REGISTRY Forgejo Arch registry base +# BREADWAY_SIGN_ONLY=1 skip collect; sign+index BREADWAY_REPO_DIR only +set -euo pipefail + +PACKAGES=( + breadlock + calamares + zen-browser-bin + bibata-cursor-theme-bin + zsh-theme-powerlevel10k + yay-bin + python-pywal +) + +ARCH="${BREADWAY_ARCH:-x86_64}" +REGISTRY="${BREADWAY_REGISTRY:-https://git.breadway.dev/api/packages/Breadway/arch/os}" +DEST="${BREADWAY_DEST:-/srv/breadway-dl/arch/${ARCH}}" +KEY_ID="${BREADWAY_KEY_ID:-releases@breadway.dev}" +DB_NAME="${BREADWAY_REGISTRY_DB:-Breadway.os.git.breadway.dev.db}" +REPO_DIR="${BREADWAY_REPO_DIR:-}" + +SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" + +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +need_key() { + if [[ -z "${GPG_PRIVATE_KEY:-}" ]]; then + die "GPG_PRIVATE_KEY is missing; refusing to publish an unsigned [breadway] repo." + fi +} + +urlencode() { + python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe="-._~"))' "$1" +} + +pkginfo_name() { + local pkg="$1" info + info="$(tar -xOf "$pkg" .PKGINFO 2>/dev/null || zstd -dc "$pkg" | tar -xO .PKGINFO)" + awk -F ' = ' '$1=="pkgname" {print $2; exit}' <<<"$info" +} + +import_key() { + export GNUPGHOME="${GNUPGHOME:-$(mktemp -d "${TMPDIR:-/tmp}/gnupg-breadway-repo.XXXXXX")}" + mkdir -m 700 -p "$GNUPGHOME" + printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import +} + +detach_sign_pkgs() { + local pkg + shopt -s nullglob + for pkg in *.pkg.tar.zst; do + gpg --batch --yes --local-user "$KEY_ID" --detach-sign "$pkg" + done + shopt -u nullglob +} + +repo_add_signed() { + local pkgs=() + shopt -s nullglob + pkgs=(*.pkg.tar.zst) + shopt -u nullglob + (( ${#pkgs[@]} > 0 )) || die "no .pkg.tar.zst files to index" + rm -f breadway.db breadway.db.tar.gz breadway.db.sig breadway.db.tar.gz.sig \ + breadway.files breadway.files.tar.gz breadway.files.sig breadway.files.tar.gz.sig + if repo-add --help 2>&1 | grep -q -- '--include-sigs'; then + repo-add -s -k "$KEY_ID" --include-sigs breadway.db.tar.gz "${pkgs[@]}" + else + repo-add -s -k "$KEY_ID" breadway.db.tar.gz "${pkgs[@]}" + fi + [[ -e breadway.db.tar.gz.sig || -e breadway.db.sig ]] \ + || die "repo-add -s did not write breadway.db*.sig" + # gpg writes 0600; nginx and the next publish need world-readable files. + find . -maxdepth 1 -type f -exec chmod a+r {} + || true +} + +ensure_arch_tools() { + if ! command -v gpg >/dev/null 2>&1; then + command -v pacman >/dev/null 2>&1 || die "gpg not on PATH" + pacman -Sy --noconfirm --needed gnupg + fi + command -v repo-add >/dev/null 2>&1 || die "repo-add not on PATH" + command -v gpg >/dev/null 2>&1 || die "gpg not on PATH" +} + +sign_and_index() { + local dir="$1" + [[ -d "$dir" ]] || die "repo dir missing: $dir" + need_key + ensure_arch_tools + import_key + ( + cd "$dir" + detach_sign_pkgs + repo_add_signed + ) +} + +container_runtime() { + if command -v docker >/dev/null 2>&1; then + printf '%s\n' docker + elif command -v podman >/dev/null 2>&1; then + printf '%s\n' podman + else + return 1 + fi +} + +sign_and_index_anywhere() { + local dir="$1" + if command -v repo-add >/dev/null 2>&1 && command -v gpg >/dev/null 2>&1; then + sign_and_index "$dir" + return + fi + local rt + rt="$(container_runtime)" || die \ + "need host gpg+repo-add, or docker/podman to run archlinux:latest (no Forgejo container: — host must see /srv/breadway-dl)" + # Host job + bind-mount, same reason bakery writes /srv without container:. + # Run as the runner user: root-owned 0600 .sig files made chmod/nginx fail + # (run 1050) and would block the next `rm -rf` of a previous tree. + "$rt" run --rm --network=host \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp \ + -e TMPDIR=/tmp \ + -e GPG_PRIVATE_KEY \ + -e BREADWAY_SIGN_ONLY=1 \ + -e BREADWAY_REPO_DIR=/repo \ + -e BREADWAY_KEY_ID="$KEY_ID" \ + -v "$dir:/repo" \ + -v "$SCRIPT_PATH:/ci-publish-signed-repo.sh:ro" \ + archlinux:latest \ + bash /ci-publish-signed-repo.sh +} + +parse_registry_db() { + local db="$1" + python3 - "$db" "${PACKAGES[@]}" <<'PY' +import sys, tarfile + +db = sys.argv[1] +want = set(sys.argv[2:]) +found = {} +with tarfile.open(db, "r:*") as tf: + for member in tf.getmembers(): + if not member.name.endswith("/desc") or not member.isfile(): + continue + fh = tf.extractfile(member) + if fh is None: + continue + text = fh.read().decode() + fields = {} + key = None + buf = [] + def flush(): + if key is not None: + fields[key] = "\n".join(buf).strip() + for line in text.splitlines(): + if line.startswith("%") and line.endswith("%") and len(line) > 2: + flush() + key = line.strip("%") + buf = [] + else: + buf.append(line) + flush() + name = fields.get("NAME", "") + filename = fields.get("FILENAME", "") + if name in want and filename: + found[name] = filename + +missing = sorted(want - set(found)) +if missing: + sys.stderr.write("registry db missing packages: " + " ".join(missing) + "\n") + raise SystemExit(1) +for name in sys.argv[2:]: + print(f"{name}\t{found[name]}") +PY +} + +copy_local_overrides() { + local dir="$1" + [[ -n "$dir" && -d "$dir" ]] || return 0 + local pkg name + shopt -s nullglob + for pkg in "$dir"/*.pkg.tar.zst "$dir"/*/*.pkg.tar.zst; do + [[ -f "$pkg" ]] || continue + name="$(pkginfo_name "$pkg")" + [[ -n "$name" ]] || continue + local wanted=0 p + for p in "${PACKAGES[@]}"; do + if [[ "$p" == "$name" ]]; then + wanted=1 + break + fi + done + if (( wanted )); then + printf 'local override: %s -> %s\n' "$name" "$(basename "$pkg")" + cp -a "$pkg" "$STAGE/$(basename "$pkg")" + fi + done + shopt -u nullglob +} + +has_pkg_named() { + local name="$1" pkg got + shopt -s nullglob + for pkg in "$STAGE"/*.pkg.tar.zst; do + got="$(pkginfo_name "$pkg")" + if [[ "$got" == "$name" ]]; then + shopt -u nullglob + return 0 + fi + done + shopt -u nullglob + return 1 +} + +collect_from_registry() { + local work db name filename enc url + work="$(mktemp -d "${TMPDIR:-/tmp}/breadway-db.XXXXXX")" + db="$work/$DB_NAME" + curl -fL --retry 3 --retry-delay 2 -o "$db" "$REGISTRY/$ARCH/$DB_NAME" \ + || die "failed to fetch $REGISTRY/$ARCH/$DB_NAME" + while IFS=$'\t' read -r name filename; do + if has_pkg_named "$name"; then + printf 'using local %s, skip registry\n' "$name" + continue + fi + enc="$(urlencode "$filename")" + url="$REGISTRY/$ARCH/$enc" + printf 'fetch %s\n' "$filename" + curl -fL --retry 3 --retry-delay 2 -o "$STAGE/$filename" "$url" \ + || die "failed to fetch $url" + done < <(parse_registry_db "$db") + rm -rf "$work" +} + +publish_tree() { + local parent dest_name prev + parent="$(dirname "$DEST")" + dest_name="$(basename "$DEST")" + mkdir -p "$parent" + chmod a+rX "$STAGE" || true + # gpg --detach-sign often writes 0600 files the runner cannot chmod; + # do not fail the publish after repo-add -s already succeeded. + find "$STAGE" -type f -exec chmod a+r {} + || true + prev="$parent/${dest_name}.prev" + rm -rf "$prev" + if [[ -e "$DEST" ]]; then + mv "$DEST" "$prev" + fi + mv "$STAGE" "$DEST" + rm -rf "$prev" + STAGE="" +} + +if [[ "${BREADWAY_SIGN_ONLY:-0}" == 1 ]]; then + [[ -n "$REPO_DIR" ]] || die "BREADWAY_SIGN_ONLY requires BREADWAY_REPO_DIR" + sign_and_index "$REPO_DIR" + exit 0 +fi + +need_key + +DEST_PARENT="$(dirname "$DEST")" +mkdir -p "$DEST_PARENT" || die "cannot create $DEST_PARENT (runner must write /srv/breadway-dl)" +STAGE="$(mktemp -d "$DEST_PARENT/.stage-XXXXXX")" +cleanup() { + if [[ -n "${STAGE:-}" && -d "${STAGE:-}" ]]; then + rm -rf "$STAGE" + fi + if [[ -n "${GNUPGHOME:-}" && "$GNUPGHOME" == *gnupg-breadway-repo* ]]; then + rm -rf "$GNUPGHOME" + fi +} +trap cleanup EXIT + +copy_local_overrides "${BREADWAY_PKG_DIR:-}" +collect_from_registry + +missing=() +for name in "${PACKAGES[@]}"; do + has_pkg_named "$name" || missing+=("$name") +done +if (( ${#missing[@]} > 0 )); then + die "missing packages after collect: ${missing[*]}" +fi + +sign_and_index_anywhere "$STAGE" + +# Do not publish helper junk if a container left any. +rm -f "$STAGE/.sign.sh" + +publish_tree + +printf 'published signed [breadway] repo -> %s\n' "$DEST" +ls -lh "$DEST" diff --git a/scripts/ci-stage-bakery.py b/scripts/ci-stage-bakery.py new file mode 100755 index 0000000..20dbfb1 --- /dev/null +++ b/scripts/ci-stage-bakery.py @@ -0,0 +1,497 @@ +#!/usr/bin/env python3 +"""Stage bakery artifacts from the verified stable index into $LAPTOP_HOME. + +Used by .forgejo/workflows/release-iso.yml so the ISO bake does not invent +binaries, fake installed.json, or cargo-build bread-theme. Never downloads +breadcast or breadarr. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urljoin, urlparse + +INDEX_URL = "https://dl.breadway.dev/index.json" +DL_ORIGIN = "https://dl.breadway.dev" +# Same key as bread-ecosystem/scripts/get.sh and bakery/src/manifest.rs. +MINISIGN_PUBKEY = "RWTBR8w/IJ+jaylOv80b52DzekKbSR2CvOVGvzB0ipGBaMhJPAOiEWq8" +BLOCKED = frozenset({"breadcast", "breadarr"}) +ARCH_SUFFIXES = ("-x86_64", "-aarch64", "-arm64", "-armv7") + + +def die(msg: str) -> None: + print(f"ERROR: {msg}", file=sys.stderr) + raise SystemExit(1) + + +def dest_name(name: str) -> str: + for suf in ARCH_SUFFIXES: + if name.endswith(suf): + return name[: -len(suf)] + return name + + +def valid_name(name: str) -> bool: + return bool(name) and "/" not in name and name not in (".", "..") + + +def load_versions(data: dict, path: Path) -> dict[str, str]: + """Optional [versions] map and/or [[pin]] tables → package → version.""" + versions: dict[str, str] = {} + + raw_map = data.get("versions") + if raw_map is not None: + if not isinstance(raw_map, dict): + die(f"{path}: [versions] must be a table of package = \"version\"") + for pkg, ver in raw_map.items(): + if not isinstance(pkg, str) or not valid_name(pkg): + die(f"{path}: invalid [versions] package {pkg!r}") + if not isinstance(ver, str) or not valid_name(ver): + die(f"{path}: invalid [versions] version for {pkg}: {ver!r}") + versions[pkg] = ver + + pins = data.get("pin") + if pins is not None: + if not isinstance(pins, list): + die(f"{path}: [[pin]] must be an array of tables") + for i, entry in enumerate(pins): + if not isinstance(entry, dict): + die(f"{path}: [[pin]] #{i} must be a table") + pkg = entry.get("package", entry.get("pkg")) + ver = entry.get("version") + if not isinstance(pkg, str) or not valid_name(pkg): + die(f"{path}: [[pin]] #{i}: missing valid package") + if not isinstance(ver, str) or not valid_name(ver): + die(f"{path}: [[pin]] #{i}: missing valid version") + if pkg in versions and versions[pkg] != ver: + die(f"{path}: conflicting pin for {pkg}: {versions[pkg]} vs {ver}") + versions[pkg] = ver + return versions + + +def load_lockfile(path: Path) -> tuple[list[str], list[str], dict[str, str]]: + with path.open("rb") as f: + data = tomllib.load(f) + required = data.get("required_bins") + optional = data.get("optional_bins") or [] + if required is None: + required = data.get("bins") or data.get("binaries") + if not isinstance(required, list) or not required: + die(f"{path}: missing non-empty required_bins (or bins) list") + if not isinstance(optional, list): + die(f"{path}: optional_bins must be a list") + for label, names in (("required_bins", required), ("optional_bins", optional)): + for b in names: + if not isinstance(b, str) or not valid_name(b): + die(f"{path}: invalid {label} name {b!r}") + if b in BLOCKED: + die(f"{path}: {b} is not shipped on the ISO") + overlap = set(required) & set(optional) + if overlap: + die(f"{path}: bins in both required and optional: {sorted(overlap)}") + return list(required), list(optional), load_versions(data, path) + + +def pinned_artifact_url(pkg: str, version: str, filename: str) -> str: + if not valid_name(pkg) or not valid_name(version) or not valid_name(filename): + die(f"refusing pinned URL with unsafe path {pkg}/{version}/{filename}") + return f"{DL_ORIGIN}/{pkg}/{version}/{filename}" + + +def package_base_url(pkg_name: str, versions: dict[str, str], first_url: str) -> str: + pin = versions.get(pkg_name) + if pin: + if not valid_name(pkg_name) or not valid_name(pin): + die(f"refusing pinned version dir {pkg_name}/{pin}") + return f"{DL_ORIGIN}/{pkg_name}/{pin}/" + return version_dir(first_url) + + +def fetch(url: str, dest: Path, *, required: bool = True) -> bool: + dest.parent.mkdir(parents=True, exist_ok=True) + try: + urllib.request.urlretrieve(url, dest) + return True + except (urllib.error.URLError, OSError) as e: + if required: + die(f"download failed: {url}: {e}") + print(f"WARN: download failed: {url}: {e}", file=sys.stderr) + if dest.exists(): + dest.unlink() + return False + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def require_sha256(value: object, what: str) -> str: + if not isinstance(value, str) or not value.strip(): + die(f"{what}: index sha256 is required and must be non-empty") + return value.strip().lower() + + +def verify_sha256(path: Path, expected: str, what: str) -> None: + actual = sha256_file(path) + if actual != expected: + die(f"{what}: sha256 mismatch (expected {expected}, got {actual})") + + +def version_dir(first_dl_url: str) -> str: + parsed = urlparse(first_dl_url) + parent = parsed.path.rsplit("/", 1)[0] + return f"{parsed.scheme}://{parsed.netloc}{parent}/" + + +def verify_index(index_path: Path, sig_path: Path) -> None: + if shutil.which("minisign") is None: + die("minisign is not installed — refuse to trust an unsigned index") + cmd = [ + "minisign", + "-V", + "-q", + "-m", + str(index_path), + "-x", + str(sig_path), + "-P", + MINISIGN_PUBKEY, + ] + result = subprocess.run(cmd, check=False) + if result.returncode != 0: + die("index.json minisign verification FAILED — refusing to proceed") + print("index.json minisign OK") + + +def bin_index(packages: dict) -> dict[str, tuple[str, dict, dict]]: + out: dict[str, tuple[str, dict, dict]] = {} + for pkg_name, pkg in packages.items(): + if pkg_name in BLOCKED: + continue + for b in pkg.get("binaries") or []: + if not isinstance(b, dict): + continue + raw = b.get("name") + if not isinstance(raw, str): + continue + dest = dest_name(raw) + if dest in BLOCKED or pkg_name in BLOCKED: + continue + if dest in out and out[dest][0] != pkg_name: + die(f"index publishes {dest} from both {out[dest][0]} and {pkg_name}") + out[dest] = (pkg_name, pkg, b) + return out + + +def patch_exec_start(text: str, bin_dir: Path) -> str: + lines = [] + for line in text.splitlines(): + if line.lstrip().startswith("ExecStart="): + rest = line.split("=", 1)[1] + argv = rest.split() + if argv: + name = os.path.basename(argv[0]) + new_path = bin_dir / name + if len(argv) == 1: + line = f"ExecStart={new_path}" + else: + line = f"ExecStart={new_path} {' '.join(argv[1:])}" + lines.append(line) + out = "\n".join(lines) + if text.endswith("\n"): + out += "\n" + return out + + +def wanted_by(text: str) -> list[str]: + targets: list[str] = [] + for line in text.splitlines(): + if line.startswith("WantedBy="): + targets.extend(line.split("=", 1)[1].split()) + return targets or ["default.target"] + + +def assert_safe_archive(path: Path) -> None: + with tarfile.open(path, "r:gz") as tf: + for info in tf.getmembers(): + name = info.name + if info.issym() or info.islnk(): + die(f"refusing archive with symlink entry {name!r}") + if name.startswith("/") or any(p in ("..", "") for p in Path(name).parts if p == ".."): + die(f"refusing archive with unsafe path {name!r}") + if Path(name).is_absolute() or ".." in Path(name).parts: + die(f"refusing archive with unsafe path {name!r}") + + +def stage_file( + url: str, + dest: Path, + sha256: str | None, + what: str, + mode: int | None = None, + *, + required: bool = True, +) -> bool: + if not fetch(url, dest, required=required): + return False + if sha256 is not None: + verify_sha256(dest, sha256, what) + if mode is not None: + dest.chmod(mode) + return True + + +def main() -> int: + # CI logs mix stdout/stderr; keep them in source order. + try: + sys.stdout.reconfigure(line_buffering=True) + sys.stderr.reconfigure(line_buffering=True) + except (AttributeError, OSError): + pass + repo = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--home", + default=os.environ.get("LAPTOP_HOME", "/build-home"), + help="builder home to populate (default: $LAPTOP_HOME or /build-home)", + ) + parser.add_argument( + "--lockfile", + default=str(repo / "iso" / "bread-lockfile.toml"), + ) + parser.add_argument("--index-url", default=INDEX_URL) + args = parser.parse_args() + + home = Path(args.home) + lockfile = Path(args.lockfile) + if not lockfile.is_file(): + die(f"lockfile missing: {lockfile}") + + required, optional, versions = load_lockfile(lockfile) + print( + f"lockfile {lockfile}: {len(required)} required, {len(optional)} optional" + + (f", {len(versions)} pinned" if versions else "") + ) + + bin_dir = home / ".local" / "bin" + state_dir = home / ".local" / "state" / "bakery" + cache_dir = home / ".cache" / "bakery" + share_dir = home / ".local" / "share" + unit_dir = home / ".config" / "systemd" / "user" + for d in (bin_dir, state_dir, cache_dir, share_dir, unit_dir): + d.mkdir(parents=True, exist_ok=True) + + index_path = cache_dir / "index.json" + sig_path = cache_dir / "index.json.minisig" + print(f"fetch {args.index_url}") + fetch(args.index_url, index_path) + print(f"fetch {args.index_url}.minisig") + fetch(args.index_url + ".minisig", sig_path) + verify_index(index_path, sig_path) + + with index_path.open() as f: + idx = json.load(f) + packages = idx.get("packages") + if not isinstance(packages, dict): + die("index.json: missing packages object") + + published = bin_index(packages) + selected: dict[str, dict] = {} + installed_bins: dict[str, list[str]] = {} + installed_sha: dict[str, dict[str, str]] = {} + fetched_url: dict[str, str] = {} + + pin_warned: set[str] = set() + + def pin_digest(pkg_name: str, pkg: dict, value: object, what: str) -> str | None: + """Index sha256 is only valid when it describes the pinned version.""" + digest = require_sha256(value, what) + pin = versions.get(pkg_name) + if pin and str(pkg.get("version")) != pin: + if pkg_name not in pin_warned: + print( + f"WARN: {pkg_name} pin {pin} != index {pkg.get('version')}; " + f"fetching pinned URL without index sha256", + file=sys.stderr, + ) + pin_warned.add(pkg_name) + return None + return digest + + def take_bin(name: str, *, required_bin: bool) -> bool: + hit = published.get(name) + if hit is None: + if required_bin: + die(f"required bin {name!r} is not in the verified stable index") + print(f"WARN: optional bin {name} not in index — skipping", file=sys.stderr) + return False + pkg_name, pkg, binary = hit + if pkg_name in BLOCKED or name in BLOCKED: + die(f"refusing blocked package/bin {pkg_name}/{name}") + raw = binary.get("name") + index_url = binary.get("dl_url") + pin = versions.get(pkg_name) + if pin: + if not isinstance(raw, str) or not valid_name(raw): + die(f"{name}: missing binary filename for pinned URL") + url = pinned_artifact_url(pkg_name, pin, raw) + else: + url = index_url + if not isinstance(url, str) or not url: + die(f"{name}: missing dl_url") + digest = pin_digest(pkg_name, pkg, binary.get("sha256"), f"binary {name}") + dest = bin_dir / name + note = f" (pin {pkg_name}={pin})" if pin else "" + print(f" {name} <- {url}{note}") + if not stage_file( + url, dest, digest, f"binary {name}", mode=0o755, required=required_bin + ): + return False + selected[pkg_name] = pkg + installed_bins.setdefault(pkg_name, []).append(name) + if digest is not None: + installed_sha.setdefault(pkg_name, {})[name] = digest + fetched_url.setdefault(pkg_name, url) + return True + + for name in required: + take_bin(name, required_bin=True) + for name in optional: + take_bin(name, required_bin=False) + + if not selected: + die("no packages selected from lockfile ∩ index") + + now = datetime.now(timezone.utc).replace(microsecond=0).isoformat() + installed: dict[str, dict] = {} + + for pkg_name, pkg in sorted(selected.items()): + bins = pkg.get("binaries") or [] + first_url = fetched_url.get(pkg_name) + if not first_url: + for b in bins: + if isinstance(b, dict) and b.get("dl_url"): + first_url = b["dl_url"] + break + if not first_url: + die(f"{pkg_name}: no binary dl_url to derive version dir") + base = package_base_url(pkg_name, versions, first_url) + service_names: list[str] = [] + + for svc in pkg.get("services") or []: + if not isinstance(svc, dict): + die(f"{pkg_name}: service entry must be an object with unit + sha256") + unit = svc.get("unit") + if not isinstance(unit, str) or not valid_name(unit): + die(f"{pkg_name}: invalid service unit {unit!r}") + digest = pin_digest(pkg_name, pkg, svc.get("sha256"), f"{pkg_name} {unit}") + dest = unit_dir / unit + url = urljoin(base, unit) + print(f" {unit} <- {url}") + fetch(url, dest) + if digest is not None: + verify_sha256(dest, digest, f"unit {unit}") + dest.write_text(patch_exec_start(dest.read_text(), bin_dir)) + dest.chmod(0o644) + if svc.get("enable"): + for target in wanted_by(dest.read_text()): + if not valid_name(target): + die(f"{unit}: invalid WantedBy {target!r}") + wants = unit_dir / f"{target}.wants" + wants.mkdir(parents=True, exist_ok=True) + link = wants / unit + if link.exists() or link.is_symlink(): + link.unlink() + link.symlink_to(Path("..") / unit) + print(f" enabled {target}.wants/{unit}") + service_names.append(unit) + + archive = pkg.get("data_archive") + if archive: + if not isinstance(archive, str) or not valid_name(archive): + die(f"{pkg_name}: invalid data_archive {archive!r}") + digest = pin_digest( + pkg_name, pkg, pkg.get("data_archive_sha256"), f"{pkg_name} {archive}" + ) + url = urljoin(base, archive) + data_dir = share_dir / pkg_name + data_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f"bos-{pkg_name}-") as tmp: + tmp_path = Path(tmp) / archive + print(f" {archive} <- {url}") + stage_file(url, tmp_path, digest, f"{pkg_name} {archive}") + assert_safe_archive(tmp_path) + subprocess.run( + [ + "tar", + "xzf", + str(tmp_path), + "--no-same-owner", + "--no-same-permissions", + "-C", + str(data_dir), + ], + check=True, + ) + print(f" extracted to {data_dir}") + + desktop = pkg.get("desktop_file") + if desktop: + if not isinstance(desktop, str) or not valid_name(desktop): + die(f"{pkg_name}: invalid desktop_file {desktop!r}") + digest = pin_digest( + pkg_name, pkg, pkg.get("desktop_file_sha256"), f"{pkg_name} {desktop}" + ) + dest = share_dir / "applications" / f"{pkg_name}.desktop" + stage_file(urljoin(base, desktop), dest, digest, f"{pkg_name} {desktop}") + + license_file = pkg.get("license_file") + if license_file: + if not isinstance(license_file, str) or not valid_name(license_file): + die(f"{pkg_name}: invalid license_file {license_file!r}") + digest = pin_digest( + pkg_name, pkg, pkg.get("license_file_sha256"), f"{pkg_name} {license_file}" + ) + dest = share_dir / "licenses" / pkg_name / "LICENSE" + stage_file(urljoin(base, license_file), dest, digest, f"{pkg_name} {license_file}") + + installed[pkg_name] = { + "name": pkg_name, + "version": versions.get(pkg_name, pkg.get("version")), + "binaries": installed_bins.get(pkg_name, []), + "services": service_names, + "installed_at": now, + "track": "stable", + "binary_sha256": installed_sha.get(pkg_name, {}), + } + + if "breadhelp" in installed: + content = share_dir / "breadhelp" / "content" + if not content.is_dir(): + die(f"breadhelp data_archive did not produce {content}") + + state_path = state_dir / "installed.json" + state_path.write_text(json.dumps({"track": "stable", "packages": installed}, indent=2) + "\n") + print(f"installed.json written ({len(installed)} packages): {', '.join(sorted(installed))}") + print(f"staged bins: {', '.join(sorted(p.name for p in bin_dir.iterdir() if p.is_file()))}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh new file mode 100755 index 0000000..e55b11e --- /dev/null +++ b/scripts/ci-verify-bake.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# Read-only checks that a builder home (and optionally a staged image) has +# everything build-local.sh needs before mkarchiso. Exit non-zero on failure. +# +# Builder home stays user-layout (~/.local). The image is system-prefix +# /usr/local; pass SKEL and/or AIROOTFS to check those destinations. +# +# LAPTOP_HOME=/build-home ./scripts/ci-verify-bake.sh +# SKEL=/tmp/bos-iso-stage/airootfs/etc/skel ./scripts/ci-verify-bake.sh +# AIROOTFS=/tmp/bos-iso-stage/airootfs ./scripts/ci-verify-bake.sh +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +LOCKFILE="${LOCKFILE:-$REPO/iso/bread-lockfile.toml}" +LAPTOP_HOME="${LAPTOP_HOME:-/build-home}" +SKEL="${SKEL:-}" +AIROOTFS="${AIROOTFS:-}" + +pass=0 +fail=0 +ok() { printf ' PASS %s\n' "$1"; pass=$((pass + 1)); } +bad() { printf ' FAIL %s\n' "$1" >&2; fail=$((fail + 1)); } + +if [[ ! -f "$LOCKFILE" ]]; then + echo "ERROR: lockfile missing: $LOCKFILE" >&2 + exit 1 +fi + +eval "$(python3 - "$LOCKFILE" <<'PY' +import sys, tomllib +path = sys.argv[1] +with open(path, "rb") as f: + data = tomllib.load(f) +required = data.get("required_bins") +optional = data.get("optional_bins") or [] +if required is None: + required = data.get("bins") or data.get("binaries") or [] +def emit(name, values): + print(f"{name}=(") + for v in values: + print(f" {v!r}") + print(")") +emit("REQUIRED_BINS", required) +emit("OPTIONAL_BINS", optional) +PY +)" + +echo "== lockfile $LOCKFILE ==" +echo " ${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional" +if grep -qE '^nvidia(-utils|-dkms|-open)?$' "$REPO/iso/packages.x86_64"; then + bad "iso/packages.x86_64 lists an nvidia driver package" +else + ok "iso/packages.x86_64 has no nvidia driver package" +fi +echo "== host tools ==" +if command -v grub-install >/dev/null 2>&1; then + ok "grub-install (uefi.grub bootmode)" +else + bad "grub-install missing — mkarchiso uefi.grub will abort (install grub on the builder)" +fi +echo "== builder home $LAPTOP_HOME ==" + +check_exec() { + local path="$1" label="$2" + if [[ -x "$path" && -f "$path" ]]; then + ok "$label executable: $path" + else + bad "$label missing or not executable: $path" + fi +} + +check_dir() { + local path="$1" label="$2" + if [[ -d "$path" ]]; then + ok "$label: $path" + else + bad "$label missing: $path" + fi +} + +check_file() { + local path="$1" label="$2" + if [[ -f "$path" ]]; then + ok "$label: $path" + else + bad "$label missing: $path" + fi +} + +for b in "${REQUIRED_BINS[@]}"; do + check_exec "$LAPTOP_HOME/.local/bin/$b" "required bin $b" +done +for b in "${OPTIONAL_BINS[@]}"; do + if [[ -x "$LAPTOP_HOME/.local/bin/$b" ]]; then + ok "optional bin $b present" + else + printf ' ---- optional bin %s not staged (ok until bread ships it)\n' "$b" + fi +done + +check_dir "$LAPTOP_HOME/.local/share/breadhelp/content" "breadhelp content" +check_file "$LAPTOP_HOME/.cache/bakery/index.json" "bakery index cache" +check_file "$LAPTOP_HOME/.local/state/bakery/installed.json" "bakery installed.json" + +mapfile -t UNITS < <(python3 - "$LAPTOP_HOME/.local/state/bakery/installed.json" <<'PY' +import json, sys +path = sys.argv[1] +with open(path) as f: + data = json.load(f) +pkgs = data.get("packages", data) +for pkg in pkgs.values(): + for s in pkg.get("services", []): + print(s["unit"] if isinstance(s, dict) else s) +PY +) +if [[ ${#UNITS[@]} -eq 0 ]]; then + bad "installed.json lists no service units" +else + for unit in "${UNITS[@]}"; do + [[ -n "$unit" ]] || continue + check_file "$LAPTOP_HOME/.config/systemd/user/$unit" "unit $unit" + done +fi + +if [[ -n "$SKEL" && -z "$AIROOTFS" ]]; then + if [[ -d "$SKEL/usr/local/bin" ]]; then + AIROOTFS="$SKEL" + SKEL="$AIROOTFS/etc/skel" + elif [[ -d "$SKEL/../../usr/local" ]]; then + AIROOTFS="$(cd "$SKEL/../.." && pwd)" + fi +elif [[ -n "$AIROOTFS" && -z "$SKEL" ]]; then + SKEL="$AIROOTFS/etc/skel" +fi + +if [[ -n "$AIROOTFS" || -n "$SKEL" ]]; then + if [[ -n "$AIROOTFS" ]]; then + echo "== staged image $AIROOTFS ==" + check_file "$AIROOTFS/etc/bakery/config.toml" "bakery prefix config" + if [[ -f "$AIROOTFS/etc/bakery/config.toml" ]] && grep -q 'prefix[[:space:]]*=[[:space:]]*"/usr/local"' "$AIROOTFS/etc/bakery/config.toml"; then + ok "bakery prefix = /usr/local" + else + bad "bakery prefix is not /usr/local in $AIROOTFS/etc/bakery/config.toml" + fi + for b in "${REQUIRED_BINS[@]}"; do + check_exec "$AIROOTFS/usr/local/bin/$b" "image required bin $b" + done + check_exec "$AIROOTFS/usr/local/bin/bos-nvidia-setup" "image bos-nvidia-setup" + check_dir "$AIROOTFS/usr/local/share/breadhelp/content" "image breadhelp content" + fi + if [[ -n "$SKEL" ]]; then + echo "== staged skel $SKEL ==" + check_file "$SKEL/.cache/bakery/index.json" "skel bakery index cache" + check_file "$SKEL/.local/state/bakery/installed.json" "skel bakery installed.json" + for b in "${REQUIRED_BINS[@]}"; do + if [[ -e "$SKEL/.local/bin/$b" ]]; then + bad "skel still has bakery bin $b (belongs in /usr/local/bin)" + fi + done + check_file "$SKEL/.config/hypr/hyprland.lua" "skel hyprland.lua" + if grep -q 'nvidia.lua' "$SKEL/.config/hypr/hyprland.lua"; then + ok "skel hyprland.lua includes nvidia.lua only if present" + else + bad "skel hyprland.lua does not mention nvidia.lua" + fi + fi + image_units_json="" + if [[ -n "$SKEL" && -f "$SKEL/.local/state/bakery/installed.json" ]]; then + image_units_json="$SKEL/.local/state/bakery/installed.json" + fi + if [[ -n "$image_units_json" ]]; then + mapfile -t IMAGE_UNITS < <(python3 - "$image_units_json" <<'PY' +import json, sys +path = sys.argv[1] +with open(path) as f: + data = json.load(f) +pkgs = data.get("packages", data) +for pkg in pkgs.values(): + for s in pkg.get("services", []): + print(s["unit"] if isinstance(s, dict) else s) +PY +) + else + IMAGE_UNITS=("${UNITS[@]}") + fi + if [[ -n "$AIROOTFS" ]]; then + for unit in "${IMAGE_UNITS[@]}"; do + [[ -n "$unit" ]] || continue + check_file "$AIROOTFS/usr/lib/systemd/user/$unit" "image unit $unit" + if [[ -f "$AIROOTFS/usr/lib/systemd/user/$unit" ]]; then + if grep -q '^ExecStart=/usr/local/bin/' "$AIROOTFS/usr/lib/systemd/user/$unit"; then + ok "image unit $unit ExecStart uses /usr/local/bin" + elif grep -q '^ExecStart=' "$AIROOTFS/usr/lib/systemd/user/$unit"; then + bad "image unit $unit ExecStart is not /usr/local/bin: $(grep '^ExecStart=' "$AIROOTFS/usr/lib/systemd/user/$unit")" + fi + fi + done + check_file "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" \ + "bakery user preset" + if [[ -L "$AIROOTFS/etc/systemd/user/default.target.wants/breadd.service" ]] \ + || [[ -f "$AIROOTFS/etc/systemd/user/default.target.wants/breadd.service" ]]; then + ok "breadd.service globally enabled (etc wants)" + else + bad "breadd.service missing from /etc/systemd/user/default.target.wants" + fi + # After bake the image has /usr/local/bin/breadd and every preset unit. + # The committed airootfs only has the preset + breadd wants. + if [[ -f "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" ]] \ + && [[ -x "$AIROOTFS/usr/local/bin/breadd" ]]; then + while read -r verb unit; do + [[ "$verb" == enable && -n "$unit" ]] || continue + check_file "$AIROOTFS/usr/lib/systemd/user/$unit" "preset unit $unit" + if [[ -L "$AIROOTFS/etc/systemd/user/default.target.wants/$unit" ]] \ + || [[ -L "$AIROOTFS/etc/systemd/user/graphical-session.target.wants/$unit" ]]; then + ok "$unit globally enabled (etc wants)" + else + bad "$unit missing from /etc/systemd/user/*.target.wants" + fi + done < "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" + fi + if [[ -x "$AIROOTFS/usr/local/bin/bos-enable-bakery-user-units" ]]; then + ok "bos-enable-bakery-user-units executable" + else + bad "bos-enable-bakery-user-units missing or not executable" + fi + fi +fi + +echo +printf 'Result: %d passed, %d failed\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index a551253..28b5c28 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -40,21 +40,72 @@ check "grub-btrfs present" "pacman -Qq grub-btrfs" echo "== enabled system services ==" for unit in NetworkManager.service greetd.service bluetooth.service tlp.service \ - cups.socket avahi-daemon.service ufw.service systemd-timesyncd.service; do + cups.socket avahi-daemon.socket ufw.service systemd-timesyncd.service; do check "$unit enabled" "systemctl is-enabled $unit" done check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]" echo "== bread ecosystem on PATH ==" -for bin in bakery bread breadd breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do +for bin in bakery bread breadd bread-emit bread-module-host breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do check "$bin found" "command -v $bin" done echo "== bos-settings ==" check "bos-settings installed" "command -v bos-settings" +echo "== breadhelp ==" +check "breadhelp installed" "command -v breadhelp" +check "breadhelp content installed" \ + "[ -d /usr/local/share/breadhelp/content ] || [ -d \"\$HOME/.local/share/breadhelp/content\" ]" +check "bos-netcheck present" "command -v bos-netcheck" +check "bos-rescue present" "command -v bos-rescue" +check "bos-first-boot present" "command -v bos-first-boot" +check "bos-nvidia-setup present" "command -v bos-nvidia-setup" +if pacman -Qq nvidia >/dev/null 2>&1; then + note "nvidia installed (optional proprietary path)" + check "nvidia env drop-in present" "[ -f \"\$HOME/.config/hypr/nvidia.lua\" ]" +else + check "nvidia not on the default image" "! pacman -Qq nvidia" +fi + +echo "== bakery user units (global enable) ==" +# A later useradd does not enable --user units unless they were enabled +# --global (or the user enables them). post-install + live-setup + bake +# write /etc/systemd/user/.wants/ and a preset listing the set. +check "bakery user preset present" \ + "[ -f /usr/lib/systemd/user-preset/90-bos-bakery.preset ]" +check "bos-enable-bakery-user-units present" \ + "command -v bos-enable-bakery-user-units" +check "breadd.service globally enabled" \ + "systemctl --global is-enabled breadd.service || [ -L /etc/systemd/user/default.target.wants/breadd.service ]" +if [[ -f /usr/lib/systemd/user-preset/90-bos-bakery.preset ]]; then + while read -r verb unit; do + [[ "$verb" == enable && -n "$unit" ]] || continue + [[ -f /usr/lib/systemd/user/$unit ]] || continue + check "$unit globally enabled" \ + "systemctl --global is-enabled $unit || [ -L /etc/systemd/user/default.target.wants/$unit ] || [ -L /etc/systemd/user/graphical-session.target.wants/$unit ]" + done < /usr/lib/systemd/user-preset/90-bos-bakery.preset +fi +check "skel hyprland.lua present" "[ -f /etc/skel/.config/hypr/hyprland.lua ]" +check "skel bakery installed.json present" \ + "[ -f /etc/skel/.local/state/bakery/installed.json ]" +check "skel bakery index cache present" \ + "[ -f /etc/skel/.cache/bakery/index.json ]" +check "skel has no bakery binaries" \ + "! [ -e /etc/skel/.local/bin/bakery ] && ! [ -e /etc/skel/.local/bin/breadd ]" +check "useradd SKEL is /etc/skel" \ + "grep -q '^SKEL=/etc/skel' /etc/default/useradd" + echo "== default dotfiles ==" check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]" +check "hyprland.lua includes nvidia.lua only if present" \ + "grep -q 'nvidia.lua' \"\$HOME/.config/hypr/hyprland.lua\"" +check "binds.json present" "[ -f \"\$HOME/.config/hypr/binds.json\" ]" +check "monitors.json present" "[ -f \"\$HOME/.config/hypr/monitors.json\" ]" +check "settings.json present" "[ -f \"\$HOME/.config/hypr/settings.json\" ]" +check "autostart.json present" "[ -f \"\$HOME/.config/hypr/autostart.json\" ]" +check "autostart includes first-boot probe" "grep -q bos-first-boot \"\$HOME/.config/hypr/autostart.json\"" +check "hypr scripts/lib present" "[ -f \"\$HOME/.config/hypr/scripts/lib/json.lua\" ]" check "mimeapps.list present" "[ -f \"\$HOME/.config/mimeapps.list\" ]" check "kitty config present" "[ -f \"\$HOME/.config/kitty/kitty.conf\" ]"