Compare commits
48 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc088a3d76 | |||
|
|
7286b49457 | ||
|
|
c97029f654 | ||
|
|
1fce1979c3 | ||
|
|
72afe790fd | ||
|
|
e073a17353 | ||
|
|
3865327c66 | ||
|
|
670cf22f2c | ||
|
|
cdd5de8f58 | ||
|
|
6063eb901c | ||
|
|
d3517d1433 | ||
|
|
a6973360bd | ||
|
|
da889d6f6a | ||
|
|
81fbc46e4f | ||
|
|
2485e1af1f | ||
|
|
50ff425d2a | ||
|
|
d26861697c | ||
|
|
1e2817537b | ||
|
|
450454d164 | ||
|
|
252f65593c | ||
|
|
3c70c7a823 | ||
|
|
3510c3ba90 | ||
|
|
c8f6a72331 | ||
|
|
d29466cb1e | ||
|
|
0384ea1354 | ||
|
|
94be831a21 | ||
|
|
6841163620 | ||
|
|
d270ac6ff7 | ||
|
|
45b5aee117 | ||
|
|
c5e871694a | ||
|
|
6ff1ee910b | ||
|
|
7bb6fbb20f | ||
|
|
96639516b1 | ||
|
|
b9e2530743 | ||
|
|
b00e145a93 | ||
|
|
34854f1471 | ||
|
|
8a794c03bf | ||
|
|
f9a8c4f915 | ||
|
|
8d3f55b607 | ||
|
|
f3905d8114 | ||
|
|
ef035dc687 | ||
|
|
e28b5cedee | ||
|
|
21131672ab | ||
|
|
d480209ec2 | ||
|
|
00ba49bfe9 | ||
|
|
edb37a28e0 | ||
|
|
d0f9c6c578 | ||
|
|
d563461d7d |
176 changed files with 127142 additions and 538 deletions
24
.forgejo/workflows/check.yml
Normal file
24
.forgejo/workflows/check.yml
Normal 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 --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
- name: test
|
||||
run: cd src && bash ci/build.sh cargo test --workspace --locked
|
||||
93
.forgejo/workflows/dev-release.yml
Normal file
93
.forgejo/workflows/dev-release.yml
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
name: dev release
|
||||
|
||||
# Publishes a dev-track build on every push to `main` (the trunk
|
||||
# branch — there is no separate `dev` branch). See bread-ecosystem's
|
||||
# docs/release-channels.md for the release-track policy this is part of.
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch main --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
# Fails fast, before the expensive release build/test below, on any
|
||||
# drift between api-schema.toml, the actual bread.*/IPC/CLI surface,
|
||||
# Documentation.md, and README.md — see api-schema.toml's header and
|
||||
# CONTRIBUTING.md's "Keeping the API docs honest" section.
|
||||
- name: check-docs
|
||||
run: cd src && cargo run -p xtask --locked -- check-docs
|
||||
|
||||
- name: build
|
||||
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||
|
||||
- name: test
|
||||
run: cd src && bash ci/build.sh cargo test --release --locked --workspace
|
||||
|
||||
# breadd/Cargo.toml is the canonical version source (this workspace has
|
||||
# no shared workspace.package.version — breadd and bread-cli are kept
|
||||
# in lockstep manually).
|
||||
- name: compute dev version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src
|
||||
# Base the dev version off the latest published stable tag,
|
||||
# not Cargo.toml — Cargo.toml can go stale relative to the last
|
||||
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
|
||||
# breadpaper), which would make a dev build sort as OLDER than
|
||||
# what's already installed and bakery would correctly refuse it.
|
||||
LATEST_TAG="$(git ls-remote --tags --refs \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
|
||||
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
|
||||
if [ -n "${LATEST_TAG}" ]; then
|
||||
CUR="${LATEST_TAG}"
|
||||
else
|
||||
CUR="$(grep -m1 '^version' breadd/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||
fi
|
||||
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||
SHA="$(git rev-parse --short HEAD)"
|
||||
TS="$(date -u +%Y%m%d%H%M%S)"
|
||||
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR="/srv/breadway-dl/dev/bread/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
for bin in breadd bread bread-emit bread-module-host; do
|
||||
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
||||
strip "${PKG_DIR}/${bin}-x86_64"
|
||||
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
||||
done
|
||||
cp src/packaging/systemd/breadd.service "${PKG_DIR}/"
|
||||
cp src/LICENSE "${PKG_DIR}/"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bread/latest"
|
||||
|
||||
# No GitHub Release upload — dev builds happen on every push and would
|
||||
# spam a release per commit, so dl.breadway.dev/dev/ is the only
|
||||
# distribution point for this track.
|
||||
- name: regenerate dev index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||
# mktemp: a fixed clone path races when multiple repos' dev/beta
|
||||
# workflows run close together on the same self-hosted runner.
|
||||
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['**']
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: Mirror to GitHub
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
|
||||
cd repo.git
|
||||
git push --prune \
|
||||
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bread.git" \
|
||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
|
|
@ -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 libgit2 openssl
|
||||
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="bread-${VERSION}/" HEAD \
|
||||
> packaging/arch/bread-${VERSION}.tar.gz
|
||||
SHA=$(sha256sum packaging/arch/bread-${VERSION}.tar.gz | awk '{print $1}')
|
||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
|
||||
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
|
||||
chown -R builder:builder /home/builder/src
|
||||
# --nocheck: packaging builds the artifact; tests belong in a CI job.
|
||||
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
|
||||
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1)
|
||||
curl -fsS -X PUT \
|
||||
-H "Authorization: token ${PUBLISH_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${PKG}" \
|
||||
"https://git.breadway.dev/api/packages/Breadway/arch/os"
|
||||
64
.forgejo/workflows/rc-release.yml
Normal file
64
.forgejo/workflows/rc-release.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
name: beta (rc) release
|
||||
|
||||
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
|
||||
# pushed to `main` — there is no separate `beta` branch; "freezing" is
|
||||
# just pausing pushes to main while an RC gets tested. See
|
||||
# bread-ecosystem's docs/release-channels.md for the release-track policy.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
- name: build
|
||||
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||
|
||||
- name: test
|
||||
run: cd src && bash ci/build.sh cargo test --release --locked --workspace
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/beta/bread/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
for bin in breadd bread bread-emit bread-module-host; do
|
||||
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
||||
strip "${PKG_DIR}/${bin}-x86_64"
|
||||
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
||||
done
|
||||
cp src/packaging/systemd/breadd.service "${PKG_DIR}/"
|
||||
cp src/LICENSE "${PKG_DIR}/"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bread/latest"
|
||||
|
||||
# No GitHub Release upload — beta builds happen on every push while
|
||||
# the branch is frozen for testing, so dl.breadway.dev/beta/ is the
|
||||
# only distribution point for this track.
|
||||
- name: regenerate beta index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||
# mktemp: a fixed clone path races when multiple repos' dev/beta
|
||||
# workflows run close together on the same self-hosted runner.
|
||||
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||
|
|
@ -6,6 +6,7 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ !contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
|
|
@ -16,10 +17,10 @@ jobs:
|
|||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
- name: build
|
||||
run: cd src && cargo build --release --locked
|
||||
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||
|
||||
- name: test
|
||||
run: cd src && cargo test --release --locked --workspace
|
||||
run: cd src && bash ci/build.sh cargo test --release --locked --workspace
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
|
|
@ -27,22 +28,33 @@ jobs:
|
|||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/bread/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
for bin in breadd bread; do
|
||||
for bin in breadd bread bread-emit bread-module-host; do
|
||||
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
|
||||
strip "${PKG_DIR}/${bin}-x86_64"
|
||||
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/${bin}-x86_64.sha256"
|
||||
done
|
||||
cp src/packaging/systemd/breadd.service "${PKG_DIR}/"
|
||||
cp src/LICENSE "${PKG_DIR}/"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/bread/latest"
|
||||
|
||||
- name: regenerate index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf /tmp/bread-ecosystem-ci
|
||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||
# mktemp: a fixed clone path races when multiple repos' release
|
||||
# workflows run close together on the same self-hosted runner.
|
||||
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||
bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||
|
||||
- name: upload to GitHub Release
|
||||
env:
|
||||
|
|
@ -56,6 +68,10 @@ jobs:
|
|||
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/bread \
|
||||
"${PKG_DIR}/breadd-x86_64" \
|
||||
"${PKG_DIR}/bread-x86_64" \
|
||||
"${PKG_DIR}/bread-emit-x86_64" \
|
||||
"${PKG_DIR}/bread-module-host-x86_64" \
|
||||
"${PKG_DIR}/breadd-x86_64.sha256" \
|
||||
"${PKG_DIR}/bread-x86_64.sha256" \
|
||||
"${PKG_DIR}/bread-emit-x86_64.sha256" \
|
||||
"${PKG_DIR}/bread-module-host-x86_64.sha256" \
|
||||
--clobber
|
||||
|
|
|
|||
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
|
|
@ -1,44 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, main, dev ]
|
||||
pull_request:
|
||||
branches: [ master, main, dev ]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy, rustfmt
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libudev-dev pkg-config
|
||||
- name: Cargo cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
. -> target
|
||||
- name: Format check
|
||||
run: cargo fmt --all --check
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- name: Build
|
||||
run: cargo build --workspace --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --verbose
|
||||
- name: Build release
|
||||
run: cargo build --workspace --release
|
||||
- name: Package artifacts
|
||||
run: |
|
||||
mkdir -p dist
|
||||
tar -czf dist/bread-ubuntu-latest.tgz target/release/breadd target/release/bread
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bread-ubuntu-latest
|
||||
path: dist/*.tgz
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -36,4 +36,3 @@ DAEMON.md
|
|||
LUA_RUNTIME.md
|
||||
CLAUDE_SPEC.md
|
||||
.claude
|
||||
CLAUDE.md
|
||||
|
|
|
|||
12
.grok/config.toml
Normal file
12
.grok/config.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Copied from .claude/settings.local.json
|
||||
[permission]
|
||||
allow = [
|
||||
"Bash(cargo test *)",
|
||||
"Bash(/home/breadway/Projects/bread/target/debug/bread health *)",
|
||||
"Bash(/home/breadway/Projects/bread/target/debug/bread sync *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(/home/breadway/Projects/bread/target/debug/bread modules *)",
|
||||
"Bash(/home/breadway/Projects/bread/target/debug/bread doctor *)",
|
||||
"Bash(/home/breadway/Projects/bread/target/debug/bread reload *)",
|
||||
"Bash(cargo build *)",
|
||||
]
|
||||
42
AGENTS.md
Normal file
42
AGENTS.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# AGENTS.md — Repo hygiene
|
||||
|
||||
This repo follows the branch/release workflow in `CONTRIBUTING.md`.
|
||||
Read that before any git, branch, or release work. Do not invent a
|
||||
different workflow.
|
||||
|
||||
Day-to-day: one long-lived branch (`main`). New work goes on
|
||||
`feature/<name>` or `fix/<name>`, then back into `main`. There is no
|
||||
`dev` or `beta` branch — those names are **bakery tracks**, published
|
||||
from `main` (dev) and from tags (`vX.Y.Z-rc.N` = beta, `vX.Y.Z` = stable).
|
||||
|
||||
API surface, event names, and IPC contracts live in `Documentation.md`
|
||||
(kept honest by `api-schema.toml` + `cargo run -p xtask -- check-docs`).
|
||||
Treat that file, not this one, as the source of truth.
|
||||
|
||||
## Remotes
|
||||
|
||||
- `origin` — Forgejo (`git.breadway.dev`) — authoritative.
|
||||
- `github` — mirror. Push tags/releases per `CONTRIBUTING.md`; day-to-day
|
||||
work pushes to `origin` only.
|
||||
|
||||
## CI
|
||||
|
||||
- `dev-release.yml` — push to `main` (includes `check-docs`).
|
||||
- `rc-release.yml` — `vX.Y.Z-rc.N` tag.
|
||||
- `release.yml` — other `v*` tags.
|
||||
|
||||
All three run on the self-hosted hestia runner. Nothing else runs on
|
||||
plain commits or PRs. Distribution is `bakery`, not a PKGBUILD.
|
||||
|
||||
## Local architecture (still true)
|
||||
|
||||
- `breadd` — daemon (adapters → normalizer → state engine → Lua / IPC).
|
||||
- `bread` — CLI over `$XDG_RUNTIME_DIR/bread/breadd.sock`.
|
||||
- `bread-emit` — fire-and-forget IPC emit (hooks + command bus).
|
||||
- `bread-module-host` — sandboxed out-of-process module runtime.
|
||||
- Known-app registry and reserved domains: `bread-shared/src/apps.rs`.
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||
- Don't commit straight to `main`.
|
||||
20
CLAUDE.md
Normal file
20
CLAUDE.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# CLAUDE.md — Repo hygiene
|
||||
|
||||
This repo follows the branch/release workflow documented in `CONTRIBUTING.md`
|
||||
— read and follow it for any git, branch, or release work here (the
|
||||
dev/beta/main lifecycle, `feature/x`/`fix/x` branch naming, when to cut or
|
||||
reset `beta`, etc). Don't improvise a different workflow.
|
||||
When starting work on a new feature, create branch "feature/<feature-name>" \
|
||||
When working on a bug or issue, create branch "fix/<issue you are fixing>"
|
||||
|
||||
## Remotes
|
||||
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
|
||||
- `github` — GitHub mirror. Push both when publishing.
|
||||
|
||||
## CI
|
||||
- `dev-release.yml` triggers on `push: branches: ['dev']`; `beta-release.yml`
|
||||
on `push: branches: ['beta']`; `release.yml` on a `v*` tag push. `package.yml` triggers the same way for the pacman-channel package.
|
||||
None of these run on plain commits or PRs beyond what's listed.
|
||||
|
||||
## Don't
|
||||
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||
108
CONTRIBUTING.md
Normal file
108
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Contributing
|
||||
|
||||
`bread` — Reactive automation daemon (breadd) and CLI for Linux desktops.
|
||||
|
||||
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 --workspace
|
||||
cargo test --release --workspace
|
||||
```
|
||||
|
||||
### Keeping the API docs honest
|
||||
|
||||
`Documentation.md`'s Lua API/IPC protocol sections and README's CLI
|
||||
reference are hand-written and have drifted from the actual code before —
|
||||
there's a checked-in registry, `api-schema.toml`, plus an `xtask` checker
|
||||
(`cargo run -p xtask -- check-docs`) that catches it happening again, and
|
||||
it's enforced in CI (`dev-release.yml` fails the build on any drift).
|
||||
|
||||
Whenever you add, rename, or remove a `bread.*` Lua binding
|
||||
(`breadd/src/lua/mod.rs`), an IPC method (`breadd/src/ipc/mod.rs`), or a
|
||||
`bread` CLI command (`bread-cli/src/main.rs`):
|
||||
|
||||
1. Add/update/remove its entry in `api-schema.toml` to match.
|
||||
2. Add/update the corresponding section — a `#### bread.<name>` heading in
|
||||
`Documentation.md` for a Lua binding, a row in `Documentation.md`'s IPC
|
||||
Methods table for an IPC method, or a `bread <name>` line in `README.md`'s
|
||||
"CLI reference" section for a CLI command.
|
||||
3. Run `cargo run -p xtask -- check-docs` before committing. It fails with
|
||||
a non-zero exit and a list of exactly what's out of sync — added but
|
||||
undocumented, stale in the schema, or missing a doc line — if
|
||||
`api-schema.toml`, the code, `Documentation.md`, and `README.md` don't
|
||||
all agree. CI runs this too, so anything that slips past a local run
|
||||
still fails the build rather than landing on `main`.
|
||||
|
||||
## 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.
|
||||
111
Cargo.lock
generated
111
Cargo.lock
generated
|
|
@ -293,7 +293,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "bread-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-shared",
|
||||
|
|
@ -311,30 +311,48 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "bread-emit"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"bread-shared",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-module-host"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-shared",
|
||||
"mlua",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-shared"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "breadd"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bread-shared",
|
||||
"futures-util",
|
||||
"landlock",
|
||||
"libc",
|
||||
"mlua",
|
||||
"netlink-packet-core",
|
||||
|
|
@ -349,6 +367,7 @@ dependencies = [
|
|||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"udev",
|
||||
"uuid",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
|
|
@ -528,23 +547,23 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -980,6 +999,17 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "landlock"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"libc",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
|
|
@ -1193,7 +1223,7 @@ dependencies = [
|
|||
"anyhow",
|
||||
"byteorder",
|
||||
"paste",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1494,13 +1524,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1543,7 +1573,7 @@ dependencies = [
|
|||
"netlink-packet-route",
|
||||
"netlink-proto",
|
||||
"nix 0.22.3",
|
||||
"thiserror",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
|
@ -1789,6 +1819,17 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
|
|
@ -1808,7 +1849,16 @@ version = "1.0.69"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1822,6 +1872,17 @@ dependencies = [
|
|||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
|
|
@ -2020,6 +2081,17 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
|
|
@ -2383,6 +2455,15 @@ dependencies = [
|
|||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "3.15.2"
|
||||
|
|
|
|||
15
Cargo.toml
15
Cargo.toml
|
|
@ -4,6 +4,8 @@ members = [
|
|||
"breadd",
|
||||
"bread-cli",
|
||||
"bread-emit",
|
||||
"bread-module-host",
|
||||
"xtask",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
|
@ -14,6 +16,17 @@ tokio = { version = "1.40", features = ["full"] }
|
|||
anyhow = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dirs = "5.0"
|
||||
dirs = "6.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tempfile = "3"
|
||||
# Pure-Rust bindings to the Linux Landlock LSM syscalls (landlock_create_ruleset/
|
||||
# landlock_restrict_self) — kernel 5.13+, no external bwrap/firejail binary
|
||||
# dependency. See Workstream G (bread-module-host / breadd's module_host
|
||||
# spawner) — the actual OS-level sandboxing mechanism for out-of-process
|
||||
# module execution. Verified against this repo's dev kernel (6.18) with a
|
||||
# real pre_exec()-restricted child process before adoption: filesystem reads
|
||||
# outside the granted rule set are denied at the kernel level
|
||||
# (RulesetStatus::FullyEnforced / PartiallyEnforced, EACCES on the denied
|
||||
# path), not merely a Lua-level check.
|
||||
landlock = "0.4"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
|
|
|||
42
DEPRECATIONS.md
Normal file
42
DEPRECATIONS.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Deprecations
|
||||
|
||||
Tracks API surface currently in its deprecation window per
|
||||
[`Documentation.md`'s API Stability & Versioning](Documentation.md#api-stability--versioning)
|
||||
policy — marked `Deprecated` and still functioning, pending removal in a
|
||||
future major version.
|
||||
|
||||
## Hyprland legacy flat event names (since v1.5)
|
||||
|
||||
**What's deprecated:** the 10 pre-namespace Hyprland event names emitted by
|
||||
`normalize_hyprland()` in `breadd/src/core/normalizer.rs`:
|
||||
|
||||
- `bread.workspace.changed`, `bread.workspace.created`, `bread.workspace.destroyed`
|
||||
- `bread.monitor.connected`, `bread.monitor.disconnected`
|
||||
- `bread.window.focus.changed`, `bread.window.focused`, `bread.window.opened`,
|
||||
`bread.window.closed`, `bread.window.moved`
|
||||
|
||||
**Recommended form going forward:** their `bread.hyprland.<rest>` equivalents
|
||||
(e.g. `bread.hyprland.workspace.changed`), which make explicit that these
|
||||
events are Hyprland-specific rather than portable across a future second
|
||||
compositor backend — see `Documentation.md`'s
|
||||
[Hyprland event reference](Documentation.md#hyprland) for the full mapping.
|
||||
|
||||
**Current behavior:** both names fire by default (`[compat]
|
||||
legacy_hyprland_event_names = true`). Setting that flag to `false` suppresses
|
||||
the legacy names; only `bread.hyprland.*` fires. The state engine applies
|
||||
*both* names (since v1.7.1), so disabling the legacy emit no longer freezes
|
||||
`bread.state` monitors / active workspace / active window.
|
||||
|
||||
**Deferred follow-up (not yet scheduled):**
|
||||
|
||||
1. Flip `[compat] legacy_hyprland_event_names`'s *default* to `false` in a
|
||||
later minor release, once downstream modules have had a full deprecation
|
||||
window to migrate.
|
||||
2. Remove the legacy flat names and the `[compat]` flag entirely in the next
|
||||
major version (v2) — at that point `normalize_hyprland()` only ever
|
||||
produces `bread.hyprland.*` names and the dual-emit machinery
|
||||
(`emit_hyprland_dual` in `normalizer.rs`) can be deleted.
|
||||
|
||||
Neither step is scheduled yet; this file exists so the removal isn't
|
||||
forgotten once the window closes. No issue tracker is wired up to this repo,
|
||||
so this note is the tracking mechanism until one exists.
|
||||
805
Documentation.md
805
Documentation.md
|
|
@ -8,9 +8,12 @@
|
|||
- [Your first module](#your-first-module)
|
||||
- [Run, reload, and watch](#run-reload-and-watch)
|
||||
- [Modules: install and manage](#modules-install-and-manage)
|
||||
- [Capability-scoped modules](#capability-scoped-modules-since-v15)
|
||||
- [Out-of-process module sandboxing](#out-of-process-module-sandboxing-since-v16)
|
||||
- [Debugging tips](#debugging-tips)
|
||||
- [Dictionary: Lua API](#dictionary-lua-api)
|
||||
- [Workflows](#workflows-since-v12)
|
||||
- [Widgets](#widgets-since-v13)
|
||||
- [Bluetooth](#bluetooth)
|
||||
- [Dictionary: Built-in modules](#dictionary-built-in-modules)
|
||||
- [Dictionary: Event reference](#dictionary-event-reference)
|
||||
|
|
@ -40,17 +43,63 @@ The Lua API surface, the IPC method set, the event-name vocabulary, and the runt
|
|||
- **Since markers.** Additions made after the v1.0 baseline are marked inline with `*Since: vX.Y*`. Anything documented in this file without a marker is part of the v1.0 baseline.
|
||||
- **Version discovery.** The current API version is returned as `api_version` in the `health` IPC response (see [Dictionary: IPC protocol](#dictionary-ipc-protocol)), so a client — the CLI, a Lua module, or a sibling `bread*` app — can assert compatibility at connect time rather than discovering a mismatch mid-session.
|
||||
|
||||
This matters because the moment sibling apps and community modules depend on this vocabulary, it becomes a contract that can break people. Treat this file, not `README.md` or `CLAUDE.md`, as the single source of truth — those files intentionally point back here rather than keeping their own copies, after a duplicated Lua API section in `README.md` was found to have already drifted from reality.
|
||||
This matters because the moment sibling apps and community modules depend on this vocabulary, it becomes a contract that can break people. Treat this file, not `README.md` or `AGENTS.md`, as the single source of truth — those files intentionally point back here rather than keeping their own copies, after a duplicated Lua API section in `README.md` was found to have already drifted from reality.
|
||||
|
||||
## Getting started
|
||||
|
||||
### 1) Create a minimal config
|
||||
|
||||
- Daemon config: `~/.config/bread/breadd.toml` (all values optional)
|
||||
- Declarative rules (optional, no Lua required): `~/.config/bread/rules.toml`
|
||||
- Lua entry point: `~/.config/bread/init.lua`
|
||||
- Lua modules: `~/.config/bread/modules/`
|
||||
|
||||
### 2) Minimal `init.lua`
|
||||
### 2) The fast path: `rules.toml` *(Since: v1.5)*
|
||||
|
||||
For the common "when event X happens, do Y" case, you don't need Lua at
|
||||
all. Create `~/.config/bread/rules.toml`:
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
on = "device.dock.connected"
|
||||
run = "~/.config/bread/scripts/dock-connected.sh"
|
||||
|
||||
[[rule]]
|
||||
on = "power.ac.disconnected"
|
||||
notify = "Unplugged"
|
||||
|
||||
[[rule]]
|
||||
on = "device.keyboard.connected"
|
||||
exec = "xset r rate 200 40"
|
||||
```
|
||||
|
||||
Each `[[rule]]` needs exactly two things: an `on` (an event-name suffix —
|
||||
`bread.` is implied, so `"device.dock.connected"` matches the real event
|
||||
`bread.device.dock.connected`; wildcards `*`/`**`/`?` work the same way they
|
||||
do in `bread.on()`) and exactly one action:
|
||||
|
||||
| Action | Meaning |
|
||||
|--------|---------|
|
||||
| `run = "<path>"` | Run exactly one script/program at that path. The path is tilde-expanded and quoted as a single unit for you, so spaces in it are safe — it will *not* be word-split into a command plus arguments. |
|
||||
| `exec = "<command line>"` | Run a full shell command line via `bread.exec()`, exactly as if you'd typed it in a shell — quote/escape arguments yourself. |
|
||||
| `notify = "<message>"` | Show a desktop notification with this text via `bread.notify()`. |
|
||||
|
||||
`rules.toml` is entirely optional and purely additive alongside
|
||||
`init.lua` — both can coexist, rules load before user-defined modules, and
|
||||
an absent file is not an error. A malformed rule (missing/empty `on`, or
|
||||
zero/multiple action keys set) doesn't stop the rest of the file from
|
||||
working: the other rules in the file still register, and the specific bad
|
||||
rule shows up via `bread doctor` (see [Debugging tips](#debugging-tips))
|
||||
the same way a broken Lua module's error would.
|
||||
|
||||
This covers the common cases directly. For fuzzier matching (substring
|
||||
device-name matching, filtering by a list of monitors, etc.) or any logic
|
||||
beyond "run this one action," reach for `bread.devices` /
|
||||
`bread.monitors` or hand-written Lua in `init.lua` — see
|
||||
[Dictionary: Built-in modules](#dictionary-built-in-modules) and the next
|
||||
section.
|
||||
|
||||
### 3) Minimal `init.lua`
|
||||
|
||||
```lua
|
||||
bread.on("bread.system.startup", function(event)
|
||||
|
|
@ -59,7 +108,7 @@ bread.on("bread.system.startup", function(event)
|
|||
end)
|
||||
```
|
||||
|
||||
### 3) Start the daemon
|
||||
### 4) Start the daemon
|
||||
|
||||
```bash
|
||||
systemctl --user start breadd
|
||||
|
|
@ -68,7 +117,7 @@ systemctl --user start breadd
|
|||
breadd
|
||||
```
|
||||
|
||||
### 4) Check that it's running
|
||||
### 5) Check that it's running
|
||||
|
||||
```bash
|
||||
bread ping
|
||||
|
|
@ -99,6 +148,12 @@ Key rules:
|
|||
- Register subscriptions inside `M.on_load` so they are cleaned up properly on hot reload.
|
||||
- Use `bread.log` early to verify handlers are firing.
|
||||
|
||||
A flat file like `modules/hello.lua` with no manifest gets full, unscoped
|
||||
`bread.*` access — exactly what you see above, unchanged. That's fine for a
|
||||
personal one-off. Once you install a module properly (`bread modules
|
||||
install`), it's worth declaring what it actually uses — see
|
||||
[Capability-scoped modules](#capability-scoped-modules-since-v15).
|
||||
|
||||
## Run, reload, and watch
|
||||
|
||||
```bash
|
||||
|
|
@ -129,9 +184,13 @@ bread modules install ~/src/bread-wifi
|
|||
# List installed modules and their daemon status
|
||||
bread modules list
|
||||
|
||||
# Show full manifest for one module
|
||||
# Show full manifest for one module (including its declared permissions)
|
||||
bread modules info bread-wifi
|
||||
|
||||
# Get a suggested [[permissions]] block from a static scan of the module's
|
||||
# Lua source — see "Capability-scoped modules" below
|
||||
bread modules audit bread-wifi
|
||||
|
||||
# Remove a module
|
||||
bread modules remove bread-wifi
|
||||
bread modules remove bread-wifi --yes # skip confirmation
|
||||
|
|
@ -146,13 +205,444 @@ description = "WiFi management for Bread"
|
|||
author = "someuser"
|
||||
source = "/home/you/src/bread-wifi"
|
||||
installed_at = "2026-01-01T00:00:00Z"
|
||||
|
||||
[[permissions]]
|
||||
type = "exec"
|
||||
bin = "nmcli"
|
||||
|
||||
[[permissions]]
|
||||
type = "notify"
|
||||
```
|
||||
|
||||
`permissions` is optional *(Since: v1.5)*. Omitting it entirely — every
|
||||
manifest written before v1.5, and any manifest an author just hasn't gotten
|
||||
around to annotating — means the module runs exactly like it always has:
|
||||
full, unscoped `bread.*` access. See the next section for what declaring it
|
||||
actually buys you and the full permission taxonomy.
|
||||
|
||||
## Capability-scoped modules *(Since: v1.5)*
|
||||
|
||||
By default every third-party module gets the full `bread` table — the same
|
||||
one built-in modules and `init.lua` see. `[[permissions]]` in
|
||||
`bread.module.toml` narrows that: a module only sees the `bread.*` bindings
|
||||
it was granted, plus a fixed **baseline** every module gets regardless.
|
||||
Anything not granted is genuinely **absent** — `bread.fs == nil`, not
|
||||
`bread.fs.read()` throwing a permission error — so a module written
|
||||
defensively (`if bread.fs then ... end`) degrades exactly the way it would
|
||||
if, say, Bluetooth hardware weren't present.
|
||||
|
||||
*Since: v1.6* — declaring `[[permissions]]` at all (even an empty list)
|
||||
also determines **where** the module runs: see [Out-of-process module
|
||||
sandboxing](#out-of-process-module-sandboxing-since-v16) below. The
|
||||
`bread` table shape described in this section is what such a module sees
|
||||
either way; what changed is what backs it and what happens if the module
|
||||
ignores it entirely and reaches for `os`/`io` directly.
|
||||
|
||||
### Baseline (always available, no manifest entry needed)
|
||||
|
||||
Event subscription and timers are how a module does anything at all, so
|
||||
they're never gated: `bread.on`/`once`/`filter`/`off`/`emit`,
|
||||
`bread.after`/`every`/`cancel`. Also baseline: `bread.json` (pure decode,
|
||||
no I/O), `bread.module` (required just to register), `bread.log`/`warn`/
|
||||
`error` (diagnostics), and the pure-Lua sugar built entirely on top of the
|
||||
above — `bread.debounce`, `bread.spawn`/`wait`/`wait_any`/`wait_all`,
|
||||
`bread.workflow.*`.
|
||||
|
||||
### Gated — requires a matching `[[permissions]]` entry
|
||||
|
||||
| `type` | Grants | Notes |
|
||||
|--------|--------|-------|
|
||||
| `state.read` | `bread.state.get`/`.monitors`/`.active_workspace`/`.active_window`/`.devices`/`.power`/`.network`/`.profile` | Read-only snapshots of daemon state. `path` is an advisory scoping hint (e.g. `"monitors"`), not yet enforced per-call — see the note below. |
|
||||
| `state.watch` | `bread.state.watch` | Split from `state.read`: a standing subscription is a more persistent capability than a one-off read. |
|
||||
| `profile.activate` | `bread.profile.activate` | Switches the daemon's system-wide active profile — a real cross-module side effect. |
|
||||
| `exec` | `bread.exec`, `bread.exec_capture` | Spawns an arbitrary shell command. `bin` is an advisory hint (e.g. `"hyprpaper"`). |
|
||||
| `notify` | `bread.notify` | Desktop notifications. |
|
||||
| `machine` | `bread.machine.name`/`.tags`/`.has_tag` | Reads hostname/tags, including an optional on-disk `sync.toml`. |
|
||||
| `hyprland` | `bread.hyprland.*` | Compositor IPC — `dispatch`/`keyword`/`eval` control the session, `monitors`/`workspaces`/`clients`/`active_window`/`on_raw` observe it. Not split further; grant it for either. |
|
||||
| `widget` | `bread.widget.register`/`.update`/`.remove`/`.list` | Registers UI in a sibling `bread*` app (breadbar). |
|
||||
| `fs.read` | `bread.fs.read`/`.exists`/`.readlink`/`.expand` | Read-only filesystem access. `path` is an advisory scoping hint. |
|
||||
| `fs.write` | `bread.fs.write` | Filesystem writes. Split from `fs.read` — a module that only reads shouldn't need to declare write access. |
|
||||
| `bluetooth` | `bread.bluetooth.*` | BlueZ control — power/connect/disconnect/scan/devices. |
|
||||
|
||||
Example — a module that switches wallpaper via `hyprpaper` based on the
|
||||
current monitor layout, and reads images from one directory:
|
||||
|
||||
```toml
|
||||
[[permissions]]
|
||||
type = "exec"
|
||||
bin = "hyprpaper"
|
||||
|
||||
[[permissions]]
|
||||
type = "state.read"
|
||||
path = "monitors"
|
||||
|
||||
[[permissions]]
|
||||
type = "fs.read"
|
||||
path = "~/Wallpapers"
|
||||
```
|
||||
|
||||
That module's `bread` table has `bread.exec`, `bread.state` (read
|
||||
functions only — no `bread.state.watch`), and `bread.fs` (read functions
|
||||
only — no `bread.fs.write`), plus the full baseline. `bread.hyprland`,
|
||||
`bread.bluetooth`, `bread.notify`, `bread.machine`, and `bread.widget` are
|
||||
all `nil`.
|
||||
|
||||
An explicit empty list (`permissions = []`) is a deliberate "baseline only"
|
||||
declaration — different from omitting the key entirely. It scopes the
|
||||
module down for real but is *not* flagged by `bread doctor`, since the
|
||||
author made a conscious choice rather than just not knowing about this
|
||||
feature yet.
|
||||
|
||||
### `path`/`bin` enforcement depends on where the module runs
|
||||
|
||||
This section describes the **in-process** scoping mechanism
|
||||
(`build_scoped_env` in `breadd/src/lua/mod.rs`), which only ever gated
|
||||
*presence* of a `bread.*` binding — the `path`/`bin` fields on each
|
||||
permission were recorded in the manifest but never checked against the
|
||||
actual arguments a module passed at runtime, and `os.execute`/`io.open`/
|
||||
`debug.*` remained fully reachable from Lua's standard library regardless
|
||||
of what a module's `bread` table contained. That's still exactly true for
|
||||
a module with **no manifest at all** (the legacy/backward-compat path,
|
||||
`ungated: true` in `modules.list`) — see [Out-of-process module
|
||||
sandboxing](#out-of-process-module-sandboxing-since-v16) below.
|
||||
|
||||
*Since: v1.6* — a module that declares `[[permissions]]` (any, including
|
||||
an explicit empty list) no longer runs in-process at all. It's spawned as
|
||||
a separate, OS-sandboxed `bread-module-host` process instead, and for that
|
||||
process `path`/`bin` *are* enforced for real, at the kernel level, via a
|
||||
Landlock ruleset — independent of whether the module even uses the
|
||||
documented `bread.*` API or goes straight for `os.execute`/`io.open`. See
|
||||
the linked section for exactly what's covered and what's still deferred.
|
||||
|
||||
### `require("bread.devices")` still works from a scoped module
|
||||
|
||||
Builtin library modules (`bread.devices`, `bread.monitors`, `bread.workspaces`,
|
||||
`bread.binds`) always load with the full ambient `bread` table — they're
|
||||
never subject to manifest-based scoping, regardless of what any third-party
|
||||
module that `require`s them declares. `require("bread.devices")` resolves
|
||||
via Lua's real `package.loaded` table (already populated by the time any
|
||||
third-party module loads, since builtins load first) — a real global,
|
||||
reachable from a scoped module through a metatable fallback to the true
|
||||
globals for everything that isn't `bread` itself (`pairs`, `string`,
|
||||
`table`, `require`, `package`, ...). The returned module's own functions
|
||||
(`devices.on()` etc.) were defined while `bread.devices` loaded unscoped,
|
||||
so they close over the *real* `bread` table as a Lua upvalue — closures
|
||||
capture their defining environment lexically, not the caller's — which is
|
||||
exactly why calling `devices.on(...)` from inside a scoped module works
|
||||
with no special-casing needed.
|
||||
|
||||
### `bread modules audit <name>`
|
||||
|
||||
Best-effort static scan of an installed module's `.lua` files (its entry
|
||||
file plus any others in the same directory) for `bread.*` call-site
|
||||
patterns, printing a suggested `[[permissions]]` block to review and paste
|
||||
into `bread.module.toml`:
|
||||
|
||||
```bash
|
||||
bread modules audit bread-wifi
|
||||
```
|
||||
|
||||
This is a text scan, not a Lua parser — false positives (suggesting a
|
||||
permission the module doesn't strictly need) are expected and fine; false
|
||||
negatives on a plain `bread.exec("...")`-style call site should be rare,
|
||||
but dynamic/computed call sites (`bread[method_name](...)`) won't be
|
||||
detected.
|
||||
|
||||
## Out-of-process module sandboxing *(Since: v1.6)*
|
||||
|
||||
### The gap this closes
|
||||
|
||||
Capability-scoped modules (above) gate the *documented* `bread.*` API
|
||||
surface — a module without `fs.read` sees `bread.fs == nil`. They never
|
||||
gated Lua's own standard library: `os.execute`, `io.open`, `debug.*`
|
||||
remained fully reachable from a scoped module's chunk regardless of what
|
||||
its `bread` table contained, because that chunk still ran as ordinary Lua
|
||||
code inside `breadd`'s own OS process, sharing its real filesystem/exec
|
||||
access at the kernel level. A well-behaved module degrades correctly when
|
||||
a permission is missing; a deliberately adversarial one just calls
|
||||
`os.execute("cat /etc/shadow")` directly and the in-process mechanism has
|
||||
nothing left to say about it.
|
||||
|
||||
This workstream closes that gap for any module that declares
|
||||
`[[permissions]]` in `bread.module.toml` — including an explicit empty
|
||||
list — by running it in a **separate OS process**, sandboxed at the kernel
|
||||
level via [Landlock](https://docs.kernel.org/userspace-api/landlock.html),
|
||||
instead of inside `breadd`'s own process.
|
||||
|
||||
### What still runs in-process
|
||||
|
||||
A module with **no manifest at all** (no `bread.module.toml`, or one with
|
||||
no `permissions` key) keeps today's pre-v1.6 behavior unchanged: loaded
|
||||
in-process, full ungated `bread` table, `os`/`io`/`debug` reachable —
|
||||
surfaced as `"ungated": true` in `modules.list`/`state.get "modules"`,
|
||||
which is exactly what `bread doctor` reads to warn about it. This is a
|
||||
deliberate scope decision, not an oversight: Landlock needs concrete rules
|
||||
to build a ruleset from, and "no manifest at all" carries no information
|
||||
to build one. A module author who wants real OS-level isolation writes a
|
||||
manifest — that's the whole point of the capability system this reuses.
|
||||
Built-in modules (`bread.devices`/`monitors`/`workspaces`/`binds`) are
|
||||
completely unaffected either way; they never go through manifest-based
|
||||
scoping.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
breadd (trusted) bread-module-host (sandboxed, per module)
|
||||
│ │
|
||||
├─ spawns child, applies a Landlock ──► │ (restriction applied by the
|
||||
│ ruleset via Command::pre_exec │ PARENT before the child's
|
||||
│ BEFORE execve() │ own main() ever runs)
|
||||
│ │
|
||||
├─ hands it a one-time token via │
|
||||
│ $BREAD_MODULE_TOKEN (env, not argv) │
|
||||
│ │
|
||||
│◄── connects to breadd's existing ──────┤
|
||||
│ IPC socket, presents the token │
|
||||
│ via module_host.hello │
|
||||
│ │
|
||||
├─ looks up which module/permissions ──► │ learns its own identity +
|
||||
│ the token was issued for, replies │ granted permissions from
|
||||
│ │ breadd's answer (never
|
||||
│ │ trusted from self-assertion)
|
||||
│ │
|
||||
│◄── module_host.on/off/emit/after/ ─────┤ loads init.lua into a fresh
|
||||
│ every/cancel/fs_read/fs_write/ │ Lua VM; bread.* functions
|
||||
│ exec/exec_capture/state_get/status │ are RPC-backed proxies, not
|
||||
│ (RPC bridge, belt) │ direct bindings
|
||||
│ │
|
||||
│ Landlock ruleset (suspenders, │ os.execute/io.open/debug.*
|
||||
│ enforced by the kernel independent │ still exist in this Lua VM
|
||||
│ of whether the RPC bridge is used) ──►│ but are bounded by the
|
||||
│ kernel regardless
|
||||
```
|
||||
|
||||
One `bread-module-host` process per out-of-process module. Its own
|
||||
dependency footprint is deliberately minimal (`mlua`, `tokio`,
|
||||
`serde_json`, `bread-shared`) — it's reviewable attack surface in its own
|
||||
right, running one module's untrusted Lua.
|
||||
|
||||
### The token/identity handshake
|
||||
|
||||
Workstream A deliberately did not build a generic IPC connection-identity
|
||||
system — it closed a narrower spoofing gap instead — so there was no
|
||||
`module:<name>` identity concept to reuse. `breadd` generates a random
|
||||
one-time token (a v4 UUID) when spawning a module-host child and passes it
|
||||
via the `$BREAD_MODULE_TOKEN` **environment variable**, not argv — argv is
|
||||
visible to any process on the system via `/proc/<pid>/cmdline`, env vars
|
||||
are not without `/proc/<pid>/environ` and matching privileges. The child's
|
||||
first message on the IPC socket, `module_host.hello {token}`, presents
|
||||
that token; `breadd` looks up which module name/permission set the token
|
||||
was issued for (`ModuleHostRegistry::take_pending`, a one-time,
|
||||
consume-on-read lookup) and replies with that identity. The child never
|
||||
asserts its own name and has that trusted — an adversarial process holding
|
||||
a *stolen or guessed* token still can't claim to be a different module
|
||||
than the one `breadd` actually spawned that token for, and a token is
|
||||
consumed on first use so it can't be replayed.
|
||||
|
||||
Other env vars passed to the child: `$BREAD_MODULE_ENTRY` (absolute path
|
||||
to the module's `init.lua`) and `$BREAD_MODULE_SOCKET` (breadd's socket
|
||||
path, for test harnesses that override it — production defaults to the
|
||||
same `bread_shared::resolve_socket_path()` every other client uses).
|
||||
`$BREAD_MODULE_NAME` is also passed, but purely informational (early log
|
||||
lines before the hello handshake completes) — never trusted for identity
|
||||
or permission lookup.
|
||||
|
||||
### The Landlock sandbox
|
||||
|
||||
[Landlock](https://docs.kernel.org/userspace-api/landlock.html) (Linux
|
||||
5.13+) was chosen over wrapping every spawn in `bubblewrap`/`firejail`:
|
||||
it's a pure-Rust crate calling the LSM's syscalls directly
|
||||
(`landlock_create_ruleset`/`landlock_restrict_self`), unprivileged (no
|
||||
setuid helper, no `CAP_SYS_ADMIN`), and fits this workspace's existing
|
||||
preference for native Rust crates over shelling out to external tools
|
||||
(same reasoning as `udev`/`zbus`/`rtnetlink` instead of CLI wrappers).
|
||||
`bubblewrap`-wrapping remains a documented fallback for a target kernel
|
||||
that lacks Landlock (pre-5.13, or compiled out) — not implemented, since
|
||||
Landlock covers this project's actual target.
|
||||
|
||||
The ruleset is built in `breadd` (the parent) and applied via
|
||||
`Command::pre_exec` — the closure runs in the forked child, after
|
||||
`fork()` but before `execve()`, so the restriction covers the module-host
|
||||
binary's own startup, not just the Lua that runs after. Because of that,
|
||||
`bread-module-host` itself needs **zero** Landlock-related code or
|
||||
dependency — by the time its `main()` runs, the restriction is already
|
||||
active and inherited across the `execve()` that started it.
|
||||
|
||||
What the ruleset grants, from `breadd/src/module_host.rs`'s
|
||||
`apply_sandbox`:
|
||||
|
||||
| Grant | Access | Why |
|
||||
|-------|--------|-----|
|
||||
| System library directories (`/usr/lib`, `/lib`, ...) + `/etc/ld.so.cache`/`.preload` | Read + **Execute** | The dynamic linker needs this to start *any* dynamically-linked binary at all — see the note below on why `Execute` is required here, not just `Read`. |
|
||||
| The `bread-module-host` binary's own resolved path | Read + Execute | The one `execve()` this process is expected to have already performed. |
|
||||
| The module's own directory (`init.lua`'s parent) | Read | So the bootstrap process can load the module's Lua at all — distinct from any `fs.read` grant, which governs the module's *own* runtime file I/O, not breadd's ability to hand it its own source. |
|
||||
| `fs.read` with a `path` hint | Read, scoped to that (`~`-expanded) path prefix | Direct mapping from the manifest. |
|
||||
| `fs.write` with a `path` hint | Read + Write + create, scoped to that path prefix | Matches `bread.fs.write`'s own `create_dir_all` + `write` behavior. |
|
||||
| `exec` with a `bin` hint | Read + Execute, scoped to that binary's resolved path | Absolute paths used as-is; bare names resolved via a `$PATH` search, `which`-style. |
|
||||
|
||||
**No `fs.read`/`fs.write`/`exec` granted at all means no corresponding
|
||||
Landlock rule exists, full stop** — the sandboxed process cannot read,
|
||||
write, or execute anything outside the fixed baseline above, regardless
|
||||
of what it tries via `os`/`io` directly.
|
||||
|
||||
**A note on `Execute` and shared libraries**: an earlier version of this
|
||||
mechanism assumed Landlock's `Execute` right only gates `execve()`, and
|
||||
that plain `Read` would be enough for the dynamic linker's `mmap(...,
|
||||
PROT_EXEC, ...)` of `.so` files. That assumption was wrong — verified
|
||||
empirically (not just reasoned about) by spawning a real sandboxed child:
|
||||
with library directories restricted to `Read`-only, even `/bin/sh -c
|
||||
"true"` failed to start at all (`EACCES` on `execve` before a single line
|
||||
of script ran); granting `Execute` on those directories too fixed it. The
|
||||
practical consequence: a module-host child's direct `os.execute`/`io.open`
|
||||
escape hatch, if it names a path under a system library directory
|
||||
specifically, is not denied the way an arbitrary path elsewhere is — the
|
||||
baseline necessarily grants real `Execute` there. This is a materially
|
||||
smaller exposure than no sandbox at all (bounded to files already shipped
|
||||
in the system's own library directories, not the whole filesystem), but
|
||||
it's a real, known trade-off, not swept under the rug. See
|
||||
`breadd/src/module_host.rs`'s `apply_sandbox` doc comment for the full
|
||||
reasoning, including why a fully static (`x86_64-unknown-linux-musl`)
|
||||
build of `bread-module-host` — confirmed available on this project's dev
|
||||
machine — would remove the need for this baseline entirely, and why that
|
||||
wasn't attempted in this pass (a build/packaging change, not a sandbox
|
||||
logic change).
|
||||
|
||||
**fs.read/fs.write with no `path` hint**: the RPC bridge's own
|
||||
belt-and-suspenders permission check still applies, but no Landlock rule
|
||||
is added — Landlock scoping needs a concrete path, and a hint-less grant
|
||||
carries none. A module author who wants the direct `os`/`io` escape hatch
|
||||
mediated at the kernel level too needs to declare a `path`.
|
||||
|
||||
**Network access is explicitly out of scope for this pass** (P2). Landlock
|
||||
gained TCP bind/connect mediation in ABI v4+ (kernel 6.7+), but wiring a
|
||||
`network` permission kind through the manifest schema and the sandbox
|
||||
builder wasn't attempted here.
|
||||
|
||||
### RPC bridge coverage
|
||||
|
||||
`bread-module-host`'s `bread` table is built entirely from RPC-backed
|
||||
proxies to `breadd` (`breadd/src/ipc/module_host_bridge.rs`), not direct
|
||||
in-process bindings. Covered:
|
||||
|
||||
- **Baseline**, always present: `bread.on`/`.once`/`.off`/`.emit`,
|
||||
`bread.after`/`.every`/`.cancel`, `bread.json.decode`, `bread.module`
|
||||
(with a process-local `.store` — see the note below), `bread.log`/
|
||||
`.warn`/`.error`. Also `bread.spawn`/`bread.wait` — the same pure-Lua
|
||||
coroutine sugar `breadd`'s own `install_wait_helper` uses, since it's
|
||||
built entirely on top of `on`/`once`/`after`/`cancel`, all of which are
|
||||
bridged; the source is currently duplicated between `breadd` and
|
||||
`bread-module-host` rather than extracted to `bread-shared` (flagged as
|
||||
follow-up below).
|
||||
- **Gated**, mirroring the permission table above: `bread.fs.read`/
|
||||
`.write` (`fs.read`/`fs.write`), `bread.exec`/`.exec_capture` (`exec`),
|
||||
`bread.state.get` (`state.read`).
|
||||
|
||||
Events/timers are delivered as unsolicited, tagged push messages
|
||||
interleaved with ordinary request/response lines on the same connection
|
||||
(`bread_shared::module_host_ipc::ModuleHostPush`) — a subscription
|
||||
registered via `module_host.on`/`.once` is matched server-side against the
|
||||
same event broadcast every other IPC subscriber reads from.
|
||||
|
||||
**Not yet bridged** (P1/P2 — see below): `bread.state.monitors`/
|
||||
`.active_workspace`/`.active_window`/`.devices`/`.power`/`.network`/
|
||||
`.profile` shorthands, `bread.state.watch`, `bread.fs.exists`/`.readlink`/
|
||||
`.expand`, `bread.profile.activate`, `bread.notify`, `bread.machine.*`,
|
||||
`bread.hyprland.*`, `bread.widget.*`, `bread.bluetooth.*`,
|
||||
`bread.wait_any`/`.wait_all`/`bread.workflow.*`. These namespaces are
|
||||
simply absent (`nil`) from an out-of-process module's `bread` table
|
||||
regardless of what the manifest grants — a real coverage gap versus the
|
||||
in-process mechanism, not a permission-check bug.
|
||||
|
||||
**`bread.module().store` is process-local**, not synced back to `breadd`'s
|
||||
`RuntimeState` — a real, known limitation versus the in-process mechanism
|
||||
(where `M.store.set`/`.get` persists in daemon state and is visible to
|
||||
`bread modules info`/other tooling). Fine for a module's own private
|
||||
scratch state; not fine yet for anything expecting cross-process
|
||||
visibility. Modules that need to report results/state externally should
|
||||
use `bread.emit(...)` instead, which does cross the process boundary.
|
||||
|
||||
### Crash isolation
|
||||
|
||||
Each spawned `bread-module-host` child is reaped by a dedicated thread in
|
||||
`breadd` (`std::process::Child::wait()`, blocking on that thread only —
|
||||
never blocking the IPC server or the Lua engine). On exit for any reason —
|
||||
clean shutdown, a Lua panic, `kill -9` — `breadd` emits
|
||||
`bread.module.crashed` with `{ module, pid, reason, exit_code, signal }`
|
||||
and updates that module's status. Verified end-to-end
|
||||
(`breadd/tests/module_host_sandbox.rs`): killing a module-host child with
|
||||
`SIGKILL` leaves `breadd` itself and every other module (in-process or
|
||||
out-of-process) fully responsive, and the crash event fires with the
|
||||
correct module name and `signal: 9`.
|
||||
|
||||
This is deliberately **detection and reporting**, not a restart/backoff
|
||||
policy — a crashed module-host stays down until the next `bread reload`
|
||||
(or daemon restart) respawns it. Richer supervision (auto-restart,
|
||||
backoff, a circuit breaker) is flagged as follow-up work, not attempted
|
||||
here.
|
||||
|
||||
### New IPC methods
|
||||
|
||||
*Since: v1.6 — `API_VERSION` bumped from `1.5.0` to `1.6.0` in
|
||||
`breadd/src/ipc/mod.rs` for this addition.* All new methods live under the
|
||||
`module_host.*` prefix and are only meaningful on a connection that has
|
||||
completed the `module_host.hello` handshake (see the token/identity
|
||||
section above) — see [Dictionary: IPC protocol](#dictionary-ipc-protocol)
|
||||
for the full list alongside the pre-existing methods.
|
||||
|
||||
### What's implemented vs. deferred
|
||||
|
||||
**Landed (P0)**:
|
||||
- The `bread-module-host` binary, spawn + token-based identity handshake.
|
||||
- Real Landlock sandboxing built from a module's `ModulePermission` list,
|
||||
independently verified at the OS level (`breadd/src/module_host.rs`'s
|
||||
`landlock_denies_reads_outside_granted_path`/
|
||||
`no_exec_permission_means_binary_cannot_be_executed_at_all` unit tests
|
||||
against a real spawned child; `breadd/tests/module_host_sandbox.rs`'s
|
||||
`os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope`
|
||||
end-to-end, going through a real IPC handshake and real Lua calling
|
||||
`os.execute`/`io.open` directly).
|
||||
- RPC bridge for the baseline set plus `fs.read`/`fs.write`/`exec`/
|
||||
`exec_capture`/`state.read` (`state.get` only).
|
||||
- Crash isolation: kill-9 of a module-host child doesn't take `breadd` or
|
||||
any other module down, and is reported via `bread.module.crashed`
|
||||
(`breadd/tests/module_host_sandbox.rs`'s
|
||||
`killing_a_module_host_child_does_not_take_down_breadd_or_other_modules`).
|
||||
|
||||
**Landed beyond the minimum (still P0-adjacent)**:
|
||||
- `bread.spawn`/`bread.wait` (pure-Lua coroutine sugar) work out-of-process
|
||||
too, since they're built entirely on already-bridged primitives.
|
||||
- `bread.state.get` (not originally required for the P0 minimum, added
|
||||
because a pre-existing capability-manifest test exercised it).
|
||||
|
||||
**Deferred (P1 — do next if this workstream continues)**:
|
||||
- `trust = "in-process"` manifest escape hatch for latency-sensitive
|
||||
modules that want to opt back into today's D-mechanism deliberately.
|
||||
- Extracting `bread.spawn`/`bread.wait`'s embedded Lua source (currently
|
||||
duplicated between `breadd` and `bread-module-host`) into a shared
|
||||
`bread-shared` module so the two copies can't drift.
|
||||
- The remaining `bread.*` namespaces over RPC: `bread.state.watch` and the
|
||||
`.monitors`/`.active_workspace`/etc. shorthands, `bread.fs.exists`/
|
||||
`.readlink`/`.expand`, `bread.profile.activate`, `bread.notify`,
|
||||
`bread.machine.*`, `bread.hyprland.*`, `bread.widget.*`,
|
||||
`bread.bluetooth.*`, `bread.wait_any`/`.wait_all`/`bread.workflow.*` —
|
||||
mechanically the same pattern as the ones already bridged.
|
||||
|
||||
**Deferred (P2 — explicitly out of scope for this pass)**:
|
||||
- Network sandboxing / a `network` permission kind.
|
||||
- `bread modules info` showing the resolved sandbox profile.
|
||||
- Full restart/backoff supervision policy for crashed module-hosts.
|
||||
- A fully static (musl) build of `bread-module-host`, which would remove
|
||||
the library-directory `Execute` baseline grant entirely.
|
||||
- 100% RPC coverage of every remaining namespace.
|
||||
|
||||
## Debugging tips
|
||||
|
||||
- Run `bread events` to see live normalized events.
|
||||
- Run `bread events --tree` *(Since: v1.5)* to render events as a causality tree instead of a flat stream — events that a Lua handler emitted via `bread.emit()` in reaction to another event are nested underneath it, following the `caused_by` chain (see [Dictionary: Event reference](#dictionary-event-reference)). Useful for untangling "why did this event fire" when several modules chain-react to each other.
|
||||
- Run `bread state` to see full runtime state as JSON.
|
||||
- Run `bread doctor` to check adapter and module health.
|
||||
- Run `bread doctor` to check adapter and module health, including modules
|
||||
running with full, ungated `bread.*` access because they have no
|
||||
`permissions` declared.
|
||||
- Log event payloads with `bread.log(tostring(event.data))`.
|
||||
- Use `RUST_LOG=debug breadd` for verbose daemon output.
|
||||
|
||||
|
|
@ -212,7 +702,7 @@ end, {
|
|||
Unsubscribe an event handler or state watch by ID.
|
||||
|
||||
#### `bread.emit(event, data)`
|
||||
Emit a custom event into the system pipeline. Useful for cross-module communication.
|
||||
Emit a custom event into the system pipeline. Useful for cross-module communication. If called synchronously from inside a `bread.on` subscriber callback (i.e. in reaction to a matched event), the emitted event's `caused_by` *(Since: v1.5)* is set to the id of the event that triggered the callback, threading causality across chains of modules that react to each other — see [Dictionary: Event reference](#dictionary-event-reference).
|
||||
|
||||
#### `bread.wait(pattern, opts) -> event | nil`
|
||||
Coroutine-only helper that suspends until a matching event arrives.
|
||||
|
|
@ -286,6 +776,105 @@ Returns the current status for `name`, or `nil` if no workflow with that name ha
|
|||
#### `bread.workflow.list() -> table`
|
||||
Returns an array of every workflow's current status, in the same shape as `bread.workflow.status`.
|
||||
|
||||
### Widgets *(Since: v1.3)*
|
||||
|
||||
Declarative, live-updating widgets rendered by sibling `bread*` apps (breadbar) in their own bar/popover free space. A widget is a small tree of typed nodes — `box`, `label`, `icon`, `progress` — not raw markup: this keeps rendering generic across every consuming app and keeps a node's appearance confined to a bounded, typed `style` vocabulary the renderer already knows about (see `style` below), with no style/CSS injection surface from Lua.
|
||||
|
||||
Widgets are registered per-module and are re-registered fresh on every hot reload (the whole registry is cleared right before the Lua VM resets, same as `bread.module`'s per-reload re-execution) — call `bread.widget.register` at module top level or in `on_load`, not somewhere that only runs once ever.
|
||||
|
||||
#### `bread.widget.register(spec) -> ok, err`
|
||||
Registers (or replaces, if `spec.id` already exists for this module) a widget. `spec`:
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `id` | string | Local id, unique within your module. Stored/addressed elsewhere as `"<module>.<id>"`. |
|
||||
| `placement` | string | One of `tray`, `left_of_clock`, `right_of_clock`, `right_of_workspaces`, `left_of_stats` — which fixed slot in the consuming app's layout this widget renders into. |
|
||||
| `order` | number | Optional, default `0`. Sort priority within a placement; lower sorts first. |
|
||||
| `visible` | bool | Optional, default `true`. |
|
||||
| `tooltip` | string | Optional. |
|
||||
| `root` | node | The render tree (see Node types below). |
|
||||
|
||||
Returns `true` on success, or `false, err` if `root` fails validation (tree too deep, too many nodes, or an invalid `class`), `root` contains a `style` field with a value outside its enum (a deserialization error, reported the same way), or `bread.widget.register` was called outside a module.
|
||||
|
||||
##### Node types
|
||||
|
||||
Every node accepts an optional `style` (a bounded, typed vocabulary — see below; this is the primary way to control a node's appearance), an optional `class` (a small freeform escape hatch, see [Style vs. class](#style-vs-class) below), and an optional `on_click` (any Lua value, passed through opaquely — see Click events below).
|
||||
|
||||
| `type` | Fields |
|
||||
|--------|--------|
|
||||
| `box` | `orientation` (`"horizontal"` \| `"vertical"`, default horizontal), `spacing`, `children` (array of nodes) |
|
||||
| `label` | `text` |
|
||||
| `icon` | `name` (bundled icon) or `path` (arbitrary SVG file) — exactly one; `size` |
|
||||
| `progress` | `value` (0.0–1.0) |
|
||||
|
||||
A tree is capped at depth 4 (root counts as depth 1) and 50 total nodes — comfortably enough for a status readout, not enough to build a full custom UI.
|
||||
|
||||
```lua
|
||||
bread.widget.register({
|
||||
id = "weather",
|
||||
placement = "left_of_stats",
|
||||
tooltip = "Sydney: Partly cloudy",
|
||||
root = {
|
||||
type = "box",
|
||||
children = {
|
||||
{ type = "icon", name = "cloud" },
|
||||
{ type = "label", text = "22°C", style = { color = "dim" }, on_click = "refresh" },
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
##### `style` *(Since: v1.4)*
|
||||
|
||||
`style` is a bounded, typed vocabulary for a node's appearance — every field is a small closed enum, not a string, so a typo is a `bread.widget.register` validation failure at registration time, not a silently-ignored CSS class. There is deliberately **no raw CSS/style-string field** anywhere in this API: a module can only ever pick from the fixed set below, never inject arbitrary style.
|
||||
|
||||
| Field | Type | Values |
|
||||
|-------|------|--------|
|
||||
| `color` | string | `fg`, `dim` (muted foreground), `accent`, `red`, `green`, `yellow`, `blue`, `pink`, `teal` |
|
||||
| `weight` | string | `normal`, `bold` |
|
||||
| `size` | string | `xs`, `sm`, `md`, `lg`, `xl` — text size in px (10/12/14/16/20); `sm`/`md` match the bread design system's own secondary/base font sizes |
|
||||
| `align` | string | `start`, `center`, `end` |
|
||||
| `background` | string | `none`, `surface`, `card` (surface + rounded corners + padding) |
|
||||
| `radius` | string | `none`, `sm`, `md`, `full` (pill) |
|
||||
| `padding` | string | `none`, `xs`, `sm`, `md` |
|
||||
|
||||
Every field is optional and independent — set only what you need. Colors, font sizes, radii, and padding all reuse the exact same palette, font, and spacing scale every other `bread*` GUI (breadbar, bos-settings, breadpad, ...) is themed from, so a widget recolors with the rest of the desktop when pywal's palette changes instead of drifting out of sync.
|
||||
|
||||
```lua
|
||||
{ type = "label", text = "LOW BATTERY", style = { color = "yellow", weight = "bold" } }
|
||||
```
|
||||
|
||||
##### Style vs. `class`
|
||||
|
||||
`class` still exists as an escape hatch for a CSS class the *consuming app's own stylesheet* happens to define (restricted to `^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`) — useful if you're targeting a specific app you know the internals of, but undiscoverable and app-specific otherwise. As of this writing, breadbar's stylesheet only gives real meaning to `dim` this way (fades a node to 60% opacity) — everything else a module needs (color, weight, size, alignment, background, radius, padding) should go through `style` instead, which every renderer is expected to understand identically.
|
||||
|
||||
#### `bread.widget.update(id, patch) -> ok, err`
|
||||
Patches an already-registered widget (local `id`, not the fully-qualified form). Any of `root`, `tooltip`, `visible`, `order` may be given; omitted fields are left as-is. `root`, when given, replaces the whole tree — there is no node-level patching. Returns `false, "no such widget"` if `id` isn't registered.
|
||||
|
||||
```lua
|
||||
bread.widget.update("weather", {
|
||||
root = { type = "box", children = { { type = "label", text = "23°C" } } },
|
||||
})
|
||||
```
|
||||
|
||||
#### `bread.widget.remove(id) -> bool`
|
||||
Removes a widget registered by the calling module. Returns whether anything was removed.
|
||||
|
||||
#### `bread.widget.list() -> table`
|
||||
Returns an array of every widget the calling module currently has registered.
|
||||
|
||||
##### Click events
|
||||
|
||||
A clicked node's `on_click` value doesn't travel back through `breadd` directly — the rendering app (breadbar) emits `bread.bar.widget_clicked` with `{ widget_id, action }` (`action` being whatever you put in `on_click`), because a rendering app may only publish inside its own `bread.<app_id>.*` namespace (see [Namespaces](#namespaces)). React to it like any other event, filtering on `widget_id`:
|
||||
|
||||
```lua
|
||||
bread.on("bread.bar.widget_clicked", function(e)
|
||||
if e.data.widget_id == "weather.weather" then
|
||||
-- e.data.action == "refresh"
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
### State
|
||||
|
||||
#### `bread.state.get(path)`
|
||||
|
|
@ -329,6 +918,29 @@ Activate a named profile. Emits `bread.profile.activated` over IPC.
|
|||
#### `bread.exec(cmd)`
|
||||
Run a shell command. Fire-and-forget (async, does not block Lua).
|
||||
|
||||
#### `bread.exec_capture(cmd, opts) -> ok, stdout`
|
||||
Run a shell command and return its result: `ok` is whether it exited zero,
|
||||
`stdout` is its captured standard output. Unlike `bread.exec`, this blocks
|
||||
the calling Lua callback until the command exits (or the timeout below
|
||||
elapses), so it's only appropriate for fast, local commands — e.g.
|
||||
`git -C <dir> rev-parse --abbrev-ref HEAD`, not anything that hits the
|
||||
network or waits on user input.
|
||||
|
||||
```lua
|
||||
local ok, branch = bread.exec_capture("git -C " .. dir .. " rev-parse --abbrev-ref HEAD")
|
||||
if ok then
|
||||
branch = branch:gsub("%s+$", "") -- trailing newline
|
||||
end
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
| Key | Type | Default |
|
||||
|-----|------|---------|
|
||||
| `timeout_ms` | number | `2000` |
|
||||
|
||||
On timeout or spawn failure, returns `false, ""`.
|
||||
|
||||
### Notifications
|
||||
|
||||
#### `bread.notify(message, opts)`
|
||||
|
|
@ -394,9 +1006,20 @@ Read a file. Returns `nil` if the file does not exist. `~` is expanded.
|
|||
#### `bread.fs.exists(path) -> bool`
|
||||
Returns true if the path exists. `~` is expanded.
|
||||
|
||||
#### `bread.fs.readlink(path) -> string | nil`
|
||||
Read a symlink's target. Returns `nil` if the path doesn't exist or isn't a
|
||||
symlink. Distinct from `bread.fs.read`, which opens and reads file
|
||||
*contents* — for something like `/proc/<pid>/cwd`, the payload is the link
|
||||
target itself, not a file to read.
|
||||
|
||||
#### `bread.fs.expand(path) -> string`
|
||||
Expand `~` to the home directory.
|
||||
|
||||
#### `bread.json.decode(str) -> table | nil`
|
||||
Parse a JSON string into a Lua table. Returns `nil` on malformed input.
|
||||
Pairs naturally with `bread.exec_capture` for consuming JSON output from a
|
||||
CLI (e.g. `kitty @ ls`).
|
||||
|
||||
### Hyprland
|
||||
|
||||
The `bread.hyprland` namespace provides compositor bindings.
|
||||
|
|
@ -409,6 +1032,10 @@ bread.hyprland.dispatch("exec", "kitty")
|
|||
-- Set a keyword
|
||||
bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1")
|
||||
|
||||
-- Send a raw request to the Hyprland socket, e.g. to evaluate a config-file
|
||||
-- expression the way `hyprctl eval <expr>` does; returns the raw response string
|
||||
local result = bread.hyprland.eval("some expression")
|
||||
|
||||
-- Query compositor state (returns deserialized Lua tables)
|
||||
local win = bread.hyprland.active_window()
|
||||
local monitors = bread.hyprland.monitors()
|
||||
|
|
@ -539,6 +1166,37 @@ Storage is scoped per module and is not shared across modules.
|
|||
|
||||
Built-ins are loaded before user modules. Disable them via `[modules].disable` in the daemon config.
|
||||
|
||||
### `bread.rules` *(Since: v1.5)*
|
||||
|
||||
The Lua side of the `rules.toml` declarative automation layer described in
|
||||
[Getting started](#getting-started) — there is no separate API to call
|
||||
here, it's driven entirely by `~/.config/bread/rules.toml`. Listed here (and
|
||||
disable-able via `[modules].disable = ["bread.rules"]` like every other
|
||||
built-in) because it's a real module the same way `bread.devices` is, just
|
||||
one whose configuration lives in TOML instead of Lua.
|
||||
|
||||
```toml
|
||||
# ~/.config/bread/rules.toml
|
||||
[[rule]]
|
||||
on = "device.dock.connected"
|
||||
run = "~/.config/bread/scripts/dock-connected.sh"
|
||||
|
||||
[[rule]]
|
||||
on = "power.ac.disconnected"
|
||||
notify = "Unplugged"
|
||||
|
||||
[[rule]]
|
||||
on = "device.keyboard.connected"
|
||||
exec = "xset r rate 200 40"
|
||||
```
|
||||
|
||||
Each rule's `on` becomes a `bread.on("bread." .. on, ...)` subscription —
|
||||
see [Getting started](#getting-started) for the full `run`/`exec`/`notify`
|
||||
semantics and validation rules. `rules.toml`'s absence is not an error;
|
||||
parse/validation problems are reported the same way a broken hand-written
|
||||
module's `on_load` error would be — via `bread doctor` / `modules.list`,
|
||||
against the `bread.rules` module name.
|
||||
|
||||
### `bread.monitors`
|
||||
|
||||
High-level declarative monitor event handlers.
|
||||
|
|
@ -721,10 +1379,15 @@ Events are delivered as a `BreadEvent`:
|
|||
"event": "bread.device.dock.connected",
|
||||
"timestamp": 1710000000000,
|
||||
"source": "Udev",
|
||||
"data": {}
|
||||
"data": {},
|
||||
"id": "b3f2c9a0-4e6d-4b8a-9c1e-7a2f5d8e0c11",
|
||||
"caused_by": null
|
||||
}
|
||||
```
|
||||
|
||||
- **`id`** *(Since: v1.5)* — a unique id assigned to this specific event instance at construction. Every `BreadEvent`, regardless of origin (adapter-normalized, IPC `emit`, Lua `bread.emit()`, or a daemon-internal send like `bread.system.startup`), gets one.
|
||||
- **`caused_by`** *(Since: v1.5)* — the `id` of the event whose Lua subscriber handler emitted this event via `bread.emit()`, or `null` if this event did not originate from inside a running handler (adapter events, IPC `emit`, daemon-internal sends). This lets you reconstruct causality chains across modules that react to each other's events: if module A's handler for event X calls `bread.emit("Y", ...)`, then Y's `caused_by` is X's `id`. See `bread events --tree` below for a rendering of these chains.
|
||||
|
||||
### Pattern matching
|
||||
|
||||
| Pattern | Matches |
|
||||
|
|
@ -741,6 +1404,7 @@ Events are delivered as a `BreadEvent`:
|
|||
| Event | Data |
|
||||
|-------|------|
|
||||
| `bread.system.startup` | `{}` |
|
||||
| `bread.module.crashed` *(Since: v1.6)* | `{ module, pid, reason, exit_code, signal }` — an out-of-process `bread-module-host` child exited (crash, panic, `kill -9`, ...). `exit_code`/`signal` are mutually exclusive (whichever applies); see [Out-of-process module sandboxing](#out-of-process-module-sandboxing-since-v16). |
|
||||
|
||||
#### Devices (udev / Bluetooth)
|
||||
|
||||
|
|
@ -775,19 +1439,43 @@ Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bre
|
|||
|
||||
#### Hyprland
|
||||
|
||||
*Since: v1.5 — the `bread.hyprland.*` namespaced forms below. Bread's event vocabulary is meant to be portable across a future second compositor backend; a flat `bread.workspace.*`/`bread.monitor.*`/`bread.window.*` name gave no way to tell a genuinely cross-backend event (like `bread.power.*`) apart from one that is Hyprland-specific. The 10 rows marked `Deprecated: v1.5` are unaffected functionally — they keep firing — but new automation should subscribe to their `bread.hyprland.*` sibling instead.*
|
||||
|
||||
Every Hyprland-sourced event below is dual-emitted: the daemon fires both the legacy flat name and its `bread.hyprland.<rest>` equivalent with identical `data`/`timestamp`/`source`, unless `[compat] legacy_hyprland_event_names = false` is set (see below), in which case only the namespaced name fires. A module that subscribes only to `bread.hyprland.*` always gets full workspace/monitor/window coverage regardless of that setting.
|
||||
|
||||
| Event | Data |
|
||||
|-------|------|
|
||||
| `bread.workspace.changed` | raw payload |
|
||||
| `bread.workspace.created` | `{ workspace }` |
|
||||
| `bread.workspace.destroyed` | `{ workspace }` |
|
||||
| `bread.monitor.connected` | raw payload |
|
||||
| `bread.monitor.disconnected` | raw payload |
|
||||
| `bread.window.focus.changed` | raw payload |
|
||||
| `bread.window.focused` | `{ address }` |
|
||||
| `bread.window.opened` | `{ address, workspace, class, title }` |
|
||||
| `bread.window.closed` | `{ address }` |
|
||||
| `bread.window.moved` | `{ address, workspace }` |
|
||||
| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds) |
|
||||
| `bread.workspace.changed` *(Deprecated: v1.5 — use `bread.hyprland.workspace.changed`)* | raw payload |
|
||||
| `bread.hyprland.workspace.changed` *(Since: v1.5)* | raw payload |
|
||||
| `bread.workspace.created` *(Deprecated: v1.5 — use `bread.hyprland.workspace.created`)* | `{ workspace }` |
|
||||
| `bread.hyprland.workspace.created` *(Since: v1.5)* | `{ workspace }` |
|
||||
| `bread.workspace.destroyed` *(Deprecated: v1.5 — use `bread.hyprland.workspace.destroyed`)* | `{ workspace }` |
|
||||
| `bread.hyprland.workspace.destroyed` *(Since: v1.5)* | `{ workspace }` |
|
||||
| `bread.monitor.connected` *(Deprecated: v1.5 — use `bread.hyprland.monitor.connected`)* | raw payload |
|
||||
| `bread.hyprland.monitor.connected` *(Since: v1.5)* | raw payload |
|
||||
| `bread.monitor.disconnected` *(Deprecated: v1.5 — use `bread.hyprland.monitor.disconnected`)* | raw payload |
|
||||
| `bread.hyprland.monitor.disconnected` *(Since: v1.5)* | raw payload |
|
||||
| `bread.window.focus.changed` *(Deprecated: v1.5 — use `bread.hyprland.window.focus.changed`)* | raw payload |
|
||||
| `bread.hyprland.window.focus.changed` *(Since: v1.5)* | raw payload |
|
||||
| `bread.window.focused` *(Deprecated: v1.5 — use `bread.hyprland.window.focused`)* | `{ address }` |
|
||||
| `bread.hyprland.window.focused` *(Since: v1.5)* | `{ address }` |
|
||||
| `bread.window.opened` *(Deprecated: v1.5 — use `bread.hyprland.window.opened`)* | `{ address, workspace, class, title }` |
|
||||
| `bread.hyprland.window.opened` *(Since: v1.5)* | `{ address, workspace, class, title }` |
|
||||
| `bread.window.closed` *(Deprecated: v1.5 — use `bread.hyprland.window.closed`)* | `{ address }` |
|
||||
| `bread.hyprland.window.closed` *(Since: v1.5)* | `{ address }` |
|
||||
| `bread.window.moved` *(Deprecated: v1.5 — use `bread.hyprland.window.moved`)* | `{ address, workspace }` |
|
||||
| `bread.hyprland.window.moved` *(Since: v1.5)* | `{ address, workspace }` |
|
||||
| `bread.hyprland.event` | `{ kind, raw, data }` (unhandled kinds — already namespaced, not part of this migration) |
|
||||
| `bread.hyprland.snapshot` *(Since: v1.7.1)* | `{ monitors, workspaces, active_workspace, active_window }` — emitted once after the Hyprland event socket connects (and again after a reconnect). `bread.state` applies this event to replace compositor topology so monitors/workspaces/focus are populated before the next live event. Not dual-emitted under a legacy name. |
|
||||
|
||||
##### Compatibility: `[compat]` config
|
||||
|
||||
```toml
|
||||
[compat]
|
||||
legacy_hyprland_event_names = true # default during the deprecation window
|
||||
```
|
||||
|
||||
Set to `false` to suppress the 10 legacy flat names above and emit only their `bread.hyprland.*` equivalents. This defaults to `true` for now; per the [API Stability & Versioning](#api-stability--versioning) deprecation-window policy, the default will flip to `false` in a later release once the window closes. Removing the legacy names entirely is a further, separate follow-up — see the note in `DEPRECATIONS.md`.
|
||||
|
||||
#### Power
|
||||
|
||||
|
|
@ -816,6 +1504,17 @@ Both USB/udev devices and Bluetooth devices emit `bread.device.connected` / `bre
|
|||
| `bread.notify.sent` | `{ title, message, urgency }` |
|
||||
| `bread.state.changed.<path>` | emitted by state watches |
|
||||
|
||||
#### Widgets *(Since: v1.3)*
|
||||
|
||||
Emitted by `breadd` itself on every `bread.widget.*` mutation — see [Widgets](#widgets-since-v13). `data` is the full `WidgetSpec` for `registered`/`updated`; just `{ id }` for `removed`.
|
||||
|
||||
| Event | Data |
|
||||
|-------|------|
|
||||
| `bread.widget.registered` | `{ id, module, placement, order, visible, tooltip, root, updated_at }` |
|
||||
| `bread.widget.updated` | same shape as `registered` |
|
||||
| `bread.widget.removed` | `{ id }` |
|
||||
| `bread.widget.cleared` | `{}` — fired once at the end of every module reload (`bread reload`), whether or not the widget set actually changed. The registry itself is wiped and re-populated as modules re-run; this is a "go re-fetch" signal for consumers that only react to `bread.widget.*` events, so a module that stops registering widgets (e.g. gets disabled) is noticed even though nothing else fires. |
|
||||
|
||||
#### Terminal (shell precmd/preexec hooks)
|
||||
|
||||
Requires `bread hooks install shell` and sourcing the generated script from your shell rc — see the CLI reference. Fires via the `bread-emit` helper, not the daemon reaching out.
|
||||
|
|
@ -885,11 +1584,13 @@ Rides the same shell-hook transport as Terminal events (`bread hooks install she
|
|||
|
||||
*Since: v1.1 — the `AdapterSource::App` variant and the known-apps registry (`bread_shared::apps::KNOWN_APPS`). No sibling app emits through this path yet as of this writing except the breadclip pilot (see its own `EVENTS.md` once that lands); the daemon-side plumbing and the convention itself are what v1.1 adds.*
|
||||
|
||||
*Since: v1.3 — breadbar is now an active `bread-client` consumer under the `bar` app id (already present in `KNOWN_APPS`): it emits `bread.bar.widget_clicked` for widget clicks (see [Widgets](#widgets-since-v13)) and reads `bread.widget.*` to render the [Dictionary: Runtime state schema](#dictionary-runtime-state-schema)'s `widgets` field.*
|
||||
|
||||
Two dotted-name segments are reserved, permanent parts of the schema — not one-off conventions:
|
||||
|
||||
- **`bread.<app>.*`** — inbound events published *by* a sibling `bread*` application about its own state (e.g. `bread.clip.copied`). An app may only publish within its own segment; the daemon enforces this at the IPC boundary (a socket client claiming a `source` of an app id it doesn't own is rejected the same way spoofing `power`/`hyprland` is rejected today).
|
||||
- **`bread.command.<app>.<verb>`** — outbound commands *to* a sibling application (e.g. `bread.command.clip.clear`). Any module or app may publish; only the target app subscribes. This reuses the existing event bus in both directions — there is no separate request/response protocol.
|
||||
- The second dotted segment is drawn from a small known-apps registry (`bread_shared::apps::KNOWN_APPS`); daemon-internal domains (`terminal`, `git`, `hyprland`, `device`, `power`, `network`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow`) are reserved and cannot be claimed as app ids.
|
||||
- **`bread.command.<app>.<verb>`** — outbound commands *to* a sibling application (e.g. `bread.command.clip.clear`). Any module or app may publish; only the target app subscribes. This reuses the existing event bus in both directions — there is no separate request/response protocol. *Since: v1.7 — well-formed `bread.command.<known-app>.<verb>` names (`known-app` ∈ `KNOWN_APPS`, verb a non-empty extra dotted segment) are allowed on the unsourced/`bread-emit` path and via sourced `AdapterSource::App` emit (an app may publish a command to another known app). `BreadClient::command` in bread-utils is the typed helper for the same path. `command` remains in `RESERVED_DOMAINS` so it cannot be claimed as an app id; `bread.command.power.off` and `bread.command.notanapp.x` are still rejected. See [Dictionary: IPC protocol](#dictionary-ipc-protocol).*
|
||||
- The second dotted segment is drawn from a small known-apps registry (`bread_shared::apps::KNOWN_APPS` in `bread-shared/src/apps.rs`); daemon-internal domains (`terminal`, `git`, `hyprland`, `device`, `power`, `network`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow`) are reserved and cannot be claimed as app ids. *Since: v1.5 — `bluetooth`, `workspace`, `window`, and `monitor` added to this list (event families the Bluetooth and Hyprland adapters already published under, but that were missing from it); this same list is now also the boundary the IPC `emit` method's no-`source` path checks event names against, see [Dictionary: IPC protocol](#dictionary-ipc-protocol).*
|
||||
- **Commands are best-effort.** Publishing `bread.command.<app>.<verb>` with no subscriber (the app isn't installed or isn't running) is a silent no-op — there is nothing to special-case, and no error is raised. An app that acts on a command *should* emit a corresponding `bread.<app>.<verb>.done` (or `.failed`) confirmation; a module that needs to know a command was actually honored must `bread.wait`/`bread.wait_any` on that confirmation with a timeout rather than assume success. There is no mandatory request/response correlation layer — most commands are legitimately fire-and-forget, and building one would contradict the "no listener, no-op" degradation property.
|
||||
- **`bread.exec("<cli> ...")`** remains the zero-infrastructure fallback for triggering a sibling app that has a synchronous CLI and no need for a structured response.
|
||||
|
||||
|
|
@ -899,10 +1600,11 @@ Two dotted-name segments are reserved, permanent parts of the schema — not one
|
|||
|
||||
This is the checklist for adding a new sibling `bread*` application to the fabric — it's deliberately short, because the whole design goal of the name-based app registry (over one `AdapterSource` enum variant per app) is that this never requires a daemon change beyond step 1. **breadclip is the reference implementation** — see its own `EVENTS.md` for a worked example of every step below.
|
||||
|
||||
1. **Register your app id.** Add it to `KNOWN_APPS` in `bread-shared/src/lib.rs` (a one-line, one-word-per-app list) — this is the only change to the `bread` repo itself a new integration needs.
|
||||
1. **Register your app id.** Add it to `KNOWN_APPS` in `bread-shared/src/apps.rs` (a one-line, one-word-per-app list) — this is the only change to the `bread` repo itself a new integration needs.
|
||||
2. **Depend on `bread-utils` with the `bread-client` feature.** In your app's daemon (the long-running piece, if you have one — a short-lived CLI tool can use `bread-emit` instead, see below), add `bread-utils = { ..., features = ["bread-client"] }` and use `bread_utils::bread_client::BreadClient`:
|
||||
- `BreadClient::connect(app_id)` — cheap, cannot fail (there is no persistent connection to fail at construction time).
|
||||
- `client.emit(event, data)` — publish within your own `bread.<app_id>.*` namespace. Each call is its own short-lived connection (fire-and-forget, like `bread-emit`) — safe to call from a short-lived per-event process invocation, not just from inside a long-running loop.
|
||||
- `client.command(target, verb, data)` — publish `bread.command.<target>.<verb>` to another known app. Same fire-and-forget socket write as `emit`; this is the typed helper for the command-bus path that `bread-emit bread.command.<app>.<verb>` uses. *Since: v1.7 — the daemon actually accepts these on the unsourced and sourced-app emit paths; see [Namespaces](#namespaces).*
|
||||
- `client.subscribe("bread.command.<app_id>.**", |event| { ... })` — receive commands addressed to you, on a background thread with its own reconnect/backoff loop.
|
||||
3. **If you don't have a persistent daemon at all** (just a CLI tool invoked occasionally), skip `bread-client` entirely and shell out to `bread-emit` instead (see `bread-emit`'s own `--help`) — it's built for exactly that case (occasional callers that can't justify holding a socket open).
|
||||
4. **Emit confirmations for commands you honor.** `bread.<app_id>.<verb>.done` or `.failed` after acting on a `bread.command.<app_id>.<verb>` — optional, but it's what lets a Lua workflow `bread.wait`/`bread.wait_any` for the real outcome instead of assuming success the moment it publishes a command.
|
||||
|
|
@ -969,11 +1671,30 @@ This is the checklist for adding a new sibling `bread*` application to the fabri
|
|||
"updated_at": 1710000001500,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"widgets": [
|
||||
{
|
||||
"id": "weather.weather",
|
||||
"module": "weather",
|
||||
"placement": "left_of_stats",
|
||||
"order": 0,
|
||||
"visible": true,
|
||||
"tooltip": "Sydney: Partly cloudy",
|
||||
"root": {
|
||||
"type": "box",
|
||||
"orientation": "horizontal",
|
||||
"children": [
|
||||
{ "type": "icon", "name": "cloud" },
|
||||
{ "type": "label", "text": "22°C" }
|
||||
]
|
||||
},
|
||||
"updated_at": 1710000001500
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`modules[].status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`. `workflows[].state` values: `running`, `done`, `failed`, `timed_out` *(Since: v1.2 — see [Workflows](#workflows-since-v12))*.
|
||||
`modules[].status` values: `loaded`, `load_error`, `not_found`, `degraded`, `disabled`. `workflows[].state` values: `running`, `done`, `failed`, `timed_out` *(Since: v1.2 — see [Workflows](#workflows-since-v12))*. `widgets[].placement` values: `tray`, `left_of_clock`, `right_of_clock`, `right_of_workspaces`, `left_of_stats` *(Since: v1.3 — see [Widgets](#widgets-since-v13))*.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1007,7 +1728,43 @@ Available methods:
|
|||
| `profile.activate` | `name` | Switch active profile |
|
||||
| `events.subscribe` | — | Upgrade to streaming mode; pushes events line by line |
|
||||
| `events.replay` | `since_ms` | Replay buffered events from the last N ms |
|
||||
| `emit` | `event`, `data`, optional `source`, `kind` | Inject an event. Without `source`, builds a `BreadEvent` directly tagged `System` (legacy path). With `source` set to `terminal`/`git`/`remote`, or a registered sibling-app id (see [Namespaces](#namespaces)), builds a real `RawEvent` (requires `kind` too) that goes through the normalizer like any adapter. Any other `source` value is rejected — this is the anti-spoofing boundary that stops a socket client from forging e.g. `power`/`hyprland` events. |
|
||||
| `emit` | `event`, `data`, optional `source`, `kind` | Inject an event. Without `source`, builds a `BreadEvent` directly, tagged `Manual` *(Since: v1.5 — previously tagged `System`; see below)*, for manually testing Lua handlers (this is what `bread emit <event>` and `bread-emit` use). Well-formed `bread.command.<known-app>.<verb>` is allowed on this path *(Since: v1.7)*; other reserved domains stay rejected. With `source` set to `terminal`/`git`/`remote`, or a registered sibling-app id (see [Namespaces](#namespaces)), builds a real `RawEvent` (requires `kind` too) that goes through the normalizer like any adapter. A sourced app may also publish a well-formed command to another known app. Any other `source` value is rejected — this is the anti-spoofing boundary that stops a socket client from forging e.g. `power`/`hyprland` events. |
|
||||
| `workflows.list` | — | List running/completed workflow instances and their step/status *(Since: v1.2)* |
|
||||
| `widgets.list` | — | List all registered widgets across every module *(Since: v1.3)* |
|
||||
|
||||
*Since: v1.6* — `module_host.*`: the RPC bridge an out-of-process
|
||||
`bread-module-host` child uses in place of direct in-process `bread.*`
|
||||
bindings (see [Out-of-process module
|
||||
sandboxing](#out-of-process-module-sandboxing-since-v16)). Meaningful only
|
||||
on a connection that has completed the handshake below; not intended for
|
||||
direct use by other clients.
|
||||
|
||||
| Method | Params | Description |
|
||||
|--------|--------|-------------|
|
||||
| `module_host.hello` | `token` | One-time handshake. Consumes the token, replies with `{ module, permissions, api_version }` or an error for an unknown/expired token. Takes over the rest of the connection's lifetime as a bidirectional RPC bridge, same as `events.subscribe` does for a plain event stream. |
|
||||
| `module_host.on` / `.once` | `pattern` | Subscribe; replies `{ subscription_id }`. Matches are pushed asynchronously as `{"push":"event", subscription_id, event}` lines interleaved with ordinary responses. |
|
||||
| `module_host.off` | `id` | Cancel a subscription. |
|
||||
| `module_host.after` / `.every` | `delay_ms` / `interval_ms` | Server-managed timer; replies `{ timer_id }`. Fires are pushed as `{"push":"timer", timer_id}`. |
|
||||
| `module_host.cancel` | `id` | Cancel a timer. |
|
||||
| `module_host.emit` | `event`, `data` | Same manual-emit semantics (and reserved-domain guard) as the top-level `emit` method. |
|
||||
| `module_host.log` / `.warn` / `.error` | `message` | Forwarded to `breadd`'s own tracing log, prefixed with the module name. |
|
||||
| `module_host.fs_read` | `path` | Requires `fs.read` granted; path-prefix-checked against the manifest's `path` hint if one was declared. Replies `{ content }` (`null` if unreadable). |
|
||||
| `module_host.fs_write` | `path`, `content` | Requires `fs.write`, same scoping check. |
|
||||
| `module_host.exec` | `cmd` | Requires `exec`; `bin`-hint-checked (by leading command word) if declared. Fire-and-forget, matching `bread.exec`'s own semantics. |
|
||||
| `module_host.exec_capture` | `cmd`, `timeout_ms` | Requires `exec`. Replies `{ ok, stdout }`. |
|
||||
| `module_host.state_get` | `key` | Requires `state.read`. Replies `{ value }`. |
|
||||
| `module_host.status` | `state` (`"loaded"`\|`"load_error"`), `error` | The module-host reports its own load outcome after running `init.lua`; updates `modules.list` status and unblocks `breadd`'s spawn-side wait. |
|
||||
|
||||
Every gated method above checks the module's granted `PermissionKind`s
|
||||
(learned at hello-time) before attempting the call — belt-and-suspenders
|
||||
alongside the Landlock sandbox enforced at the OS level on the
|
||||
module-host process itself, not a replacement for it.
|
||||
|
||||
The `health` response's `api_version` field lets a client — the CLI, a Lua module via `bread.exec`, or a `bread-client`-linked sibling app — assert compatibility with this document's versioned schema at connect time (see [API Stability & Versioning](#api-stability--versioning)).
|
||||
|
||||
*Since: v1.5 — `emit` without `source` closed a spoofing gap: previously any event name was accepted with zero validation and tagged `System`, the same tag the daemon uses internally for events it originates itself in Rust code (`bread.system.startup`, `bread.profile.activated`, ...). That made a manually-injected event indistinguishable from a trusted, daemon-originated one. Now:*
|
||||
- *The unsourced path is tagged `AdapterSource::Manual`, not `System` — `System` is reserved for the daemon's own Rust-originated sends and can no longer be produced from data that arrived over the IPC socket.*
|
||||
- *The event name is rejected if its top-level dotted segment (the part right after `bread.`) is one of the reserved, adapter-owned domains in `bread_shared::apps::RESERVED_DOMAINS` — `terminal`, `git`, `hyprland`, `device`, `power`, `network`, `bluetooth`, `workspace`, `window`, `monitor`, `service`, `container`, `project`, `remote`, `system`, `profile`, `notify`, `command`, `workflow` (see [Namespaces](#namespaces)) — since a socket client emitting e.g. `bread.power.ac.connected` this way would otherwise be indistinguishable from the real power adapter observing it.*
|
||||
- *Since: v1.7 — well-formed `bread.command.<known-app>.<verb>` is an explicit exception to that reserved-domain reject (`command` stays reserved so it cannot be claimed as an app id). `bread.command.clip.clear` is accepted unsourced and as a sourced `AdapterSource::App` emit from another known app; `bread.command.power.off`, `bread.command.notanapp.x`, and `bread.hyprland.*` are still rejected. `API_VERSION` bumped from `1.6.0` to `1.7.0` for this addition.*
|
||||
- *Since: v1.7.1 — the state engine applies both legacy Hyprland names and `bread.hyprland.*` (so flipping `[compat] legacy_hyprland_event_names = false` no longer freezes monitors/workspace/window). `RuntimeState.workspaces` is written on `workspace.created`/`destroyed` and replaced by `bread.hyprland.snapshot`. `API_VERSION` bumped from `1.7.0` to `1.7.1`.*
|
||||
- *Freely-named custom/test event names (anything outside those reserved domains, including names with no `bread.` prefix at all) remain unrestricted — this is what keeps `bread emit <name>` useful for testing Lua handlers without unplugging cables, and what `bread-emit`'s fire-and-forget, no-reply-wait design still works against unchanged (a single JSON line write is still sufficient; no handshake was added).*
|
||||
|
|
|
|||
72
Examples.md
72
Examples.md
|
|
@ -238,6 +238,78 @@ Check on a running (or finished) workflow via the IPC method directly (there's n
|
|||
echo '{"id":"1","method":"workflows.list","params":{}}' | nc -U -q0 "$XDG_RUNTIME_DIR/bread/breadd.sock"
|
||||
```
|
||||
|
||||
## Example 5: A live widget in breadbar
|
||||
|
||||
The examples above all react to something; they don't put anything on
|
||||
screen. `bread.widget` *(Since: v1.3)* does — a module declares a small node
|
||||
tree and breadbar (or any sibling app that renders `bread.widget.*`) shows
|
||||
it in one of five fixed layout slots, live-updated from Lua.
|
||||
|
||||
Full source: `examples/modules/cpu-temp-widget.lua`.
|
||||
|
||||
```lua
|
||||
-- ~/.config/bread/modules/cpu-temp-widget.lua
|
||||
local M = bread.module({ name = "cpu-temp-widget", version = "1.0.0" })
|
||||
|
||||
local TEMP_PATH = "/sys/class/hwmon/hwmon6/temp1_input"
|
||||
local HOT_THRESHOLD_C = 80
|
||||
|
||||
local function read_temp_c()
|
||||
local raw = bread.fs.read(TEMP_PATH)
|
||||
return raw and (tonumber(raw) / 1000) or nil
|
||||
end
|
||||
|
||||
local function widget_root(temp_c)
|
||||
local text = temp_c and string.format("%.0f°C", temp_c) or "—"
|
||||
local hot = temp_c ~= nil and temp_c >= HOT_THRESHOLD_C
|
||||
return {
|
||||
type = "box",
|
||||
children = {
|
||||
{
|
||||
type = "label",
|
||||
text = text,
|
||||
style = hot and { color = "red", weight = "bold" } or { color = "dim" },
|
||||
},
|
||||
{
|
||||
type = "progress",
|
||||
value = temp_c and math.min(temp_c / 100, 1.0) or 0,
|
||||
style = hot and { color = "red" } or nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
function M.on_load()
|
||||
bread.widget.register({
|
||||
id = "cpu-temp",
|
||||
placement = "left_of_stats",
|
||||
tooltip = "CPU package temperature (Tctl)",
|
||||
root = widget_root(read_temp_c()),
|
||||
})
|
||||
|
||||
bread.every(5000, function()
|
||||
bread.widget.update("cpu-temp", { root = widget_root(read_temp_c()) })
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
```
|
||||
|
||||
Walking through what each piece buys you:
|
||||
|
||||
- **`root` is a small typed tree, not markup.** `box`/`label`/`icon`/`progress` map directly onto GTK primitives, so any renderer can draw it without interpreting a DSL — see [Widgets](Documentation.md#widgets-since-v13) for the full node reference and the size/depth caps.
|
||||
- **`bread.widget.update(id, { root = ... })` replaces the whole tree.** There's no node-level patching — for something this small, rebuilding the tree on every tick (here, every 5s) is simpler than diffing, and it's cheap enough that it doesn't matter.
|
||||
- **`style` is a bounded, typed vocabulary, not a style string.** `color = "red"` here maps to one fixed CSS class the rendering app defines, resolved from the real pywal-derived palette — see [Widgets §style](Documentation.md#style-since-v14) for the full field list. There's also a freeform `class` escape hatch, but a module can't inject arbitrary CSS through either path.
|
||||
- **Clicks come back as events, not callbacks.** A node's `on_click` value isn't invoked directly — the rendering app emits `bread.bar.widget_clicked` with `{ widget_id, action }`, and your module reacts with a normal `bread.on` handler. See `examples/modules/bluetooth-toggle-widget.lua` for a widget that uses this to drive a real action (`bread.bluetooth.power`) instead of just displaying something — or `examples/modules/focus-mode-widget.lua` for one that drives `bread.profile.activate` and stays in sync when the profile changes from somewhere else entirely (the CLI, another module), not just from its own click.
|
||||
- **Placement is one of five fixed slots** (`tray`, `left_of_clock`, `right_of_clock`, `right_of_workspaces`, `left_of_stats`) — see `examples/modules/active-window-widget.lua` for `right_of_workspaces` driven by `bread.state.watch` instead of a timer.
|
||||
- **A widget doesn't have to read hardware.** `examples/modules/workflow-status-widget.lua` polls `bread.workflow.list()` instead — the same engine from Example 4 — and sets `visible = false` to disappear entirely when there's nothing to report, rather than showing a stale or empty readout.
|
||||
|
||||
Check what's currently registered over IPC (there's no dedicated `bread` subcommand for this yet — see `widgets.list` in the [IPC protocol dictionary](Documentation.md#dictionary-ipc-protocol)):
|
||||
|
||||
```bash
|
||||
echo '{"id":"1","method":"widgets.list","params":{}}' | nc -U -q0 "$XDG_RUNTIME_DIR/bread/breadd.sock"
|
||||
```
|
||||
|
||||
## Tips for porting your own scripts
|
||||
|
||||
- Start by logging the event payload: `bread.log(event.data.raw)`
|
||||
|
|
|
|||
80
README.md
80
README.md
|
|
@ -15,7 +15,7 @@ Instead of scattering behavior across shell scripts, compositor configs, udev ru
|
|||
Bread runs a long-lived daemon (`breadd`) that:
|
||||
|
||||
1. Ingests raw signals from your compositor, hardware, and OS
|
||||
2. Normalizes them into stable, semantic events (`bread.device.dock.connected`, `bread.monitor.connected`, etc.)
|
||||
2. Normalizes them into stable, semantic events (`bread.device.dock.connected`, `bread.hyprland.monitor.connected`, etc.)
|
||||
3. Maintains a live model of your desktop state
|
||||
4. Delivers those events to Lua modules that implement your automation
|
||||
|
||||
|
|
@ -42,10 +42,12 @@ return M
|
|||
## Architecture
|
||||
|
||||
```
|
||||
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
|
||||
bread-cli/ CLI frontend — talks to breadd over a Unix socket
|
||||
bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource
|
||||
packaging/ Arch PKGBUILD and systemd user service
|
||||
breadd/ Rust daemon — event pipeline, state engine, IPC, adapter supervision
|
||||
bread-cli/ CLI frontend — talks to breadd over a Unix socket
|
||||
bread-emit/ Tiny fire-and-forget IPC emitter (hooks / command bus)
|
||||
bread-module-host/ Out-of-process sandboxed Lua module runtime
|
||||
bread-shared/ Shared types — RawEvent, BreadEvent, AdapterSource
|
||||
packaging/ systemd user service unit (bakery installs this)
|
||||
```
|
||||
|
||||
The daemon is structured in four layers:
|
||||
|
|
@ -80,7 +82,7 @@ git clone https://git.breadway.dev/Breadway/bread.git
|
|||
cd bread
|
||||
```
|
||||
|
||||
Run the install script — it builds, symlinks `breadd` and `bread` into `~/.local/bin` (override with `BIN_DIR=…`), installs the systemd user service, and starts the daemon:
|
||||
Run the install script — it builds, symlinks `breadd`, `bread`, `bread-emit`, and `bread-module-host` into `~/.local/bin` (override with `BIN_DIR=…`), installs the systemd user service, and starts the daemon:
|
||||
|
||||
```bash
|
||||
bash scripts/install.sh
|
||||
|
|
@ -92,13 +94,16 @@ Or step by step (system-wide install):
|
|||
cargo build --release
|
||||
sudo install -Dm755 target/release/breadd /usr/bin/breadd
|
||||
sudo install -Dm755 target/release/bread /usr/bin/bread
|
||||
sudo install -Dm755 target/release/bread-emit /usr/bin/bread-emit
|
||||
sudo install -Dm755 target/release/bread-module-host /usr/bin/bread-module-host
|
||||
```
|
||||
|
||||
### Arch Linux (PKGBUILD)
|
||||
### Via bakery
|
||||
|
||||
Prebuilt binaries ship through `bakery` (the bread-ecosystem package manager), not a PKGBUILD / pacman package:
|
||||
|
||||
```bash
|
||||
cd packaging/arch
|
||||
makepkg -si
|
||||
bakery install bread
|
||||
```
|
||||
|
||||
### systemd user service
|
||||
|
|
@ -144,17 +149,42 @@ enabled = true
|
|||
[events]
|
||||
dedup_window_ms = 100
|
||||
|
||||
[compat]
|
||||
legacy_hyprland_event_names = true # dual-emits bread.hyprland.* alongside legacy flat names; see Documentation.md
|
||||
|
||||
[notifications]
|
||||
default_timeout_ms = 5000
|
||||
default_urgency = "normal"
|
||||
notify_send_path = "notify-send"
|
||||
|
||||
[modules]
|
||||
builtin = true # load built-in modules (monitors, devices, workspaces, binds)
|
||||
builtin = true # load built-in modules (monitors, devices, workspaces, binds, rules)
|
||||
disable = [] # list of built-in module names to disable
|
||||
```
|
||||
|
||||
Your automation lives in `~/.config/bread/init.lua`. Modules placed in `~/.config/bread/modules/` are auto-loaded after `init.lua`:
|
||||
For the common "when event X happens, do Y" case, you don't need Lua at
|
||||
all — drop rules straight into `~/.config/bread/rules.toml` and skip
|
||||
`init.lua` entirely:
|
||||
|
||||
```toml
|
||||
# ~/.config/bread/rules.toml
|
||||
[[rule]]
|
||||
on = "device.dock.connected"
|
||||
run = "~/.config/bread/scripts/dock-connected.sh"
|
||||
|
||||
[[rule]]
|
||||
on = "power.ac.disconnected"
|
||||
notify = "Unplugged"
|
||||
```
|
||||
|
||||
It's optional and purely additive alongside `init.lua` — see
|
||||
[Getting started in Documentation.md](Documentation.md#getting-started) for
|
||||
the full schema (`run` vs `exec` vs `notify`, wildcard `on` patterns, and
|
||||
how a malformed rule surfaces via `bread doctor`).
|
||||
|
||||
For anything beyond a single action per event, your automation lives in
|
||||
`~/.config/bread/init.lua`. Modules placed in `~/.config/bread/modules/` are
|
||||
auto-loaded after `init.lua`:
|
||||
|
||||
```lua
|
||||
-- ~/.config/bread/init.lua
|
||||
|
|
@ -190,6 +220,7 @@ bread events bread.device.* # Stream filtered events
|
|||
bread events --since 60 # Replay events from the last 60 seconds
|
||||
bread events --fields event,data # Limit output to specific fields
|
||||
bread events --json # Output raw JSON
|
||||
bread events --tree # Render as a causality tree (caused_by) instead of a flat stream
|
||||
bread emit <event> # Manually fire an event (for testing)
|
||||
|
||||
# Profiles
|
||||
|
|
@ -201,6 +232,17 @@ bread modules list # List installed modules and daemon status
|
|||
bread modules install /local/path # Install from a local module directory
|
||||
bread modules remove <name> # Remove an installed module (--yes skips confirmation)
|
||||
bread modules info <name> # Show full manifest and daemon status
|
||||
bread modules audit <name> # Scan a module's Lua source and suggest a [[permissions]] block
|
||||
|
||||
# Hooks
|
||||
bread hooks install-shell [shell] # Install precmd/preexec/chpwd shell hooks (auto-detects $SHELL)
|
||||
bread hooks install-git # Install git hooks (post-commit/checkout/merge) in the current repo
|
||||
|
||||
# Compositor integration
|
||||
bread init # Install breadbar's Hyprland layer-rule integration (shows a diff, asks to confirm)
|
||||
bread init --dry-run # Print the proposed diff only, change nothing
|
||||
bread init --yes # Skip the confirmation prompt (scripted/image builds)
|
||||
bread init --undo # Remove the previously installed integration
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -211,10 +253,18 @@ Modules are Lua files (or directories) installed to `~/.config/bread/modules/`.
|
|||
|
||||
### Installing modules
|
||||
|
||||
Modules install from a local directory only. Modules run with full
|
||||
`bread.exec()` privileges and are **not** sandboxed, so to use a module
|
||||
published on a git host, clone it yourself and review the Lua before
|
||||
installing from the local checkout:
|
||||
Modules install from a local directory only. By default a module runs
|
||||
in-process with full, ungated `bread.exec()` privileges — the same trust
|
||||
model as before — so to use a module published on a git host, clone it
|
||||
yourself and review the Lua before installing from the local checkout.
|
||||
A module can opt into a smaller, enforced footprint by declaring
|
||||
`[[permissions]]` in its manifest: it then runs out-of-process under an
|
||||
OS-level (Landlock) sandbox limited to exactly what it declared, with a
|
||||
`bread` table that only exposes the granted namespaces. See
|
||||
[Capability-scoped modules](Documentation.md#capability-scoped-modules-since-v15) and
|
||||
[Out-of-process module sandboxing](Documentation.md#out-of-process-module-sandboxing-since-v16)
|
||||
for the full permission taxonomy and what's enforced at the kernel level
|
||||
versus what isn't yet.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/someuser/bread-wifi ~/src/bread-wifi
|
||||
|
|
|
|||
615
api-schema.toml
Normal file
615
api-schema.toml
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
# Bread Automation API schema — checked-in source-of-truth registry.
|
||||
#
|
||||
# This is Workstream F from the governance-hardening report: a *drift
|
||||
# detector*, not a doc generator. `Documentation.md`'s "Dictionary: Lua API"
|
||||
# section is hand-written prose (one `#### bread.<name>(...)` heading per
|
||||
# binding, with worked examples and edge-case notes) — nothing here
|
||||
# regenerates or reformats that prose. Instead, this file is the checked-in
|
||||
# list of every `bread.*` Lua binding, IPC method, and `bread` CLI command
|
||||
# that is supposed to exist right now, and `cargo run -p xtask -- check-docs`
|
||||
# (wired into CI via .forgejo/workflows/dev-release.yml — fails the build on
|
||||
# any drift) cross-checks it against:
|
||||
#
|
||||
# 1. The actual bindings registered in breadd/src/lua/mod.rs
|
||||
# (`bread.set("name", ...)` calls, the nested `<x>_tbl.set(...)` calls
|
||||
# for state/profile/hyprland/widget/machine/fs/json/bluetooth, and the
|
||||
# handful of bindings defined via plain embedded Lua source rather than
|
||||
# `bread.set` — log/warn/error/debounce/spawn/wait/wait_any/wait_all/
|
||||
# workflow.*).
|
||||
# 2. The actual IPC methods dispatched in breadd/src/ipc/mod.rs's
|
||||
# `match req.method.as_str() { ... }` block, plus the specially-cased
|
||||
# `events.subscribe` streaming upgrade.
|
||||
# 3. The actual `bread` CLI commands declared in bread-cli/src/main.rs's
|
||||
# `Commands`/`ModulesCommand`/`HooksCommand` enums.
|
||||
# 4. Documentation.md, to make sure each lua_function/lua_table/ipc_method
|
||||
# entry here still has a `#### bread.<name>` heading (Lua) or a row in
|
||||
# the IPC Methods table (IPC methods).
|
||||
# 5. README.md's "CLI reference" section, to make sure each cli_command
|
||||
# entry here still has a `bread <name>` line there. This check exists
|
||||
# because that section drifted from Documentation.md/the real CLI
|
||||
# surface before check-docs covered it at all (missing `modules audit`,
|
||||
# `hooks install-shell`/`install-git`, `events --tree`) — found and
|
||||
# fixed by hand, then closed here so it can't recur silently.
|
||||
#
|
||||
# Whenever you add, rename, or remove a `bread.*` binding, an IPC method, or
|
||||
# a `bread` CLI command:
|
||||
# 1. Update this file to match.
|
||||
# 2. Update/add the corresponding section in Documentation.md (Lua/IPC)
|
||||
# or README.md's CLI reference (CLI commands).
|
||||
# 3. Run `cargo run -p xtask -- check-docs` before committing — it fails
|
||||
# loudly (non-zero exit) if the schema, the code, and the docs are out
|
||||
# of sync. CI runs this too (dev-release.yml), so drift that slips past
|
||||
# a local run still fails the build.
|
||||
#
|
||||
# `kind` is one of: "lua_function", "lua_table", "ipc_method", "cli_command".
|
||||
#
|
||||
# `since` means two different things depending on `kind`, because CLI
|
||||
# commands were never part of the Bread Automation API's own versioned
|
||||
# contract (see Documentation.md's "API Stability & Versioning" section —
|
||||
# it's explicitly scoped to "Lua API surface + IPC methods + event
|
||||
# vocabulary + runtime-state schema", not the CLI):
|
||||
# - lua_function / lua_table / ipc_method: the Bread Automation API
|
||||
# version (breadd/src/ipc/mod.rs's `API_VERSION`) the binding/method was
|
||||
# introduced in. Anything from the original v1.0 baseline (no
|
||||
# `*(Since: vX.Y)*` marker in Documentation.md) is listed as "1.0" here.
|
||||
# - cli_command: the `bread`/`breadd` package version (the workspace
|
||||
# crates' `Cargo.toml` `version`, kept in lockstep — see CONTRIBUTING.md)
|
||||
# the command was introduced in. Pre-existing commands as of this
|
||||
# registry's creation are listed as "0.7" (the release before this one);
|
||||
# no attempt was made to date them more precisely than that.
|
||||
#
|
||||
# Format chosen: a single checked-in TOML file (this is the "a schema file
|
||||
# that's checked and diffed against the actual API surface, and CI fails the
|
||||
# build if they drift" option the source report names, as opposed to Rust
|
||||
# attribute macros — overkill for a ~45-entry surface with no existing
|
||||
# proc-macro infrastructure in this workspace). TOML specifically because
|
||||
# `toml = "0.8"` is already a dependency of breadd/bread-cli/bread-shared
|
||||
# (see breadd/src/core/config.rs, bread-cli/src/modules_mgmt.rs) — no new
|
||||
# format/parser needed anywhere in the ecosystem.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Events (breadd/src/lua/mod.rs install_api + install_wait_helper)
|
||||
|
||||
[[entry]]
|
||||
name = "on"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "once"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "filter"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "off"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "emit"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "wait"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "spawn"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "wait_any"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "wait_all"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Workflows (install_workflow_helpers) *(Since: v1.2)*
|
||||
|
||||
[[entry]]
|
||||
name = "workflow"
|
||||
kind = "lua_table"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "workflow.define"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "workflow.start"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "workflow.step"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "workflow.status"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "workflow.list"
|
||||
kind = "lua_function"
|
||||
since = "1.2"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Widgets (widget_tbl) *(Since: v1.3)*
|
||||
|
||||
[[entry]]
|
||||
name = "widget"
|
||||
kind = "lua_table"
|
||||
since = "1.3"
|
||||
|
||||
[[entry]]
|
||||
name = "widget.register"
|
||||
kind = "lua_function"
|
||||
since = "1.3"
|
||||
|
||||
[[entry]]
|
||||
name = "widget.update"
|
||||
kind = "lua_function"
|
||||
since = "1.3"
|
||||
|
||||
[[entry]]
|
||||
name = "widget.remove"
|
||||
kind = "lua_function"
|
||||
since = "1.3"
|
||||
|
||||
[[entry]]
|
||||
name = "widget.list"
|
||||
kind = "lua_function"
|
||||
since = "1.3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — State (state_tbl)
|
||||
|
||||
[[entry]]
|
||||
name = "state"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.get"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.monitors"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.active_workspace"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.active_window"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.devices"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.power"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.network"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.profile"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.watch"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Profiles (profile_tbl)
|
||||
|
||||
[[entry]]
|
||||
name = "profile"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "profile.activate"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Execution, notifications, timers
|
||||
|
||||
[[entry]]
|
||||
name = "exec"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "exec_capture"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "notify"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "after"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "every"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "cancel"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Hyprland (hyprland_tbl)
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.dispatch"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.keyword"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.eval"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.active_window"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.monitors"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.workspaces"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.clients"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "hyprland.on_raw"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Module declaration
|
||||
|
||||
[[entry]]
|
||||
name = "module"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Machine and filesystem (machine_tbl / fs_tbl / json_tbl)
|
||||
|
||||
[[entry]]
|
||||
name = "machine"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "machine.name"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "machine.tags"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "machine.has_tag"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs.write"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs.read"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs.exists"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs.readlink"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "fs.expand"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "json"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "json.decode"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Bluetooth (bluetooth_tbl)
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth"
|
||||
kind = "lua_table"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.power"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.powered"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.connect"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.disconnect"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.scan"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "bluetooth.devices"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lua API — Utilities (install_log_helpers / install_debounce)
|
||||
|
||||
[[entry]]
|
||||
name = "log"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "warn"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "error"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "debounce"
|
||||
kind = "lua_function"
|
||||
since = "1.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IPC methods (breadd/src/ipc/mod.rs handle_request + events.subscribe)
|
||||
|
||||
[[entry]]
|
||||
name = "ping"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "health"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.get"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "state.dump"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "modules.list"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "modules.reload"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "profile.list"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "profile.activate"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "emit"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "events.subscribe"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "events.replay"
|
||||
kind = "ipc_method"
|
||||
since = "1.0"
|
||||
|
||||
[[entry]]
|
||||
name = "workflows.list"
|
||||
kind = "ipc_method"
|
||||
since = "1.2"
|
||||
|
||||
[[entry]]
|
||||
name = "widgets.list"
|
||||
kind = "ipc_method"
|
||||
since = "1.3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI commands (bread-cli/src/main.rs) — checked against README.md's "CLI
|
||||
# reference" section, not Documentation.md (which has no CLI section of its
|
||||
# own). `since` here is the package version, not the API_VERSION — see this
|
||||
# file's header.
|
||||
|
||||
[[entry]]
|
||||
name = "reload"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "state"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "events"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "modules.list"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "modules.install"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "modules.remove"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "modules.info"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "modules.audit"
|
||||
kind = "cli_command"
|
||||
since = "0.8"
|
||||
|
||||
[[entry]]
|
||||
name = "hooks.install-shell"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "hooks.install-git"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "profile-list"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "profile-activate"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "emit"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "ping"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "health"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "doctor"
|
||||
kind = "cli_command"
|
||||
since = "0.7"
|
||||
|
||||
[[entry]]
|
||||
name = "init"
|
||||
kind = "cli_command"
|
||||
since = "0.8"
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
name = "bread"
|
||||
description = "Reactive automation daemon and CLI for Linux desktops"
|
||||
binaries = ["breadd", "bread"]
|
||||
binaries = ["breadd", "bread", "bread-emit", "bread-module-host"]
|
||||
system_deps = ["systemd-libs", "openssl", "zlib"]
|
||||
optional_system_deps = ["bluez", "hyprland"]
|
||||
bread_deps = []
|
||||
license_file = "LICENSE"
|
||||
|
||||
[[service]]
|
||||
unit = "breadd.service"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "bread-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
|
|
|||
|
|
@ -13,6 +13,20 @@
|
|||
//! user later doesn't want it, an rc-file edit is much harder to notice and
|
||||
//! undo than a printed snippet they chose to paste in.
|
||||
//!
|
||||
//! `bread init` (`init.rs`) looks like it breaks this rule — it does
|
||||
//! propose an edit to a user's own `hyprland.lua` — but it's a narrower,
|
||||
//! consent-gated exception, not a reversal of it. The difference is what's
|
||||
//! silent about the failure mode: a missing shell hook is invisible
|
||||
//! enrichment (no telemetry, nothing looks wrong), so leaving it to the
|
||||
//! user to paste in at their own pace is the right default. A missing
|
||||
//! compositor layer rule is not invisible — breadbar renders unblurred and
|
||||
//! with the wrong opacity, which reads as "this app is broken," not
|
||||
//! "optional feature not enabled," and there's no snippet a user could be
|
||||
//! expected to reverse-engineer for that. `init.rs` earns the edit by
|
||||
//! showing the exact diff, requiring explicit consent (prompt, `--yes`, or
|
||||
//! nothing at all without a TTY), backing up the file first, and shipping
|
||||
//! a real `--undo`. See `init.rs`'s module docs for the full rationale.
|
||||
//!
|
||||
//! # Why bread-emit, not `bread emit`
|
||||
//!
|
||||
//! The generated hooks shell out to the separate `bread-emit` binary
|
||||
|
|
|
|||
1281
bread-cli/src/init.rs
Normal file
1281
bread-cli/src/init.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,4 @@
|
|||
mod init;
|
||||
mod hooks_git;
|
||||
mod hooks_shell;
|
||||
mod modules_mgmt;
|
||||
|
|
@ -6,6 +7,7 @@ use anyhow::Result;
|
|||
use clap::{Parser, Subcommand};
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::io::{self, Write as IoWrite};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -54,6 +56,12 @@ enum Commands {
|
|||
/// Replay events from the last N seconds
|
||||
#[arg(long)]
|
||||
since: Option<u64>,
|
||||
/// Render events as a causality tree via `caused_by` instead of a
|
||||
/// flat stream (events emitted from inside a `bread.emit()` call
|
||||
/// made by another event's Lua handler are nested under it).
|
||||
/// Overrides `--json` — tree rendering always uses the formatted view.
|
||||
#[arg(long)]
|
||||
tree: bool,
|
||||
},
|
||||
/// Manage installed Lua modules
|
||||
Modules {
|
||||
|
|
@ -92,6 +100,22 @@ enum Commands {
|
|||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Install bread's Hyprland compositor layer-rule integration
|
||||
/// (breadbar/breadbox blur, ignore_alpha, animation) into
|
||||
/// ~/.config/hypr/hyprland.lua, with consent, a backup, and full undo.
|
||||
/// See bread-cli/src/init.rs for the design rationale.
|
||||
Init {
|
||||
/// Print the proposed diff and change nothing
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
/// Skip the confirmation prompt (for scripted/image builds)
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
/// Remove the previously installed marked block instead of
|
||||
/// installing it
|
||||
#[arg(long)]
|
||||
undo: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
|
|
@ -125,6 +149,9 @@ enum ModulesCommand {
|
|||
List,
|
||||
/// Show full manifest details for a module
|
||||
Info { name: String },
|
||||
/// Statically scan an installed module's Lua source and suggest a
|
||||
/// `[[permissions]]` block for its `bread.module.toml` manifest
|
||||
Audit { name: String },
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -158,8 +185,9 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
fields,
|
||||
since,
|
||||
tree,
|
||||
} => {
|
||||
stream_events(&socket, pattern, json, fields, since).await?;
|
||||
stream_events(&socket, pattern, json, fields, since, tree).await?;
|
||||
}
|
||||
Commands::Modules { subcommand } => {
|
||||
handle_modules_cmd(subcommand, &socket).await?;
|
||||
|
|
@ -213,6 +241,9 @@ async fn main() -> Result<()> {
|
|||
print_doctor(&socket).await?;
|
||||
}
|
||||
}
|
||||
Commands::Init { dry_run, yes, undo } => {
|
||||
init::run(dry_run, yes, undo)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -303,6 +334,55 @@ async fn handle_modules_cmd(cmd: ModulesCommand, socket: &Path) -> Result<()> {
|
|||
println!("source: {}", m.source);
|
||||
println!("installed_at: {}", m.installed_at);
|
||||
println!("status: {}", status);
|
||||
match &m.permissions {
|
||||
None => println!(
|
||||
"permissions: (none declared — full, ungated bread.* access; see 'bread doctor')"
|
||||
),
|
||||
Some(perms) if perms.is_empty() => {
|
||||
println!("permissions: (declared empty — baseline access only)")
|
||||
}
|
||||
Some(perms) => {
|
||||
println!("permissions:");
|
||||
for p in perms {
|
||||
let mut line = format!(" - {:?}", p.kind);
|
||||
if let Some(path) = &p.path {
|
||||
line.push_str(&format!(" path={path}"));
|
||||
}
|
||||
if let Some(bin) = &p.bin {
|
||||
line.push_str(&format!(" bin={bin}"));
|
||||
}
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModulesCommand::Audit { name } => {
|
||||
let module_dir = mods_dir.join(&name);
|
||||
if !module_dir.exists() {
|
||||
eprintln!("bread: module '{}' is not installed", name);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let suggested = modules_mgmt::audit_module(&module_dir)?;
|
||||
if suggested.is_empty() {
|
||||
println!(
|
||||
"bread: no capability-gated bread.* calls found in '{}' — \
|
||||
it appears to only use baseline APIs (events, timers, json, \
|
||||
logging). Declaring `permissions = []` in bread.module.toml \
|
||||
documents that intentionally and avoids the 'no permissions \
|
||||
declared' warning from `bread doctor`.",
|
||||
name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"bread: suggested permissions for '{}' (best-effort static scan — \
|
||||
review before pasting into bread.module.toml; false positives \
|
||||
are possible, missing an actually-needed permission should be rare \
|
||||
for direct bread.exec()-style call sites):\n",
|
||||
name
|
||||
);
|
||||
print!("{}", modules_mgmt::render_permissions_toml(&suggested)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -378,7 +458,13 @@ async fn stream_events(
|
|||
raw_json: bool,
|
||||
fields: Option<String>,
|
||||
since: Option<u64>,
|
||||
tree: bool,
|
||||
) -> Result<()> {
|
||||
// Tree rendering needs `id`/`caused_by` visible in a consistent shape,
|
||||
// so it always uses the formatted view — a live-streaming-friendly
|
||||
// indent-as-you-go tree rather than buffering the whole stream.
|
||||
let mut causality = CausalityTracker::default();
|
||||
|
||||
if let Some(seconds) = since {
|
||||
let replay = send_request(
|
||||
socket,
|
||||
|
|
@ -388,7 +474,9 @@ async fn stream_events(
|
|||
.await?;
|
||||
if let Some(list) = replay.as_array() {
|
||||
for item in list {
|
||||
if raw_json {
|
||||
if tree {
|
||||
causality.print(item);
|
||||
} else if raw_json {
|
||||
println!("{}", serde_json::to_string_pretty(item)?);
|
||||
} else {
|
||||
print_event(item, fields.as_deref());
|
||||
|
|
@ -426,7 +514,9 @@ async fn stream_events(
|
|||
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let value: Value = serde_json::from_str(&line)?;
|
||||
if raw_json {
|
||||
if tree {
|
||||
causality.print(&value);
|
||||
} else if raw_json {
|
||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||
} else {
|
||||
print_event(&value, fields.as_deref());
|
||||
|
|
@ -436,6 +526,63 @@ async fn stream_events(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Tracks `id` -> `caused_by` for events seen so far in this stream/replay
|
||||
/// batch, so each new event can be indented under its parent as it arrives
|
||||
/// — no buffering, no waiting for the stream to end. An event whose parent
|
||||
/// hasn't been seen yet (e.g. the parent predates a replay window, or the
|
||||
/// causing event is filtered out by the subscription pattern) is rendered
|
||||
/// as its own root rather than blocking on a parent that may never show up.
|
||||
#[derive(Default)]
|
||||
struct CausalityTracker {
|
||||
parents: HashMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
impl CausalityTracker {
|
||||
/// Depth = number of ancestors reachable by following `caused_by`.
|
||||
/// Guards against cycles/self-loops (shouldn't happen, but a rendering
|
||||
/// bug here must never hang the CLI) with both a seen-set and a hard cap.
|
||||
fn depth(&self, id: &str) -> usize {
|
||||
let mut depth = 0;
|
||||
let mut current = id.to_string();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some(Some(parent)) = self.parents.get(¤t) {
|
||||
if depth >= 64 || !seen.insert(current.clone()) {
|
||||
break;
|
||||
}
|
||||
depth += 1;
|
||||
current = parent.clone();
|
||||
}
|
||||
depth
|
||||
}
|
||||
|
||||
fn print(&mut self, event: &Value) {
|
||||
let id = event.get("id").and_then(Value::as_str).map(str::to_string);
|
||||
let caused_by = event
|
||||
.get("caused_by")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
|
||||
let depth = if let Some(id) = &id {
|
||||
self.parents.insert(id.clone(), caused_by.clone());
|
||||
self.depth(id)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let ts = event.get("timestamp").and_then(Value::as_u64).unwrap_or(0);
|
||||
let event_name = event.get("event").and_then(Value::as_str).unwrap_or("?");
|
||||
let source = event.get("source").and_then(Value::as_str).unwrap_or("?");
|
||||
let time = format_timestamp(ts);
|
||||
let indent = " ".repeat(depth);
|
||||
let connector = if depth > 0 { "\u{2514}\u{2500} " } else { "" };
|
||||
let id_display = id.as_deref().unwrap_or("?");
|
||||
println!("{indent}{connector}{time} {event_name} source={source} id={id_display}");
|
||||
if let Some(data) = event.get("data") {
|
||||
println!("{indent} data: {data}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(value: &Value) -> Result<()> {
|
||||
println!("{}", serde_json::to_string_pretty(value)?);
|
||||
Ok(())
|
||||
|
|
@ -564,6 +711,7 @@ async fn print_doctor(socket: &Path) -> Result<()> {
|
|||
println!();
|
||||
println!(" start the daemon: systemctl --user start breadd");
|
||||
println!(" view logs: journalctl --user -u breadd -f");
|
||||
print_compositor_doctor_section();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
|
@ -602,14 +750,35 @@ fn render_doctor(health: &Value) {
|
|||
if let Some(modules) = health.get("modules").and_then(Value::as_array) {
|
||||
println!();
|
||||
println!("modules");
|
||||
let mut ungated_count = 0;
|
||||
for module in modules {
|
||||
let name = module.get("name").and_then(Value::as_str).unwrap_or("?");
|
||||
let status = module.get("status").and_then(Value::as_str).unwrap_or("?");
|
||||
let error = module.get("last_error").and_then(Value::as_str);
|
||||
let ungated = module
|
||||
.get("ungated")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
println!(" {:30} {}", name, status);
|
||||
if let Some(error) = error {
|
||||
println!(" └ {error}");
|
||||
}
|
||||
if ungated {
|
||||
ungated_count += 1;
|
||||
println!(
|
||||
" └ ⚠ running with full, ungated access — no permissions \
|
||||
manifest declared (add `[[permissions]]` to its \
|
||||
bread.module.toml, or run `bread modules audit {name}` \
|
||||
for a suggested block)"
|
||||
);
|
||||
}
|
||||
}
|
||||
if ungated_count > 0 {
|
||||
println!();
|
||||
println!(
|
||||
" {ungated_count} module(s) running with full, ungated bread.* access — \
|
||||
see above"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -627,6 +796,20 @@ fn render_doctor(health: &Value) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
print_compositor_doctor_section();
|
||||
}
|
||||
|
||||
/// Compositor integration status — pure filesystem check, independent of
|
||||
/// the daemon, so it prints the same whether breadd is up or not. Reports
|
||||
/// only; `bread doctor` never installs anything (that's `bread init`'s
|
||||
/// job).
|
||||
fn print_compositor_doctor_section() {
|
||||
println!();
|
||||
println!("compositor");
|
||||
for line in init::doctor_report(&init::hypr_dir()) {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn config_directory() -> PathBuf {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use bread_shared::{ModulePermission, PermissionKind};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
|
@ -13,6 +14,16 @@ pub struct ModuleManifest {
|
|||
pub author: String,
|
||||
pub source: String,
|
||||
pub installed_at: String,
|
||||
/// Declared `[[permissions]]` entries. `None` means the manifest has no
|
||||
/// `permissions` key at all — either because it predates this field (an
|
||||
/// already-installed module) or because the author simply didn't add
|
||||
/// one. `breadd` treats that the same way: full, ungated `bread.*`
|
||||
/// access, same as today, but `bread doctor` flags it so the gap is
|
||||
/// visible instead of silently permanent. An explicit `permissions = []`
|
||||
/// is different: it's a deliberate "baseline only" declaration and does
|
||||
/// *not* get flagged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permissions: Option<Vec<ModulePermission>>,
|
||||
}
|
||||
|
||||
/// Resolve a module source string to a local directory path.
|
||||
|
|
@ -227,3 +238,235 @@ fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// `bread modules audit` — best-effort static permission suggestion
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// This is deliberately a text scan, not a Lua parser. `breadd`'s own
|
||||
// scoping mechanism only cares whether a permission was declared at all
|
||||
// (see `Documentation.md`'s "Capability-scoped modules" section), so the
|
||||
// bar here is the same one the report set: false positives (suggesting a
|
||||
// permission a module doesn't strictly need) are fine, false negatives on
|
||||
// a plain `bread.exec("...")`-style call site should be rare. It is not
|
||||
// expected to follow dynamic dispatch, string-built calls, or anything a
|
||||
// real parser would be needed for.
|
||||
|
||||
/// Statically scan every `.lua` file in `module_dir` (recursively — a
|
||||
/// module may `require()` sibling files from its own directory) for
|
||||
/// `bread.*` call-site patterns and return a suggested, deduplicated
|
||||
/// permission list for the user to review.
|
||||
pub fn audit_module(module_dir: &Path) -> Result<Vec<ModulePermission>> {
|
||||
let mut found: std::collections::BTreeMap<PermissionKind, ModulePermission> =
|
||||
std::collections::BTreeMap::new();
|
||||
let mut files = Vec::new();
|
||||
collect_lua_files(module_dir, &mut files)?;
|
||||
for file in &files {
|
||||
if let Ok(src) = fs::read_to_string(file) {
|
||||
scan_lua_source(&src, &mut found);
|
||||
}
|
||||
}
|
||||
Ok(found.into_values().collect())
|
||||
}
|
||||
|
||||
/// Render a suggested permission list as a pastable `[[permissions]]` TOML
|
||||
/// block, matching exactly what `bread.module.toml` expects.
|
||||
pub fn render_permissions_toml(perms: &[ModulePermission]) -> Result<String> {
|
||||
#[derive(Serialize)]
|
||||
struct PermissionsBlock<'a> {
|
||||
permissions: &'a [ModulePermission],
|
||||
}
|
||||
toml::to_string_pretty(&PermissionsBlock { permissions: perms })
|
||||
.context("failed to render suggested permissions as TOML")
|
||||
}
|
||||
|
||||
fn collect_lua_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_lua_files(&path, out)?;
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("lua") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scan one file's source for `bread.<ident>[.<ident>]` occurrences and
|
||||
/// classify each into a permission, accumulating into `found` (keyed by
|
||||
/// kind, so repeated call sites for the same permission collapse to one
|
||||
/// suggestion — first-seen scoping hint wins).
|
||||
fn scan_lua_source(src: &str, found: &mut std::collections::BTreeMap<PermissionKind, ModulePermission>) {
|
||||
const NEEDLE: &str = "bread.";
|
||||
let mut cursor = 0usize;
|
||||
while let Some(rel) = src[cursor..].find(NEEDLE) {
|
||||
let ident_start = cursor + rel + NEEDLE.len();
|
||||
cursor = ident_start;
|
||||
let rest = &src[ident_start..];
|
||||
let ident_len = rest
|
||||
.find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.'))
|
||||
.unwrap_or(rest.len());
|
||||
let ident = rest[..ident_len].trim_end_matches('.');
|
||||
if ident.is_empty() {
|
||||
continue;
|
||||
}
|
||||
classify_call_site(ident, &rest[ident_len..], found);
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_call_site(
|
||||
ident: &str,
|
||||
tail: &str,
|
||||
found: &mut std::collections::BTreeMap<PermissionKind, ModulePermission>,
|
||||
) {
|
||||
let (kind, bin, path): (PermissionKind, Option<String>, Option<String>) = match ident {
|
||||
"fs.write" => (PermissionKind::FsWrite, None, extract_first_string_arg(tail)),
|
||||
"fs.read" | "fs.exists" | "fs.readlink" | "fs.expand" => {
|
||||
(PermissionKind::FsRead, None, extract_first_string_arg(tail))
|
||||
}
|
||||
"exec" | "exec_capture" => {
|
||||
let hint = extract_first_string_arg(tail)
|
||||
.and_then(|s| s.split_whitespace().next().map(str::to_string));
|
||||
(PermissionKind::Exec, hint, None)
|
||||
}
|
||||
"notify" => (PermissionKind::Notify, None, None),
|
||||
"profile.activate" => (PermissionKind::ProfileActivate, None, None),
|
||||
"state.watch" => (PermissionKind::StateWatch, None, extract_first_string_arg(tail)),
|
||||
other if other == "state" || other.starts_with("state.") => {
|
||||
(PermissionKind::StateRead, None, extract_first_string_arg(tail))
|
||||
}
|
||||
other if other == "machine" || other.starts_with("machine.") => {
|
||||
(PermissionKind::Machine, None, None)
|
||||
}
|
||||
other if other == "hyprland" || other.starts_with("hyprland.") => {
|
||||
(PermissionKind::Hyprland, None, None)
|
||||
}
|
||||
other if other == "widget" || other.starts_with("widget.") => {
|
||||
(PermissionKind::Widget, None, None)
|
||||
}
|
||||
other if other == "bluetooth" || other.starts_with("bluetooth.") => {
|
||||
(PermissionKind::Bluetooth, None, None)
|
||||
}
|
||||
// Everything else (on/once/filter/off/emit/after/every/cancel/json/
|
||||
// module/log/warn/error/debounce/spawn/wait/wait_any/wait_all/
|
||||
// workflow/__private) is baseline — always available, nothing to
|
||||
// suggest.
|
||||
_ => return,
|
||||
};
|
||||
found
|
||||
.entry(kind)
|
||||
.or_insert(ModulePermission { kind, path, bin });
|
||||
}
|
||||
|
||||
/// Best-effort extraction of the first quoted string literal appearing on
|
||||
/// the same line right after a call-site's opening paren, e.g.
|
||||
/// `bread.exec("hyprpaper --config foo")` -> `Some("hyprpaper --config foo")`.
|
||||
/// Returns `None` for dynamic/variable arguments (`bread.fs.read(path)`) —
|
||||
/// the permission is still suggested, just without a scoping hint.
|
||||
fn extract_first_string_arg(tail: &str) -> Option<String> {
|
||||
let line_end = tail.find('\n').unwrap_or(tail.len());
|
||||
let window = &tail[..line_end];
|
||||
let quote_pos = window.find(['"', '\''])?;
|
||||
let quote_char = window.as_bytes()[quote_pos] as char;
|
||||
let after = &window[quote_pos + 1..];
|
||||
let quote_end = after.find(quote_char)?;
|
||||
Some(after[..quote_end].to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod audit_tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn audit_detects_fs_read_and_widget_from_cpu_temp_widget_style_module() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
dir.path().join("init.lua"),
|
||||
r#"
|
||||
local M = bread.module({ name = "cpu-temp-widget", version = "1.0.0" })
|
||||
local function read_temp_c()
|
||||
local raw = bread.fs.read("/sys/class/hwmon/hwmon6/temp1_input")
|
||||
return raw
|
||||
end
|
||||
function M.on_load()
|
||||
bread.widget.register({ id = "cpu-temp" })
|
||||
bread.every(5000, function()
|
||||
bread.widget.update("cpu-temp", {})
|
||||
end)
|
||||
end
|
||||
return M
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let perms = audit_module(dir.path()).unwrap();
|
||||
let kinds: Vec<PermissionKind> = perms.iter().map(|p| p.kind).collect();
|
||||
assert!(kinds.contains(&PermissionKind::FsRead));
|
||||
assert!(kinds.contains(&PermissionKind::Widget));
|
||||
assert!(!kinds.contains(&PermissionKind::Exec));
|
||||
assert!(!kinds.contains(&PermissionKind::Bluetooth));
|
||||
|
||||
let fs_perm = perms.iter().find(|p| p.kind == PermissionKind::FsRead).unwrap();
|
||||
assert_eq!(fs_perm.path.as_deref(), Some("/sys/class/hwmon/hwmon6/temp1_input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_extracts_exec_bin_hint_and_ignores_baseline_calls() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
dir.path().join("init.lua"),
|
||||
r#"
|
||||
local M = bread.module({ name = "wallpaper", version = "1.0.0" })
|
||||
function M.on_load()
|
||||
bread.on("bread.monitor.connected", function()
|
||||
bread.exec("hyprpaper --config /tmp/foo")
|
||||
end)
|
||||
bread.log("loaded")
|
||||
end
|
||||
return M
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let perms = audit_module(dir.path()).unwrap();
|
||||
assert_eq!(perms.len(), 1);
|
||||
assert_eq!(perms[0].kind, PermissionKind::Exec);
|
||||
assert_eq!(perms[0].bin.as_deref(), Some("hyprpaper"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_scans_required_sibling_files_in_module_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
dir.path().join("init.lua"),
|
||||
r#"local lib = require("./lib"); return bread.module({ name = "m", version = "1.0.0" })"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("lib.lua"),
|
||||
r#"return { go = function() bread.bluetooth.power(true) end }"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let perms = audit_module(dir.path()).unwrap();
|
||||
assert!(perms.iter().any(|p| p.kind == PermissionKind::Bluetooth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_permissions_toml_produces_pastable_block() {
|
||||
let perms = vec![ModulePermission {
|
||||
kind: PermissionKind::Exec,
|
||||
path: None,
|
||||
bin: Some("hyprpaper".to_string()),
|
||||
}];
|
||||
let rendered = render_permissions_toml(&perms).unwrap();
|
||||
assert!(rendered.contains("[[permissions]]"));
|
||||
assert!(rendered.contains("type = \"exec\""));
|
||||
assert!(rendered.contains("bin = \"hyprpaper\""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "bread-emit"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
|
|
|||
29
bread-module-host/Cargo.toml
Normal file
29
bread-module-host/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "bread-module-host"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "bread-module-host"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Deliberately minimal dependency footprint (Workstream G): this binary is
|
||||
# itself reviewable attack surface running third-party Lua under an
|
||||
# OS-level sandbox constructed by breadd's parent process (see
|
||||
# breadd/src/module_host.rs) — it does not depend on landlock itself, since
|
||||
# the Landlock ruleset is applied by breadd via Command::pre_exec() *before*
|
||||
# this binary's own main() ever runs (landlock_restrict_self() applies to
|
||||
# the calling process across the subsequent execve()).
|
||||
[dependencies]
|
||||
bread-shared = { path = "../bread-shared" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1.40", features = ["net", "io-util", "rt", "rt-multi-thread", "time", "macros", "sync"] }
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
mlua = { version = "0.9", features = ["lua54", "vendored", "serialize"] }
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
269
bread-module-host/src/io.rs
Normal file
269
bread-module-host/src/io.rs
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
//! The async half of `bread-module-host`: owns the Unix socket connection
|
||||
//! back to `breadd` and speaks the newline-delimited-JSON IPC protocol
|
||||
//! (`breadd/src/ipc/mod.rs`), extended with `module_host.*` methods (see
|
||||
//! `breadd/src/module_host.rs` for the server side of this bridge).
|
||||
//!
|
||||
//! Runs on its own dedicated OS thread with its own single-threaded Tokio
|
||||
//! runtime — mirroring `breadd`'s own `spawn_runtime` split between an async
|
||||
//! IPC/adapters world and a synchronous, single-threaded Lua world (see
|
||||
//! `breadd/src/lua/mod.rs`'s `spawn_runtime`). The Lua-driving thread in
|
||||
//! `main.rs` talks to this thread over two plain `std::sync::mpsc` channels
|
||||
//! (`IoCommand` out, `HostMessage` in) rather than sharing an async runtime,
|
||||
//! since `mlua::Lua` values are not `Send` and Lua callbacks need to make
|
||||
//! synchronous (blocking, from Lua's point of view) RPC calls.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use bread_shared::{ModuleHostHello, ModuleHostPush};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tracing::warn;
|
||||
|
||||
/// A request the Lua-driving thread wants sent to `breadd`, with a reply
|
||||
/// channel for the (blocking, from Lua's perspective) response.
|
||||
pub enum IoCommand {
|
||||
Request {
|
||||
method: String,
|
||||
params: Value,
|
||||
reply: mpsc::Sender<Result<Value, String>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// In-flight RPC calls awaiting a response, keyed by request id: each entry
|
||||
/// is the reply channel for the call that's blocked waiting on it.
|
||||
type PendingReplies = Arc<Mutex<HashMap<String, mpsc::Sender<Result<Value, String>>>>>;
|
||||
|
||||
/// Something the IO thread has for the Lua-driving thread: either an
|
||||
/// unsolicited push (a subscribed event fired, a timer fired) or "the
|
||||
/// connection is gone" (breadd exited, socket closed, etc).
|
||||
pub enum HostMessage {
|
||||
Push(ModuleHostPush),
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RpcResponse {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
result: Option<Value>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Connect, perform the `module_host.hello` handshake, and — on success —
|
||||
/// run the steady-state request/response + push-forwarding loop until the
|
||||
/// connection closes. `hello_tx` is always sent to exactly once, before
|
||||
/// anything else; the caller blocks on it to learn the module's granted
|
||||
/// identity (or why the handshake failed) before doing anything else.
|
||||
pub fn run(
|
||||
socket_path: PathBuf,
|
||||
token: String,
|
||||
cmd_rx: mpsc::Receiver<IoCommand>,
|
||||
host_tx: mpsc::Sender<HostMessage>,
|
||||
hello_tx: mpsc::Sender<Result<ModuleHostHello, String>>,
|
||||
) {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = hello_tx.send(Err(format!("failed to start io runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async move {
|
||||
let stream = match UnixStream::connect(&socket_path).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = hello_tx.send(Err(format!(
|
||||
"failed to connect to {}: {e}",
|
||||
socket_path.display()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let mut lines = BufReader::new(read_half).lines();
|
||||
|
||||
let hello_req = json!({
|
||||
"id": "hello",
|
||||
"method": "module_host.hello",
|
||||
"params": { "token": token },
|
||||
});
|
||||
let Ok(hello_line) = serde_json::to_string(&hello_req) else {
|
||||
let _ = hello_tx.send(Err("failed to encode hello request".to_string()));
|
||||
return;
|
||||
};
|
||||
if write_half
|
||||
.write_all(format!("{hello_line}\n").as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = hello_tx.send(Err("failed to write hello request".to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
let response_line = match lines.next_line().await {
|
||||
Ok(Some(line)) => line,
|
||||
Ok(None) => {
|
||||
let _ = hello_tx.send(Err(
|
||||
"connection closed before hello response".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = hello_tx.send(Err(format!("read error awaiting hello: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let resp: RpcResponse = match serde_json::from_str(&response_line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let _ = hello_tx.send(Err(format!("malformed hello response: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(err) = resp.error {
|
||||
let _ = hello_tx.send(Err(err));
|
||||
return;
|
||||
}
|
||||
let hello: ModuleHostHello = match resp
|
||||
.result
|
||||
.and_then(|v| serde_json::from_value(v).ok())
|
||||
{
|
||||
Some(h) => h,
|
||||
None => {
|
||||
let _ = hello_tx.send(Err("hello response missing result".to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if hello_tx.send(Ok(hello)).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Steady state. `pending` routes response lines back to whichever
|
||||
// Lua-side call is blocked waiting for them; a dedicated thread
|
||||
// bridges the synchronous `cmd_rx` (fed from the Lua thread) onto an
|
||||
// async channel this task can select on.
|
||||
let pending: PendingReplies = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let (async_cmd_tx, mut async_cmd_rx) = tokio::sync::mpsc::unbounded_channel::<IoCommand>();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(cmd) = cmd_rx.recv() {
|
||||
if async_cmd_tx.send(cmd).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let pending_for_writer = pending.clone();
|
||||
let write_task = tokio::spawn(async move {
|
||||
let mut next_id: u64 = 1;
|
||||
while let Some(IoCommand::Request {
|
||||
method,
|
||||
params,
|
||||
reply,
|
||||
}) = async_cmd_rx.recv().await
|
||||
{
|
||||
let id = format!("m{next_id}");
|
||||
next_id += 1;
|
||||
let req = json!({ "id": id, "method": method, "params": params });
|
||||
let line = match serde_json::to_string(&req) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
let _ = reply.send(Err(e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
pending_for_writer.lock().unwrap().insert(id.clone(), reply);
|
||||
if write_half
|
||||
.write_all(format!("{line}\n").as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
if let Some(tx) = pending_for_writer.lock().unwrap().remove(&id) {
|
||||
let _ = tx.send(Err("write failed; connection lost".to_string()));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loop {
|
||||
let line = match lines.next_line().await {
|
||||
Ok(Some(l)) => l,
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "module-host: connection read error");
|
||||
break;
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value: Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "module-host: malformed line from breadd");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if value.get("push").is_some() {
|
||||
match serde_json::from_value::<ModuleHostPush>(value) {
|
||||
Ok(push) => {
|
||||
if host_tx.send(HostMessage::Push(push)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "module-host: malformed push message"),
|
||||
}
|
||||
} else if let Ok(resp) = serde_json::from_value::<RpcResponse>(value) {
|
||||
if let Some(tx) = pending.lock().unwrap().remove(&resp.id) {
|
||||
let result = match resp.error {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(resp.result.unwrap_or(Value::Null)),
|
||||
};
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
write_task.abort();
|
||||
// Any calls still blocked waiting for a reply need to be unblocked
|
||||
// rather than hanging forever now that the connection is gone.
|
||||
for (_, tx) in pending.lock().unwrap().drain() {
|
||||
let _ = tx.send(Err("connection closed".to_string()));
|
||||
}
|
||||
let _ = host_tx.send(HostMessage::Closed);
|
||||
});
|
||||
}
|
||||
|
||||
/// Blocking helper used from Lua callback closures (which run on the
|
||||
/// Lua-driving thread, not the async IO thread): send a request and wait —
|
||||
/// with a timeout, so a wedged connection can't hang a Lua callback forever
|
||||
/// — for its response.
|
||||
pub fn call(
|
||||
cmd_tx: &mpsc::Sender<IoCommand>,
|
||||
method: &str,
|
||||
params: Value,
|
||||
timeout: Duration,
|
||||
) -> Result<Value, String> {
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
cmd_tx
|
||||
.send(IoCommand::Request {
|
||||
method: method.to_string(),
|
||||
params,
|
||||
reply: reply_tx,
|
||||
})
|
||||
.map_err(|_| "io thread gone".to_string())?;
|
||||
reply_rx
|
||||
.recv_timeout(timeout)
|
||||
.map_err(|_| format!("{method} timed out"))?
|
||||
}
|
||||
531
bread-module-host/src/lua_env.rs
Normal file
531
bread-module-host/src/lua_env.rs
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
//! The Lua half of `bread-module-host`: a `bread` table whose functions are
|
||||
//! RPC-backed proxies to `breadd` instead of directly touching daemon state,
|
||||
//! plus a dispatch loop that turns `ModuleHostPush` messages (from
|
||||
//! `crate::io`) into Lua callback invocations.
|
||||
//!
|
||||
//! Structurally a slimmed-down sibling of `breadd/src/lua/mod.rs`'s
|
||||
//! `LuaEngine`/`spawn_runtime`: one dedicated thread runs Lua synchronously
|
||||
//! and reacts to messages from a channel (`HostMessage` here, `LuaMessage`
|
||||
//! there); a separate thread/task owns the actual async I/O. Only ONE
|
||||
//! module is ever loaded per `bread-module-host` process, so there's no
|
||||
//! module registry, load ordering, or `after` dependency resolution here —
|
||||
//! `breadd` already resolved all of that before deciding this module needed
|
||||
//! its own process.
|
||||
//!
|
||||
//! `bread.module()`'s `store` is process-local (an in-memory table, not
|
||||
//! synced back to `breadd`) — a documented gap vs. the in-process
|
||||
//! implementation's `bread.module().store`, which persists in
|
||||
//! `RuntimeState` and is visible to `bread modules info`/other modules.
|
||||
//! Fine for a single module's own private scratch state; not fine yet for
|
||||
//! anything that expects cross-module visibility. See `Documentation.md`.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use bread_shared::{BreadEvent, ModulePermission, PermissionKind};
|
||||
use mlua::{Error as LuaError, Function, Lua, LuaSerdeExt, RegistryKey, Table, Value as LuaValue};
|
||||
use serde_json::{json, Value as JsonValue};
|
||||
use tracing::error;
|
||||
|
||||
use crate::io::{call, IoCommand};
|
||||
|
||||
/// Timeout for a single RPC round trip to `breadd`. Generous relative to a
|
||||
/// same-host Unix socket hop — this exists to fail loudly if the connection
|
||||
/// wedges rather than to accommodate genuinely slow calls.
|
||||
const RPC_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Pure-Lua `bread.spawn`/`bread.wait` sugar, copied verbatim from
|
||||
/// `breadd/src/lua/mod.rs`'s `install_wait_helper`. It only depends on
|
||||
/// `coroutine` plus `bread.once`/`bread.on`/`bread.after`/`bread.cancel`,
|
||||
/// all of which this module provides as RPC-backed bindings above, so the
|
||||
/// suspension mechanism works unmodified against a remote event source.
|
||||
///
|
||||
/// Deliberately duplicated rather than shared: extracting this into
|
||||
/// `bread-shared` (so `breadd` and `bread-module-host` load the same
|
||||
/// constant instead of two hand-kept-in-sync copies) is flagged as
|
||||
/// follow-up work in `Documentation.md` — doing it here would also require
|
||||
/// making `breadd`'s currently-private `const BUILTIN_*`/wait-helper
|
||||
/// strings public, which is a larger refactor than this workstream's time
|
||||
/// budget covers.
|
||||
const WAIT_HELPER: &str = r#"
|
||||
bread.spawn = function(fn)
|
||||
local co = coroutine.create(fn)
|
||||
local ok, err = coroutine.resume(co)
|
||||
if not ok then
|
||||
error(err)
|
||||
end
|
||||
end
|
||||
|
||||
bread.wait = function(pattern, opts)
|
||||
if type(pattern) ~= "string" then
|
||||
error("bread.wait requires a pattern string")
|
||||
end
|
||||
opts = opts or {}
|
||||
local co = coroutine.running()
|
||||
if not co then
|
||||
error("bread.wait must be called inside a coroutine")
|
||||
end
|
||||
local id
|
||||
local timer
|
||||
id = bread.once(pattern, function(event)
|
||||
if timer then
|
||||
bread.cancel(timer)
|
||||
end
|
||||
coroutine.resume(co, event)
|
||||
end)
|
||||
if opts.timeout then
|
||||
timer = bread.after(opts.timeout, function()
|
||||
bread.off(id)
|
||||
coroutine.resume(co, nil)
|
||||
end)
|
||||
end
|
||||
return coroutine.yield()
|
||||
end
|
||||
"#;
|
||||
|
||||
fn json_to_lua<'lua>(lua: &'lua Lua, value: &JsonValue) -> mlua::Result<LuaValue<'lua>> {
|
||||
Ok(match value {
|
||||
JsonValue::Null => LuaValue::Nil,
|
||||
JsonValue::Bool(b) => LuaValue::Boolean(*b),
|
||||
JsonValue::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
LuaValue::Integer(i)
|
||||
} else {
|
||||
LuaValue::Number(n.as_f64().unwrap_or(0.0))
|
||||
}
|
||||
}
|
||||
JsonValue::String(s) => LuaValue::String(lua.create_string(s)?),
|
||||
JsonValue::Array(arr) => {
|
||||
let tbl = lua.create_table()?;
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
tbl.set(i + 1, json_to_lua(lua, v)?)?;
|
||||
}
|
||||
LuaValue::Table(tbl)
|
||||
}
|
||||
JsonValue::Object(obj) => {
|
||||
let tbl = lua.create_table()?;
|
||||
for (k, v) in obj.iter() {
|
||||
tbl.set(k.clone(), json_to_lua(lua, v)?)?;
|
||||
}
|
||||
LuaValue::Table(tbl)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The Lua VM plus the bookkeeping needed to route `ModuleHostPush`
|
||||
/// messages to the right registered callback. Lives entirely on one thread
|
||||
/// (`mlua::Lua` is `!Send`) — see `main.rs`.
|
||||
pub struct ModuleHostLua {
|
||||
lua: Lua,
|
||||
/// subscription_id or timer_id -> the Lua callback registered for it.
|
||||
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
|
||||
registered: Rc<RefCell<bool>>,
|
||||
module_table_key: Rc<RefCell<Option<RegistryKey>>>,
|
||||
module_name: String,
|
||||
}
|
||||
|
||||
impl ModuleHostLua {
|
||||
pub fn new(
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
module_name: String,
|
||||
permissions: Vec<ModulePermission>,
|
||||
) -> Result<Self> {
|
||||
let lua = Lua::new();
|
||||
let bread = lua.create_table()?;
|
||||
let handlers: Rc<RefCell<HashMap<String, RegistryKey>>> = Rc::new(RefCell::new(HashMap::new()));
|
||||
let registered = Rc::new(RefCell::new(false));
|
||||
let module_table_key: Rc<RefCell<Option<RegistryKey>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
Self::install_module_fn(
|
||||
&lua,
|
||||
&bread,
|
||||
module_name.clone(),
|
||||
registered.clone(),
|
||||
module_table_key.clone(),
|
||||
)?;
|
||||
Self::install_logging(&lua, &bread, cmd_tx.clone())?;
|
||||
Self::install_json(&lua, &bread)?;
|
||||
Self::install_events(&lua, &bread, cmd_tx.clone(), handlers.clone())?;
|
||||
Self::install_timers(&lua, &bread, cmd_tx.clone(), handlers.clone())?;
|
||||
Self::install_emit(&lua, &bread, cmd_tx.clone())?;
|
||||
|
||||
let granted: HashSet<PermissionKind> = permissions.iter().map(|p| p.kind).collect();
|
||||
Self::install_fs(&lua, &bread, cmd_tx.clone(), &granted)?;
|
||||
Self::install_exec(&lua, &bread, cmd_tx.clone(), &granted)?;
|
||||
Self::install_state(&lua, &bread, cmd_tx, &granted)?;
|
||||
|
||||
lua.globals().set("bread", bread)?;
|
||||
lua.load(WAIT_HELPER).set_name("<bread-module-host wait helper>").exec()?;
|
||||
|
||||
Ok(Self {
|
||||
lua,
|
||||
handlers,
|
||||
registered,
|
||||
module_table_key,
|
||||
module_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn install_module_fn(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
expected_name: String,
|
||||
registered: Rc<RefCell<bool>>,
|
||||
module_table_key: Rc<RefCell<Option<RegistryKey>>>,
|
||||
) -> Result<()> {
|
||||
let store: Rc<RefCell<HashMap<String, JsonValue>>> = Rc::new(RefCell::new(HashMap::new()));
|
||||
let module_fn = lua.create_function(move |lua, table: Table| -> mlua::Result<Table> {
|
||||
let name: String = table.get("name")?;
|
||||
if name != expected_name {
|
||||
return Err(LuaError::RuntimeError(format!(
|
||||
"bread.module({{name = \"{name}\"}}) does not match the module breadd spawned this process for (\"{expected_name}\")"
|
||||
)));
|
||||
}
|
||||
let version: Option<String> = table.get("version").ok();
|
||||
|
||||
let module_tbl = lua.create_table()?;
|
||||
module_tbl.set("name", name.clone())?;
|
||||
if let Some(v) = version {
|
||||
module_tbl.set("version", v)?;
|
||||
}
|
||||
|
||||
let store_tbl = lua.create_table()?;
|
||||
let store_get = store.clone();
|
||||
let get_fn = lua.create_function(move |lua, key: String| {
|
||||
match store_get.borrow().get(&key) {
|
||||
Some(v) => json_to_lua(lua, v),
|
||||
None => Ok(LuaValue::Nil),
|
||||
}
|
||||
})?;
|
||||
store_tbl.set("get", get_fn)?;
|
||||
|
||||
let store_set = store.clone();
|
||||
let set_fn = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
|
||||
let json: JsonValue = lua.from_value(value).unwrap_or(JsonValue::Null);
|
||||
store_set.borrow_mut().insert(key, json);
|
||||
Ok(())
|
||||
})?;
|
||||
store_tbl.set("set", set_fn)?;
|
||||
module_tbl.set("store", store_tbl)?;
|
||||
|
||||
*registered.borrow_mut() = true;
|
||||
let key = lua.create_registry_value(module_tbl.clone())?;
|
||||
*module_table_key.borrow_mut() = Some(key);
|
||||
|
||||
Ok(module_tbl)
|
||||
})?;
|
||||
bread.set("module", module_fn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_logging(lua: &Lua, bread: &Table, cmd_tx: mpsc::Sender<IoCommand>) -> Result<()> {
|
||||
for (name, method) in [
|
||||
("log", "module_host.log"),
|
||||
("warn", "module_host.warn"),
|
||||
("error", "module_host.error"),
|
||||
] {
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
let f = lua.create_function(move |_, message: String| {
|
||||
let _ = call(&cmd_tx, method, json!({ "message": message }), RPC_TIMEOUT);
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set(name, f)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_json(lua: &Lua, bread: &Table) -> Result<()> {
|
||||
let json_tbl = lua.create_table()?;
|
||||
let decode_fn = lua.create_function(|lua, s: String| {
|
||||
match serde_json::from_str::<JsonValue>(&s) {
|
||||
Ok(v) => Ok((json_to_lua(lua, &v)?, LuaValue::Nil)),
|
||||
Err(e) => Ok((LuaValue::Nil, LuaValue::String(lua.create_string(e.to_string())?))),
|
||||
}
|
||||
})?;
|
||||
json_tbl.set("decode", decode_fn)?;
|
||||
bread.set("json", json_tbl)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_events(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
|
||||
) -> Result<()> {
|
||||
for (name, once) in [("on", false), ("once", true)] {
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
let handlers = handlers.clone();
|
||||
let method = if once { "module_host.once" } else { "module_host.on" };
|
||||
let f = lua.create_function(move |lua, (pattern, callback): (String, Function)| {
|
||||
let result = call(&cmd_tx, method, json!({ "pattern": pattern }), RPC_TIMEOUT)
|
||||
.map_err(LuaError::external)?;
|
||||
let id = result
|
||||
.get("subscription_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| LuaError::external("module_host.on: missing subscription_id"))?
|
||||
.to_string();
|
||||
let key = lua.create_registry_value(callback)?;
|
||||
handlers.borrow_mut().insert(id.clone(), key);
|
||||
Ok(id)
|
||||
})?;
|
||||
bread.set(name, f)?;
|
||||
}
|
||||
|
||||
let cmd_tx_off = cmd_tx.clone();
|
||||
let handlers_off = handlers.clone();
|
||||
let off_fn = lua.create_function(move |_, id: String| {
|
||||
let _ = call(&cmd_tx_off, "module_host.off", json!({ "id": id }), RPC_TIMEOUT);
|
||||
handlers_off.borrow_mut().remove(&id);
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set("off", off_fn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_timers(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
handlers: Rc<RefCell<HashMap<String, RegistryKey>>>,
|
||||
) -> Result<()> {
|
||||
for (name, method, param_key) in [
|
||||
("after", "module_host.after", "delay_ms"),
|
||||
("every", "module_host.every", "interval_ms"),
|
||||
] {
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
let handlers = handlers.clone();
|
||||
let f = lua.create_function(move |lua, (delay_ms, callback): (u64, Function)| {
|
||||
let result = call(&cmd_tx, method, json!({ param_key: delay_ms }), RPC_TIMEOUT)
|
||||
.map_err(LuaError::external)?;
|
||||
let id = result
|
||||
.get("timer_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| LuaError::external(format!("{method}: missing timer_id")))?
|
||||
.to_string();
|
||||
let key = lua.create_registry_value(callback)?;
|
||||
handlers.borrow_mut().insert(id.clone(), key);
|
||||
Ok(id)
|
||||
})?;
|
||||
bread.set(name, f)?;
|
||||
}
|
||||
|
||||
let cmd_tx_cancel = cmd_tx.clone();
|
||||
let handlers_cancel = handlers.clone();
|
||||
let cancel_fn = lua.create_function(move |_, id: String| {
|
||||
let _ = call(&cmd_tx_cancel, "module_host.cancel", json!({ "id": id }), RPC_TIMEOUT);
|
||||
handlers_cancel.borrow_mut().remove(&id);
|
||||
Ok(())
|
||||
})?;
|
||||
bread.set("cancel", cancel_fn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_emit(lua: &Lua, bread: &Table, cmd_tx: mpsc::Sender<IoCommand>) -> Result<()> {
|
||||
let emit_fn = lua.create_function(move |lua, (event, data): (String, Option<LuaValue>)| {
|
||||
let data_json: JsonValue = match data {
|
||||
Some(v) => lua.from_value(v).unwrap_or(JsonValue::Null),
|
||||
None => json!({}),
|
||||
};
|
||||
call(
|
||||
&cmd_tx,
|
||||
"module_host.emit",
|
||||
json!({ "event": event, "data": data_json }),
|
||||
RPC_TIMEOUT,
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(LuaError::external)
|
||||
})?;
|
||||
bread.set("emit", emit_fn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `bread.state.get(path)`, gated on `state.read`. Only the `get`
|
||||
/// shorthand is bridged here — `.monitors()`/`.active_workspace()`/etc.
|
||||
/// convenience wrappers and `state.watch` (a standing subscription, a
|
||||
/// materially different capability — see `PermissionKind::StateWatch`'s
|
||||
/// doc comment in `bread-shared`) are deferred; see `Documentation.md`'s
|
||||
/// Workstream G section for the full list of what's bridged vs. not.
|
||||
fn install_state(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
granted: &HashSet<PermissionKind>,
|
||||
) -> Result<()> {
|
||||
if !granted.contains(&PermissionKind::StateRead) {
|
||||
return Ok(());
|
||||
}
|
||||
let state_tbl = lua.create_table()?;
|
||||
let get_fn = lua.create_function(move |lua, key: String| {
|
||||
let result = call(&cmd_tx, "module_host.state_get", json!({ "key": key }), RPC_TIMEOUT)
|
||||
.map_err(LuaError::external)?;
|
||||
match result.get("value") {
|
||||
Some(v) => json_to_lua(lua, v),
|
||||
None => Ok(LuaValue::Nil),
|
||||
}
|
||||
})?;
|
||||
state_tbl.set("get", get_fn)?;
|
||||
bread.set("state", state_tbl)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_fs(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
granted: &HashSet<PermissionKind>,
|
||||
) -> Result<()> {
|
||||
if !granted.contains(&PermissionKind::FsRead) && !granted.contains(&PermissionKind::FsWrite) {
|
||||
return Ok(());
|
||||
}
|
||||
let fs_tbl = lua.create_table()?;
|
||||
if granted.contains(&PermissionKind::FsRead) {
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
let read_fn = lua.create_function(move |_, path: String| {
|
||||
let result = call(&cmd_tx, "module_host.fs_read", json!({ "path": path }), RPC_TIMEOUT)
|
||||
.map_err(LuaError::external)?;
|
||||
Ok(result
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()))
|
||||
})?;
|
||||
fs_tbl.set("read", read_fn)?;
|
||||
}
|
||||
if granted.contains(&PermissionKind::FsWrite) {
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
let write_fn = lua.create_function(move |_, (path, content): (String, String)| {
|
||||
call(
|
||||
&cmd_tx,
|
||||
"module_host.fs_write",
|
||||
json!({ "path": path, "content": content }),
|
||||
RPC_TIMEOUT,
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(LuaError::external)
|
||||
})?;
|
||||
fs_tbl.set("write", write_fn)?;
|
||||
}
|
||||
bread.set("fs", fs_tbl)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_exec(
|
||||
lua: &Lua,
|
||||
bread: &Table,
|
||||
cmd_tx: mpsc::Sender<IoCommand>,
|
||||
granted: &HashSet<PermissionKind>,
|
||||
) -> Result<()> {
|
||||
if !granted.contains(&PermissionKind::Exec) {
|
||||
return Ok(());
|
||||
}
|
||||
let cmd_tx_exec = cmd_tx.clone();
|
||||
let exec_fn = lua.create_function(move |_, cmd: String| {
|
||||
call(&cmd_tx_exec, "module_host.exec", json!({ "cmd": cmd }), RPC_TIMEOUT)
|
||||
.map(|_| ())
|
||||
.map_err(LuaError::external)
|
||||
})?;
|
||||
bread.set("exec", exec_fn)?;
|
||||
|
||||
let exec_capture_fn = lua.create_function(move |_, (cmd, opts): (String, Option<Table>)| {
|
||||
let timeout_ms: u64 = opts
|
||||
.as_ref()
|
||||
.and_then(|o| o.get("timeout_ms").ok())
|
||||
.unwrap_or(2000);
|
||||
let call_timeout = RPC_TIMEOUT + Duration::from_millis(timeout_ms);
|
||||
let result = call(
|
||||
&cmd_tx,
|
||||
"module_host.exec_capture",
|
||||
json!({ "cmd": cmd, "timeout_ms": timeout_ms }),
|
||||
call_timeout,
|
||||
)
|
||||
.map_err(LuaError::external)?;
|
||||
let ok = result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let stdout = result
|
||||
.get("stdout")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Ok((ok, stdout))
|
||||
})?;
|
||||
bread.set("exec_capture", exec_capture_fn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load and execute the module's `init.lua`, then verify it actually
|
||||
/// called `bread.module(...)` — mirrors `breadd`'s own
|
||||
/// `load_module`/`load_scoped_lua_file` contract exactly (see
|
||||
/// `breadd/src/lua/mod.rs`).
|
||||
pub fn load_entry(&self, entry_path: &std::path::Path) -> Result<()> {
|
||||
let src = std::fs::read_to_string(entry_path)
|
||||
.map_err(|e| anyhow!("failed to read {}: {e}", entry_path.display()))?;
|
||||
self.lua
|
||||
.load(&src)
|
||||
.set_name(entry_path.to_string_lossy().as_ref())
|
||||
.exec()
|
||||
.map_err(|e| anyhow!(e.to_string()))?;
|
||||
|
||||
if !*self.registered.borrow() {
|
||||
return Err(anyhow!("module did not call bread.module(...)"));
|
||||
}
|
||||
self.run_on_load()
|
||||
}
|
||||
|
||||
fn run_on_load(&self) -> Result<()> {
|
||||
let key_ref = self.module_table_key.borrow();
|
||||
let Some(key) = key_ref.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let module_tbl: Table = self
|
||||
.lua
|
||||
.registry_value(key)
|
||||
.map_err(|e| anyhow!(e.to_string()))?;
|
||||
let hook: Option<Function> = module_tbl.get("on_load").ok();
|
||||
drop(key_ref);
|
||||
if let Some(hook) = hook {
|
||||
hook.call::<_, ()>(())
|
||||
.map_err(|e| anyhow!("{} on_load failed: {e}", self.module_name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn dispatch_event(&self, subscription_id: &str, event: &BreadEvent) {
|
||||
let func = self.lookup(subscription_id);
|
||||
if let Some(func) = func {
|
||||
if let Err(e) = self.call_event_handler(&func, event) {
|
||||
error!(subscription_id, error = %e, "module-host: event handler error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dispatch_timer(&self, timer_id: &str) {
|
||||
let func = self.lookup(timer_id);
|
||||
if let Some(func) = func {
|
||||
if let Err(e) = func.call::<_, ()>(()) {
|
||||
error!(timer_id, error = %e, "module-host: timer handler error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup(&self, id: &str) -> Option<Function<'_>> {
|
||||
let handlers = self.handlers.borrow();
|
||||
let key = handlers.get(id)?;
|
||||
self.lua.registry_value::<Function>(key).ok()
|
||||
}
|
||||
|
||||
fn call_event_handler(&self, func: &Function, event: &BreadEvent) -> mlua::Result<()> {
|
||||
let data = json_to_lua(&self.lua, &event.data)?;
|
||||
let evt_tbl = self.lua.create_table()?;
|
||||
evt_tbl.set("event", event.event.clone())?;
|
||||
evt_tbl.set("data", data)?;
|
||||
evt_tbl.set("timestamp", event.timestamp)?;
|
||||
evt_tbl.set("id", event.id.clone())?;
|
||||
if let Some(caused_by) = &event.caused_by {
|
||||
evt_tbl.set("caused_by", caused_by.clone())?;
|
||||
}
|
||||
func.call::<_, ()>(evt_tbl)
|
||||
}
|
||||
}
|
||||
164
bread-module-host/src/main.rs
Normal file
164
bread-module-host/src/main.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
//! `bread-module-host` — the out-of-process runtime for a single third-party
|
||||
//! Bread module (Workstream G).
|
||||
//!
|
||||
//! `breadd` spawns one of these per out-of-process module (see
|
||||
//! `breadd/src/module_host.rs`), sandboxed at the OS level via a Landlock
|
||||
//! ruleset applied by the parent *before* this binary's own `main()` ever
|
||||
//! runs (through `Command::pre_exec` — see that module's doc comment for
|
||||
//! why this binary itself has no Landlock dependency at all). This process
|
||||
//! then:
|
||||
//!
|
||||
//! 1. Connects to `breadd`'s existing IPC socket
|
||||
//! (`$XDG_RUNTIME_DIR/bread/breadd.sock` by default).
|
||||
//! 2. Presents the one-time token `breadd` gave it (via `$BREAD_MODULE_TOKEN`,
|
||||
//! an env var rather than argv, which is visible to any process via
|
||||
//! `/proc/*/cmdline`) via `module_host.hello` and learns its own identity
|
||||
//! (module name + granted permissions) from `breadd`'s answer — it never
|
||||
//! asserts its own name and have that trusted.
|
||||
//! 3. Loads exactly one module's `init.lua` (`$BREAD_MODULE_ENTRY`) into a
|
||||
//! fresh Lua VM whose `bread` table is built entirely from RPC-backed
|
||||
//! proxies (see `lua_env`) instead of direct in-process bindings.
|
||||
//! 4. Reports load success/failure back to `breadd` (`module_host.status`),
|
||||
//! then dispatches subscribed events/timers pushed down the same
|
||||
//! connection until it closes.
|
||||
//!
|
||||
//! Env vars, all required except `BREAD_MODULE_SOCKET` and
|
||||
//! `BREAD_MODULE_NAME`:
|
||||
//! - `BREAD_MODULE_TOKEN` — one-time handshake token.
|
||||
//! - `BREAD_MODULE_ENTRY` — absolute path to the module's entry `.lua` file.
|
||||
//! - `BREAD_MODULE_SOCKET` — override for breadd's socket path (defaults to
|
||||
//! `bread_shared::resolve_socket_path()`, the same resolution breadd's own
|
||||
//! `Config::socket_path` uses).
|
||||
//! - `BREAD_MODULE_NAME` — informational only (early log lines before the
|
||||
//! hello response arrives); never trusted for permission lookup.
|
||||
|
||||
mod io;
|
||||
mod lua_env;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bread_shared::{ModuleHostHello, ModuleHostPush};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use io::{HostMessage, IoCommand};
|
||||
use lua_env::ModuleHostLua;
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let module_name_hint = std::env::var("BREAD_MODULE_NAME").unwrap_or_else(|_| "?".to_string());
|
||||
|
||||
let token = match std::env::var("BREAD_MODULE_TOKEN") {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
eprintln!("bread-module-host: missing BREAD_MODULE_TOKEN env var");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let entry = match std::env::var("BREAD_MODULE_ENTRY") {
|
||||
Ok(e) => PathBuf::from(e),
|
||||
Err(_) => {
|
||||
eprintln!("bread-module-host: missing BREAD_MODULE_ENTRY env var");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let socket_path = match std::env::var("BREAD_MODULE_SOCKET") {
|
||||
Ok(s) => PathBuf::from(s),
|
||||
Err(_) => bread_shared::resolve_socket_path(),
|
||||
};
|
||||
|
||||
info!(
|
||||
module_hint = %module_name_hint,
|
||||
entry = %entry.display(),
|
||||
socket = %socket_path.display(),
|
||||
"bread-module-host starting"
|
||||
);
|
||||
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<IoCommand>();
|
||||
let (host_tx, host_rx) = mpsc::channel::<HostMessage>();
|
||||
let (hello_tx, hello_rx) = mpsc::channel::<Result<ModuleHostHello, String>>();
|
||||
|
||||
if std::thread::Builder::new()
|
||||
.name("module-host-io".to_string())
|
||||
.spawn(move || io::run(socket_path, token, cmd_rx, host_tx, hello_tx))
|
||||
.is_err()
|
||||
{
|
||||
eprintln!("bread-module-host: failed to spawn io thread");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let hello = match hello_rx.recv_timeout(Duration::from_secs(15)) {
|
||||
Ok(Ok(h)) => h,
|
||||
Ok(Err(e)) => {
|
||||
error!(error = %e, "bread-module-host: hello handshake failed");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(_) => {
|
||||
error!("bread-module-host: timed out waiting for hello handshake");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
module = %hello.module,
|
||||
permissions = ?hello.permissions,
|
||||
api_version = %hello.api_version,
|
||||
"bread-module-host: identity established by breadd"
|
||||
);
|
||||
|
||||
let engine = match ModuleHostLua::new(cmd_tx.clone(), hello.module.clone(), hello.permissions.clone()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
error!(error = %e, "bread-module-host: failed to build lua environment");
|
||||
report_status(&cmd_tx, false, Some(e.to_string()));
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match engine.load_entry(&entry) {
|
||||
Ok(()) => {
|
||||
info!(module = %hello.module, "bread-module-host: module loaded successfully");
|
||||
report_status(&cmd_tx, true, None);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(module = %hello.module, error = %e, "bread-module-host: module load failed");
|
||||
report_status(&cmd_tx, false, Some(e.to_string()));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Steady state: dispatch pushed events/timers until the connection to
|
||||
// breadd drops (breadd exited, socket closed, or we were killed and
|
||||
// this line never runs at all — see breadd/src/module_host.rs's
|
||||
// child-reap thread for the other half of that crash-isolation story).
|
||||
loop {
|
||||
match host_rx.recv() {
|
||||
Ok(HostMessage::Push(ModuleHostPush::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
})) => {
|
||||
engine.dispatch_event(&subscription_id, &event);
|
||||
}
|
||||
Ok(HostMessage::Push(ModuleHostPush::Timer { timer_id })) => {
|
||||
engine.dispatch_timer(&timer_id);
|
||||
}
|
||||
Ok(HostMessage::Closed) | Err(_) => {
|
||||
warn!("bread-module-host: connection to breadd closed, exiting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report_status(cmd_tx: &mpsc::Sender<IoCommand>, ok: bool, error: Option<String>) {
|
||||
let params = if ok {
|
||||
serde_json::json!({ "state": "loaded" })
|
||||
} else {
|
||||
serde_json::json!({ "state": "load_error", "error": error })
|
||||
};
|
||||
let _ = io::call(cmd_tx, "module_host.status", params, Duration::from_secs(5));
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "bread-shared"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -8,3 +8,4 @@ serde.workspace = true
|
|||
serde_json.workspace = true
|
||||
dirs.workspace = true
|
||||
toml = "0.8"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
|
|
|||
|
|
@ -11,13 +11,30 @@
|
|||
/// (commands) namespaces.
|
||||
pub const KNOWN_APPS: &[&str] = &[
|
||||
"clip", "pad", "bar", "box", "lock", "mon", "paper", "search", "shot", "arr", "crumbs", "help",
|
||||
"bakery",
|
||||
"bakery", "cast",
|
||||
];
|
||||
|
||||
/// Daemon-internal domains that are reserved and can never be claimed as an
|
||||
/// app id, even if a future `bread*` app would otherwise want that name —
|
||||
/// these are the top-level segments the normalizer and built-in event
|
||||
/// families already use.
|
||||
///
|
||||
/// This is also the single source of truth the IPC boundary checks before
|
||||
/// allowing a manual (no-`source`) `emit` request to use an event name — a
|
||||
/// socket client may freely emit a custom/test event, but not one whose
|
||||
/// top-level segment is one of these, since that would let it impersonate
|
||||
/// a real adapter (or another daemon-internal event family) rather than
|
||||
/// producing an obviously-manual one. The one exception is a well-formed
|
||||
/// [`validate_command_event`] name (`bread.command.<known-app>.<verb>`):
|
||||
/// `command` stays reserved so it cannot be claimed as an app id, but the
|
||||
/// command bus itself is meant to be publishable. See [`is_reserved_domain`]
|
||||
/// and `breadd/src/ipc/mod.rs`'s `emit` handler. *Since: v1.5 — `bluetooth`,
|
||||
/// `workspace`, `window`, and `monitor` added (event families the Hyprland
|
||||
/// and Bluetooth adapters already published under, but that were missing
|
||||
/// from this list) when this became a spoofing-prevention boundary and not
|
||||
/// just an app-id-conflict one. Since: v1.7 — command-bus exception.
|
||||
/// `module`, `state`, `widget`, and `reload` are daemon-synthesized
|
||||
/// families and must stay unclaimable.*
|
||||
const RESERVED_DOMAINS: &[&str] = &[
|
||||
"terminal",
|
||||
"git",
|
||||
|
|
@ -34,6 +51,14 @@ const RESERVED_DOMAINS: &[&str] = &[
|
|||
"notify",
|
||||
"command",
|
||||
"workflow",
|
||||
"bluetooth",
|
||||
"workspace",
|
||||
"window",
|
||||
"monitor",
|
||||
"module",
|
||||
"state",
|
||||
"widget",
|
||||
"reload",
|
||||
];
|
||||
|
||||
/// Whether `id` is a registered sibling-app id.
|
||||
|
|
@ -50,11 +75,63 @@ pub fn is_reserved_domain(id: &str) -> bool {
|
|||
/// Whether `event` is a well-formed event name for `app` — i.e. it starts
|
||||
/// with `bread.<app>.`. An app may only publish within its own namespace
|
||||
/// segment; this is what the IPC boundary checks before constructing a
|
||||
/// `RawEvent` tagged `AdapterSource::App(app)`.
|
||||
/// `RawEvent` tagged `AdapterSource::App(app)`. Command events addressed
|
||||
/// to another known app are a separate, explicit exception — see
|
||||
/// [`validate_command_event`].
|
||||
pub fn validate_app_namespace(app: &str, event: &str) -> bool {
|
||||
event.starts_with(&format!("bread.{app}."))
|
||||
}
|
||||
|
||||
/// Whether `event` is in the outbound command namespace
|
||||
/// (`bread.command.*`). Does not check that the target is a known app —
|
||||
/// use [`validate_command_event`] for that.
|
||||
pub fn is_command_event(event: &str) -> bool {
|
||||
event.starts_with("bread.command.")
|
||||
}
|
||||
|
||||
/// The app id a command event is addressed to — the segment immediately
|
||||
/// after `bread.command.`. Returns `None` if `event` is not a command
|
||||
/// event or the app-id segment is empty.
|
||||
pub fn command_target(event: &str) -> Option<&str> {
|
||||
let rest = event.strip_prefix("bread.command.")?;
|
||||
let app = rest.split('.').next()?;
|
||||
if app.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(app)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `event` is a well-formed command to a registered sibling app:
|
||||
/// `bread.command.<known_app>.<verb>` with `known_app` in [`KNOWN_APPS`]
|
||||
/// and a non-empty verb (at least one extra dotted segment).
|
||||
///
|
||||
/// This is the exception the IPC unsourced/`bread-emit` path (and sourced
|
||||
/// `AdapterSource::App` emit) use so any module or app can publish
|
||||
/// commands without `command` leaving [`is_reserved_domain`] — `command`
|
||||
/// must stay unclaimable as an app id. `bread.command.power.off` and
|
||||
/// `bread.command.notanapp.x` still fail because the target is not in
|
||||
/// [`KNOWN_APPS`].
|
||||
pub fn validate_command_event(event: &str) -> bool {
|
||||
let rest = match event.strip_prefix("bread.command.") {
|
||||
Some(rest) => rest,
|
||||
None => return false,
|
||||
};
|
||||
let Some((app, verb)) = rest.split_once('.') else {
|
||||
return false;
|
||||
};
|
||||
is_known_app(app) && !verb.is_empty()
|
||||
}
|
||||
|
||||
/// The top-level dotted segment after `bread.` in an event name — e.g.
|
||||
/// `Some("power")` for `"bread.power.ac.connected"`. Returns `None` for
|
||||
/// event names that don't start with `bread.` at all, which are always
|
||||
/// outside any reserved namespace (freely-named custom/test events, the
|
||||
/// `bread emit <name>` debug use case, never take this prefix).
|
||||
pub fn event_domain(event: &str) -> Option<&str> {
|
||||
event.strip_prefix("bread.")?.split('.').next()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -88,6 +165,46 @@ mod tests {
|
|||
assert!(!is_reserved_domain("clip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_domains_cover_every_adapter_owned_event_family() {
|
||||
// Every top-level segment a real adapter (via the normalizer) or the
|
||||
// daemon itself publishes under must be reserved, or a manual/no-source
|
||||
// `emit` over the IPC socket could impersonate it undetected.
|
||||
for domain in [
|
||||
"power",
|
||||
"network",
|
||||
"device",
|
||||
"bluetooth",
|
||||
"hyprland",
|
||||
"workspace",
|
||||
"monitor",
|
||||
"window",
|
||||
"system",
|
||||
"module",
|
||||
"state",
|
||||
"widget",
|
||||
"reload",
|
||||
] {
|
||||
assert!(
|
||||
is_reserved_domain(domain),
|
||||
"'{domain}' is an adapter-owned event family and must be reserved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_domain_extracts_top_level_segment() {
|
||||
assert_eq!(event_domain("bread.power.ac.connected"), Some("power"));
|
||||
assert_eq!(event_domain("bread.custom.event"), Some("custom"));
|
||||
assert_eq!(event_domain("bread.test"), Some("test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_domain_is_none_without_bread_prefix() {
|
||||
assert_eq!(event_domain("power.ac.connected"), None);
|
||||
assert_eq!(event_domain(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_app_namespace_accepts_own_namespace() {
|
||||
assert!(validate_app_namespace("clip", "bread.clip.copied"));
|
||||
|
|
@ -110,4 +227,47 @@ mod tests {
|
|||
// because it shares a string prefix.
|
||||
assert!(!validate_app_namespace("clip", "bread.clipx.copied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_command_event_requires_command_prefix() {
|
||||
assert!(is_command_event("bread.command.clip.clear"));
|
||||
assert!(is_command_event("bread.command.power.off"));
|
||||
assert!(!is_command_event("bread.command"));
|
||||
assert!(!is_command_event("bread.clip.copied"));
|
||||
assert!(!is_command_event("command.clip.clear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_target_extracts_app_id() {
|
||||
assert_eq!(command_target("bread.command.clip.clear"), Some("clip"));
|
||||
assert_eq!(command_target("bread.command.cast.start.now"), Some("cast"));
|
||||
assert_eq!(command_target("bread.command.clip"), Some("clip"));
|
||||
assert_eq!(command_target("bread.command."), None);
|
||||
assert_eq!(command_target("bread.clip.copied"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_command_event_accepts_known_app_with_verb() {
|
||||
assert!(validate_command_event("bread.command.clip.clear"));
|
||||
assert!(validate_command_event("bread.command.cast.start"));
|
||||
assert!(validate_command_event("bread.command.clip.stack.clear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_command_event_rejects_unknown_target_or_missing_verb() {
|
||||
assert!(!validate_command_event("bread.command.power.off"));
|
||||
assert!(!validate_command_event("bread.command.notanapp.x"));
|
||||
assert!(!validate_command_event("bread.command.clip"));
|
||||
assert!(!validate_command_event("bread.command.clip."));
|
||||
assert!(!validate_command_event("bread.command."));
|
||||
assert!(!validate_command_event("bread.hyprland.workspace.changed"));
|
||||
assert!(!validate_command_event("bread.clip.copied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_stays_reserved_and_is_not_a_known_app() {
|
||||
assert!(is_reserved_domain("command"));
|
||||
assert!(!is_known_app("command"));
|
||||
assert!(!validate_command_event("bread.command.command.x"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
pub mod apps;
|
||||
pub mod glob;
|
||||
pub mod module_host_ipc;
|
||||
pub mod permissions;
|
||||
pub mod widget;
|
||||
|
||||
pub use module_host_ipc::{ModuleHostHello, ModuleHostPush};
|
||||
pub use permissions::{ModulePermission, PermissionKind};
|
||||
|
||||
/// Identifies which adapter produced an event.
|
||||
///
|
||||
|
|
@ -31,9 +37,25 @@ pub enum AdapterSource {
|
|||
Power,
|
||||
/// Network state (rtnetlink / NetworkManager).
|
||||
Network,
|
||||
/// Internal events synthesized by the daemon itself
|
||||
/// (e.g. `bread.profile.activated`, `bread.state.changed.*`).
|
||||
/// Internal events synthesized by the daemon itself, i.e. trusted,
|
||||
/// Rust-code-originated sends via `emit_tx` (e.g. `bread.system.startup`,
|
||||
/// `bread.profile.activated`, `bread.state.changed.*`, and Lua's
|
||||
/// `bread.emit()` binding). Never assignable from data that arrived
|
||||
/// over the IPC socket — see [`Manual`](AdapterSource::Manual) for that
|
||||
/// case. *Since: v1.5 — this constraint is now enforced; previously the
|
||||
/// IPC `emit` method's no-`source` path could also tag events `System`.*
|
||||
System,
|
||||
/// A manual `emit` IPC request with no `source` param — a human or
|
||||
/// script poked the daemon's Unix socket directly (e.g. `bread emit
|
||||
/// <event>` for testing Lua handlers without unplugging cables).
|
||||
/// Distinct from [`System`](AdapterSource::System) so downstream Lua
|
||||
/// modules and tooling can tell "someone manually injected this event"
|
||||
/// apart from "a real adapter observed this" or "the daemon itself
|
||||
/// produced this." The IPC boundary restricts which event names may be
|
||||
/// tagged this way — it may not claim an adapter-owned namespace (see
|
||||
/// `apps::is_reserved_domain`), but is otherwise free for custom/test
|
||||
/// event names. *Since: v1.5*
|
||||
Manual,
|
||||
/// BlueZ Bluetooth stack via D-Bus.
|
||||
Bluetooth,
|
||||
/// Shell precmd/preexec hooks (terminal command lifecycle, cwd changes).
|
||||
|
|
@ -89,20 +111,65 @@ pub struct BreadEvent {
|
|||
pub source: AdapterSource,
|
||||
/// Structured event data. The shape depends on the event family.
|
||||
pub data: serde_json::Value,
|
||||
/// Unique id for this specific event instance, assigned at construction.
|
||||
///
|
||||
/// *Since: v1.5* — enables causality tracking (`caused_by`) across chains
|
||||
/// of Lua modules that re-emit events from inside `bread.on` handlers.
|
||||
pub id: String,
|
||||
/// The `id` of the event whose Lua handler emitted this event via
|
||||
/// `bread.emit()`, if any.
|
||||
///
|
||||
/// `None` for events that originate outside any Lua handler invocation
|
||||
/// (adapter-normalized events, IPC `emit`, daemon-internal sends like
|
||||
/// `bread.system.startup` / `bread.profile.activated`). Populated only
|
||||
/// when the event was constructed by `bread.emit()` while a subscriber
|
||||
/// callback was synchronously running — see the "current dispatch id"
|
||||
/// mechanism on `breadd`'s Lua engine.
|
||||
///
|
||||
/// *Since: v1.5*
|
||||
pub caused_by: Option<String>,
|
||||
}
|
||||
|
||||
impl BreadEvent {
|
||||
/// Construct a new event with `timestamp` set to the current wall-clock.
|
||||
/// Construct a new event with `timestamp` set to the current wall-clock,
|
||||
/// a freshly generated `id`, and `caused_by` unset.
|
||||
pub fn new(event: impl Into<String>, source: AdapterSource, data: serde_json::Value) -> Self {
|
||||
Self::with_timestamp(event, now_unix_ms(), source, data)
|
||||
}
|
||||
|
||||
/// Construct a new event with an explicit `timestamp`, preserving the
|
||||
/// originating signal's observed time instead of "now". Used by the
|
||||
/// normalizer, which carries `RawEvent::timestamp` through unchanged.
|
||||
///
|
||||
/// Like [`BreadEvent::new`], this always assigns a fresh `id` and leaves
|
||||
/// `caused_by` unset — callers that need to thread causality set
|
||||
/// `caused_by` on the returned value themselves.
|
||||
pub fn with_timestamp(
|
||||
event: impl Into<String>,
|
||||
timestamp: u64,
|
||||
source: AdapterSource,
|
||||
data: serde_json::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
event: event.into(),
|
||||
timestamp: now_unix_ms(),
|
||||
timestamp,
|
||||
source,
|
||||
data,
|
||||
id: new_event_id(),
|
||||
caused_by: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a fresh unique id for a [`BreadEvent`].
|
||||
///
|
||||
/// Every construction path (`BreadEvent::new`, `BreadEvent::with_timestamp`,
|
||||
/// and any remaining struct-literal construction) calls this so every event
|
||||
/// gets a stable identity to hang `caused_by` chains off of.
|
||||
pub fn new_event_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Current Unix epoch in milliseconds.
|
||||
///
|
||||
/// Falls back to `0` if the system clock is before the epoch, which keeps
|
||||
|
|
@ -221,6 +288,10 @@ mod tests {
|
|||
serde_json::to_string(&AdapterSource::System).unwrap(),
|
||||
"\"system\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Manual).unwrap(),
|
||||
"\"manual\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&AdapterSource::Bluetooth).unwrap(),
|
||||
"\"bluetooth\""
|
||||
|
|
@ -267,6 +338,7 @@ mod tests {
|
|||
AdapterSource::Power,
|
||||
AdapterSource::Network,
|
||||
AdapterSource::System,
|
||||
AdapterSource::Manual,
|
||||
AdapterSource::Bluetooth,
|
||||
AdapterSource::Terminal,
|
||||
AdapterSource::Git,
|
||||
|
|
@ -315,6 +387,8 @@ mod tests {
|
|||
timestamp: 1_700_000_000_000,
|
||||
source: AdapterSource::Udev,
|
||||
data: json!({ "id": "usb-1-1.4", "name": "Logitech" }),
|
||||
id: "test-id-1".to_string(),
|
||||
caused_by: Some("test-id-0".to_string()),
|
||||
};
|
||||
let raw = serde_json::to_string(&original).unwrap();
|
||||
let decoded: BreadEvent = serde_json::from_str(&raw).unwrap();
|
||||
|
|
@ -323,6 +397,30 @@ mod tests {
|
|||
assert_eq!(decoded.timestamp, original.timestamp);
|
||||
assert_eq!(decoded.source, original.source);
|
||||
assert_eq!(decoded.data, original.data);
|
||||
assert_eq!(decoded.id, original.id);
|
||||
assert_eq!(decoded.caused_by, original.caused_by);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bread_event_new_assigns_unique_id_and_no_cause() {
|
||||
let a = BreadEvent::new("bread.test.a", AdapterSource::System, json!({}));
|
||||
let b = BreadEvent::new("bread.test.b", AdapterSource::System, json!({}));
|
||||
assert!(!a.id.is_empty());
|
||||
assert_ne!(a.id, b.id, "each constructed event should get a unique id");
|
||||
assert_eq!(a.caused_by, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bread_event_with_timestamp_preserves_timestamp_and_assigns_id() {
|
||||
let event = BreadEvent::with_timestamp(
|
||||
"bread.test.c",
|
||||
42,
|
||||
AdapterSource::Udev,
|
||||
json!({ "x": 1 }),
|
||||
);
|
||||
assert_eq!(event.timestamp, 42);
|
||||
assert!(!event.id.is_empty());
|
||||
assert_eq!(event.caused_by, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
108
bread-shared/src/module_host_ipc.rs
Normal file
108
bread-shared/src/module_host_ipc.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
//! Wire types shared between `breadd`'s IPC server and the `bread-module-host`
|
||||
//! client for the out-of-process module bridge (Workstream G).
|
||||
//!
|
||||
//! Living here (rather than duplicated as private structs in each crate)
|
||||
//! means the two processes can't drift on what a `module_host.hello`
|
||||
//! response or an async event/timer push looks like on the wire — the same
|
||||
//! failure mode `ModulePermission`/`PermissionKind` already guard against
|
||||
//! for the manifest schema (see `permissions.rs`).
|
||||
//!
|
||||
//! The request side (`{"id", "method", "params"}`) and the plain response
|
||||
//! side (`{"id", "result"/"error"}`) are *not* duplicated here: they're
|
||||
//! generic enough (a bare method+params envelope) that both ends already
|
||||
//! define their own minimal local copy, and sharing a type for something
|
||||
//! that's just "an id, a string, and a `Value`" buys little. What's shared
|
||||
//! is the part that's easy to get subtly wrong across two independently
|
||||
//! maintained crates: the exact shape of the one-time `hello` handshake
|
||||
//! result and the tagged push-message envelope used for unsolicited
|
||||
//! event/timer delivery on an otherwise request/response connection.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::permissions::ModulePermission;
|
||||
use crate::BreadEvent;
|
||||
|
||||
/// The successful result of a `module_host.hello` call — what `breadd`
|
||||
/// looked up for the presented one-time token, told back to the
|
||||
/// `bread-module-host` process that presented it. Deliberately does not
|
||||
/// trust anything the child process asserts about its own identity (see
|
||||
/// `breadd/src/module_host.rs`'s token/registry doc comments) — this is
|
||||
/// `breadd` telling the child who *it* has decided the child is, based on
|
||||
/// which token was issued for which pending spawn.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModuleHostHello {
|
||||
pub module: String,
|
||||
pub permissions: Vec<ModulePermission>,
|
||||
pub api_version: String,
|
||||
}
|
||||
|
||||
/// An unsolicited message `breadd` pushes down an already-established
|
||||
/// module-host connection, interleaved with ordinary request/response
|
||||
/// lines. Distinguished on the wire by the `"push"` tag (internally-tagged
|
||||
/// enum), which never collides with a plain `{"id", "result"/"error"}`
|
||||
/// response envelope or an `{"id", "method", "params"}` request envelope —
|
||||
/// neither of those ever carries a `"push"` key.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "push")]
|
||||
pub enum ModuleHostPush {
|
||||
/// A `bread.on`/`bread.once` subscription (registered via
|
||||
/// `module_host.on`/`module_host.once`) matched an event.
|
||||
#[serde(rename = "event")]
|
||||
Event {
|
||||
subscription_id: String,
|
||||
event: BreadEvent,
|
||||
},
|
||||
/// A `bread.after`/`bread.every` timer (registered via
|
||||
/// `module_host.after`/`module_host.every`) fired.
|
||||
#[serde(rename = "timer")]
|
||||
Timer { timer_id: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{AdapterSource, PermissionKind};
|
||||
|
||||
#[test]
|
||||
fn hello_round_trips() {
|
||||
let hello = ModuleHostHello {
|
||||
module: "wallpaper".to_string(),
|
||||
permissions: vec![ModulePermission {
|
||||
kind: PermissionKind::FsRead,
|
||||
path: Some("~/Wallpapers".to_string()),
|
||||
bin: None,
|
||||
}],
|
||||
api_version: "1.6.0".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&hello).unwrap();
|
||||
let back: ModuleHostHello = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.module, "wallpaper");
|
||||
assert_eq!(back.permissions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_event_tag_is_distinguishable_from_a_response_envelope() {
|
||||
let push = ModuleHostPush::Event {
|
||||
subscription_id: "sub-1".to_string(),
|
||||
event: BreadEvent::new("bread.test.tick", AdapterSource::Manual, serde_json::json!({})),
|
||||
};
|
||||
let value = serde_json::to_value(&push).unwrap();
|
||||
assert_eq!(value.get("push").and_then(|v| v.as_str()), Some("event"));
|
||||
// A plain response envelope never has a "push" key — this is the
|
||||
// disambiguator bread-module-host's read loop relies on.
|
||||
assert!(value.get("id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_timer_round_trips() {
|
||||
let push = ModuleHostPush::Timer {
|
||||
timer_id: "timer-1".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&push).unwrap();
|
||||
let back: ModuleHostPush = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
ModuleHostPush::Timer { timer_id } => assert_eq!(timer_id, "timer-1"),
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
187
bread-shared/src/permissions.rs
Normal file
187
bread-shared/src/permissions.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
//! Structured module permission types for the capability-scoped module API.
|
||||
//!
|
||||
//! This is the `[[permissions]]` schema for `bread.module.toml`. It is shared
|
||||
//! between `bread-cli` (which parses/writes the manifest on `bread modules
|
||||
//! install`/`audit`) and `breadd` (which reads the same manifest to build a
|
||||
//! capability-scoped Lua environment for third-party modules) so the two
|
||||
//! never drift on what a permission "type" string means — see
|
||||
//! `Documentation.md`'s "Capability-scoped modules" section for the full
|
||||
//! baseline-vs-gated taxonomy this enum encodes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One `[[permissions]]` entry in a module's `bread.module.toml`, e.g.:
|
||||
///
|
||||
/// ```toml
|
||||
/// [[permissions]]
|
||||
/// type = "fs.read"
|
||||
/// path = "~/Wallpapers"
|
||||
/// ```
|
||||
///
|
||||
/// `path`/`bin` are optional scoping metadata (a filesystem path prefix, a
|
||||
/// state-tree path, or a binary name). **They are not enforced by the
|
||||
/// in-process Lua environment scoping `breadd` builds today** — that
|
||||
/// mechanism only gates *presence* of a `bread.*` binding (a module without
|
||||
/// `fs.read` sees `bread.fs == nil`, full stop). Recording the scoping
|
||||
/// metadata now means manifests won't need a second migration when the
|
||||
/// planned out-of-process module sandboxing workstream lands and actually
|
||||
/// enforces path/bin matching per call — that enforcement is explicitly out
|
||||
/// of scope here.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ModulePermission {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: PermissionKind,
|
||||
/// Scoping hint for `fs.read`/`fs.write` (a path prefix) or
|
||||
/// `state.read`/`state.watch` (a dotted state-tree path, e.g.
|
||||
/// `"monitors"`). Advisory only — see the struct-level doc.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
/// Scoping hint for `exec` (the binary name the module intends to run,
|
||||
/// e.g. `"hyprpaper"`). Advisory only — see the struct-level doc.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bin: Option<String>,
|
||||
}
|
||||
|
||||
/// The permission taxonomy covering every capability-gated `bread.*`
|
||||
/// binding.
|
||||
///
|
||||
/// Not covered here because they're **baseline** (always available to every
|
||||
/// module, gated or not — no real side effect, or a side effect a module
|
||||
/// can't function at all without): `bread.on`/`once`/`filter`/`off`/`emit`
|
||||
/// (event subscription is how a module does anything), `bread.after`/
|
||||
/// `every`/`cancel` (timers), `bread.json` (pure decode), `bread.module`
|
||||
/// (required just to register), `bread.log`/`warn`/`error` (diagnostics),
|
||||
/// `bread.debounce`/`spawn`/`wait`/`wait_any`/`wait_all`/`workflow` (pure
|
||||
/// Lua sugar built entirely on top of the baseline primitives above).
|
||||
///
|
||||
/// Gated because they touch the filesystem, spawn processes, control
|
||||
/// hardware, or otherwise have a real side effect:
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum PermissionKind {
|
||||
/// `bread.state.get`/`.monitors`/`.active_workspace`/`.active_window`/
|
||||
/// `.devices`/`.power`/`.network`/`.profile` — read-only snapshots of
|
||||
/// daemon-maintained runtime state.
|
||||
#[serde(rename = "state.read")]
|
||||
StateRead,
|
||||
/// `bread.state.watch` — a standing subscription to state changes,
|
||||
/// gated separately from `state.read` since a long-lived watch is a
|
||||
/// more persistent capability than a one-off read.
|
||||
#[serde(rename = "state.watch")]
|
||||
StateWatch,
|
||||
/// `bread.profile.activate` — switches the daemon's system-wide active
|
||||
/// profile, a real cross-module side effect.
|
||||
#[serde(rename = "profile.activate")]
|
||||
ProfileActivate,
|
||||
/// `bread.exec` and `bread.exec_capture` — spawns an arbitrary shell
|
||||
/// command.
|
||||
#[serde(rename = "exec")]
|
||||
Exec,
|
||||
/// `bread.notify` — sends a desktop notification.
|
||||
#[serde(rename = "notify")]
|
||||
Notify,
|
||||
/// `bread.machine.name`/`.tags`/`.has_tag` — reads hostname/tags,
|
||||
/// including an optional on-disk `sync.toml`.
|
||||
#[serde(rename = "machine")]
|
||||
Machine,
|
||||
/// `bread.hyprland.*` — compositor IPC (dispatch/keyword/eval read and
|
||||
/// control the running Hyprland session).
|
||||
#[serde(rename = "hyprland")]
|
||||
Hyprland,
|
||||
/// `bread.widget.*` — registers/updates/removes a rendered widget in a
|
||||
/// sibling `bread*` app (breadbar).
|
||||
#[serde(rename = "widget")]
|
||||
Widget,
|
||||
/// `bread.fs.read`/`.exists`/`.readlink`/`.expand` — read-only
|
||||
/// filesystem access.
|
||||
#[serde(rename = "fs.read")]
|
||||
FsRead,
|
||||
/// `bread.fs.write` — filesystem writes.
|
||||
#[serde(rename = "fs.write")]
|
||||
FsWrite,
|
||||
/// `bread.bluetooth.*` — BlueZ control (power/connect/disconnect/scan).
|
||||
#[serde(rename = "bluetooth")]
|
||||
Bluetooth,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn permission_round_trips_through_toml_with_dotted_type_names() {
|
||||
let toml_src = r#"
|
||||
type = "fs.read"
|
||||
path = "~/Wallpapers"
|
||||
"#;
|
||||
let perm: ModulePermission = toml::from_str(toml_src).unwrap();
|
||||
assert_eq!(perm.kind, PermissionKind::FsRead);
|
||||
assert_eq!(perm.path.as_deref(), Some("~/Wallpapers"));
|
||||
assert_eq!(perm.bin, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_permission_with_bin_round_trips() {
|
||||
let toml_src = r#"
|
||||
type = "exec"
|
||||
bin = "hyprpaper"
|
||||
"#;
|
||||
let perm: ModulePermission = toml::from_str(toml_src).unwrap();
|
||||
assert_eq!(perm.kind, PermissionKind::Exec);
|
||||
assert_eq!(perm.bin.as_deref(), Some("hyprpaper"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_list_round_trips_as_array_of_tables() {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Wrapper {
|
||||
#[serde(default)]
|
||||
permissions: Option<Vec<ModulePermission>>,
|
||||
}
|
||||
|
||||
let w = Wrapper {
|
||||
permissions: Some(vec![
|
||||
ModulePermission {
|
||||
kind: PermissionKind::StateRead,
|
||||
path: Some("monitors".to_string()),
|
||||
bin: None,
|
||||
},
|
||||
ModulePermission {
|
||||
kind: PermissionKind::Exec,
|
||||
path: None,
|
||||
bin: Some("hyprpaper".to_string()),
|
||||
},
|
||||
]),
|
||||
};
|
||||
let out = toml::to_string_pretty(&w).unwrap();
|
||||
assert!(out.contains("[[permissions]]"));
|
||||
assert!(out.contains("type = \"state.read\""));
|
||||
assert!(out.contains("type = \"exec\""));
|
||||
|
||||
let back: Wrapper = toml::from_str(&out).unwrap();
|
||||
assert_eq!(back.permissions.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_permissions_field_deserializes_to_none() {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Wrapper {
|
||||
#[serde(default)]
|
||||
permissions: Option<Vec<ModulePermission>>,
|
||||
name: String,
|
||||
}
|
||||
let w: Wrapper = toml::from_str("name = \"x\"\n").unwrap();
|
||||
assert!(w.permissions.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_empty_permissions_deserializes_to_some_empty() {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Wrapper {
|
||||
#[serde(default)]
|
||||
permissions: Option<Vec<ModulePermission>>,
|
||||
name: String,
|
||||
}
|
||||
let w: Wrapper = toml::from_str("name = \"x\"\npermissions = []\n").unwrap();
|
||||
assert_eq!(w.permissions, Some(vec![]));
|
||||
}
|
||||
}
|
||||
580
bread-shared/src/widget.rs
Normal file
580
bread-shared/src/widget.rs
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
//! Wire types for Lua-declared bar widgets.
|
||||
//!
|
||||
//! A module running in breadd's Lua runtime can register a small declarative
|
||||
//! node tree (see [`WidgetNode`]) that gets rendered generically by a
|
||||
//! sibling `bread*` app (breadbar) in its bar or hamburger-popover free
|
||||
//! space. These types are the shared contract between `breadd` (which
|
||||
//! stores/validates/emits them from `bread.widget.*`) and any renderer.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Maximum nesting depth of a widget's node tree (the root counts as depth 1).
|
||||
pub const MAX_NODE_DEPTH: usize = 4;
|
||||
/// Maximum total number of nodes (root + all descendants) in a widget's tree.
|
||||
pub const MAX_NODE_COUNT: usize = 50;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Orientation {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// One node in a widget's declarative render tree.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
pub enum WidgetNode {
|
||||
Box {
|
||||
#[serde(default = "default_orientation")]
|
||||
orientation: Orientation,
|
||||
#[serde(default)]
|
||||
spacing: Option<i32>,
|
||||
#[serde(default)]
|
||||
class: Option<String>,
|
||||
#[serde(default)]
|
||||
style: Option<WidgetStyle>,
|
||||
#[serde(default)]
|
||||
on_click: Option<Value>,
|
||||
#[serde(default)]
|
||||
children: Vec<WidgetNode>,
|
||||
},
|
||||
Label {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
class: Option<String>,
|
||||
#[serde(default)]
|
||||
style: Option<WidgetStyle>,
|
||||
#[serde(default)]
|
||||
on_click: Option<Value>,
|
||||
},
|
||||
Icon {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
size: Option<i32>,
|
||||
#[serde(default)]
|
||||
class: Option<String>,
|
||||
#[serde(default)]
|
||||
style: Option<WidgetStyle>,
|
||||
#[serde(default)]
|
||||
on_click: Option<Value>,
|
||||
},
|
||||
Progress {
|
||||
value: f64,
|
||||
#[serde(default)]
|
||||
class: Option<String>,
|
||||
#[serde(default)]
|
||||
style: Option<WidgetStyle>,
|
||||
#[serde(default)]
|
||||
on_click: Option<Value>,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_orientation() -> Orientation {
|
||||
Orientation::Horizontal
|
||||
}
|
||||
|
||||
/// A bounded, typed vocabulary for a node's appearance — the alternative to
|
||||
/// letting Lua hand the renderer a raw CSS/style string. Every field is a
|
||||
/// small closed enum, so an invalid value is simply a deserialization error,
|
||||
/// the same as any other malformed field; there is no free-text surface here
|
||||
/// for a module to smuggle style-string injection through.
|
||||
///
|
||||
/// Fields left `None` mean "renderer default" (see `render.rs`'s `build_node`
|
||||
/// for what that default looks like), not "no style" — a node with no
|
||||
/// `style` at all is fully equivalent to one whose every field is `None`.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
pub struct WidgetStyle {
|
||||
pub color: Option<SemanticColor>,
|
||||
pub weight: Option<FontWeight>,
|
||||
pub size: Option<TextSize>,
|
||||
pub align: Option<Align>,
|
||||
pub background: Option<Background>,
|
||||
pub radius: Option<Radius>,
|
||||
pub padding: Option<Padding>,
|
||||
}
|
||||
|
||||
/// Foreground/text colors, one per name `bread-theme`'s shared stylesheet
|
||||
/// defines via `@define-color` (see that crate's `color_pairs()`). Deliberately
|
||||
/// excludes `bg`/`surface`/`overlay`, which only make sense as backgrounds.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SemanticColor {
|
||||
/// Foreground / body text color.
|
||||
Fg,
|
||||
/// Muted foreground — the existing `.dim` look, formalized.
|
||||
Dim,
|
||||
Accent,
|
||||
Red,
|
||||
Green,
|
||||
Yellow,
|
||||
Blue,
|
||||
Pink,
|
||||
Teal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FontWeight {
|
||||
Normal,
|
||||
Bold,
|
||||
}
|
||||
|
||||
/// Text size scale (10/12/14/16/20px) — `sm`/`md` match `bread-theme::tokens`'
|
||||
/// existing `FONT_SIZE_SECONDARY`/`FONT_SIZE_BASE` rather than inventing a
|
||||
/// second set of magic numbers; `xs`/`lg`/`xl` fill out the rest of the scale
|
||||
/// (the spacing scale's 4/8/12/16/20px is for padding/radius, not text —
|
||||
/// applying it directly to `font-size` renders illegibly at the small end).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TextSize {
|
||||
Xs,
|
||||
Sm,
|
||||
Md,
|
||||
Lg,
|
||||
Xl,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Align {
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Background {
|
||||
None,
|
||||
Surface,
|
||||
Card,
|
||||
}
|
||||
|
||||
/// Reuses `bread-theme::tokens`' radius scale: `sm` = tertiary (4px, small
|
||||
/// interactive elements), `md` = primary (8px), `full` = pill (999px).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Radius {
|
||||
None,
|
||||
Sm,
|
||||
Md,
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Reuses `bread-theme::tokens`' spacing scale (4/8/12px).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Padding {
|
||||
None,
|
||||
Xs,
|
||||
Sm,
|
||||
Md,
|
||||
}
|
||||
|
||||
/// Where a widget renders in breadbar. Each variant names a fixed slot in
|
||||
/// breadbar's existing `CenterBox` layout (workspaces | clock | stats), plus
|
||||
/// the hamburger control-panel popover's tray section.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WidgetPlacement {
|
||||
/// Tucked inside the hamburger control-panel popover, alongside the
|
||||
/// existing SNI tray icons.
|
||||
Tray,
|
||||
/// In the center section, immediately left of the clock label.
|
||||
LeftOfClock,
|
||||
/// In the center section, immediately right of the clock label.
|
||||
RightOfClock,
|
||||
/// In the start section, immediately right of the workspace buttons.
|
||||
RightOfWorkspaces,
|
||||
/// In the end section, immediately left of the CPU/RAM/power/battery group.
|
||||
LeftOfStats,
|
||||
}
|
||||
|
||||
/// A full widget declaration, as stored in `RuntimeState.widgets` and
|
||||
/// returned by the `widgets.list` IPC method.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WidgetSpec {
|
||||
/// Fully-qualified id: `"<module>.<local_id>"`. Unique across all widgets.
|
||||
pub id: String,
|
||||
/// Name of the module that registered this widget.
|
||||
pub module: String,
|
||||
pub placement: WidgetPlacement,
|
||||
/// Sort priority within a placement; lower sorts first.
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
#[serde(default = "default_visible")]
|
||||
pub visible: bool,
|
||||
#[serde(default)]
|
||||
pub tooltip: Option<String>,
|
||||
pub root: WidgetNode,
|
||||
/// Unix epoch milliseconds of the last register/update.
|
||||
pub updated_at: u64,
|
||||
}
|
||||
|
||||
fn default_visible() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Why a [`WidgetNode`] tree failed validation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WidgetValidationError {
|
||||
TooDeep { max: usize },
|
||||
TooManyNodes { max: usize },
|
||||
InvalidClass { class: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WidgetValidationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::TooDeep { max } => write!(f, "widget node tree exceeds max depth of {max}"),
|
||||
Self::TooManyNodes { max } => {
|
||||
write!(f, "widget node tree exceeds max node count of {max}")
|
||||
}
|
||||
Self::InvalidClass { class } => write!(
|
||||
f,
|
||||
"invalid css class '{class}': must match ^[a-zA-Z][a-zA-Z0-9_-]{{0,63}}$"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WidgetValidationError {}
|
||||
|
||||
/// A CSS class is restricted to a conservative identifier shape so widget
|
||||
/// styling can only opt into classes predefined in breadbar's stylesheet —
|
||||
/// there is no raw style/CSS injection surface from Lua.
|
||||
fn is_valid_class(class: &str) -> bool {
|
||||
let mut chars = class.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if !first.is_ascii_alphabetic() {
|
||||
return false;
|
||||
}
|
||||
class.len() <= 64
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
impl WidgetNode {
|
||||
/// The node's CSS class hook, if any — a renderer should apply this via
|
||||
/// its GTK equivalent of `add_css_class` rather than injecting raw style.
|
||||
pub fn class(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Box { class, .. }
|
||||
| Self::Label { class, .. }
|
||||
| Self::Icon { class, .. }
|
||||
| Self::Progress { class, .. } => class.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The node's typed style vocabulary, if any — a renderer maps each
|
||||
/// `Some` field to a predefined CSS class (see `render.rs`'s
|
||||
/// `apply_style`), never to raw injected CSS.
|
||||
pub fn style(&self) -> Option<&WidgetStyle> {
|
||||
match self {
|
||||
Self::Box { style, .. }
|
||||
| Self::Label { style, .. }
|
||||
| Self::Icon { style, .. }
|
||||
| Self::Progress { style, .. } => style.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The node's opaque click payload, if any — a renderer attaches a click
|
||||
/// handler that reports this value back verbatim (see the Lua API's
|
||||
/// "Click events" contract in `Documentation.md`), it never interprets it.
|
||||
pub fn on_click(&self) -> Option<&Value> {
|
||||
match self {
|
||||
Self::Box { on_click, .. }
|
||||
| Self::Label { on_click, .. }
|
||||
| Self::Icon { on_click, .. }
|
||||
| Self::Progress { on_click, .. } => on_click.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn children(&self) -> &[WidgetNode] {
|
||||
match self {
|
||||
Self::Box { children, .. } => children,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate depth, total node count, and every `class` field. Call this
|
||||
/// on any tree received from Lua before storing or broadcasting it.
|
||||
pub fn validate(&self) -> Result<(), WidgetValidationError> {
|
||||
let mut total = 0usize;
|
||||
self.validate_inner(1, &mut total)
|
||||
}
|
||||
|
||||
fn validate_inner(
|
||||
&self,
|
||||
depth: usize,
|
||||
total: &mut usize,
|
||||
) -> Result<(), WidgetValidationError> {
|
||||
if depth > MAX_NODE_DEPTH {
|
||||
return Err(WidgetValidationError::TooDeep { max: MAX_NODE_DEPTH });
|
||||
}
|
||||
*total += 1;
|
||||
if *total > MAX_NODE_COUNT {
|
||||
return Err(WidgetValidationError::TooManyNodes { max: MAX_NODE_COUNT });
|
||||
}
|
||||
if let Some(class) = self.class() {
|
||||
if !is_valid_class(class) {
|
||||
return Err(WidgetValidationError::InvalidClass {
|
||||
class: class.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for child in self.children() {
|
||||
child.validate_inner(depth + 1, total)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn label(class: Option<&str>) -> WidgetNode {
|
||||
WidgetNode::Label {
|
||||
text: "x".to_string(),
|
||||
class: class.map(str::to_string),
|
||||
style: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn box_of(children: Vec<WidgetNode>) -> WidgetNode {
|
||||
WidgetNode::Box {
|
||||
orientation: Orientation::Horizontal,
|
||||
spacing: None,
|
||||
class: None,
|
||||
style: None,
|
||||
on_click: None,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_label_is_valid() {
|
||||
assert!(label(Some("dim")).validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_class_characters() {
|
||||
assert_eq!(
|
||||
label(Some("dim; color: red")).validate(),
|
||||
Err(WidgetValidationError::InvalidClass {
|
||||
class: "dim; color: red".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_class_starting_with_digit() {
|
||||
assert!(label(Some("1dim")).validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_class() {
|
||||
assert!(label(Some("")).validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_class_with_underscore_and_hyphen() {
|
||||
assert!(label(Some("my_class-2")).validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tree_deeper_than_max() {
|
||||
// depth 1 (root box) -> 2 -> 3 -> 4 -> 5 (label), exceeds MAX_NODE_DEPTH=4
|
||||
let tree = box_of(vec![box_of(vec![box_of(vec![box_of(vec![label(None)])])])]);
|
||||
assert_eq!(
|
||||
tree.validate(),
|
||||
Err(WidgetValidationError::TooDeep { max: MAX_NODE_DEPTH })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_tree_at_max_depth() {
|
||||
// depth 1 -> 2 -> 3 -> 4 (label), exactly MAX_NODE_DEPTH
|
||||
let tree = box_of(vec![box_of(vec![box_of(vec![label(None)])])]);
|
||||
assert!(tree.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_many_nodes() {
|
||||
let children: Vec<WidgetNode> = (0..MAX_NODE_COUNT).map(|_| label(None)).collect();
|
||||
let tree = box_of(children);
|
||||
assert_eq!(
|
||||
tree.validate(),
|
||||
Err(WidgetValidationError::TooManyNodes { max: MAX_NODE_COUNT })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_node_count_at_max() {
|
||||
let children: Vec<WidgetNode> = (0..MAX_NODE_COUNT - 1).map(|_| label(None)).collect();
|
||||
let tree = box_of(children);
|
||||
assert!(tree.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_spec_round_trips_through_json() {
|
||||
let spec = WidgetSpec {
|
||||
id: "weather.temp".to_string(),
|
||||
module: "weather".to_string(),
|
||||
placement: WidgetPlacement::LeftOfStats,
|
||||
order: 10,
|
||||
visible: true,
|
||||
tooltip: Some("Sydney".to_string()),
|
||||
root: box_of(vec![
|
||||
WidgetNode::Icon {
|
||||
name: Some("cloud".to_string()),
|
||||
path: None,
|
||||
size: Some(16),
|
||||
class: None,
|
||||
style: None,
|
||||
on_click: None,
|
||||
},
|
||||
label(Some("dim")),
|
||||
WidgetNode::Progress {
|
||||
value: 0.5,
|
||||
class: None,
|
||||
style: Some(WidgetStyle {
|
||||
color: Some(SemanticColor::Accent),
|
||||
..Default::default()
|
||||
}),
|
||||
on_click: Some(json!({ "action": "refresh" })),
|
||||
},
|
||||
]),
|
||||
updated_at: 1_700_000_000_000,
|
||||
};
|
||||
let raw = serde_json::to_string(&spec).unwrap();
|
||||
let decoded: WidgetSpec = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(decoded.id, spec.id);
|
||||
assert_eq!(decoded.placement, spec.placement);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placement_serializes_as_snake_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WidgetPlacement::Tray).unwrap(),
|
||||
"\"tray\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WidgetPlacement::LeftOfClock).unwrap(),
|
||||
"\"left_of_clock\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WidgetPlacement::RightOfClock).unwrap(),
|
||||
"\"right_of_clock\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WidgetPlacement::RightOfWorkspaces).unwrap(),
|
||||
"\"right_of_workspaces\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&WidgetPlacement::LeftOfStats).unwrap(),
|
||||
"\"left_of_stats\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_serializes_with_type_tag() {
|
||||
let value = serde_json::to_value(label(Some("dim"))).unwrap();
|
||||
assert_eq!(value["type"], "label");
|
||||
assert_eq!(value["text"], "x");
|
||||
assert_eq!(value["class"], "dim");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_without_style_omits_it_when_serialized_and_back() {
|
||||
let node = label(None);
|
||||
assert!(node.style().is_none());
|
||||
let raw = serde_json::to_string(&node).unwrap();
|
||||
let decoded: WidgetNode = serde_json::from_str(&raw).unwrap();
|
||||
assert!(decoded.style().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_field_is_optional_when_absent_from_json() {
|
||||
let raw = json!({ "type": "label", "text": "x" });
|
||||
let node: WidgetNode = serde_json::from_value(raw).unwrap();
|
||||
assert!(node.style().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_round_trips_through_json() {
|
||||
let node = WidgetNode::Label {
|
||||
text: "x".to_string(),
|
||||
class: None,
|
||||
style: Some(WidgetStyle {
|
||||
color: Some(SemanticColor::Red),
|
||||
weight: Some(FontWeight::Bold),
|
||||
size: Some(TextSize::Lg),
|
||||
align: Some(Align::Center),
|
||||
background: Some(Background::Card),
|
||||
radius: Some(Radius::Sm),
|
||||
padding: Some(Padding::Xs),
|
||||
}),
|
||||
on_click: None,
|
||||
};
|
||||
let raw = serde_json::to_string(&node).unwrap();
|
||||
let decoded: WidgetNode = serde_json::from_str(&raw).unwrap();
|
||||
let style = decoded.style().expect("style should round-trip");
|
||||
assert_eq!(style.color, Some(SemanticColor::Red));
|
||||
assert_eq!(style.weight, Some(FontWeight::Bold));
|
||||
assert_eq!(style.size, Some(TextSize::Lg));
|
||||
assert_eq!(style.align, Some(Align::Center));
|
||||
assert_eq!(style.background, Some(Background::Card));
|
||||
assert_eq!(style.radius, Some(Radius::Sm));
|
||||
assert_eq!(style.padding, Some(Padding::Xs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_enums_serialize_as_snake_case() {
|
||||
assert_eq!(serde_json::to_string(&SemanticColor::Dim).unwrap(), "\"dim\"");
|
||||
assert_eq!(serde_json::to_string(&Background::None).unwrap(), "\"none\"");
|
||||
assert_eq!(serde_json::to_string(&Radius::Full).unwrap(), "\"full\"");
|
||||
assert_eq!(serde_json::to_string(&Padding::Xs).unwrap(), "\"xs\"");
|
||||
assert_eq!(serde_json::to_string(&Align::End).unwrap(), "\"end\"");
|
||||
assert_eq!(serde_json::to_string(&FontWeight::Bold).unwrap(), "\"bold\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_style_color_fails_to_deserialize() {
|
||||
let raw = json!({ "type": "label", "text": "x", "style": { "color": "bg" } });
|
||||
assert!(serde_json::from_value::<WidgetNode>(raw).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_with_all_fields_none_is_equivalent_to_default() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(WidgetStyle::default()).unwrap(),
|
||||
json!({
|
||||
"color": null, "weight": null, "size": null,
|
||||
"align": null, "background": null, "radius": null, "padding": null
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_does_not_affect_validation() {
|
||||
let node = WidgetNode::Label {
|
||||
text: "x".to_string(),
|
||||
class: None,
|
||||
style: Some(WidgetStyle {
|
||||
color: Some(SemanticColor::Accent),
|
||||
..Default::default()
|
||||
}),
|
||||
on_click: None,
|
||||
};
|
||||
assert!(node.validate().is_ok());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "breadd"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -22,6 +22,8 @@ netlink-packet-route = "0.11"
|
|||
netlink-packet-core = "0.4"
|
||||
libc = "0.2"
|
||||
notify = "6.1"
|
||||
landlock.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::path::PathBuf;
|
|||
use anyhow::{anyhow, Result};
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
|
|
@ -24,6 +24,13 @@ impl Adapter for HyprlandAdapter {
|
|||
debug!("hyprland adapter started");
|
||||
let socket = hyprland_event_socket()?;
|
||||
let stream = UnixStream::connect(&socket).await?;
|
||||
// Snapshot current compositor topology *after* the event socket is
|
||||
// connected so we don't miss a change that lands during the query,
|
||||
// then apply the snapshot first so bread.state is populated before
|
||||
// the live stream. Failure is non-fatal: live events still work.
|
||||
if let Err(e) = emit_topology_snapshot(&tx).await {
|
||||
warn!("hyprland topology snapshot failed: {e}");
|
||||
}
|
||||
let reader = BufReader::new(stream);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
|
|
@ -83,6 +90,53 @@ fn hyprland_event_socket() -> Result<PathBuf> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Request socket sits next to `.socket2.sock` as `.socket.sock`.
|
||||
fn hyprland_request_socket() -> Result<PathBuf> {
|
||||
let events = hyprland_event_socket()?;
|
||||
let parent = events
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("hyprland event socket has no parent dir"))?;
|
||||
Ok(parent.join(".socket.sock"))
|
||||
}
|
||||
|
||||
async fn hyprland_request_json(request: &str) -> Result<serde_json::Value> {
|
||||
let path = hyprland_request_socket()?;
|
||||
let mut stream = UnixStream::connect(&path).await?;
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
stream.shutdown().await?;
|
||||
let mut buf = String::new();
|
||||
stream.read_to_string(&mut buf).await?;
|
||||
serde_json::from_str(&buf)
|
||||
.map_err(|e| anyhow!("hyprland {request} JSON: {e}"))
|
||||
}
|
||||
|
||||
/// Query current monitors / workspaces / focus and emit one
|
||||
/// `hyprland.snapshot` RawEvent. The normalizer turns that into
|
||||
/// `bread.hyprland.snapshot`; the state engine replaces topology from it.
|
||||
async fn emit_topology_snapshot(tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
let monitors = hyprland_request_json("j/monitors").await.unwrap_or(json!([]));
|
||||
let workspaces = hyprland_request_json("j/workspaces")
|
||||
.await
|
||||
.unwrap_or(json!([]));
|
||||
let active_workspace = hyprland_request_json("j/activeworkspace").await.ok();
|
||||
let active_window = hyprland_request_json("j/activewindow").await.ok();
|
||||
|
||||
tx.send(RawEvent {
|
||||
source: AdapterSource::Hyprland,
|
||||
kind: "hyprland.snapshot".to_string(),
|
||||
payload: json!({
|
||||
"monitors": monitors,
|
||||
"workspaces": workspaces,
|
||||
"active_workspace": active_workspace,
|
||||
"active_window": active_window,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow!("raw channel closed during hyprland snapshot"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_hyprland_line(line: &str) -> (String, String) {
|
||||
if let Some((kind, data)) = line.split_once(">>") {
|
||||
return (kind.to_string(), data.to_string());
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::os::unix::io::AsRawFd;
|
|||
|
||||
use anyhow::Result;
|
||||
use bread_shared::{now_unix_ms, AdapterSource, RawEvent};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
|
||||
|
|
@ -19,20 +19,13 @@ impl UdevAdapter {
|
|||
}
|
||||
|
||||
pub async fn enumerate_existing(&self, tx: &mpsc::Sender<RawEvent>) -> Result<()> {
|
||||
let devices = enumerate_with_udev(&self.subsystems)?;
|
||||
for device in devices {
|
||||
tx.send(RawEvent {
|
||||
source: AdapterSource::Udev,
|
||||
kind: "udev.enumerate".to_string(),
|
||||
payload: json!({
|
||||
"action": "add",
|
||||
"id": device.id,
|
||||
"name": device.name,
|
||||
"subsystem": device.subsystem,
|
||||
}),
|
||||
timestamp: now_unix_ms(),
|
||||
})
|
||||
.await?;
|
||||
let mut enumerator = udev::Enumerator::new()?;
|
||||
for subsystem in &self.subsystems {
|
||||
enumerator.match_subsystem(subsystem)?;
|
||||
}
|
||||
for device in enumerator.scan_devices()? {
|
||||
tx.send(build_device_event(&device, "add", "udev.enumerate"))
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -50,12 +43,6 @@ impl Adapter for UdevAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
struct ScannedDevice {
|
||||
id: String,
|
||||
name: String,
|
||||
subsystem: String,
|
||||
}
|
||||
|
||||
// udev::MonitorSocket uses a non-blocking socket; calling iter().next() without
|
||||
// first polling the fd returns None immediately and exits the loop — which is
|
||||
// why the old code silently fell back to sysfs on every start. We use poll(2)
|
||||
|
|
@ -110,81 +97,157 @@ fn build_event(event: &udev::Event) -> RawEvent {
|
|||
.action()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "change".to_string());
|
||||
let subsystem = event
|
||||
build_device_event(event, &action, "udev.change")
|
||||
}
|
||||
|
||||
/// Shared live/enumerate payload. `udev::Event` deref's to `Device`, so
|
||||
/// boot-time enumerate of an already-plugged device is equivalent to an
|
||||
/// `add` of that same device (same identity + classification fields
|
||||
/// `resolve_device` needs).
|
||||
fn build_device_event(device: &udev::Device, action: &str, kind: &str) -> RawEvent {
|
||||
let subsystem = device
|
||||
.subsystem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let name = event
|
||||
let name = device
|
||||
.property_value("ID_MODEL")
|
||||
.or_else(|| event.property_value("NAME"))
|
||||
.or_else(|| device.property_value("NAME"))
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
.or_else(|| event.devnode().map(|n| n.display().to_string()))
|
||||
.or_else(|| device.devnode().map(|n| n.display().to_string()))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let id = event.syspath().to_string_lossy().to_string();
|
||||
let id = device.syspath().to_string_lossy().to_string();
|
||||
|
||||
RawEvent {
|
||||
source: AdapterSource::Udev,
|
||||
kind: "udev.change".to_string(),
|
||||
payload: json!({
|
||||
"action": action,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"subsystem": subsystem,
|
||||
"id_input_keyboard": prop_bool(event, "ID_INPUT_KEYBOARD"),
|
||||
"id_input_mouse": prop_bool(event, "ID_INPUT_MOUSE"),
|
||||
"id_input_joystick": prop_bool(event, "ID_INPUT_JOYSTICK"),
|
||||
"id_input_touchpad": prop_bool(event, "ID_INPUT_TOUCHPAD"),
|
||||
"id_input_tablet": prop_bool(event, "ID_INPUT_TABLET"),
|
||||
"id_usb_class": prop_str(event, "ID_USB_CLASS"),
|
||||
"id_usb_interfaces": prop_str(event, "ID_USB_INTERFACES"),
|
||||
"id_vendor": prop_str(event, "ID_VENDOR"),
|
||||
"id_model": prop_str(event, "ID_MODEL"),
|
||||
"vendor_id": prop_str(event, "ID_VENDOR_ID"),
|
||||
"product_id": prop_str(event, "ID_MODEL_ID"),
|
||||
}),
|
||||
kind: kind.to_string(),
|
||||
payload: udev_event_payload(
|
||||
action,
|
||||
&id,
|
||||
&name,
|
||||
&subsystem,
|
||||
UdevClassification::from_device(device),
|
||||
),
|
||||
timestamp: now_unix_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
fn enumerate_with_udev(subsystems: &[String]) -> Result<Vec<ScannedDevice>> {
|
||||
let mut enumerator = udev::Enumerator::new()?;
|
||||
for subsystem in subsystems {
|
||||
enumerator.match_subsystem(subsystem)?;
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for dev in enumerator.scan_devices()? {
|
||||
let subsystem = dev
|
||||
.subsystem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let name = dev
|
||||
.property_value("ID_MODEL")
|
||||
.or_else(|| dev.property_value("NAME"))
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
.or_else(|| dev.sysname().to_str().map(ToString::to_string))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let id = dev.syspath().to_string_lossy().to_string();
|
||||
out.push(ScannedDevice {
|
||||
id,
|
||||
name,
|
||||
subsystem,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
/// Classification / identity fields copied onto every udev payload so
|
||||
/// `resolve_device` can name a device after boot the same way it names a
|
||||
/// live plug-in.
|
||||
struct UdevClassification {
|
||||
id_input_keyboard: bool,
|
||||
id_input_mouse: bool,
|
||||
id_input_joystick: bool,
|
||||
id_input_touchpad: bool,
|
||||
id_input_tablet: bool,
|
||||
id_usb_class: Option<String>,
|
||||
id_usb_interfaces: Option<String>,
|
||||
id_vendor: Option<String>,
|
||||
id_model: Option<String>,
|
||||
vendor_id: Option<String>,
|
||||
product_id: Option<String>,
|
||||
}
|
||||
|
||||
fn prop_bool(event: &udev::Event, key: &str) -> bool {
|
||||
event
|
||||
impl UdevClassification {
|
||||
fn from_device(device: &udev::Device) -> Self {
|
||||
Self {
|
||||
id_input_keyboard: prop_bool(device, "ID_INPUT_KEYBOARD"),
|
||||
id_input_mouse: prop_bool(device, "ID_INPUT_MOUSE"),
|
||||
id_input_joystick: prop_bool(device, "ID_INPUT_JOYSTICK"),
|
||||
id_input_touchpad: prop_bool(device, "ID_INPUT_TOUCHPAD"),
|
||||
id_input_tablet: prop_bool(device, "ID_INPUT_TABLET"),
|
||||
id_usb_class: prop_str(device, "ID_USB_CLASS"),
|
||||
id_usb_interfaces: prop_str(device, "ID_USB_INTERFACES"),
|
||||
id_vendor: prop_str(device, "ID_VENDOR"),
|
||||
id_model: prop_str(device, "ID_MODEL"),
|
||||
vendor_id: prop_str(device, "ID_VENDOR_ID"),
|
||||
product_id: prop_str(device, "ID_MODEL_ID"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn udev_event_payload(
|
||||
action: &str,
|
||||
id: &str,
|
||||
name: &str,
|
||||
subsystem: &str,
|
||||
class: UdevClassification,
|
||||
) -> Value {
|
||||
json!({
|
||||
"action": action,
|
||||
"id": id,
|
||||
"name": name,
|
||||
"subsystem": subsystem,
|
||||
"id_input_keyboard": class.id_input_keyboard,
|
||||
"id_input_mouse": class.id_input_mouse,
|
||||
"id_input_joystick": class.id_input_joystick,
|
||||
"id_input_touchpad": class.id_input_touchpad,
|
||||
"id_input_tablet": class.id_input_tablet,
|
||||
"id_usb_class": class.id_usb_class,
|
||||
"id_usb_interfaces": class.id_usb_interfaces,
|
||||
"id_vendor": class.id_vendor,
|
||||
"id_model": class.id_model,
|
||||
"vendor_id": class.vendor_id,
|
||||
"product_id": class.product_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn prop_bool(device: &udev::Device, key: &str) -> bool {
|
||||
device
|
||||
.property_value(key)
|
||||
.and_then(|v| v.to_str())
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn prop_str(event: &udev::Event, key: &str) -> Option<String> {
|
||||
event
|
||||
fn prop_str(device: &udev::Device, key: &str) -> Option<String> {
|
||||
device
|
||||
.property_value(key)
|
||||
.map(|v| v.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enumerate_payload_includes_classification_fields() {
|
||||
// Boot-time enumerate used to send only {action,id,name,subsystem},
|
||||
// so resolve_device could never match vendor/product/input rules
|
||||
// and bread.state.devices stayed "unknown" until the next unplug.
|
||||
let payload = udev_event_payload(
|
||||
"add",
|
||||
"/sys/devices/pci0000:00/usb1/1-3",
|
||||
"Keychron K2",
|
||||
"usb",
|
||||
UdevClassification {
|
||||
id_input_keyboard: true,
|
||||
id_input_mouse: false,
|
||||
id_input_joystick: false,
|
||||
id_input_touchpad: false,
|
||||
id_input_tablet: false,
|
||||
id_usb_class: None,
|
||||
id_usb_interfaces: None,
|
||||
id_vendor: Some("Keychron".into()),
|
||||
id_model: Some("Keychron K2".into()),
|
||||
vendor_id: Some("3434".into()),
|
||||
product_id: Some("d030".into()),
|
||||
},
|
||||
);
|
||||
assert_eq!(payload["action"], "add");
|
||||
assert_eq!(payload["id"], "/sys/devices/pci0000:00/usb1/1-3");
|
||||
assert_eq!(payload["name"], "Keychron K2");
|
||||
assert_eq!(payload["subsystem"], "usb");
|
||||
assert_eq!(payload["vendor_id"], "3434");
|
||||
assert_eq!(payload["product_id"], "d030");
|
||||
assert_eq!(payload["id_vendor"], "Keychron");
|
||||
assert_eq!(payload["id_model"], "Keychron K2");
|
||||
assert_eq!(payload["id_input_keyboard"], true);
|
||||
assert_eq!(payload["id_input_mouse"], false);
|
||||
assert_eq!(payload["id_input_joystick"], false);
|
||||
assert_eq!(payload["id_input_touchpad"], false);
|
||||
assert_eq!(payload["id_input_tablet"], false);
|
||||
assert!(payload["id_usb_class"].is_null());
|
||||
assert!(payload["id_usb_interfaces"].is_null());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ pub struct Config {
|
|||
pub notifications: NotificationsConfig,
|
||||
#[serde(default)]
|
||||
pub events: EventsConfig,
|
||||
#[serde(default)]
|
||||
pub compat: CompatConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -119,6 +121,22 @@ pub struct EventsConfig {
|
|||
pub dedup_window_ms: u64,
|
||||
}
|
||||
|
||||
/// Deprecation-window toggles for backwards compatibility with pre-namespace
|
||||
/// event names. See `[compat]` in `breadd.toml` / `Documentation.md`'s
|
||||
/// Hyprland event reference for the migration this gates.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CompatConfig {
|
||||
/// When `true` (the default during the deprecation window), the Hyprland
|
||||
/// adapter dual-emits both its legacy flat event names (e.g.
|
||||
/// `bread.workspace.changed`) and their namespaced `bread.hyprland.*`
|
||||
/// equivalents (e.g. `bread.hyprland.workspace.changed`). Set to `false`
|
||||
/// to suppress the legacy names and emit only the namespaced ones — this
|
||||
/// will become the default in a later release once the deprecation
|
||||
/// window closes.
|
||||
#[serde(default = "default_true")]
|
||||
pub legacy_hyprland_event_names: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct NotificationsConfig {
|
||||
#[serde(default = "default_notify_timeout")]
|
||||
|
|
@ -218,6 +236,14 @@ impl Default for NotificationsConfig {
|
|||
}
|
||||
}
|
||||
|
||||
impl Default for CompatConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
legacy_hyprland_event_names: default_true(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = config_path();
|
||||
|
|
@ -256,6 +282,19 @@ fn config_path() -> PathBuf {
|
|||
expand_home("~/.config/bread/breadd.toml")
|
||||
}
|
||||
|
||||
/// Location of the optional `rules.toml` — declarative automation rules
|
||||
/// (see `crate::core::rules`). Resolved with the exact same
|
||||
/// `XDG_CONFIG_HOME`-vs-`HOME` precedence as `config_path()` above; see the
|
||||
/// `expand_home` doc comment for why every config-adjacent path the daemon
|
||||
/// resolves has to agree on that precedence.
|
||||
pub fn rules_path() -> PathBuf {
|
||||
if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
|
||||
return Path::new(&xdg).join("bread").join("rules.toml");
|
||||
}
|
||||
|
||||
expand_home("~/.config/bread/rules.toml")
|
||||
}
|
||||
|
||||
/// Expands a leading `~/`. `~/.config/...` paths specifically prefer
|
||||
/// `$XDG_CONFIG_HOME` when it's set, consistent with `config_path()`'s own
|
||||
/// resolution of `breadd.toml` itself — otherwise the default `lua.entry_point`
|
||||
|
|
@ -264,7 +303,7 @@ fn config_path() -> PathBuf {
|
|||
/// though the config file that sets them was found via that same variable,
|
||||
/// which is exactly the kind of inconsistency that made init.lua/module
|
||||
/// loading silently no-op for a XDG_CONFIG_HOME-only test setup.
|
||||
fn expand_home(input: &str) -> PathBuf {
|
||||
pub(crate) fn expand_home(input: &str) -> PathBuf {
|
||||
if let Some(stripped) = input.strip_prefix("~/.config/") {
|
||||
if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") {
|
||||
return Path::new(&xdg_config).join(stripped);
|
||||
|
|
@ -379,6 +418,7 @@ mod tests {
|
|||
assert_eq!(cfg.notifications.notify_send_path, "notify-send");
|
||||
assert!(cfg.modules.builtin);
|
||||
assert!(cfg.modules.disable.is_empty());
|
||||
assert!(cfg.compat.legacy_hyprland_event_names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -394,6 +434,7 @@ mod tests {
|
|||
let cfg: Config = toml::from_str("").unwrap();
|
||||
assert_eq!(cfg.daemon.log_level, "info");
|
||||
assert!(cfg.adapters.hyprland.enabled);
|
||||
assert!(cfg.compat.legacy_hyprland_event_names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -435,6 +476,9 @@ dedup_window_ms = 250
|
|||
default_timeout_ms = 1000
|
||||
default_urgency = "critical"
|
||||
notify_send_path = "/usr/local/bin/notify-send"
|
||||
|
||||
[compat]
|
||||
legacy_hyprland_event_names = false
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(raw).unwrap();
|
||||
assert_eq!(cfg.daemon.log_level, "debug");
|
||||
|
|
@ -453,6 +497,7 @@ notify_send_path = "/usr/local/bin/notify-send"
|
|||
assert_eq!(cfg.events.dedup_window_ms, 250);
|
||||
assert_eq!(cfg.notifications.default_timeout_ms, 1000);
|
||||
assert_eq!(cfg.notifications.default_urgency, "critical");
|
||||
assert!(!cfg.compat.legacy_hyprland_event_names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -466,6 +511,23 @@ log_level = "trace"
|
|||
// Untouched sections still get their defaults.
|
||||
assert!(cfg.adapters.hyprland.enabled);
|
||||
assert_eq!(cfg.events.dedup_window_ms, 100);
|
||||
assert!(cfg.compat.legacy_hyprland_event_names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_section_defaults_legacy_hyprland_names_to_true() {
|
||||
let cfg = Config::default();
|
||||
assert!(cfg.compat.legacy_hyprland_event_names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_section_can_disable_legacy_hyprland_names() {
|
||||
let raw = r#"
|
||||
[compat]
|
||||
legacy_hyprland_event_names = false
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(raw).unwrap();
|
||||
assert!(!cfg.compat.legacy_hyprland_event_names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -586,4 +648,25 @@ log_level = "trace"
|
|||
PathBuf::from("/synthetic/home/.config/bread/breadd.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_path_respects_xdg_config_home() {
|
||||
let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]);
|
||||
std::env::set_var("XDG_CONFIG_HOME", "/synthetic/xdg-config");
|
||||
assert_eq!(
|
||||
rules_path(),
|
||||
PathBuf::from("/synthetic/xdg-config/bread/rules.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_path_falls_back_to_home_when_no_xdg() {
|
||||
let _g = EnvGuard::new(&["XDG_CONFIG_HOME", "HOME"]);
|
||||
std::env::remove_var("XDG_CONFIG_HOME");
|
||||
std::env::set_var("HOME", "/synthetic/home");
|
||||
assert_eq!(
|
||||
rules_path(),
|
||||
PathBuf::from("/synthetic/home/.config/bread/rules.toml")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod config;
|
||||
pub mod normalizer;
|
||||
pub mod rules;
|
||||
pub mod state_engine;
|
||||
pub mod subscriptions;
|
||||
pub mod supervisor;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use bread_shared::{apps::validate_app_namespace, AdapterSource, BreadEvent, RawEvent};
|
||||
use bread_shared::{
|
||||
apps::{validate_app_namespace, validate_command_event},
|
||||
AdapterSource, BreadEvent, RawEvent,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// How many multiples of `dedup_window_ms` an entry must be idle before eviction.
|
||||
|
|
@ -14,6 +17,11 @@ pub struct EventNormalizer {
|
|||
/// fired within the current window, so subsequent child-node events from the
|
||||
/// same plug-in are suppressed at the normalizer level.
|
||||
seen_devices: RwLock<HashMap<String, u64>>,
|
||||
/// Mirrors `[compat] legacy_hyprland_event_names` in `breadd.toml`. When
|
||||
/// `true` (the default during the deprecation window), `normalize_hyprland`
|
||||
/// dual-emits both its legacy flat event names and their namespaced
|
||||
/// `bread.hyprland.*` equivalents. See `with_legacy_hyprland_event_names`.
|
||||
legacy_hyprland_event_names: bool,
|
||||
}
|
||||
|
||||
impl EventNormalizer {
|
||||
|
|
@ -22,9 +30,21 @@ impl EventNormalizer {
|
|||
dedup_window_ms,
|
||||
recent: RwLock::new(HashMap::new()),
|
||||
seen_devices: RwLock::new(HashMap::new()),
|
||||
legacy_hyprland_event_names: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides whether the Hyprland adapter's legacy flat event names
|
||||
/// (`bread.workspace.changed` etc.) keep firing alongside their
|
||||
/// `bread.hyprland.*` equivalents. Defaults to `true` via `new`, mirroring
|
||||
/// `[compat] legacy_hyprland_event_names`'s documented default during the
|
||||
/// deprecation window; `main.rs` overrides this from
|
||||
/// `config.compat.legacy_hyprland_event_names`.
|
||||
pub fn with_legacy_hyprland_event_names(mut self, enabled: bool) -> Self {
|
||||
self.legacy_hyprland_event_names = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn normalize(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let mut out = match &raw.source {
|
||||
AdapterSource::Udev => self.normalize_udev(raw),
|
||||
|
|
@ -43,6 +63,23 @@ impl EventNormalizer {
|
|||
event: raw.kind.clone(),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
}],
|
||||
// `Manual` is never constructed as a `RawEvent::source` in this
|
||||
// codebase — the IPC boundary's unsourced `emit` path builds a
|
||||
// `BreadEvent` directly (see `breadd/src/ipc/mod.rs`), bypassing
|
||||
// this normalizer entirely, exactly as `System` did before it.
|
||||
// This arm exists only so the match stays exhaustive; if that
|
||||
// ever changes, pass-through (like `System`) is the sane default
|
||||
// rather than silently dropping the event.
|
||||
AdapterSource::Manual => vec![BreadEvent {
|
||||
event: raw.kind.clone(),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
}],
|
||||
};
|
||||
|
|
@ -143,6 +180,8 @@ impl EventNormalizer {
|
|||
event: format!("bread.device.{}", verb),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Udev,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: json!({
|
||||
"id": id,
|
||||
"device": "unknown",
|
||||
|
|
@ -157,6 +196,17 @@ impl EventNormalizer {
|
|||
}
|
||||
|
||||
fn normalize_hyprland(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
if raw.kind == "hyprland.snapshot" {
|
||||
return vec![BreadEvent {
|
||||
event: "bread.hyprland.snapshot".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
}];
|
||||
}
|
||||
|
||||
let kind = raw
|
||||
.payload
|
||||
.get("kind")
|
||||
|
|
@ -169,97 +219,119 @@ impl EventNormalizer {
|
|||
.unwrap_or("");
|
||||
|
||||
match kind {
|
||||
"workspace" | "workspacev2" => vec![BreadEvent {
|
||||
event: "bread.workspace.changed".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: raw.payload.clone(),
|
||||
}],
|
||||
"createworkspace" => vec![BreadEvent {
|
||||
event: "bread.workspace.created".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({ "workspace": data }),
|
||||
}],
|
||||
"destroyworkspace" => vec![BreadEvent {
|
||||
event: "bread.workspace.destroyed".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({ "workspace": data }),
|
||||
}],
|
||||
"monitoradded" => vec![BreadEvent {
|
||||
event: "bread.monitor.connected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({ "name": data }),
|
||||
}],
|
||||
"monitorremoved" => vec![BreadEvent {
|
||||
event: "bread.monitor.disconnected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({ "name": data }),
|
||||
}],
|
||||
"activewindow" => vec![BreadEvent {
|
||||
event: "bread.window.focus.changed".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: raw.payload.clone(),
|
||||
}],
|
||||
"workspace" | "workspacev2" => {
|
||||
self.emit_hyprland_dual("bread.workspace.changed", raw.payload.clone(), raw)
|
||||
}
|
||||
"createworkspace" => self.emit_hyprland_dual(
|
||||
"bread.workspace.created",
|
||||
json!({ "workspace": data }),
|
||||
raw,
|
||||
),
|
||||
"destroyworkspace" => self.emit_hyprland_dual(
|
||||
"bread.workspace.destroyed",
|
||||
json!({ "workspace": data }),
|
||||
raw,
|
||||
),
|
||||
"monitoradded" => {
|
||||
self.emit_hyprland_dual("bread.monitor.connected", json!({ "name": data }), raw)
|
||||
}
|
||||
"monitorremoved" => {
|
||||
self.emit_hyprland_dual("bread.monitor.disconnected", json!({ "name": data }), raw)
|
||||
}
|
||||
"activewindow" => {
|
||||
self.emit_hyprland_dual("bread.window.focus.changed", raw.payload.clone(), raw)
|
||||
}
|
||||
"activewindowv2" => {
|
||||
let fields = split_hyprland_fields(data);
|
||||
vec![BreadEvent {
|
||||
event: "bread.window.focused".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({
|
||||
self.emit_hyprland_dual(
|
||||
"bread.window.focused",
|
||||
json!({
|
||||
"address": fields.first().unwrap_or(&"")
|
||||
}),
|
||||
}]
|
||||
raw,
|
||||
)
|
||||
}
|
||||
"openwindow" => {
|
||||
let fields = split_hyprland_fields(data);
|
||||
vec![BreadEvent {
|
||||
event: "bread.window.opened".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({
|
||||
self.emit_hyprland_dual(
|
||||
"bread.window.opened",
|
||||
json!({
|
||||
"address": fields.first().unwrap_or(&""),
|
||||
"workspace": fields.get(1).unwrap_or(&""),
|
||||
"class": fields.get(2).unwrap_or(&""),
|
||||
"title": fields.get(3).unwrap_or(&""),
|
||||
}),
|
||||
}]
|
||||
raw,
|
||||
)
|
||||
}
|
||||
"closewindow" => {
|
||||
let fields = split_hyprland_fields(data);
|
||||
vec![BreadEvent {
|
||||
event: "bread.window.closed".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({ "address": fields.first().unwrap_or(&"") }),
|
||||
}]
|
||||
self.emit_hyprland_dual(
|
||||
"bread.window.closed",
|
||||
json!({ "address": fields.first().unwrap_or(&"") }),
|
||||
raw,
|
||||
)
|
||||
}
|
||||
"movewindow" => {
|
||||
let fields = split_hyprland_fields(data);
|
||||
vec![BreadEvent {
|
||||
event: "bread.window.moved".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
data: json!({
|
||||
self.emit_hyprland_dual(
|
||||
"bread.window.moved",
|
||||
json!({
|
||||
"address": fields.first().unwrap_or(&""),
|
||||
"workspace": fields.get(1).unwrap_or(&""),
|
||||
}),
|
||||
}]
|
||||
raw,
|
||||
)
|
||||
}
|
||||
_ => vec![BreadEvent {
|
||||
event: "bread.hyprland.event".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a Hyprland event under its namespaced `bread.hyprland.<rest>`
|
||||
/// name — always — plus, when `legacy_hyprland_event_names` is enabled
|
||||
/// (the default during the deprecation window), a second `BreadEvent`
|
||||
/// under the pre-namespace flat name (e.g. `bread.workspace.changed`).
|
||||
/// This is the single mechanism behind Workstream C: the two names carry
|
||||
/// identical `data`/`timestamp`/`source`, so a module can migrate to
|
||||
/// `bread.hyprland.*` at its own pace without missing events either way.
|
||||
fn emit_hyprland_dual(
|
||||
&self,
|
||||
legacy_event: &str,
|
||||
data: Value,
|
||||
raw: &RawEvent,
|
||||
) -> Vec<BreadEvent> {
|
||||
let rest = legacy_event.strip_prefix("bread.").unwrap_or(legacy_event);
|
||||
let namespaced_event = format!("bread.hyprland.{rest}");
|
||||
|
||||
let mut out = Vec::with_capacity(2);
|
||||
if self.legacy_hyprland_event_names {
|
||||
out.push(BreadEvent {
|
||||
event: legacy_event.to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: data.clone(),
|
||||
});
|
||||
}
|
||||
out.push(BreadEvent {
|
||||
event: namespaced_event,
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Hyprland,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data,
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn normalize_power(&self, raw: &RawEvent) -> Vec<BreadEvent> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
|
|
@ -272,6 +344,8 @@ impl EventNormalizer {
|
|||
},
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Power,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
});
|
||||
}
|
||||
|
|
@ -294,6 +368,8 @@ impl EventNormalizer {
|
|||
event: event.to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Power,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
});
|
||||
}
|
||||
|
|
@ -304,6 +380,8 @@ impl EventNormalizer {
|
|||
event: "bread.power.changed".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Power,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
});
|
||||
}
|
||||
|
|
@ -339,6 +417,8 @@ impl EventNormalizer {
|
|||
event: "bread.device.connected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"device": "unknown",
|
||||
|
|
@ -352,6 +432,8 @@ impl EventNormalizer {
|
|||
event: "bread.device.disconnected".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"device": "unknown",
|
||||
|
|
@ -365,6 +447,8 @@ impl EventNormalizer {
|
|||
event: "bread.bluetooth.device.paired".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"name": name,
|
||||
|
|
@ -377,6 +461,8 @@ impl EventNormalizer {
|
|||
event: "bread.bluetooth.device.unpaired".to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Bluetooth,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: json!({
|
||||
"id": path,
|
||||
"address": address,
|
||||
|
|
@ -421,6 +507,8 @@ impl EventNormalizer {
|
|||
event: name.to_string(),
|
||||
timestamp: raw.timestamp,
|
||||
source: AdapterSource::Network,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data,
|
||||
}]
|
||||
}
|
||||
|
|
@ -435,6 +523,8 @@ impl EventNormalizer {
|
|||
event: format!("bread.terminal.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
|
@ -444,6 +534,8 @@ impl EventNormalizer {
|
|||
event: format!("bread.remote.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
|
@ -453,6 +545,8 @@ impl EventNormalizer {
|
|||
event: format!("bread.git.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
|
@ -462,6 +556,8 @@ impl EventNormalizer {
|
|||
event: format!("bread.project.{}", raw.kind),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
|
@ -474,6 +570,8 @@ impl EventNormalizer {
|
|||
event: format!("bread.service.{suffix}"),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
|
@ -490,6 +588,8 @@ impl EventNormalizer {
|
|||
event,
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
|
@ -505,13 +605,18 @@ impl EventNormalizer {
|
|||
let AdapterSource::App(app) = &raw.source else {
|
||||
return vec![];
|
||||
};
|
||||
if !validate_app_namespace(app, &raw.kind) {
|
||||
// Own-namespace events plus well-formed commands to another known
|
||||
// app (the command bus). Anything else — including spoofed adapter
|
||||
// namespaces — is dropped here even if it somehow crossed IPC.
|
||||
if !validate_app_namespace(app, &raw.kind) && !validate_command_event(&raw.kind) {
|
||||
return vec![];
|
||||
}
|
||||
vec![BreadEvent {
|
||||
event: raw.kind.clone(),
|
||||
timestamp: raw.timestamp,
|
||||
source: raw.source.clone(),
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
data: raw.payload.clone(),
|
||||
}]
|
||||
}
|
||||
|
|
@ -710,6 +815,11 @@ mod tests {
|
|||
}
|
||||
|
||||
// ─── Hyprland ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// `EventNormalizer::new` defaults `legacy_hyprland_event_names` to `true`
|
||||
// (mirrors `[compat]`'s documented default), so by default every mapped
|
||||
// Hyprland kind dual-emits: the legacy flat name plus its namespaced
|
||||
// `bread.hyprland.*` sibling. See `emit_hyprland_dual`.
|
||||
|
||||
#[test]
|
||||
fn hyprland_workspace_change() {
|
||||
|
|
@ -721,8 +831,11 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.workspace.changed");
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(out.iter().any(|e| e.event == "bread.workspace.changed"));
|
||||
assert!(out
|
||||
.iter()
|
||||
.any(|e| e.event == "bread.hyprland.workspace.changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -735,9 +848,14 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.window.focused");
|
||||
assert_eq!(out[0].data.get("address").unwrap(), "0xdeadbeef");
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(out.iter().any(|e| e.event == "bread.window.focused"));
|
||||
assert!(out
|
||||
.iter()
|
||||
.any(|e| e.event == "bread.hyprland.window.focused"));
|
||||
for ev in &out {
|
||||
assert_eq!(ev.data.get("address").unwrap(), "0xdeadbeef");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -750,17 +868,25 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.window.opened");
|
||||
let d = &out[0].data;
|
||||
assert_eq!(d.get("address").unwrap(), "0xabc");
|
||||
assert_eq!(d.get("workspace").unwrap(), "2");
|
||||
assert_eq!(d.get("class").unwrap(), "firefox");
|
||||
assert_eq!(d.get("title").unwrap(), "Mozilla Firefox");
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(out.iter().any(|e| e.event == "bread.window.opened"));
|
||||
assert!(out
|
||||
.iter()
|
||||
.any(|e| e.event == "bread.hyprland.window.opened"));
|
||||
for ev in &out {
|
||||
let d = &ev.data;
|
||||
assert_eq!(d.get("address").unwrap(), "0xabc");
|
||||
assert_eq!(d.get("workspace").unwrap(), "2");
|
||||
assert_eq!(d.get("class").unwrap(), "firefox");
|
||||
assert_eq!(d.get("title").unwrap(), "Mozilla Firefox");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyprland_unknown_kind_falls_through_to_generic_event() {
|
||||
// The `bread.hyprland.event` fallback is already namespaced, so it's
|
||||
// exempt from dual-emit — it never had a legacy flat name to begin
|
||||
// with.
|
||||
let n = EventNormalizer::new(0);
|
||||
let ev = raw(
|
||||
AdapterSource::Hyprland,
|
||||
|
|
@ -788,9 +914,283 @@ mod tests {
|
|||
json!({"kind": "monitorremoved", "data": "HDMI-A-1"}),
|
||||
2,
|
||||
));
|
||||
assert_eq!(added[0].event, "bread.monitor.connected");
|
||||
assert_eq!(added[0].data.get("name").unwrap(), "HDMI-A-1");
|
||||
assert_eq!(removed[0].event, "bread.monitor.disconnected");
|
||||
assert_eq!(added.len(), 2);
|
||||
assert!(added.iter().any(|e| e.event == "bread.monitor.connected"));
|
||||
assert!(added
|
||||
.iter()
|
||||
.any(|e| e.event == "bread.hyprland.monitor.connected"));
|
||||
for ev in &added {
|
||||
assert_eq!(ev.data.get("name").unwrap(), "HDMI-A-1");
|
||||
}
|
||||
|
||||
assert_eq!(removed.len(), 2);
|
||||
assert!(removed
|
||||
.iter()
|
||||
.any(|e| e.event == "bread.monitor.disconnected"));
|
||||
assert!(removed
|
||||
.iter()
|
||||
.any(|e| e.event == "bread.hyprland.monitor.disconnected"));
|
||||
}
|
||||
|
||||
/// (a) With the default config (`legacy_hyprland_event_names = true`),
|
||||
/// every one of the 10 dual-emit mappings fires both its legacy flat
|
||||
/// name and its namespaced `bread.hyprland.*` sibling, with identical
|
||||
/// data on each.
|
||||
#[test]
|
||||
fn hyprland_dual_emit_covers_all_ten_mappings_by_default() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let cases: &[(&str, &str, &str, &str)] = &[
|
||||
(
|
||||
"workspace",
|
||||
"2",
|
||||
"bread.workspace.changed",
|
||||
"bread.hyprland.workspace.changed",
|
||||
),
|
||||
(
|
||||
"workspacev2",
|
||||
"2,name",
|
||||
"bread.workspace.changed",
|
||||
"bread.hyprland.workspace.changed",
|
||||
),
|
||||
(
|
||||
"createworkspace",
|
||||
"3",
|
||||
"bread.workspace.created",
|
||||
"bread.hyprland.workspace.created",
|
||||
),
|
||||
(
|
||||
"destroyworkspace",
|
||||
"3",
|
||||
"bread.workspace.destroyed",
|
||||
"bread.hyprland.workspace.destroyed",
|
||||
),
|
||||
(
|
||||
"monitoradded",
|
||||
"HDMI-A-1",
|
||||
"bread.monitor.connected",
|
||||
"bread.hyprland.monitor.connected",
|
||||
),
|
||||
(
|
||||
"monitorremoved",
|
||||
"HDMI-A-1",
|
||||
"bread.monitor.disconnected",
|
||||
"bread.hyprland.monitor.disconnected",
|
||||
),
|
||||
(
|
||||
"activewindow",
|
||||
"firefox,Mozilla Firefox",
|
||||
"bread.window.focus.changed",
|
||||
"bread.hyprland.window.focus.changed",
|
||||
),
|
||||
(
|
||||
"activewindowv2",
|
||||
"0xdead",
|
||||
"bread.window.focused",
|
||||
"bread.hyprland.window.focused",
|
||||
),
|
||||
(
|
||||
"openwindow",
|
||||
"0xabc>>2>>firefox>>Mozilla Firefox",
|
||||
"bread.window.opened",
|
||||
"bread.hyprland.window.opened",
|
||||
),
|
||||
(
|
||||
"closewindow",
|
||||
"0xabc",
|
||||
"bread.window.closed",
|
||||
"bread.hyprland.window.closed",
|
||||
),
|
||||
(
|
||||
"movewindow",
|
||||
"0xabc,2",
|
||||
"bread.window.moved",
|
||||
"bread.hyprland.window.moved",
|
||||
),
|
||||
];
|
||||
|
||||
for (kind, data, legacy_event, namespaced_event) in cases {
|
||||
let ev = raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": kind, "data": data}),
|
||||
1,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
2,
|
||||
"kind {kind} should dual-emit exactly 2 events"
|
||||
);
|
||||
assert!(
|
||||
out.iter().any(|e| &e.event == legacy_event),
|
||||
"kind {kind} missing legacy event {legacy_event}"
|
||||
);
|
||||
assert!(
|
||||
out.iter().any(|e| &e.event == namespaced_event),
|
||||
"kind {kind} missing namespaced event {namespaced_event}"
|
||||
);
|
||||
let legacy_data = out
|
||||
.iter()
|
||||
.find(|e| &e.event == legacy_event)
|
||||
.unwrap()
|
||||
.data
|
||||
.clone();
|
||||
let namespaced_data = out
|
||||
.iter()
|
||||
.find(|e| &e.event == namespaced_event)
|
||||
.unwrap()
|
||||
.data
|
||||
.clone();
|
||||
assert_eq!(
|
||||
legacy_data, namespaced_data,
|
||||
"kind {kind}: legacy and namespaced events should carry identical data"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// (b) With `legacy_hyprland_event_names = false`, only the namespaced
|
||||
/// `bread.hyprland.*` names fire — the legacy flat names are fully
|
||||
/// suppressed, not just relegated to a secondary slot.
|
||||
#[test]
|
||||
fn hyprland_legacy_names_suppressed_when_compat_disabled() {
|
||||
let n = EventNormalizer::new(0).with_legacy_hyprland_event_names(false);
|
||||
let cases: &[(&str, &str, &str, &str)] = &[
|
||||
(
|
||||
"workspace",
|
||||
"2",
|
||||
"bread.workspace.changed",
|
||||
"bread.hyprland.workspace.changed",
|
||||
),
|
||||
(
|
||||
"createworkspace",
|
||||
"3",
|
||||
"bread.workspace.created",
|
||||
"bread.hyprland.workspace.created",
|
||||
),
|
||||
(
|
||||
"destroyworkspace",
|
||||
"3",
|
||||
"bread.workspace.destroyed",
|
||||
"bread.hyprland.workspace.destroyed",
|
||||
),
|
||||
(
|
||||
"monitoradded",
|
||||
"HDMI-A-1",
|
||||
"bread.monitor.connected",
|
||||
"bread.hyprland.monitor.connected",
|
||||
),
|
||||
(
|
||||
"monitorremoved",
|
||||
"HDMI-A-1",
|
||||
"bread.monitor.disconnected",
|
||||
"bread.hyprland.monitor.disconnected",
|
||||
),
|
||||
(
|
||||
"activewindow",
|
||||
"firefox,Mozilla Firefox",
|
||||
"bread.window.focus.changed",
|
||||
"bread.hyprland.window.focus.changed",
|
||||
),
|
||||
(
|
||||
"activewindowv2",
|
||||
"0xdead",
|
||||
"bread.window.focused",
|
||||
"bread.hyprland.window.focused",
|
||||
),
|
||||
(
|
||||
"openwindow",
|
||||
"0xabc>>2>>firefox>>Mozilla Firefox",
|
||||
"bread.window.opened",
|
||||
"bread.hyprland.window.opened",
|
||||
),
|
||||
(
|
||||
"closewindow",
|
||||
"0xabc",
|
||||
"bread.window.closed",
|
||||
"bread.hyprland.window.closed",
|
||||
),
|
||||
(
|
||||
"movewindow",
|
||||
"0xabc,2",
|
||||
"bread.window.moved",
|
||||
"bread.hyprland.window.moved",
|
||||
),
|
||||
];
|
||||
|
||||
for (kind, data, legacy_event, namespaced_event) in cases {
|
||||
let ev = raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": kind, "data": data}),
|
||||
1,
|
||||
);
|
||||
let out = n.normalize(&ev);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
1,
|
||||
"kind {kind} should emit exactly 1 event when legacy names are disabled"
|
||||
);
|
||||
assert_eq!(
|
||||
out[0].event, *namespaced_event,
|
||||
"kind {kind} should emit only the namespaced event"
|
||||
);
|
||||
assert!(
|
||||
!out.iter().any(|e| &e.event == legacy_event),
|
||||
"kind {kind} leaked legacy event {legacy_event} despite being disabled"
|
||||
);
|
||||
}
|
||||
|
||||
// The already-namespaced fallback is unaffected either way.
|
||||
let fallback = n.normalize(&raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": "submap", "data": "resize"}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(fallback.len(), 1);
|
||||
assert_eq!(fallback[0].event, "bread.hyprland.event");
|
||||
}
|
||||
|
||||
/// (c) A module that subscribes only to `bread.hyprland.*` gets full
|
||||
/// coverage of workspace, monitor, and window activity — regardless of
|
||||
/// the `[compat]` setting — since the namespaced name is unconditional.
|
||||
/// This is the actual promise Workstream C makes: "portable automation"
|
||||
/// only means something if the namespaced form alone is a complete feed.
|
||||
#[test]
|
||||
fn hyprland_namespace_only_subscriber_gets_full_coverage_regardless_of_compat() {
|
||||
let kinds_and_data: &[(&str, &str)] = &[
|
||||
("workspace", "2"),
|
||||
("createworkspace", "3"),
|
||||
("destroyworkspace", "3"),
|
||||
("monitoradded", "HDMI-A-1"),
|
||||
("monitorremoved", "HDMI-A-1"),
|
||||
("activewindow", "firefox,Mozilla Firefox"),
|
||||
("activewindowv2", "0xdead"),
|
||||
("openwindow", "0xabc>>2>>firefox>>Mozilla Firefox"),
|
||||
("closewindow", "0xabc"),
|
||||
("movewindow", "0xabc,2"),
|
||||
];
|
||||
|
||||
for legacy_enabled in [true, false] {
|
||||
let n = EventNormalizer::new(0).with_legacy_hyprland_event_names(legacy_enabled);
|
||||
for (kind, data) in kinds_and_data {
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::Hyprland,
|
||||
"hypr",
|
||||
json!({"kind": kind, "data": data}),
|
||||
1,
|
||||
));
|
||||
let namespaced_hits = out
|
||||
.iter()
|
||||
.filter(|e| e.event.starts_with("bread.hyprland."))
|
||||
.count();
|
||||
assert_eq!(
|
||||
namespaced_hits, 1,
|
||||
"kind {kind} (legacy_enabled={legacy_enabled}) should always emit exactly \
|
||||
one bread.hyprland.* event, matching a bread.hyprland.* subscriber's view"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Power ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -1053,9 +1453,14 @@ mod tests {
|
|||
json!({"kind": "workspace", "data": "2"}),
|
||||
1100,
|
||||
);
|
||||
assert_eq!(n.normalize(&a).len(), 1);
|
||||
// Hyprland's "workspace" kind dual-emits (legacy + namespaced name,
|
||||
// see the Hyprland test section below), so each distinct payload
|
||||
// produces 2 events rather than 1 — but the point of this test is
|
||||
// that neither call is suppressed by dedup, since the payloads
|
||||
// differ and thus so does the dedup key.
|
||||
assert_eq!(n.normalize(&a).len(), 2);
|
||||
// Different payloads = different dedup key
|
||||
assert_eq!(n.normalize(&b).len(), 1);
|
||||
assert_eq!(n.normalize(&b).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1075,6 +1480,65 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── App / command bus ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn app_own_namespace_passes_through() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::App("clip".into()),
|
||||
"bread.clip.copied",
|
||||
json!({"len": 4}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.clip.copied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_command_to_another_known_app_passes_through() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::App("cast".into()),
|
||||
"bread.command.clip.clear",
|
||||
json!({}),
|
||||
1,
|
||||
));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].event, "bread.command.clip.clear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_wrong_namespace_is_dropped() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let out = n.normalize(&raw(
|
||||
AdapterSource::App("cast".into()),
|
||||
"bread.clip.copied",
|
||||
json!({}),
|
||||
1,
|
||||
));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_command_to_unknown_or_reserved_target_is_dropped() {
|
||||
let n = EventNormalizer::new(0);
|
||||
let power = n.normalize(&raw(
|
||||
AdapterSource::App("cast".into()),
|
||||
"bread.command.power.off",
|
||||
json!({}),
|
||||
1,
|
||||
));
|
||||
assert!(power.is_empty());
|
||||
let spoof = n.normalize(&raw(
|
||||
AdapterSource::App("cast".into()),
|
||||
"bread.hyprland.workspace.changed",
|
||||
json!({}),
|
||||
1,
|
||||
));
|
||||
assert!(spoof.is_empty());
|
||||
}
|
||||
|
||||
// ─── Helper ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
410
breadd/src/core/rules.rs
Normal file
410
breadd/src/core/rules.rs
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
//! `rules.toml` — a declarative shortcut for the common "when event X
|
||||
//! happens, do Y" case that otherwise requires hand-written Lua
|
||||
//! (`bread.on(...)`, `bread.exec(...)`, etc).
|
||||
//!
|
||||
//! This module owns TOML parsing and validation only; it has no `mlua`
|
||||
//! dependency so it can be unit-tested in isolation. The `bread.rules`
|
||||
//! built-in Lua module (`breadd/src/lua/mod.rs`, `BUILTIN_RULES`) is the
|
||||
//! other half — it takes the [`ParsedRule`]s this module produces and turns
|
||||
//! each one into a real `bread.on()` subscription.
|
||||
//!
|
||||
//! Schema:
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[rule]]
|
||||
//! on = "device.dock.connected" # matched against "bread." .. on, wildcards allowed
|
||||
//! run = "~/.config/bread/scripts/dock-connected.sh"
|
||||
//!
|
||||
//! [[rule]]
|
||||
//! on = "power.ac.disconnected"
|
||||
//! notify = "Unplugged"
|
||||
//!
|
||||
//! [[rule]]
|
||||
//! on = "device.keyboard.connected"
|
||||
//! exec = "xset r rate 200 40"
|
||||
//! ```
|
||||
//!
|
||||
//! Exactly one of `run` / `notify` / `exec` must be set per rule. `run` and
|
||||
//! `exec` both ultimately shell out via `bread.exec()`, but with distinct
|
||||
//! semantics documented on [`RuleAction`].
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RulesFile {
|
||||
#[serde(default, rename = "rule")]
|
||||
rule: Vec<RawRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RawRule {
|
||||
on: Option<String>,
|
||||
run: Option<String>,
|
||||
notify: Option<String>,
|
||||
exec: Option<String>,
|
||||
}
|
||||
|
||||
/// The action a validated rule fires when its event matches.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RuleAction {
|
||||
/// Path to a single script/executable. Treated as exactly one program —
|
||||
/// tilde-expanded and shell-quoted as a whole before being handed to
|
||||
/// `bread.exec()`, so a path containing spaces still runs as one file
|
||||
/// rather than being word-split into a command plus arguments.
|
||||
Run(String),
|
||||
/// A raw shell command line, passed to `bread.exec()` verbatim (same as
|
||||
/// calling `bread.exec()` from hand-written Lua) — you're responsible
|
||||
/// for quoting/escaping exactly as if you'd typed it in a shell.
|
||||
Exec(String),
|
||||
/// A desktop notification message, passed to `bread.notify()`.
|
||||
Notify(String),
|
||||
}
|
||||
|
||||
/// A `[[rule]]` entry that passed validation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedRule {
|
||||
/// Event-name suffix, e.g. `"device.dock.connected"`. The real
|
||||
/// subscription is `"bread." .. on` — wildcards (`*`, `**`, `?`) are
|
||||
/// whatever `bread.on()` itself supports, since this is handed straight
|
||||
/// through.
|
||||
pub on: String,
|
||||
pub action: RuleAction,
|
||||
}
|
||||
|
||||
/// A `[[rule]]` entry that failed validation — reported, not silently
|
||||
/// dropped, so it shows up via `bread doctor` the same way a broken Lua
|
||||
/// module would.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuleIssue {
|
||||
/// Zero-based position of the `[[rule]]` table in the file.
|
||||
pub index: usize,
|
||||
/// The rule's `on` value, if it had one (helps identify *which* rule in
|
||||
/// a large file when `on` itself isn't the problem).
|
||||
pub on: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuleIssue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match &self.on {
|
||||
Some(on) => write!(f, "rule #{} (on = \"{}\"): {}", self.index, on, self.message),
|
||||
None => write!(f, "rule #{}: {}", self.index, self.message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of attempting to load `rules.toml`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RulesLoadOutcome {
|
||||
/// The file doesn't exist. Not an error — `rules.toml` is entirely
|
||||
/// optional and purely additive alongside `init.lua`.
|
||||
Absent,
|
||||
/// The file exists but couldn't be read, or doesn't parse as TOML at
|
||||
/// all — no rules could be recovered from it.
|
||||
Fatal(String),
|
||||
/// The file parsed as TOML. `rules` are the entries that passed
|
||||
/// validation (register these); `issues` describes any `[[rule]]`
|
||||
/// entries that didn't (report these, but they don't block the rest of
|
||||
/// the file from working).
|
||||
Loaded {
|
||||
rules: Vec<ParsedRule>,
|
||||
issues: Vec<RuleIssue>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Location of `rules.toml` — re-exported from `core::config` (single
|
||||
/// source of truth, colocated with `config_path()`'s identical
|
||||
/// `XDG_CONFIG_HOME`-vs-`HOME` resolution for `breadd.toml`) so callers that
|
||||
/// only care about rules loading can reach it as `rules::rules_path()`.
|
||||
pub use crate::core::config::rules_path;
|
||||
|
||||
/// Reads and validates `rules.toml` at `path`. Never panics — every failure
|
||||
/// mode (missing file, unreadable file, invalid TOML, invalid individual
|
||||
/// rules) is represented in the returned [`RulesLoadOutcome`].
|
||||
pub fn load_rules(path: &Path) -> RulesLoadOutcome {
|
||||
if !path.exists() {
|
||||
return RulesLoadOutcome::Absent;
|
||||
}
|
||||
|
||||
let raw = match std::fs::read_to_string(path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return RulesLoadOutcome::Fatal(format!("failed to read rules.toml: {e}")),
|
||||
};
|
||||
|
||||
let parsed: RulesFile = match toml::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return RulesLoadOutcome::Fatal(format!("failed to parse rules.toml: {e}")),
|
||||
};
|
||||
|
||||
let mut rules = Vec::new();
|
||||
let mut issues = Vec::new();
|
||||
for (index, raw_rule) in parsed.rule.into_iter().enumerate() {
|
||||
match validate_rule(index, raw_rule) {
|
||||
Ok(rule) => rules.push(rule),
|
||||
Err(issue) => issues.push(issue),
|
||||
}
|
||||
}
|
||||
|
||||
RulesLoadOutcome::Loaded { rules, issues }
|
||||
}
|
||||
|
||||
fn validate_rule(index: usize, raw: RawRule) -> Result<ParsedRule, RuleIssue> {
|
||||
let on = raw.on.filter(|s| !s.trim().is_empty());
|
||||
|
||||
let mut present = Vec::new();
|
||||
if raw.run.is_some() {
|
||||
present.push("run");
|
||||
}
|
||||
if raw.notify.is_some() {
|
||||
present.push("notify");
|
||||
}
|
||||
if raw.exec.is_some() {
|
||||
present.push("exec");
|
||||
}
|
||||
|
||||
let Some(on) = on else {
|
||||
return Err(RuleIssue {
|
||||
index,
|
||||
on: None,
|
||||
message: "missing or empty `on`".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
if present.is_empty() {
|
||||
return Err(RuleIssue {
|
||||
index,
|
||||
on: Some(on),
|
||||
message: "must set exactly one of `run`, `notify`, `exec` (none set)".to_string(),
|
||||
});
|
||||
}
|
||||
if present.len() > 1 {
|
||||
return Err(RuleIssue {
|
||||
index,
|
||||
on: Some(on),
|
||||
message: format!(
|
||||
"must set exactly one of `run`, `notify`, `exec` (found: {})",
|
||||
present.join(", ")
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let action = if let Some(v) = raw.run {
|
||||
RuleAction::Run(v)
|
||||
} else if let Some(v) = raw.notify {
|
||||
RuleAction::Notify(v)
|
||||
} else {
|
||||
RuleAction::Exec(raw.exec.expect("exactly one action present"))
|
||||
};
|
||||
|
||||
Ok(ParsedRule { on, action })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn write_temp(contents: &str) -> (tempfile::TempDir, PathBuf) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("rules.toml");
|
||||
let mut f = std::fs::File::create(&path).expect("create rules.toml");
|
||||
f.write_all(contents.as_bytes()).expect("write rules.toml");
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_file_is_not_an_error() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("does-not-exist.toml");
|
||||
assert_eq!(load_rules(&path), RulesLoadOutcome::Absent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_toml_is_fatal() {
|
||||
let (_dir, path) = write_temp("[[rule\nbroken");
|
||||
match load_rules(&path) {
|
||||
RulesLoadOutcome::Fatal(msg) => assert!(msg.contains("failed to parse")),
|
||||
other => panic!("expected Fatal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_loads_with_no_rules() {
|
||||
let (_dir, path) = write_temp("");
|
||||
assert_eq!(
|
||||
load_rules(&path),
|
||||
RulesLoadOutcome::Loaded {
|
||||
rules: vec![],
|
||||
issues: vec![],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_rules_all_three_action_kinds_parse() {
|
||||
let (_dir, path) = write_temp(
|
||||
r#"
|
||||
[[rule]]
|
||||
on = "device.dock.connected"
|
||||
run = "~/.config/bread/scripts/dock-connected.sh"
|
||||
|
||||
[[rule]]
|
||||
on = "power.ac.disconnected"
|
||||
notify = "Unplugged"
|
||||
|
||||
[[rule]]
|
||||
on = "device.keyboard.connected"
|
||||
exec = "xset r rate 200 40"
|
||||
"#,
|
||||
);
|
||||
let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else {
|
||||
panic!("expected Loaded");
|
||||
};
|
||||
assert!(issues.is_empty());
|
||||
assert_eq!(
|
||||
rules,
|
||||
vec![
|
||||
ParsedRule {
|
||||
on: "device.dock.connected".to_string(),
|
||||
action: RuleAction::Run(
|
||||
"~/.config/bread/scripts/dock-connected.sh".to_string()
|
||||
),
|
||||
},
|
||||
ParsedRule {
|
||||
on: "power.ac.disconnected".to_string(),
|
||||
action: RuleAction::Notify("Unplugged".to_string()),
|
||||
},
|
||||
ParsedRule {
|
||||
on: "device.keyboard.connected".to_string(),
|
||||
action: RuleAction::Exec("xset r rate 200 40".to_string()),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_on_is_passed_through_unvalidated() {
|
||||
let (_dir, path) = write_temp(
|
||||
r#"
|
||||
[[rule]]
|
||||
on = "device.*.connected"
|
||||
exec = "true"
|
||||
"#,
|
||||
);
|
||||
let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else {
|
||||
panic!("expected Loaded");
|
||||
};
|
||||
assert!(issues.is_empty());
|
||||
assert_eq!(rules[0].on, "device.*.connected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_on_is_reported_with_index_and_no_on() {
|
||||
let (_dir, path) = write_temp(
|
||||
r#"
|
||||
[[rule]]
|
||||
exec = "true"
|
||||
"#,
|
||||
);
|
||||
let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else {
|
||||
panic!("expected Loaded");
|
||||
};
|
||||
assert!(rules.is_empty());
|
||||
assert_eq!(issues.len(), 1);
|
||||
assert_eq!(issues[0].index, 0);
|
||||
assert_eq!(issues[0].on, None);
|
||||
assert!(issues[0].message.contains("missing or empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_on_is_treated_as_missing() {
|
||||
let (_dir, path) = write_temp(
|
||||
r#"
|
||||
[[rule]]
|
||||
on = " "
|
||||
exec = "true"
|
||||
"#,
|
||||
);
|
||||
let RulesLoadOutcome::Loaded { issues, .. } = load_rules(&path) else {
|
||||
panic!("expected Loaded");
|
||||
};
|
||||
assert_eq!(issues.len(), 1);
|
||||
assert!(issues[0].message.contains("missing or empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_action_keys_is_reported() {
|
||||
let (_dir, path) = write_temp(
|
||||
r#"
|
||||
[[rule]]
|
||||
on = "power.ac.disconnected"
|
||||
"#,
|
||||
);
|
||||
let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else {
|
||||
panic!("expected Loaded");
|
||||
};
|
||||
assert!(rules.is_empty());
|
||||
assert_eq!(issues.len(), 1);
|
||||
assert_eq!(issues[0].on.as_deref(), Some("power.ac.disconnected"));
|
||||
assert!(issues[0].message.contains("none set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_action_keys_is_reported() {
|
||||
let (_dir, path) = write_temp(
|
||||
r#"
|
||||
[[rule]]
|
||||
on = "power.ac.disconnected"
|
||||
notify = "Unplugged"
|
||||
exec = "true"
|
||||
"#,
|
||||
);
|
||||
let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else {
|
||||
panic!("expected Loaded");
|
||||
};
|
||||
assert!(rules.is_empty());
|
||||
assert_eq!(issues.len(), 1);
|
||||
assert!(issues[0].message.contains("notify"));
|
||||
assert!(issues[0].message.contains("exec"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bad_rule_does_not_block_other_valid_rules() {
|
||||
let (_dir, path) = write_temp(
|
||||
r#"
|
||||
[[rule]]
|
||||
on = "device.dock.connected"
|
||||
exec = "true"
|
||||
|
||||
[[rule]]
|
||||
notify = "no on here"
|
||||
|
||||
[[rule]]
|
||||
on = "power.ac.disconnected"
|
||||
notify = "Unplugged"
|
||||
"#,
|
||||
);
|
||||
let RulesLoadOutcome::Loaded { rules, issues } = load_rules(&path) else {
|
||||
panic!("expected Loaded");
|
||||
};
|
||||
assert_eq!(rules.len(), 2);
|
||||
assert_eq!(issues.len(), 1);
|
||||
assert_eq!(issues[0].index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_issue_display_includes_index_and_on() {
|
||||
let issue = RuleIssue {
|
||||
index: 2,
|
||||
on: Some("power.ac.disconnected".to_string()),
|
||||
message: "must set exactly one of `run`, `notify`, `exec` (none set)".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
issue.to_string(),
|
||||
"rule #2 (on = \"power.ac.disconnected\"): must set exactly one of `run`, `notify`, `exec` (none set)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -38,11 +38,13 @@ pub enum StateCommand {
|
|||
},
|
||||
ClearSubscriptions,
|
||||
ClearModules,
|
||||
ClearWidgets,
|
||||
SetModuleStatus {
|
||||
name: String,
|
||||
status: ModuleLoadState,
|
||||
last_error: Option<String>,
|
||||
builtin: bool,
|
||||
ungated: bool,
|
||||
},
|
||||
SetProfile {
|
||||
name: String,
|
||||
|
|
@ -117,18 +119,41 @@ impl StateHandle {
|
|||
let _ = self.command_tx.send(StateCommand::ClearModules);
|
||||
}
|
||||
|
||||
pub fn clear_widgets(&self) {
|
||||
let _ = self.command_tx.send(StateCommand::ClearWidgets);
|
||||
}
|
||||
|
||||
pub fn set_module_status(
|
||||
&self,
|
||||
name: String,
|
||||
status: ModuleLoadState,
|
||||
last_error: Option<String>,
|
||||
builtin: bool,
|
||||
) {
|
||||
self.set_module_status_ex(name, status, last_error, builtin, false);
|
||||
}
|
||||
|
||||
/// Same as [`set_module_status`](Self::set_module_status) but also
|
||||
/// records whether the module is running with full, ungated `bread.*`
|
||||
/// access (no `permissions` declared in its manifest). Kept as a
|
||||
/// separate method rather than changing `set_module_status`'s signature
|
||||
/// everywhere so call sites that don't yet know the answer (load
|
||||
/// errors, disabled modules, etc.) don't have to thread a meaningless
|
||||
/// value through.
|
||||
pub fn set_module_status_ex(
|
||||
&self,
|
||||
name: String,
|
||||
status: ModuleLoadState,
|
||||
last_error: Option<String>,
|
||||
builtin: bool,
|
||||
ungated: bool,
|
||||
) {
|
||||
let _ = self.command_tx.send(StateCommand::SetModuleStatus {
|
||||
name,
|
||||
status,
|
||||
last_error,
|
||||
builtin,
|
||||
ungated,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +260,7 @@ pub async fn run_state_engine(
|
|||
}
|
||||
|
||||
if let (Some(before), Some(after)) = (before_snapshot, after_snapshot) {
|
||||
for (_id, path) in watches.iter() {
|
||||
for path in watches.values() {
|
||||
let old_val = value_at_path(&before, path).unwrap_or(Value::Null);
|
||||
let new_val = value_at_path(&after, path).unwrap_or(Value::Null);
|
||||
if old_val != new_val {
|
||||
|
|
@ -290,23 +315,29 @@ async fn handle_command(
|
|||
StateCommand::ClearModules => {
|
||||
state.write().await.modules.clear();
|
||||
}
|
||||
StateCommand::ClearWidgets => {
|
||||
state.write().await.widgets.clear();
|
||||
}
|
||||
StateCommand::SetModuleStatus {
|
||||
name,
|
||||
status,
|
||||
last_error,
|
||||
builtin,
|
||||
ungated,
|
||||
} => {
|
||||
let mut guard = state.write().await;
|
||||
if let Some(existing) = guard.modules.iter_mut().find(|m| m.name == name) {
|
||||
existing.status = status;
|
||||
existing.last_error = last_error;
|
||||
existing.builtin = builtin;
|
||||
existing.ungated = ungated;
|
||||
} else {
|
||||
guard.modules.push(crate::core::types::ModuleStatus {
|
||||
name,
|
||||
status,
|
||||
last_error,
|
||||
builtin,
|
||||
ungated,
|
||||
store: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
|
@ -364,55 +395,180 @@ fn value_at_path(value: &Value, path: &str) -> Option<Value> {
|
|||
Some(current.clone())
|
||||
}
|
||||
|
||||
/// Logical Hyprland state key: both the legacy flat name and its
|
||||
/// `bread.hyprland.*` sibling collapse to the same suffix
|
||||
/// (`monitor.connected`, `workspace.changed`, …).
|
||||
fn hyprland_state_key(event: &str) -> Option<&str> {
|
||||
if let Some(rest) = event.strip_prefix("bread.hyprland.") {
|
||||
return Some(rest);
|
||||
}
|
||||
match event {
|
||||
"bread.monitor.connected" => Some("monitor.connected"),
|
||||
"bread.monitor.disconnected" => Some("monitor.disconnected"),
|
||||
"bread.workspace.changed" => Some("workspace.changed"),
|
||||
"bread.workspace.created" => Some("workspace.created"),
|
||||
"bread.workspace.destroyed" => Some("workspace.destroyed"),
|
||||
"bread.window.focus.changed" => Some("window.focus.changed"),
|
||||
"bread.window.focused" => Some("window.focused"),
|
||||
"bread.window.opened" => Some("window.opened"),
|
||||
"bread.window.closed" => Some("window.closed"),
|
||||
"bread.window.moved" => Some("window.moved"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn json_stringish(value: Option<&Value>) -> Option<String> {
|
||||
let value = value?;
|
||||
if let Some(s) = value.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
value.as_i64().map(|n| n.to_string())
|
||||
}
|
||||
|
||||
fn workspace_id_from_data(data: &Value) -> Option<String> {
|
||||
json_stringish(data.get("workspace"))
|
||||
.or_else(|| json_stringish(data.get("id")))
|
||||
.or_else(|| json_stringish(data.get("name")))
|
||||
.or_else(|| {
|
||||
data.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn active_window_from_data(data: &Value) -> Option<String> {
|
||||
json_stringish(data.get("window"))
|
||||
.or_else(|| json_stringish(data.get("class")))
|
||||
.or_else(|| json_stringish(data.get("address")))
|
||||
.or_else(|| {
|
||||
data.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_monitor(state: &mut RuntimeState, data: &Value) {
|
||||
let Some(name) = data.get("name").and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
|
||||
m.connected = true;
|
||||
if let Some(res) = data.get("resolution").and_then(Value::as_str) {
|
||||
m.resolution = Some(res.to_string());
|
||||
}
|
||||
if let Some(pos) = data.get("position").and_then(Value::as_str) {
|
||||
m.position = Some(pos.to_string());
|
||||
}
|
||||
} else {
|
||||
state.monitors.push(crate::core::types::Monitor {
|
||||
name: name.to_string(),
|
||||
connected: true,
|
||||
resolution: data
|
||||
.get("resolution")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string),
|
||||
position: data
|
||||
.get("position")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_hyprland_snapshot(state: &mut RuntimeState, data: &Value) {
|
||||
if let Some(arr) = data.get("monitors").and_then(Value::as_array) {
|
||||
state.monitors = arr
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let name = m.get("name").and_then(Value::as_str)?;
|
||||
let width = m.get("width").and_then(Value::as_u64);
|
||||
let height = m.get("height").and_then(Value::as_u64);
|
||||
let x = m.get("x").and_then(Value::as_i64);
|
||||
let y = m.get("y").and_then(Value::as_i64);
|
||||
let disabled = m.get("disabled").and_then(Value::as_bool).unwrap_or(false);
|
||||
Some(crate::core::types::Monitor {
|
||||
name: name.to_string(),
|
||||
connected: !disabled,
|
||||
resolution: match (width, height) {
|
||||
(Some(w), Some(h)) => Some(format!("{w}x{h}")),
|
||||
_ => None,
|
||||
},
|
||||
position: match (x, y) {
|
||||
(Some(x), Some(y)) => Some(format!("{x}x{y}")),
|
||||
_ => None,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Some(arr) = data.get("workspaces").and_then(Value::as_array) {
|
||||
state.workspaces = arr
|
||||
.iter()
|
||||
.filter_map(|ws| {
|
||||
let id = json_stringish(ws.get("id")).or_else(|| json_stringish(ws.get("name")))?;
|
||||
Some(crate::core::types::Workspace {
|
||||
id,
|
||||
monitor: json_stringish(ws.get("monitor")),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Some(aw) = data.get("active_workspace") {
|
||||
state.active_workspace =
|
||||
json_stringish(aw.get("name")).or_else(|| json_stringish(aw.get("id")));
|
||||
}
|
||||
if let Some(win) = data.get("active_window") {
|
||||
state.active_window = json_stringish(win.get("address"))
|
||||
.or_else(|| json_stringish(win.get("class")))
|
||||
.or_else(|| json_stringish(win.get("title")));
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_event_to_state(state: &mut RuntimeState, event: &BreadEvent) {
|
||||
if event.event == "bread.hyprland.snapshot" {
|
||||
apply_hyprland_snapshot(state, &event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(key) = hyprland_state_key(event.event.as_str()) {
|
||||
match key {
|
||||
"monitor.connected" => upsert_monitor(state, &event.data),
|
||||
"monitor.disconnected" => {
|
||||
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
|
||||
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
|
||||
m.connected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
"workspace.changed" => {
|
||||
state.active_workspace = workspace_id_from_data(&event.data);
|
||||
}
|
||||
"workspace.created" => {
|
||||
if let Some(id) = workspace_id_from_data(&event.data) {
|
||||
if !state.workspaces.iter().any(|w| w.id == id) {
|
||||
state.workspaces.push(crate::core::types::Workspace {
|
||||
id,
|
||||
monitor: json_stringish(event.data.get("monitor")),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"workspace.destroyed" => {
|
||||
if let Some(id) = workspace_id_from_data(&event.data) {
|
||||
state.workspaces.retain(|w| w.id != id);
|
||||
}
|
||||
}
|
||||
"window.focus.changed" | "window.focused" => {
|
||||
state.active_window = active_window_from_data(&event.data);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match event.event.as_str() {
|
||||
"bread.monitor.connected" => {
|
||||
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
|
||||
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
|
||||
m.connected = true;
|
||||
} else {
|
||||
state.monitors.push(crate::core::types::Monitor {
|
||||
name: name.to_string(),
|
||||
connected: true,
|
||||
resolution: event
|
||||
.data
|
||||
.get("resolution")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string),
|
||||
position: event
|
||||
.data
|
||||
.get("position")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"bread.monitor.disconnected" => {
|
||||
if let Some(name) = event.data.get("name").and_then(Value::as_str) {
|
||||
if let Some(m) = state.monitors.iter_mut().find(|m| m.name == name) {
|
||||
m.connected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
"bread.workspace.changed" => {
|
||||
let ws = event
|
||||
.data
|
||||
.get("workspace")
|
||||
.or_else(|| event.data.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
state.active_workspace = ws;
|
||||
}
|
||||
"bread.window.focus.changed" | "bread.window.focused" => {
|
||||
state.active_window = event
|
||||
.data
|
||||
.get("window")
|
||||
.or_else(|| event.data.get("class"))
|
||||
.or_else(|| event.data.get("address"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
}
|
||||
"bread.device.connected" => {
|
||||
apply_device_change(state, &event.data, true);
|
||||
}
|
||||
|
|
@ -648,6 +804,8 @@ mod tests {
|
|||
timestamp: 0,
|
||||
source: AdapterSource::System,
|
||||
data,
|
||||
id: bread_shared::new_event_id(),
|
||||
caused_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -754,6 +912,67 @@ mod tests {
|
|||
assert_eq!(state.active_window.as_deref(), Some("0xdeadbeef"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespaced_hyprland_events_update_the_same_state() {
|
||||
let mut state = RuntimeState::default();
|
||||
apply_event_to_state(
|
||||
&mut state,
|
||||
&ev(
|
||||
"bread.hyprland.monitor.connected",
|
||||
json!({"name": "HDMI-A-1"}),
|
||||
),
|
||||
);
|
||||
apply_event_to_state(
|
||||
&mut state,
|
||||
&ev("bread.hyprland.workspace.changed", json!({"id": 4})),
|
||||
);
|
||||
apply_event_to_state(
|
||||
&mut state,
|
||||
&ev(
|
||||
"bread.hyprland.window.focus.changed",
|
||||
json!({"kind": "activewindow", "data": "kitty,foo"}),
|
||||
),
|
||||
);
|
||||
apply_event_to_state(
|
||||
&mut state,
|
||||
&ev("bread.hyprland.workspace.created", json!({"workspace": "9"})),
|
||||
);
|
||||
assert_eq!(state.monitors.len(), 1);
|
||||
assert_eq!(state.monitors[0].name, "HDMI-A-1");
|
||||
assert_eq!(state.active_workspace.as_deref(), Some("4"));
|
||||
assert_eq!(state.active_window.as_deref(), Some("kitty"));
|
||||
assert_eq!(state.workspaces.len(), 1);
|
||||
assert_eq!(state.workspaces[0].id, "9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyprland_snapshot_replaces_topology() {
|
||||
let mut state = RuntimeState::default();
|
||||
apply_event_to_state(
|
||||
&mut state,
|
||||
&ev("bread.monitor.connected", json!({"name": "stale"})),
|
||||
);
|
||||
apply_event_to_state(
|
||||
&mut state,
|
||||
&ev(
|
||||
"bread.hyprland.snapshot",
|
||||
json!({
|
||||
"monitors": [{"name": "eDP-1", "width": 1920, "height": 1200, "x": 0, "y": 0}],
|
||||
"workspaces": [{"id": 1, "name": "1", "monitor": "eDP-1"}],
|
||||
"active_workspace": {"id": 1, "name": "1"},
|
||||
"active_window": {"address": "0xabc", "class": "kitty"}
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert_eq!(state.monitors.len(), 1);
|
||||
assert_eq!(state.monitors[0].name, "eDP-1");
|
||||
assert_eq!(state.monitors[0].resolution.as_deref(), Some("1920x1200"));
|
||||
assert_eq!(state.workspaces.len(), 1);
|
||||
assert_eq!(state.workspaces[0].id, "1");
|
||||
assert_eq!(state.active_workspace.as_deref(), Some("1"));
|
||||
assert_eq!(state.active_window.as_deref(), Some("0xabc"));
|
||||
}
|
||||
|
||||
// ─── apply_device_change ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use bread_shared::widget::WidgetSpec;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
|
|
@ -15,6 +16,10 @@ pub struct RuntimeState {
|
|||
pub profile: ProfileState,
|
||||
pub modules: Vec<ModuleStatus>,
|
||||
pub workflows: Vec<WorkflowStatus>,
|
||||
/// Widgets registered via `bread.widget.register` from any Lua module,
|
||||
/// keyed implicitly by `WidgetSpec.id` (fully-qualified `<module>.<local_id>`).
|
||||
/// Surfaced via the `widgets.list` IPC method for breadbar to render.
|
||||
pub widgets: Vec<WidgetSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -118,6 +123,15 @@ pub struct ModuleStatus {
|
|||
pub builtin: bool,
|
||||
#[serde(default)]
|
||||
pub store: HashMap<String, Value>,
|
||||
/// `true` when this is a third-party module running with full, ungated
|
||||
/// `bread.*` access because its `bread.module.toml` declares no
|
||||
/// `permissions` at all (or the module has no manifest on disk). Always
|
||||
/// `false` for builtin modules, which are never subject to capability
|
||||
/// scoping in the first place — see the "Capability-scoped modules"
|
||||
/// section of `Documentation.md`. `bread doctor` surfaces this as a
|
||||
/// warning so an ungated module doesn't stay invisible forever.
|
||||
#[serde(default)]
|
||||
pub ungated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ use std::sync::Arc;
|
|||
use std::time::Instant;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use bread_shared::apps::{is_known_app, validate_app_namespace};
|
||||
use bread_shared::apps::{
|
||||
event_domain, is_known_app, is_reserved_domain, validate_app_namespace, validate_command_event,
|
||||
};
|
||||
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent, RawEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
|
@ -20,6 +22,9 @@ use tracing::{error, info, warn};
|
|||
use crate::adapters::AdapterStatus;
|
||||
use crate::core::state_engine::StateHandle;
|
||||
use crate::lua::RuntimeHandle;
|
||||
use crate::module_host::ModuleHostRegistry;
|
||||
|
||||
mod module_host_bridge;
|
||||
|
||||
/// The Bread Automation API version (Lua API surface + IPC methods + event
|
||||
/// vocabulary + runtime-state schema), per `Documentation.md`'s "API
|
||||
|
|
@ -27,7 +32,16 @@ use crate::lua::RuntimeHandle;
|
|||
/// something new-but-additive (a binding, an event, an IPC param); bump the
|
||||
/// major version only for a breaking change, which should not happen inside
|
||||
/// this daemon's v1 lifetime per that section's stated policy.
|
||||
const API_VERSION: &str = "1.2.0";
|
||||
///
|
||||
/// *Since 1.6.0* — Workstream G's `module_host.*` methods (hello handshake
|
||||
/// plus the RPC bridge a `bread-module-host` child uses in place of direct
|
||||
/// in-process `bread.*` bindings).
|
||||
/// *Since 1.7.0* — well-formed `bread.command.<known-app>.<verb>` is an
|
||||
/// explicit exception to the reserved-domain reject on unsourced emit, and
|
||||
/// sourced `AdapterSource::App` emit may publish commands to another known
|
||||
/// app. `command` stays in `RESERVED_DOMAINS` so it cannot be claimed as
|
||||
/// an app id.
|
||||
const API_VERSION: &str = "1.7.1";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Server {
|
||||
|
|
@ -42,6 +56,11 @@ pub struct Server {
|
|||
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
|
||||
started_at: Instant,
|
||||
pid: u32,
|
||||
/// Workstream G: token/identity bookkeeping for out-of-process module
|
||||
/// hosts, shared with the Lua engine (which spawns them). See
|
||||
/// `crate::module_host` and `module_host_bridge` (this module's
|
||||
/// `module_host.*` method handling).
|
||||
module_host_registry: ModuleHostRegistry,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -62,7 +81,7 @@ struct IpcResponse {
|
|||
}
|
||||
|
||||
impl Server {
|
||||
// Server::new legitimately requires all 8 fields; a builder pattern here would be
|
||||
// Server::new legitimately requires all 10 fields; a builder pattern here would be
|
||||
// over-engineering for a single-call-site constructor.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
|
|
@ -75,6 +94,7 @@ impl Server {
|
|||
adapter_status: Arc<RwLock<HashMap<String, AdapterStatus>>>,
|
||||
subscription_count: Arc<AtomicU64>,
|
||||
event_buffer: Arc<std::sync::Mutex<VecDeque<BreadEvent>>>,
|
||||
module_host_registry: ModuleHostRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
socket_path,
|
||||
|
|
@ -84,6 +104,7 @@ impl Server {
|
|||
emit_tx,
|
||||
raw_tx,
|
||||
adapter_status,
|
||||
module_host_registry,
|
||||
subscription_count,
|
||||
event_buffer,
|
||||
started_at: Instant::now(),
|
||||
|
|
@ -176,6 +197,20 @@ impl Server {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// Workstream G: a `bread-module-host` child's very first message
|
||||
// presents its one-time spawn token. From here on this
|
||||
// connection is a dedicated, bidirectional module-host bridge
|
||||
// (RPC requests interleaved with async event/timer pushes) —
|
||||
// see `module_host_bridge::handle_module_host_connection` —
|
||||
// rather than a one-shot request/response exchange, so it takes
|
||||
// over the rest of this connection's lifetime exactly like
|
||||
// `events.subscribe` above does for a plain event stream.
|
||||
if req.method == "module_host.hello" {
|
||||
return self
|
||||
.handle_module_host_connection(req, lines, write_half)
|
||||
.await;
|
||||
}
|
||||
|
||||
let response = match self.handle_request(req).await {
|
||||
Ok(res) => IpcResponse {
|
||||
id: res.0,
|
||||
|
|
@ -222,6 +257,10 @@ impl Server {
|
|||
let full = self.state_handle.state_dump().await;
|
||||
Ok(full.get("workflows").cloned().unwrap_or_else(|| json!([])))
|
||||
}
|
||||
"widgets.list" => {
|
||||
let full = self.state_handle.state_dump().await;
|
||||
Ok(full.get("widgets").cloned().unwrap_or_else(|| json!([])))
|
||||
}
|
||||
"modules.reload" => {
|
||||
let started = Instant::now();
|
||||
if let Err(err) = self.lua_runtime.reload().await {
|
||||
|
|
@ -293,13 +332,17 @@ impl Server {
|
|||
};
|
||||
// For a sibling-app source, `kind` is the full dotted event
|
||||
// name (e.g. "bread.clip.copied"), not a bare suffix — it
|
||||
// must live inside that app's own namespace.
|
||||
// must live inside that app's own namespace. Well-formed
|
||||
// `bread.command.<known-app>.<verb>` is the one exception:
|
||||
// an app may publish a command addressed to another known
|
||||
// app (see `validate_command_event`). Adapter namespaces
|
||||
// (`bread.power.*`, `bread.hyprland.*`, ...) stay rejected.
|
||||
if let AdapterSource::App(app) = &source {
|
||||
if !validate_app_namespace(app, kind) {
|
||||
if !validate_app_namespace(app, kind) && !validate_command_event(kind) {
|
||||
return Err((
|
||||
id,
|
||||
format!(
|
||||
"event '{kind}' is not in the '{app}' namespace (must start with 'bread.{app}.')"
|
||||
"event '{kind}' is not in the '{app}' namespace (must start with 'bread.{app}.') and is not a well-formed command event"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
|
@ -319,17 +362,27 @@ impl Server {
|
|||
}
|
||||
Ok(json!({ "emitted": true }))
|
||||
} else {
|
||||
// Unsourced emit: the manual-testing path ("bread emit
|
||||
// <event>", used to poke Lua handlers without unplugging
|
||||
// cables). Tagged `Manual`, never `System` — `System` is
|
||||
// reserved for events the daemon originates itself in
|
||||
// Rust code (e.g. `bread.system.startup` in `serve()`
|
||||
// above), not for anything that arrived over the wire.
|
||||
// A socket client can still name any custom/test event
|
||||
// it likes, but not one whose top-level segment is a
|
||||
// reserved, adapter-owned domain (`bread.power.*`,
|
||||
// `bread.hyprland.*`, ...) — otherwise this path would
|
||||
// let any same-UID process impersonate a real adapter
|
||||
// event with nothing downstream able to tell the
|
||||
// difference. Well-formed `bread.command.<known-app>.<verb>`
|
||||
// is the documented exception: the command bus is
|
||||
// supposed to be publishable by any module or
|
||||
// `bread-emit` caller. Other reserved domains, and
|
||||
// `bread.command.<not-an-app>.*`, stay rejected.
|
||||
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
|
||||
return Err((id, "missing event name".to_string()));
|
||||
};
|
||||
if self
|
||||
.emit_tx
|
||||
.send(BreadEvent::new(event, AdapterSource::System, data))
|
||||
.is_err()
|
||||
{
|
||||
return Err((id, "emit channel closed".to_string()));
|
||||
}
|
||||
Ok(json!({ "emitted": true }))
|
||||
self.manual_emit(event, data)
|
||||
}
|
||||
}
|
||||
"health" => {
|
||||
|
|
@ -382,6 +435,31 @@ impl Server {
|
|||
}
|
||||
}
|
||||
|
||||
/// Unsourced-emit logic, factored out of `handle_request`'s `"emit"`
|
||||
/// case so `module_host_bridge`'s `module_host.emit` (Workstream G) can
|
||||
/// share the exact same reserved-domain guard rather than re-deriving
|
||||
/// it — see the original inline comment (still above the one call site
|
||||
/// in `handle_request`) for why the guard exists: a same-UID socket
|
||||
/// client (or, now, a module-host child) must not be able to
|
||||
/// impersonate a real adapter-owned event namespace.
|
||||
fn manual_emit(&self, event: &str, data: Value) -> std::result::Result<Value, String> {
|
||||
if let Some(domain) = event_domain(event) {
|
||||
if is_reserved_domain(domain) && !validate_command_event(event) {
|
||||
return Err(format!(
|
||||
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, a well-formed bread.command.<app>.<verb>, or a sourced emit if this should go through the normalizer"
|
||||
));
|
||||
}
|
||||
}
|
||||
if self
|
||||
.emit_tx
|
||||
.send(BreadEvent::new(event, AdapterSource::Manual, data))
|
||||
.is_err()
|
||||
{
|
||||
return Err("emit channel closed".to_string());
|
||||
}
|
||||
Ok(json!({ "emitted": true }))
|
||||
}
|
||||
|
||||
async fn stream_events(
|
||||
&self,
|
||||
writer: &mut tokio::net::unix::OwnedWriteHalf,
|
||||
|
|
|
|||
721
breadd/src/ipc/module_host_bridge.rs
Normal file
721
breadd/src/ipc/module_host_bridge.rs
Normal file
|
|
@ -0,0 +1,721 @@
|
|||
//! The `module_host.*` side of the IPC protocol (Workstream G): once a
|
||||
//! connection presents a valid one-time token via `module_host.hello`, this
|
||||
//! module takes over its remaining lifetime as a bidirectional RPC bridge —
|
||||
//! ordinary request/response lines interleaved with unsolicited
|
||||
//! event/timer pushes — for exactly one `bread-module-host` child.
|
||||
//!
|
||||
//! # Wire shape
|
||||
//!
|
||||
//! Requests/responses reuse the existing `IpcRequest`/`IpcResponse`
|
||||
//! envelope unchanged. Pushes are a separate, `"push"`-tagged envelope
|
||||
//! (`bread_shared::ModuleHostPush`) that never collides with a response —
|
||||
//! see that type's doc comment. A single `mpsc` channel (`out_tx`/`out_rx`)
|
||||
//! feeds one writer task so both kinds of outgoing line interleave safely
|
||||
//! on the one underlying socket without any extra locking.
|
||||
//!
|
||||
//! # Where the "belt" is, relative to the "suspenders"
|
||||
//!
|
||||
//! Every method here re-checks the module's granted `PermissionKind`s
|
||||
//! before doing anything — `fs_read`/`fs_write`/`exec`/`exec_capture`
|
||||
//! additionally check the manifest's `path`/`bin` scoping hint. This is
|
||||
//! the belt; `module_host::apply_sandbox`'s Landlock ruleset (enforced by
|
||||
//! the kernel on the child process directly, independent of whether the
|
||||
//! child even uses this RPC bridge at all) is the suspenders. A module
|
||||
//! that skips this bridge entirely and calls `os.execute`/`io.open`
|
||||
//! directly from Lua bypasses every check in this file — that's the
|
||||
//! scenario the sandbox exists for, not this file.
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use bread_shared::{glob, ModuleHostHello, ModuleHostPush, ModulePermission, PermissionKind};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncWriteExt, BufReader, Lines};
|
||||
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::types::ModuleLoadState;
|
||||
use crate::module_host::ModuleHostOutcome;
|
||||
|
||||
use super::{IpcRequest, IpcResponse, Server, API_VERSION};
|
||||
|
||||
impl Server {
|
||||
/// Authenticate a `module_host.hello` request against the pending-token
|
||||
/// registry and, on success, run this connection's dedicated
|
||||
/// request/response + push loop until it closes. Mirrors
|
||||
/// `handle_connection`'s `events.subscribe` special-case in spirit
|
||||
/// (taking over the rest of the connection's lifetime) but is
|
||||
/// bidirectional rather than one-directional.
|
||||
pub(super) async fn handle_module_host_connection(
|
||||
&self,
|
||||
hello_req: IpcRequest,
|
||||
mut lines: Lines<BufReader<OwnedReadHalf>>,
|
||||
write_half: OwnedWriteHalf,
|
||||
) -> anyhow::Result<()> {
|
||||
let hello_id = hello_req.id.clone();
|
||||
let token = hello_req
|
||||
.params
|
||||
.get("token")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
|
||||
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();
|
||||
let writer_task = tokio::spawn(async move {
|
||||
let mut write_half = write_half;
|
||||
while let Some(line) = out_rx.recv().await {
|
||||
if write_half.write_all(line.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let send = |resp: IpcResponse| -> anyhow::Result<()> {
|
||||
let line = format!("{}\n", serde_json::to_string(&resp)?);
|
||||
let _ = out_tx.send(line);
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let Some(token) = token else {
|
||||
send(IpcResponse {
|
||||
id: hello_id,
|
||||
result: None,
|
||||
error: Some("module_host.hello: missing token".to_string()),
|
||||
})?;
|
||||
drop(out_tx);
|
||||
let _ = writer_task.await;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(pending) = self.module_host_registry.take_pending(&token) else {
|
||||
send(IpcResponse {
|
||||
id: hello_id,
|
||||
result: None,
|
||||
error: Some("invalid or expired module-host token".to_string()),
|
||||
})?;
|
||||
drop(out_tx);
|
||||
let _ = writer_task.await;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let module_name = pending.module_name.clone();
|
||||
let permissions = pending.permissions.clone();
|
||||
let mut outcome_tx = Some(pending.outcome_tx);
|
||||
|
||||
let hello_result = ModuleHostHello {
|
||||
module: module_name.clone(),
|
||||
permissions: permissions.clone(),
|
||||
api_version: API_VERSION.to_string(),
|
||||
};
|
||||
send(IpcResponse {
|
||||
id: hello_id,
|
||||
result: Some(serde_json::to_value(&hello_result)?),
|
||||
error: None,
|
||||
})?;
|
||||
|
||||
info!(module = %module_name, permissions = ?permissions, "module-host authenticated");
|
||||
|
||||
let mut subs: HashMap<String, JoinHandle<()>> = HashMap::new();
|
||||
let mut timers: HashMap<String, JoinHandle<()>> = HashMap::new();
|
||||
|
||||
loop {
|
||||
let line = match lines.next_line().await {
|
||||
Ok(Some(l)) => l,
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
warn!(module = %module_name, error = %e, "module-host connection read error");
|
||||
break;
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let req: IpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
send(IpcResponse {
|
||||
id: "?".to_string(),
|
||||
result: None,
|
||||
error: Some(format!("parse error: {e}")),
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let req_id = req.id.clone();
|
||||
let result = self
|
||||
.dispatch_module_host_method(
|
||||
&req,
|
||||
&module_name,
|
||||
&permissions,
|
||||
&out_tx,
|
||||
&mut subs,
|
||||
&mut timers,
|
||||
&mut outcome_tx,
|
||||
)
|
||||
.await;
|
||||
let resp = match result {
|
||||
Ok(v) => IpcResponse {
|
||||
id: req_id,
|
||||
result: Some(v),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => IpcResponse {
|
||||
id: req_id,
|
||||
result: None,
|
||||
error: Some(e),
|
||||
},
|
||||
};
|
||||
send(resp)?;
|
||||
}
|
||||
|
||||
for (_, h) in subs.drain() {
|
||||
h.abort();
|
||||
}
|
||||
for (_, h) in timers.drain() {
|
||||
h.abort();
|
||||
}
|
||||
drop(out_tx);
|
||||
let _ = writer_task.await;
|
||||
|
||||
// The connection dropped before the module ever reported
|
||||
// load-success/load-failure (e.g. it crashed mid-`init.lua`, or
|
||||
// never got that far) — unblock whatever's still waiting in
|
||||
// `spawn_module_host` rather than leaving it to time out.
|
||||
if let Some(tx) = outcome_tx.take() {
|
||||
let _ = tx.send(ModuleHostOutcome::LoadError(format!(
|
||||
"module-host connection for '{module_name}' closed before reporting ready"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dispatch_module_host_method(
|
||||
&self,
|
||||
req: &IpcRequest,
|
||||
module_name: &str,
|
||||
permissions: &[ModulePermission],
|
||||
out_tx: &mpsc::UnboundedSender<String>,
|
||||
subs: &mut HashMap<String, JoinHandle<()>>,
|
||||
timers: &mut HashMap<String, JoinHandle<()>>,
|
||||
outcome_tx: &mut Option<std::sync::mpsc::Sender<ModuleHostOutcome>>,
|
||||
) -> std::result::Result<Value, String> {
|
||||
match req.method.as_str() {
|
||||
"module_host.on" | "module_host.once" => {
|
||||
let once = req.method == "module_host.once";
|
||||
let pattern = req
|
||||
.params
|
||||
.get("pattern")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("missing pattern")?
|
||||
.to_string();
|
||||
let sub_id = Uuid::new_v4().to_string();
|
||||
let mut rx = self.event_tx.subscribe();
|
||||
let out_tx2 = out_tx.clone();
|
||||
let sid = sub_id.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(evt) => {
|
||||
if glob::matches_pattern(&pattern, &evt.event) {
|
||||
let push = ModuleHostPush::Event {
|
||||
subscription_id: sid.clone(),
|
||||
event: evt,
|
||||
};
|
||||
let Ok(line) = serde_json::to_string(&push) else {
|
||||
continue;
|
||||
};
|
||||
if out_tx2.send(format!("{line}\n")).is_err() {
|
||||
break;
|
||||
}
|
||||
if once {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
subs.insert(sub_id.clone(), handle);
|
||||
Ok(json!({ "subscription_id": sub_id }))
|
||||
}
|
||||
"module_host.off" => {
|
||||
let id = req
|
||||
.params
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("missing id")?
|
||||
.to_string();
|
||||
if let Some(h) = subs.remove(&id) {
|
||||
h.abort();
|
||||
}
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"module_host.after" | "module_host.every" => {
|
||||
let every = req.method == "module_host.every";
|
||||
let key = if every { "interval_ms" } else { "delay_ms" };
|
||||
let ms = req
|
||||
.params
|
||||
.get(key)
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
.max(1);
|
||||
let timer_id = Uuid::new_v4().to_string();
|
||||
let out_tx2 = out_tx.clone();
|
||||
let tid = timer_id.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
if every {
|
||||
let mut iv = tokio::time::interval(Duration::from_millis(ms));
|
||||
iv.tick().await; // first tick fires immediately; consume it so the module's first callback fires after one full interval
|
||||
loop {
|
||||
iv.tick().await;
|
||||
let push = ModuleHostPush::Timer {
|
||||
timer_id: tid.clone(),
|
||||
};
|
||||
let Ok(line) = serde_json::to_string(&push) else {
|
||||
continue;
|
||||
};
|
||||
if out_tx2.send(format!("{line}\n")).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tokio::time::sleep(Duration::from_millis(ms)).await;
|
||||
let push = ModuleHostPush::Timer { timer_id: tid };
|
||||
if let Ok(line) = serde_json::to_string(&push) {
|
||||
let _ = out_tx2.send(format!("{line}\n"));
|
||||
}
|
||||
}
|
||||
});
|
||||
timers.insert(timer_id.clone(), handle);
|
||||
Ok(json!({ "timer_id": timer_id }))
|
||||
}
|
||||
"module_host.cancel" => {
|
||||
let id = req
|
||||
.params
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("missing id")?
|
||||
.to_string();
|
||||
if let Some(h) = timers.remove(&id) {
|
||||
h.abort();
|
||||
}
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"module_host.state_get" => {
|
||||
if !permissions
|
||||
.iter()
|
||||
.any(|p| p.kind == PermissionKind::StateRead)
|
||||
{
|
||||
return Err("state.read not granted to this module".to_string());
|
||||
}
|
||||
let key = req
|
||||
.params
|
||||
.get("key")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
match self.state_handle.state_get(&key).await {
|
||||
Some(v) => Ok(json!({ "value": v })),
|
||||
None => Err("state path not found".to_string()),
|
||||
}
|
||||
}
|
||||
"module_host.emit" => {
|
||||
let event = req
|
||||
.params
|
||||
.get("event")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("missing event")?
|
||||
.to_string();
|
||||
let data = req.params.get("data").cloned().unwrap_or_else(|| json!({}));
|
||||
self.manual_emit(&event, data)
|
||||
}
|
||||
"module_host.log" | "module_host.warn" | "module_host.error" => {
|
||||
let message = req
|
||||
.params
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
match req.method.as_str() {
|
||||
"module_host.log" => info!(module = %module_name, "{message}"),
|
||||
"module_host.warn" => warn!(module = %module_name, "{message}"),
|
||||
_ => error!(module = %module_name, "{message}"),
|
||||
}
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"module_host.fs_read" => {
|
||||
if !permissions.iter().any(|p| p.kind == PermissionKind::FsRead) {
|
||||
return Err("fs.read not granted to this module".to_string());
|
||||
}
|
||||
let path = req
|
||||
.params
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("missing path")?
|
||||
.to_string();
|
||||
if !path_allowed(permissions, PermissionKind::FsRead, &path) {
|
||||
return Err(format!(
|
||||
"path '{path}' is outside this module's granted fs.read scope"
|
||||
));
|
||||
}
|
||||
let expanded = bread_shared::expand_path(&path);
|
||||
let content = std::fs::read_to_string(&expanded).ok();
|
||||
Ok(json!({ "content": content }))
|
||||
}
|
||||
"module_host.fs_write" => {
|
||||
if !permissions
|
||||
.iter()
|
||||
.any(|p| p.kind == PermissionKind::FsWrite)
|
||||
{
|
||||
return Err("fs.write not granted to this module".to_string());
|
||||
}
|
||||
let path = req
|
||||
.params
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("missing path")?
|
||||
.to_string();
|
||||
if !path_allowed(permissions, PermissionKind::FsWrite, &path) {
|
||||
return Err(format!(
|
||||
"path '{path}' is outside this module's granted fs.write scope"
|
||||
));
|
||||
}
|
||||
let content = req
|
||||
.params
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let expanded = bread_shared::expand_path(&path);
|
||||
if let Some(parent) = expanded.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
std::fs::write(&expanded, content).map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"module_host.exec" => {
|
||||
if !permissions.iter().any(|p| p.kind == PermissionKind::Exec) {
|
||||
return Err("exec not granted to this module".to_string());
|
||||
}
|
||||
let cmd = req
|
||||
.params
|
||||
.get("cmd")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("missing cmd")?
|
||||
.to_string();
|
||||
let argv = exec_argv(permissions, &cmd)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
match std::process::Command::new(&argv[0])
|
||||
.args(&argv[1..])
|
||||
.status()
|
||||
{
|
||||
Ok(status) if !status.success() => {
|
||||
warn!(cmd = %argv[0], code = ?status.code(), "module_host.exec exited non-zero");
|
||||
}
|
||||
Err(e) => {
|
||||
error!(cmd = %argv[0], error = %e, "module_host.exec failed to spawn");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"module_host.exec_capture" => {
|
||||
if !permissions.iter().any(|p| p.kind == PermissionKind::Exec) {
|
||||
return Err("exec not granted to this module".to_string());
|
||||
}
|
||||
let cmd = req
|
||||
.params
|
||||
.get("cmd")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("missing cmd")?
|
||||
.to_string();
|
||||
let argv = exec_argv(permissions, &cmd)?;
|
||||
let timeout_ms = req
|
||||
.params
|
||||
.get("timeout_ms")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(2000);
|
||||
let handle = tokio::task::spawn_blocking(move || {
|
||||
std::process::Command::new(&argv[0])
|
||||
.args(&argv[1..])
|
||||
.output()
|
||||
});
|
||||
match tokio::time::timeout(Duration::from_millis(timeout_ms + 500), handle).await {
|
||||
Ok(Ok(Ok(out))) => Ok(json!({
|
||||
"ok": out.status.success(),
|
||||
"stdout": String::from_utf8_lossy(&out.stdout),
|
||||
})),
|
||||
_ => Ok(json!({ "ok": false, "stdout": "" })),
|
||||
}
|
||||
}
|
||||
"module_host.status" => {
|
||||
let state = req
|
||||
.params
|
||||
.get("state")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("load_error");
|
||||
let error = req
|
||||
.params
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let (load_state, outcome) = if state == "loaded" {
|
||||
(ModuleLoadState::Loaded, ModuleHostOutcome::Ready)
|
||||
} else {
|
||||
(
|
||||
ModuleLoadState::LoadError,
|
||||
ModuleHostOutcome::LoadError(
|
||||
error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "module load failed".to_string()),
|
||||
),
|
||||
)
|
||||
};
|
||||
// Out-of-process modules are never "ungated": they only
|
||||
// exist in this branch because they declared a manifest
|
||||
// (see lua/mod.rs's load_module), so `ungated=false`
|
||||
// unconditionally here is correct, not a placeholder.
|
||||
self.state_handle.set_module_status_ex(
|
||||
module_name.to_string(),
|
||||
load_state,
|
||||
error,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
if let Some(tx) = outcome_tx.take() {
|
||||
let _ = tx.send(outcome);
|
||||
}
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
other => Err(format!("unknown module_host method: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Belt-and-suspenders path scoping for `fs_read`/`fs_write`: if the
|
||||
/// manifest declared a `path` hint for this permission kind, the requested
|
||||
/// path (after `~`-expansion and canonicalize) must be a real descendant
|
||||
/// of at least one granted prefix. No hint at all means this RPC-level
|
||||
/// check stays permissive (matching Workstream D's existing "un-hinted
|
||||
/// grant = ungated within that namespace" behavior) — Landlock's own
|
||||
/// ruleset (built independently in `module_host::apply_sandbox`) does NOT
|
||||
/// grant a filesystem rule for an un-hinted permission, so the direct
|
||||
/// `os`/`io` escape hatch remains kernel-denied for that case regardless
|
||||
/// of what this function returns.
|
||||
fn path_allowed(permissions: &[ModulePermission], kind: PermissionKind, path: &str) -> bool {
|
||||
let hints: Vec<&String> = permissions
|
||||
.iter()
|
||||
.filter(|p| p.kind == kind)
|
||||
.filter_map(|p| p.path.as_ref())
|
||||
.collect();
|
||||
if hints.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let Some(requested) = resolve_scoped_path(path) else {
|
||||
return false;
|
||||
};
|
||||
hints.iter().any(|hint| {
|
||||
let Some(granted) = resolve_scoped_path(hint) else {
|
||||
return false;
|
||||
};
|
||||
// Path::starts_with is component-wise; str::starts_with would let
|
||||
// `/Wallpapers-evil` match a `/Wallpapers` grant.
|
||||
requested.starts_with(&granted)
|
||||
})
|
||||
}
|
||||
|
||||
/// Canonicalize for containment: existing paths resolve symlinks and `..`;
|
||||
/// new files canonicalize the parent then re-join the filename. Anything
|
||||
/// that still contains `..` after lexical normalization is rejected.
|
||||
fn resolve_scoped_path(path: &str) -> Option<PathBuf> {
|
||||
let expanded = bread_shared::expand_path(path);
|
||||
if let Ok(canon) = expanded.canonicalize() {
|
||||
return Some(canon);
|
||||
}
|
||||
let parent = expanded.parent().filter(|p| !p.as_os_str().is_empty());
|
||||
if let (Some(parent), Some(name)) = (parent, expanded.file_name()) {
|
||||
if let Ok(parent_canon) = parent.canonicalize() {
|
||||
return Some(parent_canon.join(name));
|
||||
}
|
||||
}
|
||||
lexical_abs(&expanded)
|
||||
}
|
||||
|
||||
fn lexical_abs(path: &Path) -> Option<PathBuf> {
|
||||
let abs = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::path::absolute(path).ok()?
|
||||
};
|
||||
let mut out = PathBuf::new();
|
||||
for c in abs.components() {
|
||||
match c {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
rest => out.push(rest),
|
||||
}
|
||||
}
|
||||
if out.components().any(|c| matches!(c, Component::ParentDir)) {
|
||||
return None;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Split `cmd` into argv without a shell. Metacharacters are rejected so a
|
||||
/// hinted binary cannot smuggle extra commands (`hyprpaper; curl evil`).
|
||||
fn parse_exec_argv(cmd: &str) -> Option<Vec<String>> {
|
||||
if cmd.chars().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
'|' | ';' | '&' | '$' | '`' | '\n' | '\r' | '<' | '>' | '(' | ')'
|
||||
)
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let argv: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
|
||||
if argv.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(argv)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `cmd` and enforce the exec `bin` hint against argv[0].
|
||||
fn exec_argv(permissions: &[ModulePermission], cmd: &str) -> Result<Vec<String>, String> {
|
||||
let argv = parse_exec_argv(cmd)
|
||||
.ok_or_else(|| "command contains shell metacharacters or is empty".to_string())?;
|
||||
if !bin_allowed(permissions, &argv[0]) {
|
||||
return Err("command is outside this module's granted exec bin scope".to_string());
|
||||
}
|
||||
Ok(argv)
|
||||
}
|
||||
|
||||
/// Same idea as [`path_allowed`] for `exec`'s `bin` hint: compares argv[0]
|
||||
/// by file name (so `bin = "hyprpaper"` matches `/usr/bin/hyprpaper` as
|
||||
/// well as a bare `hyprpaper`) or an exact path match.
|
||||
fn bin_allowed(permissions: &[ModulePermission], program: &str) -> bool {
|
||||
let hints: Vec<&String> = permissions
|
||||
.iter()
|
||||
.filter(|p| p.kind == PermissionKind::Exec)
|
||||
.filter_map(|p| p.bin.as_ref())
|
||||
.collect();
|
||||
if hints.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let cmd_leaf = Path::new(program)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or(program);
|
||||
hints.iter().any(|hint| {
|
||||
let hint_leaf = Path::new(hint.as_str())
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or(hint.as_str());
|
||||
cmd_leaf == hint_leaf || program == hint.as_str()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fs_grant(kind: PermissionKind, path: &str) -> Vec<ModulePermission> {
|
||||
vec![ModulePermission {
|
||||
kind,
|
||||
path: Some(path.to_string()),
|
||||
bin: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn exec_grant(bin: &str) -> Vec<ModulePermission> {
|
||||
vec![ModulePermission {
|
||||
kind: PermissionKind::Exec,
|
||||
path: None,
|
||||
bin: Some(bin.to_string()),
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_allowed_denies_dotdot_escape_from_granted_prefix() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let wallpapers = tmp.path().join("Wallpapers");
|
||||
std::fs::create_dir(&wallpapers).unwrap();
|
||||
let granted = wallpapers.to_str().unwrap();
|
||||
let perms = fs_grant(PermissionKind::FsRead, granted);
|
||||
|
||||
let escape = wallpapers.join("../../.ssh/id_rsa");
|
||||
assert!(
|
||||
!path_allowed(&perms, PermissionKind::FsRead, escape.to_str().unwrap()),
|
||||
"Wallpapers/../../.ssh/id_rsa must not match a Wallpapers grant"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_allowed_denies_string_prefix_sibling() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let wallpapers = tmp.path().join("Wallpapers");
|
||||
let evil = tmp.path().join("Wallpapers-evil");
|
||||
std::fs::create_dir(&wallpapers).unwrap();
|
||||
std::fs::create_dir(&evil).unwrap();
|
||||
let secret = evil.join("secret");
|
||||
std::fs::write(&secret, "x").unwrap();
|
||||
let perms = fs_grant(PermissionKind::FsRead, wallpapers.to_str().unwrap());
|
||||
assert!(!path_allowed(
|
||||
&perms,
|
||||
PermissionKind::FsRead,
|
||||
secret.to_str().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_allowed_accepts_real_descendant_and_new_file() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let wallpapers = tmp.path().join("Wallpapers");
|
||||
std::fs::create_dir(&wallpapers).unwrap();
|
||||
let existing = wallpapers.join("bg.png");
|
||||
std::fs::write(&existing, "x").unwrap();
|
||||
let perms = fs_grant(PermissionKind::FsWrite, wallpapers.to_str().unwrap());
|
||||
assert!(path_allowed(
|
||||
&perms,
|
||||
PermissionKind::FsWrite,
|
||||
existing.to_str().unwrap()
|
||||
));
|
||||
let new_file = wallpapers.join("new.png");
|
||||
assert!(path_allowed(
|
||||
&perms,
|
||||
PermissionKind::FsWrite,
|
||||
new_file.to_str().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_argv_denies_shell_chaining_when_bin_hinted() {
|
||||
let perms = exec_grant("hyprpaper");
|
||||
assert!(exec_argv(&perms, "hyprpaper; curl evil").is_err());
|
||||
assert!(exec_argv(&perms, "hyprpaper").is_ok());
|
||||
assert!(exec_argv(&perms, "/usr/bin/hyprpaper --config x").is_ok());
|
||||
assert!(exec_argv(&perms, "curl evil").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exec_argv_rejects_metacharacters() {
|
||||
assert!(parse_exec_argv("hyprpaper; curl evil").is_none());
|
||||
assert!(parse_exec_argv("hyprpaper | curl evil").is_none());
|
||||
assert!(parse_exec_argv("hyprpaper && curl evil").is_none());
|
||||
assert_eq!(
|
||||
parse_exec_argv("hyprpaper --config x"),
|
||||
Some(vec!["hyprpaper".into(), "--config".into(), "x".into()])
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,7 @@ mod adapters;
|
|||
mod core;
|
||||
mod ipc;
|
||||
mod lua;
|
||||
mod module_host;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
|
|
@ -37,9 +38,14 @@ async fn main() -> Result<()> {
|
|||
|
||||
let subscription_count = Arc::new(AtomicU64::new(0));
|
||||
let state_handle = StateHandle::new(state.clone(), state_cmd_tx);
|
||||
let module_host_registry = module_host::ModuleHostRegistry::new();
|
||||
|
||||
let lua_runtime =
|
||||
lua::spawn_runtime(config.clone(), state_handle.clone(), normalized_tx.clone())?;
|
||||
let lua_runtime = lua::spawn_runtime(
|
||||
config.clone(),
|
||||
state_handle.clone(),
|
||||
normalized_tx.clone(),
|
||||
module_host_registry.clone(),
|
||||
)?;
|
||||
let lua_tx = lua_runtime.sender();
|
||||
|
||||
tokio::spawn(run_state_engine(
|
||||
|
|
@ -52,7 +58,10 @@ async fn main() -> Result<()> {
|
|||
shutdown_rx.clone(),
|
||||
));
|
||||
|
||||
let normalizer = Arc::new(EventNormalizer::new(config.events.dedup_window_ms));
|
||||
let normalizer = Arc::new(
|
||||
EventNormalizer::new(config.events.dedup_window_ms)
|
||||
.with_legacy_hyprland_event_names(config.compat.legacy_hyprland_event_names),
|
||||
);
|
||||
{
|
||||
let normalizer = normalizer.clone();
|
||||
let normalized_tx = normalized_tx.clone();
|
||||
|
|
@ -116,6 +125,7 @@ async fn main() -> Result<()> {
|
|||
adapter_status,
|
||||
subscription_count,
|
||||
event_buffer,
|
||||
module_host_registry.clone(),
|
||||
);
|
||||
|
||||
info!("breadd fully started");
|
||||
|
|
@ -133,6 +143,7 @@ async fn main() -> Result<()> {
|
|||
let _ = shutdown_tx.send(true);
|
||||
|
||||
lua_runtime.shutdown();
|
||||
module_host_registry.shutdown_all();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
780
breadd/src/module_host.rs
Normal file
780
breadd/src/module_host.rs
Normal file
|
|
@ -0,0 +1,780 @@
|
|||
//! Spawning, token-based identity, and OS-level sandboxing for out-of-process
|
||||
//! module hosts (Workstream G).
|
||||
//!
|
||||
//! # Why a process, not just the existing Lua-level scoping
|
||||
//!
|
||||
//! Workstream D's `build_scoped_env` (see `lua/mod.rs`) gates the
|
||||
//! *documented* `bread.*` surface by controlling which keys exist on the
|
||||
//! `bread` table a module's chunk sees. Its own doc comment says plainly
|
||||
//! that `os.execute`/`io.open`/`debug.*` remain fully reachable from a
|
||||
//! scoped module — Lua's stdlib isn't sandboxed at all, only the `bread`
|
||||
//! table is. A module that never calls `bread.fs`/`bread.exec` and instead
|
||||
//! calls `io.open`/`os.execute` directly bypasses the whole mechanism,
|
||||
//! because everything still runs as Lua code inside `breadd`'s own OS
|
||||
//! process, sharing its real filesystem/exec access at the kernel level.
|
||||
//!
|
||||
//! This module closes that gap for any module that opted into the
|
||||
//! capability-manifest system (`decl.permissions.is_some()` — see
|
||||
//! `lua/mod.rs`'s `load_module`): instead of loading its chunk in-process,
|
||||
//! `breadd` spawns a separate `bread-module-host` OS process for it,
|
||||
//! restricted by a Landlock ruleset built from that module's granted
|
||||
//! `ModulePermission`s *before* the child ever executes a byte of the
|
||||
//! module's Lua.
|
||||
//!
|
||||
//! # Why Landlock over bubblewrap/firejail
|
||||
//!
|
||||
//! - Pure Rust, no external sandboxing binary dependency — this workspace's
|
||||
//! existing style already favors native Rust crates over shelling out
|
||||
//! (see e.g. `udev`, `zbus`, `rtnetlink` instead of wrapping CLI tools).
|
||||
//! - Unprivileged: no setuid helper, no CAP_SYS_ADMIN, works from an
|
||||
//! ordinary user session exactly like the rest of `breadd`.
|
||||
//! - Available since Linux 5.13; this repo's dev kernel is 6.18 and the
|
||||
//! mechanism was verified against it directly before adoption (see the
|
||||
//! `landlock` entry in the workspace `Cargo.toml` and
|
||||
//! `module_host::tests::landlock_denies_reads_outside_granted_path`
|
||||
//! below) — a `pre_exec`-restricted child process attempting to read a
|
||||
//! file outside its granted rule set gets `EACCES` from the kernel, not a
|
||||
//! Lua-level error.
|
||||
//! - `bubblewrap`-wrapping remains a documented fallback if a target
|
||||
//! platform's kernel lacks Landlock support (pre-5.13, or a hardened
|
||||
//! kernel config with it compiled out) — not implemented here since
|
||||
//! Landlock covers this repo's actual target (a modern desktop Linux
|
||||
//! kernel) and keeps the dependency footprint native-Rust-only.
|
||||
//!
|
||||
//! # What Landlock does *not* cover here (P2, explicitly deferred)
|
||||
//!
|
||||
//! Network access. Landlock gained TCP bind/connect mediation in ABI v4+
|
||||
//! (kernel 6.7+), but wiring a `network` permission kind through the
|
||||
//! manifest schema, `PermissionKind`, and this sandbox builder is scoped
|
||||
//! out of this workstream's P0 — see `Documentation.md`.
|
||||
//!
|
||||
//! # The token handshake
|
||||
//!
|
||||
//! Workstream A deliberately did not build a generic IPC connection-identity
|
||||
//! system (it closed a narrower spoofing gap instead), so there's no
|
||||
//! existing `module:<name>` identity concept to hook into. This module adds
|
||||
//! the minimal mechanism Workstream G actually needs: `breadd` generates a
|
||||
//! random one-time token when spawning a module-host child, hands it to the
|
||||
//! child via `$BREAD_MODULE_TOKEN` (an env var, not argv — argv is visible
|
||||
//! to any process on the system via `/proc/<pid>/cmdline`, env vars are not
|
||||
//! without `/proc/<pid>/environ` + matching privileges), and the child's
|
||||
//! first message on the IPC socket (`module_host.hello`) presents that
|
||||
//! token. `breadd` looks up which module name/manifest/permission set the
|
||||
//! token was issued for — see [`ModuleHostRegistry::take_pending`] — rather
|
||||
//! than trusting any name the child process might assert about itself.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::os::unix::process::{CommandExt, ExitStatusExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use bread_shared::{AdapterSource, BreadEvent, ModulePermission, PermissionKind};
|
||||
use landlock::{
|
||||
make_bitflags, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr,
|
||||
RulesetCreatedAttr, RulesetStatus, ABI,
|
||||
};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// What `load_module` ultimately learns about a spawn attempt, reported back
|
||||
/// over IPC (`module_host.hello` consumes the pending entry;
|
||||
/// `module_host.status` supplies the final verdict) via
|
||||
/// [`PendingModuleHost::outcome_tx`].
|
||||
pub enum ModuleHostOutcome {
|
||||
Ready,
|
||||
LoadError(String),
|
||||
}
|
||||
|
||||
/// What `breadd` knows about a spawned-but-not-yet-authenticated module-host
|
||||
/// child, keyed by the one-time token it was handed. Consumed exactly once,
|
||||
/// by whichever connection presents the matching token first (see
|
||||
/// `ipc::Server`'s `module_host.hello` handling).
|
||||
pub struct PendingModuleHost {
|
||||
pub module_name: String,
|
||||
pub permissions: Vec<ModulePermission>,
|
||||
pub outcome_tx: std::sync::mpsc::Sender<ModuleHostOutcome>,
|
||||
}
|
||||
|
||||
struct ActiveModuleHost {
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
/// Shared handle to the pending-token / active-child bookkeeping, cloned
|
||||
/// into both the Lua engine thread (which spawns children) and the IPC
|
||||
/// server (which authenticates them and serves their RPC calls).
|
||||
#[derive(Clone)]
|
||||
pub struct ModuleHostRegistry {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
pending: HashMap<String, PendingModuleHost>,
|
||||
active: HashMap<String, ActiveModuleHost>,
|
||||
}
|
||||
|
||||
impl Default for ModuleHostRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ModuleHostRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(Inner::default())),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_pending(&self, token: String, pending: PendingModuleHost) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.pending
|
||||
.insert(token, pending);
|
||||
}
|
||||
|
||||
/// One-time consumption of a pending token, called from the IPC side
|
||||
/// when a connection presents it via `module_host.hello`. Returns
|
||||
/// `None` for an unknown/already-consumed/expired token — the caller
|
||||
/// must not extend any trust to that connection in that case.
|
||||
pub fn take_pending(&self, token: &str) -> Option<PendingModuleHost> {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.pending
|
||||
.remove(token)
|
||||
}
|
||||
|
||||
fn insert_active(&self, name: String, pid: u32) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.active
|
||||
.insert(name, ActiveModuleHost { pid });
|
||||
}
|
||||
|
||||
fn remove_active(&self, name: &str) {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.active
|
||||
.remove(name);
|
||||
}
|
||||
|
||||
/// Best-effort SIGTERM of a previously spawned module-host for `name`,
|
||||
/// if still tracked as active. Called at the top of
|
||||
/// [`spawn_module_host`] so `bread reload`/`modules.reload` respawning
|
||||
/// the same module doesn't leak an orphaned duplicate process still
|
||||
/// holding an open IPC connection and reacting to events alongside its
|
||||
/// replacement. The old process's own reap thread (started when it was
|
||||
/// first spawned) will still notice it exit and emit
|
||||
/// `bread.module.crashed` for it — a known rough edge documented in
|
||||
/// `Documentation.md`: an intentional reload-triggered replacement
|
||||
/// currently looks identical, on the wire, to an unexpected crash.
|
||||
fn terminate_existing(&self, name: &str) {
|
||||
let pid = {
|
||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.active.get(name).map(|a| a.pid)
|
||||
};
|
||||
if let Some(pid) = pid {
|
||||
unsafe {
|
||||
libc::kill(pid as libc::pid_t, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort SIGTERM of every still-tracked module-host child. Called
|
||||
/// from `breadd`'s shutdown path so stopping the daemon doesn't leave
|
||||
/// orphaned sandboxed processes holding a now-dead socket connection.
|
||||
pub fn shutdown_all(&self) {
|
||||
let pids: Vec<u32> = {
|
||||
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.active.values().map(|a| a.pid).collect()
|
||||
};
|
||||
for pid in pids {
|
||||
// SAFETY: kill(2) with a pid we just read from our own
|
||||
// bookkeeping and a plain termination signal; no memory safety
|
||||
// concerns, just an FFI call.
|
||||
unsafe {
|
||||
libc::kill(pid as libc::pid_t, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long `load_module` blocks waiting for a freshly spawned module-host
|
||||
/// to either report ready (`module_host.status{state:"loaded"}`) or fail —
|
||||
/// mirrors the synchronous "a module either loaded or it didn't" contract
|
||||
/// `load_scoped_lua_file` already has for in-process modules. 25s rather
|
||||
/// than something tighter: a real spawn (process fork/exec + Landlock
|
||||
/// ruleset setup + Lua init) takes well under a second in isolation, but
|
||||
/// this repo's integration test suite spawns many real `breadd` +
|
||||
/// `bread-module-host` process pairs concurrently (`cargo test`'s default
|
||||
/// parallelism), and under that load a spawn occasionally takes several
|
||||
/// seconds of wall-clock time waiting for CPU/scheduler time rather than
|
||||
/// being slow on its own merits.
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
|
||||
/// Spawn a sandboxed `bread-module-host` child for one third-party module
|
||||
/// and block (on a `std::sync::mpsc` channel, not an async await — this is
|
||||
/// called from the Lua engine's own dedicated OS thread, which is not
|
||||
/// async) until it reports ready or fails to within [`READY_TIMEOUT`].
|
||||
///
|
||||
/// `emit_tx` is used once, later, not by this function directly: the
|
||||
/// crash-detection thread this function spawns uses it to emit
|
||||
/// `bread.module.crashed` if the child dies after having successfully
|
||||
/// loaded.
|
||||
pub fn spawn_module_host(
|
||||
registry: &ModuleHostRegistry,
|
||||
module_name: &str,
|
||||
entry_path: &Path,
|
||||
permissions: &[ModulePermission],
|
||||
socket_path: &Path,
|
||||
emit_tx: &UnboundedSender<BreadEvent>,
|
||||
) -> Result<ModuleHostOutcome> {
|
||||
registry.terminate_existing(module_name);
|
||||
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
let (outcome_tx, outcome_rx) = std::sync::mpsc::channel();
|
||||
registry.insert_pending(
|
||||
token.clone(),
|
||||
PendingModuleHost {
|
||||
module_name: module_name.to_string(),
|
||||
permissions: permissions.to_vec(),
|
||||
outcome_tx,
|
||||
},
|
||||
);
|
||||
|
||||
let bin_path = resolve_module_host_binary();
|
||||
|
||||
let mut cmd = Command::new(&bin_path);
|
||||
cmd.env("BREAD_MODULE_TOKEN", &token)
|
||||
.env("BREAD_MODULE_ENTRY", entry_path)
|
||||
.env("BREAD_MODULE_SOCKET", socket_path)
|
||||
.env("BREAD_MODULE_NAME", module_name)
|
||||
.stdin(std::process::Stdio::null());
|
||||
|
||||
let sandbox_permissions = permissions.to_vec();
|
||||
let sandbox_bin_path = bin_path.clone();
|
||||
let sandbox_module_name = module_name.to_string();
|
||||
let sandbox_entry_path = entry_path.to_path_buf();
|
||||
let sandbox_socket_path = socket_path.to_path_buf();
|
||||
// SAFETY: the closure runs in the forked child between fork() and
|
||||
// execve() (that's what pre_exec is for). It only touches its own
|
||||
// captured, already-allocated data plus filesystem/landlock syscalls —
|
||||
// no allocation-unsafe signal-handler tricks, matching the same
|
||||
// pattern the `landlock` crate's own sandboxing examples use for
|
||||
// restricting a spawned child.
|
||||
unsafe {
|
||||
cmd.pre_exec(move || {
|
||||
apply_sandbox(
|
||||
&sandbox_bin_path,
|
||||
&sandbox_entry_path,
|
||||
&sandbox_socket_path,
|
||||
&sandbox_permissions,
|
||||
)
|
||||
.map_err(|e| {
|
||||
std::io::Error::other(format!(
|
||||
"landlock sandbox setup failed for module '{sandbox_module_name}': {e}"
|
||||
))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
registry.take_pending(&token);
|
||||
return Err(anyhow!(
|
||||
"failed to spawn bread-module-host at {}: {e}",
|
||||
bin_path.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let pid = child.id();
|
||||
registry.insert_active(module_name.to_string(), pid);
|
||||
info!(module = %module_name, pid, bin = %bin_path.display(), "spawned bread-module-host");
|
||||
|
||||
// Reap thread: detects the child exiting for ANY reason (clean exit,
|
||||
// panic, `kill -9`) without blocking breadd's IPC server or the Lua
|
||||
// engine thread — this is the mechanism behind the "crash isolation"
|
||||
// acceptance test (P0 item 5): killing this child must not take breadd
|
||||
// or any other module down with it, and breadd must notice and report
|
||||
// it via `bread.module.crashed`.
|
||||
{
|
||||
let registry = registry.clone();
|
||||
let emit_tx = emit_tx.clone();
|
||||
let module_name = module_name.to_string();
|
||||
let thread_name = format!("mh-reap-{}", short(&module_name));
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name(thread_name)
|
||||
.spawn(move || {
|
||||
let status = child.wait();
|
||||
registry.remove_active(&module_name);
|
||||
let (reason, exit_code, signal) = describe_exit(&status);
|
||||
warn!(module = %module_name, pid, reason = %reason, "bread-module-host exited");
|
||||
let _ = emit_tx.send(BreadEvent::new(
|
||||
"bread.module.crashed",
|
||||
AdapterSource::System,
|
||||
serde_json::json!({
|
||||
"module": module_name,
|
||||
"pid": pid,
|
||||
"reason": reason,
|
||||
"exit_code": exit_code,
|
||||
"signal": signal,
|
||||
}),
|
||||
));
|
||||
})
|
||||
{
|
||||
error!(error = %e, "failed to spawn module-host reap thread");
|
||||
}
|
||||
}
|
||||
|
||||
match outcome_rx.recv_timeout(READY_TIMEOUT) {
|
||||
Ok(outcome) => Ok(outcome),
|
||||
Err(_) => {
|
||||
registry.take_pending(&token);
|
||||
Ok(ModuleHostOutcome::LoadError(format!(
|
||||
"module-host for '{module_name}' did not report ready within {:?}",
|
||||
READY_TIMEOUT
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn short(name: &str) -> String {
|
||||
name.chars().take(12).collect()
|
||||
}
|
||||
|
||||
fn describe_exit(
|
||||
status: &std::io::Result<std::process::ExitStatus>,
|
||||
) -> (String, Option<i32>, Option<i32>) {
|
||||
match status {
|
||||
Ok(s) => {
|
||||
if let Some(code) = s.code() {
|
||||
(format!("exited with code {code}"), Some(code), None)
|
||||
} else if let Some(sig) = s.signal() {
|
||||
(format!("killed by signal {sig}"), None, Some(sig))
|
||||
} else {
|
||||
("exited (unknown reason)".to_string(), None, None)
|
||||
}
|
||||
}
|
||||
Err(e) => (format!("wait() failed: {e}"), None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the `bread-module-host` binary's path: prefer the sibling of
|
||||
/// `breadd`'s own executable (the layout `cargo build --workspace` and this
|
||||
/// repo's packaging both produce — all workspace binaries land in the same
|
||||
/// `target/{debug,release}` or install bindir), falling back to a bare
|
||||
/// `PATH` lookup for layouts where `current_exe()` resolution is
|
||||
/// unreliable.
|
||||
pub fn resolve_module_host_binary() -> PathBuf {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
let candidate = dir.join("bread-module-host");
|
||||
if candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
PathBuf::from("bread-module-host")
|
||||
}
|
||||
|
||||
/// Build and apply the Landlock ruleset for a module-host child, from
|
||||
/// inside `Command::pre_exec` (i.e. after `fork()`, before `execve()` of
|
||||
/// `bread-module-host` itself — so the restriction covers that very
|
||||
/// `execve()` too, which is why the baseline rules below exist at all).
|
||||
///
|
||||
/// # The baseline (always granted, not manifest-driven)
|
||||
///
|
||||
/// A dynamically linked binary needs to read its own file (to `execve` it)
|
||||
/// and load the shared libraries `ld.so` maps into it. The initial version
|
||||
/// of this function assumed Landlock's `Execute` access right gates
|
||||
/// `execve()`/`execveat()` only, and that granting plain `ReadFile` on the
|
||||
/// library directories would be enough for the dynamic linker's
|
||||
/// `mmap(..., PROT_EXEC, ...)` calls on `.so` files. That assumption was
|
||||
/// **wrong** — verified empirically (not just reasoned about from the
|
||||
/// kernel docs) by spawning a real sandboxed child: with library
|
||||
/// directories restricted to `ReadFile`-only, even `/bin/sh -c "true"`
|
||||
/// fails `execve()` with `EACCES` before running a single line of script;
|
||||
/// granting `Execute` on those directories too makes it work. So the
|
||||
/// running kernel's Landlock implementation *does* mediate the executable
|
||||
/// `mmap` the dynamic linker performs via the same `Execute` right,
|
||||
/// contrary to what a first reading of "Execute a file" (the kernel doc's
|
||||
/// one-line description) suggests. The baseline therefore grants:
|
||||
/// - `ReadFile | ReadDir | Execute` on the system library directories and
|
||||
/// `ReadFile` on `/etc/ld.so.cache`/`/etc/ld.so.preload` — what the
|
||||
/// dynamic linker actually needs to start this binary at all.
|
||||
/// - `ReadFile | Execute` on the `bread-module-host` binary's own resolved
|
||||
/// path specifically (not a whole directory).
|
||||
///
|
||||
/// **Known trade-off, not swept under the rug**: this means a module-host
|
||||
/// child's direct `os.execute`/`io.open` escape hatch, if it names a path
|
||||
/// under `/usr/lib`/`/lib` (etc.) directly, is not denied by Landlock the
|
||||
/// way an arbitrary path elsewhere on the filesystem is — the baseline
|
||||
/// necessarily grants real `Execute` there, not just enough for the linker.
|
||||
/// This is a materially smaller exposure than "no sandbox at all" (it's
|
||||
/// bounded to files already shipped in the system's own library
|
||||
/// directories, not the whole filesystem, and not anything a manifest
|
||||
/// didn't otherwise ask for), but it is a real gap worth being honest
|
||||
/// about — see `Documentation.md`'s "Workstream G" section. A fully static
|
||||
/// build of `bread-module-host` (e.g. targeting `x86_64-unknown-linux-musl`
|
||||
/// — confirmed available via `rustup target list --installed` in this
|
||||
/// repo's dev environment) would remove the need for this baseline
|
||||
/// entirely, since there'd be no dynamic linker involved at all; that's
|
||||
/// flagged as follow-up work rather than attempted here, since it's a
|
||||
/// build/packaging change (cross-compiling mlua's vendored Lua and every
|
||||
/// transitive dependency against musl, plus a CI/xtask change) bigger than
|
||||
/// this workstream's remaining time budget affords.
|
||||
///
|
||||
/// # The manifest-driven grants
|
||||
///
|
||||
/// - `fs.read` with a `path` hint -> `ReadFile | ReadDir` scoped to that
|
||||
/// (`~`-expanded) path prefix.
|
||||
/// - `fs.write` with a `path` hint -> the read bits above plus
|
||||
/// `WriteFile | MakeReg | MakeDir` (matches `bread.fs.write`'s own
|
||||
/// `create_dir_all` + `write` behavior).
|
||||
/// - `exec` with a `bin` hint -> `ReadFile | Execute` scoped to that
|
||||
/// binary's resolved path (absolute paths used as-is; bare names are
|
||||
/// resolved via a `$PATH` search, `which`-style).
|
||||
/// - `fs.read`/`fs.write` with **no** `path` hint: the RPC bridge's
|
||||
/// belt-and-suspenders permission check still applies (see
|
||||
/// `ipc/mod.rs`), but no Landlock rule is added, since Landlock scoping
|
||||
/// needs a concrete path. A module author who wants the direct
|
||||
/// `os`/`io` escape hatch mediated at the kernel level too needs to
|
||||
/// declare a `path` — documented as a known sharp edge in
|
||||
/// `Documentation.md` rather than silently "fixed" by granting
|
||||
/// filesystem-wide access.
|
||||
/// - Every other `PermissionKind` (`state.*`, `notify`, `machine`,
|
||||
/// `hyprland`, `widget`, `bluetooth`, `profile.activate`) is RPC-gated
|
||||
/// only (see `ipc/mod.rs`) — they have no filesystem shape to hand
|
||||
/// Landlock in the first place.
|
||||
fn apply_sandbox(
|
||||
module_host_bin: &Path,
|
||||
entry_path: &Path,
|
||||
socket_path: &Path,
|
||||
permissions: &[ModulePermission],
|
||||
) -> Result<()> {
|
||||
let abi = ABI::V1;
|
||||
let lib_dir_access = make_bitflags!(AccessFs::{ReadFile | ReadDir | Execute});
|
||||
let read_file_only = make_bitflags!(AccessFs::{ReadFile});
|
||||
let read_only = make_bitflags!(AccessFs::{ReadFile | ReadDir});
|
||||
let read_and_exec = make_bitflags!(AccessFs::{ReadFile | Execute});
|
||||
let read_and_write =
|
||||
make_bitflags!(AccessFs::{ReadFile | ReadDir | WriteFile | MakeReg | MakeDir});
|
||||
|
||||
let mut ruleset = Ruleset::default()
|
||||
.handle_access(AccessFs::from_all(abi))
|
||||
.map_err(|e| anyhow!("landlock handle_access: {e}"))?
|
||||
.create()
|
||||
.map_err(|e| anyhow!("landlock ruleset create: {e}"))?;
|
||||
|
||||
for dir in ["/usr/lib", "/usr/lib64", "/lib", "/lib64"] {
|
||||
let p = Path::new(dir);
|
||||
if p.exists() {
|
||||
if let Ok(fd) = PathFd::new(p) {
|
||||
ruleset = ruleset
|
||||
.add_rule(PathBeneath::new(fd, lib_dir_access))
|
||||
.map_err(|e| anyhow!("landlock rule for {dir}: {e}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for f in ["/etc/ld.so.cache", "/etc/ld.so.preload"] {
|
||||
let p = Path::new(f);
|
||||
if p.exists() {
|
||||
if let Ok(fd) = PathFd::new(p) {
|
||||
ruleset = ruleset
|
||||
.add_rule(PathBeneath::new(fd, read_file_only))
|
||||
.map_err(|e| anyhow!("landlock rule for {f}: {e}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(fd) = PathFd::new(module_host_bin) {
|
||||
ruleset = ruleset
|
||||
.add_rule(PathBeneath::new(fd, read_and_exec))
|
||||
.map_err(|e| anyhow!("landlock rule for module-host binary: {e}"))?;
|
||||
}
|
||||
// The module-host bootstrap process needs to read its OWN module's
|
||||
// directory (init.lua, bread.module.toml, an optional lib/ subtree —
|
||||
// the same directory shape load_scoped_lua_file's in-process
|
||||
// counterpart reads from) to load any Lua at all, entirely separate
|
||||
// from whatever `fs.read` the manifest grants for the module's own
|
||||
// runtime file I/O. Without this rule, EVERY out-of-process module
|
||||
// fails to load — including ones with no `fs.read` permission at
|
||||
// all — since it can't even read its own entry file.
|
||||
if let Some(module_dir) = entry_path.parent() {
|
||||
if let Ok(fd) = PathFd::new(module_dir) {
|
||||
ruleset = ruleset
|
||||
.add_rule(PathBeneath::new(fd, read_only))
|
||||
.map_err(|e| {
|
||||
anyhow!("landlock rule for module directory {}: {e}", module_dir.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
// The module-host must connect back to breadd over this Unix socket
|
||||
// (BREAD_MODULE_SOCKET). Path-based AF_UNIX connect is mediated by
|
||||
// Landlock as filesystem access — without a rule for the socket path
|
||||
// (and walk access on its parent), UnixStream::connect fails with
|
||||
// EACCES, the hello handshake never completes, and every
|
||||
// out-of-process module times out with "did not report ready".
|
||||
if let Some(socket_dir) = socket_path.parent() {
|
||||
if let Ok(fd) = PathFd::new(socket_dir) {
|
||||
ruleset = ruleset
|
||||
.add_rule(PathBeneath::new(fd, read_only))
|
||||
.map_err(|e| {
|
||||
anyhow!(
|
||||
"landlock rule for socket directory {}: {e}",
|
||||
socket_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if socket_path.exists() {
|
||||
if let Ok(fd) = PathFd::new(socket_path) {
|
||||
ruleset = ruleset
|
||||
.add_rule(PathBeneath::new(fd, read_file_only))
|
||||
.map_err(|e| {
|
||||
anyhow!(
|
||||
"landlock rule for socket {}: {e}",
|
||||
socket_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
for perm in permissions {
|
||||
match perm.kind {
|
||||
PermissionKind::FsRead => {
|
||||
if let Some(path) = &perm.path {
|
||||
let expanded = bread_shared::expand_path(path);
|
||||
if let Ok(fd) = PathFd::new(&expanded) {
|
||||
ruleset = ruleset
|
||||
.add_rule(PathBeneath::new(fd, read_only))
|
||||
.map_err(|e| {
|
||||
anyhow!("landlock fs.read rule for {}: {e}", expanded.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
PermissionKind::FsWrite => {
|
||||
if let Some(path) = &perm.path {
|
||||
let expanded = bread_shared::expand_path(path);
|
||||
if let Ok(fd) = PathFd::new(&expanded) {
|
||||
ruleset = ruleset
|
||||
.add_rule(PathBeneath::new(fd, read_and_write))
|
||||
.map_err(|e| {
|
||||
anyhow!("landlock fs.write rule for {}: {e}", expanded.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
PermissionKind::Exec => {
|
||||
if let Some(bin) = &perm.bin {
|
||||
if let Some(resolved) = resolve_bin_path(bin) {
|
||||
if let Ok(fd) = PathFd::new(&resolved) {
|
||||
ruleset = ruleset
|
||||
.add_rule(PathBeneath::new(fd, read_and_exec))
|
||||
.map_err(|e| {
|
||||
anyhow!("landlock exec rule for {}: {e}", resolved.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let status = ruleset
|
||||
.restrict_self()
|
||||
.map_err(|e| anyhow!("landlock restrict_self: {e}"))?;
|
||||
if !matches!(status.ruleset, RulesetStatus::FullyEnforced) {
|
||||
// Not fatal: PartiallyEnforced still means real kernel enforcement
|
||||
// for whatever subset the running kernel/LSM stack supports (see
|
||||
// this module's doc comment — verified directly against this
|
||||
// repo's dev kernel, which reports PartiallyEnforced yet still
|
||||
// denies out-of-scope reads). NotEnforced (pre-5.13 kernel, or
|
||||
// Landlock compiled out) would mean this module is running fully
|
||||
// unsandboxed — loud enough to want in the log, not loud enough to
|
||||
// refuse to start the module entirely and regress availability.
|
||||
eprintln!(
|
||||
"bread-module-host: landlock ruleset status = {:?} (not fully enforced on this kernel)",
|
||||
status.ruleset
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `which`-style resolution for an `exec` permission's `bin` hint: absolute
|
||||
/// paths are used as-is, bare names are searched on `$PATH`.
|
||||
fn resolve_bin_path(bin: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(bin);
|
||||
if p.is_absolute() {
|
||||
return Some(p.to_path_buf());
|
||||
}
|
||||
let path_var = std::env::var_os("PATH")?;
|
||||
for dir in std::env::split_paths(&path_var) {
|
||||
let candidate = dir.join(bin);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
/// The single most important test in this whole workstream (see the
|
||||
/// task's P0 item 4 and `Documentation.md`'s "Workstream G" section):
|
||||
/// a real spawned child, restricted only by `apply_sandbox` for a
|
||||
/// module granted `fs.read` on exactly one directory, must be denied
|
||||
/// by the *kernel* — not a Lua-level check — when it tries to read a
|
||||
/// file outside that directory. This talks to `apply_sandbox` and
|
||||
/// `Command::pre_exec` exactly the way `spawn_module_host` does; the
|
||||
/// full end-to-end version (going through the real IPC handshake and
|
||||
/// an actual `os.execute`/`io.open` call from inside Lua) lives in
|
||||
/// `breadd/tests/module_host_sandbox.rs`.
|
||||
#[test]
|
||||
fn landlock_denies_reads_outside_granted_path() {
|
||||
let allowed_dir = tempfile::tempdir().unwrap();
|
||||
let allowed_file = allowed_dir.path().join("allowed.txt");
|
||||
std::fs::write(&allowed_file, b"ok").unwrap();
|
||||
|
||||
let denied_dir = tempfile::tempdir().unwrap();
|
||||
let denied_file = denied_dir.path().join("secret.txt");
|
||||
std::fs::write(&denied_file, b"nope").unwrap();
|
||||
|
||||
// Mirrors the task's own acceptance scenario verbatim:
|
||||
// `os.execute("cat /etc/shadow")` from inside a module granted
|
||||
// `fs.read` for exactly one other directory. `cat` does a plain
|
||||
// `open()`+`read()` — no shell builtin involved — which is both
|
||||
// the most faithful stand-in for the direct `os`/`io` escape hatch
|
||||
// and (empirically, see the note on `no_exec_permission_...` below)
|
||||
// avoids a bash `read`-builtin quirk that turned out to need more
|
||||
// than a `ReadFile` grant for reasons unrelated to what this test
|
||||
// is actually checking.
|
||||
let cat_bin = resolve_bin_path("cat").expect("cat not found on $PATH");
|
||||
let permissions = vec![
|
||||
ModulePermission {
|
||||
kind: PermissionKind::FsRead,
|
||||
path: Some(allowed_dir.path().to_string_lossy().to_string()),
|
||||
bin: None,
|
||||
},
|
||||
ModulePermission {
|
||||
kind: PermissionKind::Exec,
|
||||
path: None,
|
||||
bin: Some(cat_bin.to_string_lossy().to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
let sh_bin = which_sh();
|
||||
let mut cmd = Command::new(&sh_bin);
|
||||
cmd.arg("-c").arg(format!(
|
||||
"{cat} {allowed} && echo ALLOWED_OK; {cat} {denied} && echo DENIED_UNEXPECTEDLY_OK",
|
||||
cat = cat_bin.display(),
|
||||
allowed = allowed_file.display(),
|
||||
denied = denied_file.display(),
|
||||
));
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let sandbox_bin = sh_bin.clone();
|
||||
unsafe {
|
||||
cmd.pre_exec(move || {
|
||||
apply_sandbox(
|
||||
&sandbox_bin,
|
||||
Path::new("/nonexistent/dummy/entry.lua"),
|
||||
Path::new("/nonexistent/dummy/breadd.sock"),
|
||||
&permissions,
|
||||
)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||
});
|
||||
}
|
||||
|
||||
let output = cmd.output().expect("failed to run sandboxed sh");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
assert!(
|
||||
stdout.contains("ALLOWED_OK"),
|
||||
"expected the granted directory to remain readable; stdout={stdout} stderr={stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("DENIED_UNEXPECTEDLY_OK"),
|
||||
"sandboxed process read a file OUTSIDE its granted fs.read path — Landlock did not enforce; stdout={stdout} stderr={stderr}"
|
||||
);
|
||||
// The kernel denial surfaces as `cat`'s own "Permission denied"
|
||||
// (EACCES from open()), on stderr — confirming this was an OS-level
|
||||
// denial, not e.g. the file simply not existing.
|
||||
assert!(
|
||||
stderr.to_lowercase().contains("permission denied"),
|
||||
"expected a kernel permission-denied error for the out-of-scope read; stderr={stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_exec_permission_means_binary_cannot_be_executed_at_all() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let script_path = dir.path().join("run.sh");
|
||||
{
|
||||
let mut f = std::fs::File::create(&script_path).unwrap();
|
||||
writeln!(f, "#!/bin/sh\necho SHOULD_NOT_RUN").unwrap();
|
||||
}
|
||||
std::fs::set_permissions(
|
||||
&script_path,
|
||||
std::os::unix::fs::PermissionsExt::from_mode(0o755),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// No permissions granted at all: the sandboxed process should not
|
||||
// be able to execute ANYTHING, including a script sitting right
|
||||
// next to files it might otherwise be able to read.
|
||||
let permissions: Vec<ModulePermission> = vec![];
|
||||
|
||||
let sh_bin = which_sh();
|
||||
let mut cmd = Command::new(&sh_bin);
|
||||
cmd.arg("-c")
|
||||
.arg(format!("{} && echo RAN", script_path.display()));
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let sandbox_bin = sh_bin.clone();
|
||||
unsafe {
|
||||
cmd.pre_exec(move || {
|
||||
apply_sandbox(
|
||||
&sandbox_bin,
|
||||
Path::new("/nonexistent/dummy/entry.lua"),
|
||||
Path::new("/nonexistent/dummy/breadd.sock"),
|
||||
&permissions,
|
||||
)
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||
});
|
||||
}
|
||||
|
||||
let output = cmd.output().expect("failed to run sandboxed sh");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
!stdout.contains("RAN"),
|
||||
"sandboxed process executed a script with no `exec` permission granted; stdout={stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
fn which_sh() -> PathBuf {
|
||||
for candidate in ["/bin/sh", "/usr/bin/sh"] {
|
||||
let p = PathBuf::from(candidate);
|
||||
if p.exists() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
panic!("no /bin/sh or /usr/bin/sh found — cannot run sandbox tests");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
560
breadd/tests/module_host_sandbox.rs
Normal file
560
breadd/tests/module_host_sandbox.rs
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
//! Workstream G acceptance tests: the real, end-to-end version of the two
|
||||
//! things this workstream exists to prove, going through a real spawned
|
||||
//! `breadd` + a real spawned `bread-module-host` child + a real IPC
|
||||
//! handshake — not the in-isolation Landlock-mechanism unit tests in
|
||||
//! `breadd/src/module_host.rs` (`landlock_denies_reads_outside_granted_path`,
|
||||
//! `no_exec_permission_means_binary_cannot_be_executed_at_all`), which only
|
||||
//! exercise `apply_sandbox` directly against a plain `sh`/`cat`.
|
||||
//!
|
||||
//! 1. [`os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope`] —
|
||||
//! a module granted `fs.read` for exactly one directory (and nothing
|
||||
//! else) runs real Lua that calls `io.open`/`os.execute` directly,
|
||||
//! bypassing the RPC bridge entirely and going straight for the
|
||||
//! `os`/`io` escape hatch Workstream D's in-process scoping admittedly
|
||||
//! leaves open (see `breadd/src/lua/mod.rs`'s `build_scoped_env` doc
|
||||
//! comment). This is the single most important test in the whole
|
||||
//! workstream: proving the denial is a *kernel* permission error, not a
|
||||
//! Lua-level check that a well-behaved module merely chooses to respect.
|
||||
//! 2. [`killing_a_module_host_child_does_not_take_down_breadd_or_other_modules`] —
|
||||
//! `kill -9` on a running module-host child's PID, confirming `breadd`
|
||||
//! itself and a second, unrelated module both keep responding, and that
|
||||
//! `breadd` detects the death and reports it via `bread.module.crashed`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::{json, Value};
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::time::{sleep, timeout};
|
||||
|
||||
// NOTE: these tests need `target/{debug,release}/bread-module-host` to
|
||||
// already exist — `breadd::module_host::resolve_module_host_binary` looks
|
||||
// for it as a sibling of `breadd`'s own executable. `bread-module-host` is
|
||||
// a bin-only crate (no `[lib]` target — deliberately, see its Cargo.toml),
|
||||
// so it can't be pulled in as a `[dev-dependencies]` entry to force cargo
|
||||
// to build it via `env!("CARGO_BIN_EXE_...")`, the usual trick for this.
|
||||
// Running via `cargo test --workspace` (this repo's documented/required
|
||||
// verification command — see Documentation.md) builds every workspace
|
||||
// member, including `bread-module-host`, before any test runs, so this
|
||||
// isn't a problem in practice; running `cargo test -p breadd` in isolation
|
||||
// without a prior `cargo build --workspace` would need one first.
|
||||
|
||||
struct TestHarness {
|
||||
_temp: TempDir,
|
||||
child: Child,
|
||||
socket_path: PathBuf,
|
||||
#[allow(dead_code)]
|
||||
home: PathBuf,
|
||||
}
|
||||
|
||||
impl TestHarness {
|
||||
/// Spawns a real `breadd` with `[modules] builtin = false` and one
|
||||
/// directory-based module per `(name, manifest_toml, init_lua)` entry —
|
||||
/// the same on-disk shape `bread modules install` produces
|
||||
/// (`<modules_dir>/<name>/{bread.module.toml,init.lua}`).
|
||||
fn spawn_with_modules(modules: &[(&str, &str, &str)]) -> Result<Self> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let runtime_dir = temp.path().join("runtime");
|
||||
let config_home = temp.path().join("config");
|
||||
let home = temp.path().join("home");
|
||||
fs::create_dir_all(&runtime_dir)?;
|
||||
fs::create_dir_all(&config_home)?;
|
||||
fs::create_dir_all(&home)?;
|
||||
|
||||
let bread_cfg = config_home.join("bread");
|
||||
fs::create_dir_all(bread_cfg.join("modules"))?;
|
||||
fs::write(
|
||||
bread_cfg.join("init.lua"),
|
||||
"bread.on('bread.system.startup', function() end)\n",
|
||||
)?;
|
||||
|
||||
for (name, manifest_toml, init_lua) in modules {
|
||||
let module_dir = bread_cfg.join("modules").join(name);
|
||||
fs::create_dir_all(&module_dir)?;
|
||||
if !manifest_toml.is_empty() {
|
||||
fs::write(module_dir.join("bread.module.toml"), manifest_toml)?;
|
||||
}
|
||||
fs::write(module_dir.join("init.lua"), init_lua)?;
|
||||
}
|
||||
|
||||
fs::write(
|
||||
bread_cfg.join("breadd.toml"),
|
||||
r#"
|
||||
[daemon]
|
||||
log_level = "error"
|
||||
|
||||
[lua]
|
||||
entry_point = "~/.config/bread/init.lua"
|
||||
module_path = "~/.config/bread/modules"
|
||||
|
||||
[modules]
|
||||
builtin = false
|
||||
|
||||
[adapters.hyprland]
|
||||
enabled = false
|
||||
|
||||
[adapters.udev]
|
||||
enabled = false
|
||||
|
||||
[adapters.power]
|
||||
enabled = false
|
||||
|
||||
[adapters.network]
|
||||
enabled = false
|
||||
|
||||
[adapters.podman]
|
||||
enabled = false
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let socket_path = runtime_dir.join("bread").join("breadd.sock");
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_breadd"))
|
||||
.env("XDG_RUNTIME_DIR", &runtime_dir)
|
||||
.env("XDG_CONFIG_HOME", &config_home)
|
||||
.env("HOME", &home)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
Ok(Self {
|
||||
_temp: temp,
|
||||
child,
|
||||
socket_path,
|
||||
home,
|
||||
})
|
||||
}
|
||||
|
||||
fn socket_path(&self) -> &Path {
|
||||
&self.socket_path
|
||||
}
|
||||
|
||||
async fn wait_until_ready(&self) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
while Instant::now() < deadline {
|
||||
if self.socket_path.exists() && self.send_request("ping", json!({})).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(anyhow!("daemon did not become ready in time"))
|
||||
}
|
||||
|
||||
/// Poll `modules.list` until `name` shows up `Loaded` — out-of-process
|
||||
/// modules report their load outcome asynchronously (see
|
||||
/// `breadd/src/lua/mod.rs`'s `load_out_of_process_module`), so a plain
|
||||
/// `wait_until_ready` (which only proves the daemon's IPC socket is up)
|
||||
/// isn't enough to know a specific module has finished spawning,
|
||||
/// connecting, authenticating, and running its `init.lua`.
|
||||
async fn wait_for_module_loaded(&self, name: &str) -> Result<()> {
|
||||
// Comfortably exceeds module_host::READY_TIMEOUT (breadd's own
|
||||
// spawn-side wait) so this test-side poll doesn't give up before
|
||||
// breadd itself would.
|
||||
let deadline = Instant::now() + Duration::from_secs(55);
|
||||
while Instant::now() < deadline {
|
||||
let modules = self.send_request("modules.list", json!({})).await?;
|
||||
if let Some(arr) = modules.as_array() {
|
||||
for m in arr {
|
||||
if m.get("name").and_then(Value::as_str) == Some(name) {
|
||||
if m.get("status").and_then(Value::as_str) == Some("loaded") {
|
||||
return Ok(());
|
||||
}
|
||||
if m.get("status").and_then(Value::as_str) == Some("load_error") {
|
||||
return Err(anyhow!(
|
||||
"module '{name}' failed to load: {:?}",
|
||||
m.get("last_error")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(anyhow!("module '{name}' did not reach Loaded within timeout"))
|
||||
}
|
||||
|
||||
async fn send_request(&self, method: &str, params: Value) -> Result<Value> {
|
||||
let stream = UnixStream::connect(self.socket_path()).await?;
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
|
||||
let req = json!({ "id": "1", "method": method, "params": params });
|
||||
write_half
|
||||
.write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes())
|
||||
.await?;
|
||||
|
||||
let mut lines = BufReader::new(read_half).lines();
|
||||
let line = lines
|
||||
.next_line()
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("missing ipc response"))?;
|
||||
let parsed: Value = serde_json::from_str(&line)?;
|
||||
|
||||
if let Some(err) = parsed.get("error").and_then(Value::as_str) {
|
||||
return Err(anyhow!(err.to_string()));
|
||||
}
|
||||
Ok(parsed.get("result").cloned().unwrap_or_else(|| json!({})))
|
||||
}
|
||||
|
||||
/// Find the PID of a `bread-module-host` child spawned for this
|
||||
/// harness's `breadd` by scanning `/proc/*/environ` for
|
||||
/// `BREAD_MODULE_NAME=<module_name>` — the module-host binary never
|
||||
/// puts its identity in argv (see its own doc comment on why:
|
||||
/// `/proc/*/cmdline` is visible to any process), so this is the same
|
||||
/// kind of environment-based lookup, just from the test side instead
|
||||
/// of breadd's.
|
||||
fn find_module_host_pid(&self, module_name: &str) -> Result<u32> {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
for entry in fs::read_dir("/proc")?.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let Some(pid_str) = file_name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pid) = pid_str.parse::<u32>() else {
|
||||
continue;
|
||||
};
|
||||
let environ_path = entry.path().join("environ");
|
||||
let Ok(environ) = fs::read(&environ_path) else {
|
||||
continue;
|
||||
};
|
||||
let wanted = format!("BREAD_MODULE_NAME={module_name}");
|
||||
if environ
|
||||
.split(|b| *b == 0)
|
||||
.any(|var| var == wanted.as_bytes())
|
||||
{
|
||||
return Ok(pid);
|
||||
}
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err(anyhow!(
|
||||
"no bread-module-host process found for module '{module_name}'"
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(self) {
|
||||
// Drop does the actual killing (see below) — this method exists so
|
||||
// call sites can be explicit about "done with this harness" without
|
||||
// caring exactly how cleanup happens.
|
||||
drop(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestHarness {
|
||||
/// Any `?`-propagated failure partway through a test (a timed-out
|
||||
/// event, a failed assertion via `anyhow!` — though assertion panics
|
||||
/// unwind rather than `?`-return, they still run `Drop`) must not leak
|
||||
/// a live `breadd` (and, transitively, any `bread-module-host`
|
||||
/// children it spawned) — `kill` here, not just on the happy path via
|
||||
/// `shutdown()`, is what keeps a failed test run from leaving orphaned
|
||||
/// sandboxed processes behind for the next run to trip over.
|
||||
fn drop(&mut self) {
|
||||
// SIGTERM first, not straight to SIGKILL — see the matching comment
|
||||
// in `ipc_integration.rs`'s `TestHarness::drop`. SIGKILL prevents
|
||||
// `breadd` from ever running its own graceful shutdown path, which
|
||||
// is what actually fires `kill_on_drop` on adapter-spawned child
|
||||
// processes (e.g. `PodmanAdapter`'s `podman events` watcher) —
|
||||
// exactly how a day of repeated `cargo test --workspace` runs left
|
||||
// 1,559 orphaned `podman events` processes system-wide. Bounded
|
||||
// wait, then SIGKILL as a fallback so a genuinely wedged `breadd`
|
||||
// doesn't hang the test suite.
|
||||
unsafe {
|
||||
libc::kill(self.child.id() as libc::pid_t, libc::SIGTERM);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
match self.child.try_wait() {
|
||||
Ok(Some(_)) => return,
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// The P0 acceptance test: verified at the OS level, not asserted. See this
|
||||
/// file's module doc comment.
|
||||
#[tokio::test]
|
||||
async fn os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope() -> Result<()>
|
||||
{
|
||||
let allowed_dir = tempfile::tempdir()?;
|
||||
let allowed_file = allowed_dir.path().join("allowed.txt");
|
||||
fs::write(&allowed_file, "allowed-content")?;
|
||||
|
||||
// Deliberately NOT under $HOME/anything the manifest grants, and
|
||||
// deliberately world-readable-by-this-user (normal DAC permissions
|
||||
// alone would NOT deny this) so a pass here can only be explained by
|
||||
// Landlock, not by an unrelated ordinary permission error — the same
|
||||
// reasoning as the `deny_dir`/`secret.txt` split in
|
||||
// `breadd/src/module_host.rs`'s unit tests, just end-to-end this time.
|
||||
let deny_dir = tempfile::tempdir()?;
|
||||
let deny_file = deny_dir.path().join("secret.txt");
|
||||
fs::write(&deny_file, "top-secret-content")?;
|
||||
|
||||
let manifest = format!(
|
||||
r#"
|
||||
name = "escape-hatch-test"
|
||||
|
||||
[[permissions]]
|
||||
type = "fs.read"
|
||||
path = "{}"
|
||||
"#,
|
||||
allowed_dir.path().display()
|
||||
);
|
||||
|
||||
// No `exec` permission granted at all, so `os.execute` should fail
|
||||
// outright (can't even launch `/bin/sh` under Landlock) — and
|
||||
// `io.open`, which doesn't need a subprocess at all, directly tests
|
||||
// the FsRead scoping. Results are reported back over `bread.emit`
|
||||
// (baseline, always available) since this module runs in a separate
|
||||
// process we can't otherwise introspect from the test.
|
||||
//
|
||||
// The checks run on a `bread.on("test.trigger", ...)` handler, NOT in
|
||||
// `on_load` — a module loads (and, if it ran in `on_load`, would emit
|
||||
// its result) as part of daemon startup, which races the test's own
|
||||
// `events.subscribe` connection. `tokio::sync::broadcast` (what
|
||||
// `events.subscribe` reads from) does not replay history to a
|
||||
// subscriber that joins after a send already happened — a late
|
||||
// subscription just misses it, no error, no buffering — so without
|
||||
// this explicit trigger the test would be racing the daemon's own
|
||||
// startup sequence rather than reliably observing anything.
|
||||
let init_lua = format!(
|
||||
r#"
|
||||
local M = bread.module({{ name = "escape-hatch-test", version = "1.0.0" }})
|
||||
|
||||
bread.on("test.trigger", function(trigger_event)
|
||||
local allowed_result = "ALLOWED_READ_FAILED"
|
||||
local fh = io.open("{allowed}", "r")
|
||||
if fh then
|
||||
local content = fh:read("*a")
|
||||
fh:close()
|
||||
allowed_result = "ALLOWED_READ_OK:" .. content
|
||||
end
|
||||
|
||||
local denied_result = "DENIED_READ_UNEXPECTEDLY_SUCCEEDED"
|
||||
local deny_fh = io.open("{denied}", "r")
|
||||
if deny_fh then
|
||||
local content = deny_fh:read("*a")
|
||||
deny_fh:close()
|
||||
denied_result = "DENIED_READ_UNEXPECTEDLY_SUCCEEDED:" .. content
|
||||
else
|
||||
denied_result = "io.open denied"
|
||||
end
|
||||
|
||||
local exec_ok = os.execute("cat {denied} > /dev/null 2>&1")
|
||||
local exec_result
|
||||
if exec_ok == true then
|
||||
exec_result = "EXEC_UNEXPECTEDLY_SUCCEEDED"
|
||||
else
|
||||
exec_result = "exec denied or failed"
|
||||
end
|
||||
|
||||
bread.emit("test.escape_hatch_result", {{
|
||||
allowed_result = allowed_result,
|
||||
denied_result = denied_result,
|
||||
exec_result = exec_result,
|
||||
}})
|
||||
end)
|
||||
|
||||
return M
|
||||
"#,
|
||||
allowed = allowed_file.display(),
|
||||
denied = deny_file.display(),
|
||||
);
|
||||
|
||||
let harness = TestHarness::spawn_with_modules(&[("escape-hatch-test", &manifest, &init_lua)])?;
|
||||
harness.wait_until_ready().await?;
|
||||
// Guarantees the module's `bread.on("test.trigger", ...)` subscription
|
||||
// is already registered server-side before the trigger below is sent —
|
||||
// "loaded" status is only reported (via `module_host.status`) after the
|
||||
// module's whole init.lua chunk, including that top-level `bread.on`
|
||||
// call, has finished executing. See this test's other race-avoidance
|
||||
// comment above for why this matters.
|
||||
harness.wait_for_module_loaded("escape-hatch-test").await?;
|
||||
|
||||
let stream = UnixStream::connect(harness.socket_path()).await?;
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let subscribe = json!({
|
||||
"id": "sub-1",
|
||||
"method": "events.subscribe",
|
||||
"params": { "filter": "test.escape_hatch_result" },
|
||||
});
|
||||
write_half
|
||||
.write_all(format!("{}\n", serde_json::to_string(&subscribe)?).as_bytes())
|
||||
.await?;
|
||||
let mut reader = BufReader::new(read_half).lines();
|
||||
let _ack = reader.next_line().await?;
|
||||
|
||||
harness
|
||||
.send_request("emit", json!({ "event": "test.trigger", "data": {} }))
|
||||
.await?;
|
||||
|
||||
let line = timeout(Duration::from_secs(15), reader.next_line())
|
||||
.await
|
||||
.map_err(|_| anyhow!("timed out waiting for test.escape_hatch_result event"))??
|
||||
.ok_or_else(|| anyhow!("connection closed before event arrived"))?;
|
||||
let event: Value = serde_json::from_str(&line)?;
|
||||
let data = event
|
||||
.get("data")
|
||||
.ok_or_else(|| anyhow!("event missing data"))?;
|
||||
|
||||
let allowed_result = data.get("allowed_result").and_then(Value::as_str).unwrap_or("");
|
||||
let denied_result = data.get("denied_result").and_then(Value::as_str).unwrap_or("");
|
||||
let exec_result = data.get("exec_result").and_then(Value::as_str).unwrap_or("");
|
||||
|
||||
assert!(
|
||||
allowed_result.starts_with("ALLOWED_READ_OK"),
|
||||
"the granted fs.read directory should remain readable via direct io.open; got {allowed_result:?}"
|
||||
);
|
||||
assert!(
|
||||
!denied_result.contains("UNEXPECTEDLY_SUCCEEDED"),
|
||||
"io.open on a path OUTSIDE the granted fs.read scope must be denied at the kernel level (Landlock), not merely un-offered by an RPC binding — got {denied_result:?}"
|
||||
);
|
||||
assert!(
|
||||
!exec_result.contains("UNEXPECTEDLY_SUCCEEDED"),
|
||||
"os.execute with no `exec` permission granted must not be able to run anything at all — got {exec_result:?}"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// P0 item 5: killing a module-host child must not take `breadd` (or any
|
||||
/// other module) down with it, and `breadd` must notice and report it.
|
||||
#[tokio::test]
|
||||
async fn killing_a_module_host_child_does_not_take_down_breadd_or_other_modules() -> Result<()> {
|
||||
// An explicit, empty `permissions = []` — not "no manifest at all" — is
|
||||
// what opts a module into the out-of-process sandboxed path with zero
|
||||
// grants (see `ModuleDecl::permissions`'s doc comment in
|
||||
// `breadd/src/lua/mod.rs`: `None` means "no manifest", which keeps
|
||||
// today's in-process, ungated legacy behavior; `Some(vec![])` means
|
||||
// "deliberately baseline-only" and IS routed out-of-process).
|
||||
let victim_manifest = "name = \"victim\"\npermissions = []\n";
|
||||
|
||||
let victim_init = r#"
|
||||
local M = bread.module({ name = "victim", version = "1.0.0" })
|
||||
function M.on_load() end
|
||||
return M
|
||||
"#;
|
||||
|
||||
// The "control" module stays in-process (no manifest at all — the
|
||||
// legacy/backward-compat path) specifically so this test also proves
|
||||
// an out-of-process module's crash doesn't disturb an *in-process*
|
||||
// module either, not just breadd's own IPC responsiveness.
|
||||
let control_init = r#"
|
||||
local M = bread.module({ name = "control", version = "1.0.0" })
|
||||
bread.on("bread.custom.ping_control", function(event)
|
||||
bread.emit("bread.custom.pong_control", {})
|
||||
end)
|
||||
return M
|
||||
"#;
|
||||
|
||||
let harness = TestHarness::spawn_with_modules(&[
|
||||
("victim", victim_manifest, victim_init),
|
||||
("control", "", control_init),
|
||||
])?;
|
||||
harness.wait_until_ready().await?;
|
||||
harness.wait_for_module_loaded("victim").await?;
|
||||
|
||||
// Subscribe to bread.module.crashed BEFORE killing, so we can't miss it.
|
||||
let crash_stream = UnixStream::connect(harness.socket_path()).await?;
|
||||
let (crash_read, mut crash_write) = crash_stream.into_split();
|
||||
crash_write
|
||||
.write_all(
|
||||
format!(
|
||||
"{}\n",
|
||||
serde_json::to_string(&json!({
|
||||
"id": "crash-sub",
|
||||
"method": "events.subscribe",
|
||||
"params": { "filter": "bread.module.crashed" },
|
||||
}))?
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
let mut crash_reader = BufReader::new(crash_read).lines();
|
||||
let _ack = crash_reader.next_line().await?;
|
||||
|
||||
let victim_pid = harness.find_module_host_pid("victim")?;
|
||||
let kill_status = Command::new("kill").args(["-9", &victim_pid.to_string()]).status()?;
|
||||
assert!(kill_status.success(), "failed to send SIGKILL to victim module-host");
|
||||
|
||||
// breadd itself must keep responding.
|
||||
let ping = harness.send_request("ping", json!({})).await?;
|
||||
assert_eq!(ping.get("ok").and_then(Value::as_bool), Some(true));
|
||||
|
||||
// The unrelated in-process "control" module must keep dispatching
|
||||
// events normally.
|
||||
let control_stream = UnixStream::connect(harness.socket_path()).await?;
|
||||
let (control_read, mut control_write) = control_stream.into_split();
|
||||
control_write
|
||||
.write_all(
|
||||
format!(
|
||||
"{}\n",
|
||||
serde_json::to_string(&json!({
|
||||
"id": "pong-sub",
|
||||
"method": "events.subscribe",
|
||||
"params": { "filter": "bread.custom.pong_control" },
|
||||
}))?
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
let mut control_reader = BufReader::new(control_read).lines();
|
||||
let _ack = control_reader.next_line().await?;
|
||||
|
||||
harness
|
||||
.send_request(
|
||||
"emit",
|
||||
json!({ "event": "bread.custom.ping_control", "data": {} }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let pong_line = timeout(Duration::from_secs(10), control_reader.next_line())
|
||||
.await
|
||||
.map_err(|_| anyhow!("control module did not respond after victim was killed"))??
|
||||
.ok_or_else(|| anyhow!("control connection closed unexpectedly"))?;
|
||||
let pong: Value = serde_json::from_str(&pong_line)?;
|
||||
assert_eq!(
|
||||
pong.get("event").and_then(Value::as_str),
|
||||
Some("bread.custom.pong_control"),
|
||||
"control module should still be alive and responsive after the victim module-host was killed"
|
||||
);
|
||||
|
||||
// breadd must have detected the death and reported it.
|
||||
let crash_line = timeout(Duration::from_secs(10), crash_reader.next_line())
|
||||
.await
|
||||
.map_err(|_| anyhow!("bread.module.crashed was not emitted after kill -9"))??
|
||||
.ok_or_else(|| anyhow!("crash subscription connection closed unexpectedly"))?;
|
||||
let crash_event: Value = serde_json::from_str(&crash_line)?;
|
||||
assert_eq!(
|
||||
crash_event
|
||||
.get("data")
|
||||
.and_then(|d| d.get("module"))
|
||||
.and_then(Value::as_str),
|
||||
Some("victim"),
|
||||
"bread.module.crashed should identify the module whose host process died"
|
||||
);
|
||||
assert_eq!(
|
||||
crash_event
|
||||
.get("data")
|
||||
.and_then(|d| d.get("signal"))
|
||||
.and_then(Value::as_i64),
|
||||
Some(9),
|
||||
"the crash report should reflect that the process was killed by SIGKILL"
|
||||
);
|
||||
|
||||
harness.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
1
ci/bread-ecosystem.rev
Normal file
1
ci/bread-ecosystem.rev
Normal file
|
|
@ -0,0 +1 @@
|
|||
147cfbbf96ae4b171027defa1130d2caddb934b1
|
||||
21
ci/build.sh
Executable file
21
ci/build.sh
Executable 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 [ ! -d "$CACHE_DIR" ]; then
|
||||
rm -rf /tmp/bread-ecosystem-ci-*
|
||||
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" bread "$ROOT" "$@"
|
||||
|
|
@ -14,6 +14,22 @@ cp low-battery-warning.lua ~/.config/bread/modules/
|
|||
bread reload
|
||||
```
|
||||
|
||||
`cpu-temp-widget/` is a directory (not a flat file) with a `bread.module.toml`
|
||||
manifest declaring its `[[permissions]]` — see
|
||||
[Capability-scoped modules](../../Documentation.md#capability-scoped-modules-since-v15).
|
||||
Either copy the whole directory into `~/.config/bread/modules/`, or install it
|
||||
properly so the manifest travels with it:
|
||||
|
||||
```sh
|
||||
bread modules install ./cpu-temp-widget
|
||||
bread reload
|
||||
```
|
||||
|
||||
The other modules here are flat files with no manifest — they load exactly
|
||||
like today, with full, ungated `bread.*` access (`bread doctor` will note
|
||||
that). Run `bread modules audit <name>` on an installed one any time to get a
|
||||
suggested `[[permissions]]` block for its own `bread.module.toml`.
|
||||
|
||||
## Modules
|
||||
|
||||
| File | What it does | Config needed |
|
||||
|
|
@ -21,6 +37,13 @@ bread reload
|
|||
| `low-battery-warning.lua` | Critical notification once when the battery runs low; resets on AC. | none |
|
||||
| `pause-media-on-headphone-unplug.lua` | Runs `playerctl pause` when a headphone/earbud device disconnects. | none (needs `playerctl`) |
|
||||
| `dock-monitors.lua` | Applies a multi-monitor layout when an external display connects, reverts when removed. | edit output names/resolutions |
|
||||
| `external-monitors.lua` | Zero-config laptop displays: any HDMI/DP/USB-C head at its preferred mode, mirrored by default (or extended), lid-safe, restores the panel on unplug. | optional `ARRANGE` / `SCALE` at the top |
|
||||
| `active-window-widget.lua` | Shows the focused window next to the workspace pills in breadbar, via `bread.widget` + `bread.state.watch`. | none |
|
||||
| `cpu-temp-widget/` | Live CPU temperature readout in breadbar's stats area, via `bread.widget` + `bread.fs.read` on a timer. Directory module with a `bread.module.toml` declaring `fs.read` + `widget` — the permission-manifest worked example. | edit `TEMP_PATH` for your hwmon layout |
|
||||
| `bluetooth-toggle-widget.lua` | One-click Bluetooth power toggle in breadbar's tray, via `bread.widget` + a click handler. | none |
|
||||
| `focus-mode-widget.lua` | Click-to-toggle "Focus" profile that mutes audio; a widget as an action launcher, not just a readout, and stays in sync with profile changes triggered elsewhere. | none (needs `wpctl`) |
|
||||
| `workflow-status-widget.lua` | Surfaces `bread.workflow.list()` in breadbar's tray — shows whichever workflow (e.g. `dock-workflow.lua`, below) is currently running or failed, hidden otherwise. | none |
|
||||
| `git-branch-widget.lua` | Shows the repo + branch of whichever git repo the focused kitty tab is sitting in; yellow when dirty. Entirely self-contained in Lua — no adapter behind it. | kitty remote control (see the module's header comment) |
|
||||
|
||||
Each module is the standard skeleton — `bread.module{...}`, an `on_load` that
|
||||
registers subscriptions, `return M` — so they double as references for writing
|
||||
|
|
|
|||
46
examples/modules/active-window-widget.lua
Normal file
46
examples/modules/active-window-widget.lua
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
-- active-window-widget — shows the focused app right next to the
|
||||
-- workspace pills, live-updated via bread.state.watch (no polling, no
|
||||
-- bread.on needed).
|
||||
--
|
||||
-- Demonstrates: WidgetPlacement "right_of_workspaces", a state-watch-driven
|
||||
-- widget (as opposed to a timer or event handler), and calling
|
||||
-- bread.widget.update from inside the watch callback. breadbar's own bar
|
||||
-- doesn't show the focused window anywhere today — this adds that for free.
|
||||
--
|
||||
-- Drop-in: copy into ~/.config/bread/modules/. Zero configuration.
|
||||
|
||||
local M = bread.module({ name = "active-window-widget", version = "1.0.0" })
|
||||
|
||||
local function label_for(window)
|
||||
-- A JSON null (no window focused) doesn't necessarily arrive as Lua nil
|
||||
-- through bread.state.* — mlua's serde bridge can hand back a distinct
|
||||
-- null sentinel instead, which `not window` won't catch. Guard on the
|
||||
-- type directly so any non-string value (nil, the sentinel, ...) falls
|
||||
-- through to the placeholder rather than crashing on #window below.
|
||||
if type(window) ~= "string" or window == "" then
|
||||
return "—"
|
||||
end
|
||||
if #window > 24 then
|
||||
return window:sub(1, 24) .. "…"
|
||||
end
|
||||
return window
|
||||
end
|
||||
|
||||
local function widget_root(window)
|
||||
return { type = "label", text = label_for(window), style = { color = "dim" } }
|
||||
end
|
||||
|
||||
function M.on_load()
|
||||
bread.widget.register({
|
||||
id = "active-window",
|
||||
placement = "right_of_workspaces",
|
||||
tooltip = "Currently focused window",
|
||||
root = widget_root(bread.state.active_window()),
|
||||
})
|
||||
|
||||
bread.state.watch("active_window", function(new_val)
|
||||
bread.widget.update("active-window", { root = widget_root(new_val) })
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
66
examples/modules/bluetooth-toggle-widget.lua
Normal file
66
examples/modules/bluetooth-toggle-widget.lua
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
-- bluetooth-toggle-widget — a real one-click Bluetooth power toggle in the
|
||||
-- hamburger popover's tray section, showing power state and connected
|
||||
-- device count. breadbar's native bar only shows a passive BT icon; this
|
||||
-- adds an actual control surface for it.
|
||||
--
|
||||
-- Demonstrates: WidgetPlacement "tray", a bread.every-polled read of
|
||||
-- bread.bluetooth.devices()/.powered(), and bread.bar.widget_clicked
|
||||
-- driving a real action (bread.bluetooth.power) rather than just display.
|
||||
--
|
||||
-- Drop-in: copy into ~/.config/bread/modules/. Zero configuration.
|
||||
|
||||
local M = bread.module({ name = "bluetooth-toggle-widget", version = "1.0.0" })
|
||||
|
||||
local function widget_root()
|
||||
local powered = bread.bluetooth.powered()
|
||||
local devices = bread.bluetooth.devices() or {}
|
||||
local connected = 0
|
||||
for _, d in ipairs(devices) do
|
||||
if d.connected then
|
||||
connected = connected + 1
|
||||
end
|
||||
end
|
||||
|
||||
local text, style
|
||||
if powered == nil then
|
||||
text, style = "BT n/a", { color = "dim" }
|
||||
elseif not powered then
|
||||
text, style = "BT off", { color = "dim" }
|
||||
elseif connected > 0 then
|
||||
text, style = "BT (" .. connected .. ")", { color = "accent", weight = "bold" }
|
||||
else
|
||||
text, style = "BT on", { color = "fg" }
|
||||
end
|
||||
|
||||
return {
|
||||
type = "label",
|
||||
text = text,
|
||||
style = style,
|
||||
on_click = "toggle",
|
||||
}
|
||||
end
|
||||
|
||||
function M.on_load()
|
||||
bread.widget.register({
|
||||
id = "toggle",
|
||||
placement = "tray",
|
||||
tooltip = "Click to toggle Bluetooth power",
|
||||
root = widget_root(),
|
||||
})
|
||||
|
||||
bread.every(5000, function()
|
||||
bread.widget.update("toggle", { root = widget_root() })
|
||||
end)
|
||||
|
||||
bread.on("bread.bar.widget_clicked", function(e)
|
||||
if e.data.widget_id == "bluetooth-toggle-widget.toggle" and e.data.action == "toggle" then
|
||||
bread.bluetooth.power(not bread.bluetooth.powered())
|
||||
-- Give BlueZ a moment to apply before refreshing the label.
|
||||
bread.after(500, function()
|
||||
bread.widget.update("toggle", { root = widget_root() })
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
21
examples/modules/cpu-temp-widget/bread.module.toml
Normal file
21
examples/modules/cpu-temp-widget/bread.module.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
name = "cpu-temp-widget"
|
||||
version = "1.0.0"
|
||||
description = "Live CPU package temperature widget, read from hwmon sysfs"
|
||||
author = "bread"
|
||||
source = "local"
|
||||
installed_at = ""
|
||||
|
||||
# This module only ever calls bread.fs.read (never .write) and
|
||||
# bread.widget.register/update — declaring exactly that is what makes
|
||||
# bread.exec, bread.bluetooth, bread.hyprland, bread.machine, bread.notify,
|
||||
# and bread.state all genuinely absent (nil) from its `bread` table at
|
||||
# runtime, rather than merely unused. `source`/`installed_at` above get
|
||||
# overwritten by `bread modules install`; they're placeholders for the
|
||||
# drop-in/copy-paste path.
|
||||
|
||||
[[permissions]]
|
||||
type = "fs.read"
|
||||
path = "/sys/class/hwmon"
|
||||
|
||||
[[permissions]]
|
||||
type = "widget"
|
||||
73
examples/modules/cpu-temp-widget/init.lua
Normal file
73
examples/modules/cpu-temp-widget/init.lua
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
-- cpu-temp-widget — live CPU package temperature, read straight from the
|
||||
-- k10temp hwmon sysfs node via bread.fs.read.
|
||||
--
|
||||
-- Demonstrates: WidgetPlacement "left_of_stats", a bread.every-polled
|
||||
-- widget reading real hardware state (the same category of readout
|
||||
-- breadbar's native CPU%/RAM stats already do in Rust — this shows it's
|
||||
-- just as easy from a drop-in Lua module), and the typed `style` vocabulary
|
||||
-- (color + weight) swapping based on a threshold so the widget visually
|
||||
-- flags when something's hot — no CSS, no guessing which class names the
|
||||
-- rendering app happens to define.
|
||||
--
|
||||
-- Drop-in: copy the whole cpu-temp-widget/ directory into
|
||||
-- ~/.config/bread/modules/ (or `bread modules install path/to/this/dir`).
|
||||
-- TEMP_PATH is specific to this machine (AMD, k10temp) — find yours with:
|
||||
-- grep -l k10temp /sys/class/hwmon/hwmon*/name
|
||||
-- and adjust below; a missing/unreadable path just shows "—" rather than
|
||||
-- erroring, since bread.fs.read returns nil (not an error) for that case.
|
||||
--
|
||||
-- This is also the worked example for the capability-manifest permission
|
||||
-- system (Documentation.md's "Capability-scoped modules" section): see the
|
||||
-- sibling bread.module.toml. It declares exactly the two permissions this
|
||||
-- module actually uses — `fs.read` (bread.fs.read, read-only) and `widget`
|
||||
-- (bread.widget.register/update) — nothing else. If you install it that
|
||||
-- way, bread.exec/bread.bluetooth/bread.hyprland/etc. are all genuinely
|
||||
-- absent (nil) from this module's `bread` table, not just unused.
|
||||
|
||||
local M = bread.module({ name = "cpu-temp-widget", version = "1.0.0" })
|
||||
|
||||
local TEMP_PATH = "/sys/class/hwmon/hwmon6/temp1_input"
|
||||
local HOT_THRESHOLD_C = 80
|
||||
|
||||
local function read_temp_c()
|
||||
local raw = bread.fs.read(TEMP_PATH)
|
||||
if not raw then
|
||||
return nil
|
||||
end
|
||||
return tonumber(raw) / 1000
|
||||
end
|
||||
|
||||
local function widget_root(temp_c)
|
||||
local text = temp_c and string.format("%.0f°C", temp_c) or "—"
|
||||
local hot = temp_c ~= nil and temp_c >= HOT_THRESHOLD_C
|
||||
return {
|
||||
type = "box",
|
||||
children = {
|
||||
{
|
||||
type = "label",
|
||||
text = text,
|
||||
style = hot and { color = "red", weight = "bold" } or { color = "dim" },
|
||||
},
|
||||
{
|
||||
type = "progress",
|
||||
value = temp_c and math.min(temp_c / 100, 1.0) or 0,
|
||||
style = hot and { color = "red" } or nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
function M.on_load()
|
||||
bread.widget.register({
|
||||
id = "cpu-temp",
|
||||
placement = "left_of_stats",
|
||||
tooltip = "CPU package temperature (Tctl)",
|
||||
root = widget_root(read_temp_c()),
|
||||
})
|
||||
|
||||
bread.every(5000, function()
|
||||
bread.widget.update("cpu-temp", { root = widget_root(read_temp_c()) })
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -21,7 +21,7 @@ bread.workflow.define("dock-connected", function()
|
|||
|
||||
bread.workflow.step("waiting for monitor")
|
||||
local event = bread.wait_any(
|
||||
{ "bread.monitor.connected", "bread.hyprland.event" },
|
||||
{ "bread.hyprland.monitor.connected", "bread.hyprland.event" },
|
||||
{ timeout = 5000 }
|
||||
)
|
||||
if not event then
|
||||
|
|
@ -35,7 +35,7 @@ bread.workflow.define("dock-connected", function()
|
|||
bread.profile.activate("docked")
|
||||
|
||||
bread.workflow.step("waiting for workspace")
|
||||
bread.wait("bread.workspace.changed", { timeout = 3000 })
|
||||
bread.wait("bread.hyprland.workspace.changed", { timeout = 3000 })
|
||||
|
||||
bread.workflow.step("notifying")
|
||||
bread.notify("Dock connected", { title = "bread" })
|
||||
|
|
|
|||
228
examples/modules/external-monitors.lua
Normal file
228
examples/modules/external-monitors.lua
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
-- external-monitors — behave like a normal laptop desktop
|
||||
--
|
||||
-- Plug in any display (HDMI, DisplayPort, USB-C dock, a random TV) and
|
||||
-- the session just works. No output names to edit.
|
||||
--
|
||||
-- • the laptop panel stays at its preferred (native) mode
|
||||
-- • each external uses its preferred mode and refresh
|
||||
-- • new screens clone the laptop (set ARRANGE = "extend" to sit to the right)
|
||||
-- • closing the lid does not sleep while an external is on
|
||||
-- • unplug everything and the laptop is the only display again
|
||||
--
|
||||
-- Drop-in: copy to ~/.config/bread/modules/ and `bread reload`.
|
||||
|
||||
local M = bread.module({
|
||||
name = "external-monitors",
|
||||
version = "1.0.0",
|
||||
after = { "bread.monitors" },
|
||||
})
|
||||
|
||||
-- "mirror" = every external clones the laptop (presentations, TVs)
|
||||
-- "extend" = extra desktop to the right
|
||||
local ARRANGE = "mirror"
|
||||
local SCALE = "auto"
|
||||
|
||||
local INTERNAL_RE = "^eDP"
|
||||
local INHIBITOR = "/tmp/bread-lid-inhibitor.pid"
|
||||
|
||||
local function inhibit_lid()
|
||||
if bread.fs.exists(INHIBITOR) then return end
|
||||
bread.exec(
|
||||
"bash -c 'systemd-inhibit --what=handle-lid-switch --who=bread "
|
||||
.. "--why=external-display sleep infinity & echo $! > "
|
||||
.. INHIBITOR
|
||||
.. "'"
|
||||
)
|
||||
end
|
||||
|
||||
local function release_lid()
|
||||
bread.exec(
|
||||
"bash -c 'kill $(cat " .. INHIBITOR .. " 2>/dev/null) 2>/dev/null; rm -f " .. INHIBITOR .. "'"
|
||||
)
|
||||
end
|
||||
|
||||
local function is_internal(name)
|
||||
return type(name) == "string" and name:match(INTERNAL_RE) ~= nil
|
||||
end
|
||||
|
||||
local function drm_status(name)
|
||||
for card = 0, 5 do
|
||||
local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/status", card, name))
|
||||
if raw then
|
||||
return raw:match("^%s*(%S+)")
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function drm_first_mode(name)
|
||||
for card = 0, 5 do
|
||||
local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/modes", card, name))
|
||||
if raw then
|
||||
local w, h = raw:match("(%d+)x(%d+)")
|
||||
if w then
|
||||
return tonumber(w), tonumber(h)
|
||||
end
|
||||
end
|
||||
end
|
||||
return 1920, 1080
|
||||
end
|
||||
|
||||
local function list_connectors()
|
||||
local names = {}
|
||||
local ok, out = bread.exec_capture("ls /sys/class/drm", { timeout_ms = 500 })
|
||||
if not ok or not out then
|
||||
return names
|
||||
end
|
||||
for ent in out:gmatch("[^%s]+") do
|
||||
local name = ent:match("^card%d+%-(.+)$")
|
||||
if name and not name:match("^Writeback") then
|
||||
names[#names + 1] = name
|
||||
end
|
||||
end
|
||||
table.sort(names)
|
||||
return names
|
||||
end
|
||||
|
||||
local function connected()
|
||||
local internal, externals = nil, {}
|
||||
for _, name in ipairs(list_connectors()) do
|
||||
if drm_status(name) == "connected" then
|
||||
if is_internal(name) then
|
||||
internal = internal or name
|
||||
else
|
||||
externals[#externals + 1] = name
|
||||
end
|
||||
end
|
||||
end
|
||||
return internal or "eDP-1", externals
|
||||
end
|
||||
|
||||
-- BOS Hyprland talks Lua (`hl.monitor`). Stock Hyprland uses the
|
||||
-- `monitor=` keyword. Try eval first, then keyword.
|
||||
local function apply_monitor(opts)
|
||||
local extra = ""
|
||||
if opts.mirror and opts.mirror ~= "" then
|
||||
extra = string.format(", mirror = %q", opts.mirror)
|
||||
end
|
||||
local expr = string.format(
|
||||
"hl.monitor({ output = %q, mode = %q, position = %q, scale = %q%s })",
|
||||
opts.output,
|
||||
opts.mode or "preferred",
|
||||
opts.position or "0x0",
|
||||
opts.scale or SCALE,
|
||||
extra
|
||||
)
|
||||
local resp = bread.hyprland.eval(expr)
|
||||
if type(resp) == "string" and resp:match("error") then
|
||||
local spec = string.format(
|
||||
"%s, %s, %s, %s",
|
||||
opts.output,
|
||||
opts.mode or "preferred",
|
||||
opts.position or "0x0",
|
||||
opts.scale or SCALE
|
||||
)
|
||||
if opts.mirror and opts.mirror ~= "" then
|
||||
spec = spec .. ", mirror, " .. opts.mirror
|
||||
end
|
||||
bread.hyprland.keyword("monitor", spec)
|
||||
end
|
||||
end
|
||||
|
||||
local function apply(internal, externals)
|
||||
apply_monitor({
|
||||
output = internal,
|
||||
mode = "preferred",
|
||||
position = "0x0",
|
||||
scale = SCALE,
|
||||
})
|
||||
|
||||
if ARRANGE == "mirror" then
|
||||
for _, name in ipairs(externals) do
|
||||
apply_monitor({
|
||||
output = name,
|
||||
mode = "preferred",
|
||||
position = "0x0",
|
||||
scale = SCALE,
|
||||
mirror = internal,
|
||||
})
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local x = select(1, drm_first_mode(internal)) or 1920
|
||||
for _, name in ipairs(externals) do
|
||||
apply_monitor({
|
||||
output = name,
|
||||
mode = "preferred",
|
||||
position = x .. "x0",
|
||||
scale = SCALE,
|
||||
})
|
||||
local w = select(1, drm_first_mode(name)) or 1920
|
||||
x = x + w
|
||||
end
|
||||
end
|
||||
|
||||
function M.on_load()
|
||||
local last = nil
|
||||
local applied = false
|
||||
|
||||
local function evaluate()
|
||||
local internal, externals = connected()
|
||||
local sig = internal .. "|" .. table.concat(externals, ",")
|
||||
if sig == last then
|
||||
return
|
||||
end
|
||||
last = sig
|
||||
|
||||
if #externals == 0 then
|
||||
if applied then
|
||||
apply_monitor({
|
||||
output = internal,
|
||||
mode = "preferred",
|
||||
position = "0x0",
|
||||
scale = SCALE,
|
||||
})
|
||||
release_lid()
|
||||
applied = false
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
apply(internal, externals)
|
||||
inhibit_lid()
|
||||
applied = true
|
||||
bread.log("[external-monitors] " .. internal .. " + " .. table.concat(externals, ", "))
|
||||
end
|
||||
|
||||
local settle = bread.debounce(1500, evaluate)
|
||||
|
||||
bread.on("bread.hyprland.monitor.connected", function(event)
|
||||
local name = event.data and event.data.name
|
||||
if name and not is_internal(name) then
|
||||
bread.notify("Display connected: " .. name, { urgency = "low" })
|
||||
end
|
||||
settle()
|
||||
end)
|
||||
|
||||
bread.on("bread.hyprland.monitor.disconnected", function()
|
||||
settle()
|
||||
end)
|
||||
|
||||
bread.on("bread.device.**", function(event)
|
||||
local sub = event.data and event.data.subsystem
|
||||
if sub == "drm" then
|
||||
settle()
|
||||
end
|
||||
end)
|
||||
|
||||
bread.hyprland.on_raw("configreloaded", function()
|
||||
last = nil
|
||||
evaluate()
|
||||
end)
|
||||
|
||||
bread.every(3000, evaluate)
|
||||
settle()
|
||||
end
|
||||
|
||||
return M
|
||||
66
examples/modules/focus-mode-widget.lua
Normal file
66
examples/modules/focus-mode-widget.lua
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
-- focus-mode-widget — a widget that does something, not just shows
|
||||
-- something: click to toggle a "focus" bread profile, which mutes audio
|
||||
-- output as an observable effect. Stays in sync if the profile changes
|
||||
-- from elsewhere too — the CLI (`bread profile-activate default`), another
|
||||
-- module, another widget — not just from its own click, by reacting to
|
||||
-- bread.profile.activated rather than tracking its own local state.
|
||||
--
|
||||
-- Demonstrates: a widget as an action launcher wired to bread's actual
|
||||
-- profile primitive (bread.profile.activate), not just a passive readout;
|
||||
-- staying in sync with state that can change from other sources; combining
|
||||
-- bread.exec with a profile switch for a real, checkable effect.
|
||||
--
|
||||
-- Drop-in: copy into ~/.config/bread/modules/. Needs `wpctl` (pipewire —
|
||||
-- already a dependency of breadbar's own volume slider, so if the bar's
|
||||
-- volume control works, this will too).
|
||||
|
||||
local M = bread.module({ name = "focus-mode-widget", version = "1.0.0" })
|
||||
|
||||
local FOCUS_PROFILE = "focus"
|
||||
local DEFAULT_PROFILE = "default"
|
||||
|
||||
local function is_focused()
|
||||
return bread.state.profile().active == FOCUS_PROFILE
|
||||
end
|
||||
|
||||
local function widget_root()
|
||||
return {
|
||||
type = "label",
|
||||
text = "Focus",
|
||||
style = is_focused() and { color = "accent", weight = "bold" } or { color = "dim" },
|
||||
on_click = "toggle",
|
||||
}
|
||||
end
|
||||
|
||||
function M.on_load()
|
||||
bread.widget.register({
|
||||
id = "toggle",
|
||||
placement = "left_of_clock",
|
||||
tooltip = "Click to toggle Focus mode (mutes audio, activates the 'focus' profile)",
|
||||
root = widget_root(),
|
||||
})
|
||||
|
||||
-- Not just self.click -> self.update: any profile change, from any
|
||||
-- source, is reflected here. Try `bread profile-activate default` from
|
||||
-- a terminal while this is in "focus" state to see it flip on its own.
|
||||
bread.on("bread.profile.activated", function()
|
||||
bread.widget.update("toggle", { root = widget_root() })
|
||||
end)
|
||||
|
||||
bread.on("bread.bar.widget_clicked", function(e)
|
||||
if e.data.widget_id ~= "focus-mode-widget.toggle" or e.data.action ~= "toggle" then
|
||||
return
|
||||
end
|
||||
if is_focused() then
|
||||
bread.profile.activate(DEFAULT_PROFILE)
|
||||
bread.exec("wpctl set-mute @DEFAULT_AUDIO_SINK@ 0")
|
||||
bread.notify("Focus mode off", { title = "bread" })
|
||||
else
|
||||
bread.profile.activate(FOCUS_PROFILE)
|
||||
bread.exec("wpctl set-mute @DEFAULT_AUDIO_SINK@ 1")
|
||||
bread.notify("Focus mode on — audio muted", { title = "bread" })
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
154
examples/modules/git-branch-widget.lua
Normal file
154
examples/modules/git-branch-widget.lua
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
-- git-branch-widget — shows "<repo> <branch>" for whichever git repo the
|
||||
-- currently focused terminal's *active tab* is sitting in, yellow when the
|
||||
-- worktree is dirty. Hides entirely when focus isn't on a terminal, or the
|
||||
-- focused tab isn't inside a git repo.
|
||||
--
|
||||
-- This is a plain Lua module doing its own OS-level legwork end to end —
|
||||
-- no dedicated Rust adapter behind it. It combines four general-purpose
|
||||
-- primitives that all already exist (or were added alongside this module
|
||||
-- as small, non-kitty-specific additions): bread.hyprland.active_window()
|
||||
-- for the focused window's class + pid, bread.fs.exists to probe for a
|
||||
-- listening socket, bread.exec_capture to run `kitty @ ls` and read its
|
||||
-- output, and bread.json.decode to parse it.
|
||||
--
|
||||
-- Why kitty remote control instead of /proc: a kitty *window* can host
|
||||
-- several *tabs*, each a separate child shell process, and the kernel has
|
||||
-- no notion of "which pty is currently displayed" — that's purely internal
|
||||
-- kitty state. Walking /proc can find the window's child processes but
|
||||
-- can't tell which one you're actually looking at. Kitty's own `kitty @ ls`
|
||||
-- tracks focus precisely at the OS-window/tab/window level, so asking it
|
||||
-- directly is the only way to get this exactly right for multi-tab windows.
|
||||
--
|
||||
-- Prerequisite — add to ~/.config/kitty/kitty.conf:
|
||||
-- allow_remote_control socket-only
|
||||
-- listen_on unix:/tmp/kitty-bread-{kitty_pid}
|
||||
-- `{kitty_pid}` makes the socket path unique per kitty process, so this
|
||||
-- works whether or not you run kitty in single-instance mode, and however
|
||||
-- many separate kitty processes you have open — this module derives the
|
||||
-- exact socket to ask from the focused window's own pid. Kitty only picks
|
||||
-- up `listen_on` on (re)start, not a config reload, so existing kitty
|
||||
-- windows need to be restarted once after adding this.
|
||||
--
|
||||
-- Drop-in: copy into ~/.config/bread/modules/. Needs `git` and the kitty
|
||||
-- remote-control config above. Assumes the terminal's WM_CLASS is "kitty"
|
||||
-- (edit TERMINAL_CLASS below for another terminal, if it has an equivalent
|
||||
-- remote-control/introspection story).
|
||||
|
||||
local M = bread.module({ name = "git-branch-widget", version = "1.0.0" })
|
||||
|
||||
local TERMINAL_CLASS = "kitty"
|
||||
|
||||
local function shell_quote(s)
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- The exact cwd of the focused tab in the focused kitty window, or nil if
|
||||
-- focus isn't on kitty, that kitty process hasn't been restarted since the
|
||||
-- listen_on config was added, or nothing came back focused (shouldn't
|
||||
-- happen for a window Hyprland itself says is focused, but `kitty @ ls`
|
||||
-- reflects kitty's own state, not Hyprland's, so treat it as fallible).
|
||||
local function focused_tab_cwd()
|
||||
local win = bread.hyprland.active_window()
|
||||
if type(win) ~= "table" or win.class ~= TERMINAL_CLASS or not win.pid then
|
||||
return nil
|
||||
end
|
||||
|
||||
local socket_path = "/tmp/kitty-bread-" .. win.pid
|
||||
if not bread.fs.exists(socket_path) then
|
||||
return nil
|
||||
end
|
||||
|
||||
local ok, output = bread.exec_capture("kitty @ --to unix:" .. socket_path .. " ls")
|
||||
if not ok then
|
||||
return nil
|
||||
end
|
||||
|
||||
local os_windows = bread.json.decode(output)
|
||||
if type(os_windows) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
for _, osw in ipairs(os_windows) do
|
||||
if osw.is_focused then
|
||||
for _, tab in ipairs(osw.tabs or {}) do
|
||||
if tab.is_focused then
|
||||
for _, w in ipairs(tab.windows or {}) do
|
||||
if w.is_focused then
|
||||
return w.cwd
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
local function git_info(cwd)
|
||||
local quoted = shell_quote(cwd)
|
||||
|
||||
local ok, toplevel = bread.exec_capture("git -C " .. quoted .. " rev-parse --show-toplevel")
|
||||
if not ok then
|
||||
return nil
|
||||
end
|
||||
toplevel = toplevel:gsub("%s+$", "")
|
||||
local repo = toplevel:match("([^/]+)/?$") or toplevel
|
||||
|
||||
local repo_quoted = shell_quote(toplevel)
|
||||
local branch_ok, branch = bread.exec_capture("git -C " .. repo_quoted .. " rev-parse --abbrev-ref HEAD")
|
||||
if not branch_ok then
|
||||
return nil
|
||||
end
|
||||
branch = branch:gsub("%s+$", "")
|
||||
|
||||
local _, status = bread.exec_capture("git -C " .. repo_quoted .. " status --porcelain")
|
||||
local dirty = status:match("%S") ~= nil
|
||||
|
||||
return { repo = repo, branch = branch, dirty = dirty }
|
||||
end
|
||||
|
||||
local function widget_root(info)
|
||||
if not info then
|
||||
return { type = "label", text = "" }
|
||||
end
|
||||
return {
|
||||
type = "label",
|
||||
text = info.repo .. " " .. info.branch,
|
||||
style = { color = info.dirty and "yellow" or "dim" },
|
||||
}
|
||||
end
|
||||
|
||||
local function update()
|
||||
local cwd = focused_tab_cwd()
|
||||
local info = cwd and git_info(cwd) or nil
|
||||
|
||||
bread.widget.update("branch", {
|
||||
visible = info ~= nil,
|
||||
tooltip = info and ("git: " .. info.repo .. "@" .. info.branch .. (info.dirty and " (dirty)" or "")) or "",
|
||||
root = widget_root(info),
|
||||
})
|
||||
end
|
||||
|
||||
function M.on_load()
|
||||
local cwd = focused_tab_cwd()
|
||||
local info = cwd and git_info(cwd) or nil
|
||||
|
||||
bread.widget.register({
|
||||
id = "branch",
|
||||
placement = "right_of_clock",
|
||||
visible = info ~= nil,
|
||||
tooltip = info and ("git: " .. info.repo .. "@" .. info.branch) or "",
|
||||
root = widget_root(info),
|
||||
})
|
||||
|
||||
-- Event-driven for instant updates on focus change, plus a poll to
|
||||
-- catch a branch switch inside the same still-focused tab (e.g. `git
|
||||
-- checkout` run without ever changing window focus), which produces no
|
||||
-- focus event at all.
|
||||
bread.on("bread.hyprland.window.focused", update)
|
||||
bread.on("bread.hyprland.window.focus.changed", update)
|
||||
bread.every(2000, update)
|
||||
end
|
||||
|
||||
return M
|
||||
83
examples/modules/workflow-status-widget.lua
Normal file
83
examples/modules/workflow-status-widget.lua
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
-- workflow-status-widget — surfaces bread's workflow engine (bread.workflow,
|
||||
-- see Examples.md's "Example 4" and dock-workflow.lua in this directory) in
|
||||
-- the bar, which had zero visibility anywhere in the UI before this. Shows
|
||||
-- whichever non-done workflow was most recently updated, with its current
|
||||
-- step if it's set one via bread.workflow.step(); hides entirely when
|
||||
-- nothing is running/failed/timed_out, so it stays out of the way until
|
||||
-- there's actually something to look at.
|
||||
--
|
||||
-- Demonstrates: WidgetPlacement "tray", polling an existing bread subsystem
|
||||
-- (bread.workflow.list()) instead of raw hardware or a single module's own
|
||||
-- state, and a widget that disappears (visible = false) rather than
|
||||
-- showing a stale or empty readout.
|
||||
--
|
||||
-- Drop-in: copy into ~/.config/bread/modules/. Zero configuration — it
|
||||
-- reflects whatever workflow any other loaded module starts, including
|
||||
-- dock-workflow.lua in this same directory.
|
||||
|
||||
local M = bread.module({ name = "workflow-status-widget", version = "1.0.0" })
|
||||
|
||||
local function most_relevant()
|
||||
local workflows = bread.workflow.list()
|
||||
local best = nil
|
||||
for _, w in ipairs(workflows) do
|
||||
if w.state ~= "done" and (not best or w.updated_at > best.updated_at) then
|
||||
best = w
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
local function widget_update()
|
||||
local w = most_relevant()
|
||||
if not w then
|
||||
-- bread.widget.update leaves an omitted field unchanged, not
|
||||
-- cleared — a Lua table can't distinguish "tooltip = nil" from
|
||||
-- "no tooltip key at all", so an explicit "" is what actually wipes
|
||||
-- the previous tooltip instead of leaving it stale under a hidden
|
||||
-- widget (harmless in practice since GTK won't show a tooltip on
|
||||
-- an invisible widget, but `bread state widgets` would otherwise
|
||||
-- report it forever).
|
||||
return { visible = false, tooltip = "", root = { type = "label", text = "" } }
|
||||
end
|
||||
|
||||
local color = "dim"
|
||||
if w.state == "failed" or w.state == "timed_out" then
|
||||
color = "red"
|
||||
elseif w.state == "running" then
|
||||
color = "accent"
|
||||
end
|
||||
|
||||
local text = w.name
|
||||
if w.step then
|
||||
text = text .. ": " .. w.step
|
||||
end
|
||||
|
||||
return {
|
||||
visible = true,
|
||||
tooltip = "Workflow " .. w.name .. " — " .. w.state,
|
||||
root = { type = "label", text = text, style = { color = color } },
|
||||
}
|
||||
end
|
||||
|
||||
function M.on_load()
|
||||
local u = widget_update()
|
||||
bread.widget.register({
|
||||
id = "status",
|
||||
placement = "tray",
|
||||
visible = u.visible,
|
||||
tooltip = u.tooltip,
|
||||
root = u.root,
|
||||
})
|
||||
|
||||
bread.every(3000, function()
|
||||
local next_u = widget_update()
|
||||
bread.widget.update("status", {
|
||||
visible = next_u.visible,
|
||||
tooltip = next_u.tooltip,
|
||||
root = next_u.root,
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
95
graphify-out/.graphify_labels.json
Normal file
95
graphify-out/.graphify_labels.json
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
{
|
||||
"0": "LuaEngine",
|
||||
"1": "RawEvent",
|
||||
"2": "config.rs",
|
||||
"3": "Server",
|
||||
"4": "widget.rs",
|
||||
"5": "Bread Daemon (breadd)",
|
||||
"6": "filesystem.rs",
|
||||
"7": "git.rs",
|
||||
"8": "Result",
|
||||
"9": "state_engine.rs",
|
||||
"10": "modules_mgmt.rs",
|
||||
"11": "systemd.rs",
|
||||
"12": "hooks_git.rs",
|
||||
"13": "bread-cli/src/main.rs",
|
||||
"14": "types.rs",
|
||||
"15": "podman.rs",
|
||||
"16": "Value",
|
||||
"17": "Result",
|
||||
"18": "hooks_shell.rs",
|
||||
"19": "bluetooth.rs",
|
||||
"20": "SubscriptionId",
|
||||
"21": "glob.rs",
|
||||
"22": "StateHandle",
|
||||
"23": "Adapter",
|
||||
"24": "network.rs",
|
||||
"25": "power.rs",
|
||||
"26": "hyprland.rs",
|
||||
"27": "parse_upower_message",
|
||||
"28": "main Branch",
|
||||
"29": "git-branch-widget.lua",
|
||||
"30": "TestHarness",
|
||||
"31": "Sync",
|
||||
"32": "active-window-widget.lua",
|
||||
"33": ".new",
|
||||
"34": "focus-mode-widget.lua",
|
||||
"35": "workflow-status-widget.lua",
|
||||
"36": "bread.widget API",
|
||||
"37": "bluetooth-toggle-widget.lua",
|
||||
"38": "pause-media-on-headphone-unplug.lua",
|
||||
"39": "Autostart Example Module",
|
||||
"40": "dock-monitors.lua",
|
||||
"41": "dock-workflow.lua",
|
||||
"42": "low-battery-warning.lua",
|
||||
"43": "install.sh",
|
||||
"44": "Filesystem Adapter",
|
||||
"45": "Git Adapter",
|
||||
"46": "Podman Adapter",
|
||||
"47": "Systemd Adapter",
|
||||
"48": "bread.after(delay_ms, fn)",
|
||||
"49": "bread.bluetooth namespace",
|
||||
"50": "bread.every(interval_ms, fn)",
|
||||
"51": "bread.exec(cmd)",
|
||||
"52": "bread.hyprland namespace",
|
||||
"53": "bread.notify(message, opts)",
|
||||
"54": "bread.state.watch(path, fn)",
|
||||
"55": "bread-cli/src/lib.rs",
|
||||
"56": "core/mod.rs",
|
||||
"57": "bread.system.startup",
|
||||
"58": "Monitors Configuration Example",
|
||||
"59": "Binds Module (Built-in)",
|
||||
"60": "lua/mod.rs",
|
||||
"61": "external-monitors.lua",
|
||||
"62": "AGENTS.md — Repo hygiene",
|
||||
"63": "CLAUDE.md — Repo hygiene",
|
||||
"64": "ModuleHostLua",
|
||||
"65": "ModuleHostRegistry",
|
||||
"66": "xtask/src/main.rs",
|
||||
"67": "Bread",
|
||||
"68": "rules.rs",
|
||||
"69": "Normalized events",
|
||||
"70": "Bread Documentation",
|
||||
"71": "udev.rs",
|
||||
"72": "Dictionary: Lua API",
|
||||
"73": "Out-of-process module sandboxing *(Since: v1.6)*",
|
||||
"74": "Dictionary: Built-in modules",
|
||||
"75": "Events",
|
||||
"76": "Machine and filesystem",
|
||||
"77": "Contributing",
|
||||
"78": "Bluetooth",
|
||||
"79": "Widgets *(Since: v1.3)*",
|
||||
"80": "Getting started",
|
||||
"81": "Capability-scoped modules *(Since: v1.5)*",
|
||||
"82": "Workflows *(Since: v1.2)*",
|
||||
"83": "Timers",
|
||||
"84": "State",
|
||||
"85": "init.lua",
|
||||
"86": "Execution",
|
||||
"87": "packaging/README.md",
|
||||
"88": "PathBuf",
|
||||
"89": "build.sh",
|
||||
"90": "beta Release Track",
|
||||
"91": "dev Release Track",
|
||||
"92": "stable Release Track"
|
||||
}
|
||||
1
graphify-out/.graphify_labels.json.sig
Normal file
1
graphify-out/.graphify_labels.json.sig
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"0": "dff8ca2baa85bb77", "1": "2213379d8e986e1b", "2": "055a5a9a2c0abb47", "3": "4e76d4b9b12f60a0", "4": "15d25492162f61da", "5": "c9e7c9b0b9f9532c", "6": "5dc03258b399572c", "7": "7e3d24ea3cd2b4cb", "8": "d2b23fb40be07856", "9": "c63bb5497aa74cc4", "10": "37be3ba834804202", "11": "e820233ee9e24e6c", "12": "90afbdba0e9131b5", "13": "a893df0933556d63", "14": "2b140ca807922de5", "15": "77fe3c708c10a92e", "16": "f46d2bf3ec15783d", "17": "9d5d3a5ebfca7e96", "18": "4b221b9f82894754", "19": "1af003225b1510f7", "20": "cec4f1891a3503a7", "21": "af54da4748cbfe19", "22": "755f92ef4fb448a8", "23": "8d61c62e8cd29e24", "24": "a6096a26ebcbe7ca", "25": "4bb3423b0748a5b8", "26": "f14069a977bb7109", "27": "c24364ae8589386f", "28": "b7dfd1e572840cf9", "29": "38bd4a3ed7d82df4", "30": "c7a227c868287ece", "31": "8298f357ade782b6", "32": "8c3f9c59418c0d78", "33": "d441528441bffad6", "34": "0ca065898bae7a4c", "35": "91d64164a425221e", "36": "8f4f1c63c1339596", "37": "f29f07965a593c46", "38": "69c04956206264ea", "39": "d5e6784916307537", "40": "9dd3e8ecc75d42d1", "41": "975bc2eaec9ade41", "42": "c119297bd4e771d7", "43": "5df098735d8e8ccf", "44": "3ea089711b226db5", "45": "937d314565bd1f7c", "46": "644deb50d1523213", "47": "a8ba58be12f4eaaf", "48": "07122a351039caad", "49": "85df939415e8bf76", "50": "ccecca45fa071d89", "51": "d12f6c2877bf28fc", "52": "8fde0947b8e44878", "53": "1894c9960c737861", "54": "8eb6456a35bc1424", "55": "266c21cbaa157140", "56": "3dd39e27b03e805b", "57": "9914e60060e52831", "58": "082ed57548a0aa26", "59": "1d8fa6f90af6c7a9", "60": "5b3a15eab473b681", "61": "53961581c79663fe", "62": "1c3daa39aab41ca2", "63": "cd7e1aedbdbb382a", "64": "793a68e5b584e15a", "65": "e5048b5a19d78c81", "66": "abaa846ce9fdc66e", "67": "a46e2827ad3261b0", "68": "56d1c4600f18d094", "69": "b29197139c1a5fc7", "70": "f6f5196c6a2f3084", "71": "f8b572524780f968", "72": "95bd136ecb3896b6", "73": "a2e1d482f5282a2b", "74": "96279e53156461bf", "75": "3c234d3ea2845544", "76": "37f5bfa38e9dda4b", "77": "c95e6689883cdc3d", "78": "784aa039bef2aabf", "79": "1e1cd6b488d8850c", "80": "42213e202400e524", "81": "bb8f3086e24c3f87", "82": "8f000e4f1c6edcd1", "83": "19573b318feb4438", "84": "47016ab7f59f2ab4", "85": "da1e8594a8fb071a", "86": "d730f8d81a4b91ff", "87": "af426938b90fa391", "88": "dce55fb2c83882bb", "89": "9d30c290d7898f81", "90": "356e8182b8067c22", "91": "825919e98a656895", "92": "c5d2ff6dacf845a7"}
|
||||
1
graphify-out/.graphify_python
Normal file
1
graphify-out/.graphify_python
Normal file
|
|
@ -0,0 +1 @@
|
|||
/home/breadway/.cache/uv/archive-v0/4yQjntA8tzDKxQRL/bin/python
|
||||
1
graphify-out/.graphify_root
Normal file
1
graphify-out/.graphify_root
Normal file
|
|
@ -0,0 +1 @@
|
|||
.
|
||||
90
graphify-out/2026-08-16/.graphify_labels.json
Normal file
90
graphify-out/2026-08-16/.graphify_labels.json
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
{
|
||||
"0": "lua/mod.rs",
|
||||
"1": "RawEvent",
|
||||
"2": "config.rs",
|
||||
"3": "Server",
|
||||
"4": "widget.rs",
|
||||
"5": "Bread Daemon (breadd)",
|
||||
"6": "filesystem.rs",
|
||||
"7": "git.rs",
|
||||
"8": "Result",
|
||||
"9": "state_engine.rs",
|
||||
"10": "modules_mgmt.rs",
|
||||
"11": "systemd.rs",
|
||||
"12": "hooks_git.rs",
|
||||
"13": "bread-cli/src/main.rs",
|
||||
"14": "types.rs",
|
||||
"15": "podman.rs",
|
||||
"16": "StateHandle",
|
||||
"17": "run_udev_monitor",
|
||||
"18": "hooks_shell.rs",
|
||||
"19": "bluetooth.rs",
|
||||
"20": "SubscriptionId",
|
||||
"21": "glob.rs",
|
||||
"22": "run_state_engine",
|
||||
"23": "RtnetlinkAdapter",
|
||||
"24": "network.rs",
|
||||
"25": "power.rs",
|
||||
"26": "hyprland.rs",
|
||||
"27": "Adapter",
|
||||
"28": "dl.breadway.dev Distribution",
|
||||
"29": "git-branch-widget.lua",
|
||||
"30": "TestHarness",
|
||||
"31": "Sync",
|
||||
"32": "active-window-widget.lua",
|
||||
"33": "cpu-temp-widget.lua",
|
||||
"34": "focus-mode-widget.lua",
|
||||
"35": "workflow-status-widget.lua",
|
||||
"36": "bread.widget API",
|
||||
"37": "bluetooth-toggle-widget.lua",
|
||||
"38": "pause-media-on-headphone-unplug.lua",
|
||||
"39": "Autostart Example Module",
|
||||
"40": "dock-monitors.lua",
|
||||
"41": "dock-workflow.lua",
|
||||
"42": "low-battery-warning.lua",
|
||||
"43": "install.sh",
|
||||
"44": "Filesystem Adapter",
|
||||
"45": "Git Adapter",
|
||||
"46": "Podman Adapter",
|
||||
"47": "Systemd Adapter",
|
||||
"48": "bread.after(delay_ms, fn)",
|
||||
"49": "bread.bluetooth namespace",
|
||||
"50": "bread.every(interval_ms, fn)",
|
||||
"51": "bread.exec(cmd)",
|
||||
"52": "bread.hyprland namespace",
|
||||
"53": "bread.notify(message, opts)",
|
||||
"54": "bread.state.watch(path, fn)",
|
||||
"55": "bread-cli/src/lib.rs",
|
||||
"56": "core/mod.rs",
|
||||
"57": "bread.system.startup",
|
||||
"58": "Monitors Configuration Example",
|
||||
"59": "Binds Module (Built-in)",
|
||||
"60": "Bakery Package Manager",
|
||||
"61": "Phase 3: GUI Control Center",
|
||||
"62": "Phase 5: Cross-Device Mesh",
|
||||
"63": "Dev Version Computation",
|
||||
"64": "ModuleHostLua",
|
||||
"65": "ModuleHostRegistry",
|
||||
"66": "xtask/src/main.rs",
|
||||
"67": "Bread",
|
||||
"68": "rules.rs",
|
||||
"69": "Normalized events",
|
||||
"70": "Bread Documentation",
|
||||
"71": "udev.rs",
|
||||
"72": "Dictionary: Lua API",
|
||||
"73": "Out-of-process module sandboxing *(Since: v1.6)*",
|
||||
"74": "Dictionary: Built-in modules",
|
||||
"75": "Events",
|
||||
"76": "Machine and filesystem",
|
||||
"77": "Contributing",
|
||||
"78": "Bluetooth",
|
||||
"79": "Widgets *(Since: v1.3)*",
|
||||
"80": "Getting started",
|
||||
"81": "Capability-scoped modules *(Since: v1.5)*",
|
||||
"82": "Workflows *(Since: v1.2)*",
|
||||
"83": "Timers",
|
||||
"84": "State",
|
||||
"85": "init.lua",
|
||||
"86": "Execution",
|
||||
"87": "packaging/README.md"
|
||||
}
|
||||
397
graphify-out/2026-08-16/GRAPH_REPORT.md
Normal file
397
graphify-out/2026-08-16/GRAPH_REPORT.md
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
# Graph Report - bread (2026-08-15)
|
||||
|
||||
## Corpus Check
|
||||
- 62 files · ~88,250 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 1467 nodes · 3347 edges · 88 communities (64 shown, 24 thin omitted)
|
||||
- Extraction: 99% EXTRACTED · 1% INFERRED · 0% AMBIGUOUS · INFERRED: 48 edges (avg confidence: 0.78)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `a6973360`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- lua/mod.rs
|
||||
- RawEvent
|
||||
- config.rs
|
||||
- Server
|
||||
- widget.rs
|
||||
- Bread Daemon (breadd)
|
||||
- filesystem.rs
|
||||
- git.rs
|
||||
- Result
|
||||
- state_engine.rs
|
||||
- modules_mgmt.rs
|
||||
- systemd.rs
|
||||
- hooks_git.rs
|
||||
- bread-cli/src/main.rs
|
||||
- types.rs
|
||||
- podman.rs
|
||||
- StateHandle
|
||||
- run_udev_monitor
|
||||
- hooks_shell.rs
|
||||
- bluetooth.rs
|
||||
- SubscriptionId
|
||||
- glob.rs
|
||||
- run_state_engine
|
||||
- RtnetlinkAdapter
|
||||
- network.rs
|
||||
- power.rs
|
||||
- hyprland.rs
|
||||
- Adapter
|
||||
- dl.breadway.dev Distribution
|
||||
- git-branch-widget.lua
|
||||
- TestHarness
|
||||
- Sync
|
||||
- active-window-widget.lua
|
||||
- cpu-temp-widget.lua
|
||||
- focus-mode-widget.lua
|
||||
- workflow-status-widget.lua
|
||||
- bread.widget API
|
||||
- bluetooth-toggle-widget.lua
|
||||
- pause-media-on-headphone-unplug.lua
|
||||
- Autostart Example Module
|
||||
- install.sh
|
||||
- Filesystem Adapter
|
||||
- Git Adapter
|
||||
- Podman Adapter
|
||||
- Systemd Adapter
|
||||
- bread.after(delay_ms, fn)
|
||||
- bread.bluetooth namespace
|
||||
- bread.every(interval_ms, fn)
|
||||
- bread.exec(cmd)
|
||||
- bread.hyprland namespace
|
||||
- bread.notify(message, opts)
|
||||
- bread.state.watch(path, fn)
|
||||
- bread.system.startup
|
||||
- Monitors Configuration Example
|
||||
- Binds Module (Built-in)
|
||||
- Bakery Package Manager
|
||||
- Phase 3: GUI Control Center
|
||||
- Phase 5: Cross-Device Mesh
|
||||
- Dev Version Computation
|
||||
- ModuleHostLua
|
||||
- ModuleHostRegistry
|
||||
- xtask/src/main.rs
|
||||
- Bread
|
||||
- rules.rs
|
||||
- Normalized events
|
||||
- Bread Documentation
|
||||
- udev.rs
|
||||
- Dictionary: Lua API
|
||||
- Out-of-process module sandboxing *(Since: v1.6)*
|
||||
- Dictionary: Built-in modules
|
||||
- Events
|
||||
- Machine and filesystem
|
||||
- Contributing
|
||||
- Bluetooth
|
||||
- Widgets *(Since: v1.3)*
|
||||
- Getting started
|
||||
- Capability-scoped modules *(Since: v1.5)*
|
||||
- Workflows *(Since: v1.2)*
|
||||
- Timers
|
||||
- State
|
||||
- init.lua
|
||||
- Execution
|
||||
- packaging/README.md
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `LuaEngine` - 59 edges
|
||||
2. `RawEvent` - 49 edges
|
||||
3. `BreadEvent` - 38 edges
|
||||
4. `raw()` - 37 edges
|
||||
5. `RuntimeState` - 34 edges
|
||||
6. `StateHandle` - 28 edges
|
||||
7. `now_unix_ms()` - 26 edges
|
||||
8. `Adapter` - 25 edges
|
||||
9. `SubscriptionId` - 25 edges
|
||||
10. `ModuleHostLua` - 24 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `parse_bluetooth_message()` --calls--> `now_unix_ms()` [INFERRED]
|
||||
breadd/src/adapters/bluetooth.rs → bread-shared/src/lib.rs
|
||||
- `try_enumerate()` --calls--> `now_unix_ms()` [INFERRED]
|
||||
breadd/src/adapters/bluetooth.rs → bread-shared/src/lib.rs
|
||||
- `classify()` --calls--> `now_unix_ms()` [INFERRED]
|
||||
breadd/src/adapters/filesystem.rs → bread-shared/src/lib.rs
|
||||
- `network_raw_event()` --calls--> `now_unix_ms()` [INFERRED]
|
||||
breadd/src/adapters/network.rs → bread-shared/src/lib.rs
|
||||
- `power_raw_event()` --calls--> `now_unix_ms()` [INFERRED]
|
||||
breadd/src/adapters/power.rs → bread-shared/src/lib.rs
|
||||
|
||||
## Import Cycles
|
||||
- 2-file cycle: `breadd/src/core/state_engine.rs -> breadd/src/lua/mod.rs -> breadd/src/core/state_engine.rs`
|
||||
- 2-file cycle: `bread-shared/src/lib.rs -> bread-shared/src/module_host_ipc.rs -> bread-shared/src/lib.rs`
|
||||
|
||||
## Hyperedges (group relationships)
|
||||
- **** — ci_dev_release, workflow_version_compute, release_track_dev, distribution_dl_breadway_dev, package_bakery [INFERRED]
|
||||
- **** — adapter_udev, adapter_hyprland, adapter_power, sys_bread_daemon, api_bread_on, sys_lua_runtime [INFERRED]
|
||||
- **** — config_init_lua, config_modules_dir, pattern_module_skeleton, api_bread_on, sys_lua_runtime [INFERRED]
|
||||
- **** — api_bread_workflow, api_bread_spawn, api_bread_wait, example_dock_workflow, api_bread_notify [INFERRED]
|
||||
- **** — api_bread_widget, api_bread_every, example_widget_cpu_temp, api_bread_state_watch [INFERRED]
|
||||
- **** — branch_main, ci_dev_release, ci_rc_release, ci_stable_release, release_track_dev, release_track_beta, release_track_stable [INFERRED]
|
||||
|
||||
## Communities (88 total, 24 thin omitted)
|
||||
|
||||
### Community 0 - "lua/mod.rs"
|
||||
Cohesion: 0.06
|
||||
Nodes (86): now_unix_ms(), ModulePermission, Option, String, WidgetSpec, RuntimeState, bluetooth_connect(), bluetooth_disconnect() (+78 more)
|
||||
|
||||
### Community 1 - "RawEvent"
|
||||
Cohesion: 0.05
|
||||
Nodes (67): adapter_source_is_hashable_and_eq(), AdapterSource, bread_event_new_accepts_owned_and_borrowed_names(), bread_event_new_assigns_unique_id_and_no_cause(), bread_event_new_sets_current_timestamp(), bread_event_with_timestamp_preserves_timestamp_and_assigns_id(), BreadEvent, DaemonSection (+59 more)
|
||||
|
||||
### Community 2 - "config.rs"
|
||||
Cohesion: 0.07
|
||||
Nodes (50): AdaptersConfig, AdapterToggle, compat_section_defaults_legacy_hyprland_names_to_true(), CompatConfig, Config, config_path(), config_path_falls_back_to_home_when_no_xdg(), config_path_respects_xdg_config_home() (+42 more)
|
||||
|
||||
### Community 3 - "Server"
|
||||
Cohesion: 0.05
|
||||
Nodes (41): A, command_target(), event_domain(), is_known_app(), is_reserved_domain(), Option, validate_app_namespace(), validate_command_event() (+33 more)
|
||||
|
||||
### Community 4 - "widget.rs"
|
||||
Cohesion: 0.07
|
||||
Nodes (32): accepts_node_count_at_max(), accepts_tree_at_max_depth(), Align, Background, box_of(), default_orientation(), FontWeight, is_valid_class() (+24 more)
|
||||
|
||||
### Community 5 - "Bread Daemon (breadd)"
|
||||
Cohesion: 0.06
|
||||
Nodes (39): Bluetooth Adapter, Hyprland Adapter, Network Adapter, Power Adapter, udev Adapter, bread.on(pattern, fn), bread.spawn(fn), bread.wait(pattern, opts) (+31 more)
|
||||
|
||||
### Community 6 - "filesystem.rs"
|
||||
Cohesion: 0.12
|
||||
Nodes (28): classify(), classify_build_artifact_created_in_target(), classify_debounces_rapid_repeat_events_for_same_path(), classify_file_changed_for_ordinary_source_file(), classify_silent_for_modify_in_target_not_create(), classify_silent_under_git(), classify_silent_under_node_modules(), detect_markers() (+20 more)
|
||||
|
||||
### Community 7 - "git.rs"
|
||||
Cohesion: 0.12
|
||||
Nodes (22): check_ahead_behind(), check_dirty(), discover_repos(), expand_roots(), expand_roots_globs_single_trailing_star(), expand_roots_skips_unreadable_glob_parent_without_panicking(), expand_roots_uses_literal_path_without_trailing_star(), GitAdapter (+14 more)
|
||||
|
||||
### Community 8 - "Result"
|
||||
Cohesion: 0.15
|
||||
Nodes (50): daemon_survives_repeated_reloads_and_pipeline_resumes(), emit_with_app_source_allows_command_to_another_app(), emit_with_app_source_rejects_wrong_namespace(), emit_with_app_source_still_rejects_foreign_app_namespace(), emit_with_internal_source_is_rejected(), emit_with_known_app_source_routes_through_normalizer(), emit_with_unregistered_app_source_is_rejected(), emit_without_event_errors() (+42 more)
|
||||
|
||||
### Community 9 - "state_engine.rs"
|
||||
Cohesion: 0.12
|
||||
Nodes (19): apply_device_change(), apply_event_to_state(), device_connect_adds_device_with_all_fields(), device_connect_is_idempotent_for_same_id(), device_disconnect_of_unknown_id_is_noop(), device_disconnect_removes_matching_id(), ev(), monitor_connect_adds_new_monitor() (+11 more)
|
||||
|
||||
### Community 10 - "modules_mgmt.rs"
|
||||
Cohesion: 0.11
|
||||
Nodes (36): audit_detects_fs_read_and_widget_from_cpu_temp_widget_style_module(), audit_extracts_exec_bin_hint_and_ignores_baseline_calls(), audit_module(), audit_scans_required_sibling_files_in_module_directory(), classify_call_site(), collect_lua_files(), copy_dir(), extract_first_string_arg() (+28 more)
|
||||
|
||||
### Community 11 - "systemd.rs"
|
||||
Cohesion: 0.12
|
||||
Nodes (17): active_state_to_kind(), failure_result(), get_unit_path(), handle_message(), is_failed_transition(), query_active_state(), Connection, HashMap (+9 more)
|
||||
|
||||
### Community 12 - "hooks_git.rs"
|
||||
Cohesion: 0.15
|
||||
Nodes (24): all_hook_scripts_exit_0_unconditionally(), all_hook_scripts_start_with_shebang_and_marker(), branch_changed_emit_line(), commit_created_emit_line(), emit_line_for(), git_dir(), hook_script(), hook_script_rejects_unknown_name() (+16 more)
|
||||
|
||||
### Community 13 - "bread-cli/src/main.rs"
|
||||
Cohesion: 0.19
|
||||
Nodes (29): CausalityTracker, Cli, Commands, config_directory(), daemon_socket_path(), format_timestamp(), handle_modules_cmd(), HooksCommand (+21 more)
|
||||
|
||||
### Community 14 - "types.rs"
|
||||
Cohesion: 0.17
|
||||
Nodes (21): Device, DeviceRule, DeviceTopology, InterfaceState, MatchCondition, ModuleStatus, Monitor, NetworkState (+13 more)
|
||||
|
||||
### Community 15 - "podman.rs"
|
||||
Cohesion: 0.15
|
||||
Nodes (17): container_event(), ignores_remove_event(), ignores_stop_event_to_avoid_double_emit_with_died(), ignores_unknown_action(), map_podman_event(), maps_died_event(), maps_health_status_event(), maps_start_event() (+9 more)
|
||||
|
||||
### Community 16 - "StateHandle"
|
||||
Cohesion: 0.14
|
||||
Nodes (10): condition_matches(), resolve_device(), Option, Result, String, Value, Vec, StateHandle (+2 more)
|
||||
|
||||
### Community 17 - "run_udev_monitor"
|
||||
Cohesion: 0.35
|
||||
Nodes (9): enumerate_with_udev(), Result, Self, Sender, String, Vec, run_udev_monitor(), ScannedDevice (+1 more)
|
||||
|
||||
### Community 18 - "hooks_shell.rs"
|
||||
Cohesion: 0.18
|
||||
Nodes (11): hook_scripts_background_every_emit_call(), hooks_dir(), install_shell(), join_line_continuations(), Option, PathBuf, Result, String (+3 more)
|
||||
|
||||
### Community 19 - "bluetooth.rs"
|
||||
Cohesion: 0.15
|
||||
Nodes (10): address_from_path(), BluetoothAdapter, parse_bluetooth_message(), Message, Option, Result, Self, Sender (+2 more)
|
||||
|
||||
### Community 20 - "SubscriptionId"
|
||||
Cohesion: 0.29
|
||||
Nodes (13): HashMap, String, Vec, Subscription, SubscriptionId, SubscriptionTable, table_add_assigns_provided_id_and_finds_match(), table_clear_removes_all() (+5 more)
|
||||
|
||||
### Community 22 - "run_state_engine"
|
||||
Cohesion: 0.27
|
||||
Nodes (12): dispatch_event(), handle_command(), Arc, AtomicU64, Receiver, RwLock, Self, Sender (+4 more)
|
||||
|
||||
### Community 23 - "RtnetlinkAdapter"
|
||||
Cohesion: 0.22
|
||||
Nodes (7): ip_from_bytes(), Option, Result, Self, Sender, String, RtnetlinkAdapter
|
||||
|
||||
### Community 24 - "network.rs"
|
||||
Cohesion: 0.27
|
||||
Nodes (9): has_default_route(), network_raw_event(), NetworkAdapter, NetworkSnapshot, read_network_state(), BTreeMap, Result, Sender (+1 more)
|
||||
|
||||
### Community 25 - "power.rs"
|
||||
Cohesion: 0.26
|
||||
Nodes (8): power_raw_event(), PowerAdapter, PowerSnapshot, read_power_state(), Option, Result, Self, Sender
|
||||
|
||||
### Community 26 - "hyprland.rs"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): hyprland_event_socket(), HyprlandAdapter, parse_hyprland_line(), PathBuf, Result, Sender, String
|
||||
|
||||
### Community 27 - "Adapter"
|
||||
Cohesion: 0.23
|
||||
Nodes (8): Adapter, parse_upower_message(), Message, Result, Self, Sender, UPowerAdapter, Send
|
||||
|
||||
### Community 28 - "dl.breadway.dev Distribution"
|
||||
Cohesion: 0.25
|
||||
Nodes (8): main Branch, dev-release.yml Workflow, rc-release.yml Workflow, release.yml Workflow, dl.breadway.dev Distribution, beta Release Track, dev Release Track, stable Release Track
|
||||
|
||||
### Community 29 - "git-branch-widget.lua"
|
||||
Cohesion: 0.62
|
||||
Nodes (6): focused_tab_cwd(), git_info(), M.on_load(), shell_quote(), update(), widget_root()
|
||||
|
||||
### Community 30 - "TestHarness"
|
||||
Cohesion: 0.16
|
||||
Nodes (16): main(), parse_args(), Option, String, killing_a_module_host_child_does_not_take_down_breadd_or_other_modules(), os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope(), Child, Drop (+8 more)
|
||||
|
||||
### Community 31 - "Sync"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): main(), Result, Sync
|
||||
|
||||
### Community 32 - "active-window-widget.lua"
|
||||
Cohesion: 0.83
|
||||
Nodes (3): label_for(), M.on_load(), widget_root()
|
||||
|
||||
### Community 33 - "cpu-temp-widget.lua"
|
||||
Cohesion: 0.83
|
||||
Nodes (3): M.on_load(), read_temp_c(), widget_root()
|
||||
|
||||
### Community 34 - "focus-mode-widget.lua"
|
||||
Cohesion: 1.00
|
||||
Nodes (3): is_focused(), M.on_load(), widget_root()
|
||||
|
||||
### Community 35 - "workflow-status-widget.lua"
|
||||
Cohesion: 0.83
|
||||
Nodes (3): M.on_load(), most_relevant(), widget_update()
|
||||
|
||||
### Community 36 - "bread.widget API"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): bread.widget API, CPU Temperature Widget Example, Live Widget Update Pattern
|
||||
|
||||
### Community 64 - "ModuleHostLua"
|
||||
Cohesion: 0.10
|
||||
Nodes (38): call(), HostMessage, IoCommand, RpcResponse, Duration, Option, PathBuf, Receiver (+30 more)
|
||||
|
||||
### Community 65 - "ModuleHostRegistry"
|
||||
Cohesion: 0.07
|
||||
Nodes (43): bin_allowed(), path_allowed(), HashMap, Option, OwnedWriteHalf, Result, Sender, String (+35 more)
|
||||
|
||||
### Community 66 - "xtask/src/main.rs"
|
||||
Cohesion: 0.11
|
||||
Nodes (36): BTreeSet, ExitCode, check(), CheckReport, clean_state_passes(), extract_cli_commands(), extract_enum_variants(), extract_ipc_methods() (+28 more)
|
||||
|
||||
### Community 67 - "Bread"
|
||||
Cohesion: 0.06
|
||||
Nodes (28): Deprecations, Hyprland legacy flat event names (since v1.5), Bread Examples, Example 1: Porting keyboard_and_display_watcher.sh (system script), Example 2: Porting autostart.lua, Example 3: Porting display/monitors.lua, Example 4: Multi-step automation workflows, Example 5: A live widget in breadbar (+20 more)
|
||||
|
||||
### Community 68 - "rules.rs"
|
||||
Cohesion: 0.14
|
||||
Nodes (27): empty_file_loads_with_no_rules(), empty_on_is_treated_as_missing(), invalid_toml_is_fatal(), load_rules(), missing_on_is_reported_with_index_and_no_on(), multiple_action_keys_is_reported(), one_bad_rule_does_not_block_other_valid_rules(), ParsedRule (+19 more)
|
||||
|
||||
### Community 69 - "Normalized events"
|
||||
Cohesion: 0.12
|
||||
Nodes (16): Bluetooth (BlueZ), Compatibility: `[compat]` config, Devices (udev / Bluetooth), Filesystem / project detection, Git (hooks + dirty-state poller), Hyprland, Network, Normalized events (+8 more)
|
||||
|
||||
### Community 70 - "Bread Documentation"
|
||||
Cohesion: 0.14
|
||||
Nodes (14): API Stability & Versioning, Bread Documentation, Contents, Debugging tips, Dictionary: Event reference, Dictionary: IPC protocol, Dictionary: Runtime state schema, Integrating a bread\* app (+6 more)
|
||||
|
||||
### Community 71 - "udev.rs"
|
||||
Cohesion: 0.32
|
||||
Nodes (10): build_device_event(), build_event(), enumerate_payload_includes_classification_fields(), prop_bool(), prop_str(), Option, Value, udev_event_payload() (+2 more)
|
||||
|
||||
### Community 72 - "Dictionary: Lua API"
|
||||
Cohesion: 0.17
|
||||
Nodes (12): `bread.debounce(delay_ms, fn) -> wrapped_fn`, `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)`, `bread.notify(message, opts)`, `bread.profile.activate(name)`, Dictionary: Lua API, Hyprland, Module declaration, Module lifecycle hooks (+4 more)
|
||||
|
||||
### Community 73 - "Out-of-process module sandboxing *(Since: v1.6)*"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): Architecture, Crash isolation, New IPC methods, Out-of-process module sandboxing *(Since: v1.6)*, RPC bridge coverage, The gap this closes, The Landlock sandbox, The token/identity handshake (+2 more)
|
||||
|
||||
### Community 74 - "Dictionary: Built-in modules"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): `bread.binds`, `bread.devices`, `bread.monitors`, `bread.rules` *(Since: v1.5)*, `bread.workspaces`, Device rule options, Dictionary: Built-in modules, Example: Dock-specific setup (+2 more)
|
||||
|
||||
### Community 75 - "Events"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): `bread.emit(event, data)`, `bread.filter(pattern, fn, opts) -> id`, `bread.off(id)`, `bread.on(pattern, fn) -> id`, `bread.once(pattern, fn) -> id`, `bread.spawn(fn)`, `bread.wait_all(patterns, opts) -> table` *(Since: v1.2)*, `bread.wait_any(patterns, opts) -> event | nil` *(Since: v1.2)* (+2 more)
|
||||
|
||||
### Community 76 - "Machine and filesystem"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): `bread.fs.exists(path) -> bool`, `bread.fs.expand(path) -> string`, `bread.fs.read(path) -> string | nil`, `bread.fs.readlink(path) -> string | nil`, `bread.fs.write(path, content)`, `bread.json.decode(str) -> table | nil`, `bread.machine.has_tag(tag) -> bool`, `bread.machine.name() -> string` (+2 more)
|
||||
|
||||
### Community 77 - "Contributing"
|
||||
Cohesion: 0.22
|
||||
Nodes (8): Branches, CI, Contributing, Keeping the API docs honest, Local development, Questions, The release cycle, Tracks, from a user's perspective
|
||||
|
||||
### Community 78 - "Bluetooth"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): Bluetooth, `bread.bluetooth.connect(address)`, `bread.bluetooth.devices() -> table | nil`, `bread.bluetooth.disconnect(address)`, `bread.bluetooth.power(enabled)`, `bread.bluetooth.powered() -> bool | nil`, `bread.bluetooth.scan(enabled)`, Example: auto-connect headphones on AC power (+1 more)
|
||||
|
||||
### Community 79 - "Widgets *(Since: v1.3)*"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): `bread.widget.list() -> table`, `bread.widget.register(spec) -> ok, err`, `bread.widget.remove(id) -> bool`, `bread.widget.update(id, patch) -> ok, err`, Click events, Node types, `style` *(Since: v1.4)*, Style vs. `class` (+1 more)
|
||||
|
||||
### Community 80 - "Getting started"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): 1) Create a minimal config, 2) The fast path: `rules.toml` *(Since: v1.5)*, 3) Minimal `init.lua`, 4) Start the daemon, 5) Check that it's running, Getting started
|
||||
|
||||
### Community 81 - "Capability-scoped modules *(Since: v1.5)*"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): Baseline (always available, no manifest entry needed), `bread modules audit <name>`, Capability-scoped modules *(Since: v1.5)*, Gated — requires a matching `[[permissions]]` entry, `path`/`bin` enforcement depends on where the module runs, `require("bread.devices")` still works from a scoped module
|
||||
|
||||
### Community 82 - "Workflows *(Since: v1.2)*"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): `bread.workflow.define(name, fn)`, `bread.workflow.list() -> table`, `bread.workflow.start(name, opts)`, `bread.workflow.status(name) -> table | nil`, `bread.workflow.step(label)`, Workflows *(Since: v1.2)*
|
||||
|
||||
### Community 83 - "Timers"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): `bread.after(delay_ms, fn) -> id`, `bread.cancel(id)`, `bread.every(interval_ms, fn) -> id`, Timers
|
||||
|
||||
### Community 84 - "State"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): `bread.state.get(path)`, `bread.state.watch(path, fn) -> id`, State, Typed shorthands
|
||||
|
||||
### Community 85 - "init.lua"
|
||||
Cohesion: 0.83
|
||||
Nodes (3): M.on_load(), read_temp_c(), widget_root()
|
||||
|
||||
### Community 86 - "Execution"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): `bread.exec_capture(cmd, opts) -> ok, stdout`, `bread.exec(cmd)`, Execution
|
||||
|
||||
## Knowledge Gaps
|
||||
- **179 isolated node(s):** `install.sh script`, `Branches`, `The release cycle`, `Tracks, from a user's perspective`, `Keeping the API docs honest` (+174 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **24 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `RawEvent` connect `RawEvent` to `Server`, `filesystem.rs`, `git.rs`, `udev.rs`, `systemd.rs`, `podman.rs`, `run_udev_monitor`, `bluetooth.rs`, `RtnetlinkAdapter`, `network.rs`, `power.rs`, `hyprland.rs`, `Adapter`?**
|
||||
_High betweenness centrality (0.094) - this node is a cross-community bridge._
|
||||
- **Why does `Adapter` connect `Adapter` to `Server`, `filesystem.rs`, `git.rs`, `udev.rs`, `systemd.rs`, `podman.rs`, `run_udev_monitor`, `bluetooth.rs`, `RtnetlinkAdapter`, `network.rs`, `power.rs`, `hyprland.rs`, `Sync`?**
|
||||
_High betweenness centrality (0.077) - this node is a cross-community bridge._
|
||||
- **Why does `BreadEvent` connect `RawEvent` to `ModuleHostLua`, `lua/mod.rs`, `ModuleHostRegistry`, `Server`, `state_engine.rs`, `run_state_engine`?**
|
||||
_High betweenness centrality (0.063) - this node is a cross-community bridge._
|
||||
- **What connects `install.sh script`, `Branches`, `The release cycle` to the rest of the system?**
|
||||
_179 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `lua/mod.rs` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.056 - nodes in this community are weakly interconnected._
|
||||
- **Should `RawEvent` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.051842708517016396 - nodes in this community are weakly interconnected._
|
||||
- **Should `config.rs` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.07191780821917808 - nodes in this community are weakly interconnected._
|
||||
18
graphify-out/2026-08-16/cost.json
Normal file
18
graphify-out/2026-08-16/cost.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"runs": [
|
||||
{
|
||||
"date": "2026-08-04T09:09:09.071813+00:00",
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"files": 55
|
||||
},
|
||||
{
|
||||
"date": "2026-08-04T09:28:29.057021+00:00",
|
||||
"input_tokens": 72759,
|
||||
"output_tokens": 0,
|
||||
"files": 55
|
||||
}
|
||||
],
|
||||
"total_input_tokens": 72759,
|
||||
"total_output_tokens": 0
|
||||
}
|
||||
55512
graphify-out/2026-08-16/graph.json
Normal file
55512
graphify-out/2026-08-16/graph.json
Normal file
File diff suppressed because it is too large
Load diff
327
graphify-out/2026-08-16/manifest.json
Normal file
327
graphify-out/2026-08-16/manifest.json
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
{
|
||||
"bread-cli/src/hooks_git.rs": {
|
||||
"mtime": 1786800467.7200727,
|
||||
"ast_hash": "7a29b5d5d90170f0aef9cececc7d8656",
|
||||
"semantic_hash": "7a29b5d5d90170f0aef9cececc7d8656"
|
||||
},
|
||||
"bread-cli/src/hooks_shell.rs": {
|
||||
"mtime": 1786800467.7201686,
|
||||
"ast_hash": "c5d5b8922fcff79954ddb00496721603",
|
||||
"semantic_hash": "c5d5b8922fcff79954ddb00496721603"
|
||||
},
|
||||
"bread-cli/src/lib.rs": {
|
||||
"mtime": 1786800467.7201686,
|
||||
"ast_hash": "d1bf6e1c239498521c42418f672bc400",
|
||||
"semantic_hash": "d1bf6e1c239498521c42418f672bc400"
|
||||
},
|
||||
"bread-cli/src/main.rs": {
|
||||
"mtime": 1786800467.7201686,
|
||||
"ast_hash": "e1fbe63ea3b10e0887072ab7133d0508",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"bread-cli/src/modules_mgmt.rs": {
|
||||
"mtime": 1786801244.1263742,
|
||||
"ast_hash": "1cb6f3e4fa26108f81215ab57bb02b91",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"bread-cli/tests/modules.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "84fca8f87dc915b1cb523f8199e78521",
|
||||
"semantic_hash": "84fca8f87dc915b1cb523f8199e78521"
|
||||
},
|
||||
"bread-emit/src/main.rs": {
|
||||
"mtime": 1786800467.7206197,
|
||||
"ast_hash": "350cefbf697cfd93f7df7b7bcc45a101",
|
||||
"semantic_hash": "350cefbf697cfd93f7df7b7bcc45a101"
|
||||
},
|
||||
"bread-shared/src/apps.rs": {
|
||||
"mtime": 1786801218.1467261,
|
||||
"ast_hash": "e8419571e7a322019d71c89559fb02fb",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"bread-shared/src/glob.rs": {
|
||||
"mtime": 1786800467.7210522,
|
||||
"ast_hash": "0594a313cb1909d1cca5fd5b8f241b68",
|
||||
"semantic_hash": "0594a313cb1909d1cca5fd5b8f241b68"
|
||||
},
|
||||
"bread-shared/src/lib.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "236b73bad29f1544a28fbf3ee1ccc795",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"bread-shared/src/widget.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "33272eb38c7fffbfa180cef7c5a286fc",
|
||||
"semantic_hash": "33272eb38c7fffbfa180cef7c5a286fc"
|
||||
},
|
||||
"breadd/src/adapters/bluetooth.rs": {
|
||||
"mtime": 1786800467.7214499,
|
||||
"ast_hash": "4d1df67347d5b7c9cdbcb12921a6ae28",
|
||||
"semantic_hash": "4d1df67347d5b7c9cdbcb12921a6ae28"
|
||||
},
|
||||
"breadd/src/adapters/filesystem.rs": {
|
||||
"mtime": 1786800467.7215972,
|
||||
"ast_hash": "8ec9dcd8de13f30922cfbe9b4a3fff56",
|
||||
"semantic_hash": "8ec9dcd8de13f30922cfbe9b4a3fff56"
|
||||
},
|
||||
"breadd/src/adapters/git.rs": {
|
||||
"mtime": 1786800467.7215972,
|
||||
"ast_hash": "6e6ff6ca318e28cd94690c30d41b520b",
|
||||
"semantic_hash": "6e6ff6ca318e28cd94690c30d41b520b"
|
||||
},
|
||||
"breadd/src/adapters/hyprland.rs": {
|
||||
"mtime": 1786800467.7215972,
|
||||
"ast_hash": "71537dbd86b413d94476f8022054f1f4",
|
||||
"semantic_hash": "71537dbd86b413d94476f8022054f1f4"
|
||||
},
|
||||
"breadd/src/adapters/mod.rs": {
|
||||
"mtime": 1786800467.7215972,
|
||||
"ast_hash": "63885887c2fc3ea2314d7fe095e5df61",
|
||||
"semantic_hash": "63885887c2fc3ea2314d7fe095e5df61"
|
||||
},
|
||||
"breadd/src/adapters/network.rs": {
|
||||
"mtime": 1786800467.7215972,
|
||||
"ast_hash": "8706dc546b7e08d5aa084ca58c48559c",
|
||||
"semantic_hash": "8706dc546b7e08d5aa084ca58c48559c"
|
||||
},
|
||||
"breadd/src/adapters/network_rtnetlink.rs": {
|
||||
"mtime": 1786800467.7215972,
|
||||
"ast_hash": "0c9d0bb2693bc46b11807f6b8ebb7c4c",
|
||||
"semantic_hash": "0c9d0bb2693bc46b11807f6b8ebb7c4c"
|
||||
},
|
||||
"breadd/src/adapters/podman.rs": {
|
||||
"mtime": 1786800467.7215972,
|
||||
"ast_hash": "97c08551c0fe53b0a5888d98730d35db",
|
||||
"semantic_hash": "97c08551c0fe53b0a5888d98730d35db"
|
||||
},
|
||||
"breadd/src/adapters/power.rs": {
|
||||
"mtime": 1786800467.7215972,
|
||||
"ast_hash": "987d202ec0d30ec26b1d747a6e320ed7",
|
||||
"semantic_hash": "987d202ec0d30ec26b1d747a6e320ed7"
|
||||
},
|
||||
"breadd/src/adapters/power_upower.rs": {
|
||||
"mtime": 1786800467.7215972,
|
||||
"ast_hash": "fc0a36ca76d340c8be77644f63e462bf",
|
||||
"semantic_hash": "fc0a36ca76d340c8be77644f63e462bf"
|
||||
},
|
||||
"breadd/src/adapters/systemd.rs": {
|
||||
"mtime": 1786800467.7215972,
|
||||
"ast_hash": "b3884dbecd39acc38abdf67589a8b954",
|
||||
"semantic_hash": "b3884dbecd39acc38abdf67589a8b954"
|
||||
},
|
||||
"breadd/src/adapters/udev.rs": {
|
||||
"mtime": 1786800978.7399695,
|
||||
"ast_hash": "568993c8c2dd218879eec57aee50a6b9",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/src/core/config.rs": {
|
||||
"mtime": 1786800467.7215972,
|
||||
"ast_hash": "7f479bfb51647e6131c14f0c2f153d95",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/src/core/mod.rs": {
|
||||
"mtime": 1786800467.7223108,
|
||||
"ast_hash": "e38bfdb894eabd208941bd3ffb1ef464",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/src/core/normalizer.rs": {
|
||||
"mtime": 1786801218.2233918,
|
||||
"ast_hash": "1596f60df5049ff8715f66f64bee9f8a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/src/core/state_engine.rs": {
|
||||
"mtime": 1786800467.7223108,
|
||||
"ast_hash": "84a35ee78f3881261a3ce378ea3c03a0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/src/core/subscriptions.rs": {
|
||||
"mtime": 1786800467.7223108,
|
||||
"ast_hash": "8735d4717b74523c787dab9ea2b0bbd8",
|
||||
"semantic_hash": "8735d4717b74523c787dab9ea2b0bbd8"
|
||||
},
|
||||
"breadd/src/core/supervisor.rs": {
|
||||
"mtime": 1786800467.7223108,
|
||||
"ast_hash": "403f7ba1807c71f9b89d81bfeff2681a",
|
||||
"semantic_hash": "403f7ba1807c71f9b89d81bfeff2681a"
|
||||
},
|
||||
"breadd/src/core/types.rs": {
|
||||
"mtime": 1786800467.7223108,
|
||||
"ast_hash": "8bd070d4c09725d8717749a79aa42a92",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/src/ipc/mod.rs": {
|
||||
"mtime": 1786800912.9916558,
|
||||
"ast_hash": "54af229257e555313f7d4b59d57ff8d3",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/src/lua/mod.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "ed13e2495399d532625653a22a4fee14",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/src/main.rs": {
|
||||
"mtime": 1786800467.722855,
|
||||
"ast_hash": "4b31888cc9e76210df0c7cff3a8b1e42",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/tests/ipc_integration.rs": {
|
||||
"mtime": 1786801218.36339,
|
||||
"ast_hash": "2b5bade2cc2e189a3af9c692232be258",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"examples/modules/active-window-widget.lua": {
|
||||
"mtime": 1786800467.7243168,
|
||||
"ast_hash": "a5f6fb2c54775a49ddaf29674f86571d",
|
||||
"semantic_hash": "a5f6fb2c54775a49ddaf29674f86571d"
|
||||
},
|
||||
"examples/modules/bluetooth-toggle-widget.lua": {
|
||||
"mtime": 1786800467.7243168,
|
||||
"ast_hash": "c8529dc45862adf4f63db48674a28277",
|
||||
"semantic_hash": "c8529dc45862adf4f63db48674a28277"
|
||||
},
|
||||
"examples/modules/dock-monitors.lua": {
|
||||
"mtime": 1786800467.724451,
|
||||
"ast_hash": "4abe51f19614d2bf117ac35e95324801",
|
||||
"semantic_hash": "4abe51f19614d2bf117ac35e95324801"
|
||||
},
|
||||
"examples/modules/dock-workflow.lua": {
|
||||
"mtime": 1786800467.724451,
|
||||
"ast_hash": "84cf81cfb7780e89a46e96a6ccfbad15",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"examples/modules/focus-mode-widget.lua": {
|
||||
"mtime": 1786800467.724451,
|
||||
"ast_hash": "f62c366c2c85cfbe59eef663fc607230",
|
||||
"semantic_hash": "f62c366c2c85cfbe59eef663fc607230"
|
||||
},
|
||||
"examples/modules/git-branch-widget.lua": {
|
||||
"mtime": 1786800467.724451,
|
||||
"ast_hash": "ebeeaebab5b5b29619f3db65c1df57d8",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"examples/modules/low-battery-warning.lua": {
|
||||
"mtime": 1786800467.724451,
|
||||
"ast_hash": "e0ba79860562fc36fa8cb183bc576fb7",
|
||||
"semantic_hash": "e0ba79860562fc36fa8cb183bc576fb7"
|
||||
},
|
||||
"examples/modules/pause-media-on-headphone-unplug.lua": {
|
||||
"mtime": 1786800467.724451,
|
||||
"ast_hash": "6c4cf82b6963eb93df224bed60d06c2d",
|
||||
"semantic_hash": "6c4cf82b6963eb93df224bed60d06c2d"
|
||||
},
|
||||
"examples/modules/workflow-status-widget.lua": {
|
||||
"mtime": 1786800467.724451,
|
||||
"ast_hash": "9f913a66a0f8654dadc1c5d6c2be0938",
|
||||
"semantic_hash": "9f913a66a0f8654dadc1c5d6c2be0938"
|
||||
},
|
||||
"scripts/install.sh": {
|
||||
"mtime": 1786800994.0764284,
|
||||
"ast_hash": "35d1a7f63824e9176bd105ab3d699576",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"CONTRIBUTING.md": {
|
||||
"mtime": 1786800467.716893,
|
||||
"ast_hash": "6d056fcf0dae29949d19c41b41792879",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"Documentation.md": {
|
||||
"mtime": 1786801027.2816515,
|
||||
"ast_hash": "7b8471e7ae45aed70682bb858826860c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"Examples.md": {
|
||||
"mtime": 1786800467.716893,
|
||||
"ast_hash": "6bcd7a14be53a9f67ef6912b160da8bc",
|
||||
"semantic_hash": "6bcd7a14be53a9f67ef6912b160da8bc"
|
||||
},
|
||||
"README.md": {
|
||||
"mtime": 1786800994.0964282,
|
||||
"ast_hash": "97ae9f7efb9f2cdceb615a47cf3dfa1a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"bread-module-host/src/io.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "dbe04e05d1cfb02770db8d7c0bcc0b68",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"bread-module-host/src/lua_env.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "97d009846e18d3c809663b1185c98bbb",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"bread-module-host/src/main.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "9a93253ae44c764b8273df40999ddede",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"bread-shared/src/module_host_ipc.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "fcdb4c03f242a5ea51fd3a096b711363",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"bread-shared/src/permissions.rs": {
|
||||
"mtime": 1786800467.7210522,
|
||||
"ast_hash": "800445ef9c90bdbf8f3ac9d4f1afd98e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/src/core/rules.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "bd430cf213f400b8d4e96ccc35a76ff1",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/src/ipc/module_host_bridge.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "caa9cf82695e48c88758fcfa835e1871",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/src/module_host.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "228826ff3c8941ea5f9bb28b66d637a9",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"breadd/tests/module_host_sandbox.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "7e637b3a950c510215a6f6a7b88dd404",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"examples/modules/cpu-temp-widget/init.lua": {
|
||||
"mtime": 1786800467.724451,
|
||||
"ast_hash": "5ac893dd2f6af6d8b22dd290e3021f77",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"xtask/src/main.rs": {
|
||||
"mtime": 1786801244.1297076,
|
||||
"ast_hash": "e914dd77ac3241df09e3a681228961db",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".forgejo/workflows/dev-release.yml": {
|
||||
"mtime": 1786800467.7161875,
|
||||
"ast_hash": "707129aa2fe79b4539d108c03e6d7abf",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".forgejo/workflows/rc-release.yml": {
|
||||
"mtime": 1786800467.7167187,
|
||||
"ast_hash": "3007d2c43d785cd72f0548096626a21d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".forgejo/workflows/release.yml": {
|
||||
"mtime": 1786800467.7167187,
|
||||
"ast_hash": "3578aa39e7b2466822c8d85247489d36",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"DEPRECATIONS.md": {
|
||||
"mtime": 1786800467.716893,
|
||||
"ast_hash": "1685b60fad2ed4184e4119c30866ec12",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"examples/modules/README.md": {
|
||||
"mtime": 1786800467.7242541,
|
||||
"ast_hash": "dfa8a72f41c08bd5b8036b684092a93b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"packaging/README.md": {
|
||||
"mtime": 1786800994.0430956,
|
||||
"ast_hash": "caa295eed64de126874a2e48b8aed8a2",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
416
graphify-out/GRAPH_REPORT.md
Normal file
416
graphify-out/GRAPH_REPORT.md
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
# Graph Report - bread (2026-08-16)
|
||||
|
||||
## Corpus Check
|
||||
- 66 files · ~90,429 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 1486 nodes · 3384 edges · 93 communities (68 shown, 25 thin omitted)
|
||||
- Extraction: 99% EXTRACTED · 1% INFERRED · 0% AMBIGUOUS · INFERRED: 47 edges (avg confidence: 0.78)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `cdd5de8f`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- LuaEngine
|
||||
- RawEvent
|
||||
- config.rs
|
||||
- Server
|
||||
- widget.rs
|
||||
- Bread Daemon (breadd)
|
||||
- filesystem.rs
|
||||
- git.rs
|
||||
- Result
|
||||
- state_engine.rs
|
||||
- modules_mgmt.rs
|
||||
- systemd.rs
|
||||
- hooks_git.rs
|
||||
- bread-cli/src/main.rs
|
||||
- types.rs
|
||||
- podman.rs
|
||||
- Value
|
||||
- Result
|
||||
- hooks_shell.rs
|
||||
- bluetooth.rs
|
||||
- SubscriptionId
|
||||
- glob.rs
|
||||
- StateHandle
|
||||
- Adapter
|
||||
- network.rs
|
||||
- power.rs
|
||||
- hyprland.rs
|
||||
- parse_upower_message
|
||||
- main Branch
|
||||
- git-branch-widget.lua
|
||||
- TestHarness
|
||||
- Sync
|
||||
- active-window-widget.lua
|
||||
- .new
|
||||
- focus-mode-widget.lua
|
||||
- workflow-status-widget.lua
|
||||
- bread.widget API
|
||||
- bluetooth-toggle-widget.lua
|
||||
- pause-media-on-headphone-unplug.lua
|
||||
- Autostart Example Module
|
||||
- install.sh
|
||||
- Filesystem Adapter
|
||||
- Git Adapter
|
||||
- Podman Adapter
|
||||
- Systemd Adapter
|
||||
- bread.after(delay_ms, fn)
|
||||
- bread.bluetooth namespace
|
||||
- bread.every(interval_ms, fn)
|
||||
- bread.exec(cmd)
|
||||
- bread.hyprland namespace
|
||||
- bread.notify(message, opts)
|
||||
- bread.state.watch(path, fn)
|
||||
- bread.system.startup
|
||||
- Monitors Configuration Example
|
||||
- Binds Module (Built-in)
|
||||
- lua/mod.rs
|
||||
- external-monitors.lua
|
||||
- AGENTS.md — Repo hygiene
|
||||
- CLAUDE.md — Repo hygiene
|
||||
- ModuleHostLua
|
||||
- ModuleHostRegistry
|
||||
- xtask/src/main.rs
|
||||
- Bread
|
||||
- rules.rs
|
||||
- Normalized events
|
||||
- Bread Documentation
|
||||
- udev.rs
|
||||
- Dictionary: Lua API
|
||||
- Out-of-process module sandboxing *(Since: v1.6)*
|
||||
- Dictionary: Built-in modules
|
||||
- Events
|
||||
- Machine and filesystem
|
||||
- Contributing
|
||||
- Bluetooth
|
||||
- Widgets *(Since: v1.3)*
|
||||
- Getting started
|
||||
- Capability-scoped modules *(Since: v1.5)*
|
||||
- Workflows *(Since: v1.2)*
|
||||
- Timers
|
||||
- State
|
||||
- init.lua
|
||||
- Execution
|
||||
- packaging/README.md
|
||||
- PathBuf
|
||||
- build.sh
|
||||
- beta Release Track
|
||||
- dev Release Track
|
||||
- stable Release Track
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `LuaEngine` - 59 edges
|
||||
2. `RawEvent` - 50 edges
|
||||
3. `BreadEvent` - 38 edges
|
||||
4. `raw()` - 37 edges
|
||||
5. `RuntimeState` - 36 edges
|
||||
6. `StateHandle` - 28 edges
|
||||
7. `now_unix_ms()` - 25 edges
|
||||
8. `Adapter` - 25 edges
|
||||
9. `SubscriptionId` - 25 edges
|
||||
10. `ModuleHostLua` - 24 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `parse_bluetooth_message()` --calls--> `now_unix_ms()` [INFERRED]
|
||||
breadd/src/adapters/bluetooth.rs → bread-shared/src/lib.rs
|
||||
- `try_enumerate()` --calls--> `now_unix_ms()` [INFERRED]
|
||||
breadd/src/adapters/bluetooth.rs → bread-shared/src/lib.rs
|
||||
- `classify()` --calls--> `now_unix_ms()` [INFERRED]
|
||||
breadd/src/adapters/filesystem.rs → bread-shared/src/lib.rs
|
||||
- `emit_topology_snapshot()` --calls--> `now_unix_ms()` [INFERRED]
|
||||
breadd/src/adapters/hyprland.rs → bread-shared/src/lib.rs
|
||||
- `network_raw_event()` --calls--> `now_unix_ms()` [INFERRED]
|
||||
breadd/src/adapters/network.rs → bread-shared/src/lib.rs
|
||||
|
||||
## Import Cycles
|
||||
- 2-file cycle: `breadd/src/core/state_engine.rs -> breadd/src/lua/mod.rs -> breadd/src/core/state_engine.rs`
|
||||
- 2-file cycle: `bread-shared/src/lib.rs -> bread-shared/src/module_host_ipc.rs -> bread-shared/src/lib.rs`
|
||||
|
||||
## Hyperedges (group relationships)
|
||||
- **** — adapter_udev, adapter_hyprland, adapter_power, sys_bread_daemon, api_bread_on, sys_lua_runtime [INFERRED]
|
||||
- **** — config_init_lua, config_modules_dir, pattern_module_skeleton, api_bread_on, sys_lua_runtime [INFERRED]
|
||||
- **** — api_bread_workflow, api_bread_spawn, api_bread_wait, example_dock_workflow, api_bread_notify [INFERRED]
|
||||
- **** — api_bread_widget, api_bread_every, example_widget_cpu_temp, api_bread_state_watch [INFERRED]
|
||||
|
||||
## Communities (93 total, 25 thin omitted)
|
||||
|
||||
### Community 0 - "LuaEngine"
|
||||
Cohesion: 0.11
|
||||
Nodes (19): ErrorEntry, HandlerEntry, HandlerKind, LuaEngine, LuaMessage, ModuleInfo, AtomicU64, HashMap (+11 more)
|
||||
|
||||
### Community 1 - "RawEvent"
|
||||
Cohesion: 0.06
|
||||
Nodes (67): adapter_source_is_hashable_and_eq(), AdapterSource, bread_event_new_accepts_owned_and_borrowed_names(), bread_event_new_assigns_unique_id_and_no_cause(), bread_event_new_sets_current_timestamp(), bread_event_with_timestamp_preserves_timestamp_and_assigns_id(), BreadEvent, DaemonSection (+59 more)
|
||||
|
||||
### Community 2 - "config.rs"
|
||||
Cohesion: 0.07
|
||||
Nodes (50): AdaptersConfig, AdapterToggle, compat_section_defaults_legacy_hyprland_names_to_true(), CompatConfig, Config, config_path(), config_path_falls_back_to_home_when_no_xdg(), config_path_respects_xdg_config_home() (+42 more)
|
||||
|
||||
### Community 3 - "Server"
|
||||
Cohesion: 0.05
|
||||
Nodes (41): A, command_target(), event_domain(), is_known_app(), is_reserved_domain(), Option, validate_app_namespace(), validate_command_event() (+33 more)
|
||||
|
||||
### Community 4 - "widget.rs"
|
||||
Cohesion: 0.07
|
||||
Nodes (33): accepts_node_count_at_max(), accepts_tree_at_max_depth(), Align, Background, box_of(), default_orientation(), FontWeight, is_valid_class() (+25 more)
|
||||
|
||||
### Community 5 - "Bread Daemon (breadd)"
|
||||
Cohesion: 0.06
|
||||
Nodes (36): Bluetooth Adapter, Hyprland Adapter, Network Adapter, Power Adapter, udev Adapter, bread.on(pattern, fn), bread.spawn(fn), bread.wait(pattern, opts) (+28 more)
|
||||
|
||||
### Community 6 - "filesystem.rs"
|
||||
Cohesion: 0.12
|
||||
Nodes (28): classify(), classify_build_artifact_created_in_target(), classify_debounces_rapid_repeat_events_for_same_path(), classify_file_changed_for_ordinary_source_file(), classify_silent_for_modify_in_target_not_create(), classify_silent_under_git(), classify_silent_under_node_modules(), detect_markers() (+20 more)
|
||||
|
||||
### Community 7 - "git.rs"
|
||||
Cohesion: 0.12
|
||||
Nodes (22): check_ahead_behind(), check_dirty(), discover_repos(), expand_roots(), expand_roots_globs_single_trailing_star(), expand_roots_skips_unreadable_glob_parent_without_panicking(), expand_roots_uses_literal_path_without_trailing_star(), GitAdapter (+14 more)
|
||||
|
||||
### Community 8 - "Result"
|
||||
Cohesion: 0.15
|
||||
Nodes (50): daemon_survives_repeated_reloads_and_pipeline_resumes(), emit_with_app_source_allows_command_to_another_app(), emit_with_app_source_rejects_wrong_namespace(), emit_with_app_source_still_rejects_foreign_app_namespace(), emit_with_internal_source_is_rejected(), emit_with_known_app_source_routes_through_normalizer(), emit_with_unregistered_app_source_is_rejected(), emit_without_event_errors() (+42 more)
|
||||
|
||||
### Community 9 - "state_engine.rs"
|
||||
Cohesion: 0.12
|
||||
Nodes (20): apply_device_change(), apply_event_to_state(), device_connect_adds_device_with_all_fields(), device_connect_is_idempotent_for_same_id(), device_disconnect_of_unknown_id_is_noop(), device_disconnect_removes_matching_id(), ev(), hyprland_snapshot_replaces_topology() (+12 more)
|
||||
|
||||
### Community 10 - "modules_mgmt.rs"
|
||||
Cohesion: 0.11
|
||||
Nodes (36): audit_detects_fs_read_and_widget_from_cpu_temp_widget_style_module(), audit_extracts_exec_bin_hint_and_ignores_baseline_calls(), audit_module(), audit_scans_required_sibling_files_in_module_directory(), classify_call_site(), collect_lua_files(), copy_dir(), extract_first_string_arg() (+28 more)
|
||||
|
||||
### Community 11 - "systemd.rs"
|
||||
Cohesion: 0.12
|
||||
Nodes (17): active_state_to_kind(), failure_result(), get_unit_path(), handle_message(), is_failed_transition(), query_active_state(), Connection, HashMap (+9 more)
|
||||
|
||||
### Community 12 - "hooks_git.rs"
|
||||
Cohesion: 0.15
|
||||
Nodes (24): all_hook_scripts_exit_0_unconditionally(), all_hook_scripts_start_with_shebang_and_marker(), branch_changed_emit_line(), commit_created_emit_line(), emit_line_for(), git_dir(), hook_script(), hook_script_rejects_unknown_name() (+16 more)
|
||||
|
||||
### Community 13 - "bread-cli/src/main.rs"
|
||||
Cohesion: 0.19
|
||||
Nodes (29): CausalityTracker, Cli, Commands, config_directory(), daemon_socket_path(), format_timestamp(), handle_modules_cmd(), HooksCommand (+21 more)
|
||||
|
||||
### Community 14 - "types.rs"
|
||||
Cohesion: 0.15
|
||||
Nodes (21): DeviceTopology, InterfaceState, MatchCondition, ModuleStatus, Monitor, NetworkState, PowerState, ProfileState (+13 more)
|
||||
|
||||
### Community 15 - "podman.rs"
|
||||
Cohesion: 0.15
|
||||
Nodes (17): container_event(), ignores_remove_event(), ignores_stop_event_to_avoid_double_emit_with_died(), ignores_unknown_action(), map_podman_event(), maps_died_event(), maps_health_status_event(), maps_start_event() (+9 more)
|
||||
|
||||
### Community 16 - "Value"
|
||||
Cohesion: 0.17
|
||||
Nodes (14): active_window_from_data(), apply_hyprland_snapshot(), condition_matches(), hyprland_state_key(), json_stringish(), resolve_device(), Option, Result (+6 more)
|
||||
|
||||
### Community 17 - "Result"
|
||||
Cohesion: 0.13
|
||||
Nodes (26): bluetooth_connect(), bluetooth_disconnect(), bluetooth_find_adapter(), bluetooth_get_powered(), bluetooth_query(), bluetooth_set_powered(), bluetooth_set_scanning(), bluetooth_spawn() (+18 more)
|
||||
|
||||
### Community 18 - "hooks_shell.rs"
|
||||
Cohesion: 0.18
|
||||
Nodes (11): hook_scripts_background_every_emit_call(), hooks_dir(), install_shell(), join_line_continuations(), Option, PathBuf, Result, String (+3 more)
|
||||
|
||||
### Community 19 - "bluetooth.rs"
|
||||
Cohesion: 0.15
|
||||
Nodes (10): address_from_path(), BluetoothAdapter, parse_bluetooth_message(), Message, Option, Result, Self, Sender (+2 more)
|
||||
|
||||
### Community 20 - "SubscriptionId"
|
||||
Cohesion: 0.29
|
||||
Nodes (13): HashMap, String, Vec, Subscription, SubscriptionId, SubscriptionTable, table_add_assigns_provided_id_and_finds_match(), table_clear_removes_all() (+5 more)
|
||||
|
||||
### Community 22 - "StateHandle"
|
||||
Cohesion: 0.15
|
||||
Nodes (16): dispatch_event(), handle_command(), Arc, AtomicU64, HashMap, Receiver, RwLock, Self (+8 more)
|
||||
|
||||
### Community 23 - "Adapter"
|
||||
Cohesion: 0.19
|
||||
Nodes (9): Adapter, ip_from_bytes(), Option, Result, Self, Sender, String, RtnetlinkAdapter (+1 more)
|
||||
|
||||
### Community 24 - "network.rs"
|
||||
Cohesion: 0.27
|
||||
Nodes (9): has_default_route(), network_raw_event(), NetworkAdapter, NetworkSnapshot, read_network_state(), BTreeMap, Result, Sender (+1 more)
|
||||
|
||||
### Community 25 - "power.rs"
|
||||
Cohesion: 0.26
|
||||
Nodes (8): power_raw_event(), PowerAdapter, PowerSnapshot, read_power_state(), Option, Result, Self, Sender
|
||||
|
||||
### Community 26 - "hyprland.rs"
|
||||
Cohesion: 0.29
|
||||
Nodes (11): emit_topology_snapshot(), hyprland_event_socket(), hyprland_request_json(), hyprland_request_socket(), HyprlandAdapter, parse_hyprland_line(), PathBuf, Result (+3 more)
|
||||
|
||||
### Community 27 - "parse_upower_message"
|
||||
Cohesion: 0.27
|
||||
Nodes (6): parse_upower_message(), Message, Result, Self, Sender, UPowerAdapter
|
||||
|
||||
### Community 29 - "git-branch-widget.lua"
|
||||
Cohesion: 0.62
|
||||
Nodes (6): focused_tab_cwd(), git_info(), M.on_load(), shell_quote(), update(), widget_root()
|
||||
|
||||
### Community 30 - "TestHarness"
|
||||
Cohesion: 0.16
|
||||
Nodes (16): main(), parse_args(), Option, String, killing_a_module_host_child_does_not_take_down_breadd_or_other_modules(), os_execute_and_io_open_are_denied_at_the_kernel_level_outside_granted_scope(), Child, Drop (+8 more)
|
||||
|
||||
### Community 31 - "Sync"
|
||||
Cohesion: 0.50
|
||||
Nodes (3): main(), Result, Sync
|
||||
|
||||
### Community 32 - "active-window-widget.lua"
|
||||
Cohesion: 0.83
|
||||
Nodes (3): label_for(), M.on_load(), widget_root()
|
||||
|
||||
### Community 33 - ".new"
|
||||
Cohesion: 0.16
|
||||
Nodes (15): ModulePermission, Option, String, bluetooth_list_devices(), builtin_module_decls(), is_lib_path(), list_lua_files(), module_name_from_path() (+7 more)
|
||||
|
||||
### Community 34 - "focus-mode-widget.lua"
|
||||
Cohesion: 1.00
|
||||
Nodes (3): is_focused(), M.on_load(), widget_root()
|
||||
|
||||
### Community 35 - "workflow-status-widget.lua"
|
||||
Cohesion: 0.83
|
||||
Nodes (3): M.on_load(), most_relevant(), widget_update()
|
||||
|
||||
### Community 36 - "bread.widget API"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): bread.widget API, CPU Temperature Widget Example, Live Widget Update Pattern
|
||||
|
||||
### Community 60 - "lua/mod.rs"
|
||||
Cohesion: 0.30
|
||||
Nodes (20): now_unix_ms(), RuntimeState, module_store_get(), module_store_set(), Arc, JsonValue, RwLock, widget_list_json() (+12 more)
|
||||
|
||||
### Community 61 - "external-monitors.lua"
|
||||
Cohesion: 0.29
|
||||
Nodes (8): apply(), apply_monitor(), connected(), drm_first_mode(), drm_status(), is_internal(), list_connectors(), M.on_load()
|
||||
|
||||
### Community 62 - "AGENTS.md — Repo hygiene"
|
||||
Cohesion: 0.33
|
||||
Nodes (5): AGENTS.md — Repo hygiene, CI, Don't, Local architecture (still true), Remotes
|
||||
|
||||
### Community 63 - "CLAUDE.md — Repo hygiene"
|
||||
Cohesion: 0.40
|
||||
Nodes (4): CI, CLAUDE.md — Repo hygiene, Don't, Remotes
|
||||
|
||||
### Community 64 - "ModuleHostLua"
|
||||
Cohesion: 0.09
|
||||
Nodes (38): call(), HostMessage, IoCommand, RpcResponse, Duration, Option, PathBuf, Receiver (+30 more)
|
||||
|
||||
### Community 65 - "ModuleHostRegistry"
|
||||
Cohesion: 0.07
|
||||
Nodes (43): bin_allowed(), path_allowed(), HashMap, Option, OwnedWriteHalf, Result, Sender, String (+35 more)
|
||||
|
||||
### Community 66 - "xtask/src/main.rs"
|
||||
Cohesion: 0.11
|
||||
Nodes (36): BTreeSet, ExitCode, check(), CheckReport, clean_state_passes(), extract_cli_commands(), extract_enum_variants(), extract_ipc_methods() (+28 more)
|
||||
|
||||
### Community 67 - "Bread"
|
||||
Cohesion: 0.06
|
||||
Nodes (28): Deprecations, Hyprland legacy flat event names (since v1.5), Bread Examples, Example 1: Porting keyboard_and_display_watcher.sh (system script), Example 2: Porting autostart.lua, Example 3: Porting display/monitors.lua, Example 4: Multi-step automation workflows, Example 5: A live widget in breadbar (+20 more)
|
||||
|
||||
### Community 68 - "rules.rs"
|
||||
Cohesion: 0.14
|
||||
Nodes (27): empty_file_loads_with_no_rules(), empty_on_is_treated_as_missing(), invalid_toml_is_fatal(), load_rules(), missing_on_is_reported_with_index_and_no_on(), multiple_action_keys_is_reported(), one_bad_rule_does_not_block_other_valid_rules(), ParsedRule (+19 more)
|
||||
|
||||
### Community 69 - "Normalized events"
|
||||
Cohesion: 0.12
|
||||
Nodes (16): Bluetooth (BlueZ), Compatibility: `[compat]` config, Devices (udev / Bluetooth), Filesystem / project detection, Git (hooks + dirty-state poller), Hyprland, Network, Normalized events (+8 more)
|
||||
|
||||
### Community 70 - "Bread Documentation"
|
||||
Cohesion: 0.14
|
||||
Nodes (14): API Stability & Versioning, Bread Documentation, Contents, Debugging tips, Dictionary: Event reference, Dictionary: IPC protocol, Dictionary: Runtime state schema, Integrating a bread\* app (+6 more)
|
||||
|
||||
### Community 71 - "udev.rs"
|
||||
Cohesion: 0.18
|
||||
Nodes (18): build_device_event(), build_event(), enumerate_payload_includes_classification_fields(), prop_bool(), prop_str(), Option, Result, Self (+10 more)
|
||||
|
||||
### Community 72 - "Dictionary: Lua API"
|
||||
Cohesion: 0.17
|
||||
Nodes (12): `bread.debounce(delay_ms, fn) -> wrapped_fn`, `bread.log(msg)` / `bread.warn(msg)` / `bread.error(msg)`, `bread.notify(message, opts)`, `bread.profile.activate(name)`, Dictionary: Lua API, Hyprland, Module declaration, Module lifecycle hooks (+4 more)
|
||||
|
||||
### Community 73 - "Out-of-process module sandboxing *(Since: v1.6)*"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): Architecture, Crash isolation, New IPC methods, Out-of-process module sandboxing *(Since: v1.6)*, RPC bridge coverage, The gap this closes, The Landlock sandbox, The token/identity handshake (+2 more)
|
||||
|
||||
### Community 74 - "Dictionary: Built-in modules"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): `bread.binds`, `bread.devices`, `bread.monitors`, `bread.rules` *(Since: v1.5)*, `bread.workspaces`, Device rule options, Dictionary: Built-in modules, Example: Dock-specific setup (+2 more)
|
||||
|
||||
### Community 75 - "Events"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): `bread.emit(event, data)`, `bread.filter(pattern, fn, opts) -> id`, `bread.off(id)`, `bread.on(pattern, fn) -> id`, `bread.once(pattern, fn) -> id`, `bread.spawn(fn)`, `bread.wait_all(patterns, opts) -> table` *(Since: v1.2)*, `bread.wait_any(patterns, opts) -> event | nil` *(Since: v1.2)* (+2 more)
|
||||
|
||||
### Community 76 - "Machine and filesystem"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): `bread.fs.exists(path) -> bool`, `bread.fs.expand(path) -> string`, `bread.fs.read(path) -> string | nil`, `bread.fs.readlink(path) -> string | nil`, `bread.fs.write(path, content)`, `bread.json.decode(str) -> table | nil`, `bread.machine.has_tag(tag) -> bool`, `bread.machine.name() -> string` (+2 more)
|
||||
|
||||
### Community 77 - "Contributing"
|
||||
Cohesion: 0.22
|
||||
Nodes (8): Branches, CI, Contributing, Keeping the API docs honest, Local development, Questions, The release cycle, Tracks, from a user's perspective
|
||||
|
||||
### Community 78 - "Bluetooth"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): Bluetooth, `bread.bluetooth.connect(address)`, `bread.bluetooth.devices() -> table | nil`, `bread.bluetooth.disconnect(address)`, `bread.bluetooth.power(enabled)`, `bread.bluetooth.powered() -> bool | nil`, `bread.bluetooth.scan(enabled)`, Example: auto-connect headphones on AC power (+1 more)
|
||||
|
||||
### Community 79 - "Widgets *(Since: v1.3)*"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): `bread.widget.list() -> table`, `bread.widget.register(spec) -> ok, err`, `bread.widget.remove(id) -> bool`, `bread.widget.update(id, patch) -> ok, err`, Click events, Node types, `style` *(Since: v1.4)*, Style vs. `class` (+1 more)
|
||||
|
||||
### Community 80 - "Getting started"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): 1) Create a minimal config, 2) The fast path: `rules.toml` *(Since: v1.5)*, 3) Minimal `init.lua`, 4) Start the daemon, 5) Check that it's running, Getting started
|
||||
|
||||
### Community 81 - "Capability-scoped modules *(Since: v1.5)*"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): Baseline (always available, no manifest entry needed), `bread modules audit <name>`, Capability-scoped modules *(Since: v1.5)*, Gated — requires a matching `[[permissions]]` entry, `path`/`bin` enforcement depends on where the module runs, `require("bread.devices")` still works from a scoped module
|
||||
|
||||
### Community 82 - "Workflows *(Since: v1.2)*"
|
||||
Cohesion: 0.33
|
||||
Nodes (6): `bread.workflow.define(name, fn)`, `bread.workflow.list() -> table`, `bread.workflow.start(name, opts)`, `bread.workflow.status(name) -> table | nil`, `bread.workflow.step(label)`, Workflows *(Since: v1.2)*
|
||||
|
||||
### Community 83 - "Timers"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): `bread.after(delay_ms, fn) -> id`, `bread.cancel(id)`, `bread.every(interval_ms, fn) -> id`, Timers
|
||||
|
||||
### Community 84 - "State"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): `bread.state.get(path)`, `bread.state.watch(path, fn) -> id`, State, Typed shorthands
|
||||
|
||||
### Community 85 - "init.lua"
|
||||
Cohesion: 0.83
|
||||
Nodes (3): M.on_load(), read_temp_c(), widget_root()
|
||||
|
||||
### Community 86 - "Execution"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): `bread.exec_capture(cmd, opts) -> ok, stdout`, `bread.exec(cmd)`, Execution
|
||||
|
||||
### Community 88 - "PathBuf"
|
||||
Cohesion: 1.00
|
||||
Nodes (3): dirs_home(), lua_expand_path(), PathBuf
|
||||
|
||||
## Knowledge Gaps
|
||||
- **182 isolated node(s):** `build.sh script`, `install.sh script`, `Remotes`, `CI`, `Local architecture (still true)` (+177 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **25 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `Adapter` connect `Adapter` to `Server`, `filesystem.rs`, `git.rs`, `udev.rs`, `systemd.rs`, `podman.rs`, `bluetooth.rs`, `network.rs`, `power.rs`, `hyprland.rs`, `parse_upower_message`, `Sync`?**
|
||||
_High betweenness centrality (0.089) - this node is a cross-community bridge._
|
||||
- **Why does `RawEvent` connect `RawEvent` to `Server`, `filesystem.rs`, `git.rs`, `udev.rs`, `systemd.rs`, `podman.rs`, `bluetooth.rs`, `Adapter`, `network.rs`, `power.rs`, `hyprland.rs`, `parse_upower_message`?**
|
||||
_High betweenness centrality (0.084) - this node is a cross-community bridge._
|
||||
- **Why does `BreadEvent` connect `RawEvent` to `ModuleHostLua`, `LuaEngine`, `.new`, `Server`, `ModuleHostRegistry`, `state_engine.rs`, `StateHandle`?**
|
||||
_High betweenness centrality (0.061) - this node is a cross-community bridge._
|
||||
- **What connects `build.sh script`, `install.sh script`, `Remotes` to the rest of the system?**
|
||||
_182 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `LuaEngine` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.11153846153846154 - nodes in this community are weakly interconnected._
|
||||
- **Should `RawEvent` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.05702970297029703 - nodes in this community are weakly interconnected._
|
||||
- **Should `config.rs` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.0670122176971492 - nodes in this community are weakly interconnected._
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "$graphify-root$_packaging_readme_md", "label": "README.md", "file_type": "document", "source_file": "packaging/README.md", "source_location": "L1"}, {"id": "$graphify-root$_packaging_readme_systemd_user_service", "label": "systemd user service", "file_type": "document", "source_file": "packaging/README.md", "source_location": "L17"}], "edges": [{"source": "$graphify-root$_packaging_readme_md", "target": "$graphify-root$_packaging_readme_systemd_user_service", "relation": "contains", "confidence": "EXTRACTED", "source_file": "packaging/README.md", "source_location": "L17", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "$graphify-root$_examples_modules_bluetooth_toggle_widget_lua", "label": "bluetooth-toggle-widget.lua", "file_type": "code", "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L1"}, {"id": "$graphify-root$_examples_modules_bluetooth_toggle_widget_widget_root", "label": "widget_root()", "file_type": "code", "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L14", "_callable": true}, {"id": "$graphify-root$_examples_modules_bluetooth_toggle_widget_m_on_load", "label": "M.on_load()", "file_type": "code", "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L43", "_callable": true}], "edges": [{"source": "$graphify-root$_examples_modules_bluetooth_toggle_widget_lua", "target": "$graphify-root$_examples_modules_bluetooth_toggle_widget_widget_root", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L14", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_bluetooth_toggle_widget_lua", "target": "$graphify-root$_examples_modules_bluetooth_toggle_widget_m_on_load", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L43", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_bluetooth_toggle_widget_m_on_load", "target": "$graphify-root$_examples_modules_bluetooth_toggle_widget_widget_root", "relation": "calls", "context": "call", "confidence": "EXTRACTED", "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L48", "weight": 1.0}], "raw_calls": [{"caller_nid": "$graphify-root$_examples_modules_bluetooth_toggle_widget_widget_root", "callee": "bread.bluetooth.powered", "is_member_call": false, "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L15", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_bluetooth_toggle_widget_widget_root", "callee": "bread.bluetooth.devices", "is_member_call": false, "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L16", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_bluetooth_toggle_widget_widget_root", "callee": "ipairs", "is_member_call": false, "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L18", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_bluetooth_toggle_widget_m_on_load", "callee": "bread.widget.register", "is_member_call": false, "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L44", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_bluetooth_toggle_widget_m_on_load", "callee": "bread.every", "is_member_call": false, "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L51", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_bluetooth_toggle_widget_m_on_load", "callee": "bread.widget.update", "is_member_call": false, "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L52", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_bluetooth_toggle_widget_m_on_load", "callee": "bread.on", "is_member_call": false, "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L55", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_bluetooth_toggle_widget_m_on_load", "callee": "bread.bluetooth.power", "is_member_call": false, "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L57", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_bluetooth_toggle_widget_m_on_load", "callee": "bread.bluetooth.powered", "is_member_call": false, "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L57", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_bluetooth_toggle_widget_m_on_load", "callee": "bread.after", "is_member_call": false, "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L59", "receiver": null}, {"caller_nid": "$graphify-root$_examples_modules_bluetooth_toggle_widget_m_on_load", "callee": "bread.widget.update", "is_member_call": false, "source_file": "examples/modules/bluetooth-toggle-widget.lua", "source_location": "L60", "receiver": null}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "$graphify-root$_deprecations_md", "label": "DEPRECATIONS.md", "file_type": "document", "source_file": "DEPRECATIONS.md", "source_location": "L1"}, {"id": "$graphify-root$_deprecations_deprecations", "label": "Deprecations", "file_type": "document", "source_file": "DEPRECATIONS.md", "source_location": "L1"}, {"id": "$graphify-root$_deprecations_hyprland_legacy_flat_event_names_since_v1_5", "label": "Hyprland legacy flat event names (since v1.5)", "file_type": "document", "source_file": "DEPRECATIONS.md", "source_location": "L8"}], "edges": [{"source": "$graphify-root$_deprecations_md", "target": "$graphify-root$_deprecations_deprecations", "relation": "contains", "confidence": "EXTRACTED", "source_file": "DEPRECATIONS.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_deprecations_md", "target": "$graphify-root$_documentation_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "DEPRECATIONS.md", "source_location": "L4", "weight": 1.0, "target_file": "$graphify-root$/Documentation.md"}, {"source": "$graphify-root$_deprecations_deprecations", "target": "$graphify-root$_deprecations_hyprland_legacy_flat_event_names_since_v1_5", "relation": "contains", "confidence": "EXTRACTED", "source_file": "DEPRECATIONS.md", "source_location": "L8", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "$graphify-root$_examples_modules_readme_md", "label": "README.md", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L1"}, {"id": "$graphify-root$_examples_modules_readme_example_bread_modules", "label": "Example bread modules", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L1"}, {"id": "$graphify-root$_examples_modules_readme_installing", "label": "Installing", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L7"}, {"id": "$graphify-root$_examples_modules_readme_modules", "label": "Modules", "file_type": "document", "source_file": "examples/modules/README.md", "source_location": "L33"}], "edges": [{"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_examples_modules_readme_example_bread_modules", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_examples_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L4", "weight": 1.0, "target_file": "$graphify-root$/Examples.md"}, {"source": "$graphify-root$_examples_modules_readme_example_bread_modules", "target": "$graphify-root$_examples_modules_readme_installing", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L7", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_examples_modules_permissions_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L18", "weight": 1.0}, {"source": "$graphify-root$_examples_modules_readme_md", "target": "$graphify-root$_documentation_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L19", "weight": 1.0, "target_file": "$graphify-root$/Documentation.md"}, {"source": "$graphify-root$_examples_modules_readme_example_bread_modules", "target": "$graphify-root$_examples_modules_readme_modules", "relation": "contains", "confidence": "EXTRACTED", "source_file": "examples/modules/README.md", "source_location": "L33", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "$graphify-root$_examples_md", "label": "Examples.md", "file_type": "document", "source_file": "Examples.md", "source_location": "L1"}, {"id": "$graphify-root$_examples_bread_examples", "label": "Bread Examples", "file_type": "document", "source_file": "Examples.md", "source_location": "L1"}, {"id": "$graphify-root$_examples_example_1_porting_keyboard_and_display_watcher_sh_system_script", "label": "Example 1: Porting keyboard_and_display_watcher.sh (system script)", "file_type": "document", "source_file": "Examples.md", "source_location": "L7"}, {"id": "$graphify-root$_examples_example_2_porting_autostart_lua", "label": "Example 2: Porting autostart.lua", "file_type": "document", "source_file": "Examples.md", "source_location": "L93"}, {"id": "$graphify-root$_examples_example_3_porting_display_monitors_lua", "label": "Example 3: Porting display/monitors.lua", "file_type": "document", "source_file": "Examples.md", "source_location": "L130"}, {"id": "$graphify-root$_examples_example_4_multi_step_automation_workflows", "label": "Example 4: Multi-step automation workflows", "file_type": "document", "source_file": "Examples.md", "source_location": "L182"}, {"id": "$graphify-root$_examples_example_5_a_live_widget_in_breadbar", "label": "Example 5: A live widget in breadbar", "file_type": "document", "source_file": "Examples.md", "source_location": "L241"}, {"id": "$graphify-root$_examples_tips_for_porting_your_own_scripts", "label": "Tips for porting your own scripts", "file_type": "document", "source_file": "Examples.md", "source_location": "L313"}], "edges": [{"source": "$graphify-root$_examples_md", "target": "$graphify-root$_examples_bread_examples", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_example_1_porting_keyboard_and_display_watcher_sh_system_script", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L7", "weight": 1.0}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_example_2_porting_autostart_lua", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L93", "weight": 1.0}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_example_3_porting_display_monitors_lua", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L130", "weight": 1.0}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_example_4_multi_step_automation_workflows", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L182", "weight": 1.0}, {"source": "$graphify-root$_examples_md", "target": "$graphify-root$_documentation_md", "relation": "references", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L235", "weight": 1.0, "target_file": "$graphify-root$/Documentation.md"}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_example_5_a_live_widget_in_breadbar", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L241", "weight": 1.0}, {"source": "$graphify-root$_examples_bread_examples", "target": "$graphify-root$_examples_tips_for_porting_your_own_scripts", "relation": "contains", "confidence": "EXTRACTED", "source_file": "Examples.md", "source_location": "L313", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1 @@
|
|||
{"nodes": [{"id": "$graphify-root$_claude_md", "label": "CLAUDE.md", "file_type": "document", "source_file": "CLAUDE.md", "source_location": "L1"}, {"id": "$graphify-root$_claude_claude_md_repo_hygiene", "label": "CLAUDE.md \u2014 Repo hygiene", "file_type": "document", "source_file": "CLAUDE.md", "source_location": "L1"}, {"id": "$graphify-root$_claude_remotes", "label": "Remotes", "file_type": "document", "source_file": "CLAUDE.md", "source_location": "L10"}, {"id": "$graphify-root$_claude_ci", "label": "CI", "file_type": "document", "source_file": "CLAUDE.md", "source_location": "L14"}, {"id": "$graphify-root$_claude_don_t", "label": "Don't", "file_type": "document", "source_file": "CLAUDE.md", "source_location": "L19"}], "edges": [{"source": "$graphify-root$_claude_md", "target": "$graphify-root$_claude_claude_md_repo_hygiene", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CLAUDE.md", "source_location": "L1", "weight": 1.0}, {"source": "$graphify-root$_claude_claude_md_repo_hygiene", "target": "$graphify-root$_claude_remotes", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CLAUDE.md", "source_location": "L10", "weight": 1.0}, {"source": "$graphify-root$_claude_claude_md_repo_hygiene", "target": "$graphify-root$_claude_ci", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CLAUDE.md", "source_location": "L14", "weight": 1.0}, {"source": "$graphify-root$_claude_claude_md_repo_hygiene", "target": "$graphify-root$_claude_don_t", "relation": "contains", "confidence": "EXTRACTED", "source_file": "CLAUDE.md", "source_location": "L19", "weight": 1.0}], "input_tokens": 0, "output_tokens": 0}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue