diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml deleted file mode 100644 index 9bbb301..0000000 --- a/.forgejo/workflows/check.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: check - -# Fast-fail lint/test on short-lived work branches, before it ever reaches -# main and triggers a dev-track release build. -on: - push: - branches: ['feature/**', 'fix/**'] - -jobs: - check: - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: clippy - run: cd src && bash ci/build.sh cargo clippy --all-targets --locked -- -D warnings - - - name: test - run: cd src && bash ci/build.sh cargo test --locked diff --git a/.forgejo/workflows/dev-release.yml b/.forgejo/workflows/dev-release.yml deleted file mode 100644 index 291d765..0000000 --- a/.forgejo/workflows/dev-release.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: dev release - -# Publishes a dev-track build on every push to `main` (the trunk -# branch — there is no separate `dev` branch). See bread-ecosystem's -# docs/release-channels.md for the release-track policy this is part of. -on: - push: - branches: ['main'] - -jobs: - build: - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch main --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: build - run: cd src && bash ci/build.sh cargo build --release --locked - - - name: test - run: cd src && bash ci/build.sh cargo test --release --locked - - - name: compute dev version - run: | - set -euo pipefail - cd src - # Base the dev version off the latest published stable tag, - # not Cargo.toml — Cargo.toml can go stale relative to the last - # real release, which would make a dev build sort as OLDER than - # what's already installed and bakery would correctly refuse it. - LATEST_TAG="$(git ls-remote --tags --refs \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ - | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)" - if [ -n "${LATEST_TAG}" ]; then - CUR="${LATEST_TAG}" - else - CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" - fi - IFS='.' read -r MA MI PA <<< "${CUR}" - SHA="$(git rev-parse --short HEAD)" - TS="$(date -u +%Y%m%d%H%M%S)" - echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV" - - - name: prepare artifacts - run: | - set -euo pipefail - PKG_DIR="/srv/breadway-dl/dev/breadhelp/${VERSION}" - mkdir -p "${PKG_DIR}" - cp "src/target/release/breadhelp" "${PKG_DIR}/breadhelp-x86_64" - strip "${PKG_DIR}/breadhelp-x86_64" - sha256sum "${PKG_DIR}/breadhelp-x86_64" | awk '{print $1}' \ - > "${PKG_DIR}/breadhelp-x86_64.sha256" - cp src/packaging/breadhelp.desktop "${PKG_DIR}/" - cp src/LICENSE "${PKG_DIR}/" - tar czf "${PKG_DIR}/content.tar.gz" -C src content - cp src/bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadhelp/latest" - - # No GitHub Release upload — dev, like the other non-stable track, - # is only distributed via dl.breadway.dev/dev/. - - name: regenerate dev index.json - env: - MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} - run: | - set -euo pipefail - if [ -z "${MINISIGN_SEC_KEY:-}" ]; then - echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)" - exit 1 - fi - # Do not wipe /tmp/bread-ecosystem-ci-* — other products' ci/build.sh - # caches live there and a wipe between their build and test steps - # is a 127 (missing ci/build.sh). - ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-index-XXXXXX)" - git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" - TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" - rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..5721d93 --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -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 gtk4-layer-shell hicolor-icon-theme + 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="breadhelp-${VERSION}/" HEAD \ + > packaging/breadhelp-${VERSION}.tar.gz + SHA=$(sha256sum packaging/breadhelp-${VERSION}.tar.gz | awk '{print $1}') + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/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 && makepkg -f --noconfirm --nocheck" + PKG=$(find /home/builder/src/packaging -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.forgejo/workflows/rc-release.yml b/.forgejo/workflows/rc-release.yml deleted file mode 100644 index 2925ca0..0000000 --- a/.forgejo/workflows/rc-release.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: beta (rc) release - -# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag -# pushed to `main` — there is no separate `beta` branch; "freezing" is -# just pausing pushes to main while an RC gets tested. See -# bread-ecosystem's docs/release-channels.md for the release-track policy. -on: - push: - tags: ['v*'] - -jobs: - build: - if: ${{ contains(github.ref_name, '-rc.') }} - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: build - run: cd src && bash ci/build.sh cargo build --release --locked - - - name: test - run: cd src && cargo test --release --locked - - - name: prepare artifacts - run: | - set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="/srv/breadway-dl/beta/breadhelp/${VERSION}" - mkdir -p "${PKG_DIR}" - cp "src/target/release/breadhelp" "${PKG_DIR}/breadhelp-x86_64" - strip "${PKG_DIR}/breadhelp-x86_64" - sha256sum "${PKG_DIR}/breadhelp-x86_64" | awk '{print $1}' \ - > "${PKG_DIR}/breadhelp-x86_64.sha256" - cp src/packaging/breadhelp.desktop "${PKG_DIR}/" - cp src/LICENSE "${PKG_DIR}/" - tar czf "${PKG_DIR}/content.tar.gz" -C src content - cp src/bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadhelp/latest" - - # No GitHub Release upload — beta, like dev, is only distributed via - # dl.breadway.dev/beta/. - - name: regenerate beta index.json - env: - MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} - run: | - set -euo pipefail - if [ -z "${MINISIGN_SEC_KEY:-}" ]; then - echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)" - exit 1 - fi - rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true - # mktemp: a fixed clone path races when multiple repos' dev/beta - # workflows run close together on the same self-hosted runner. - ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" - TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" - rm -rf "${ECOSYSTEM_CI_DIR}" diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index f64fc79..0000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: release - -on: - push: - tags: ["v*"] - -jobs: - build: - if: ${{ !contains(github.ref_name, '-rc.') }} - runs-on: [self-hosted, hestia] - steps: - - name: checkout - run: | - set -euo pipefail - rm -rf src && mkdir src - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src - - - name: build - run: | - set -euo pipefail - if [ ! -f src/ci/build.sh ]; then - echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper" - exit 1 - fi - cd src && bash ci/build.sh cargo build --release --locked || { - echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked." - exit 1 - } - - - name: test - run: cd src && bash ci/build.sh cargo test --release --locked - - - name: prepare artifacts - run: | - set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="/srv/breadway-dl/breadhelp/${VERSION}" - mkdir -p "${PKG_DIR}" - cp "src/target/release/breadhelp" "${PKG_DIR}/breadhelp-x86_64" - strip "${PKG_DIR}/breadhelp-x86_64" - sha256sum "${PKG_DIR}/breadhelp-x86_64" | awk '{print $1}' \ - > "${PKG_DIR}/breadhelp-x86_64.sha256" - cp src/packaging/breadhelp.desktop "${PKG_DIR}/" - cp src/LICENSE "${PKG_DIR}/" - tar czf "${PKG_DIR}/content.tar.gz" -C src content - cp src/bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${VERSION}" "/srv/breadway-dl/breadhelp/latest" - - - name: regenerate index.json - env: - MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} - run: | - set -euo pipefail - if [ -z "${MINISIGN_SEC_KEY:-}" ]; then - echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)" - exit 1 - fi - rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true - ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" - git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" - bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh" - rm -rf "${ECOSYSTEM_CI_DIR}" - - - name: upload to GitHub Release - env: - GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} - run: | - set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" - PKG_DIR="/srv/breadway-dl/breadhelp/${VERSION}" - gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadhelp \ - --title "breadhelp v${VERSION}" --generate-notes 2>/dev/null || true - gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadhelp \ - "${PKG_DIR}/breadhelp-x86_64" \ - "${PKG_DIR}/breadhelp-x86_64.sha256" \ - --clobber diff --git a/.gitignore b/.gitignore index 14a0220..45cd856 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,6 @@ /target/ **/*.pdb -# makepkg local build artifacts -packaging/pkg/ -packaging/src/ -packaging/*.tar.gz -packaging/*.pkg.tar.zst - # Editor / IDE .vscode/ .idea/ @@ -38,8 +32,3 @@ logs/ # Claude Code local agent state .claude/ - -# Local hygiene notes (not for commit) - -# graphify knowledge-graph output (local tool cache, not for commit) -graphify-out/ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index ee1101f..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,23 +0,0 @@ -# AGENTS.md — Repo hygiene - -Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. - -## Identity -- breadhelp is a **bakery** product (`bakery.toml` + bakery CI + BOS ISO bake). It is not pacman-packaged. - -## Branch model -- One long-lived branch: **`main`**. There is no separate `dev` or `beta` branch — those are bakery tracks, published from `main` and from tags. Follow `CONTRIBUTING.md`. -- Short-lived work: `feature/` or `fix/` off `main`. - -## Remotes -- `origin` — Forgejo on Hestia only (`ssh://git@100.66.238.26:2222/Breadway/breadhelp.git`), via Tailscale. No GitHub mirror, unlike most bread-ecosystem repos. - -## CI -- `.forgejo/workflows/dev-release.yml` — push to `main` (bakery dev track). -- `.forgejo/workflows/rc-release.yml` — `vX.Y.Z-rc.N` tags (bakery beta track). -- `.forgejo/workflows/release.yml` — other `v*` tags (signed bakery stable). -- There is no `package.yml` / pacman publish workflow. - -## Don't -- Don't embed credentials in remote URLs — this repo already uses SSH, keep it that way. -- Don't start a Tauri port — this stays GTK4. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 2a6508e..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,84 +0,0 @@ -# Contributing - -`breadhelp` — Onboarding and help center for Bread OS. - -Part of the bread ecosystem; this repo follows the same branch/release -workflow as every other ecosystem product. - -## Branches - -There is one long-lived branch: **`main`**. All day-to-day work lands here. -Every push to `main` automatically builds and publishes a **dev-track** -build (see Tracks below) — a real install you can test before cutting -anything more formal. - -New work — features and bug fixes alike — goes on a short-lived branch: - -``` -feature/ -fix/ -``` - -Branch off `main`, open a PR/push back into `main` when ready. Short-lived -branches get deleted on merge — they never accumulate the kind of drift a -second long-lived branch does. - -## The release cycle - -There's no separate `beta` or release branch — "stable" and "beta" are both -just **tags** on `main`, not branches that need to be kept in sync: - -1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push - auto-publishes a dev build — install it with `bakery track set dev` and - `bakery update --all`, then fix anything broken with another push. -2. When you want to stabilize before a real release, tag a release - candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to - both remotes). That tag alone triggers a beta-track build — - "freezing" is just pausing pushes to `main` while you test it, not a - branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes. -3. Once an RC has gone without issues, tag the real release: - `git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the - signed stable release build. - -## Tracks, from a user's perspective - -``` -bakery track show # what you're currently on (defaults to stable) -bakery track set dev # or beta, or stable -bakery update --all # pull the latest build on your current track -``` - -| Track | What it is | Published from | -|--------|-----------|-----------------| -| `stable` | The last tagged release | a `vX.Y.Z` tag | -| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag | -| `dev` | Bleeding edge | `main`, on every push | - -Dev versions are auto-computed (`X.Y.Z-dev.+`) from the -latest published stable tag, so they always sort as newer than what you -have installed — no manual version bumping needed. Beta versions are just -the RC tag itself (already valid semver, already sorts below the real -release it's a candidate for). - -## Local development - -```sh -cargo build --release -cargo test --release -``` - -## CI - -- `dev-release.yml` — triggered on push to `main`. -- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push. -- `release.yml` — triggered on any other `v*` tag push, cuts the actual - stable release. - -All CI runs on a self-hosted runner; nothing runs automatically on plain -commits or PRs beyond the track builds above. See -[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md) -for the full policy, including how a new product gets wired onto these tracks. - -## Questions - -Open an issue on this repo's Forgejo tracker. diff --git a/Cargo.lock b/Cargo.lock index 16c4fd9..ed110bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - [[package]] name = "async-channel" version = "2.5.0" @@ -28,35 +22,14 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "bread-screenshots" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" -dependencies = [ - "anyhow", - "bread-utils", - "tracing", -] - -[[package]] -name = "bread-shared" -version = "0.7.0" -source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" -dependencies = [ - "dirs", - "serde", - "serde_json", - "toml 0.8.23", -] +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bread-theme" -version = "0.7.4" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e" +version = "0.2.3" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" dependencies = [ "dirs", "gtk4", @@ -64,27 +37,12 @@ dependencies = [ "serde_json", ] -[[package]] -name = "bread-utils" -version = "0.7.2" -source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73" -dependencies = [ - "bread-shared", - "dirs", - "serde", - "serde_json", - "toml_edit 0.22.27", -] - [[package]] name = "breadhelp" -version = "0.2.6" +version = "0.2.0" dependencies = [ - "anyhow", "async-channel", - "bread-screenshots", "bread-theme", - "bread-utils", "gdk4", "glib", "gtk4", @@ -115,7 +73,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" dependencies = [ "glib-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -178,10 +136,11 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "event-listener" -version = "5.4.2" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ + "concurrent-queue", "parking", "pin-project-lite", ] @@ -208,24 +167,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -234,32 +193,32 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-macro", @@ -290,7 +249,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -323,7 +282,7 @@ dependencies = [ "libc", "pango-sys", "pkg-config", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -363,7 +322,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 7.0.8", + "system-deps", "windows-sys 0.61.2", ] @@ -417,7 +376,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -427,7 +386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -438,7 +397,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c" dependencies = [ "glib-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -459,7 +418,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -490,7 +449,7 @@ dependencies = [ "graphene-sys", "libc", "pango-sys", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -516,9 +475,9 @@ dependencies = [ [[package]] name = "gtk4-layer-shell" -version = "0.8.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" dependencies = [ "bitflags", "gdk4", @@ -531,15 +490,15 @@ dependencies = [ [[package]] name = "gtk4-layer-shell-sys" -version = "0.6.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" dependencies = [ "gdk4-sys", "glib-sys", "gtk4-sys", "libc", - "system-deps 8.0.0", + "system-deps", ] [[package]] @@ -551,7 +510,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -570,7 +529,7 @@ dependencies = [ "gsk4-sys", "libc", "pango-sys", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -609,15 +568,15 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -643,12 +602,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - [[package]] name = "option-ext" version = "0.2.0" @@ -675,7 +628,7 @@ dependencies = [ "glib-sys", "gobject-sys", "libc", - "system-deps 7.0.8", + "system-deps", ] [[package]] @@ -692,9 +645,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "proc-macro-crate" @@ -707,18 +660,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.47" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -751,9 +704,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -761,29 +714,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -833,17 +786,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "system-deps" version = "7.0.8" @@ -853,20 +795,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml 1.1.4+spec-1.1.0", - "version-compare", -] - -[[package]] -name = "system-deps" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3" -dependencies = [ - "cfg-expr", - "heck", - "pkg-config", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "version-compare", ] @@ -893,7 +822,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -910,9 +839,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", @@ -969,9 +898,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow 1.0.4", ] @@ -988,37 +917,6 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - [[package]] name = "unicode-ident" version = "1.0.24" @@ -1138,9 +1036,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.29" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index c93d5c2..d63710a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadhelp" -version = "0.2.6" +version = "0.2.0" edition = "2021" [dependencies] @@ -10,14 +10,10 @@ gtk4-layer-shell = "0.8" glib = "0.22" # Shared ecosystem theming — same generated stylesheet bos-settings/breadbar/ # breadbox/breadpad load, so this looks like part of the same desktop. -bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" # Non-destructive state editing (mirrors bos-settings/src/config/mod.rs). toml_edit = "0.22" async-channel = "2" -bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml", "bread-client"] } -# Capture primitives for `--screenshot` mode — see src/screenshot.rs. -bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" } -anyhow = "1" diff --git a/EVENTS.md b/EVENTS.md deleted file mode 100644 index bfce9b9..0000000 --- a/EVENTS.md +++ /dev/null @@ -1,68 +0,0 @@ -# breadhelp — bread event integration - -breadhelp is a standalone GTK help center: it works exactly the same with -or without `breadd` running. When breadd *is* present, it publishes one -event after the main window is actually shown. See the parent `bread` -repo's `Documentation.md` — specifically its "Namespaces" and -"Integrating a bread\* app" sections — for the general convention this -follows. - -App id: **`help`**. Transport: `bread-utils`'s `bread_client` module -(feature `bread-client`) — breadhelp links it directly. Each `emit` is -its own short-lived connection. Command verbs are only received while -`breadhelp listen` is running — that process holds the -`bread.command.help.**` subscription open. - -## Events published (`bread.help.*`) - -| Event | Data | When | -|-------|------|------| -| `bread.help.opened` | `{ "autostart": bool }` | The main help window is presented (`ApplicationWindow::present`). `autostart` is `true` when that invocation was launched with `--autostart`. | -| `bread.help.open.done` | `{}` | `bread.command.help.open` was received and `breadhelp` was spawned. This is the command confirmation, not proof the window mapped — the spawned process is the same no-args invocation as SUPER+/. | -| `bread.help.open.failed` | `{ "error": "" }` | `bread.command.help.open` was received but this binary could not be started. | - -Not emitted when: - -- every-login `--autostart` builds a hidden window because onboarding is - already done (silent autostart) -- first-run `--autostart` presents the main window on the day-zero - wizard; the tour overlay starts after that wizard finishes (or is - skipped). `bread.help.opened` **is** emitted for that first-run present. -- `--onboard` / `--tour-event` (tour only) -- `--screenshot` (capture, not a user-visible open) - -## Commands honored (`bread.command.help.*`) - -These are only received while `breadhelp listen` is running. Publishing a -command with no subscriber is a silent no-op — that is the documented -bread convention, not a breadhelp bug. - -| Verb | Data | Effect | -|------|------|--------| -| `open` | none | Same as running `breadhelp` with no flags: present the main help window (GApplication forwards to an already-running primary instance). Emits `bread.help.open.done` / `.failed`. | - -```lua -bread.spawn(function() - bread.emit("bread.command.help.open") - bread.wait("bread.help.open.done", { timeout = 5000 }) -end) -``` - -### Not implemented: extra verbs - -There is no `onboard` / `tour` / `suggest` command verb. Those already -exist as local CLI flags (`--onboard`, `--tour-event`, `--suggest`). -If/when a bus verb maps to real extra behavior, add it then — do not -stub one as a no-op ahead of it. - -## Fail-safe behavior - -- If breadd isn't installed or isn't running, `emit` is a silent no-op - (`BreadClient::emit` never blocks or errors the caller) and the - command subscription simply never receives anything — the help - center, tour, and screenshots are entirely unaffected. -- If breadd restarts, the command subscription reconnects automatically - (`BreadClient::subscribe`'s background thread has its own backoff - loop); no restart of `breadhelp listen` is needed. -- If `breadhelp listen` is not running, commands are a graceful no-op at - the bus (no subscriber). The CLI still works. diff --git a/README.md b/README.md index 24156d7..0938dd0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # breadhelp -Onboarding and help center for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — GTK4, a searchable guide library, an interactive keybind viewer, a troubleshooting wizard with one-click fixes, a short first-boot day-zero wizard (timezone, network hint, updates, snapper), and a live guided tour overlay that spotlights real on-screen bread\* apps (breadbar, breadbox, ...) instead of walking through a static wizard. +Onboarding and help center for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — GTK4, a searchable guide library, an interactive keybind viewer, a troubleshooting wizard with one-click fixes, and a live guided tour overlay that spotlights real on-screen bread\* apps (breadbar, breadbox, ...) instead of walking through a static wizard. -Split out of the `bos` repo into its own repo so a breadhelp release doesn't require a BOS ISO release, and vice versa. A [bakery](https://git.breadway.dev/Breadway/bread-ecosystem) product (`bakery.toml` + bakery CI) and baked into the BOS ISO — not pacman-packaged. +Split out of the `bos` repo into its own repo so a breadhelp release doesn't require a BOS ISO release, and vice versa. Pacman-packaged (not bakery-managed) — see `packaging/README.md`. ## Building @@ -12,9 +12,4 @@ cargo build --release ## Packaging / releasing -See `CONTRIBUTING.md` for the single-trunk + tag model. `main` publishes a bakery **dev** build; a `vX.Y.Z-rc.N` tag publishes **beta**; a `vX.Y.Z` tag publishes **stable**. The leftover `packaging/` tree is not a pacman source — bakery is canonical (see `packaging/README.md`). - -## bread event integration - -Works the same with or without `breadd`. When the main window is shown, -breadhelp publishes `bread.help.opened`. See [EVENTS.md](EVENTS.md). +See `packaging/README.md`. In short: bump `Cargo.toml`'s version, tag `vX.Y.Z`, push the tag to `forgejo` — `.forgejo/workflows/package.yml` builds and publishes to the `[breadway]` pacman repo automatically. diff --git a/bakery.toml b/bakery.toml deleted file mode 100644 index 69fd764..0000000 --- a/bakery.toml +++ /dev/null @@ -1,12 +0,0 @@ -name = "breadhelp" -description = "Onboarding and help center for Bread OS" -binaries = ["breadhelp"] -system_deps = ["gtk4", "glib2", "gtk4-layer-shell", "hicolor-icon-theme"] -optional_system_deps = ["snapper"] -bread_deps = [] -license_file = "LICENSE" -desktop_file = "breadhelp.desktop" -data_archive = "content.tar.gz" - -[install] -post_install = [] diff --git a/ci/bread-ecosystem.rev b/ci/bread-ecosystem.rev deleted file mode 100644 index 34e7aa9..0000000 --- a/ci/bread-ecosystem.rev +++ /dev/null @@ -1 +0,0 @@ -147cfbbf96ae4b171027defa1130d2caddb934b1 diff --git a/ci/build.sh b/ci/build.sh deleted file mode 100755 index 63262e8..0000000 --- a/ci/build.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -# Delegates to bread-ecosystem's shared CI build image/script, pinned to -# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI -# files now affect every product's release pipeline, so bumping the pin -# is a deliberate act instead of silent drift (see the bread-theme test -# that broke here for exactly that reason, before it was pinned by rev). -# -# Usage: ci/build.sh cargo build --release --locked -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" - -CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" -if [ ! -f "$CACHE_DIR/ci/build.sh" ]; then - rm -rf "$CACHE_DIR" - git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR" - git -C "$CACHE_DIR" checkout --quiet "$REV" -fi - -bash "${CACHE_DIR}/ci/build.sh" breadhelp "$ROOT" "$@" diff --git a/content/daily-use/01-searching-your-files/content.md b/content/daily-use/01-searching-your-files/content.md index 2dad088..b0ee950 100644 --- a/content/daily-use/01-searching-your-files/content.md +++ b/content/daily-use/01-searching-your-files/content.md @@ -4,6 +4,6 @@ breadsearch indexes your files so you can find things instantly, without digging through folders. - Open it from BOS Settings, or run it directly. -- Describe what you are looking for in plain language — breadsearch ranks files by meaning, not by letters in a filename. Results update as you type. +- Type a few letters of a filename — results update as you type. - [Run]breadsearch|Open File Search[/Run] diff --git a/content/getting-started/02-essential-keybinds/content.md b/content/getting-started/02-essential-keybinds/content.md index 132bd70..b5955ba 100644 --- a/content/getting-started/02-essential-keybinds/content.md +++ b/content/getting-started/02-essential-keybinds/content.md @@ -13,17 +13,9 @@ nothing here is mandatory either, click around if you'd rather. ## Apps - [Show Keybind]super + space[/Show Keybind] opens the launcher (breadbox) -- [Show Keybind]super + e[/Show Keybind] opens files (nautilus) +- [Show Keybind]super + e[/Show Keybind] opens files - [Show Keybind]super + b[/Show Keybind] opens the browser -- [Show Keybind]super + u[/Show Keybind] opens notes (breadpad) -- [Show Keybind]super + ,[/Show Keybind] opens BOS Settings -- [Show Keybind]super + l[/Show Keybind] locks the screen - -## Screenshots - -- [Show Keybind]super + shift + s[/Show Keybind] captures a region to a file (breadshot) -- [Show Keybind]super + shift + c[/Show Keybind] captures a region to the clipboard (breadshot) -- [Show Keybind]super + shift + p[/Show Keybind] captures the active screen to a file (breadshot) +- [Show Keybind]super + u[/Show Keybind] opens notes ## The full list diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD new file mode 100644 index 0000000..8fc985a --- /dev/null +++ b/packaging/PKGBUILD @@ -0,0 +1,40 @@ +# Maintainer: Breadway + +pkgname=breadhelp +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Onboarding and help center for Bread OS" +arch=('x86_64') +url="https://git.breadway.dev/Breadway/breadhelp" +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' 'gtk4-layer-shell' 'hicolor-icon-theme') +optdepends=( + 'snapper: create-backup one-click fix' +) +makedepends=('rust' 'cargo') +source=("${pkgname}-${pkgver}.tar.gz") +sha256sums=('SKIP') + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo build --release --locked +} + +check() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo test --release --locked +} + +package() { + cd "${srcdir}/${pkgname}-${pkgver}" + install -Dm755 target/release/breadhelp "${pkgdir}/usr/bin/breadhelp" + install -Dm644 packaging/breadhelp.desktop \ + "${pkgdir}/usr/share/applications/breadhelp.desktop" + install -d "${pkgdir}/usr/share/breadhelp" + cp -r content "${pkgdir}/usr/share/breadhelp/content" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/packaging/README.md b/packaging/README.md index 2c69413..8573435 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,9 +1,26 @@ -# packaging/ +Arch packaging +============== -Leftover from the pacman era. breadhelp is bakery-managed now -(`bakery.toml` plus `.forgejo/workflows/{dev-release,rc-release,release}.yml`) -and is baked into the BOS ISO. +`PKGBUILD` builds and installs `breadhelp` from source. -`breadhelp.desktop` is still live: bakery CI copies it into the published -artifact. There is no PKGBUILD and no `package.yml` — do not treat this -directory as a pacman package source. +## Local build + +```bash +makepkg -si +``` + +## Before publishing to [breadway] repo + +1. Tag a release on GitHub. +2. Update `pkgver` to match the tag. +3. Update `source` to the release tarball URL. +4. Run `updpkgsums` (or manually set `sha256sums`). + +## Runtime dependencies + +| Package | Required | Notes | +|---------|----------|-------| +| `gtk4` | yes | UI toolkit | +| `glib2` | yes | always | +| `gtk4-layer-shell` | yes | tour overlay (dim mask + spotlight callout) | +| `snapper` | optional | create-backup one-click fix | diff --git a/src/cli.rs b/src/cli.rs index 62df4bc..bb35833 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,10 +4,7 @@ //! `connect_command_line`, so `--onboard` on a second launch re-triggers the //! tour in the existing window instead of spawning a duplicate. -use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH}; -use std::path::Path; - -#[derive(Clone)] +#[derive(Clone, Default)] pub struct Action { /// Force-restart the onboarding tour from step 0, regardless of whether /// it was already completed or in progress. @@ -17,8 +14,7 @@ pub struct Action { /// `present()` build the window (so the app is ready to respond the /// instant it's needed) without popping it open when onboarding is /// already done — autostart should only be visible on a genuine first - /// run (day-zero wizard, then the desktop tour), never on every - /// subsequent login. + /// run, never on every subsequent login. pub autostart: bool, /// From a breadd Lua module (e.g. `breadhelp-suggest.lua`) reacting to a /// system event. Resolved to banner text by `services::breadd::resolve`. @@ -29,52 +25,6 @@ pub struct Action { /// Only acted on if a tour is currently waiting for this exact id — /// see the crash-safety note on `ui::tour`. pub tour_event: Option, - /// Render the named tab, capture it, then exit instead of running - /// normally. Known views: "home", "learn", "ask". See `crate::screenshot`. - pub screenshot: Option, - /// PNG path to write the capture to. Required together with `screenshot`. - pub output: Option, - /// Capture canvas width — matches the isolated compositor's output - /// width (`bread-capture --isolate-width`). - pub width: u32, - /// Capture canvas height — see `width`. - pub height: u32, -} - -impl Default for Action { - fn default() -> Self { - Self { - force_onboard: false, - autostart: false, - suggest: None, - tour_event: None, - screenshot: None, - output: None, - width: DEFAULT_WIDTH, - height: DEFAULT_HEIGHT, - } - } -} - -impl Action { - /// `None` for a normal run. Exits the process with an error if the - /// `--screenshot` / `--output` pair is incomplete, before any GTK setup - /// happens. - pub fn screenshot_request(&self) -> Option { - if let Err(e) = validate_pair( - self.screenshot.as_deref(), - self.output.as_deref().map(Path::new), - ) { - eprintln!("breadhelp: {e}"); - std::process::exit(1); - } - Some(crate::screenshot::ScreenshotRequest { - view: self.screenshot.clone()?, - output: self.output.clone()?.into(), - width: self.width, - height: self.height, - }) - } } pub fn parse(args: &[std::ffi::OsString]) -> Action { @@ -89,18 +39,6 @@ pub fn parse(args: &[std::ffi::OsString]) -> Action { action.suggest = it.next().and_then(|s| s.to_str()).map(str::to_string); } else if arg == "--tour-event" { action.tour_event = it.next().and_then(|s| s.to_str()).map(str::to_string); - } else if arg == "--screenshot" { - action.screenshot = it.next().and_then(|s| s.to_str()).map(str::to_string); - } else if arg == "--output" { - action.output = it.next().and_then(|s| s.to_str()).map(str::to_string); - } else if arg == "--width" { - if let Some(v) = it.next().and_then(|s| s.to_str()).and_then(|s| s.parse().ok()) { - action.width = v; - } - } else if arg == "--height" { - if let Some(v) = it.next().and_then(|s| s.to_str()).and_then(|s| s.parse().ok()) { - action.height = v; - } } } action diff --git a/src/config.rs b/src/config.rs index d609c8c..acdca4a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,16 +1,20 @@ -//! `~/.config/breadhelp/state.toml` — non-destructive TOML editing via -//! `bread_utils::tomlcfg`, the same `load_doc`/`save_doc` discipline -//! `bos-settings/src/config/mod.rs` uses: a missing file yields defaults, a -//! file that exists but fails to parse is backed up once before falling -//! back, so a bad edit is always recoverable. This module used to carry its -//! own byte-for-byte copy of that logic (introduced in the same fix pass -//! that added it to bos-settings) — now both share one implementation. +//! `~/.config/breadhelp/state.toml` — non-destructive TOML editing, same +//! `load_doc`/`save_doc` discipline as `bos-settings/src/config/mod.rs`: a +//! missing file yields defaults, a file that exists but fails to parse is +//! backed up once before falling back, so a bad edit is always recoverable. use std::path::{Path, PathBuf}; use toml_edit::{value, DocumentMut}; pub fn config_dir() -> PathBuf { - bread_utils::xdg::config_home() + 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") } fn state_path() -> PathBuf { @@ -18,11 +22,32 @@ fn state_path() -> PathBuf { } fn load_doc(path: &Path) -> DocumentMut { - bread_utils::tomlcfg::load_doc("breadhelp", path) + let Ok(text) = std::fs::read_to_string(path) else { + return DocumentMut::default(); + }; + match text.parse::() { + Ok(doc) => doc, + Err(e) => { + let backup = PathBuf::from(format!("{}.bak", path.display())); + eprintln!( + "breadhelp: {} failed to parse ({e}); backed up to {} before falling back to defaults", + path.display(), + backup.display() + ); + let _ = std::fs::write(&backup, &text); + DocumentMut::default() + } + } } fn save_doc(path: &Path, doc: &DocumentMut) { - if let Err(e) = bread_utils::tomlcfg::save_doc(path, doc) { + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!("breadhelp: couldn't create {}: {e}", parent.display()); + return; + } + } + if let Err(e) = std::fs::write(path, doc.to_string()) { eprintln!("breadhelp: couldn't write {}: {e}", path.display()); } } @@ -86,11 +111,7 @@ impl State { } pub fn onboarding_step(&self) -> i64 { - self.doc - .get("onboarding") - .and_then(|t| t.get("step")) - .and_then(|v| v.as_integer()) - .unwrap_or(0) + self.doc.get("onboarding").and_then(|t| t.get("step")).and_then(|v| v.as_integer()).unwrap_or(0) } pub fn set_onboarding_step(&mut self, step: i64) { @@ -98,48 +119,6 @@ impl State { self.save(); } - /// First-boot day-zero wizard (timezone / network hint / updates / - /// snapshot). Independent of the live desktop tour so an existing user - /// who already finished onboarding is never pulled back into setup. - pub fn day_zero_completed(&self) -> bool { - self.doc - .get("day_zero") - .and_then(|t| t.get("completed")) - .and_then(|v| v.as_bool()) - .unwrap_or(false) - } - - pub fn set_day_zero_completed(&mut self, completed: bool) { - self.doc["day_zero"]["completed"] = value(completed); - self.save(); - } - - pub fn day_zero_step(&self) -> i64 { - self.doc - .get("day_zero") - .and_then(|t| t.get("step")) - .and_then(|v| v.as_integer()) - .unwrap_or(0) - } - - pub fn set_day_zero_step(&mut self, step: i64) { - self.doc["day_zero"]["step"] = value(step); - self.save(); - } - - pub fn day_zero_snapshot_attempted(&self) -> bool { - self.doc - .get("day_zero") - .and_then(|t| t.get("snapshot_attempted")) - .and_then(|v| v.as_bool()) - .unwrap_or(false) - } - - pub fn set_day_zero_snapshot_attempted(&mut self, attempted: bool) { - self.doc["day_zero"]["snapshot_attempted"] = value(attempted); - self.save(); - } - /// Set the instant before `services::hyprland::rebind_temp` is called for /// a tour step with no compositor-observable signal (e.g. the screenshot /// step), cleared the instant after reverting. If breadhelp is killed @@ -147,18 +126,8 @@ impl State { /// pointing at a chained `--tour-event` ping and self-heals it before /// the user can be surprised by a stray tour popup — see `ui::tour`. pub fn pending_rebind(&self) -> Option<(String, String)> { - let key = self - .doc - .get("tour")? - .get("pending_rebind_key")? - .as_str()? - .to_string(); - let original = self - .doc - .get("tour")? - .get("pending_rebind_original")? - .as_str()? - .to_string(); + let key = self.doc.get("tour")?.get("pending_rebind_key")?.as_str()?.to_string(); + let original = self.doc.get("tour")?.get("pending_rebind_original")?.as_str()?.to_string(); Some((key, original)) } @@ -177,12 +146,7 @@ impl State { } pub fn mode(&self) -> Mode { - let s = self - .doc - .get("general") - .and_then(|t| t.get("mode")) - .and_then(|v| v.as_str()) - .unwrap_or("normal"); + let s = self.doc.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()).unwrap_or("normal"); Mode::from_str(s) } @@ -195,110 +159,3 @@ impl State { save_doc(&self.path, &self.doc); } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn state_toml_write_backs_up_previous_contents_and_no_tmp_file_left_behind() { - // Exercises the same bread_utils::atomic::write_atomic_backed_up - // path save_doc uses (via tomlcfg::save_doc), at the file-layout - // breadhelp actually writes to — the deeper backup/no-leftover-tmp - // behavior itself is covered by bread-utils' own test suite. - let dir = std::env::temp_dir().join(format!( - "breadhelp-atomic-write-test-{}", - std::process::id() - )); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("state.toml"); - let backup = dir.join("state.toml.bak"); - - bread_utils::atomic::write_atomic_backed_up(&path, "first").unwrap(); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "first"); - assert!( - !backup.exists(), - "no backup should be made when there's nothing to back up yet" - ); - - bread_utils::atomic::write_atomic_backed_up(&path, "second").unwrap(); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "second"); - assert_eq!(std::fs::read_to_string(&backup).unwrap(), "first"); - - let leftover_tmp: Vec<_> = std::fs::read_dir(&dir) - .unwrap() - .filter_map(|e| e.ok()) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .filter(|n| n.contains(".tmp.")) - .collect(); - assert!( - leftover_tmp.is_empty(), - "temp file should be renamed away, not left behind: {leftover_tmp:?}" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn save_doc_then_load_doc_round_trips_state() { - let dir = std::env::temp_dir().join(format!( - "breadhelp-state-roundtrip-test-{}", - std::process::id() - )); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("state.toml"); - - let mut doc = DocumentMut::default(); - doc["onboarding"]["completed"] = value(true); - doc["general"]["mode"] = value("dad"); - save_doc(&path, &doc); - - let loaded = load_doc(&path); - assert_eq!( - loaded - .get("onboarding") - .and_then(|t| t.get("completed")) - .and_then(|v| v.as_bool()), - Some(true) - ); - assert_eq!( - loaded - .get("general") - .and_then(|t| t.get("mode")) - .and_then(|v| v.as_str()), - Some("dad") - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn day_zero_defaults_incomplete_and_round_trips() { - let dir = - std::env::temp_dir().join(format!("breadhelp-day-zero-state-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("state.toml"); - - let mut state = State { - doc: DocumentMut::default(), - path: path.clone(), - }; - assert!(!state.day_zero_completed()); - assert_eq!(state.day_zero_step(), 0); - assert!(!state.day_zero_snapshot_attempted()); - - state.set_day_zero_step(3); - state.set_day_zero_snapshot_attempted(true); - state.set_day_zero_completed(true); - - let loaded = State { - doc: load_doc(&path), - path, - }; - assert!(loaded.day_zero_completed()); - assert_eq!(loaded.day_zero_step(), 3); - assert!(loaded.day_zero_snapshot_attempted()); - - let _ = std::fs::remove_dir_all(&dir); - } -} diff --git a/src/content/meta.rs b/src/content/meta.rs index 364ca0a..905ce02 100644 --- a/src/content/meta.rs +++ b/src/content/meta.rs @@ -12,23 +12,14 @@ pub enum Difficulty { #[derive(serde::Deserialize, Clone, Default)] pub struct GuideMeta { pub title: String, - /// Not read by any UI yet — reserved for a future difficulty badge. - /// Real content already sets it, so kept rather than dropped. - #[allow(dead_code)] #[serde(default)] pub difficulty: Difficulty, #[serde(default)] pub tags: Vec, /// Guide dir-names in the same or another category. Unresolvable entries /// are silently dropped when rendering "related guides" — never a crash. - /// Not read by any UI yet — the "related guides" render path doesn't - /// exist. Real content already sets it, so kept rather than dropped. - #[allow(dead_code)] #[serde(default)] pub related: Vec, - /// Not read by any UI yet — reserved for a future "N min read" hint. - /// Real content already sets it, so kept rather than dropped. - #[allow(dead_code)] #[serde(default)] pub estimated_minutes: u32, /// Overrides the parent directory name as the guide's category, if set. diff --git a/src/content/mod.rs b/src/content/mod.rs index 520b5f0..814c6c9 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -41,7 +41,7 @@ pub struct ContentStore { guides: Vec, } -pub(crate) fn user_content_root() -> PathBuf { +fn user_content_root() -> PathBuf { if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { let p = PathBuf::from(xdg); if p.is_absolute() { diff --git a/src/content/tour.rs b/src/content/tour.rs index 6bee9af..d49709f 100644 --- a/src/content/tour.rs +++ b/src/content/tour.rs @@ -29,10 +29,6 @@ pub enum Success { #[derive(serde::Deserialize, Clone)] pub struct Step { - /// Not read by any Rust code yet — steps are matched by index, not id. - /// Real tour content already sets it (content authors use it to keep - /// track of steps), so kept rather than dropped. - #[allow(dead_code)] pub id: String, pub title: String, pub body: String, @@ -90,26 +86,15 @@ struct TourFile { } const SYSTEM_TOUR_PATH: &str = "/usr/share/breadhelp/content/tours/onboarding.toml"; -const TOUR_SUBPATH: &str = "tours/onboarding.toml"; -/// Checks the user content root first (where bakery installs content — -/// see `content::user_content_root`), falling back to the system path a -/// pacman package would have used. Same "user copy wins" precedent as -/// `ContentStore::load`. pub fn load() -> Vec { - let user_path = super::user_content_root().join(TOUR_SUBPATH); - let path = if user_path.exists() { - user_path - } else { - Path::new(SYSTEM_TOUR_PATH).to_path_buf() - }; - let Ok(text) = std::fs::read_to_string(&path) else { + let Ok(text) = std::fs::read_to_string(Path::new(SYSTEM_TOUR_PATH)) else { return Vec::new(); }; match toml::from_str::(&text) { Ok(f) => f.steps, Err(e) => { - eprintln!("breadhelp: {} failed to parse: {e}", path.display()); + eprintln!("breadhelp: {SYSTEM_TOUR_PATH} failed to parse: {e}"); Vec::new() } } diff --git a/src/content/troubleshoot.rs b/src/content/troubleshoot.rs index 948826e..3613f84 100644 --- a/src/content/troubleshoot.rs +++ b/src/content/troubleshoot.rs @@ -15,11 +15,6 @@ pub struct SymptomOption { pub struct Fix { pub description: String, pub command: String, - /// Not read by `ui::troubleshoot_wizard` yet — the "Run fix" button - /// currently executes unconditionally regardless of this flag. Real - /// symptom content already sets it, so kept rather than dropped; the - /// wizard should gate on it before this is treated as safe. - #[allow(dead_code)] #[serde(default)] pub requires_confirm: bool, } @@ -41,11 +36,13 @@ struct SymptomFile { } const SYSTEM_SYMPTOMS_DIR: &str = "/usr/share/breadhelp/content/troubleshooting/_symptoms"; -const SYMPTOMS_SUBPATH: &str = "troubleshooting/_symptoms"; -fn scan_symptoms_dir(dir: &Path, out: &mut std::collections::HashMap>) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; +/// Loads every `*.toml` file in the symptoms directory, keyed by file stem so +/// a `SymptomOption::goto` of `"no-sound:check-mute"` can be resolved. +pub fn load_all() -> std::collections::HashMap> { + let mut out = std::collections::HashMap::new(); + let Ok(entries) = std::fs::read_dir(Path::new(SYSTEM_SYMPTOMS_DIR)) else { + return out; }; for entry in entries.flatten() { let path = entry.path(); @@ -65,16 +62,5 @@ fn scan_symptoms_dir(dir: &Path, out: &mut std::collections::HashMap eprintln!("breadhelp: {} failed to parse: {e}", path.display()), } } -} - -/// Loads every `*.toml` file in the symptoms directory, keyed by file stem so -/// a `SymptomOption::goto` of `"no-sound:check-mute"` can be resolved. Scans -/// the system path (a pacman package's location) then the user content root -/// (where bakery installs content — see `content::user_content_root`) — -/// same "user copy wins on collision" precedent as `ContentStore::load`. -pub fn load_all() -> std::collections::HashMap> { - let mut out = std::collections::HashMap::new(); - scan_symptoms_dir(Path::new(SYSTEM_SYMPTOMS_DIR), &mut out); - scan_symptoms_dir(&super::user_content_root().join(SYMPTOMS_SUBPATH), &mut out); out } diff --git a/src/listen.rs b/src/listen.rs deleted file mode 100644 index 45f8c7e..0000000 --- a/src/listen.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Long-running command subscription for `bread.command.help.*`. -//! -//! `breadhelp` is still a GTK help center by default. `breadhelp listen` is -//! the optional persistent process that can honor bus commands. See -//! `EVENTS.md`. - -use bread_utils::bread_client::{BreadClient, BreadEvent}; - -use crate::services::breadd::APP_ID; - -/// Subscribe to `bread.command.help.**` and block until the process is killed. -/// -/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects -/// with backoff, and `on_event` simply isn't called until the daemon is up. -pub fn run() { - let client = BreadClient::connect(APP_ID); - if client.health().is_none() { - eprintln!( - "breadhelp: breadd unreachable; command subscription will connect when it comes back" - ); - } - - let _commands = client.subscribe("bread.command.help.**", |event| { - handle_command(&event); - }); - - eprintln!("breadhelp: listening for bread.command.help.**"); - loop { - std::thread::park(); - } -} - -/// Reacts to `bread.command.help.*` verbs. Only `open` is honored today — -/// other verbs are ignored, not stubbed as no-ops that pretend to succeed. -fn handle_command(event: &BreadEvent) { - let Some(verb) = command_verb(&event.event) else { - return; - }; - match verb { - "open" => handle_open(), - other => { - eprintln!("breadhelp: ignoring unrecognized bread.command.help.{other}"); - } - } -} - -fn handle_open() { - // Same as running `breadhelp` with no flags: GApplication presents the - // main window (or forwards to the already-running primary instance). - let result = spawn_self(); - let client = BreadClient::connect(APP_ID); - match result { - Ok(_) => client.emit("bread.help.open.done", serde_json::json!({})), - Err(e) => { - eprintln!("breadhelp: bread.command.help.open failed: {e}"); - client.emit( - "bread.help.open.failed", - serde_json::json!({ "error": e.to_string() }), - ); - } - } -} - -fn spawn_self() -> std::io::Result { - let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadhelp")); - std::process::Command::new(exe).spawn() -} - -fn command_verb(event_name: &str) -> Option<&str> { - event_name.strip_prefix("bread.command.help.") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn command_verb_strips_help_prefix() { - assert_eq!(command_verb("bread.command.help.open"), Some("open")); - assert_eq!(command_verb("bread.command.help.onboard"), Some("onboard")); - assert_eq!(command_verb("bread.command.box.open"), None); - assert_eq!(command_verb("bread.help.opened"), None); - } -} diff --git a/src/main.rs b/src/main.rs index 3aeed69..337863e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,6 @@ mod cli; mod config; mod content; -mod listen; -mod screenshot; mod services; mod theme; mod ui; @@ -11,11 +9,6 @@ use gtk4::gio::ApplicationFlags; use gtk4::prelude::*; fn main() { - if std::env::args().nth(1).as_deref() == Some("listen") { - listen::run(); - return; - } - // GApplication requires a dotted id (g_application_id_is_valid rejects a // bare "breadhelp" with a GLib-GIO-CRITICAL and silently skips setting // it, which would break D-Bus single-instance activation). The Wayland @@ -26,17 +19,9 @@ fn main() { // reach the running (primary) instance's argv, not just re-activate it // with no arguments — that's what lets a second launch re-trigger the // onboarding tour instead of only focusing the window. - let mut flags = ApplicationFlags::HANDLES_COMMAND_LINE; - if std::env::args().any(|a| a == "--screenshot") { - // Without this, a screenshot run would be forwarded over D-Bus to - // the real, already-running breadhelp instead of starting a fresh - // one — reusing (and mutating the tab of) the operator's actual - // help-center window instead of a disposable one. - flags |= ApplicationFlags::NON_UNIQUE; - } let app = gtk4::Application::builder() .application_id("com.breadway.breadhelp") - .flags(flags) + .flags(ApplicationFlags::HANDLES_COMMAND_LINE) .build(); app.connect_command_line(|app, cmdline| { diff --git a/src/screenshot.rs b/src/screenshot.rs deleted file mode 100644 index dd5ea16..0000000 --- a/src/screenshot.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! `--screenshot` CLI mode: switch to the named tab, capture it via -//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s -//! `bread-capture` orchestrator, or run standalone for one-off captures. -//! -//! breadhelp has three tabs worth capturing (Home/Learn/Ask), switched via -//! the same `Stack::set_visible_child_name` the tab switcher itself uses — -//! see `ui::tabs`. The window is a plain top-level (not layer-shell), so a -//! full known-size canvas capture is enough, same reasoning as breadpad's -//! popup view. -//! -//! One more view isn't a tab: "troubleshoot-wizard" opens the modal wizard -//! window (`ui::troubleshoot_wizard`, normally reachable via Home's -//! "Something's wrong?" button) directly. -//! -//! Deliberately NOT covered here: the onboarding tour (`ui::tour`). It's a -//! live, multi-step walkthrough that overlays callouts on the *real* -//! desktop as the user does real things (opens real apps, switches real -//! workspaces) — there's no meaningful "screenshot" of a single step in an -//! isolated environment with no real desktop for it to point at. -//! `ui::guide_view` and `ui::keybind_viewer` also aren't separate views: -//! both render embedded *inside* the Learn/Home tabs respectively, so -//! they're already covered by those tabs' own captures. - -use bread_utils::screenshot_cli::SETTLE_DELAY; -use gtk4::prelude::*; -use std::path::PathBuf; -use std::time::Duration; - -/// Delay before opening the wizard modal — same reasoning as every other -/// app's PRE_POPUP_DELAY: the parent window's own layout needs a beat to -/// settle first. -const PRE_POPUP_DELAY: Duration = SETTLE_DELAY; - -const KNOWN_VIEWS: &[&str] = &["home", "learn", "ask", "troubleshoot-wizard"]; - -#[derive(Clone)] -pub struct ScreenshotRequest { - pub view: String, - pub output: PathBuf, - pub width: u32, - pub height: u32, -} - -/// Wire up the given view's screenshot sequence against an already-built -/// window and its tab `Stack`. Every path here ends by exiting the process -/// — it never returns control to the normal help-center UI. -pub fn dispatch(window: >k4::ApplicationWindow, stack: >k4::Stack, req: ScreenshotRequest) { - if !KNOWN_VIEWS.contains(&req.view.as_str()) { - eprintln!( - "breadhelp: unknown screenshot view '{}' (known: {})", - req.view, - KNOWN_VIEWS.join(", ") - ); - std::process::exit(1); - } - let output = req.output; - let (width, height) = (req.width as i32, req.height as i32); - - if req.view == "troubleshoot-wizard" { - window.connect_map(move |win| { - let output = output.clone(); - let win = win.clone(); - gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || { - crate::ui::troubleshoot_wizard::open(&win, move |dialog| { - let output = output.clone(); - dialog.connect_map(move |_| { - let output = output.clone(); - gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { - finish(bread_screenshots::capture_region(0, 0, width, height, &output)); - }); - }); - }); - }); - }); - // The caller (`ui::window::present`) returns immediately after this - // for the screenshot path, skipping its own normal `window.present()` - // call — trigger it here instead, so `connect_map` above actually has - // something to fire for. - window.present(); - return; - } - - stack.set_visible_child_name(&req.view); - - window.connect_map(move |_| { - let output = output.clone(); - gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { - finish(bread_screenshots::capture_region(0, 0, width, height, &output)); - }); - }); - window.present(); -} - -fn finish(result: anyhow::Result<()>) { - match result { - Ok(()) => std::process::exit(0), - Err(e) => { - eprintln!("breadhelp: screenshot capture failed: {e}"); - std::process::exit(1); - } - } -} diff --git a/src/services/breadd.rs b/src/services/breadd.rs index 1bdd64d..a7c06d0 100644 --- a/src/services/breadd.rs +++ b/src/services/breadd.rs @@ -1,23 +1,6 @@ -//! Bread bus integration. `--suggest` banners stay local; `bread.help.*` -//! emits are optional and fail-silent. See `EVENTS.md`. -//! -//! `BreadClient::emit` never blocks or errors this process — a missing or -//! restarting breadd must not affect the help center itself. - -use bread_utils::bread_client::BreadClient; - -/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`. Events publish as -/// `bread.help.*`. Command verbs are handled by `breadhelp listen`. -pub const APP_ID: &str = "help"; - -/// Fire-and-forget `bread.help.opened` after the main window is actually -/// shown. Silent autostart (window built, not presented) does not call this. -pub fn emit_opened(autostart: bool) { - BreadClient::connect(APP_ID).emit( - "bread.help.opened", - serde_json::json!({ "autostart": autostart }), - ); -} +//! Maps a `--suggest ` payload (sent by a breadd Lua module, e.g. +//! `breadhelp-suggest.lua` reacting to `bread.monitor.connected`) to Home +//! tab banner text. pub struct Suggestion { pub text: String, diff --git a/src/services/exec.rs b/src/services/exec.rs index 827e576..4bd87f9 100644 --- a/src/services/exec.rs +++ b/src/services/exec.rs @@ -1,4 +1,3 @@ -use std::path::Path; use std::process::Command; /// Fire-and-forget: runs `command` via a shell, matching the idiom already @@ -9,35 +8,6 @@ pub fn run(command: &str) { } } -/// Fire-and-forget argv form — use this when arguments come from the UI -/// (timezone names, page ids) so they never pass through a shell. -pub fn run_argv(prog: &str, args: &[&str]) { - if let Err(e) = Command::new(prog).args(args).spawn() { - eprintln!("breadhelp: failed to run `{prog}`: {e}"); - } -} - -/// `true` when `name` resolves to an executable on `PATH`. -pub fn command_exists(name: &str) -> bool { - std::env::var_os("PATH") - .map(|paths| std::env::split_paths(&paths).any(|dir| is_executable(&dir.join(name)))) - .unwrap_or(false) -} - -fn is_executable(path: &Path) -> bool { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::metadata(path) - .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) - .unwrap_or(false) - } - #[cfg(not(unix))] - { - path.is_file() - } -} - /// Runs `command` on a background thread and delivers success/failure back /// onto the GTK main loop via `on_done` — plain `spawn()` only confirms the /// process *launched*, not how it exited, and one-click fixes need to report @@ -56,19 +26,3 @@ pub fn run_reporting(command: &str, on_done: impl Fn(bool) + 'static) { } }); } - -/// Same as [`run_reporting`], but without a shell. -pub fn run_argv_reporting(prog: &str, args: &[&str], on_done: impl Fn(bool) + 'static) { - let prog = prog.to_string(); - let args: Vec = args.iter().map(|s| (*s).to_string()).collect(); - let (tx, rx) = async_channel::bounded(1); - std::thread::spawn(move || { - let status = Command::new(&prog).args(&args).status(); - let _ = tx.send_blocking(status.map(|s| s.success()).unwrap_or(false)); - }); - glib::MainContext::default().spawn_local(async move { - if let Ok(success) = rx.recv().await { - on_done(success); - } - }); -} diff --git a/src/services/hyprland.rs b/src/services/hyprland.rs index ab3bd3e..1060481 100644 --- a/src/services/hyprland.rs +++ b/src/services/hyprland.rs @@ -28,6 +28,10 @@ pub struct Client { pub struct Monitor { pub id: i32, pub name: String, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, pub focused: bool, } @@ -42,9 +46,8 @@ pub struct LayerClient { } fn query_json(cmd: &str) -> Option { - // Timeout-guarded: an unresponsive hyprctl (Hyprland wedged/reloading) - // used to be able to block clients()/monitors()/layers() indefinitely. - bread_utils::proc::run_json("hyprctl", &["-j", cmd], std::time::Duration::from_secs(3)) + let output = Command::new("hyprctl").args(["-j", cmd]).output().ok()?; + output.status.success().then(|| serde_json::from_slice(&output.stdout).ok()).flatten() } pub fn clients() -> Vec { diff --git a/src/theme.rs b/src/theme.rs index 89cebf1..0f87d6f 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -10,7 +10,7 @@ use std::cell::RefCell; const APP_CSS: &str = "\ .view-content { padding: 24px; }\n\ -.view-content > label.page-title { margin-bottom: 16px; }\n\ +.view-content > label.title { margin-bottom: 16px; }\n\ /* [Show Keybind] chip: reads as a keycap inline with guide/keybind-viewer text. */\n\ label.keybind-chip { \ font-family: monospace; \ @@ -44,7 +44,7 @@ window.tour-window { background-color: transparent; }\n\ border-radius: 12px; \ padding: 16px 20px; \ }\n\ -.tour-callout label.page-title { font-weight: bold; font-size: 15px; }\n\ +.tour-callout label.title { font-weight: bold; font-size: 15px; }\n\ /* The one payoff line in the whole tour — the user did the real thing and \ the compositor proved it — shouldn't read as the same throwaway meta \ text as the step counter. */\n\ @@ -53,24 +53,6 @@ window.tour-window { background-color: transparent; }\n\ the \"Show me\" fallback as its own beat, not a continuation of the \ instructional paragraph above it. */\n\ .tour-hint-row { margin-top: 8px; }\n\ -/* ui::troubleshoot_wizard — a bare gtk4::Window with no titlebar falls back \ - to GTK's own minimal CSD (flat bar, plain system-font title, square \ - corners), which read as a stray window from a different decade next to \ - the rest of this (rounded, borderless) app. A real HeaderBar (set in \ - troubleshoot_wizard::open) picks up the window's title automatically; \ - this just makes it and the window match the shared dark theme instead \ - of GTK's default light-grey CSD chrome. */\n\ -window.wizard-dialog { background-color: @bg; color: @on-bg; border-radius: 10px; }\n\ -window.wizard-dialog headerbar { \ - background-color: @bg; \ - color: @on-bg; \ - border-top-left-radius: 10px; \ - border-top-right-radius: 10px; \ - border-bottom: 1px solid alpha(@on-bg, 0.08); \ - box-shadow: none; \ -}\n\ -/* ui::day_zero — timezone list is a tall boxed list inside the Welcome tab. */\n\ -.day-zero list { margin-top: 4px; }\n\ "; thread_local! { @@ -81,8 +63,3 @@ pub fn load(_display: >k4::gdk::Display) { bread_theme::gtk::apply_shared(); APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell)); } - -pub fn bind_window(window: &impl gtk4::prelude::IsA) { - bread_theme::gtk::bind_window_auto_with_app_css(window, |_| APP_CSS.to_string()); -} - diff --git a/src/ui/ask.rs b/src/ui/ask.rs index a79e6d2..157ea82 100644 --- a/src/ui/ask.rs +++ b/src/ui/ask.rs @@ -148,7 +148,7 @@ pub fn build(store: Rc, binds: Rc>, mode: Mode) -> Wi } Kind::Command { name, description } => { let title = Label::new(Some(name)); - title.add_css_class("page-title"); + title.add_css_class("title"); title.set_xalign(0.0); detail.append(&title); diff --git a/src/ui/day_zero.rs b/src/ui/day_zero.rs deleted file mode 100644 index 23eef73..0000000 --- a/src/ui/day_zero.rs +++ /dev/null @@ -1,665 +0,0 @@ -//! In-window day-zero wizard for first boot / `--autostart`. -//! -//! Not a second app: this is one extra tab on the existing help window so -//! Learn/Ask stay reachable when bakery `content.tar.gz` is installed. -//! Existing users who already finished the desktop tour never see it -//! (`needs_wizard` is false when `onboarding.completed` is set). -//! -//! Finish (or skip) hands off to the live tour — the previous first-run -//! destination — with the Home cheatsheet already showing underneath. - -use std::cell::{Cell, RefCell}; -use std::process::Command; -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::{ - Align, Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, - SearchEntry, SelectionMode, Stack, -}; - -use crate::config::State; -use crate::content::keybinds::Keybind; -use crate::content::{markdown, ContentStore}; -use crate::services::exec; - -const PAGE_NAME: &str = "day-zero"; -const STEP_COUNT: usize = 6; -const WELCOME_GUIDE_CATEGORY: &str = "getting-started"; -const WELCOME_GUIDE_ID: &str = "01-what-is-bos"; -const FALLBACK_WELCOME: &str = "\ -# Welcome to BOS - -BOS (the Bread Operating System) is a complete Hyprland desktop with the \ -bread ecosystem preinstalled: a bar, a launcher, notes, Wi-Fi profiles, and a \ -settings app that needs no config files. - -This short setup gets the clock, network, and updates in place. Use the tabs \ -above any time to browse the full guide. -"; - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Step { - Welcome, - Timezone, - Network, - Updates, - Snapshot, - Finish, -} - -const STEPS: [Step; STEP_COUNT] = [ - Step::Welcome, - Step::Timezone, - Step::Network, - Step::Updates, - Step::Snapshot, - Step::Finish, -]; - -struct Wizard { - content: GBox, - welcome_markdown: String, - binds: Vec, - step: RefCell, - finished: Cell, - on_finished: Box, -} - -/// First boot only: skip when the wizard already ran *or* the user already -/// finished the older tour-only onboarding (so upgrades don't re-prompt). -pub fn needs_wizard() -> bool { - needs_wizard_from( - State::load().day_zero_completed(), - State::load().onboarding_completed(), - ) -} - -pub(crate) fn needs_wizard_from(day_zero_done: bool, onboarding_done: bool) -> bool { - !day_zero_done && !onboarding_done -} - -/// Add (or focus) the Welcome tab and show it. Idempotent across D-Bus -/// re-activation while the wizard is already up. -pub fn attach( - stack: &Stack, - store: &ContentStore, - binds: &[Keybind], - on_finished: impl Fn() + 'static, -) { - if stack.child_by_name(PAGE_NAME).is_some() { - stack.set_visible_child_name(PAGE_NAME); - return; - } - - let start = (State::load().day_zero_step() as usize).min(STEP_COUNT - 1); - let welcome_markdown = store - .guide(WELCOME_GUIDE_CATEGORY, WELCOME_GUIDE_ID) - .map(|g| store.body(g, State::load().mode().is_simplified())) - .filter(|body| !body.trim().is_empty()) - .unwrap_or_else(|| FALLBACK_WELCOME.to_string()); - - let content = GBox::new(Orientation::Vertical, 16); - content.add_css_class("view-content"); - content.add_css_class("day-zero"); - - let wizard = Rc::new(Wizard { - content: content.clone(), - welcome_markdown, - binds: binds.to_vec(), - step: RefCell::new(start), - finished: Cell::new(false), - on_finished: Box::new(on_finished), - }); - wizard.render(); - - let page = ScrolledWindow::builder() - .child(&content) - .vexpand(true) - .hexpand(true) - .build(); - stack.add_titled(&page, Some(PAGE_NAME), "Welcome"); - stack.set_visible_child_name(PAGE_NAME); -} - -pub(crate) fn pick_network_tool( - has_settings: bool, - has_nm: bool, -) -> Option<(&'static str, &'static str, &'static [&'static str])> { - if has_settings { - Some(( - "Open network settings", - "bos-settings", - &["--page", "network"], - )) - } else if has_nm { - Some(("Open network editor", "nm-connection-editor", &[])) - } else { - None - } -} - -pub(crate) fn pick_updates_tool( - has_settings: bool, -) -> Option<(&'static str, &'static str, &'static [&'static str])> { - if has_settings { - Some(("Open BOS Settings", "bos-settings", &["--page", "packages"])) - } else { - None - } -} - -pub(crate) fn is_safe_timezone_name(tz: &str) -> bool { - !tz.is_empty() - && tz.len() < 128 - && tz - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '+' | '-' | '.')) -} - -fn parse_timezone_property(stdout: &str) -> Option { - stdout - .trim() - .strip_prefix("Timezone=") - .map(str::to_string) - .filter(|s| !s.is_empty()) -} - -fn parse_timezone_list(stdout: &str) -> Vec { - stdout - .lines() - .map(str::trim) - .filter(|l| !l.is_empty()) - .map(str::to_string) - .collect() -} - -impl Wizard { - fn current_step(&self) -> Step { - STEPS[*self.step.borrow()] - } - - fn go(self: &Rc, step: usize) { - let step = step.min(STEP_COUNT - 1); - *self.step.borrow_mut() = step; - let mut state = State::load(); - state.set_day_zero_step(step as i64); - self.render(); - } - - fn finish(self: &Rc) { - if self.finished.replace(true) { - return; - } - let mut state = State::load(); - state.set_day_zero_completed(true); - (self.on_finished)(); - } - - fn clear(&self) { - while let Some(child) = self.content.first_child() { - self.content.remove(&child); - } - } - - fn render(self: &Rc) { - self.clear(); - let index = *self.step.borrow(); - let step = self.current_step(); - - let title = Label::new(Some(step_title(step))); - title.add_css_class("page-title"); - title.set_xalign(0.0); - self.content.append(&title); - - match step { - Step::Welcome => self.render_welcome(), - Step::Timezone => self.render_timezone(), - Step::Network => self.render_network(), - Step::Updates => self.render_updates(), - Step::Snapshot => self.render_snapshot(), - Step::Finish => self.render_finish(), - } - - let counter = Label::new(Some(&format!("Step {} of {STEP_COUNT}", index + 1))); - counter.add_css_class("dim-label"); - counter.set_xalign(0.0); - self.content.append(&counter); - - let nav = GBox::new(Orientation::Horizontal, 8); - let skip = Button::with_label("Skip setup"); - skip.add_css_class("flat"); - let this = self.clone(); - skip.connect_clicked(move |_| this.finish()); - - let back = Button::with_label("Back"); - back.set_sensitive(index > 0); - let this = self.clone(); - back.connect_clicked(move |_| { - let i = *this.step.borrow(); - if i > 0 { - this.go(i - 1); - } - }); - - let spacer = GBox::new(Orientation::Horizontal, 0); - spacer.set_hexpand(true); - - let is_last = index + 1 >= STEP_COUNT; - let next = Button::with_label(if is_last { "Start tour" } else { "Next" }); - next.add_css_class("suggested-action"); - let this = self.clone(); - next.connect_clicked(move |_| { - let i = *this.step.borrow(); - if i + 1 >= STEP_COUNT { - this.finish(); - } else { - this.go(i + 1); - } - }); - - nav.append(&skip); - nav.append(&spacer); - nav.append(&back); - nav.append(&next); - self.content.append(&nav); - } - - fn render_welcome(self: &Rc) { - let blurb = Label::new(Some( - "A few things before the desktop tour. The other tabs stay available if you want to look around.", - )); - blurb.set_wrap(true); - blurb.set_xalign(0.0); - self.content.append(&blurb); - self.content.append(&markdown::render( - &markdown::parse(&self.welcome_markdown), - &self.binds, - )); - } - - fn render_timezone(self: &Rc) { - let (current, zones) = load_timezones(); - let intro = if zones.is_empty() { - "Couldn't list timezones (is timedatectl available?). You can set this later from BOS Settings." - } else { - "The clock should match where you are. Applying a change may ask for your password." - }; - let blurb = Label::new(Some(intro)); - blurb.set_wrap(true); - blurb.set_xalign(0.0); - self.content.append(&blurb); - - let current_lbl = Label::new(Some(&if current.is_empty() { - "Current timezone: unknown".into() - } else { - format!("Current timezone: {current}") - })); - current_lbl.set_xalign(0.0); - current_lbl.add_css_class("dim-label"); - self.content.append(¤t_lbl); - - if zones.is_empty() { - return; - } - - let search = SearchEntry::new(); - search.set_placeholder_text(Some("Search timezones")); - search.set_hexpand(true); - self.content.append(&search); - - let list = ListBox::new(); - list.set_selection_mode(SelectionMode::Single); - list.add_css_class("boxed-list"); - for tz in &zones { - let row = ListBoxRow::new(); - row.set_widget_name(tz); - let lbl = Label::new(Some(tz)); - lbl.set_xalign(0.0); - lbl.set_margin_top(4); - lbl.set_margin_bottom(4); - lbl.set_margin_start(8); - row.set_child(Some(&lbl)); - list.append(&row); - if tz == ¤t { - list.select_row(Some(&row)); - } - } - - { - let list = list.clone(); - search.connect_search_changed(move |entry| { - let q = entry.text().to_lowercase(); - let mut i = 0; - let mut first_visible: Option = None; - loop { - let Some(row) = list.row_at_index(i) else { - break; - }; - let name = row.widget_name(); - let visible = q.is_empty() || name.to_lowercase().contains(&q); - row.set_visible(visible); - if visible && first_visible.is_none() { - first_visible = Some(row); - } - i += 1; - } - if let Some(row) = first_visible { - list.select_row(Some(&row)); - } - }); - } - - let scroller = ScrolledWindow::builder() - .child(&list) - .min_content_height(180) - .hexpand(true) - .vexpand(true) - .build(); - self.content.append(&scroller); - - let status = Label::new(None); - status.set_xalign(0.0); - status.add_css_class("dim-label"); - - let apply = Button::with_label("Apply timezone"); - apply.set_halign(Align::Start); - apply.add_css_class("suggested-action"); - let current_lbl = current_lbl.clone(); - let status_for_click = status.clone(); - apply.connect_clicked(move |_| { - let Some(row) = list.selected_row() else { - status_for_click.set_label("Pick a timezone first."); - return; - }; - let tz = row.widget_name().to_string(); - if !is_safe_timezone_name(&tz) { - status_for_click.set_label("That timezone name doesn't look valid."); - return; - } - status_for_click.set_label("Applying\u{2026}"); - let status = status_for_click.clone(); - let current_lbl = current_lbl.clone(); - apply_timezone(tz, move |msg, ok, tz| { - status.set_label(&msg); - if ok { - current_lbl.set_label(&format!("Current timezone: {tz}")); - } - }); - }); - self.content.append(&apply); - self.content.append(&status); - } - - fn render_network(self: &Rc) { - let blurb = Label::new(Some( - "Connect to Wi-Fi in BOS Settings (or the NetworkManager editor). This window does not manage networks itself.", - )); - blurb.set_wrap(true); - blurb.set_xalign(0.0); - self.content.append(&blurb); - - if let Some((label, prog, args)) = pick_network_tool( - exec::command_exists("bos-settings"), - exec::command_exists("nm-connection-editor"), - ) { - let btn = Button::with_label(label); - btn.set_halign(Align::Start); - let prog = prog.to_string(); - let args: Vec = args.iter().map(|s| (*s).to_string()).collect(); - btn.connect_clicked(move |_| { - let argv: Vec<&str> = args.iter().map(String::as_str).collect(); - exec::run_argv(&prog, &argv); - }); - self.content.append(&btn); - } else { - let hint = Label::new(Some( - "No network UI found. Run `breadcrumbs` or `nmcli` in a terminal to connect.", - )); - hint.set_wrap(true); - hint.set_xalign(0.0); - hint.add_css_class("dim-label"); - self.content.append(&hint); - } - - if exec::command_exists("breadcrumbs") { - let crumbs = Label::new(Some( - "Location profiles (home / work / away) are handled by breadcrumbs once a network is saved.", - )); - crumbs.set_wrap(true); - crumbs.set_xalign(0.0); - crumbs.add_css_class("dim-label"); - self.content.append(&crumbs); - } - } - - fn render_updates(self: &Rc) { - let blurb = Label::new(Some( - "When you're online, check for system and bread-ecosystem updates. Nothing is installed from this step.", - )); - blurb.set_wrap(true); - blurb.set_xalign(0.0); - self.content.append(&blurb); - - if let Some((label, prog, args)) = pick_updates_tool(exec::command_exists("bos-settings")) { - let btn = Button::with_label(label); - btn.set_halign(Align::Start); - let prog = prog.to_string(); - let args: Vec = args.iter().map(|s| (*s).to_string()).collect(); - btn.connect_clicked(move |_| { - let argv: Vec<&str> = args.iter().map(String::as_str).collect(); - exec::run_argv(&prog, &argv); - }); - self.content.append(&btn); - } - - let hint = Label::new(Some(if exec::command_exists("bos-settings") { - "Or run `bos-update` in a terminal." - } else { - "BOS Settings isn't installed. Run `bos-update` in a terminal when you're ready." - })); - hint.set_wrap(true); - hint.set_xalign(0.0); - hint.add_css_class("dim-label"); - self.content.append(&hint); - } - - fn render_snapshot(self: &Rc) { - let available = exec::command_exists("snapper"); - let blurb = Label::new(Some(if available { - "A snapshot is a restore point. We'll try to create one named day-zero — if this machine doesn't allow it, that's fine." - } else { - "snapper isn't available on this system, so no first-boot snapshot will be created. You can skip this." - })); - blurb.set_wrap(true); - blurb.set_xalign(0.0); - self.content.append(&blurb); - - let status = Label::new(None); - status.set_xalign(0.0); - status.add_css_class("dim-label"); - - if available { - let retry = Button::with_label("Create snapshot"); - retry.set_halign(Align::Start); - let status_click = status.clone(); - retry.connect_clicked(move |_| { - status_click.set_label("Creating snapshot\u{2026}"); - let status = status_click.clone(); - create_day_zero_snapshot(move |ok| { - status.set_label(snapshot_status(ok)); - }); - }); - self.content.append(&retry); - - if !State::load().day_zero_snapshot_attempted() { - let mut state = State::load(); - state.set_day_zero_snapshot_attempted(true); - status.set_label("Creating snapshot\u{2026}"); - let status = status.clone(); - create_day_zero_snapshot(move |ok| { - status.set_label(snapshot_status(ok)); - }); - } - } - - self.content.append(&status); - } - - fn render_finish(self: &Rc) { - let blurb = Label::new(Some( - "You're set. Next is a short tour of the real desktop. Super+/ opens this help center any time — the Home tab is the keybind cheatsheet.", - )); - blurb.set_wrap(true); - blurb.set_xalign(0.0); - self.content.append(&blurb); - } -} - -fn step_title(step: Step) -> &'static str { - match step { - Step::Welcome => "Welcome to BOS", - Step::Timezone => "Timezone", - Step::Network => "Network", - Step::Updates => "Updates", - Step::Snapshot => "Snapshot", - Step::Finish => "You're ready", - } -} - -fn snapshot_status(ok: bool) -> &'static str { - if ok { - "Created snapshot “day-zero”." - } else { - "Couldn't create a snapshot (that's OK — you can do this later from Settings)." - } -} - -fn load_timezones() -> (String, Vec) { - let current = Command::new("timedatectl") - .args(["show", "--property=Timezone"]) - .output() - .ok() - .filter(|o| o.status.success()) - .and_then(|o| parse_timezone_property(&String::from_utf8_lossy(&o.stdout))) - .unwrap_or_default(); - let zones = Command::new("timedatectl") - .arg("list-timezones") - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| parse_timezone_list(&String::from_utf8_lossy(&o.stdout))) - .unwrap_or_default(); - (current, zones) -} - -fn apply_timezone(tz: String, on_done: impl Fn(String, bool, String) + 'static) { - let (tx, rx) = async_channel::bounded(1); - std::thread::spawn(move || { - let direct = Command::new("timedatectl") - .args(["set-timezone", &tz]) - .status(); - if direct.map(|s| s.success()).unwrap_or(false) { - let _ = tx.send_blocking((format!("Timezone set to {tz}."), true, tz)); - return; - } - if !exec::command_exists("pkexec") { - let _ = tx.send_blocking(( - "Need permission to change the timezone, and pkexec isn't available.".into(), - false, - tz, - )); - return; - } - let elevated = Command::new("pkexec") - .args(["timedatectl", "set-timezone", &tz]) - .status(); - if elevated.map(|s| s.success()).unwrap_or(false) { - let _ = tx.send_blocking((format!("Timezone set to {tz}."), true, tz)); - } else { - let _ = tx.send_blocking(( - "Couldn't change the timezone. You can try again from BOS Settings.".into(), - false, - tz, - )); - } - }); - glib::MainContext::default().spawn_local(async move { - if let Ok((msg, ok, tz)) = rx.recv().await { - on_done(msg, ok, tz); - } - }); -} - -fn create_day_zero_snapshot(on_done: impl Fn(bool) + 'static) { - exec::run_argv_reporting( - "snapper", - &["-c", "root", "create", "-d", "day-zero"], - on_done, - ); -} - -/// Used by `window` after the wizard finishes: drop the Welcome tab so the -/// help center is just Home/Learn/Ask again. -pub fn detach(stack: &Stack) { - if let Some(child) = stack.child_by_name(PAGE_NAME) { - stack.remove(&child); - } - if stack.child_by_name("home").is_some() { - stack.set_visible_child_name("home"); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn existing_onboarding_skips_wizard() { - assert!(!needs_wizard_from(false, true)); - assert!(!needs_wizard_from(true, true)); - assert!(!needs_wizard_from(true, false)); - assert!(needs_wizard_from(false, false)); - } - - #[test] - fn network_prefers_bos_settings_then_nm() { - let (label, prog, args) = pick_network_tool(true, true).unwrap(); - assert_eq!(prog, "bos-settings"); - assert_eq!(args, &["--page", "network"]); - assert!(label.contains("network")); - let (_, prog, _) = pick_network_tool(false, true).unwrap(); - assert_eq!(prog, "nm-connection-editor"); - assert!(pick_network_tool(false, false).is_none()); - } - - #[test] - fn updates_tool_only_when_settings_exists() { - assert!(pick_updates_tool(true).is_some()); - assert!(pick_updates_tool(false).is_none()); - } - - #[test] - fn timezone_names_reject_shell_metacharacters() { - assert!(is_safe_timezone_name("Australia/Perth")); - assert!(is_safe_timezone_name("Etc/GMT+8")); - assert!(is_safe_timezone_name("America/Argentina/ComodRivadavia")); - assert!(!is_safe_timezone_name("")); - assert!(!is_safe_timezone_name("America/New York")); - assert!(!is_safe_timezone_name("x; rm -rf /")); - assert!(!is_safe_timezone_name("$(reboot)")); - } - - #[test] - fn parses_timedatectl_show_and_list() { - assert_eq!( - parse_timezone_property("Timezone=Australia/Perth\n").as_deref(), - Some("Australia/Perth") - ); - assert_eq!(parse_timezone_property("Timezone=\n"), None); - assert_eq!( - parse_timezone_list("Africa/Abidjan\nAustralia/Perth\n\nUTC\n"), - vec!["Africa/Abidjan", "Australia/Perth", "UTC"] - ); - } -} diff --git a/src/ui/guide_view.rs b/src/ui/guide_view.rs index 7007418..42da4c7 100644 --- a/src/ui/guide_view.rs +++ b/src/ui/guide_view.rs @@ -8,7 +8,7 @@ pub fn build(store: &ContentStore, guide: &Guide, simplified: bool, binds: &[Key vbox.add_css_class("view-content"); let title = Label::new(Some(&guide.meta.title)); - title.add_css_class("page-title"); + title.add_css_class("title"); title.set_xalign(0.0); vbox.append(&title); diff --git a/src/ui/home.rs b/src/ui/home.rs index 2d9ebc3..f2c1152 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -47,7 +47,7 @@ pub fn build( let header = GBox::new(Orientation::Horizontal, 12); let title = Label::new(Some("Home")); - title.add_css_class("page-title"); + title.add_css_class("title"); title.set_xalign(0.0); title.set_hexpand(true); header.append(&title); @@ -83,7 +83,7 @@ pub fn build( troubleshoot_btn.set_halign(Align::Start); { let parent = parent_window.clone(); - troubleshoot_btn.connect_clicked(move |_| troubleshoot_wizard::open(&parent, |_| {})); + troubleshoot_btn.connect_clicked(move |_| troubleshoot_wizard::open(&parent)); } vbox.append(&troubleshoot_btn); diff --git a/src/ui/keybind_viewer.rs b/src/ui/keybind_viewer.rs index a82cd49..3cb2f14 100644 --- a/src/ui/keybind_viewer.rs +++ b/src/ui/keybind_viewer.rs @@ -7,7 +7,7 @@ pub fn build(binds: &[Keybind]) -> gtk4::Widget { let vbox = GBox::new(Orientation::Vertical, 6); let header = Label::new(Some("Keybinds")); - header.add_css_class("page-title"); + header.add_css_class("title"); header.set_xalign(0.0); vbox.append(&header); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7f7a760..8374aab 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,4 @@ pub mod ask; -pub mod day_zero; pub mod guide_view; pub mod home; pub mod keybind_viewer; diff --git a/src/ui/tour/callout.rs b/src/ui/tour/callout.rs index 8baf553..2368ca1 100644 --- a/src/ui/tour/callout.rs +++ b/src/ui/tour/callout.rs @@ -49,7 +49,6 @@ pub fn build( window.set_layer(Layer::Overlay); window.set_keyboard_mode(KeyboardMode::OnDemand); window.set_monitor(Some(monitor)); - crate::theme::bind_window(&window); if let Some(rect) = rect { let (mon_w, mon_h) = monitor_size; @@ -73,7 +72,7 @@ pub fn build( root.set_size_request(CALLOUT_WIDTH, -1); let title = Label::new(Some(&step.title)); - title.add_css_class("page-title"); + title.add_css_class("title"); title.set_xalign(0.0); title.set_wrap(true); root.append(&title); diff --git a/src/ui/tour/mask.rs b/src/ui/tour/mask.rs index e26c9da..8ae8059 100644 --- a/src/ui/tour/mask.rs +++ b/src/ui/tour/mask.rs @@ -20,7 +20,6 @@ fn new_mask_window(monitor: &gdk4::Monitor) -> Window { window.set_layer(Layer::Overlay); window.set_keyboard_mode(KeyboardMode::None); window.set_monitor(Some(monitor)); - crate::theme::bind_window(&window); let dim = GBox::new(gtk4::Orientation::Vertical, 0); dim.add_css_class("tour-mask"); dim.set_hexpand(true); diff --git a/src/ui/tour/mod.rs b/src/ui/tour/mod.rs index 7404e81..8112a9d 100644 --- a/src/ui/tour/mod.rs +++ b/src/ui/tour/mod.rs @@ -110,7 +110,7 @@ pub fn self_heal() { pub fn on_tour_event(id: &str) { let hit = STATE.with(|cell| { let mut borrow = cell.borrow_mut(); - let state = borrow.as_mut()?; + let Some(state) = borrow.as_mut() else { return None }; // Only a *visibly* confirmed step blocks a re-fire. `confirmed` gets // set below, before `render_step` runs, so that render actually // paints the "done" state instead of the stale pre-confirmation one diff --git a/src/ui/troubleshoot_wizard.rs b/src/ui/troubleshoot_wizard.rs index a7565d4..465c5ec 100644 --- a/src/ui/troubleshoot_wizard.rs +++ b/src/ui/troubleshoot_wizard.rs @@ -25,11 +25,7 @@ struct Wizard { trees: HashMap>, } -/// `on_build` runs on the freshly built window *before* it's presented — -/// screenshot mode's only hook point, since `connect_map` registered any -/// later would miss a map that already happened. The real call site -/// (`ui::home`) passes a no-op. -pub fn open(parent: &impl IsA, on_build: impl FnOnce(&Window)) { +pub fn open(parent: &impl IsA) { let window = Window::builder() .transient_for(parent) .modal(true) @@ -37,11 +33,6 @@ pub fn open(parent: &impl IsA, on_build: impl FnOnce(&Window)) { .default_width(480) .default_height(360) .build(); - window.add_css_class("wizard-dialog"); - crate::theme::bind_window(&window); - let header = gtk4::HeaderBar::new(); - header.set_show_title_buttons(true); - window.set_titlebar(Some(&header)); let content = GBox::new(Orientation::Vertical, 12); content.add_css_class("view-content"); @@ -50,7 +41,6 @@ pub fn open(parent: &impl IsA, on_build: impl FnOnce(&Window)) { wizard.render_picker(); window.set_child(Some(&content)); - on_build(&window); window.present(); } @@ -65,7 +55,7 @@ impl Wizard { self.clear(); let title = Label::new(Some("What's wrong?")); - title.add_css_class("page-title"); + title.add_css_class("title"); title.set_xalign(0.0); self.content.append(&title); diff --git a/src/ui/window.rs b/src/ui/window.rs index 866b63d..85aa86e 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -6,20 +6,16 @@ use gtk4::{Application, ApplicationWindow, Box as GBox, Orientation, Stack, Stac use crate::cli::Action; use crate::config::State; -use crate::content::keybinds::Keybind; use crate::content::{keybinds, ContentStore}; use super::home::Home; -use super::{ask, day_zero, learn, modes, tabs, tour}; +use super::{ask, learn, modes, tabs, tour}; const DEFAULT_TAB: &str = "home"; struct Handle { window: ApplicationWindow, home: Home, - stack: Stack, - store: Rc, - binds: Rc>, } thread_local! { @@ -30,44 +26,19 @@ thread_local! { /// over D-Bus to this primary instance by a second `breadhelp` launch — so /// it must build the window at most once and reuse it thereafter. pub fn present(app: &Application, action: Action) { - // Crash-recover keybinds even if this invocation never builds a window - // (silent every-login autostart). - tour::self_heal(); - - // Once onboarding is done, autostart only needs a warm process so - // Super+/ is instant. GTK/theme/font load is most of that cost and is - // paid by staying resident (~14 MiB PSS, no window). Building tabs - // adds ~2 MiB of dead weight for a session that may never open help. - let is_bare_silent_autostart = action.autostart - && action.screenshot.is_none() - && !action.force_onboard - && action.tour_event.is_none() - && action.suggest.is_none() - && State::load().onboarding_completed(); - if is_bare_silent_autostart { - // No window means nothing holds GApplication open. Forget the hold - // guard so the primary instance lives for the session; logout is - // what ends it. Without this, the next Super+/ would cold-start. - std::mem::forget(app.hold()); - return; - } - HANDLE.with(|cell| { + let mut just_built = false; { let mut cell_ref = cell.borrow_mut(); if cell_ref.is_none() { *cell_ref = Some(build(app)); + just_built = true; } } let cell_ref = cell.borrow(); let handle = cell_ref.as_ref().unwrap(); let display = WidgetExt::display(&handle.window); - if let Some(req) = action.screenshot_request() { - crate::screenshot::dispatch(&handle.window, &handle.stack, req); - return; - } - if action.force_onboard { tour::restart(&display); return; @@ -82,55 +53,29 @@ pub fn present(app: &Application, action: Action) { if let Some(s) = crate::services::breadd::resolve(id) { handle.home.set_suggestion(Some(&s.text)); } - // Background daemon event — never steal focus. A `--suggest` - // can now be the first invocation of the session because - // silent autostart no longer builds a window. + // Only focus the window the first time this process builds it + // (i.e. breadhelp wasn't already running) — a background daemon + // event shouldn't steal focus from whatever the user is doing. + if just_built { + handle.window.present(); + } return; } - // Bare silent autostart (onboarding done) already returned above. - // Autostart that reaches here is a genuine first run. - let onboarded = State::load().onboarding_completed(); - if action.autostart && onboarded { - return; - } - - if day_zero::needs_wizard() { - let stack = handle.stack.clone(); - let display = display.clone(); - day_zero::attach(&handle.stack, &handle.store, &handle.binds, move || { - // Drop the Welcome tab after the click handler returns — - // removing it here would destroy the Skip/Finish button - // mid-signal. - let stack = stack.clone(); - let display = display.clone(); - glib::idle_add_local_once(move || { - day_zero::detach(&stack); - if !State::load().onboarding_completed() { - tour::start(&display); - } - }); - }); - show_window(&handle.window, action.autostart); - return; - } - - if action.autostart && !onboarded { + // Every-login autostart builds the window (so the app is ready to + // respond to SUPER+/ instantly) but only starts the tour / pops the + // window open on a genuine first run — never on later logins. + if action.autostart && !State::load().onboarding_completed() { tour::start(&display); return; } - show_window(&handle.window, action.autostart); + let silent_autostart = action.autostart && State::load().onboarding_completed(); + if !silent_autostart { + handle.window.present(); + } }); } -/// Present the help window and publish `bread.help.opened`. Screenshot -/// mode calls `window.present()` itself and must not go through here — -/// that is capture, not a user-visible open. -fn show_window(window: &ApplicationWindow, autostart: bool) { - window.present(); - crate::services::breadd::emit_opened(autostart); -} - fn build(app: &Application) -> Handle { // First thing on every cold start: revert any keybind a previous, // crashed run may have left temporarily rebound mid tour-step (see @@ -146,7 +91,6 @@ fn build(app: &Application) -> Handle { .build(); crate::theme::load(&WidgetExt::display(&window)); - crate::theme::bind_window(&window); let store = Rc::new(ContentStore::load()); let binds = Rc::new(keybinds::load()); @@ -168,16 +112,8 @@ fn build(app: &Application) -> Handle { } }); stack.add_titled(&home.root, Some("home"), "Home"); - stack.add_titled( - &learn::build(&store, &binds, state.mode()), - Some("learn"), - "Learn", - ); - stack.add_titled( - &ask::build(store.clone(), binds.clone(), state.mode()), - Some("ask"), - "Ask", - ); + stack.add_titled(&learn::build(&store, &binds, state.mode()), Some("learn"), "Learn"); + stack.add_titled(&ask::build(store.clone(), binds.clone(), state.mode()), Some("ask"), "Ask"); stack.set_visible_child_name(DEFAULT_TAB); let switcher = tabs::build(&stack); @@ -188,17 +124,12 @@ fn build(app: &Application) -> Handle { window.set_child(Some(&content_vbox)); // Deliberately not presented here — `present()` (the caller) decides - // whether this initial build should actually be shown, so a from-cold - // every-login autostart with onboarding already complete builds a - // ready-but-hidden window instead of flashing it open. First run - // presents the window with the day-zero wizard; the tour overlay - // starts after that finishes. + // whether this initial build should actually be shown (see + // `silent_autostart` above), so a from-cold every-login autostart with + // onboarding already complete builds a ready-but-hidden window instead + // of flashing it open. On a genuine first run, the tour overlay runs + // independently of this window (see `tour::start`) — it never needs to + // be shown at all until the user explicitly opens it later. - Handle { - window, - home, - stack, - store, - binds, - } + Handle { window, home } }