Compare commits

...

27 commits
v0.2.3 ... main

Author SHA1 Message Date
Breadway
0d540c6bb9 Skip building the help window on silent every-login autostart
All checks were successful
dev release / build (push) Successful in 49s
Keep a warm GApplication with hold() so Super+/ still hits a resident
process, without paying for tabs that may never open. Ignore graphify-out.
2026-08-23 15:08:58 +08:00
Breadway
e27c497df8 clippy: fix dead-code and question-mark warnings
All checks were successful
dev release / build (push) Successful in 2m0s
- meta.rs/tour.rs/troubleshoot.rs: annotate content-schema fields that
  real TOML content already populates but no UI reads yet, instead of
  dropping them
- hyprland.rs: drop Monitor's x/y/width/height, genuinely unused
  anywhere and not part of any authored content schema
- tour/mod.rs: collapse a let-else into ? in the Option-returning
  on_tour_event closure

(cherry picked from commit 4497eca4661112f808d33ef477265d39e0e0a601)
2026-08-23 14:58:07 +08:00
Breadway
03517d9b0f Match help copy to live BOS binds and semantic search
Some checks failed
check / check (push) Failing after 55s
dev release / build (push) Successful in 2m10s
Keep Super+U / Super+Backspace as in iso binds.json; document Super+, for
settings. Describe breadsearch as meaning search, not filename letters.
2026-08-23 14:42:57 +08:00
Breadway
36cb292213 Bump version to v0.2.6
Some checks failed
beta (rc) release / build (push) Has been skipped
dev release / build (push) Successful in 2m28s
release / build (push) Failing after 2m23s
2026-08-16 14:09:43 +08:00
Breadway
d42d6632c6 CI: stop wiping shared bread-ecosystem clones mid-job
All checks were successful
dev release / build (push) Successful in 49s
Another product's gen-index step was deleting /tmp/bread-ecosystem-ci-*
between breadhelp's build and test, so the test step 127'd on a missing
ci/build.sh.
2026-08-16 13:46:28 +08:00
Breadway
5ae12e3701 Bind help windows to the current monitor's bread-theme palette
Some checks failed
dev release / build (push) Failing after 2m23s
Pin bread-theme to v0.7.4.
2026-08-16 13:25:24 +08:00
Breadway
9c4a1133fb CI: run release tests through ci/build.sh
Some checks failed
check / check (push) Failing after 9s
dev release / build (push) Successful in 1m57s
beta (rc) release / build (push) Has been skipped
release / build (push) Failing after 1m22s
2026-08-16 00:50:22 +08:00
Breadway
a7a67d7d84 Adopt bread_utils::screenshot_cli for --screenshot flags
Some checks failed
check / check (push) Failing after 3s
dev release / build (push) Successful in 29s
Replace the local settle delay, canvas defaults, and pair-validation
error path with bread-utils v0.7.2. Hand-rolled argv parsing stays.
2026-08-16 00:26:07 +08:00
Breadway
af6ba1bd82 Add a day-zero first-run wizard before the desktop tour
Some checks failed
check / check (push) Failing after 6s
dev release / build (push) Successful in 29s
First-boot --autostart now walks Welcome, timezone, network hint, updates,
and an optional snapper day-zero snapshot in the existing help window, then
hands off to the live cheatsheet tour. Existing onboarded users are skipped.
2026-08-16 00:00:23 +08:00
Breadway
d9012c8e72 Honor bread.command.help.open via breadhelp listen
All checks were successful
dev release / build (push) Successful in 39s
2026-08-15 23:11:28 +08:00
Breadway
f021473269 Bump version to v0.2.4
Some checks failed
beta (rc) release / build (push) Has been skipped
release / build (push) Failing after 19s
dev release / build (push) Has been cancelled
2026-08-15 23:05:47 +08:00
Breadway
1839b6a9ea Pin bread-ecosystem crates to v0.7.2
All checks were successful
dev release / build (push) Successful in 1m3s
Repoint bread-theme, bread-utils, and bread-screenshots at tag v0.7.2
on git.breadway.dev. Drop the 69ce2d67 rev pin now that screenshots
is on a tagged release.
2026-08-15 22:53:46 +08:00
Breadway
9d3bdb976c CI: port breadhelp onto bread-ecosystem's shared Arch build container
All checks were successful
dev release / build (push) Successful in 2m58s
Mirrors breadpad's proven pattern: ci/build.sh wraps bread-ecosystem's
shared ci/build.sh, pinned by commit sha in ci/bread-ecosystem.rev
(rev 147cfbbf96ae4b171027defa1130d2caddb934b1). No ci/deps.txt — the
shared image's gtk4/libadwaita/gtk4-layer-shell/graphene set already
covers breadhelp's dependency surface (no libadwaita or extra native
libs beyond those). dev-release.yml/rc-release.yml/release.yml now
build (and, in dev-release.yml, test) through ci/build.sh instead of
bare cargo on the runner; a new check.yml runs clippy+test on
feature/**/fix/** pushes as a fast pre-release signal.
2026-08-15 22:33:03 +08:00
Breadway
c9445fadcc Emit bread.help.opened when the main window is shown
All checks were successful
dev release / build (push) Successful in 2m4s
Fail-silent via bread-utils BreadClient (ecosystem v0.7.1, bread-client).
No command verbs. Silent autostart and screenshot mode do not emit.
2026-08-15 22:16:30 +08:00
Breadway
3634edef0d Track AGENTS.md 2026-08-15 22:03:30 +08:00
Breadway
4a356333d1 Ecosystem platform pass: bakery identity, v0.7.1 pins, BOS keybinds
README and packaging/ now describe bakery (CI + ISO bake) as canonical
instead of the old pacman package.yml flow. Pin bread-theme and
bread-utils to bread-ecosystem v0.7.1; rev-pin bread-screenshots because
that crate is not in the tag. Update the essential-keybinds guide to
match the BOS skel (nautilus, lock, breadshot).
2026-08-15 21:39:24 +08:00
Breadway
28c399efd1 Repoint bread-ecosystem git deps from dev to main branch
All checks were successful
dev release / build (push) Successful in 5m0s
2026-07-31 14:30:01 +08:00
Breadway
fc408878de Remove leftover pacman packaging (breadhelp moved to bakery-only)
All checks were successful
dev release / build (push) Successful in 4m52s
2026-07-31 11:10:54 +08:00
Breadway
8f61dc8a95 CONTRIBUTING.md: document single-trunk + RC-tag release model 2026-07-31 11:08:41 +08:00
Breadway
a0960948ea CI: single-trunk model — dev triggers on main, beta becomes RC-tag-triggered
Replaces the dev/beta branch split with one trunk (main): dev-track
builds still publish on every push, but the beta track now publishes
from a vX.Y.Z-rc.N prerelease tag instead of a separately-maintained
beta branch. Removes the branch nobody reliably kept in sync.
2026-07-31 11:05:29 +08:00
Breadway
08cd1aa42a Merge feature/page-title-rename into dev
All checks were successful
dev release / build (push) Successful in 2m54s
2026-07-31 09:09:44 +08:00
Breadway
9aa99a4549 Update Cargo.lock to bread-ecosystem's latest merged dev commit 2026-07-31 09:09:28 +08:00
Breadway
54e765afd0 Rename .title CSS class to .page-title to avoid libadwaita collision
bread-theme's shared .title rule (1.4em bold) was a bare class selector
that also matched libadwaita's internal row/window-title labels, which
carry the same "title" class. That's what inflated breadman's settings
row titles to ~24px. Scoping the name to .page-title here (the only
consumer of the old class) and bumping bread-theme to the dev branch
where the rename lives.
2026-07-30 20:01:33 +08:00
Breadway
12df11084a Merge feature/troubleshoot-wizard-csd: proper CSD for the troubleshoot wizard
All checks were successful
dev release / build (push) Successful in 2m52s
2026-07-30 19:06:35 +08:00
Breadway
b67c681b3a breadhelp: give the troubleshoot wizard proper CSD + rounded corners
Same fix as breadbar's wifi add-network dialog: a bare gtk4::Window with
no titlebar falls back to GTK's own minimal CSD (flat bar, plain
system-font title, square corners) — a real HeaderBar (which picks up
the window's title automatically) plus matching dark/rounded theming on
window.wizard-dialog makes this look like part of the app instead of a
stray window from a different decade.

Didn't hit the accent-button rendering quirk noted in breadbar's dialog
fix — this wizard's buttons are plain option/close buttons, no
suggested-action/confirm-button in play.
2026-07-30 19:05:01 +08:00
Breadway
e0012f83b1 Merge feature/troubleshoot-wizard-view: capture the troubleshoot wizard modal
All checks were successful
dev release / build (push) Successful in 3m10s
2026-07-29 21:59:22 +08:00
Breadway
4efc5eafd1 breadhelp: capture the troubleshoot wizard modal
One more view: "troubleshoot-wizard" opens ui::troubleshoot_wizard
directly (normally only reachable via Home's "Something's wrong?"
button), using the same on_build-hook pattern as breadbar/breadman's
add-dialogs — open() now takes a callback run before .present(), the
only point connect_map can still catch the map. The real call site
(ui::home) passes a no-op.

Deliberately not covered: 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, which has no meaningful single-screenshot
representation in an isolated environment with no real desktop for it
to point at. guide_view and keybind_viewer aren't separate views either
— both render embedded inside the Learn/Home tabs already captured.
2026-07-29 21:37:22 +08:00
41 changed files with 1575 additions and 320 deletions

View file

@ -0,0 +1,24 @@
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

View file

@ -1,12 +1,11 @@
name: dev release name: dev release
# Publishes a dev-track build on every push to `dev` — # Publishes a dev-track build on every push to `main` (the trunk
# separate from release.yml's tag-triggered stable releases. See # branch — there is no separate `dev` branch). See bread-ecosystem's
# bread-ecosystem's docs/release-channels.md for the three-track policy # docs/release-channels.md for the release-track policy this is part of.
# this is part of.
on: on:
push: push:
branches: ['dev'] branches: ['main']
jobs: jobs:
build: build:
@ -16,14 +15,14 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
rm -rf src && mkdir src rm -rf src && mkdir src
git clone --branch dev --depth 1 \ git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build - name: build
run: cd src && cargo build --release --locked run: cd src && bash ci/build.sh cargo build --release --locked
- name: test - name: test
run: cd src && cargo test --release --locked run: cd src && bash ci/build.sh cargo test --release --locked
- name: compute dev version - name: compute dev version
run: | run: |
@ -35,7 +34,7 @@ jobs:
# what's already installed and bakery would correctly refuse it. # what's already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \ LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \ "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | sort -V | tail -1)" | awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}" CUR="${LATEST_TAG}"
else else
@ -72,10 +71,10 @@ jobs:
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)" 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 exit 1
fi fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true # Do not wipe /tmp/bread-ecosystem-ci-* — other products' ci/build.sh
# mktemp: a fixed clone path races when multiple repos' dev/beta # caches live there and a wipe between their build and test steps
# workflows run close together on the same self-hosted runner. # is a 127 (missing ci/build.sh).
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)" ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-index-XXXXXX)"
git clone --branch dev https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}" 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" TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}" rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -1,40 +0,0 @@
name: Build and publish package
on:
push:
tags: ['v*']
jobs:
package:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
# Note: no actions/checkout — the archlinux image has no Node, which JS
# actions require. Everything runs as shell steps and clones manually.
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
pacman -Syu --noconfirm base-devel git rust cargo gtk4 glib2 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"

View file

@ -1,54 +1,35 @@
name: beta release name: beta (rc) release
# Publishes a beta-track build on every push to `beta` — a frozen # Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
# stabilization branch cut from `dev` when ready to stabilize; only # pushed to `main` — there is no separate `beta` branch; "freezing" is
# fix/<issue> branches merged into `beta` should land here afterward. # just pausing pushes to main while an RC gets tested. See
# See bread-ecosystem's docs/release-channels.md for the three-track policy. # bread-ecosystem's docs/release-channels.md for the release-track policy.
on: on:
push: push:
branches: ['beta'] tags: ['v*']
jobs: jobs:
build: build:
if: ${{ contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia] runs-on: [self-hosted, hestia]
steps: steps:
- name: checkout - name: checkout
run: | run: |
set -euo pipefail set -euo pipefail
rm -rf src && mkdir src rm -rf src && mkdir src
git clone --branch beta --depth 1 \ git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build - name: build
run: cd src && cargo build --release --locked run: cd src && bash ci/build.sh cargo build --release --locked
- name: test - name: test
run: cd src && cargo test --release --locked run: cd src && cargo test --release --locked
- name: compute beta version
run: |
set -euo pipefail
cd src
# Base the beta 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 beta 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//' | 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))-beta.${TS}+${SHA}" >> "$GITHUB_ENV"
- name: prepare artifacts - name: prepare artifacts
run: | run: |
set -euo pipefail set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/beta/breadhelp/${VERSION}" PKG_DIR="/srv/breadway-dl/beta/breadhelp/${VERSION}"
mkdir -p "${PKG_DIR}" mkdir -p "${PKG_DIR}"
cp "src/target/release/breadhelp" "${PKG_DIR}/breadhelp-x86_64" cp "src/target/release/breadhelp" "${PKG_DIR}/breadhelp-x86_64"

View file

@ -6,6 +6,7 @@ on:
jobs: jobs:
build: build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia] runs-on: [self-hosted, hestia]
steps: steps:
- name: checkout - name: checkout
@ -16,10 +17,19 @@ jobs:
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build - name: build
run: cd src && cargo build --release --locked 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 - name: test
run: cd src && cargo test --release --locked run: cd src && bash ci/build.sh cargo test --release --locked
- name: prepare artifacts - name: prepare artifacts
run: | run: |

4
.gitignore vendored
View file

@ -40,4 +40,6 @@ logs/
.claude/ .claude/
# Local hygiene notes (not for commit) # Local hygiene notes (not for commit)
CLAUDE.md
# graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/

23
AGENTS.md Normal file
View file

@ -0,0 +1,23 @@
# 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/<name>` or `fix/<name>` 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.

84
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,84 @@
# 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/<short-name>
fix/<issue-number-or-short-name>
```
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.<timestamp>+<sha>`) 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.

189
Cargo.lock generated
View file

@ -34,18 +34,29 @@ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]] [[package]]
name = "bread-screenshots" name = "bread-screenshots"
version = "0.3.1" version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#1a3475bd2358202f60e29c9bd27d06b1428b1a27" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils 0.3.1", "bread-utils",
"tracing", "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",
]
[[package]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.2.3" version = "0.7.4"
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.4#fcba3760387e2523edb71350f8efea3bc851b21e"
dependencies = [ dependencies = [
"dirs", "dirs",
"gtk4", "gtk4",
@ -55,34 +66,25 @@ dependencies = [
[[package]] [[package]]
name = "bread-utils" name = "bread-utils"
version = "0.3.0" version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939" source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [ dependencies = [
"bread-shared",
"dirs", "dirs",
"serde", "serde",
"serde_json", "serde_json",
"toml_edit 0.22.27", "toml_edit 0.22.27",
] ]
[[package]]
name = "bread-utils"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#1a3475bd2358202f60e29c9bd27d06b1428b1a27"
dependencies = [
"dirs",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "breadhelp" name = "breadhelp"
version = "0.2.1" version = "0.2.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
"bread-screenshots", "bread-screenshots",
"bread-theme", "bread-theme",
"bread-utils 0.3.0", "bread-utils",
"gdk4", "gdk4",
"glib", "glib",
"gtk4", "gtk4",
@ -113,7 +115,7 @@ checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -176,11 +178,10 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "event-listener" name = "event-listener"
version = "5.4.1" version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [ dependencies = [
"concurrent-queue",
"parking", "parking",
"pin-project-lite", "pin-project-lite",
] ]
@ -207,24 +208,24 @@ dependencies = [
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [ dependencies = [
"futures-core", "futures-core",
] ]
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]] [[package]]
name = "futures-executor" name = "futures-executor"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-task", "futures-task",
@ -233,32 +234,32 @@ dependencies = [
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.3",
] ]
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-macro", "futures-macro",
@ -289,7 +290,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -322,7 +323,7 @@ dependencies = [
"libc", "libc",
"pango-sys", "pango-sys",
"pkg-config", "pkg-config",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -362,7 +363,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -416,7 +417,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -426,7 +427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
dependencies = [ dependencies = [
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -437,7 +438,7 @@ checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -458,7 +459,7 @@ checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04"
dependencies = [ dependencies = [
"glib-sys", "glib-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -489,7 +490,7 @@ dependencies = [
"graphene-sys", "graphene-sys",
"libc", "libc",
"pango-sys", "pango-sys",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -515,9 +516,9 @@ dependencies = [
[[package]] [[package]]
name = "gtk4-layer-shell" name = "gtk4-layer-shell"
version = "0.8.0" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"gdk4", "gdk4",
@ -530,15 +531,15 @@ dependencies = [
[[package]] [[package]]
name = "gtk4-layer-shell-sys" name = "gtk4-layer-shell-sys"
version = "0.6.0" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b"
dependencies = [ dependencies = [
"gdk4-sys", "gdk4-sys",
"glib-sys", "glib-sys",
"gtk4-sys", "gtk4-sys",
"libc", "libc",
"system-deps", "system-deps 8.0.0",
] ]
[[package]] [[package]]
@ -550,7 +551,7 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -569,7 +570,7 @@ dependencies = [
"gsk4-sys", "gsk4-sys",
"libc", "libc",
"pango-sys", "pango-sys",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -608,15 +609,15 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.18" version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
dependencies = [ dependencies = [
"libc", "libc",
] ]
@ -674,7 +675,7 @@ dependencies = [
"glib-sys", "glib-sys",
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps 7.0.8",
] ]
[[package]] [[package]]
@ -691,9 +692,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
@ -706,18 +707,18 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.46" version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@ -750,9 +751,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive", "serde_derive",
@ -760,29 +761,29 @@ dependencies = [
[[package]] [[package]]
name = "serde_core" name = "serde_core"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.228" version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.3",
] ]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.150" version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@ -832,6 +833,17 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "system-deps" name = "system-deps"
version = "7.0.8" version = "7.0.8"
@ -841,7 +853,20 @@ dependencies = [
"cfg-expr", "cfg-expr",
"heck", "heck",
"pkg-config", "pkg-config",
"toml 1.1.3+spec-1.1.0", "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",
"version-compare", "version-compare",
] ]
@ -868,7 +893,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -885,9 +910,9 @@ dependencies = [
[[package]] [[package]]
name = "toml" name = "toml"
version = "1.1.3+spec-1.1.0" version = "1.1.4+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde_core", "serde_core",
@ -944,9 +969,9 @@ dependencies = [
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.1.2+spec-1.1.0" version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [ dependencies = [
"winnow 1.0.4", "winnow 1.0.4",
] ]
@ -982,7 +1007,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@ -1113,9 +1138,9 @@ dependencies = [
[[package]] [[package]]
name = "xml-rs" name = "xml-rs"
version = "0.8.28" version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]] [[package]]
name = "zmij" name = "zmij"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "breadhelp" name = "breadhelp"
version = "0.2.1" version = "0.2.6"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@ -10,15 +10,14 @@ gtk4-layer-shell = "0.8"
glib = "0.22" glib = "0.22"
# Shared ecosystem theming — same generated stylesheet bos-settings/breadbar/ # Shared ecosystem theming — same generated stylesheet bos-settings/breadbar/
# breadbox/breadpad load, so this looks like part of the same desktop. # breadbox/breadpad load, so this looks like part of the same desktop.
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8" toml = "0.8"
# Non-destructive state editing (mirrors bos-settings/src/config/mod.rs). # Non-destructive state editing (mirrors bos-settings/src/config/mod.rs).
toml_edit = "0.22" toml_edit = "0.22"
async-channel = "2" async-channel = "2"
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["toml"] } 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. Not # Capture primitives for `--screenshot` mode — see src/screenshot.rs.
# tag-pinned like the deps above since it doesn't have a tagged release yet. bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" }
anyhow = "1" anyhow = "1"

68
EVENTS.md Normal file
View file

@ -0,0 +1,68 @@
# 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": "<message>" }` | `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.

View file

@ -1,8 +1,8 @@
# breadhelp # 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, 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, 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.
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`. 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.
## Building ## Building
@ -12,4 +12,9 @@ cargo build --release
## Packaging / releasing ## Packaging / releasing
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. 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).

1
ci/bread-ecosystem.rev Normal file
View file

@ -0,0 +1 @@
147cfbbf96ae4b171027defa1130d2caddb934b1

21
ci/build.sh Executable file
View file

@ -0,0 +1,21 @@
#!/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" "$@"

View file

@ -4,6 +4,6 @@ breadsearch indexes your files so you can find things instantly, without
digging through folders. digging through folders.
- Open it from BOS Settings, or run it directly. - Open it from BOS Settings, or run it directly.
- Type a few letters of a filename — results update as you type. - 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.
- [Run]breadsearch|Open File Search[/Run] - [Run]breadsearch|Open File Search[/Run]

View file

@ -13,9 +13,17 @@ nothing here is mandatory either, click around if you'd rather.
## Apps ## Apps
- [Show Keybind]super + space[/Show Keybind] opens the launcher (breadbox) - [Show Keybind]super + space[/Show Keybind] opens the launcher (breadbox)
- [Show Keybind]super + e[/Show Keybind] opens files - [Show Keybind]super + e[/Show Keybind] opens files (nautilus)
- [Show Keybind]super + b[/Show Keybind] opens the browser - [Show Keybind]super + b[/Show Keybind] opens the browser
- [Show Keybind]super + u[/Show Keybind] opens notes - [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)
## The full list ## The full list

View file

@ -1,40 +0,0 @@
# Maintainer: Breadway <plasticbread849@gmail.com>
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"
}

View file

@ -1,26 +1,9 @@
Arch packaging # packaging/
==============
`PKGBUILD` builds and installs `breadhelp` from source. 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.
## Local build `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
```bash directory as a pacman package source.
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 |

View file

@ -4,7 +4,10 @@
//! `connect_command_line`, so `--onboard` on a second launch re-triggers the //! `connect_command_line`, so `--onboard` on a second launch re-triggers the
//! tour in the existing window instead of spawning a duplicate. //! tour in the existing window instead of spawning a duplicate.
#[derive(Clone, Default)] use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH};
use std::path::Path;
#[derive(Clone)]
pub struct Action { pub struct Action {
/// Force-restart the onboarding tour from step 0, regardless of whether /// Force-restart the onboarding tour from step 0, regardless of whether
/// it was already completed or in progress. /// it was already completed or in progress.
@ -14,7 +17,8 @@ pub struct Action {
/// `present()` build the window (so the app is ready to respond the /// `present()` build the window (so the app is ready to respond the
/// instant it's needed) without popping it open when onboarding is /// instant it's needed) without popping it open when onboarding is
/// already done — autostart should only be visible on a genuine first /// already done — autostart should only be visible on a genuine first
/// run, never on every subsequent login. /// run (day-zero wizard, then the desktop tour), never on every
/// subsequent login.
pub autostart: bool, pub autostart: bool,
/// From a breadd Lua module (e.g. `breadhelp-suggest.lua`) reacting to a /// From a breadd Lua module (e.g. `breadhelp-suggest.lua`) reacting to a
/// system event. Resolved to banner text by `services::breadd::resolve`. /// system event. Resolved to banner text by `services::breadd::resolve`.
@ -37,19 +41,36 @@ pub struct Action {
pub height: u32, 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 { impl Action {
/// `None` for a normal run. Exits the process with an error if /// `None` for a normal run. Exits the process with an error if the
/// `--screenshot` was given without `--output`, before any GTK setup /// `--screenshot` / `--output` pair is incomplete, before any GTK setup
/// happens. /// happens.
pub fn screenshot_request(&self) -> Option<crate::screenshot::ScreenshotRequest> { pub fn screenshot_request(&self) -> Option<crate::screenshot::ScreenshotRequest> {
let view = self.screenshot.clone()?; if let Err(e) = validate_pair(
let Some(output) = self.output.clone() else { self.screenshot.as_deref(),
eprintln!("breadhelp: --screenshot requires --output"); self.output.as_deref().map(Path::new),
) {
eprintln!("breadhelp: {e}");
std::process::exit(1); std::process::exit(1);
}; }
Some(crate::screenshot::ScreenshotRequest { Some(crate::screenshot::ScreenshotRequest {
view, view: self.screenshot.clone()?,
output: output.into(), output: self.output.clone()?.into(),
width: self.width, width: self.width,
height: self.height, height: self.height,
}) })
@ -57,7 +78,7 @@ impl Action {
} }
pub fn parse(args: &[std::ffi::OsString]) -> Action { pub fn parse(args: &[std::ffi::OsString]) -> Action {
let mut action = Action { width: 1920, height: 1080, ..Action::default() }; let mut action = Action::default();
let mut it = args.iter().skip(1); let mut it = args.iter().skip(1);
while let Some(arg) = it.next() { while let Some(arg) = it.next() {
if arg == "--onboard" { if arg == "--onboard" {

View file

@ -86,7 +86,11 @@ impl State {
} }
pub fn onboarding_step(&self) -> i64 { 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) { pub fn set_onboarding_step(&mut self, step: i64) {
@ -94,6 +98,48 @@ impl State {
self.save(); 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 /// Set the instant before `services::hyprland::rebind_temp` is called for
/// a tour step with no compositor-observable signal (e.g. the screenshot /// a tour step with no compositor-observable signal (e.g. the screenshot
/// step), cleared the instant after reverting. If breadhelp is killed /// step), cleared the instant after reverting. If breadhelp is killed
@ -101,8 +147,18 @@ impl State {
/// pointing at a chained `--tour-event` ping and self-heals it before /// 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`. /// the user can be surprised by a stray tour popup — see `ui::tour`.
pub fn pending_rebind(&self) -> Option<(String, String)> { pub fn pending_rebind(&self) -> Option<(String, String)> {
let key = self.doc.get("tour")?.get("pending_rebind_key")?.as_str()?.to_string(); let key = self
let original = self.doc.get("tour")?.get("pending_rebind_original")?.as_str()?.to_string(); .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)) Some((key, original))
} }
@ -121,7 +177,12 @@ impl State {
} }
pub fn mode(&self) -> Mode { 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) Mode::from_str(s)
} }
@ -145,14 +206,20 @@ mod tests {
// path save_doc uses (via tomlcfg::save_doc), at the file-layout // path save_doc uses (via tomlcfg::save_doc), at the file-layout
// breadhelp actually writes to — the deeper backup/no-leftover-tmp // breadhelp actually writes to — the deeper backup/no-leftover-tmp
// behavior itself is covered by bread-utils' own test suite. // 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())); let dir = std::env::temp_dir().join(format!(
"breadhelp-atomic-write-test-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("state.toml"); let path = dir.join("state.toml");
let backup = dir.join("state.toml.bak"); let backup = dir.join("state.toml.bak");
bread_utils::atomic::write_atomic_backed_up(&path, "first").unwrap(); bread_utils::atomic::write_atomic_backed_up(&path, "first").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first"); 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"); 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(); 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(&path).unwrap(), "second");
@ -164,14 +231,20 @@ mod tests {
.map(|e| e.file_name().to_string_lossy().into_owned()) .map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp.")) .filter(|n| n.contains(".tmp."))
.collect(); .collect();
assert!(leftover_tmp.is_empty(), "temp file should be renamed away, not left behind: {leftover_tmp:?}"); assert!(
leftover_tmp.is_empty(),
"temp file should be renamed away, not left behind: {leftover_tmp:?}"
);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test] #[test]
fn save_doc_then_load_doc_round_trips_state() { fn save_doc_then_load_doc_round_trips_state() {
let dir = std::env::temp_dir().join(format!("breadhelp-state-roundtrip-test-{}", std::process::id())); let dir = std::env::temp_dir().join(format!(
"breadhelp-state-roundtrip-test-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("state.toml"); let path = dir.join("state.toml");
@ -181,8 +254,50 @@ mod tests {
save_doc(&path, &doc); save_doc(&path, &doc);
let loaded = load_doc(&path); 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!(
assert_eq!(loaded.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()), Some("dad")); 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); let _ = std::fs::remove_dir_all(&dir);
} }

View file

@ -12,14 +12,23 @@ pub enum Difficulty {
#[derive(serde::Deserialize, Clone, Default)] #[derive(serde::Deserialize, Clone, Default)]
pub struct GuideMeta { pub struct GuideMeta {
pub title: String, 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)] #[serde(default)]
pub difficulty: Difficulty, pub difficulty: Difficulty,
#[serde(default)] #[serde(default)]
pub tags: Vec<String>, pub tags: Vec<String>,
/// Guide dir-names in the same or another category. Unresolvable entries /// Guide dir-names in the same or another category. Unresolvable entries
/// are silently dropped when rendering "related guides" — never a crash. /// 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)] #[serde(default)]
pub related: Vec<String>, pub related: Vec<String>,
/// 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)] #[serde(default)]
pub estimated_minutes: u32, pub estimated_minutes: u32,
/// Overrides the parent directory name as the guide's category, if set. /// Overrides the parent directory name as the guide's category, if set.

View file

@ -29,6 +29,10 @@ pub enum Success {
#[derive(serde::Deserialize, Clone)] #[derive(serde::Deserialize, Clone)]
pub struct Step { 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 id: String,
pub title: String, pub title: String,
pub body: String, pub body: String,

View file

@ -15,6 +15,11 @@ pub struct SymptomOption {
pub struct Fix { pub struct Fix {
pub description: String, pub description: String,
pub command: 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)] #[serde(default)]
pub requires_confirm: bool, pub requires_confirm: bool,
} }

84
src/listen.rs Normal file
View file

@ -0,0 +1,84 @@
//! 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<std::process::Child> {
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);
}
}

View file

@ -1,6 +1,7 @@
mod cli; mod cli;
mod config; mod config;
mod content; mod content;
mod listen;
mod screenshot; mod screenshot;
mod services; mod services;
mod theme; mod theme;
@ -10,6 +11,11 @@ use gtk4::gio::ApplicationFlags;
use gtk4::prelude::*; use gtk4::prelude::*;
fn main() { 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 // GApplication requires a dotted id (g_application_id_is_valid rejects a
// bare "breadhelp" with a GLib-GIO-CRITICAL and silently skips setting // bare "breadhelp" with a GLib-GIO-CRITICAL and silently skips setting
// it, which would break D-Bus single-instance activation). The Wayland // it, which would break D-Bus single-instance activation). The Wayland

View file

@ -7,17 +7,31 @@
//! see `ui::tabs`. The window is a plain top-level (not layer-shell), so a //! 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 //! full known-size canvas capture is enough, same reasoning as breadpad's
//! popup view. //! 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 gtk4::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
/// Extra settle time after `map` for the first frame to actually paint /// Delay before opening the wizard modal — same reasoning as every other
/// before grim runs — `map` fires once the surface exists, not once /// app's PRE_POPUP_DELAY: the parent window's own layout needs a beat to
/// anything has been drawn into it. /// settle first.
const SETTLE_DELAY: Duration = Duration::from_millis(300); const PRE_POPUP_DELAY: Duration = SETTLE_DELAY;
const KNOWN_VIEWS: &[&str] = &["home", "learn", "ask"]; const KNOWN_VIEWS: &[&str] = &["home", "learn", "ask", "troubleshoot-wizard"];
#[derive(Clone)] #[derive(Clone)]
pub struct ScreenshotRequest { pub struct ScreenshotRequest {
@ -39,20 +53,41 @@ pub fn dispatch(window: &gtk4::ApplicationWindow, stack: &gtk4::Stack, req: Scre
); );
std::process::exit(1); std::process::exit(1);
} }
stack.set_visible_child_name(&req.view);
let output = req.output; let output = req.output;
let (width, height) = (req.width as i32, req.height as i32); 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 |_| { window.connect_map(move |_| {
let output = output.clone(); let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || { gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output)); 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(); window.present();
} }

View file

@ -1,6 +1,23 @@
//! Maps a `--suggest <id>` payload (sent by a breadd Lua module, e.g. //! Bread bus integration. `--suggest` banners stay local; `bread.help.*`
//! `breadhelp-suggest.lua` reacting to `bread.monitor.connected`) to Home //! emits are optional and fail-silent. See `EVENTS.md`.
//! tab banner text. //!
//! `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 }),
);
}
pub struct Suggestion { pub struct Suggestion {
pub text: String, pub text: String,

View file

@ -1,3 +1,4 @@
use std::path::Path;
use std::process::Command; use std::process::Command;
/// Fire-and-forget: runs `command` via a shell, matching the idiom already /// Fire-and-forget: runs `command` via a shell, matching the idiom already
@ -8,6 +9,35 @@ 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 /// Runs `command` on a background thread and delivers success/failure back
/// onto the GTK main loop via `on_done` — plain `spawn()` only confirms the /// 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 /// process *launched*, not how it exited, and one-click fixes need to report
@ -26,3 +56,19 @@ 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<String> = 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);
}
});
}

View file

@ -28,10 +28,6 @@ pub struct Client {
pub struct Monitor { pub struct Monitor {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
pub x: i32,
pub y: i32,
pub width: i32,
pub height: i32,
pub focused: bool, pub focused: bool,
} }

View file

@ -10,7 +10,7 @@ use std::cell::RefCell;
const APP_CSS: &str = "\ const APP_CSS: &str = "\
.view-content { padding: 24px; }\n\ .view-content { padding: 24px; }\n\
.view-content > label.title { margin-bottom: 16px; }\n\ .view-content > label.page-title { margin-bottom: 16px; }\n\
/* [Show Keybind] chip: reads as a keycap inline with guide/keybind-viewer text. */\n\ /* [Show Keybind] chip: reads as a keycap inline with guide/keybind-viewer text. */\n\
label.keybind-chip { \ label.keybind-chip { \
font-family: monospace; \ font-family: monospace; \
@ -44,7 +44,7 @@ window.tour-window { background-color: transparent; }\n\
border-radius: 12px; \ border-radius: 12px; \
padding: 16px 20px; \ padding: 16px 20px; \
}\n\ }\n\
.tour-callout label.title { font-weight: bold; font-size: 15px; }\n\ .tour-callout label.page-title { font-weight: bold; font-size: 15px; }\n\
/* The one payoff line in the whole tour — the user did the real thing and \ /* 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 \ the compositor proved it shouldn't read as the same throwaway meta \
text as the step counter. */\n\ text as the step counter. */\n\
@ -53,6 +53,24 @@ window.tour-window { background-color: transparent; }\n\
the \"Show me\" fallback as its own beat, not a continuation of the \ the \"Show me\" fallback as its own beat, not a continuation of the \
instructional paragraph above it. */\n\ instructional paragraph above it. */\n\
.tour-hint-row { margin-top: 8px; }\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! { thread_local! {
@ -63,3 +81,8 @@ pub fn load(_display: &gtk4::gdk::Display) {
bread_theme::gtk::apply_shared(); bread_theme::gtk::apply_shared();
APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell)); APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell));
} }
pub fn bind_window(window: &impl gtk4::prelude::IsA<gtk4::Native>) {
bread_theme::gtk::bind_window_auto_with_app_css(window, |_| APP_CSS.to_string());
}

View file

@ -148,7 +148,7 @@ pub fn build(store: Rc<ContentStore>, binds: Rc<Vec<Keybind>>, mode: Mode) -> Wi
} }
Kind::Command { name, description } => { Kind::Command { name, description } => {
let title = Label::new(Some(name)); let title = Label::new(Some(name));
title.add_css_class("title"); title.add_css_class("page-title");
title.set_xalign(0.0); title.set_xalign(0.0);
detail.append(&title); detail.append(&title);

665
src/ui/day_zero.rs Normal file
View file

@ -0,0 +1,665 @@
//! 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<Keybind>,
step: RefCell<usize>,
finished: Cell<bool>,
on_finished: Box<dyn Fn()>,
}
/// 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<String> {
stdout
.trim()
.strip_prefix("Timezone=")
.map(str::to_string)
.filter(|s| !s.is_empty())
}
fn parse_timezone_list(stdout: &str) -> Vec<String> {
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<Self>, 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<Self>) {
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>) {
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<Self>) {
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<Self>) {
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(&current_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 == &current {
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<ListBoxRow> = 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<Self>) {
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<String> = 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<Self>) {
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<String> = 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<Self>) {
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<Self>) {
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<String>) {
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"]
);
}
}

View file

@ -8,7 +8,7 @@ pub fn build(store: &ContentStore, guide: &Guide, simplified: bool, binds: &[Key
vbox.add_css_class("view-content"); vbox.add_css_class("view-content");
let title = Label::new(Some(&guide.meta.title)); let title = Label::new(Some(&guide.meta.title));
title.add_css_class("title"); title.add_css_class("page-title");
title.set_xalign(0.0); title.set_xalign(0.0);
vbox.append(&title); vbox.append(&title);

View file

@ -47,7 +47,7 @@ pub fn build(
let header = GBox::new(Orientation::Horizontal, 12); let header = GBox::new(Orientation::Horizontal, 12);
let title = Label::new(Some("Home")); let title = Label::new(Some("Home"));
title.add_css_class("title"); title.add_css_class("page-title");
title.set_xalign(0.0); title.set_xalign(0.0);
title.set_hexpand(true); title.set_hexpand(true);
header.append(&title); header.append(&title);
@ -83,7 +83,7 @@ pub fn build(
troubleshoot_btn.set_halign(Align::Start); troubleshoot_btn.set_halign(Align::Start);
{ {
let parent = parent_window.clone(); 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); vbox.append(&troubleshoot_btn);

View file

@ -7,7 +7,7 @@ pub fn build(binds: &[Keybind]) -> gtk4::Widget {
let vbox = GBox::new(Orientation::Vertical, 6); let vbox = GBox::new(Orientation::Vertical, 6);
let header = Label::new(Some("Keybinds")); let header = Label::new(Some("Keybinds"));
header.add_css_class("title"); header.add_css_class("page-title");
header.set_xalign(0.0); header.set_xalign(0.0);
vbox.append(&header); vbox.append(&header);

View file

@ -1,4 +1,5 @@
pub mod ask; pub mod ask;
pub mod day_zero;
pub mod guide_view; pub mod guide_view;
pub mod home; pub mod home;
pub mod keybind_viewer; pub mod keybind_viewer;

View file

@ -49,6 +49,7 @@ pub fn build(
window.set_layer(Layer::Overlay); window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::OnDemand); window.set_keyboard_mode(KeyboardMode::OnDemand);
window.set_monitor(Some(monitor)); window.set_monitor(Some(monitor));
crate::theme::bind_window(&window);
if let Some(rect) = rect { if let Some(rect) = rect {
let (mon_w, mon_h) = monitor_size; let (mon_w, mon_h) = monitor_size;
@ -72,7 +73,7 @@ pub fn build(
root.set_size_request(CALLOUT_WIDTH, -1); root.set_size_request(CALLOUT_WIDTH, -1);
let title = Label::new(Some(&step.title)); let title = Label::new(Some(&step.title));
title.add_css_class("title"); title.add_css_class("page-title");
title.set_xalign(0.0); title.set_xalign(0.0);
title.set_wrap(true); title.set_wrap(true);
root.append(&title); root.append(&title);

View file

@ -20,6 +20,7 @@ fn new_mask_window(monitor: &gdk4::Monitor) -> Window {
window.set_layer(Layer::Overlay); window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::None); window.set_keyboard_mode(KeyboardMode::None);
window.set_monitor(Some(monitor)); window.set_monitor(Some(monitor));
crate::theme::bind_window(&window);
let dim = GBox::new(gtk4::Orientation::Vertical, 0); let dim = GBox::new(gtk4::Orientation::Vertical, 0);
dim.add_css_class("tour-mask"); dim.add_css_class("tour-mask");
dim.set_hexpand(true); dim.set_hexpand(true);

View file

@ -110,7 +110,7 @@ pub fn self_heal() {
pub fn on_tour_event(id: &str) { pub fn on_tour_event(id: &str) {
let hit = STATE.with(|cell| { let hit = STATE.with(|cell| {
let mut borrow = cell.borrow_mut(); let mut borrow = cell.borrow_mut();
let Some(state) = borrow.as_mut() else { return None }; let state = borrow.as_mut()?;
// Only a *visibly* confirmed step blocks a re-fire. `confirmed` gets // Only a *visibly* confirmed step blocks a re-fire. `confirmed` gets
// set below, before `render_step` runs, so that render actually // set below, before `render_step` runs, so that render actually
// paints the "done" state instead of the stale pre-confirmation one // paints the "done" state instead of the stale pre-confirmation one

View file

@ -25,7 +25,11 @@ struct Wizard {
trees: HashMap<String, Vec<SymptomNode>>, trees: HashMap<String, Vec<SymptomNode>>,
} }
pub fn open(parent: &impl IsA<gtk4::Window>) { /// `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<gtk4::Window>, on_build: impl FnOnce(&Window)) {
let window = Window::builder() let window = Window::builder()
.transient_for(parent) .transient_for(parent)
.modal(true) .modal(true)
@ -33,6 +37,11 @@ pub fn open(parent: &impl IsA<gtk4::Window>) {
.default_width(480) .default_width(480)
.default_height(360) .default_height(360)
.build(); .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); let content = GBox::new(Orientation::Vertical, 12);
content.add_css_class("view-content"); content.add_css_class("view-content");
@ -41,6 +50,7 @@ pub fn open(parent: &impl IsA<gtk4::Window>) {
wizard.render_picker(); wizard.render_picker();
window.set_child(Some(&content)); window.set_child(Some(&content));
on_build(&window);
window.present(); window.present();
} }
@ -55,7 +65,7 @@ impl Wizard {
self.clear(); self.clear();
let title = Label::new(Some("What's wrong?")); let title = Label::new(Some("What's wrong?"));
title.add_css_class("title"); title.add_css_class("page-title");
title.set_xalign(0.0); title.set_xalign(0.0);
self.content.append(&title); self.content.append(&title);

View file

@ -6,10 +6,11 @@ use gtk4::{Application, ApplicationWindow, Box as GBox, Orientation, Stack, Stac
use crate::cli::Action; use crate::cli::Action;
use crate::config::State; use crate::config::State;
use crate::content::keybinds::Keybind;
use crate::content::{keybinds, ContentStore}; use crate::content::{keybinds, ContentStore};
use super::home::Home; use super::home::Home;
use super::{ask, learn, modes, tabs, tour}; use super::{ask, day_zero, learn, modes, tabs, tour};
const DEFAULT_TAB: &str = "home"; const DEFAULT_TAB: &str = "home";
@ -17,6 +18,8 @@ struct Handle {
window: ApplicationWindow, window: ApplicationWindow,
home: Home, home: Home,
stack: Stack, stack: Stack,
store: Rc<ContentStore>,
binds: Rc<Vec<Keybind>>,
} }
thread_local! { thread_local! {
@ -27,13 +30,33 @@ thread_local! {
/// over D-Bus to this primary instance by a second `breadhelp` launch — so /// 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. /// it must build the window at most once and reuse it thereafter.
pub fn present(app: &Application, action: Action) { 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| { HANDLE.with(|cell| {
let mut just_built = false;
{ {
let mut cell_ref = cell.borrow_mut(); let mut cell_ref = cell.borrow_mut();
if cell_ref.is_none() { if cell_ref.is_none() {
*cell_ref = Some(build(app)); *cell_ref = Some(build(app));
just_built = true;
} }
} }
let cell_ref = cell.borrow(); let cell_ref = cell.borrow();
@ -59,29 +82,55 @@ pub fn present(app: &Application, action: Action) {
if let Some(s) = crate::services::breadd::resolve(id) { if let Some(s) = crate::services::breadd::resolve(id) {
handle.home.set_suggestion(Some(&s.text)); handle.home.set_suggestion(Some(&s.text));
} }
// Only focus the window the first time this process builds it // Background daemon event — never steal focus. A `--suggest`
// (i.e. breadhelp wasn't already running) — a background daemon // can now be the first invocation of the session because
// event shouldn't steal focus from whatever the user is doing. // silent autostart no longer builds a window.
if just_built {
handle.window.present();
}
return; return;
} }
// Every-login autostart builds the window (so the app is ready to // Bare silent autostart (onboarding done) already returned above.
// respond to SUPER+/ instantly) but only starts the tour / pops the // Autostart that reaches here is a genuine first run.
// window open on a genuine first run — never on later logins. let onboarded = State::load().onboarding_completed();
if action.autostart && !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 {
tour::start(&display); tour::start(&display);
return; return;
} }
let silent_autostart = action.autostart && State::load().onboarding_completed(); show_window(&handle.window, action.autostart);
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 { fn build(app: &Application) -> Handle {
// First thing on every cold start: revert any keybind a previous, // First thing on every cold start: revert any keybind a previous,
// crashed run may have left temporarily rebound mid tour-step (see // crashed run may have left temporarily rebound mid tour-step (see
@ -97,6 +146,7 @@ fn build(app: &Application) -> Handle {
.build(); .build();
crate::theme::load(&WidgetExt::display(&window)); crate::theme::load(&WidgetExt::display(&window));
crate::theme::bind_window(&window);
let store = Rc::new(ContentStore::load()); let store = Rc::new(ContentStore::load());
let binds = Rc::new(keybinds::load()); let binds = Rc::new(keybinds::load());
@ -118,8 +168,16 @@ fn build(app: &Application) -> Handle {
} }
}); });
stack.add_titled(&home.root, Some("home"), "Home"); stack.add_titled(&home.root, Some("home"), "Home");
stack.add_titled(&learn::build(&store, &binds, state.mode()), Some("learn"), "Learn"); stack.add_titled(
stack.add_titled(&ask::build(store.clone(), binds.clone(), state.mode()), Some("ask"), "Ask"); &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); stack.set_visible_child_name(DEFAULT_TAB);
let switcher = tabs::build(&stack); let switcher = tabs::build(&stack);
@ -130,12 +188,17 @@ fn build(app: &Application) -> Handle {
window.set_child(Some(&content_vbox)); window.set_child(Some(&content_vbox));
// Deliberately not presented here — `present()` (the caller) decides // Deliberately not presented here — `present()` (the caller) decides
// whether this initial build should actually be shown (see // whether this initial build should actually be shown, so a from-cold
// `silent_autostart` above), so a from-cold every-login autostart with // every-login autostart with onboarding already complete builds a
// onboarding already complete builds a ready-but-hidden window instead // ready-but-hidden window instead of flashing it open. First run
// of flashing it open. On a genuine first run, the tour overlay runs // presents the window with the day-zero wizard; the tour overlay
// independently of this window (see `tour::start`) — it never needs to // starts after that finishes.
// be shown at all until the user explicitly opens it later.
Handle { window, home, stack } Handle {
window,
home,
stack,
store,
binds,
}
} }