Compare commits
No commits in common. "main" and "v0.4.1" have entirely different histories.
100 changed files with 4499 additions and 5274 deletions
|
|
@ -24,12 +24,7 @@ jobs:
|
||||||
kcoreaddons kpmcore libpwquality qt6-declarative qt6-svg yaml-cpp
|
kcoreaddons kpmcore libpwquality qt6-declarative qt6-svg yaml-cpp
|
||||||
useradd -m builder
|
useradd -m builder
|
||||||
git config --global --add safe.directory '*'
|
git config --global --add safe.directory '*'
|
||||||
# Clone the branch/tag that triggered this run (not the default
|
git clone --depth 1 "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
|
||||||
# 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
|
chown -R builder:builder /home/builder/src
|
||||||
su builder -c "cd /home/builder/src/packaging/calamares && makepkg -f --noconfirm --nocheck"
|
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)
|
PKG=$(find /home/builder/src/packaging/calamares -name '*.pkg.tar.zst' | head -1)
|
||||||
|
|
|
||||||
21
.forgejo/workflows/mirror.yml
Normal file
21
.forgejo/workflows/mirror.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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/*'
|
||||||
40
.forgejo/workflows/package.yml
Normal file
40
.forgejo/workflows/package.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
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"
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
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"
|
|
||||||
|
|
@ -1,21 +1,14 @@
|
||||||
name: Build and release ISO
|
name: Build and release ISO
|
||||||
|
|
||||||
# Builds the BOS ISO on the hestia self-hosted runner (native Arch container).
|
# Builds the BOS ISO on the hestia self-hosted runner (native Arch container),
|
||||||
# Stages bakery desktop apps from the *minisign-verified* stable index at
|
# downloads all bakery ecosystem binaries from their GitHub releases, compiles
|
||||||
# https://dl.breadway.dev/index.json (see iso/bread-lockfile.toml), then runs
|
# bread-theme from source, and uploads the resulting ISO to a Forgejo pre-release.
|
||||||
# build-local.sh and uploads the ISO to a Forgejo release. A matching GitHub
|
# A matching GitHub release is created that points to Forgejo for the download
|
||||||
# release is created best-effort and points at Forgejo for the download
|
|
||||||
# (GitHub releases cannot host files larger than 2 GB).
|
# (GitHub releases cannot host files larger than 2 GB).
|
||||||
#
|
#
|
||||||
# Required secrets:
|
# Required secrets:
|
||||||
# RELEASE_TOKEN — Forgejo API token with write:repository scope
|
# RELEASE_TOKEN — Forgejo API token with write:repository scope
|
||||||
# MIRROR_TOKEN — GitHub personal access token with repo scope
|
# MIRROR_TOKEN — GitHub personal access token with repo scope (already used by mirror.yml)
|
||||||
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
@ -30,8 +23,6 @@ jobs:
|
||||||
release-iso:
|
release-iso:
|
||||||
runs-on: [self-hosted, hestia]
|
runs-on: [self-hosted, hestia]
|
||||||
container:
|
container:
|
||||||
# Floating tag: this environment cannot pin a reproducible digest of
|
|
||||||
# archlinux:latest. Do not invent one.
|
|
||||||
image: archlinux:latest
|
image: archlinux:latest
|
||||||
# --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod
|
# --privileged: mkarchiso needs CAP_SYS_ADMIN for loop mounts + mknod
|
||||||
# --network=host: gives localhost:3002 access to Forgejo (avoids the
|
# --network=host: gives localhost:3002 access to Forgejo (avoids the
|
||||||
|
|
@ -41,10 +32,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: |
|
run: |
|
||||||
# grub is required by profiledef.sh bootmodes=('uefi.grub'):
|
pacman -Syu --noconfirm archiso curl python git rust
|
||||||
# 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
|
- name: Determine tag and version
|
||||||
id: vars
|
id: vars
|
||||||
|
|
@ -62,17 +50,72 @@ jobs:
|
||||||
git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \
|
git clone --branch "${{ steps.vars.outputs.tag }}" --depth 1 \
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /bos
|
||||||
|
|
||||||
- name: Stage bakery ecosystem from signed stable index
|
- name: Download bakery ecosystem binaries
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd /bos
|
mkdir -p /build-home/.local/bin \
|
||||||
LAPTOP_HOME=/build-home python3 scripts/ci-stage-bakery.py
|
/build-home/.local/state/bakery \
|
||||||
|
/build-home/.cache/bakery
|
||||||
|
|
||||||
- name: Verify staged bakery bake inputs
|
# 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
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd /bos
|
# bread-theme is not in the bakery index; build it at the tag pinned
|
||||||
LAPTOP_HOME=/build-home bash scripts/ci-verify-bake.sh
|
# 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"
|
||||||
|
|
||||||
- name: Build ISO
|
- name: Build ISO
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -86,35 +129,14 @@ jobs:
|
||||||
bash build-local.sh
|
bash build-local.sh
|
||||||
ls -lh /bos-out/*.iso
|
ls -lh /bos-out/*.iso
|
||||||
|
|
||||||
- name: Checksum and sign
|
- name: Create Forgejo release and upload ISO
|
||||||
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:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${{ steps.vars.outputs.tag }}"
|
TAG="${{ steps.vars.outputs.tag }}"
|
||||||
VERSION="${{ steps.vars.outputs.version }}"
|
VERSION="${{ steps.vars.outputs.version }}"
|
||||||
|
ISO=$(ls /bos-out/*.iso | head -1)
|
||||||
ISO_NAME="bos-${VERSION}-x86_64.iso"
|
ISO_NAME="bos-${VERSION}-x86_64.iso"
|
||||||
|
|
||||||
# Use an existing release for this tag if one exists (e.g. created
|
# Use an existing release for this tag if one exists (e.g. created
|
||||||
|
|
@ -135,41 +157,35 @@ jobs:
|
||||||
\"tag_name\": \"${TAG}\",
|
\"tag_name\": \"${TAG}\",
|
||||||
\"name\": \"BOS ${TAG}\",
|
\"name\": \"BOS ${TAG}\",
|
||||||
\"prerelease\": false,
|
\"prerelease\": false,
|
||||||
\"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.\"
|
\"body\": \"ISO image attached below.\\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'])")
|
RELEASE_ID=$(echo "${RELEASE}" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
|
||||||
fi
|
fi
|
||||||
echo "Using release ID: ${RELEASE_ID}"
|
echo "Using release ID: ${RELEASE_ID}"
|
||||||
|
|
||||||
upload_asset() {
|
# Remove any existing asset with the same name before uploading
|
||||||
local file="$1" name
|
ASSET_ID=$(curl -sf \
|
||||||
name="$(basename "$file")"
|
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||||
local asset_id
|
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||||
asset_id=$(curl -sf \
|
| python3 -c "
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
|
||||||
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
|
||||||
| python3 -c "
|
|
||||||
import json,sys
|
import json,sys
|
||||||
assets=json.load(sys.stdin)
|
assets=json.load(sys.stdin)
|
||||||
match=[a['id'] for a in assets if a['name']=='${name}']
|
match=[a['id'] for a in assets if a['name']=='${ISO_NAME}']
|
||||||
print(match[0] if match else '')
|
print(match[0] if match else '')
|
||||||
" 2>/dev/null || true)
|
" 2>/dev/null || true)
|
||||||
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}" \
|
|
||||||
-F "attachment=@${file};filename=${name}" \
|
|
||||||
"http://localhost:3002/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets"
|
|
||||||
echo "Uploaded: ${name}"
|
|
||||||
}
|
|
||||||
|
|
||||||
upload_asset "/bos-out/${ISO_NAME}"
|
if [ -n "${ASSET_ID}" ]; then
|
||||||
upload_asset "/bos-out/SHA256SUMS"
|
curl -fsS -X DELETE \
|
||||||
upload_asset "/bos-out/SHA256SUMS.asc"
|
-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
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
env:
|
env:
|
||||||
|
|
@ -180,27 +196,12 @@ jobs:
|
||||||
VERSION="${{ steps.vars.outputs.version }}"
|
VERSION="${{ steps.vars.outputs.version }}"
|
||||||
FORGEJO_URL="https://git.breadway.dev/${GITHUB_REPOSITORY}/releases/tag/${TAG}"
|
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), 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.' \
|
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.' \
|
||||||
"${FORGEJO_URL}" "${VERSION}" > /tmp/gh-release-notes.md
|
"${FORGEJO_URL}" "${VERSION}" > /tmp/gh-release-notes.md
|
||||||
|
|
||||||
gh release create "${TAG}" \
|
gh release create "${TAG}" \
|
||||||
--repo "Breadway/bos" \
|
--repo "Breadway/bos" \
|
||||||
--title "BOS ${TAG}" \
|
--title "BOS ${TAG}" \
|
||||||
|
\
|
||||||
--notes-file /tmp/gh-release-notes.md \
|
--notes-file /tmp/gh-release-notes.md \
|
||||||
|| echo "skip: GitHub release failed (MIRROR_TOKEN historically broken)"
|
2>/dev/null || echo "GitHub release already exists — skipping"
|
||||||
|
|
||||||
# `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
|
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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"
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -42,10 +42,3 @@ logs/
|
||||||
|
|
||||||
# Wallpaper source drop (baked copy lives in airootfs/usr/share/backgrounds)
|
# Wallpaper source drop (baked copy lives in airootfs/usr/share/backgrounds)
|
||||||
/Bread Background.png
|
/Bread Background.png
|
||||||
|
|
||||||
# Local hygiene notes (not for commit)
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
|
||||||
58
AGENTS.md
58
AGENTS.md
|
|
@ -1,58 +0,0 @@
|
||||||
# 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).
|
|
||||||
1039
Cargo.lock
generated
Normal file
1039
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[workspace]
|
||||||
|
members = ["bos-settings"]
|
||||||
|
resolver = "2"
|
||||||
165
DESIGN.md
165
DESIGN.md
|
|
@ -1,26 +1,4 @@
|
||||||
# BOS — historical design plan
|
# BOS — Bread Operating System 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
|
## Context
|
||||||
|
|
||||||
|
|
@ -29,25 +7,51 @@ The bread ecosystem (bread, breadbar, breadbox, breadcrumbs, breadpad/breadman,
|
||||||
Goals:
|
Goals:
|
||||||
- **Install and be done**: Calamares GUI installer → reboot → working Hyprland + full bread stack
|
- **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
|
- **Rollback safety**: Btrfs subvolumes + snapper + snap-pac; every pacman transaction is snapshotted
|
||||||
- **Unified config**: `bos-settings` surfaces all app configs + snapshot management + bakery updates
|
- **Unified config**: `bos-settings` GTK4 app surfaces all app configs + snapshot management + bakery updates
|
||||||
- **Future-compatible**: Btrfs layout is designed to allow A/B partition migration later (SteamOS model)
|
- **Future-compatible**: Btrfs layout is designed to allow A/B partition migration later (SteamOS model)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Repo Structure
|
## Repo Structure
|
||||||
|
|
||||||
Single new repo: `Breadway/bos` — *planned as* a Cargo workspace. **That is
|
Single new repo: `Breadway/bos` — a Cargo workspace.
|
||||||
not what landed**; see Current architecture.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
bos/
|
bos/
|
||||||
├── Cargo.toml # Workspace (members: [bos-settings]) — NOT in tree
|
├── Cargo.toml # Workspace (members: [bos-settings])
|
||||||
├── bos-settings/ # planned GTK4 app — now its own bakery repo
|
├── bos-settings/ # GTK4 unified settings app
|
||||||
├── iso/ # archiso profile (this is the repo)
|
│ ├── 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
|
||||||
│ ├── profiledef.sh
|
│ ├── profiledef.sh
|
||||||
│ ├── packages.x86_64
|
│ ├── packages.x86_64 # Live ISO + installed system package list
|
||||||
│ └── airootfs/
|
│ ├── airootfs/ # Files overlaid onto live ISO root
|
||||||
└── dotfiles/ # planned install-time configs — NOT the live skel
|
│ │ └── 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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -66,11 +70,7 @@ bos/
|
||||||
|
|
||||||
Mount options: `noatime,compress=zstd,space_cache=v2` on all subvolumes.
|
Mount options: `noatime,compress=zstd,space_cache=v2` on all subvolumes.
|
||||||
|
|
||||||
**A/B compatibility note (future):** The `@` subvolume is self-contained and
|
**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.
|
||||||
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)
|
### Snapshot tooling (installed + configured during post-install)
|
||||||
|
|
||||||
|
|
@ -96,79 +96,100 @@ No user-facing CLI needed for this component — `bos-settings` is the interface
|
||||||
### archiso profile (`iso/`)
|
### archiso profile (`iso/`)
|
||||||
|
|
||||||
- Derives from `/usr/share/archiso/configs/releng/` (the standard baseline)
|
- Derives from `/usr/share/archiso/configs/releng/` (the standard baseline)
|
||||||
- `packages.x86_64` is the live + installed pacman set (Hyprland, Calamares,
|
- `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
|
||||||
breadlock, WebKitGTK 4.1 for Tauri bos-settings, …). bakery apps are not
|
- `airootfs/etc/skel/` contains the default dotfiles (symlinked from `dotfiles/`)
|
||||||
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
|
- Live session autologs into a `liveuser` and launches Calamares automatically
|
||||||
|
|
||||||
### Calamares modules (in order)
|
### 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)
|
1. **welcome** — system checks (RAM ≥ 2GB, internet, disk space)
|
||||||
2. **locale** — timezone + locale selection
|
2. **locale** — timezone + locale selection
|
||||||
3. **keyboard** — layout selection
|
3. **keyboard** — layout selection
|
||||||
4. **partition** — custom `btrfs` mode: creates EFI partition + single btrfs pool with the subvolume layout above
|
4. **partition** — custom `btrfs` mode: creates EFI partition + single btrfs pool with the subvolume layout above
|
||||||
5. **users** — create main user, set password
|
5. **users** — create main user, set password
|
||||||
6. **packages** — install package list (reuses `packages.x86_64`)
|
6. **packages** — install package list (reuses `packages.x86_64`)
|
||||||
7. **bootloader** — *planned*; actual GRUB install is in `post-install.sh`
|
7. **bootloader** — install GRUB to EFI, `grub-mkconfig` with grub-btrfs hook
|
||||||
8. **shellprocess (post-install)** — snapper, services, copy skel; does **not** run bakery
|
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)
|
||||||
9. **finished** — reboot prompt
|
9. **finished** — reboot prompt
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Component 3: `bos-settings` (planned as GTK4)
|
## Component 3: `bos-settings` GTK4 App
|
||||||
|
|
||||||
### Tech choices (original)
|
|
||||||
|
|
||||||
|
### Tech choices
|
||||||
- **gtk4-rs** (v0.11, v4_12 feature), no relm4 — plain GTK4 following breadman's pattern
|
- **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`)
|
||||||
**What shipped:** Tauri 2 + Svelte in its own repo
|
- Reads/writes each tool's own config file directly (no unified intermediate config)
|
||||||
(`git.breadway.dev/Breadway/bos-settings`), distributed by bakery. This
|
- Window: 960×640, sidebar 190px, `gtk4::Stack` for view switching — identical structure to breadman
|
||||||
repo does not build it.
|
|
||||||
|
|
||||||
### Sidebar sections + views
|
### Sidebar sections + views
|
||||||
|
|
||||||
The panel list is still roughly accurate; see README. Snapshots recovery
|
| Section | View | What it does |
|
||||||
should send users through **grub-btrfs reboot**, not `snapper rollback N`.
|
|---------|------|--------------|
|
||||||
|
| **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 <pkg>` |
|
||||||
|
| | Hyprland | "Open config in editor" + monitor list from `bread.state.monitors()` |
|
||||||
|
|
||||||
|
### Config loading pattern
|
||||||
|
|
||||||
|
Each view has a dedicated `load_config(path) -> Result<T>` 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 <N>` → notify user to reboot
|
||||||
|
- Delete: `snapper delete <N>`
|
||||||
|
- 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
|
||||||
|
|
||||||
### Distribution
|
### Distribution
|
||||||
|
|
||||||
`bos-settings` has its own `bakery.toml` and is installable via
|
`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.
|
||||||
`bakery install bos-settings` on any Arch/Hyprland system, not only as part
|
|
||||||
of a BOS install.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Component 4: Default Dotfiles
|
## Component 4: Default Dotfiles
|
||||||
|
|
||||||
Minimal but functional defaults. These live in `iso/airootfs/etc/skel`
|
Minimal but functional defaults deployed at install time. These are opinionated starting points, not locked configs — users edit freely after install.
|
||||||
(`hyprland.lua` + JSON binds, not `dotfiles/hyprland/*.conf`).
|
|
||||||
|
|
||||||
Zero-config bakery apps survive with no extra skel files. breadcrumbs
|
| File | Key content |
|
||||||
networks are user-filled after install — do not invent a full
|
|------|-------------|
|
||||||
`breadcrumbs.toml` in-tree.
|
| `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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build Order
|
## Build Order
|
||||||
|
|
||||||
Historical. The ISO profile + skel + Calamares path is what this repo
|
1. **Dotfiles** — write default configs; these unblock installer testing immediately
|
||||||
iterates on. bos-settings is developed in its own repo.
|
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- **ISO**: `sudo ./build-local.sh` (not a raw `mkarchiso iso/` — the bake
|
- **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
|
||||||
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
|
- **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
|
- **snapper**: `snapper list`; run `pacman -Syu` and confirm two new snapshots appear
|
||||||
- **grub-btrfs**: Reboot and confirm snapshot submenu in GRUB
|
- **grub-btrfs**: Reboot and confirm snapshot submenu in GRUB
|
||||||
- **bos-settings**: built and tested in the bos-settings repo, not here
|
- **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
|
||||||
|
|
|
||||||
15
KEYS.asc
15
KEYS.asc
|
|
@ -1,15 +0,0 @@
|
||||||
-----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-----
|
|
||||||
381
README.md
381
README.md
|
|
@ -1,142 +1,66 @@
|
||||||
# BOS — Bread Operating System
|
# BOS — Bread Operating System
|
||||||
|
|
||||||
An Arch-based, Hyprland desktop distribution that ships the [bread
|
An Arch-based, Hyprland desktop distribution that ships the [bread
|
||||||
ecosystem](https://git.breadway.dev/Breadway) preconfigured. One Calamares install
|
ecosystem](https://github.com/Breadway) preconfigured. One Calamares install
|
||||||
produces a themed, bootable Wayland desktop — no manual Arch bootstrap, no
|
produces a themed, bootable Wayland desktop — no manual Arch bootstrap, no
|
||||||
wiring up dotfiles, no per-tool bakery installs.
|
wiring up dotfiles, no per-tool bakery installs.
|
||||||
|
|
||||||
> This file is the product as the tree ships it. [DESIGN.md](DESIGN.md) is the
|
> Design rationale and the btrfs/A-B roadmap live in [DESIGN.md](DESIGN.md).
|
||||||
> original plan, kept as history — several of its sections (in-tree GTK
|
> This file is the practical overview: what's in the image, how to build it,
|
||||||
> bos-settings, bakery-at-post-install, A/B as if it were current) are not
|
> and how to test it.
|
||||||
> how the ISO works today.
|
|
||||||
|
|
||||||
## What you get
|
## What you get
|
||||||
|
|
||||||
- **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated
|
- **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated
|
||||||
keybinds, snappy animations, blur, and pywal-driven colours on a black base.
|
keybinds, snappy animations, blur, and pywal-driven colours on a black base.
|
||||||
- **bread ecosystem**, baked into `/usr/local` from bakery-managed binaries
|
- **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries
|
||||||
(no network needed at install time; per-user bakery state is seeded in
|
(no network needed at install time): `bread`/`breadd`, `breadbar` (status bar
|
||||||
`/etc/skel`): the `bread`/`breadd` automation daemon
|
+ notification daemon), `breadbox` (launcher), `breadcrumbs` (Wi-Fi profiles),
|
||||||
(`bread-emit` / `bread-module-host` when the stable bread release publishes
|
`breadpad` (notes/reminders), `breadman`, and the `bakery` package manager.
|
||||||
them), `breadbar` (status bar + notifications), `breadbox` (launcher),
|
- **bos-settings**: a GTK4 control panel that configures every bread\* app's
|
||||||
`breadclip` (clipboard history), `breadcrumbs` (Wi-Fi profiles),
|
config from a GUI (non-destructively), plus snapshot rollback and bakery
|
||||||
`breadpad`/`breadman` (notes), `breadpaper` (wallpaper + theme),
|
updates. See below.
|
||||||
`breadsearch` (system search), `breadmon` (monitor layout TUI),
|
- **Login**: greetd + tuigreet → Hyprland session.
|
||||||
`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).
|
- **Boot splash**: Plymouth `bos` theme (logo + spinner, black background).
|
||||||
- **Theming**: global dark across GTK3 (Adwaita-dark), GTK4/libadwaita
|
- **Theming**: global dark across GTK3 (Adwaita-dark), GTK4/libadwaita
|
||||||
(`color-scheme: prefer-dark`), and Qt (qt5ct/qt6ct Fusion dark); Papirus-Dark
|
(`color-scheme: prefer-dark`), and Qt (qt5ct/qt6ct Fusion dark); Papirus-Dark
|
||||||
icons; Bibata cursor.
|
icons; Bibata cursor.
|
||||||
- **Apps**: kitty, nautilus (+ gvfs), Zen browser, VLC, loupe, gnome-text-editor,
|
- **Apps**: kitty, nautilus (+ gvfs), Zen browser, VLC, loupe, gnome-text-editor,
|
||||||
gnome-calculator, file-roller, with file associations wired in `mimeapps.list`.
|
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
|
- **Hardware**: pipewire audio, NetworkManager, BlueZ + blueman, CUPS printing
|
||||||
with avahi mDNS discovery, TLP power management, fwupd firmware updates.
|
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
|
- **Resilience**: btrfs + snapper + snap-pac + grub-btrfs snapshots on every
|
||||||
pacman transaction (**root `@` only** — snapper does not cover `@home`);
|
pacman transaction; zram swap; ufw firewall (deny-incoming, mDNS allowed).
|
||||||
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/<pkg>/<ver>/...`). `build-local.sh` fails if any
|
|
||||||
**required** binary is missing on the builder.
|
|
||||||
|
|
||||||
## Repo layout
|
## 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/
|
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
|
├── iso/ # archiso profile
|
||||||
│ ├── bread-lockfile.toml # bakery bins + optional version pins
|
|
||||||
│ ├── profiledef.sh
|
│ ├── profiledef.sh
|
||||||
│ ├── packages.x86_64 # live + installed pacman set
|
│ ├── packages.x86_64 # live + installed package set
|
||||||
│ └── airootfs/ # files overlaid onto the image
|
│ └── airootfs/ # files overlaid onto the image
|
||||||
│ └── etc/
|
│ └── etc/
|
||||||
│ ├── skel/ # live user defaults (hypr, kitty, gtk, …)
|
│ ├── skel/ # default user dotfiles (hypr, kitty, gtk, …)
|
||||||
│ └── calamares/ # installer config + post-install.sh
|
│ └── calamares/ # installer config + post-install.sh
|
||||||
├── packaging/ # in-house PKGBUILDs for AUR-only deps
|
├── packaging/ # in-house PKGBUILDs for AUR-only deps
|
||||||
|
│ ├── arch/ # bos-settings
|
||||||
│ ├── calamares/
|
│ ├── calamares/
|
||||||
│ ├── bibata/
|
│ └── bibata/
|
||||||
│ ├── powerlevel10k/
|
├── .forgejo/workflows/ # CI: build + publish packages to [breadway]
|
||||||
│ └── 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
|
├── build-local.sh # native ISO build for this machine
|
||||||
├── README.md
|
└── DESIGN.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
|
## Building the ISO
|
||||||
|
|
||||||
`build-local.sh` builds the image natively (no container) and copies this
|
`build-local.sh` builds the image natively (no container) and bakes this
|
||||||
machine's bakery-installed bread binaries + breadhelp content from the
|
machine's bakery-installed bread binaries into `/etc/skel`:
|
||||||
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
|
```sh
|
||||||
sudo ./build-local.sh # release-quality (xz squashfs)
|
sudo ./build-local.sh # release-quality (xz squashfs)
|
||||||
|
|
@ -144,47 +68,16 @@ sudo FAST_BUILD=1 ./build-local.sh # fast dev iteration (zstd squashfs)
|
||||||
```
|
```
|
||||||
|
|
||||||
The ISO lands in `out/bos-<date>-x86_64.iso`. The script pins
|
The ISO lands in `out/bos-<date>-x86_64.iso`. The script pins
|
||||||
`SOURCE_DATE_EPOCH` (reproducible UUIDs), rewrites the `[breadway]` repo URL
|
`SOURCE_DATE_EPOCH` (reproducible UUIDs) and rewrites the `[breadway]` repo URL
|
||||||
to the Tailscale-reachable Forgejo registry for the build, and **exits
|
to the Tailscale-reachable Forgejo registry for the build.
|
||||||
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/<pkg>/<ver>/...`) 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
|
### Why some packages are in-house
|
||||||
|
|
||||||
`calamares`, `zen-browser-bin`, `bibata-cursor-theme`, and `yay-bin` are
|
`calamares`, `zen-browser-bin`, and `bibata-cursor-theme` are AUR-only. BOS
|
||||||
AUR-only. BOS keeps a PKGBUILD for each under `packaging/` and republishes
|
keeps a PKGBUILD for each under `packaging/` and republishes the built package
|
||||||
the built package to the `[breadway]` repo via a Forgejo Actions workflow
|
to the `[breadway]` repo via a Forgejo Actions workflow (built on the hestia
|
||||||
(built on the hestia self-hosted runner, published with a scoped registry
|
self-hosted runner, published with a scoped registry token). `bos-settings`
|
||||||
token). `[breadway]` is **not** where bakery/breadbar/bos-settings live.
|
itself publishes the same way on a `v*` tag.
|
||||||
|
|
||||||
### 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
|
## Testing in a VM
|
||||||
|
|
||||||
|
|
@ -200,123 +93,43 @@ 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
|
Hyprland session in QEMU. The disk lives on NVMe (not the tmpfs `/tmp`) to
|
||||||
avoid memory pressure.
|
avoid memory pressure.
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
## Second account
|
|
||||||
|
|
||||||
Bakery desktop apps live in `/usr/local` — shared, already on PATH. A later
|
|
||||||
account does **not** get a private copy of those binaries.
|
|
||||||
|
|
||||||
`/etc/default/useradd` keeps `SKEL=/etc/skel`. Stock `useradd -m` is enough:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sudo useradd -m alice
|
|
||||||
sudo passwd alice
|
|
||||||
```
|
|
||||||
|
|
||||||
- **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
|
## bos-settings
|
||||||
|
|
||||||
Standalone bakery product: **Tauri 2 + Svelte**, not GTK4, and not built
|
`bos-settings` edits each bread\* app's TOML **non-destructively**: it parses
|
||||||
from this repo. Install/update with `bakery`; the ISO just bakes whatever
|
the file with `toml_edit`, changes only the keys a view exposes, and writes it
|
||||||
binary the builder has.
|
back — preserving comments and any keys the UI doesn't model (calendar
|
||||||
|
passwords, saved-network passwords, model paths). Views:
|
||||||
|
|
||||||
It aims for GNOME-Settings-style parity: live system state and control, so
|
| View | Config |
|
||||||
day-to-day administration doesn't require a terminal. Bread-ecosystem
|
|------|--------|
|
||||||
configs are edited **non-destructively** (comments and unmodeled keys stay).
|
| bread | `bread/breadd.toml` — daemon, lua, modules, all adapters, events, notifications |
|
||||||
Panels with a daemon (bread, breadbox, breadcrumbs, breadsearch, breadclip)
|
| breadbar | `breadbar/style.css` override |
|
||||||
also get live systemd status + Start/Stop/Restart/Logs.
|
| 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 |
|
||||||
|
|
||||||
| Panel | What it does |
|
Build standalone:
|
||||||
|-------|--------------|
|
|
||||||
| 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)
|
```sh
|
||||||
repo, not here.
|
cargo build --release -p bos-settings
|
||||||
|
cargo test -p bos-settings # includes config round-trip tests
|
||||||
|
```
|
||||||
|
|
||||||
## The bread ecosystem
|
## The bread ecosystem at a glance
|
||||||
|
|
||||||
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 |
|
| Tool | Role | Launch |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| `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`) |
|
| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor signals into events dispatched to Lua modules | runs at login |
|
||||||
| `breadbar` | Top status bar: workspaces, clock, system stats, tray, **and** the notification daemon — one process, not two | runs at login |
|
| `breadbar` | Top status bar (workspaces, clock, stats, tray) **and** the notification daemon | runs at login |
|
||||||
| `breadbox` | Application launcher (fuzzy search, per-context results via `breadbox-sync`) | `SUPER+Space` |
|
| `breadbox` | Application launcher | `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 |
|
| `breadpad` | Notes & reminders (AI-classified, optional CalDAV sync) | `SUPER+U` |
|
||||||
| `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 |
|
| `breadman` | Package-manager UI | `SUPER+M` |
|
||||||
|
| `breadcrumbs` | Wi-Fi profile state machine (location-aware) | CLI / BOS Settings |
|
||||||
**Productivity**
|
| `bakery` | CLI package manager for the ecosystem | `bakery` |
|
||||||
|
| `bos-settings` | Unified GTK4 control panel for all of the above + snapshots + updates | `SUPER+,` |
|
||||||
| 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
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
|
@ -328,16 +141,12 @@ cheatsheet in-session; first boot shows a short welcome (once).
|
||||||
| `SUPER+Return` | Terminal (kitty) |
|
| `SUPER+Return` | Terminal (kitty) |
|
||||||
| `SUPER+Space` | App launcher (breadbox) |
|
| `SUPER+Space` | App launcher (breadbox) |
|
||||||
| `SUPER+E` / `SUPER+B` | Files (nautilus) / Browser (zen) |
|
| `SUPER+E` / `SUPER+B` | Files (nautilus) / Browser (zen) |
|
||||||
| `SUPER+U` / `SUPER+M` | Notes (breadpad) / notes manager (breadman) |
|
| `SUPER+U` / `SUPER+M` | breadpad / breadman |
|
||||||
| `SUPER+,` / `SUPER+/` | BOS Settings / keybind cheatsheet |
|
| `SUPER+,` / `SUPER+/` | BOS Settings / keybind cheatsheet |
|
||||||
| `SUPER+L` / `SUPER+N` | Lock / log out |
|
| `SUPER+L` / `SUPER+N` | Lock / log out |
|
||||||
| `SUPER+Backspace` | Close window |
|
| `SUPER+Backspace` | Close window |
|
||||||
| `SUPER+F` | Fullscreen |
|
| `SUPER+F` / `SUPER+V` / `SUPER+T` | Fullscreen / float / toggle split |
|
||||||
| `SUPER+I` | Toggle floating |
|
| `SUPER+Shift+V` | Clipboard history |
|
||||||
| `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+Tab` | Last window |
|
||||||
| `SUPER+Shift+S/C/P` | Screenshot region→file / region→clipboard / screen→file |
|
| `SUPER+Shift+S/C/P` | Screenshot region→file / region→clipboard / screen→file |
|
||||||
| `SUPER+arrows` | Move focus |
|
| `SUPER+arrows` | Move focus |
|
||||||
|
|
@ -346,75 +155,41 @@ cheatsheet in-session; first boot shows a short welcome (once).
|
||||||
| `SUPER+1..0` | Switch to workspace 1–10 |
|
| `SUPER+1..0` | Switch to workspace 1–10 |
|
||||||
| `SUPER+Shift+1..0` | Move window to workspace |
|
| `SUPER+Shift+1..0` | Move window to workspace |
|
||||||
| `SUPER+[ / ]` | Previous / next 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 |
|
| `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
|
## 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.
|
- **GPUs**: ships the generic Mesa stack — AMD and Intel work out of the box.
|
||||||
NVIDIA is **unsupported** (no proprietary driver, no NVIDIA firmware). See
|
The **NVIDIA proprietary driver is not included**; NVIDIA users must install
|
||||||
[docs/hardware.md](docs/hardware.md).
|
`nvidia`/`nvidia-utils` and set the usual Hyprland env vars after install.
|
||||||
- **Virtual machines**: Hyprland needs GPU acceleration to be smooth. Use
|
- **Virtual machines**: Hyprland needs GPU acceleration to be smooth. Use
|
||||||
`virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is
|
`virtio-vga-gl` + `-display gtk,gl=on` (virgl); plain software rendering is
|
||||||
noticeably laggy.
|
noticeably laggy.
|
||||||
- **Wayland-first**: X11-only apps run through XWayland; a few may misbehave.
|
- **Wayland-first**: X11-only apps run through XWayland; a few may misbehave.
|
||||||
- **Secure Boot**: self-signed only, via `sbctl` — BOS can't ship a
|
- **Secure Boot**: not configured. Boot with Secure Boot disabled, or enroll
|
||||||
Microsoft-signed shim (that needs going through Microsoft's own paid UEFI
|
your own keys. The installer writes both an NVRAM entry and the removable
|
||||||
CA process). Post-install enrolls BOS's own keys automatically, but only
|
`EFI/BOOT/BOOTX64.EFI` fallback.
|
||||||
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
|
- **Snapshots assume btrfs**: the snapper/grub-btrfs tooling expects the default
|
||||||
btrfs subvolume layout the installer creates. Recovery is the GRUB
|
btrfs subvolume layout the installer creates.
|
||||||
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
|
## Recovery
|
||||||
|
|
||||||
**An update broke something (system still boots):** reboot → **GRUB
|
**An update broke something (system still boots):** open BOS Settings →
|
||||||
“snapshots” submenu** (grub-btrfs), then boot that entry.
|
Snapshots and roll back, or pick a pre-update snapshot from the **GRUB
|
||||||
|
“snapshots” submenu** at boot, then run `snapper rollback` from the booted
|
||||||
BOS Settings → Snapshots lists each snapshot’s number, date, and
|
snapshot.
|
||||||
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-<id>`, 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):**
|
**The system won't boot (broken GRUB / lost EFI entry):**
|
||||||
|
|
||||||
1. Boot the BOS ISO and open a terminal (`SUPER+Return`).
|
1. Boot the BOS ISO and open a terminal (`SUPER+Return`).
|
||||||
2. Run `sudo bos-rescue`. It finds the installed btrfs `@` and the ESP,
|
2. Mount the installed root and EFI, then chroot:
|
||||||
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
|
```sh
|
||||||
mount -o subvol=@ /dev/sdXN /mnt
|
mount -o subvol=@ /dev/sdXN /mnt
|
||||||
mount /dev/sdXP /mnt/boot/efi # the EFI partition
|
mount /dev/sdXP /mnt/boot/efi # the EFI partition
|
||||||
arch-chroot /mnt
|
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 --bootloader-id=BOS --recheck
|
||||||
grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck
|
grub-install --target=x86_64-efi --efi-directory=/boot/efi --removable --recheck
|
||||||
grub-mkconfig -o /boot/grub/grub.cfg
|
grub-mkconfig -o /boot/grub/grub.cfg
|
||||||
|
|
|
||||||
12
bakery.toml
Normal file
12
bakery.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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 = []
|
||||||
19
bos-settings/Cargo.toml
Normal file
19
bos-settings/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[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"
|
||||||
213
bos-settings/src/config/mod.rs
Normal file
213
bos-settings/src/config/mod.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
//! 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::<DocumentMut>() {
|
||||||
|
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<dyn Error>> {
|
||||||
|
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<bool> {
|
||||||
|
get(doc, path)?.as_bool()
|
||||||
|
}
|
||||||
|
pub fn get_str(doc: &DocumentMut, path: &[&str]) -> Option<String> {
|
||||||
|
get(doc, path)?.as_str().map(str::to_string)
|
||||||
|
}
|
||||||
|
pub fn get_i64(doc: &DocumentMut, path: &[&str]) -> Option<i64> {
|
||||||
|
get(doc, path)?.as_integer()
|
||||||
|
}
|
||||||
|
pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option<f64> {
|
||||||
|
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<String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
bos-settings/src/main.rs
Normal file
13
bos-settings/src/main.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
30
bos-settings/src/theme.rs
Normal file
30
bos-settings/src/theme.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
//! 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<Option<CssProvider>> = 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));
|
||||||
|
}
|
||||||
4
bos-settings/src/ui/mod.rs
Normal file
4
bos-settings/src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod sidebar;
|
||||||
|
pub mod views;
|
||||||
|
pub mod widgets;
|
||||||
|
pub mod window;
|
||||||
74
bos-settings/src/ui/sidebar.rs
Normal file
74
bos-settings/src/ui/sidebar.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
bos-settings/src/ui/views/bread.rs
Normal file
158
bos-settings/src/ui/views/bread.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
//! 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
|
||||||
|
}
|
||||||
75
bos-settings/src/ui/views/breadbar.rs
Normal file
75
bos-settings/src/ui/views/breadbar.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
204
bos-settings/src/ui/views/breadbox.rs
Normal file
204
bos-settings/src/ui/views/breadbox.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
//! 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> std::path::PathBuf {
|
||||||
|
config::config_dir().join("breadbox/config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_contexts(doc: &DocumentMut) -> Vec<Context> {
|
||||||
|
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<RefCell<Vec<Context>>>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
479
bos-settings/src/ui/views/breadcrumbs.rs
Normal file
479
bos-settings/src/ui/views/breadcrumbs.rs
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
//! breadcrumbs.toml — Wi-Fi profile state machine.
|
||||||
|
//! Schema mirrors breadcrumbs/src/config.rs:
|
||||||
|
//! [settings] scalar tunables
|
||||||
|
//! [[networks]] saved networks (ssid / password / hidden)
|
||||||
|
//! [profiles.<name>] 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<Network> {
|
||||||
|
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<RefCell<Vec<Network>>>) {
|
||||||
|
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<String>,
|
||||||
|
detect_ssids: Vec<String>,
|
||||||
|
bootstrap: String,
|
||||||
|
exit_node: String,
|
||||||
|
tailscale: bool,
|
||||||
|
include_all_known: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_profiles(doc: &DocumentMut) -> Vec<Profile> {
|
||||||
|
let Some(tbl) = doc.get("profiles").and_then(Item::as_table) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let str_list = |item: Option<&Item>| -> Vec<String> {
|
||||||
|
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<gtk4::Widget>) -> 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<RefCell<Vec<Profile>>>) {
|
||||||
|
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
|
||||||
|
}
|
||||||
146
bos-settings/src/ui/views/breadpad.rs
Normal file
146
bos-settings/src/ui/views/breadpad.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
//! 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
|
||||||
|
}
|
||||||
153
bos-settings/src/ui/views/breadpaper.rs
Normal file
153
bos-settings/src/ui/views/breadpaper.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
//! breadpaper — wallpaper manager. No config file to edit here; breadpaper
|
||||||
|
//! takes no persistent settings, just an image path via its CLI (`breadpaper
|
||||||
|
//! set <path>` / `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<PathBuf> {
|
||||||
|
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::<gtk4::Window>().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::<bool>(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
|
||||||
|
}
|
||||||
111
bos-settings/src/ui/views/breadsearch.rs
Normal file
111
bos-settings/src/ui/views/breadsearch.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
//! 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
|
||||||
|
}
|
||||||
95
bos-settings/src/ui/views/hyprland.rs
Normal file
95
bos-settings/src/ui/views/hyprland.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{Box as GBox, Button, Label, Orientation};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn get_monitors() -> Vec<String> {
|
||||||
|
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::<Vec<serde_json::Value>>(&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
|
||||||
|
}
|
||||||
10
bos-settings/src/ui/views/mod.rs
Normal file
10
bos-settings/src/ui/views/mod.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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;
|
||||||
258
bos-settings/src/ui/views/packages.rs
Normal file
258
bos-settings/src/ui/views/packages.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
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<String, String> {
|
||||||
|
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::<serde_json::Value>(&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::<HashMap<String, serde_json::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::<String>(256);
|
||||||
|
let args: Vec<String> = 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
|
||||||
|
}
|
||||||
245
bos-settings/src/ui/views/snapshots.rs
Normal file
245
bos-settings/src/ui/views/snapshots.rs
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
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<SnapshotRow> {
|
||||||
|
// 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::<gtk4::Window>().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::<gtk4::Window>().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::<bool>(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
|
||||||
|
}
|
||||||
235
bos-settings/src/ui/widgets.rs
Normal file
235
bos-settings/src/ui/widgets.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
//! 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<RefCell<DocumentMut>>;
|
||||||
|
|
||||||
|
/// 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<gtk4::Widget>) -> 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<String> = 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<String> = 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
|
||||||
|
}
|
||||||
60
bos-settings/src/ui/window.rs
Normal file
60
bos-settings/src/ui/window.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
428
build-local.sh
428
build-local.sh
|
|
@ -25,19 +25,14 @@ OUT="${OUT:-$REPO/out}"
|
||||||
STAGE=/tmp/bos-iso-stage
|
STAGE=/tmp/bos-iso-stage
|
||||||
rm -rf "$STAGE" && cp -a "$REPO/iso" "$STAGE"
|
rm -rf "$STAGE" && cp -a "$REPO/iso" "$STAGE"
|
||||||
|
|
||||||
# [breadway] now points at the signed public repo https://dl.breadway.dev/arch
|
# Rewrite the [breadway] pacman repo URL to the fastest reachable address.
|
||||||
# (SigLevel = Required) — no Forgejo-registry URL rewrite needed anymore.
|
# 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
|
||||||
# Trust the [breadway] repo key in *this* build host's pacman keyring so
|
# Only ever rewrites the staged copy, never the committed pacman.conf.
|
||||||
# `pacstrap` can verify [breadway] packages while assembling the airootfs.
|
if [ "${CI_BUILD:-0}" = "1" ]; then
|
||||||
# The same key is baked into the image at etc/pacman.d/breadway-repo.asc and
|
sed -i 's#https://git.breadway.dev/api/packages/Breadway/arch/os#http://localhost:3002/api/packages/Breadway/arch/os#' "$STAGE/pacman.conf"
|
||||||
# re-trusted on the live medium / installed target (calamares/post-install.sh).
|
else
|
||||||
BREADWAY_KEY_FPR="56203B86A110695AE7F310934AF3323D678EB5E2"
|
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_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
|
fi
|
||||||
|
|
||||||
if [ "${FAST_BUILD:-0}" = "1" ]; then
|
if [ "${FAST_BUILD:-0}" = "1" ]; then
|
||||||
|
|
@ -46,402 +41,89 @@ if [ "${FAST_BUILD:-0}" = "1" ]; then
|
||||||
fi
|
fi
|
||||||
grep airootfs_image_tool_options "$STAGE/profiledef.sh"
|
grep airootfs_image_tool_options "$STAGE/profiledef.sh"
|
||||||
|
|
||||||
# --- Bake this machine's bakery-installed bread ecosystem into the image ------
|
# --- Bake this laptop's bakery-installed bread ecosystem into /etc/skel -------
|
||||||
# The bread desktop apps are bakery-managed (release binaries from
|
# The bread apps are managed by bakery (which fetches release binaries from
|
||||||
# dl.breadway.dev / GitHub), not pacman. bakery needs DNS at install time,
|
# GitHub), not pacman. bakery needs DNS at install time, which the live/installed
|
||||||
# which the live/installed image doesn't have — so instead of running bakery
|
# image doesn't have — so instead of running bakery on the target, we copy the
|
||||||
# on the target, we copy the binaries + bakery manifest this builder already
|
# exact binaries + bakery manifest this laptop already has into skel. Every user
|
||||||
# has. Builder home stays user-layout (~/.local); the *image* is system-prefix
|
# created from skel (the live user and the installed user) then gets the same
|
||||||
# /usr/local so apps live on @ and ride snapper/grub-btrfs snapshots.
|
# versions `bakery list` reports here, fully offline. Copied at build time so the
|
||||||
# installed.json + index cache stay per-user in skel. Copied at build time
|
# binaries never bloat the git repo and always track the current bakery state.
|
||||||
# so the binaries never bloat the git repo.
|
BREAD_BINS=(bakery bread breadd breadman breadbar breadbox breadbox-sync breadcrumbs breadpad breadpaper bread-theme breadmon breadsearch breadmill breadclip breadclipd breadshot)
|
||||||
#
|
|
||||||
# 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)}"
|
LAPTOP_HOME="${LAPTOP_HOME:-$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)}"
|
||||||
BAKERY_BIN="$LAPTOP_HOME/.local/bin"
|
BAKERY_BIN="$LAPTOP_HOME/.local/bin"
|
||||||
BAKERY_STATE="$LAPTOP_HOME/.local/state/bakery"
|
BAKERY_STATE="$LAPTOP_HOME/.local/state/bakery"
|
||||||
BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery"
|
BAKERY_CACHE="$LAPTOP_HOME/.cache/bakery"
|
||||||
BAKERY_SHARE="$LAPTOP_HOME/.local/share"
|
SKEL="$STAGE/airootfs/etc/skel"
|
||||||
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 ==="
|
echo "=== baking bakery bread ecosystem from $LAPTOP_HOME ==="
|
||||||
echo "lockfile: $LOCKFILE (${#REQUIRED_BINS[@]} required, ${#OPTIONAL_BINS[@]} optional)"
|
install -d -m 0755 "$SKEL/.local/bin" "$SKEL/.local/state/bakery" "$SKEL/.cache/bakery"
|
||||||
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
|
|
||||||
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
|
for b in "${BREAD_BINS[@]}"; do
|
||||||
install -m 0755 "$BAKERY_BIN/$b" "$IMAGE_BIN/$b"
|
install -m 0755 "$BAKERY_BIN/$b" "$SKEL/.local/bin/$b"
|
||||||
done
|
done
|
||||||
|
install -m 0644 "$BAKERY_STATE/installed.json" "$SKEL/.local/state/bakery/installed.json"
|
||||||
# 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),
|
# 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
|
# 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,
|
# 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;
|
# so bake it in too — then bakery works fully offline (list/info from cache;
|
||||||
# install/update still need network, as expected).
|
# 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"
|
install -m 0644 "$BAKERY_CACHE/index.json" "$SKEL/.cache/bakery/index.json"
|
||||||
echo "baked bins: $(ls "$IMAGE_BIN")"
|
echo "baked: $(ls "$SKEL/.local/bin")"
|
||||||
|
|
||||||
# --- Bake bakery data dirs the apps need offline ------------------------------
|
|
||||||
# bakery extracts data_archive (breadhelp's content.tar.gz) to
|
|
||||||
# $prefix/share/<pkg>/ 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 -----------
|
# --- Bake systemd user services for bakery-managed bread packages -----------
|
||||||
# Historically only breadd.service was hand-committed to skel; every other
|
# Historically only breadd.service was hand-committed to skel; every other
|
||||||
# bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was
|
# bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was
|
||||||
# silently left out, so those daemons never start on a fresh install/live
|
# silently left out, so those daemons never start on a fresh install/live
|
||||||
# boot until the user re-runs `bakery install` (which needs network).
|
# boot until the user re-runs `bakery install` (which needs network).
|
||||||
# Units come from installed.json + the bakery index + local unit files
|
# Generalize from the same source of truth as the binary bake above: read
|
||||||
# whose ExecStart is a lockfile binary (installed.json has omitted
|
# the services this laptop's bakery actually installed, copy each unit file
|
||||||
# breadcrumbs.service before). Units go to /usr/lib/systemd/user with
|
# into skel with ExecStart rewritten from this laptop's literal home path to
|
||||||
# ExecStart rewritten to /usr/local/bin. Recreate whichever
|
# the portable `%h` specifier, and recreate whichever *.target.wants enable
|
||||||
# *.target.wants enable symlink bakery created locally (or that skel
|
# symlink bakery created locally. Units already committed by hand (breadd.service
|
||||||
# already ships), and write /etc/systemd/user/*.wants/ (--global).
|
# carries a RuntimeDirectoryPreserve=yes fix not yet upstreamed — see bread-release-build
|
||||||
# Hand-committed skel units (breadd.service carries a
|
# notes) are left alone rather than overwritten.
|
||||||
# RuntimeDirectoryPreserve=yes fix not yet upstreamed) are the source
|
echo "=== baking bakery service units into skel ==="
|
||||||
# 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"
|
SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user"
|
||||||
SKEL_SYSTEMD="$SKEL/.config/systemd/user"
|
SKEL_SYSTEMD="$SKEL/.config/systemd/user"
|
||||||
install -d -m 0755 "$IMAGE_UNITS"
|
mapfile -t SERVICE_UNITS < <(python3 -c "
|
||||||
# installed.json on the builder can omit a service even when the index and
|
import json
|
||||||
# the local unit file exist (breadcrumbs has done this). Merge all three
|
with open('$BAKERY_STATE/installed.json') as f:
|
||||||
# so every lockfile daemon is baked and can be --global enabled.
|
d = json.load(f)
|
||||||
mapfile -t SERVICE_UNITS < <(python3 - \
|
for pkg in d.get('packages', d).values():
|
||||||
"$SKEL/.local/state/bakery/installed.json" \
|
for s in pkg.get('services', []):
|
||||||
"$BAKERY_CACHE/index.json" \
|
print(s)
|
||||||
"$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
|
for unit in "${SERVICE_UNITS[@]}"; do
|
||||||
[[ -n "$unit" ]] || continue
|
|
||||||
if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then
|
if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then
|
||||||
src="$SKEL_SYSTEMD/$unit"
|
echo " $unit already committed in skel, leaving as-is"
|
||||||
echo " $unit using committed skel unit as source"
|
continue
|
||||||
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
|
fi
|
||||||
rewrite_exec_start "$src" "$IMAGE_UNITS/$unit"
|
src="$SYSTEMD_USER_DIR/$unit"
|
||||||
if [[ -f "$SKEL_SYSTEMD/$unit" ]]; then
|
if [[ ! -f "$src" ]]; then
|
||||||
rewrite_exec_start "$src" "$SKEL_SYSTEMD/$unit"
|
echo " warning: $unit not found at $src, skipping"
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
for base in "$SYSTEMD_USER_DIR" "$SKEL_SYSTEMD"; do
|
install -d -m 0755 "$SKEL_SYSTEMD"
|
||||||
[[ -d "$base" ]] || continue
|
sed "s#ExecStart=$LAPTOP_HOME/.local/bin/#ExecStart=%h/.local/bin/#" "$src" > "$SKEL_SYSTEMD/$unit"
|
||||||
for wants_dir in "$base"/*.target.wants; do
|
for wants_dir in "$SYSTEMD_USER_DIR"/*.target.wants; do
|
||||||
[[ -e "$wants_dir" || -L "$wants_dir" ]] || continue
|
[[ -L "$wants_dir/$unit" ]] || continue
|
||||||
[[ -L "$wants_dir/$unit" ]] || continue
|
target_name="$(basename "$wants_dir")"
|
||||||
target_name="$(basename "$wants_dir")"
|
install -d -m 0755 "$SKEL_SYSTEMD/$target_name"
|
||||||
install -d -m 0755 "$IMAGE_UNITS/$target_name"
|
ln -sf "../$unit" "$SKEL_SYSTEMD/$target_name/$unit"
|
||||||
ln -sf "../$unit" "$IMAGE_UNITS/$target_name/$unit"
|
|
||||||
done
|
|
||||||
done
|
done
|
||||||
# systemctl --global enable equivalent: /etc/systemd/user/<WantedBy>.wants/
|
echo " baked $unit"
|
||||||
# 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
|
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
|
# 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
|
# 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
|
# exec-once launches fail with "permission denied". Inject a 0755 entry for each
|
||||||
# baked bakery binary right after the array opener (bos-* bins are already
|
# baked binary right after the array opener (keeps the binary list in one place).
|
||||||
# listed; keeps the bakery list in one place — the lockfile).
|
|
||||||
perm_file="$(mktemp)"
|
perm_file="$(mktemp)"
|
||||||
for b in "${BREAD_BINS[@]}"; do
|
for b in "${BREAD_BINS[@]}"; do
|
||||||
printf ' ["/usr/local/bin/%s"]="0:0:755"\n' "$b" >>"$perm_file"
|
printf ' ["/etc/skel/.local/bin/%s"]="0:0:755"\n' "$b" >>"$perm_file"
|
||||||
done
|
done
|
||||||
sed -i "/^file_permissions=(/r $perm_file" "$STAGE/profiledef.sh"
|
sed -i "/^file_permissions=(/r $perm_file" "$STAGE/profiledef.sh"
|
||||||
rm -f "$perm_file"
|
rm -f "$perm_file"
|
||||||
echo "=== file_permissions after injection ==="; grep -A40 '^file_permissions=(' "$STAGE/profiledef.sh"
|
echo "=== file_permissions after injection ==="; grep -A14 '^file_permissions=(' "$STAGE/profiledef.sh"
|
||||||
|
|
||||||
# Pin one timestamp for the whole build. Without this, mkarchiso derives the
|
# 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
|
# boot-config UUID (%ARCHISO_UUID%) when it starts and the iso9660 volume UUID
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
# 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-<ver>-1-x86_64.pkg.tar.zst
|
|
||||||
breadlock-<ver>-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-<ver>-1-x86_64.pkg.tar.zst
|
|
||||||
# → breadlock-<ver>-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 `<section>.db` + `<section>.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…`.
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# `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.
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
# 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
|
$mod = SUPER
|
||||||
|
|
||||||
# App launchers
|
# App launchers
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# Bakery desktop apps live under /usr/local so they ride snapper @ snapshots.
|
|
||||||
prefix = "/usr/local"
|
|
||||||
|
|
@ -9,10 +9,10 @@ strings:
|
||||||
versionedName: "BOS (rolling)"
|
versionedName: "BOS (rolling)"
|
||||||
shortVersionedName: "BOS"
|
shortVersionedName: "BOS"
|
||||||
bootloaderEntryName: "BOS"
|
bootloaderEntryName: "BOS"
|
||||||
productUrl: "https://git.breadway.dev/Breadway/bos"
|
productUrl: "https://github.com/Breadway/bos"
|
||||||
supportUrl: "https://git.breadway.dev/Breadway/bos/issues"
|
supportUrl: "https://github.com/Breadway/bos/issues"
|
||||||
knownIssuesUrl: "https://git.breadway.dev/Breadway/bos/issues"
|
knownIssuesUrl: "https://github.com/Breadway/bos/issues"
|
||||||
releaseNotesUrl: "https://git.breadway.dev/Breadway/bos/releases"
|
releaseNotesUrl: "https://github.com/Breadway/bos/releases"
|
||||||
|
|
||||||
images:
|
images:
|
||||||
productLogo: "logo.png"
|
productLogo: "logo.png"
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,10 @@
|
||||||
---
|
---
|
||||||
# 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
|
backend: pacman
|
||||||
|
|
||||||
skip_if_no_internet: true
|
options:
|
||||||
update_db: true
|
- update_db: true
|
||||||
ignore_update_db_error: true
|
|
||||||
update_system: false
|
|
||||||
|
|
||||||
pacman:
|
operations:
|
||||||
num_retries: 1
|
- try_install:
|
||||||
disable_download_timeout: false
|
- pipewire-pulse
|
||||||
needed_only: true
|
- pipewire-alsa
|
||||||
|
|
||||||
operations: []
|
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,3 @@ userSwapChoices:
|
||||||
- small
|
- small
|
||||||
- suspend
|
- suspend
|
||||||
- file
|
- 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
|
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,9 @@ showSupportUrl: false
|
||||||
showKnownIssuesUrl: false
|
showKnownIssuesUrl: false
|
||||||
showReleaseNotesUrl: 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:
|
requirements:
|
||||||
requiredStorage: 20
|
requiredStorage: 20
|
||||||
requiredRam: 2.0
|
requiredRam: 2.0
|
||||||
internetCheckUrl: "https://breadway.dev"
|
checkInternet: true
|
||||||
check:
|
checkPower: true
|
||||||
- storage
|
internetCheckUrl: "https://archlinux.org"
|
||||||
- ram
|
|
||||||
- power
|
|
||||||
- internet
|
|
||||||
- root
|
|
||||||
required:
|
|
||||||
- storage
|
|
||||||
- ram
|
|
||||||
- root
|
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,7 @@
|
||||||
# Best-effort: do NOT use `set -e`; a single failure here must not abort the rest.
|
# Best-effort: do NOT use `set -e`; a single failure here must not abort the rest.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
# Whether Calamares encrypted the root partition (LUKS) — checked once here,
|
MAIN_USER="$(getent passwd 1000 | cut -d: -f1 || true)"
|
||||||
# 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.
|
# Strip live-only bits that unpackfs copied verbatim from the live medium.
|
||||||
|
|
@ -31,40 +20,6 @@ rm -f /usr/local/bin/bos-live-setup /usr/local/bin/bos-launch-calamares
|
||||||
rm -f /etc/sudoers.d/99-bos-live
|
rm -f /etc/sudoers.d/99-bos-live
|
||||||
userdel -r liveuser 2>/dev/null || true
|
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).
|
# Root used a passwordless entry on the live medium; lock it (sudo model).
|
||||||
passwd -l root || true
|
passwd -l root || true
|
||||||
|
|
||||||
|
|
@ -73,23 +28,12 @@ passwd -l root || true
|
||||||
# over to the target (unpackfs may skip it / perms differ), leaving the installed
|
# 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
|
# 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
|
# with "keyring is not writable / required key missing". Initialise it here so a
|
||||||
# fresh install can update out of the box. archlinux-keyring verifies official
|
# fresh install can update out of the box. archlinux-keyring is already present;
|
||||||
# Arch packages; the BOS release key (56203B86…, shipped at
|
# [breadway] is SigLevel=Never so it needs no key.
|
||||||
# /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
|
if command -v pacman-key &>/dev/null; then
|
||||||
pacman-key --init || echo "WARN: pacman-key --init failed"
|
pacman-key --init || echo "WARN: pacman-key --init failed"
|
||||||
pacman-key --populate archlinux || echo "WARN: pacman-key --populate 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
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -108,44 +52,11 @@ if [[ -f /etc/mkinitcpio.conf ]]; then
|
||||||
sed -i 's/^\(HOOKS=.*\bautodetect\b\)/\1 microcode/' /etc/mkinitcpio.conf \
|
sed -i 's/^\(HOOKS=.*\bautodetect\b\)/\1 microcode/' /etc/mkinitcpio.conf \
|
||||||
|| echo "WARN: adding microcode hook failed"
|
|| echo "WARN: adding microcode hook failed"
|
||||||
fi
|
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 \
|
if command -v plymouth-set-default-theme &>/dev/null \
|
||||||
&& ! grep -qE '^HOOKS=.*\bplymouth\b' /etc/mkinitcpio.conf; then
|
&& ! grep -qE '^HOOKS=.*\bplymouth\b' /etc/mkinitcpio.conf; then
|
||||||
sed -i "s/^\(HOOKS=.*\b${BASE_HOOK}\b\)/\1 plymouth/" /etc/mkinitcpio.conf \
|
sed -i 's/^\(HOOKS=.*\budev\b\)/\1 plymouth/' /etc/mkinitcpio.conf \
|
||||||
|| echo "WARN: adding plymouth hook failed"
|
|| echo "WARN: adding plymouth hook failed"
|
||||||
fi
|
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
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -163,17 +74,6 @@ if command -v plymouth-set-default-theme &>/dev/null; then
|
||||||
plymouth-set-default-theme bos || echo "WARN: plymouth-set-default-theme failed"
|
plymouth-set-default-theme bos || echo "WARN: plymouth-set-default-theme failed"
|
||||||
fi
|
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
|
# Rebuild every preset (default + fallback that bos-copy-kernel wrote) so the
|
||||||
# microcode + plymouth HOOKS above are actually baked into the initramfs.
|
# microcode + plymouth HOOKS above are actually baked into the initramfs.
|
||||||
mkinitcpio -P || echo "WARN: mkinitcpio -P failed"
|
mkinitcpio -P || echo "WARN: mkinitcpio -P failed"
|
||||||
|
|
@ -195,20 +95,18 @@ mkinitcpio -P || echo "WARN: mkinitcpio -P failed"
|
||||||
# BIOS: MBR install onto the disk hosting /.
|
# BIOS: MBR install onto the disk hosting /.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if command -v grub-install &>/dev/null; then
|
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
|
if [[ -d /sys/firmware/efi ]]; then
|
||||||
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
|
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
|
||||||
--bootloader-id=BOS --recheck "${CRYPT_MODULES[@]}" \
|
--bootloader-id=BOS --recheck \
|
||||||
|| echo "WARN: grub-install (nvram) failed"
|
|| echo "WARN: grub-install (nvram) failed"
|
||||||
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
|
grub-install --target=x86_64-efi --efi-directory=/boot/efi \
|
||||||
--removable --recheck "${CRYPT_MODULES[@]}" \
|
--removable --recheck \
|
||||||
|| echo "WARN: grub-install (removable) failed"
|
|| echo "WARN: grub-install (removable) failed"
|
||||||
else
|
else
|
||||||
ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')"
|
ROOT_DEV="$(findmnt -no SOURCE / | sed 's/\[.*\]//')"
|
||||||
ROOT_DISK="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null)"
|
ROOT_DISK="$(lsblk -no pkname "$ROOT_DEV" 2>/dev/null)"
|
||||||
if [[ -n "$ROOT_DISK" ]]; then
|
if [[ -n "$ROOT_DISK" ]]; then
|
||||||
grub-install --target=i386-pc --recheck "${CRYPT_MODULES[@]}" "/dev/$ROOT_DISK" \
|
grub-install --target=i386-pc --recheck "/dev/$ROOT_DISK" \
|
||||||
|| echo "WARN: grub-install (BIOS) failed"
|
|| echo "WARN: grub-install (BIOS) failed"
|
||||||
else
|
else
|
||||||
echo "WARN: could not determine the disk hosting / (root device: ${ROOT_DEV:-unknown}) — BIOS grub-install skipped"
|
echo "WARN: could not determine the disk hosting / (root device: ${ROOT_DEV:-unknown}) — BIOS grub-install skipped"
|
||||||
|
|
@ -219,33 +117,6 @@ if command -v grub-mkconfig &>/dev/null; then
|
||||||
grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed"
|
grub-mkconfig -o /boot/grub/grub.cfg || echo "WARN: grub-mkconfig failed"
|
||||||
fi
|
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 @,
|
# Create @snapshots, @log, @cache as top-level btrfs subvolumes (peers of @,
|
||||||
# not nested under it — so snapshots of @ don't recursively include
|
# not nested under it — so snapshots of @ don't recursively include
|
||||||
|
|
@ -315,64 +186,38 @@ fi
|
||||||
# transiently fail inside the Calamares chroot (the same mount unmounts
|
# transiently fail inside the Calamares chroot (the same mount unmounts
|
||||||
# cleanly moments later once booted normally — a chroot-specific busy-mount
|
# cleanly moments later once booted normally — a chroot-specific busy-mount
|
||||||
# race, not a logic error), which cascades into snapper create-config
|
# race, not a logic error), which cascades into snapper create-config
|
||||||
# refusing because .snapshots "already exists". A single pass — retry the
|
# refusing because .snapshots "already exists". Retry a few times, then
|
||||||
# umount a few times, then fall back to a lazy unmount — was NOT enough on
|
# fall back to a lazy unmount (detaches the mountpoint immediately even if
|
||||||
# real hardware: also confirmed is a run where every umount attempt in that
|
# something still transiently references it) rather than give up.
|
||||||
# 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
|
if command -v snapper &>/dev/null; then
|
||||||
for attempt in 1 2 3; do
|
unmounted=0
|
||||||
[[ -f /etc/snapper/configs/root ]] && break
|
for _ in 1 2 3 4 5; do
|
||||||
|
if umount /.snapshots 2>/dev/null; then
|
||||||
unmounted=0
|
unmounted=1
|
||||||
for _ in 1 2 3 4 5; do
|
break
|
||||||
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
|
fi
|
||||||
rmdir /.snapshots 2>/dev/null || echo "WARN: rmdir /.snapshots failed (attempt $attempt)"
|
sleep 1
|
||||||
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
|
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
|
if [[ -f /etc/snapper/configs/root ]]; then
|
||||||
sed -i 's/TIMELINE_CREATE="yes"/TIMELINE_CREATE="no"/' /etc/snapper/configs/root
|
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_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_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="[^"]*"/NUMBER_LIMIT="10"/' /etc/snapper/configs/root
|
||||||
sed -i 's/NUMBER_LIMIT_IMPORTANT="[^"]*"/NUMBER_LIMIT_IMPORTANT="5"/' /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" ]] && \
|
[[ -n "$MAIN_USER" ]] && \
|
||||||
snapper -c root set-config "ALLOW_USERS=$MAIN_USER"
|
sed -i "s/ALLOW_USERS=\"\"/ALLOW_USERS=\"$MAIN_USER\"/" /etc/snapper/configs/root
|
||||||
else
|
|
||||||
echo "ERROR: snapper config for root still missing after 3 attempts — snapshots/rollback will not work on this install"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -384,39 +229,15 @@ fi
|
||||||
# greetd — graphical login (shipped disabled; live uses tty autologin)
|
# greetd — graphical login (shipped disabled; live uses tty autologin)
|
||||||
# grub-btrfsd — regenerates GRUB snapshot entries (the unit is grub-btrfsd.service,
|
# grub-btrfsd — regenerates GRUB snapshot entries (the unit is grub-btrfsd.service,
|
||||||
# NOT grub-btrfs.path, which no longer exists)
|
# 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 \
|
for unit in NetworkManager.service bluetooth.service systemd-timesyncd.service \
|
||||||
tlp.service greetd.service snapper-cleanup.timer grub-btrfsd.service \
|
tlp.service greetd.service snapper-cleanup.timer grub-btrfsd.service \
|
||||||
fstrim.timer cups.socket avahi-daemon.socket ufw.service \
|
fstrim.timer cups.socket avahi-daemon.service ufw.service \
|
||||||
fwupd-refresh.timer reflector.timer; do
|
fwupd-refresh.timer reflector.timer; do
|
||||||
systemctl enable "$unit" || echo "WARN: failed to enable $unit"
|
systemctl enable "$unit" || echo "WARN: failed to enable $unit"
|
||||||
done
|
done
|
||||||
systemctl set-default graphical.target || echo "WARN: set-default graphical failed"
|
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
|
# mDNS resolution (nss-mdns): insert mdns_minimal into the hosts: line so the
|
||||||
# resolver answers *.local (network printers, other hosts) via avahi. Idempotent.
|
# resolver answers *.local (network printers, other hosts) via avahi. Idempotent.
|
||||||
|
|
@ -438,21 +259,11 @@ if command -v ufw &>/dev/null; then
|
||||||
ufw --force enable || echo "WARN: ufw enable failed"
|
ufw --force enable || echo "WARN: ufw enable failed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The whole bread ecosystem (bakery, bread, breadbar, breadbox, breadcrumbs,
|
# The bread ecosystem (bakery + bread, breadbar, breadbox, breadcrumbs, breadpad)
|
||||||
# breadpad, bos-settings, breadhelp, ...) is bakery-managed, not pacman:
|
# is bakery-managed, not pacman: the binaries and bakery manifest live in
|
||||||
# binaries, share/data, and user units are baked into /usr/local and
|
# /etc/skel/.local (baked in at ISO build time) and are copied into the user's
|
||||||
# /usr/lib/systemd/user (system prefix). Per-user bakery state (installed.json
|
# home below, so the install works fully offline with no DNS for bakery/GitHub.
|
||||||
# + index cache) is seeded from /etc/skel/.local and copied into the user's
|
# bos-settings is the only pacman bread package and was installed by unpackfs.
|
||||||
# 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
|
# Deploy dotfiles + the bakery bread ecosystem into the user's home (Calamares
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,12 @@ sequence:
|
||||||
- users
|
- users
|
||||||
- networkcfg
|
- networkcfg
|
||||||
- hwclock
|
- 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
|
# archiso strips the kernel from the squashfs; stage it, drop the archiso
|
||||||
# initramfs config, and write a stock mkinitcpio preset before initcpio runs.
|
# initramfs config, and write a stock mkinitcpio preset before initcpio runs.
|
||||||
- shellprocess@kernel
|
- shellprocess@kernel
|
||||||
|
|
@ -51,12 +57,6 @@ sequence:
|
||||||
# BOS finalization: GRUB install + cleanup + snapper + services + dotfiles.
|
# BOS finalization: GRUB install + cleanup + snapper + services + dotfiles.
|
||||||
# All fast, and runs after initcpio so /boot has the kernel + initramfs.
|
# All fast, and runs after initcpio so /boot has the kernel + initramfs.
|
||||||
- shellprocess
|
- 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
|
- umount
|
||||||
- show:
|
- show:
|
||||||
- finished
|
- finished
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,5 @@ GROUP=users
|
||||||
HOME=/home
|
HOME=/home
|
||||||
INACTIVE=-1
|
INACTIVE=-1
|
||||||
EXPIRE=
|
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
|
SKEL=/etc/skel
|
||||||
CREATE_MAIL_SPOOL=no
|
CREATE_MAIL_SPOOL=no
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,8 @@
|
||||||
# alongside BOS's own bos.desktop, and breadgreet's session picker matches by
|
# alongside BOS's own bos.desktop, and breadgreet's session picker matches by
|
||||||
# .desktop file stem — with no override it picks "hyprland.desktop" over
|
# .desktop file stem — with no override it picks "hyprland.desktop" over
|
||||||
# "bos.desktop", which skips bos-session's PATH fixup (adds ~/.local/bin for
|
# "bos.desktop", which skips bos-session's PATH fixup (adds ~/.local/bin for
|
||||||
# per-user tools; bakery apps are in /usr/local/bin). greetd starts no login
|
# the bakery bread apps; greetd starts no login shell, so /etc/profile.d is
|
||||||
# shell, so /etc/profile.d is never sourced any other way. Confirmed via
|
# never sourced any other way). Confirmed via breadgreet's own test suite
|
||||||
# breadgreet's own test suite
|
|
||||||
# (sessions.rs: discover_prefers_configured_default_over_first_entry).
|
# (sessions.rs: discover_prefers_configured_default_over_first_entry).
|
||||||
|
|
||||||
[sessions]
|
[sessions]
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ ID_LIKE=arch
|
||||||
BUILD_ID=rolling
|
BUILD_ID=rolling
|
||||||
ANSI_COLOR="38;2;23;147;209"
|
ANSI_COLOR="38;2;23;147;209"
|
||||||
HOME_URL="https://breadway.dev"
|
HOME_URL="https://breadway.dev"
|
||||||
DOCUMENTATION_URL="https://git.breadway.dev/Breadway/bos"
|
DOCUMENTATION_URL="https://wiki.archlinux.org/"
|
||||||
SUPPORT_URL="https://git.breadway.dev/Breadway/bos/issues"
|
SUPPORT_URL="https://bbs.archlinux.org/"
|
||||||
BUG_REPORT_URL="https://git.breadway.dev/Breadway/bos/issues"
|
BUG_REPORT_URL="https://git.breadway.dev/Breadway/bos/issues"
|
||||||
PRIVACY_POLICY_URL="https://breadway.dev"
|
PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/"
|
||||||
|
|
|
||||||
|
|
@ -26,23 +26,20 @@ Include = /etc/pacman.d/mirrorlist
|
||||||
Include = /etc/pacman.d/mirrorlist
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Breadway custom repo — breadlock plus AUR republishes the ISO needs
|
# Breadway custom repo — provides: bakery and the bread ecosystem packages
|
||||||
# (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin,
|
# (bread, breadbar, breadbox, breadcrumbs, breadpad, bos-settings).
|
||||||
# zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp
|
# (calamares comes from the official extra repo, not here.)
|
||||||
# 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
|
# Packages are published to the Forgejo Arch registry (group "os") by the
|
||||||
# .forgejo/workflows/*.yml workflows; scripts/ci-publish-signed-repo.sh then
|
# .forgejo/workflows/package.yml workflow in each repo, on tag push.
|
||||||
# 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).
|
|
||||||
#
|
#
|
||||||
# SigLevel = Required: every package AND the db carry a .sig from key
|
# Forgejo signs the repo db with a key pacman can't look up, so TrustAll
|
||||||
# 56203B86A110695AE7F310934AF3323D678EB5E2 — the same key committed as
|
# fails. SigLevel = Never skips verification (acceptable for this private
|
||||||
# KEYS.asc / etc/pacman.d/breadway-repo.asc, imported into the pacman
|
# repo over TLS). Future improvement: import Forgejo's signing key and
|
||||||
# keyring at build time (build-local.sh), on the live medium, and on the
|
# switch to SigLevel = Required for full package verification.
|
||||||
# installed target (calamares/post-install.sh).
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
[breadway]
|
# The section name must match Forgejo's served db filename
|
||||||
SigLevel = Required
|
# ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server.
|
||||||
Server = https://dl.breadway.dev/arch/$arch
|
[Breadway.os.git.breadway.dev]
|
||||||
|
SigLevel = Never
|
||||||
|
Server = https://git.breadway.dev/api/packages/Breadway/arch/os/$arch
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
-----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-----
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Keep ~/.local/bin on PATH for per-user tools. Arch already includes
|
# Put the per-user bakery bin dir on PATH. The bread ecosystem (breadd, breadbar,
|
||||||
# /usr/local/bin (where bakery desktop apps live on BOS). The Hyprland
|
# breadbox, …) is installed there by bakery, and the Hyprland session launches
|
||||||
# session resolves exec-once against the PATH it inherits from the login
|
# them via `exec-once`, which resolves against the PATH it inherits from the
|
||||||
# shell; Arch's stock /etc/profile does not add ~/.local/bin, so do it
|
# login shell. Arch's stock /etc/profile does not add ~/.local/bin, so do it here
|
||||||
# here for every login shell (live user and installed user alike).
|
# for every login shell (live user and installed user alike).
|
||||||
case ":$PATH:" in
|
case ":$PATH:" in
|
||||||
*":$HOME/.local/bin:"*) ;;
|
*":$HOME/.local/bin:"*) ;;
|
||||||
*) export PATH="$HOME/.local/bin:$PATH" ;;
|
*) export PATH="$HOME/.local/bin:$PATH" ;;
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
/usr/share/backgrounds/bos/bread-background.png
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
-- 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 <id>` 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
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
-- 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 <cmd>` (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
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
-- 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
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"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 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
{
|
|
||||||
"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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +1,61 @@
|
||||||
-- BOS Hyprland configuration — native Lua config (Hyprland 0.55+).
|
-- BOS Hyprland configuration — native Lua config (Hyprland 0.55+).
|
||||||
-- hyprlang (.conf) is deprecated; this uses the built-in `hl` API.
|
-- hyprlang (.conf) is deprecated; this uses the built-in `hl` API.
|
||||||
-- Mostly single-file by design (reference: https://wiki.hypr.land/) — the
|
-- Single-file and non-modular by design. Reference: https://wiki.hypr.land/
|
||||||
-- exceptions are keybinds, appearance settings, monitor layout, and the
|
|
||||||
-- extra autostart list, each loaded from a JSON file (binds.json,
|
local mod = "SUPER"
|
||||||
-- 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 — from monitors.json, always falls back to a generic
|
-- Monitors — generic default that works on any hardware.
|
||||||
-- any-hardware default (see scripts/display/monitors.lua).
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
pcall(dofile, script_dir .. "display/monitors.lua")
|
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Core appearance/input settings — from settings.json (see
|
-- Core settings.
|
||||||
-- scripts/ui/settings.lua).
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
pcall(dofile, script_dir .. "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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Animations — snappy curves + per-leaf speeds (matches the reference laptop;
|
-- Animations — snappy curves + per-leaf speeds (matches the reference laptop;
|
||||||
|
|
@ -55,12 +86,10 @@ for _, animation in ipairs(animations) do
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Window rules — float + centre the onboarding/help popups.
|
-- Window rules — float + centre the onboarding popups (kitty --class …).
|
||||||
-- 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 = "breadhelp", match = { class = "^(com\\.breadway\\.breadhelp)$" }, float = true, size = { 880, 600 } })
|
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 = "bos-netsetup", match = { class = "^(bos-netsetup)$" }, float = true, size = { 700, 560 } })
|
hl.window_rule({ name = "bos-netsetup", match = { class = "^(bos-netsetup)$" }, float = true, size = { 700, 560 } })
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
@ -77,54 +106,98 @@ hl.env("SDL_VIDEODRIVER", "wayland")
|
||||||
hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto")
|
hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto")
|
||||||
hl.env("_JAVA_AWT_WM_NONREPARENTING", "1")
|
hl.env("_JAVA_AWT_WM_NONREPARENTING", "1")
|
||||||
|
|
||||||
-- 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
|
-- kitty sets its own background_opacity (see kitty.conf), so the global blur
|
||||||
-- above blurs behind the terminal while keeping text fully opaque.
|
-- above blurs behind the terminal while keeping text fully opaque.
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Standard BOS keybinds — data-driven from binds.json (apps, windows,
|
-- Standard BOS keybinds (SUPER = mod).
|
||||||
-- screenshots, focus/move/resize, workspaces, mouse, media keys), loaded via
|
-- ---------------------------------------------------------------------------
|
||||||
-- scripts/input/{binds,keybinds}.lua. breadhelp's keybind viewer reads
|
-- Apps / window management
|
||||||
-- binds.json directly as its source of truth (no separately-maintained
|
hl.bind(mod .. " + RETURN", hl.dsp.exec_cmd("kitty"))
|
||||||
-- cheatsheet to keep in sync), and a future bos-settings editor can
|
hl.bind(mod .. " + BACKSPACE", hl.dsp.window.close())
|
||||||
-- read/write the same file.
|
hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("breadbox"))
|
||||||
pcall(function()
|
hl.bind(mod .. " + E", hl.dsp.exec_cmd("nautilus"))
|
||||||
local binds = dofile(script_dir .. "input/binds.lua")(config_home .. "binds.json")
|
hl.bind(mod .. " + B", hl.dsp.exec_cmd("zen-browser"))
|
||||||
dofile(script_dir .. "input/keybinds.lua")({
|
hl.bind(mod .. " + U", hl.dsp.exec_cmd("breadpad"))
|
||||||
default_mods = binds.default_mods,
|
hl.bind(mod .. " + M", hl.dsp.exec_cmd("breadman"))
|
||||||
bindings = binds.bindings,
|
hl.bind(mod .. " + comma", hl.dsp.exec_cmd("bos-settings"))
|
||||||
})
|
hl.bind(mod .. " + slash", hl.dsp.exec_cmd("bos-keybinds"))
|
||||||
end)
|
hl.bind(mod .. " + L", hl.dsp.exec_cmd("loginctl lock-session"))
|
||||||
-- 3-finger horizontal trackpad swipe → workspace switch (1:1 gesture)
|
hl.bind(mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" }))
|
||||||
hl.gesture({
|
hl.bind(mod .. " + I", hl.dsp.window.float({ action = "toggle" }))
|
||||||
fingers = 3,
|
hl.bind(mod .. " + P", hl.dsp.window.pseudo({ action = "toggle" }))
|
||||||
direction = "horizontal",
|
hl.bind(mod .. " + R", hl.dsp.window.resize())
|
||||||
action = "workspace",
|
-- 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"))
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Autostart. Core bootstrap sequence (polkit agent, dark theme, wallpaper
|
-- Autostart. polkit agent + the bread ecosystem + idle daemon + 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.)
|
-- (bos-live-setup appends the live-installer launch below this on the ISO.)
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
hl.on("hyprland.start", function()
|
hl.on("hyprland.start", function()
|
||||||
local core_startup = {
|
local startup = {
|
||||||
-- Generate the shared bread GUI stylesheet first, so breadbar/breadbox/
|
-- Generate the shared bread GUI stylesheet first, so breadbar/breadbox/
|
||||||
-- bos-settings load it on start (they also live-reload if it changes).
|
-- bos-settings load it on start (they also live-reload if it changes).
|
||||||
"bread-theme generate",
|
"bread-theme generate",
|
||||||
|
|
@ -135,28 +208,18 @@ hl.on("hyprland.start", function()
|
||||||
"gsettings set org.gnome.desktop.interface cursor-theme Bibata-Modern-Ice",
|
"gsettings set org.gnome.desktop.interface cursor-theme Bibata-Modern-Ice",
|
||||||
"gsettings set org.gnome.desktop.interface cursor-size 24",
|
"gsettings set org.gnome.desktop.interface cursor-size 24",
|
||||||
-- Clipboard history is breadclipd, a bakery-managed systemd --user
|
-- Clipboard history is breadclipd, a bakery-managed systemd --user
|
||||||
-- service (auto-started from /usr/lib/systemd/user — see
|
-- service (auto-started via skel — see build-local.sh's service bake)
|
||||||
-- build-local.sh's service bake) rather than an exec-once here.
|
-- rather than an exec-once here.
|
||||||
-- Prefer bread-polkit if it is on PATH (not baked; lockfile does not
|
"/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1",
|
||||||
-- 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",
|
"awww-daemon",
|
||||||
-- Set the default wallpaper once the daemon is up (retry until ready).
|
-- Set the default wallpaper once the daemon is up (retry until
|
||||||
-- Raw `awww img`, NOT `breadpaper set` — breadpaper set also runs real
|
-- ready) via `breadpaper set`, not raw `awww img` — breadpaper also
|
||||||
-- pywal against the image, which would clobber the curated black-base
|
-- generates the pywal palette and reloads bread-theme, and records
|
||||||
-- colors.json baked into skel (.cache/wal/colors.json: #0c0c0c bg,
|
-- the path so `breadpaper get` (and its bos-settings panel) show
|
||||||
-- bread-toned browns reserved for accent slots only) with colors
|
-- the real default instead of "No wallpaper set" on a fresh install.
|
||||||
-- actually extracted from bread-background.png — which is an all-beige
|
[[bash -c 'until breadpaper set /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']],
|
||||||
-- photo, so every bread-theme app (breadbar included) turns brown.
|
-- breadd runs as a systemd user service (~/.config/systemd/user/breadd.service,
|
||||||
-- `breadpaper get` still works on a fresh install without ever running
|
-- enabled in skel). It autostarts at login but before Hyprland exists, so
|
||||||
-- 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
|
-- 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.
|
-- 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",
|
"dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE",
|
||||||
|
|
@ -170,38 +233,16 @@ hl.on("hyprland.start", function()
|
||||||
-- Start it directly instead. If more graphical-session.target
|
-- Start it directly instead. If more graphical-session.target
|
||||||
-- services show up later, add them here too.
|
-- services show up later, add them here too.
|
||||||
"systemctl --user start breadclipd.service",
|
"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(core_startup) do
|
for _, cmd in ipairs(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))
|
hl.dispatch(hl.dsp.exec_cmd(cmd))
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"monitors": [
|
|
||||||
{ "output": "", "mode": "preferred", "position": "auto", "scale": "auto" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
-- 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
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
-- 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
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
-- 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
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
-- 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
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
-- 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
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
-- 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
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
@ -3,8 +3,8 @@ Description=Bread Runtime Daemon
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
# System-prefix bakery install — same path for every account.
|
# %h = the user's home — works for any account created from this skel.
|
||||||
ExecStart=/usr/local/bin/breadd
|
ExecStart=%h/.local/bin/breadd
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
UMask=0077
|
UMask=0077
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
[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
|
|
||||||
|
|
@ -81,15 +81,7 @@ alias ip='ip --color=auto'
|
||||||
alias update='bos-update'
|
alias update='bos-update'
|
||||||
alias pacman='sudo pacman'
|
alias pacman='sudo pacman'
|
||||||
|
|
||||||
# Package shortcuts — official repos via pacman, AUR via yay (alt-* prefix).
|
# ~/.local/bin holds the bread* binaries baked in at build time.
|
||||||
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"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
# Powerlevel10k prompt configuration.
|
# Powerlevel10k prompt configuration.
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
/usr/lib/systemd/user/breadd.service
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Enable bakery systemd --user units for every account (current and future).
|
|
||||||
#
|
|
||||||
# `systemctl --global enable` writes /etc/systemd/user/<target>.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
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
#!/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" <<EOF
|
|
||||||
{
|
|
||||||
"detected": true,
|
|
||||||
"pci": "$(json_escape "$nvidia_pci")",
|
|
||||||
"driver_on_iso": false,
|
|
||||||
"auto_install": false,
|
|
||||||
"message": "NVIDIA GPU detected. The proprietary driver is not on the ISO.",
|
|
||||||
"offered_at": "$(iso_now)"
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
notify "NVIDIA GPU detected. The proprietary driver is not on the ISO — open BOS Settings later. Nothing was installed." normal
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# VM without GL (no /dev/dri). Notify only when both are true.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
virt="none"
|
|
||||||
if command -v systemd-detect-virt >/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" <<EOF
|
|
||||||
{
|
|
||||||
"virt": "$(json_escape "$virt")",
|
|
||||||
"gl": false,
|
|
||||||
"dri": false,
|
|
||||||
"noted_at": "$(iso_now)"
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
notify "This looks like a virtual machine without hardware GL. Hyprland may use software rendering." normal
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# HiDPI — hint file for bos-settings. Do not rewrite monitors.json.
|
|
||||||
# scale > 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
|
|
||||||
4
iso/airootfs/usr/local/bin/bos-keybinds
Normal file
4
iso/airootfs/usr/local/bin/bos-keybinds
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/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
|
||||||
|
|
@ -7,17 +7,9 @@
|
||||||
# bos-launch-calamares). Runs once at boot, before the tty1 autologin getty.
|
# bos-launch-calamares). Runs once at boot, before the tty1 autologin getty.
|
||||||
set -e
|
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
|
# useradd -m copies /etc/skel, so the live user gets the real BOS desktop
|
||||||
# (hypr + bread config + bakery state) — proper live-media functionality,
|
# (breadd + breadbar + breadbox + keybinds) — proper live-media functionality,
|
||||||
# not an installer kiosk. Binaries are /usr/local, not skel.
|
# not an installer kiosk.
|
||||||
if ! id liveuser &>/dev/null; then
|
if ! id liveuser &>/dev/null; then
|
||||||
useradd -m -s /usr/bin/zsh liveuser
|
useradd -m -s /usr/bin/zsh liveuser
|
||||||
for g in wheel video input audio storage power; do
|
for g in wheel video input audio storage power; do
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,598 +0,0 @@
|
||||||
#!/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: DEVICE<TAB>KIND<TAB>PRETTY 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 <disk-hosting-root>
|
|
||||||
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 "$@"
|
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
# BOS graphical session launcher, run by greetd on the INSTALLED system after
|
# BOS graphical session launcher, run by greetd on the INSTALLED system after
|
||||||
# the user authenticates (see /etc/greetd/config.toml).
|
# the user authenticates (see /etc/greetd/config.toml).
|
||||||
#
|
#
|
||||||
# greetd does not start a login shell, so /etc/profile.d is never sourced.
|
# greetd does not start a login shell, so /etc/profile.d is never sourced — which
|
||||||
# Bakery desktop apps live in /usr/local/bin (already on Arch PATH). Source
|
# means ~/.local/bin (where bakery installs the bread ecosystem: breadd, breadbar,
|
||||||
# the login profile here so ~/.local/bin (per-user tools) is also on PATH,
|
# breadbox-sync, …) would be missing from PATH and the Hyprland `exec-once`
|
||||||
# set the Wayland session hints, then hand off to Hyprland.
|
# launches would fail. Source the login profile here so PATH is correct, set the
|
||||||
|
# Wayland session hints, then hand off to Hyprland.
|
||||||
#
|
#
|
||||||
# Launched via start-hyprland (ships with the hyprland package) rather than the
|
# Launched via start-hyprland (ships with the hyprland package) rather than the
|
||||||
# raw Hyprland binary — Hyprland upstream no longer recommends exec'ing it
|
# raw Hyprland binary — Hyprland upstream no longer recommends exec'ing it
|
||||||
|
|
|
||||||
|
|
@ -2,41 +2,19 @@
|
||||||
# bos-update — update all of BOS in one go.
|
# bos-update — update all of BOS in one go.
|
||||||
#
|
#
|
||||||
# BOS packages come from two channels, so a full update touches both:
|
# BOS packages come from two channels, so a full update touches both:
|
||||||
# 1. pacman — Arch base/desktop + the [breadway] repo (breadlock + AUR
|
# 1. pacman — Arch base/desktop + the [breadway] repo (bos-settings, etc.).
|
||||||
# republishes: calamares, zen-browser-bin, bibata, yay-bin,
|
# Every transaction is snapshotted by snap-pac, so you can roll
|
||||||
# powerlevel10k). [breadway] does NOT provide bos-settings
|
# back from the GRUB "snapshots" submenu or BOS Settings.
|
||||||
# or other bakery desktop apps. Every transaction is
|
# 2. bakery — the bread ecosystem apps in ~/.local/bin (whatever `bakery list`
|
||||||
# snapshotted by snap-pac; recover via the GRUB "snapshots"
|
# reports as installed — bread, breadbar, breadbox, breadcrumbs,
|
||||||
# submenu (grub-btrfs), not `snapper rollback`.
|
# breadpad, breadman, bread-theme, breadpaper, breadmon,
|
||||||
# 2. bakery — the bread ecosystem apps in /usr/local (whatever `bakery list`
|
# breadsearch, breadclip, breadshot, ...).
|
||||||
# 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.
|
# Best-effort: a failure in one channel doesn't abort the other.
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
|
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)"
|
bold "==> System packages (pacman -Syu)"
|
||||||
if command -v pacman >/dev/null; then
|
if command -v pacman >/dev/null; then
|
||||||
sudo pacman -Syu || echo "WARN: pacman update failed"
|
sudo pacman -Syu || echo "WARN: pacman update failed"
|
||||||
|
|
@ -47,22 +25,10 @@ fi
|
||||||
echo
|
echo
|
||||||
bold "==> Bread ecosystem (bakery update --all)"
|
bold "==> Bread ecosystem (bakery update --all)"
|
||||||
if command -v bakery >/dev/null; then
|
if command -v bakery >/dev/null; then
|
||||||
# /usr/local is root-owned. Never run bakery as the user against it;
|
bakery update --all || echo "WARN: bakery update failed"
|
||||||
# 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
|
else
|
||||||
echo "bakery not found; skipping"
|
echo "bakery not found; skipping"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
bold "==> BOS is up to date."
|
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=@)."
|
|
||||||
|
|
|
||||||
48
iso/airootfs/usr/local/bin/bos-welcome
Normal file
48
iso/airootfs/usr/local/bin/bos-welcome
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
#!/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
|
||||||
53
iso/airootfs/usr/share/bos/keybinds.txt
Normal file
53
iso/airootfs/usr/share/bos/keybinds.txt
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
|
||||||
|
██████ ██████ ███████ 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 + ,).
|
||||||
24
iso/airootfs/usr/share/bos/welcome.txt
Normal file
24
iso/airootfs/usr/share/bos/welcome.txt
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
# 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/<pkg>/<ver>/...
|
|
||||||
# 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/<pkg>/<ver>/ 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"
|
|
||||||
|
|
@ -1,26 +1,9 @@
|
||||||
# Base system
|
# Base system
|
||||||
base
|
base
|
||||||
|
base-devel
|
||||||
linux
|
linux
|
||||||
# linux-firmware metapackage pulls every mandatory vendor blob (incl. nvidia).
|
linux-firmware
|
||||||
# List the subpackages we actually need so nvidia (~103 MiB, nouveau-only —
|
linux-headers
|
||||||
# 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
|
# 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
|
# 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.
|
# Intel targets. bos-copy-kernel also stages these into the live target /boot.
|
||||||
|
|
@ -47,21 +30,12 @@ efibootmgr
|
||||||
btrfs-progs
|
btrfs-progs
|
||||||
dosfstools
|
dosfstools
|
||||||
mtools
|
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
|
# squashfs-tools: provides unsquashfs, which Calamares' unpackfs module uses
|
||||||
# to extract airootfs.sfs onto the target during install.
|
# to extract airootfs.sfs onto the target during install.
|
||||||
squashfs-tools
|
squashfs-tools
|
||||||
# rsync: unpackfs copies the unpacked rootfs onto the target with rsync.
|
# rsync: unpackfs copies the unpacked rootfs onto the target with rsync.
|
||||||
rsync
|
rsync
|
||||||
# Live-ISO boot (archiso bootmodes: bios.syslinux + uefi.grub)
|
# Live-ISO boot (archiso bootmodes: bios.syslinux + uefi.systemd-boot)
|
||||||
# mkinitcpio-archiso provides the initramfs hooks that find and mount
|
# mkinitcpio-archiso provides the initramfs hooks that find and mount
|
||||||
# airootfs.sfs and switch root into it — without it the live ISO drops
|
# airootfs.sfs and switch root into it — without it the live ISO drops
|
||||||
# to emergency mode on boot.
|
# to emergency mode on boot.
|
||||||
|
|
@ -78,8 +52,6 @@ snapper
|
||||||
snap-pac
|
snap-pac
|
||||||
grub-btrfs
|
grub-btrfs
|
||||||
inotify-tools
|
inotify-tools
|
||||||
# Home backup (Settings → Backup). Snapper is root (`@`) only; restic covers $HOME.
|
|
||||||
restic
|
|
||||||
|
|
||||||
# Wayland / Hyprland
|
# Wayland / Hyprland
|
||||||
hyprland
|
hyprland
|
||||||
|
|
@ -118,17 +90,11 @@ bluez-utils
|
||||||
# blueman: GUI Bluetooth manager (pair/connect devices; breadbar shows status only).
|
# blueman: GUI Bluetooth manager (pair/connect devices; breadbar shows status only).
|
||||||
blueman
|
blueman
|
||||||
|
|
||||||
# GTK4 runtime (breadbar, breadbox, breadclip, breadhelp, and other bakery apps)
|
# GTK4 runtime
|
||||||
gtk4
|
gtk4
|
||||||
gtk4-layer-shell
|
gtk4-layer-shell
|
||||||
librsvg
|
librsvg
|
||||||
libpulse
|
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
|
# 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.
|
# skel settings.ini silently falls back to the light theme for GTK3 apps.
|
||||||
gnome-themes-extra
|
gnome-themes-extra
|
||||||
|
|
@ -150,9 +116,7 @@ wayland-protocols
|
||||||
|
|
||||||
# Fonts
|
# Fonts
|
||||||
noto-fonts
|
noto-fonts
|
||||||
# noto-fonts-cjk is ~299 MiB installed / ~196 MiB on the ISO and only useful
|
noto-fonts-cjk
|
||||||
# to CJK-locale users. Install on first run for zh/ja/ko.
|
|
||||||
# noto-fonts-cjk
|
|
||||||
noto-fonts-emoji
|
noto-fonts-emoji
|
||||||
ttf-jetbrains-mono
|
ttf-jetbrains-mono
|
||||||
# Nerd font variant — icons in terminal tools (eza --icons, fastfetch, yazi)
|
# Nerd font variant — icons in terminal tools (eza --icons, fastfetch, yazi)
|
||||||
|
|
@ -178,12 +142,13 @@ file-roller
|
||||||
|
|
||||||
# GUI applications a general desktop is expected to have out of the box.
|
# GUI applications a general desktop is expected to have out of the box.
|
||||||
# gnome-text-editor: graphical editor (terminal editors aside); gnome-calculator:
|
# gnome-text-editor: graphical editor (terminal editors aside); gnome-calculator:
|
||||||
# calculator; loupe: Wayland-native image viewer (default for image files).
|
# 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(+pdf-mupdf): lightweight Wayland PDF viewer (BOS had no PDF reader).
|
||||||
# zathura+zathura-pdf-mupdf would pull libmupdf (~56 MiB) as a never-default viewer.
|
|
||||||
gnome-text-editor
|
gnome-text-editor
|
||||||
gnome-calculator
|
gnome-calculator
|
||||||
loupe
|
loupe
|
||||||
|
zathura
|
||||||
|
zathura-pdf-mupdf
|
||||||
# Media player — BOS ships gstreamer codecs but otherwise has no player app.
|
# Media player — BOS ships gstreamer codecs but otherwise has no player app.
|
||||||
vlc
|
vlc
|
||||||
# Web browser (served from the [Breadway] repo; AUR zen-browser-bin republished
|
# Web browser (served from the [Breadway] repo; AUR zen-browser-bin republished
|
||||||
|
|
@ -196,24 +161,20 @@ mailcap
|
||||||
# (calamares 3.4.x is already Qt6; there is no separate calamares-qt6 package)
|
# (calamares 3.4.x is already Qt6; there is no separate calamares-qt6 package)
|
||||||
calamares
|
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.
|
# Bread ecosystem.
|
||||||
#
|
#
|
||||||
# breadlock is the only bread* pacman package here (it needs a root-owned
|
# The bread apps themselves (bakery, bread, breadbar, breadbox, breadcrumbs,
|
||||||
# /etc/pam.d/breadlock). Everything else — bakery, bread/breadd/bread-emit/
|
# breadpad) are NOT pacman packages here — they are bakery-managed binaries
|
||||||
# bread-module-host, breadbar, breadbox, breadcrumbs, breadpad, breadpaper,
|
# baked into /etc/skel/.local/bin at build time (see build-local.sh), so every
|
||||||
# bread-theme, breadmon, breadsearch, breadclip, breadshot, bos-settings,
|
# user gets the exact versions from this laptop's bakery install with no
|
||||||
# breadhelp — is bakery-managed and baked into /usr/local at ISO build
|
# network/DNS needed at install or runtime. Their runtime system deps are pulled
|
||||||
# time from iso/bread-lockfile.toml (see build-local.sh). breadcast and
|
# in elsewhere in this list (gtk4, gtk4-layer-shell, iw, libpulse, librsvg,
|
||||||
# breadarr are not shipped. bos-settings/breadhelp desktop entries are
|
# networkmanager, openssl, zlib, systemd-libs) — keep those even though no bread
|
||||||
# also committed under iso/airootfs/etc/skel/.local/share/applications/. Runtime
|
# package depends on them.
|
||||||
# deps stay listed even though no bread package depends on them via pacman
|
#
|
||||||
# (gtk4, gtk4-layer-shell, webkit2gtk-4.1, iw, libpulse, librsvg, …).
|
# 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
|
||||||
|
|
||||||
# Input / screen utilities
|
# Input / screen utilities
|
||||||
brightnessctl
|
brightnessctl
|
||||||
|
|
@ -225,8 +186,6 @@ slurp
|
||||||
wl-clipboard
|
wl-clipboard
|
||||||
playerctl
|
playerctl
|
||||||
# Wallpaper daemon + pywal (drives the bread* colour palette from the wallpaper).
|
# 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
|
awww
|
||||||
python-pywal
|
python-pywal
|
||||||
# Boot splash (BOS logo + spinner instead of kernel text).
|
# Boot splash (BOS logo + spinner instead of kernel text).
|
||||||
|
|
@ -313,15 +272,6 @@ system-config-printer
|
||||||
# remote post-install (needs network); the runtime is shipped ready.
|
# remote post-install (needs network); the runtime is shipped ready.
|
||||||
flatpak
|
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
|
# Firewall — ufw, enabled deny-incoming in post-install.sh (mDNS allowed so
|
||||||
# printer discovery still works).
|
# printer discovery still works).
|
||||||
ufw
|
ufw
|
||||||
|
|
@ -348,3 +298,6 @@ qt6ct
|
||||||
# hyprland.lua) needs these or Qt apps fall back to (blurry) XWayland.
|
# hyprland.lua) needs these or Qt apps fall back to (blurry) XWayland.
|
||||||
qt5-wayland
|
qt5-wayland
|
||||||
qt6-wayland
|
qt6-wayland
|
||||||
|
|
||||||
|
# Dev tools (for bos-settings standalone install)
|
||||||
|
rustup
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,6 @@ Architecture = auto
|
||||||
CheckSpace
|
CheckSpace
|
||||||
ParallelDownloads = 5
|
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
|
Color
|
||||||
VerbosePkgLists
|
VerbosePkgLists
|
||||||
ILoveCandy
|
ILoveCandy
|
||||||
|
|
@ -43,23 +26,20 @@ Include = /etc/pacman.d/mirrorlist
|
||||||
Include = /etc/pacman.d/mirrorlist
|
Include = /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Breadway custom repo — breadlock plus AUR republishes the ISO needs
|
# Breadway custom repo — provides: bakery and the bread ecosystem packages
|
||||||
# (calamares, zen-browser-bin, bibata-cursor-theme-bin, yay-bin,
|
# (bread, breadbar, breadbox, breadcrumbs, breadpad, bos-settings).
|
||||||
# zsh-theme-powerlevel10k). bakery / breadbar / bos-settings / breadhelp
|
# (calamares comes from the official extra repo, not here.)
|
||||||
# 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
|
# Packages are published to the Forgejo Arch registry (group "os") by the
|
||||||
# .forgejo/workflows/*.yml workflows; scripts/ci-publish-signed-repo.sh then
|
# .forgejo/workflows/package.yml workflow in each repo, on tag push.
|
||||||
# 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).
|
|
||||||
#
|
#
|
||||||
# SigLevel = Required: every package AND the db carry a .sig from key
|
# Forgejo signs the repo db with a key pacman can't look up, so TrustAll
|
||||||
# 56203B86A110695AE7F310934AF3323D678EB5E2 — the same key committed as
|
# fails. SigLevel = Never skips verification (acceptable for this private
|
||||||
# KEYS.asc / airootfs/etc/pacman.d/breadway-repo.asc, imported into the
|
# repo over TLS). Future improvement: import Forgejo's signing key and
|
||||||
# pacman keyring at build time (build-local.sh), on the live medium, and
|
# switch to SigLevel = Required for full package verification.
|
||||||
# on the installed target (calamares/post-install.sh).
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
[breadway]
|
# The section name must match Forgejo's served db filename
|
||||||
SigLevel = Required
|
# ({owner}.{group}.{domain}.db) — pacman fetches "<section>.db" from Server.
|
||||||
Server = https://dl.breadway.dev/arch/$arch
|
[Breadway.os.git.breadway.dev]
|
||||||
|
SigLevel = Never
|
||||||
|
Server = https://git.breadway.dev/api/packages/Breadway/arch/os/$arch
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,7 @@ iso_application="Bread Operating System"
|
||||||
iso_version="$(date +%Y.%m.%d)"
|
iso_version="$(date +%Y.%m.%d)"
|
||||||
install_dir="arch"
|
install_dir="arch"
|
||||||
buildmodes=('iso')
|
buildmodes=('iso')
|
||||||
# systemd-boot can only read files from the ESP it was launched from, so
|
bootmodes=('bios.syslinux' 'uefi.systemd-boot')
|
||||||
# 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"
|
arch="x86_64"
|
||||||
pacman_conf="pacman.conf"
|
pacman_conf="pacman.conf"
|
||||||
airootfs_image_type="squashfs"
|
airootfs_image_type="squashfs"
|
||||||
|
|
@ -27,10 +22,7 @@ file_permissions=(
|
||||||
["/usr/local/bin/bos-copy-kernel"]="0:0:755"
|
["/usr/local/bin/bos-copy-kernel"]="0:0:755"
|
||||||
["/usr/local/bin/bos-resolve-airootfs"]="0:0:755"
|
["/usr/local/bin/bos-resolve-airootfs"]="0:0:755"
|
||||||
["/usr/local/bin/bos-session"]="0:0:755"
|
["/usr/local/bin/bos-session"]="0:0:755"
|
||||||
["/usr/local/bin/bos-netcheck"]="0:0:755"
|
["/usr/local/bin/bos-keybinds"]="0:0:755"
|
||||||
|
["/usr/local/bin/bos-welcome"]="0:0:755"
|
||||||
["/usr/local/bin/bos-update"]="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"
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
38
packaging/arch/PKGBUILD
Normal file
38
packaging/arch/PKGBUILD
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,25 @@
|
||||||
Arch packaging
|
Arch packaging
|
||||||
==============
|
==============
|
||||||
|
|
||||||
This directory only holds `PKGBUILD`s for third-party AUR packages BOS
|
`PKGBUILD` builds and installs `bos-settings` from source.
|
||||||
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/<name>.yml` (in this repo) for how each
|
|
||||||
one publishes on a push to `packaging/<name>/**`.
|
|
||||||
|
|
||||||
Every bread-ecosystem app (bakery, bread, breadbar, breadbox, breadcrumbs,
|
## Local build
|
||||||
breadpad, breadpaper, breadmon, breadsearch, breadclip, breadshot,
|
|
||||||
bos-settings, breadhelp, ...) is bakery-managed, not pacman-packaged — see
|
```bash
|
||||||
`iso/bread-lockfile.toml` (`required_bins` + `optional_bins`), which
|
makepkg -si
|
||||||
`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
|
## Before publishing to [breadway] repo
|
||||||
`/etc/pam.d/breadlock` PAM service file, which bakery — by design — has no
|
|
||||||
privileged-install path for) and stays on pacman only; see
|
1. Tag a release on GitHub.
|
||||||
`bread-ecosystem/docs/release-channels.md` for the full policy.
|
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 |
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,7 @@ options=('!strip')
|
||||||
source=("${pkgname%-bin}-$pkgver.tar.xz::$url/releases/download/v$pkgver/Bibata.tar.xz")
|
source=("${pkgname%-bin}-$pkgver.tar.xz::$url/releases/download/v$pkgver/Bibata.tar.xz")
|
||||||
sha256sums=('172e33c4ae415278384dcecc7d1a9b7a024266bc944bc751fd86532be1cc6251')
|
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() {
|
package() {
|
||||||
install -d "$pkgdir/usr/share/icons"
|
install -d "$pkgdir/usr/share/icons"
|
||||||
for v in "${_variants[@]}"; do
|
cp -r Bibata* "$pkgdir/usr/share/icons"
|
||||||
cp -r "$v" "$pkgdir/usr/share/icons/"
|
|
||||||
done
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Maintainer: Breadway <plasticbread849@gmail.com>
|
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||||
# In-house copy of the AUR calamares PKGBUILD (Calamares is AUR-only; not in
|
# 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.
|
# Arch's official repos). Built by CI and published to the [breadway] repo.
|
||||||
# Source of truth: https://aur.archlinux.org/packages/calamares
|
# Source of truth: https://aur.archlinux.org/packages/calamares
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# 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 <plasticbread849@gmail.com>
|
|
||||||
# Upstream maintainer: Morten Linderud <foxboron@archlinux.org>
|
|
||||||
# Contributor: Sean Haugh <seanphaugh@gmail.com>
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
# 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 <pkgbuilds at jguer.space>
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
@ -1,497 +0,0 @@
|
||||||
#!/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())
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
#!/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 ]]
|
|
||||||
|
|
@ -40,72 +40,21 @@ check "grub-btrfs present" "pacman -Qq grub-btrfs"
|
||||||
|
|
||||||
echo "== enabled system services =="
|
echo "== enabled system services =="
|
||||||
for unit in NetworkManager.service greetd.service bluetooth.service tlp.service \
|
for unit in NetworkManager.service greetd.service bluetooth.service tlp.service \
|
||||||
cups.socket avahi-daemon.socket ufw.service systemd-timesyncd.service; do
|
cups.socket avahi-daemon.service ufw.service systemd-timesyncd.service; do
|
||||||
check "$unit enabled" "systemctl is-enabled $unit"
|
check "$unit enabled" "systemctl is-enabled $unit"
|
||||||
done
|
done
|
||||||
check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]"
|
check "graphical.target is default" "[ \"\$(systemctl get-default)\" = graphical.target ]"
|
||||||
|
|
||||||
echo "== bread ecosystem on PATH =="
|
echo "== bread ecosystem on PATH =="
|
||||||
for bin in bakery bread breadd bread-emit bread-module-host breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do
|
for bin in bakery bread breadd breadbar breadbox breadbox-sync breadcrumbs breadpad breadman; do
|
||||||
check "$bin found" "command -v $bin"
|
check "$bin found" "command -v $bin"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "== bos-settings =="
|
echo "== bos-settings =="
|
||||||
check "bos-settings installed" "command -v 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/<target>.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 =="
|
echo "== default dotfiles =="
|
||||||
check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]"
|
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 "mimeapps.list present" "[ -f \"\$HOME/.config/mimeapps.list\" ]"
|
||||||
check "kitty config present" "[ -f \"\$HOME/.config/kitty/kitty.conf\" ]"
|
check "kitty config present" "[ -f \"\$HOME/.config/kitty/kitty.conf\" ]"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue