Compare commits
29 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c55ea4756 | ||
| b52bf77ed3 | |||
|
|
28ff4bc982 | ||
|
|
b9abafe572 | ||
|
|
06015b2627 | ||
|
|
6330ad1951 | ||
|
|
c359fd74be | ||
|
|
4d0c0288b7 | ||
|
|
fbd73c1984 | ||
|
|
d42249a586 | ||
|
|
2a157bdf2c | ||
|
|
1b02cec0a6 | ||
|
|
e5ce91a9c9 | ||
|
|
e5ba33d810 | ||
| 3251e18a49 | |||
|
|
73b3c24a3c | ||
|
|
1b3193855e | ||
|
|
0347d6deae | ||
|
|
7907b1192b | ||
|
|
ff7d1c8957 | ||
|
|
8d6573590b | ||
|
|
a47fd5852c | ||
|
|
57a6c9f20c | ||
|
|
88d5c86dc9 | ||
|
|
d27d6ef0b4 | ||
|
|
e20ad51def | ||
|
|
c0d1728bc0 | ||
|
|
4df124e7e8 | ||
|
|
1dc7030237 |
25 changed files with 1401 additions and 520 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
|
||||||
80
.forgejo/workflows/dev-release.yml
Normal file
80
.forgejo/workflows/dev-release.yml
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
name: dev release
|
||||||
|
|
||||||
|
# Publishes a dev-track build on every push to `main` (the trunk
|
||||||
|
# branch — there is no separate `dev` branch). See bread-ecosystem's
|
||||||
|
# docs/release-channels.md for the release-track policy this is part of.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['main']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch main --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||||
|
|
||||||
|
- name: 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' breadbox/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/breadbox/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
for bin in breadbox breadbox-sync; 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/breadbox-sync.service "${PKG_DIR}/"
|
||||||
|
cp src/config.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/LICENSE "${PKG_DIR}/"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadbox/latest"
|
||||||
|
|
||||||
|
# No GitHub Release upload — dev, like the other non-stable track,
|
||||||
|
# is only distributed via dl.breadway.dev/dev/.
|
||||||
|
- name: regenerate dev index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
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,21 +0,0 @@
|
||||||
name: Mirror to GitHub
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ['**']
|
|
||||||
tags: ['**']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
mirror:
|
|
||||||
runs-on: [self-hosted, hestia]
|
|
||||||
steps:
|
|
||||||
- name: Mirror to GitHub
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
|
|
||||||
cd repo.git
|
|
||||||
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
|
|
||||||
# --prune deletes GitHub refs that no longer exist on Forgejo.
|
|
||||||
git push --prune \
|
|
||||||
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadbox.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 gtk4 gtk4-layer-shell librsvg
|
|
||||||
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="breadbox-${VERSION}/" HEAD \
|
|
||||||
> packaging/arch/breadbox-${VERSION}.tar.gz
|
|
||||||
SHA=$(sha256sum packaging/arch/breadbox-${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"
|
|
||||||
61
.forgejo/workflows/rc-release.yml
Normal file
61
.forgejo/workflows/rc-release.yml
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
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: prepare artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/beta/breadbox/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
for bin in breadbox breadbox-sync; 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/breadbox-sync.service "${PKG_DIR}/"
|
||||||
|
cp src/config.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/LICENSE "${PKG_DIR}/"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadbox/latest"
|
||||||
|
|
||||||
|
# No GitHub Release upload — beta, like dev, is only distributed via
|
||||||
|
# dl.breadway.dev/beta/.
|
||||||
|
- name: regenerate beta index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||||
|
# mktemp: a fixed clone path races when multiple repos' dev/beta
|
||||||
|
# workflows run close together on the same self-hosted runner.
|
||||||
|
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||||
|
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||||
|
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||||
76
.forgejo/workflows/release.yml
Normal file
76
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: ${{ !contains(github.ref_name, '-rc.') }}
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ ! -f src/ci/build.sh ]; then
|
||||||
|
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd src && bash ci/build.sh cargo build --release --locked || {
|
||||||
|
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/breadbox/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
for bin in breadbox breadbox-sync; 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/breadbox-sync.service "${PKG_DIR}/"
|
||||||
|
cp src/config.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/LICENSE "${PKG_DIR}/"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/breadbox/latest"
|
||||||
|
|
||||||
|
- name: regenerate index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||||
|
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||||
|
|
||||||
|
- name: upload to GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/breadbox/${VERSION}"
|
||||||
|
gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadbox \
|
||||||
|
--title "breadbox v${VERSION}" --generate-notes 2>/dev/null || true
|
||||||
|
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadbox \
|
||||||
|
"${PKG_DIR}/breadbox-x86_64" \
|
||||||
|
"${PKG_DIR}/breadbox-sync-x86_64" \
|
||||||
|
"${PKG_DIR}/breadbox-x86_64.sha256" \
|
||||||
|
"${PKG_DIR}/breadbox-sync-x86_64.sha256" \
|
||||||
|
--clobber
|
||||||
1
.github/README.md
vendored
Normal file
1
.github/README.md
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Forgejo (`.forgejo/workflows`) is the canonical CI. This `.github` tree is unused.
|
||||||
67
.github/workflows/release.yml
vendored
67
.github/workflows/release.yml
vendored
|
|
@ -1,67 +0,0 @@
|
||||||
name: release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ["v*"]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
DL_DIR: /srv/breadway-dl
|
|
||||||
ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: [self-hosted, hestia]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: install build deps
|
|
||||||
run: sudo apt-get install -y libgtk-4-dev librsvg2-dev libdbus-1-dev pkg-config 2>/dev/null || true
|
|
||||||
|
|
||||||
- name: build
|
|
||||||
run: cargo build --release --locked
|
|
||||||
|
|
||||||
- name: prepare artifacts
|
|
||||||
run: |
|
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
PKG_DIR="${DL_DIR}/breadbox/${VERSION}"
|
|
||||||
mkdir -p "${PKG_DIR}"
|
|
||||||
for bin in breadbox breadbox-sync; do
|
|
||||||
cp "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 packaging/breadbox-sync.service "${PKG_DIR}/"
|
|
||||||
cp config.example.toml "${PKG_DIR}/"
|
|
||||||
cp bakery.toml "${PKG_DIR}/bakery.toml"
|
|
||||||
ln -sfn "${VERSION}" "${DL_DIR}/breadbox/latest"
|
|
||||||
|
|
||||||
- name: ensure bread-ecosystem
|
|
||||||
run: |
|
|
||||||
if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then
|
|
||||||
git -C "${ECOSYSTEM_DIR}" pull --ff-only
|
|
||||||
else
|
|
||||||
mkdir -p "$(dirname "${ECOSYSTEM_DIR}")"
|
|
||||||
git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: regenerate index.json
|
|
||||||
run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh"
|
|
||||||
|
|
||||||
- name: upload to GitHub Release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
|
||||||
PKG_DIR="${DL_DIR}/breadbox/${VERSION}"
|
|
||||||
gh release create "${GITHUB_REF_NAME}" \
|
|
||||||
--title "breadbox v${VERSION}" --generate-notes 2>/dev/null || true
|
|
||||||
gh release upload "${GITHUB_REF_NAME}" \
|
|
||||||
"${PKG_DIR}/breadbox-x86_64" \
|
|
||||||
"${PKG_DIR}/breadbox-sync-x86_64" \
|
|
||||||
"${PKG_DIR}/breadbox-x86_64.sha256" \
|
|
||||||
"${PKG_DIR}/breadbox-sync-x86_64.sha256" \
|
|
||||||
--clobber
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -19,3 +19,9 @@ Thumbs.db
|
||||||
|
|
||||||
# Claude Code session data
|
# Claude Code session data
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Local hygiene notes (not for commit)
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# graphify knowledge-graph output (local tool cache, not for commit)
|
||||||
|
graphify-out/
|
||||||
|
|
|
||||||
13
AGENTS.md
Normal file
13
AGENTS.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# AGENTS.md — Repo hygiene
|
||||||
|
|
||||||
|
Follow [`CONTRIBUTING.md`](CONTRIBUTING.md). Single-trunk: `main` plus short-lived `feature/` / `fix/` branches. `dev`/`beta` are bakery tracks (tags / main), not git branches.
|
||||||
|
|
||||||
|
## Remotes
|
||||||
|
- `origin` — Forgejo (`git.breadway.dev`) — authoritative.
|
||||||
|
- `github` — mirror. Day-to-day push `origin` only.
|
||||||
|
|
||||||
|
## Product
|
||||||
|
GTK4 app launcher + `breadbox-sync` icon cache. Theme via `bread-theme` (pin by tag on `git.breadway.dev`). Toggle uses `bread-utils::singleton`, not a homegrown PID file. `EVENTS.md` is the bread-event contract (app id `box`); emit `bread.box.launched` after a successful launch. `breadbox listen` honors `bread.command.box.open`.
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
Bakery (`bakery.toml`). Forgejo `.forgejo/workflows/` is canonical; do not re-add a GitHub Actions release workflow.
|
||||||
84
CONTRIBUTING.md
Normal file
84
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
`breadbox` — App launcher for Hyprland / Wayland.
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
557
Cargo.lock
generated
557
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
65
EVENTS.md
Normal file
65
EVENTS.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# breadbox — bread event integration
|
||||||
|
|
||||||
|
breadbox is a standalone app launcher: it works exactly the same with or
|
||||||
|
without `breadd` running. When breadd *is* present, the GTK launcher
|
||||||
|
publishes a single event into the shared bread automation fabric after a
|
||||||
|
successful launch. See the parent `bread` repo's `Documentation.md` —
|
||||||
|
specifically its "Namespaces" and "Integrating a bread\* app" sections —
|
||||||
|
for the general convention this follows.
|
||||||
|
|
||||||
|
App id: **`box`**. Transport: `bread-utils`'s `bread_client` module
|
||||||
|
(feature `bread-client`) — `breadbox` links it directly. One-shot
|
||||||
|
launcher invocations each `emit` on their own fire-and-forget
|
||||||
|
connection. Command verbs are only received while `breadbox listen` is
|
||||||
|
running — that process holds the `bread.command.box.**` subscription
|
||||||
|
open.
|
||||||
|
|
||||||
|
## Events published (`bread.box.*`)
|
||||||
|
|
||||||
|
| Event | Data | When |
|
||||||
|
|-------|------|------|
|
||||||
|
| `bread.box.launched` | `{ "id": "<desktop id or exec>", "name": "<display name>" }` | The user launched an app (Enter / keypad Enter on the selected row, or activating a row) **and** the spawn succeeded. Not emitted if `Command::spawn` fails (missing terminal, `exec` that cannot start). `id` is the desktop-file id (the `.desktop` filename, e.g. `firefox.desktop`), falling back to the stripped `Exec=` line when that id is empty. `name` is the desktop-entry display name. |
|
||||||
|
| `bread.box.open.done` | `{}` | `bread.command.box.open` was received and `breadbox` was spawned. This is the command confirmation, not proof the overlay mapped — the spawned process is the same toggle as a keybind. |
|
||||||
|
| `bread.box.open.failed` | `{ "error": "<message>" }` | `bread.command.box.open` was received but this binary could not be started. |
|
||||||
|
|
||||||
|
Launch history is local to breadbox (`~/.cache/breadbox/history.json`);
|
||||||
|
the event bus is a notification that a launch happened, not a channel
|
||||||
|
for the exec line's arguments or the resulting process.
|
||||||
|
|
||||||
|
## Commands honored (`bread.command.box.*`)
|
||||||
|
|
||||||
|
These are only received while `breadbox listen` is running. Publishing a
|
||||||
|
command with no subscriber is a silent no-op — that is the documented
|
||||||
|
bread convention, not a breadbox bug.
|
||||||
|
|
||||||
|
| Verb | Data | Effect |
|
||||||
|
|------|------|--------|
|
||||||
|
| `open` | none | Same as running `breadbox` (toggle the launcher overlay via the existing singleton). Emits `bread.box.open.done` / `.failed`. |
|
||||||
|
|
||||||
|
```lua
|
||||||
|
bread.spawn(function()
|
||||||
|
bread.emit("bread.command.box.open")
|
||||||
|
bread.wait("bread.box.open.done", { timeout = 5000 })
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Not implemented: extra verbs
|
||||||
|
|
||||||
|
There is no `launch` / `close` / `query` command verb. Picking a desktop
|
||||||
|
id from the bus would be a new product surface. If/when that exists, add
|
||||||
|
the corresponding `bread.command.box.*` verb at the same time, not
|
||||||
|
stubbed as a no-op ahead of it.
|
||||||
|
|
||||||
|
## Fail-safe behavior
|
||||||
|
|
||||||
|
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
||||||
|
(`BreadClient::emit` never blocks or errors the caller) and the
|
||||||
|
command subscription simply never receives anything — launching,
|
||||||
|
history, theming, and the singleton toggle are entirely unaffected.
|
||||||
|
- If breadd restarts, the command subscription reconnects automatically
|
||||||
|
(`BreadClient::subscribe`'s background thread has its own backoff
|
||||||
|
loop); no restart of `breadbox listen` is needed.
|
||||||
|
- If `breadbox listen` is not running, commands are a graceful no-op at
|
||||||
|
the bus (no subscriber). The CLI still works, and one-shot invocations
|
||||||
|
still emit `bread.box.launched` on their own short-lived connection.
|
||||||
|
- Closing the launcher without launching anything emits nothing.
|
||||||
|
|
@ -14,7 +14,7 @@ breadbox GTK4 layer-shell launcher
|
||||||
- Reads the active Hyprland workspace and sorts apps by context priority
|
- Reads the active Hyprland workspace and sorts apps by context priority
|
||||||
- Fuzzy filtering as you type; Enter launches, Escape closes
|
- Fuzzy filtering as you type; Enter launches, Escape closes
|
||||||
- App icons loaded from the resolved icon cache (see `breadbox-sync`)
|
- App icons loaded from the resolved icon cache (see `breadbox-sync`)
|
||||||
- pywal palette auto-detected from `~/.cache/wal/colors.json`, falls back to Catppuccin Mocha
|
- pywal accents from `~/.cache/wal/colors.json`; background/surface/overlay/foreground stay fixed BOS dark
|
||||||
- User CSS override at `~/.config/breadbox/style.css`
|
- User CSS override at `~/.config/breadbox/style.css`
|
||||||
- Toggle/dismiss: running a second instance kills the first
|
- Toggle/dismiss: running a second instance kills the first
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ binaries = ["breadbox", "breadbox-sync"]
|
||||||
system_deps = ["gtk4", "gtk4-layer-shell", "librsvg"]
|
system_deps = ["gtk4", "gtk4-layer-shell", "librsvg"]
|
||||||
optional_system_deps = ["hyprland"]
|
optional_system_deps = ["hyprland"]
|
||||||
bread_deps = []
|
bread_deps = []
|
||||||
|
license_file = "LICENSE"
|
||||||
|
|
||||||
[[service]]
|
[[service]]
|
||||||
unit = "breadbox-sync.service"
|
unit = "breadbox-sync.service"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadbox-shared"
|
name = "breadbox-shared"
|
||||||
version = "0.2.4"
|
version = "0.3.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,9 @@ pub fn app_dirs() -> Vec<PathBuf> {
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DesktopEntry {
|
pub struct DesktopEntry {
|
||||||
|
/// Desktop file id (the `.desktop` filename, e.g. `firefox.desktop`).
|
||||||
|
/// Empty only if the path had no file name; callers fall back to `exec`.
|
||||||
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub exec: String,
|
pub exec: String,
|
||||||
pub icon_name: String,
|
pub icon_name: String,
|
||||||
|
|
@ -155,7 +158,14 @@ pub fn parse_desktop(path: &Path) -> Option<DesktopEntry> {
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let id = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
Some(DesktopEntry {
|
Some(DesktopEntry {
|
||||||
|
id,
|
||||||
name,
|
name,
|
||||||
exec,
|
exec,
|
||||||
icon_name,
|
icon_name,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadbox-sync"
|
name = "breadbox-sync"
|
||||||
version = "0.2.4"
|
version = "0.3.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadbox"
|
name = "breadbox"
|
||||||
version = "0.2.4"
|
version = "0.3.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
|
@ -9,8 +9,13 @@ name = "breadbox"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
|
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4", features = ["gtk"] }
|
||||||
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client", "gtk"] }
|
||||||
|
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
|
||||||
|
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
|
||||||
breadbox-shared = { path = "../breadbox-shared" }
|
breadbox-shared = { path = "../breadbox-shared" }
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
gtk4-layer-shell = "0.8"
|
gtk4-layer-shell = "0.8"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
anyhow = "1"
|
||||||
|
|
|
||||||
84
breadbox/src/listen.rs
Normal file
84
breadbox/src/listen.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
//! Long-running command subscription for `bread.command.box.*`.
|
||||||
|
//!
|
||||||
|
//! `breadbox` is still a one-shot toggle overlay by default. `breadbox listen`
|
||||||
|
//! is the optional persistent process that can honor bus commands. See
|
||||||
|
//! `EVENTS.md`.
|
||||||
|
|
||||||
|
use bread_utils::bread_client::{BreadClient, BreadEvent};
|
||||||
|
|
||||||
|
/// Sibling-app id in `bread_shared::apps::KNOWN_APPS`.
|
||||||
|
const APP_ID: &str = "box";
|
||||||
|
|
||||||
|
/// Subscribe to `bread.command.box.**` and block until the process is killed.
|
||||||
|
///
|
||||||
|
/// breadd being absent is not an error: [`BreadClient::subscribe`] reconnects
|
||||||
|
/// with backoff, and `on_event` simply isn't called until the daemon is up.
|
||||||
|
pub fn run() {
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
if client.health().is_none() {
|
||||||
|
eprintln!("breadbox: breadd unreachable; command subscription will connect when it comes back");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _commands = client.subscribe("bread.command.box.**", |event| {
|
||||||
|
handle_command(&event);
|
||||||
|
});
|
||||||
|
|
||||||
|
eprintln!("breadbox: listening for bread.command.box.**");
|
||||||
|
loop {
|
||||||
|
std::thread::park();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to `bread.command.box.*` verbs. Only `open` is honored today —
|
||||||
|
/// other verbs are ignored, not stubbed as no-ops that pretend to succeed.
|
||||||
|
fn handle_command(event: &BreadEvent) {
|
||||||
|
let Some(verb) = command_verb(&event.event) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match verb {
|
||||||
|
"open" => handle_open(),
|
||||||
|
other => {
|
||||||
|
eprintln!("breadbox: ignoring unrecognized bread.command.box.{other}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_open() {
|
||||||
|
// Same as running `breadbox` from a keybind: toggle the overlay via the
|
||||||
|
// existing singleton. Spawn success is the command confirmation — we do
|
||||||
|
// not wait for the GTK window to map.
|
||||||
|
let result = spawn_self();
|
||||||
|
let client = BreadClient::connect(APP_ID);
|
||||||
|
match result {
|
||||||
|
Ok(_) => client.emit("bread.box.open.done", serde_json::json!({})),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("breadbox: bread.command.box.open failed: {e}");
|
||||||
|
client.emit(
|
||||||
|
"bread.box.open.failed",
|
||||||
|
serde_json::json!({ "error": e.to_string() }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_self() -> std::io::Result<std::process::Child> {
|
||||||
|
let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("breadbox"));
|
||||||
|
std::process::Command::new(exe).spawn()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_verb(event_name: &str) -> Option<&str> {
|
||||||
|
event_name.strip_prefix("bread.command.box.")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_verb_strips_box_prefix() {
|
||||||
|
assert_eq!(command_verb("bread.command.box.open"), Some("open"));
|
||||||
|
assert_eq!(command_verb("bread.command.box.launch"), Some("launch"));
|
||||||
|
assert_eq!(command_verb("bread.command.clip.clear"), None);
|
||||||
|
assert_eq!(command_verb("bread.box.launched"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,31 @@
|
||||||
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
|
use bread_theme::{hex_to_rgba, ink_on, load_palette, Palette};
|
||||||
|
use bread_utils::bread_client::BreadClient;
|
||||||
use std::{
|
use std::{
|
||||||
cell::RefCell,
|
cell::{Cell, RefCell},
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
env,
|
env, fs,
|
||||||
fs,
|
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
os::unix::net::UnixStream,
|
os::unix::net::UnixStream,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::{Command, Stdio},
|
process::{Command, Stdio},
|
||||||
rc::Rc,
|
rc::Rc,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// This app's id in bread's sibling-app namespace registry
|
||||||
|
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.box.*`.
|
||||||
|
const APP_ID: &str = "box";
|
||||||
|
|
||||||
use breadbox_shared::{
|
use breadbox_shared::{
|
||||||
config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory,
|
config_dir, load_all_desktop_entries, Config, DesktopEntry, IconCache, LaunchHistory,
|
||||||
};
|
};
|
||||||
use gtk4::{
|
use gtk4::{
|
||||||
glib,
|
glib, pango::EllipsizeMode, prelude::*, Application, Box as GBox, CssProvider, Entry,
|
||||||
pango::EllipsizeMode,
|
EventControllerKey, Label, ListBox, Orientation, PolicyType, ScrolledWindow, SelectionMode,
|
||||||
prelude::*,
|
|
||||||
Application, ApplicationWindow, Box as GBox, CssProvider, EventControllerKey, Label,
|
|
||||||
ListBox, Orientation, PolicyType, ScrolledWindow, SearchEntry, SelectionMode,
|
|
||||||
};
|
};
|
||||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
|
||||||
|
mod listen;
|
||||||
|
mod screenshot;
|
||||||
|
|
||||||
// ---- Hyprland IPC -----------------------------------------------------------
|
// ---- Hyprland IPC -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -82,7 +86,9 @@ fn load_sorted_entries(
|
||||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||||
(None, None) => {
|
(None, None) => {
|
||||||
// Most-launched first, then alphabetical
|
// Most-launched first, then alphabetical
|
||||||
history.count(&b.name).cmp(&history.count(&a.name))
|
history
|
||||||
|
.count(&b.name)
|
||||||
|
.cmp(&history.count(&a.name))
|
||||||
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -130,58 +136,171 @@ fn matches_term(field: &str, term: &str) -> bool {
|
||||||
|
|
||||||
// ---- Theming ----------------------------------------------------------------
|
// ---- Theming ----------------------------------------------------------------
|
||||||
|
|
||||||
|
const STAGGER_ROWS: usize = 12;
|
||||||
|
|
||||||
fn build_css(p: &Palette) -> String {
|
fn build_css(p: &Palette) -> String {
|
||||||
let bg_panel = hex_to_rgba(&p.background, 0.60);
|
let bg_panel = hex_to_rgba(&p.background, 0.68);
|
||||||
// breadbox-specific rules only — fonts, palette, and generic widgets come
|
// breadbox-specific rules only — fonts, palette, and generic widgets come
|
||||||
// from the shared ecosystem stylesheet (applied first in connect_activate).
|
// from the shared ecosystem stylesheet (applied first in connect_activate).
|
||||||
// Colour is set on each surface (panel, search box, hovered/selected row) so
|
// Colour is set on each surface (panel, search, hovered/selected row) so
|
||||||
// child labels inherit the legible ink for that background. `on_*` are
|
// child labels inherit the legible ink for that background. `on_*` are
|
||||||
// luminance-picked black/white — the pywal hues are untouched. Without this a
|
// luminance-picked black/white — the pywal hues are untouched.
|
||||||
// light `surface` slot makes the selected row's text vanish.
|
//
|
||||||
|
// GTK4 ListBox's node is `list`, not `listbox`. These `list row:selected`
|
||||||
|
// rules beat the shared sheet's solid accent fill + on-accent ink so the
|
||||||
|
// glass card keeps a tinted selection and a left inset hairline.
|
||||||
|
let stagger = (0..STAGGER_ROWS)
|
||||||
|
.map(|i| {
|
||||||
format!(
|
format!(
|
||||||
"window {{ background-color: transparent; }}\
|
".launcher-bg.just-opened list row.stagger-{i} {{ animation-delay: {}ms; }}",
|
||||||
.launcher-bg {{ background-color: {bg_panel}; color: {on_bg}; border-radius: 8px;\
|
i * 28
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.6); }}\
|
|
||||||
searchentry {{ background-color: {surface}; color: {on_surface}; caret-color: {accent};\
|
|
||||||
border: none; outline: none; box-shadow: none;\
|
|
||||||
padding: 12px 16px; border-radius: 6px 6px 0 0; }}\
|
|
||||||
listbox {{ background-color: transparent; padding: 4px; }}\
|
|
||||||
row {{ padding: 8px 12px; color: {on_bg}; background-color: transparent;\
|
|
||||||
border-radius: 6px; }}\
|
|
||||||
row:hover {{ background-color: {surface}; color: {on_surface}; }}\
|
|
||||||
row:selected {{ background-color: {surface}; color: {on_surface}; }}\
|
|
||||||
.app-name {{ font-size: 14px; }}\
|
|
||||||
.app-muted {{ opacity: 0.6; font-size: 12px; }}\
|
|
||||||
image {{ margin-right: 8px; }}",
|
|
||||||
bg_panel = bg_panel,
|
|
||||||
surface = p.color0,
|
|
||||||
accent = p.color4,
|
|
||||||
on_bg = ink_on(&p.background),
|
|
||||||
on_surface = ink_on(&p.color0),
|
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
format!(
|
||||||
|
"\
|
||||||
|
window {{ background-color: rgba(0, 0, 0, 0.28); animation: scrim-in 0.28s ease both; }}\
|
||||||
|
@keyframes scrim-in {{\
|
||||||
|
from {{ background-color: rgba(0, 0, 0, 0); }}\
|
||||||
|
to {{ background-color: rgba(0, 0, 0, 0.28); }}\
|
||||||
|
}}\
|
||||||
|
.launcher-bg {{\
|
||||||
|
background-color: {bg_panel}; color: {on_bg}; border-radius: 20px;\
|
||||||
|
border: 1px solid alpha({on_bg}, 0.14);\
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.50);\
|
||||||
|
animation: card-in 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;\
|
||||||
|
}}\
|
||||||
|
@keyframes card-in {{\
|
||||||
|
from {{ opacity: 0; margin-top: 112px; }}\
|
||||||
|
to {{ opacity: 1; margin-top: 88px; }}\
|
||||||
|
}}\
|
||||||
|
.launcher-bg entry {{\
|
||||||
|
background-color: transparent; color: {on_bg}; caret-color: {accent};\
|
||||||
|
border: none; outline: none; box-shadow: none;\
|
||||||
|
padding: 20px 22px 14px; border-radius: 20px 20px 0 0;\
|
||||||
|
font-size: 17px; min-height: 28px;\
|
||||||
|
}}\
|
||||||
|
.launcher-bg entry:focus, .launcher-bg entry:focus-within {{\
|
||||||
|
border: none; outline: none; box-shadow: none; background-color: transparent;\
|
||||||
|
}}\
|
||||||
|
entry > text {{ background: transparent; }}\
|
||||||
|
entry image {{ opacity: 0; min-width: 0; margin: 0; padding: 0; }}\
|
||||||
|
.launcher-caret {{\
|
||||||
|
min-height: 2px; max-height: 2px; margin: 0 20px; border-radius: 2px;\
|
||||||
|
background-color: {accent};\
|
||||||
|
background-image: linear-gradient(90deg, {accent}, {accent2});\
|
||||||
|
}}\
|
||||||
|
.launcher-bg.just-opened .launcher-caret {{\
|
||||||
|
animation: caret-draw 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;\
|
||||||
|
}}\
|
||||||
|
@keyframes caret-draw {{\
|
||||||
|
from {{ margin-right: 600px; opacity: 0.25; }}\
|
||||||
|
to {{ margin-right: 20px; opacity: 1; }}\
|
||||||
|
}}\
|
||||||
|
scrolledwindow {{ background: transparent; }}\
|
||||||
|
list {{ background-color: transparent; padding: 8px 8px 4px; }}\
|
||||||
|
list row {{\
|
||||||
|
padding: 6px 8px; color: {on_bg}; background-color: transparent;\
|
||||||
|
border-radius: 14px; margin: 1px 10px; outline: none;\
|
||||||
|
}}\
|
||||||
|
list row:hover {{ background-color: alpha({on_bg}, 0.07); color: {on_bg}; }}\
|
||||||
|
row:selected, list row:selected, list row:selected:focus,\
|
||||||
|
list row:selected:hover, list row:selected:focus:hover {{\
|
||||||
|
background-color: alpha({accent}, 0.22); color: {on_bg};\
|
||||||
|
outline: none; box-shadow: none;\
|
||||||
|
}}\
|
||||||
|
list row:selected label, list row:selected .app-name, list row:selected .app-muted {{\
|
||||||
|
color: {on_bg};\
|
||||||
|
}}\
|
||||||
|
.app-row {{ min-height: 48px; }}\
|
||||||
|
.app-icon-well {{\
|
||||||
|
min-width: 38px; min-height: 38px; margin-right: 12px;\
|
||||||
|
border-radius: 999px; background-color: alpha({on_bg}, 0.08);\
|
||||||
|
}}\
|
||||||
|
.app-icon {{ color: {on_bg}; opacity: 0.88; }}\
|
||||||
|
.app-name {{ font-size: 14px; font-weight: bold; }}\
|
||||||
|
.app-muted {{ opacity: 0.48; font-size: 11px; }}\
|
||||||
|
.launcher-footer {{\
|
||||||
|
padding: 8px 18px 12px; font-size: 11px; opacity: 0.40;\
|
||||||
|
letter-spacing: 0.08em; text-transform: uppercase;\
|
||||||
|
}}\
|
||||||
|
.launcher-bg.just-opened list row {{\
|
||||||
|
animation: row-in 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;\
|
||||||
|
}}\
|
||||||
|
@keyframes row-in {{\
|
||||||
|
from {{ opacity: 0; }}\
|
||||||
|
to {{ opacity: 1; }}\
|
||||||
|
}}\
|
||||||
|
{stagger}\
|
||||||
|
list.reflow row {{ animation: row-fade 0.16s ease both; }}\
|
||||||
|
@keyframes row-fade {{\
|
||||||
|
from {{ opacity: 0.40; }}\
|
||||||
|
to {{ opacity: 1; }}\
|
||||||
|
}}\
|
||||||
|
.no-motion, .no-motion * {{ animation: none; transition: none; }}",
|
||||||
|
bg_panel = bg_panel,
|
||||||
|
accent = p.color4,
|
||||||
|
accent2 = p.color5,
|
||||||
|
on_bg = ink_on(&p.background),
|
||||||
|
stagger = stagger,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn category_label(entry: &DesktopEntry) -> &'static str {
|
||||||
|
let has = |needle: &str| {
|
||||||
|
entry
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.eq_ignore_ascii_case(needle) || c.to_ascii_lowercase().contains(needle))
|
||||||
|
};
|
||||||
|
if entry.terminal || has("terminalemulator") {
|
||||||
|
"Terminal"
|
||||||
|
} else if has("webbrowser") {
|
||||||
|
"Browser"
|
||||||
|
} else if has("game") {
|
||||||
|
"Games"
|
||||||
|
} else if has("instantmessaging") || has("chat") || has("ircclient") {
|
||||||
|
"Chat"
|
||||||
|
} else if has("settings") || has("desktopsettings") || has("system") {
|
||||||
|
"System"
|
||||||
|
} else if has("ide") || has("development") {
|
||||||
|
"IDE"
|
||||||
|
} else if has("office") || has("wordprocessor") || has("texteditor") || has("notes") {
|
||||||
|
"Notes"
|
||||||
|
} else if has("audio") || has("player") || has("audiovideo") {
|
||||||
|
"Music"
|
||||||
|
} else if has("graphics") || has("photography") || has("camera") {
|
||||||
|
"Capture"
|
||||||
|
} else if has("filemanager") {
|
||||||
|
"Files"
|
||||||
|
} else {
|
||||||
|
"App"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn symbolic_icon(entry: &DesktopEntry) -> &'static str {
|
||||||
|
match category_label(entry) {
|
||||||
|
"Browser" => "web-browser-symbolic",
|
||||||
|
"Terminal" => "utilities-terminal-symbolic",
|
||||||
|
"Notes" => "accessories-text-editor-symbolic",
|
||||||
|
"System" => "emblem-system-symbolic",
|
||||||
|
"Chat" => "user-available-symbolic",
|
||||||
|
"Games" => "applications-games-symbolic",
|
||||||
|
"Music" => "audio-x-generic-symbolic",
|
||||||
|
"Capture" => "camera-photo-symbolic",
|
||||||
|
"IDE" => "applications-engineering-symbolic",
|
||||||
|
"Files" => "folder-symbolic",
|
||||||
|
_ => "application-x-executable-symbolic",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Icon loading -----------------------------------------------------------
|
// ---- Icon loading -----------------------------------------------------------
|
||||||
|
|
||||||
fn make_icon(icon_name: &str, icon_path: Option<&Path>) -> gtk4::Image {
|
fn make_icon(entry: &DesktopEntry) -> gtk4::Image {
|
||||||
// Try loading from resolved cached path via gio::File
|
let img = gtk4::Image::from_icon_name(symbolic_icon(entry));
|
||||||
if let Some(path) = icon_path {
|
img.set_pixel_size(18);
|
||||||
let gio_file = gtk4::gio::File::for_path(path);
|
img.add_css_class("app-icon");
|
||||||
if let Ok(texture) = gtk4::gdk::Texture::from_file(&gio_file) {
|
|
||||||
let img = gtk4::Image::new();
|
|
||||||
img.set_paintable(Some(&texture));
|
|
||||||
img.set_pixel_size(32);
|
|
||||||
return img;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fall back to GTK icon theme lookup by name
|
|
||||||
let name = if icon_name.is_empty() {
|
|
||||||
"application-x-executable"
|
|
||||||
} else {
|
|
||||||
icon_name
|
|
||||||
};
|
|
||||||
let img = gtk4::Image::from_icon_name(name);
|
|
||||||
img.set_pixel_size(32);
|
|
||||||
img
|
img
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,24 +323,42 @@ fn pick_terminal() -> String {
|
||||||
|
|
||||||
fn do_launch(entry: &DesktopEntry) {
|
fn do_launch(entry: &DesktopEntry) {
|
||||||
let cmd = entry.exec.trim();
|
let cmd = entry.exec.trim();
|
||||||
if entry.terminal {
|
let spawned = if entry.terminal {
|
||||||
let term = pick_terminal();
|
let term = pick_terminal();
|
||||||
let _ = Command::new(&term)
|
Command::new(&term)
|
||||||
.args(["-e", "bash", "-c", cmd])
|
.args(["-e", "bash", "-c", cmd])
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.spawn();
|
.spawn()
|
||||||
} else {
|
} else {
|
||||||
let _ = Command::new("bash")
|
Command::new("bash")
|
||||||
.args(["-c", cmd])
|
.args(["-c", cmd])
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.spawn();
|
.spawn()
|
||||||
|
};
|
||||||
|
if spawned.is_ok() {
|
||||||
|
emit_launched(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publishes `bread.box.launched` after a successful spawn. Fire-and-forget
|
||||||
|
/// and non-fatal (`BreadClient::emit` never blocks or errors this caller) —
|
||||||
|
/// breadd being absent must never affect launching itself.
|
||||||
|
fn emit_launched(entry: &DesktopEntry) {
|
||||||
|
let id = if entry.id.is_empty() {
|
||||||
|
entry.exec.as_str()
|
||||||
|
} else {
|
||||||
|
entry.id.as_str()
|
||||||
|
};
|
||||||
|
BreadClient::connect(APP_ID).emit(
|
||||||
|
"bread.box.launched",
|
||||||
|
serde_json::json!({ "id": id, "name": entry.name }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Fuzzy matching ---------------------------------------------------------
|
// ---- Fuzzy matching ---------------------------------------------------------
|
||||||
|
|
||||||
fn fuzzy_matches(pattern: &str, text: &str) -> bool {
|
fn fuzzy_matches(pattern: &str, text: &str) -> bool {
|
||||||
|
|
@ -245,47 +382,21 @@ fn fuzzy_score(query: &str, entry: &DesktopEntry) -> u32 {
|
||||||
let q = query.to_lowercase();
|
let q = query.to_lowercase();
|
||||||
let name = entry.name.to_lowercase();
|
let name = entry.name.to_lowercase();
|
||||||
let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
|
let wm = entry.wm_class.as_deref().unwrap_or("").to_lowercase();
|
||||||
if name == q || wm == q { return 0; }
|
if name == q || wm == q {
|
||||||
if name.starts_with(&q) { return 1; }
|
return 0;
|
||||||
if name.contains(&q) { return 2; }
|
}
|
||||||
if wm.starts_with(&q) || wm.contains(&q) { return 3; }
|
if name.starts_with(&q) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if name.contains(&q) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if wm.starts_with(&q) || wm.contains(&q) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
4 // subsequence match
|
4 // subsequence match
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- PID file toggle --------------------------------------------------------
|
|
||||||
|
|
||||||
fn pid_file() -> PathBuf {
|
|
||||||
env::var("XDG_RUNTIME_DIR")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| PathBuf::from("/tmp"))
|
|
||||||
.join("breadbox.pid")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_breadbox_pid(pid: u32) -> bool {
|
|
||||||
fs::read_to_string(format!("/proc/{}/comm", pid))
|
|
||||||
.map(|s| s.trim() == "breadbox")
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns false if an existing instance was killed (caller should exit).
|
|
||||||
fn toggle_or_continue() -> bool {
|
|
||||||
let pf = pid_file();
|
|
||||||
if let Ok(content) = fs::read_to_string(&pf) {
|
|
||||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
|
||||||
if is_breadbox_pid(pid) {
|
|
||||||
let _ = Command::new("kill").arg(pid.to_string()).status();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = fs::write(&pf, std::process::id().to_string());
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cleanup_pid() {
|
|
||||||
let _ = fs::remove_file(pid_file());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- UI ---------------------------------------------------------------------
|
// ---- UI ---------------------------------------------------------------------
|
||||||
|
|
||||||
fn get_row_entry(row: >k4::ListBoxRow) -> Option<DesktopEntry> {
|
fn get_row_entry(row: >k4::ListBoxRow) -> Option<DesktopEntry> {
|
||||||
|
|
@ -295,13 +406,44 @@ fn get_row_entry(row: >k4::ListBoxRow) -> Option<DesktopEntry> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
|
fn visible_row_count(list: &ListBox) -> u32 {
|
||||||
let app = Application::builder()
|
let mut n = 0;
|
||||||
.application_id("com.breadway.breadbox")
|
let mut i = 0;
|
||||||
.build();
|
while let Some(row) = list.row_at_index(i) {
|
||||||
|
if row.is_visible() {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_footer_count(footer: &Label, n: u32) {
|
||||||
|
match n {
|
||||||
|
0 => footer.set_text("no match"),
|
||||||
|
1 => footer.set_text("1 app"),
|
||||||
|
n => footer.set_text(&format!("{n} apps")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ui(
|
||||||
|
entries: Vec<DesktopEntry>,
|
||||||
|
history: LaunchHistory,
|
||||||
|
screenshot_req: Option<screenshot::ScreenshotRequest>,
|
||||||
|
) {
|
||||||
|
let mut builder = Application::builder().application_id("com.breadway.breadbox");
|
||||||
|
if screenshot_req.is_some() {
|
||||||
|
// GApplication is single-instance by default; this machine typically
|
||||||
|
// already has a real breadbox instance, so without this a screenshot
|
||||||
|
// run would just message the *existing* instance instead of
|
||||||
|
// starting a fresh one that ever sees `screenshot_req`.
|
||||||
|
builder = builder.flags(gtk4::gio::ApplicationFlags::NON_UNIQUE);
|
||||||
|
}
|
||||||
|
let app = builder.build();
|
||||||
|
|
||||||
let history_rc = Rc::new(RefCell::new(history));
|
let history_rc = Rc::new(RefCell::new(history));
|
||||||
let query_rc: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
|
let query_rc: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
|
||||||
|
let is_screenshot_run = screenshot_req.is_some();
|
||||||
|
|
||||||
app.connect_activate(move |app| {
|
app.connect_activate(move |app| {
|
||||||
// Shared ecosystem base (fonts, palette, generic widgets) first, then
|
// Shared ecosystem base (fonts, palette, generic widgets) first, then
|
||||||
|
|
@ -317,21 +459,13 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
|
||||||
bread_theme::gtk::apply_user_css(&user_css_path, &user_cell);
|
bread_theme::gtk::apply_user_css(&user_css_path, &user_cell);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full-screen transparent window; clicks outside the launcher panel close it.
|
// Full-screen transparent overlay; panel widget is positioned inside it.
|
||||||
let window = ApplicationWindow::builder().application(app).build();
|
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadbox");
|
||||||
window.init_layer_shell();
|
bread_theme::gtk::bind_window_auto(&window);
|
||||||
window.set_namespace(Some("breadbox"));
|
|
||||||
window.set_layer(Layer::Overlay);
|
|
||||||
window.set_keyboard_mode(KeyboardMode::Exclusive);
|
|
||||||
for edge in [Edge::Top, Edge::Bottom, Edge::Left, Edge::Right] {
|
|
||||||
window.set_anchor(edge, true);
|
|
||||||
}
|
|
||||||
window.set_exclusive_zone(0);
|
|
||||||
|
|
||||||
let close_all: Rc<dyn Fn()> = Rc::new({
|
let close_all: Rc<dyn Fn()> = Rc::new({
|
||||||
let w = window.clone();
|
let w = window.clone();
|
||||||
move || {
|
move || {
|
||||||
cleanup_pid();
|
|
||||||
w.close();
|
w.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -340,13 +474,25 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
|
||||||
vbox.add_css_class("launcher-bg");
|
vbox.add_css_class("launcher-bg");
|
||||||
vbox.set_halign(gtk4::Align::Center);
|
vbox.set_halign(gtk4::Align::Center);
|
||||||
vbox.set_valign(gtk4::Align::Start);
|
vbox.set_valign(gtk4::Align::Start);
|
||||||
vbox.set_margin_top(120);
|
vbox.set_margin_top(88);
|
||||||
vbox.set_size_request(600, -1);
|
vbox.set_size_request(600, -1);
|
||||||
|
if is_screenshot_run {
|
||||||
|
window.add_css_class("no-motion");
|
||||||
|
vbox.add_css_class("no-motion");
|
||||||
|
} else {
|
||||||
|
vbox.add_css_class("just-opened");
|
||||||
|
}
|
||||||
|
|
||||||
let search = SearchEntry::new();
|
let search = Entry::new();
|
||||||
search.set_placeholder_text(Some("breadbox"));
|
search.set_placeholder_text(Some("Search"));
|
||||||
|
search.set_has_frame(false);
|
||||||
vbox.append(&search);
|
vbox.append(&search);
|
||||||
|
|
||||||
|
let caret = GBox::new(Orientation::Horizontal, 0);
|
||||||
|
caret.add_css_class("launcher-caret");
|
||||||
|
caret.set_hexpand(true);
|
||||||
|
vbox.append(&caret);
|
||||||
|
|
||||||
let scroll = ScrolledWindow::new();
|
let scroll = ScrolledWindow::new();
|
||||||
scroll.set_policy(PolicyType::Never, PolicyType::Automatic);
|
scroll.set_policy(PolicyType::Never, PolicyType::Automatic);
|
||||||
scroll.set_max_content_height(480);
|
scroll.set_max_content_height(480);
|
||||||
|
|
@ -357,28 +503,44 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
|
||||||
|
|
||||||
for (idx, entry) in entries.iter().enumerate() {
|
for (idx, entry) in entries.iter().enumerate() {
|
||||||
let row = gtk4::ListBoxRow::new();
|
let row = gtk4::ListBoxRow::new();
|
||||||
|
if idx < STAGGER_ROWS {
|
||||||
|
row.add_css_class(&format!("stagger-{idx}"));
|
||||||
|
}
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 0);
|
let hbox = GBox::new(Orientation::Horizontal, 0);
|
||||||
hbox.set_margin_start(6);
|
hbox.add_css_class("app-row");
|
||||||
hbox.set_margin_end(6);
|
|
||||||
hbox.set_valign(gtk4::Align::Center);
|
hbox.set_valign(gtk4::Align::Center);
|
||||||
|
|
||||||
let icon = make_icon(&entry.icon_name, entry.icon_path.as_deref());
|
let well = GBox::new(Orientation::Horizontal, 0);
|
||||||
hbox.append(&icon);
|
well.add_css_class("app-icon-well");
|
||||||
|
well.set_size_request(38, 38);
|
||||||
|
well.set_halign(gtk4::Align::Center);
|
||||||
|
well.set_valign(gtk4::Align::Center);
|
||||||
|
well.set_hexpand(false);
|
||||||
|
let icon = make_icon(entry);
|
||||||
|
icon.set_halign(gtk4::Align::Center);
|
||||||
|
icon.set_valign(gtk4::Align::Center);
|
||||||
|
icon.set_hexpand(true);
|
||||||
|
well.append(&icon);
|
||||||
|
hbox.append(&well);
|
||||||
|
|
||||||
|
let text = GBox::new(Orientation::Vertical, 1);
|
||||||
|
text.add_css_class("app-text");
|
||||||
|
text.set_hexpand(true);
|
||||||
|
text.set_valign(gtk4::Align::Center);
|
||||||
|
|
||||||
let name_lbl = Label::new(Some(&entry.name));
|
let name_lbl = Label::new(Some(&entry.name));
|
||||||
name_lbl.add_css_class("app-name");
|
name_lbl.add_css_class("app-name");
|
||||||
name_lbl.set_xalign(0.0);
|
name_lbl.set_xalign(0.0);
|
||||||
name_lbl.set_hexpand(true);
|
|
||||||
name_lbl.set_ellipsize(EllipsizeMode::End);
|
name_lbl.set_ellipsize(EllipsizeMode::End);
|
||||||
hbox.append(&name_lbl);
|
text.append(&name_lbl);
|
||||||
|
|
||||||
if let Some(ref wm) = entry.wm_class {
|
let sub_lbl = Label::new(Some(category_label(entry)));
|
||||||
let wm_lbl = Label::new(Some(wm));
|
sub_lbl.add_css_class("app-muted");
|
||||||
wm_lbl.add_css_class("app-muted");
|
sub_lbl.set_xalign(0.0);
|
||||||
wm_lbl.set_xalign(1.0);
|
sub_lbl.set_ellipsize(EllipsizeMode::End);
|
||||||
hbox.append(&wm_lbl);
|
text.append(&sub_lbl);
|
||||||
}
|
|
||||||
|
|
||||||
|
hbox.append(&text);
|
||||||
row.set_child(Some(&hbox));
|
row.set_child(Some(&hbox));
|
||||||
unsafe { row.set_data("entry", entry.clone()) };
|
unsafe { row.set_data("entry", entry.clone()) };
|
||||||
unsafe { row.set_data("initial_order", idx as u32) };
|
unsafe { row.set_data("initial_order", idx as u32) };
|
||||||
|
|
@ -392,8 +554,16 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
|
||||||
list.set_sort_func(move |row_a, row_b| {
|
list.set_sort_func(move |row_a, row_b| {
|
||||||
let query = sort_query.borrow();
|
let query = sort_query.borrow();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
let oa = unsafe { row_a.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref()) };
|
let oa = unsafe {
|
||||||
let ob = unsafe { row_b.data::<u32>("initial_order").map_or(u32::MAX, |p| *p.as_ref()) };
|
row_a
|
||||||
|
.data::<u32>("initial_order")
|
||||||
|
.map_or(u32::MAX, |p| *p.as_ref())
|
||||||
|
};
|
||||||
|
let ob = unsafe {
|
||||||
|
row_b
|
||||||
|
.data::<u32>("initial_order")
|
||||||
|
.map_or(u32::MAX, |p| *p.as_ref())
|
||||||
|
};
|
||||||
return oa.cmp(&ob).into();
|
return oa.cmp(&ob).into();
|
||||||
}
|
}
|
||||||
let (Some(ea), Some(eb)) = (get_row_entry(row_a), get_row_entry(row_b)) else {
|
let (Some(ea), Some(eb)) = (get_row_entry(row_a), get_row_entry(row_b)) else {
|
||||||
|
|
@ -416,11 +586,29 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
|
||||||
|
|
||||||
scroll.set_child(Some(&list));
|
scroll.set_child(Some(&list));
|
||||||
vbox.append(&scroll);
|
vbox.append(&scroll);
|
||||||
|
|
||||||
|
let footer = Label::new(None);
|
||||||
|
footer.add_css_class("launcher-footer");
|
||||||
|
footer.set_xalign(0.0);
|
||||||
|
set_footer_count(&footer, visible_row_count(&list));
|
||||||
|
vbox.append(&footer);
|
||||||
|
|
||||||
window.set_child(Some(&vbox));
|
window.set_child(Some(&vbox));
|
||||||
|
|
||||||
// Filter on keystroke
|
if !is_screenshot_run {
|
||||||
|
let vbox_open = vbox.clone();
|
||||||
|
glib::timeout_add_local_once(Duration::from_millis(520), move || {
|
||||||
|
vbox_open.remove_css_class("just-opened");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter on keystroke. ListBox keeps row identity across sort, so the
|
||||||
|
// reorder is already a cheap FLIP analog; a short CSS fade is the extra.
|
||||||
let list_f = list.clone();
|
let list_f = list.clone();
|
||||||
|
let footer_f = footer.clone();
|
||||||
|
let vbox_f = vbox.clone();
|
||||||
let filter_query = Rc::clone(&query_rc);
|
let filter_query = Rc::clone(&query_rc);
|
||||||
|
let reflow_gen = Rc::new(Cell::new(0u32));
|
||||||
search.connect_changed(move |entry| {
|
search.connect_changed(move |entry| {
|
||||||
let text = entry.text();
|
let text = entry.text();
|
||||||
let query = text.as_str();
|
let query = text.as_str();
|
||||||
|
|
@ -430,6 +618,7 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
|
||||||
let vis = get_row_entry(&row)
|
let vis = get_row_entry(&row)
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
fuzzy_matches(query, &e.name)
|
fuzzy_matches(query, &e.name)
|
||||||
|
|| fuzzy_matches(query, category_label(&e))
|
||||||
|| e.wm_class
|
|| e.wm_class
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|w| fuzzy_matches(query, w))
|
.is_some_and(|w| fuzzy_matches(query, w))
|
||||||
|
|
@ -440,10 +629,23 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
list_f.invalidate_sort();
|
list_f.invalidate_sort();
|
||||||
let first_vis = (0i32..).find_map(|j| {
|
let first_vis =
|
||||||
list_f.row_at_index(j).filter(|r| r.is_visible())
|
(0i32..).find_map(|j| list_f.row_at_index(j).filter(|r| r.is_visible()));
|
||||||
});
|
|
||||||
list_f.select_row(first_vis.as_ref());
|
list_f.select_row(first_vis.as_ref());
|
||||||
|
set_footer_count(&footer_f, visible_row_count(&list_f));
|
||||||
|
if !vbox_f.has_css_class("just-opened") {
|
||||||
|
list_f.remove_css_class("reflow");
|
||||||
|
list_f.add_css_class("reflow");
|
||||||
|
let gen = reflow_gen.get().wrapping_add(1);
|
||||||
|
reflow_gen.set(gen);
|
||||||
|
let list_fade = list_f.clone();
|
||||||
|
let reflow_gen = Rc::clone(&reflow_gen);
|
||||||
|
glib::timeout_add_local_once(Duration::from_millis(180), move || {
|
||||||
|
if reflow_gen.get() == gen {
|
||||||
|
list_fade.remove_css_class("reflow");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard handling — capture phase on window
|
// Keyboard handling — capture phase on window
|
||||||
|
|
@ -471,36 +673,11 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Down => {
|
Key::Down => {
|
||||||
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(-1);
|
bread_utils::gtk_popup::select_next_visible(&list_k);
|
||||||
let mut i = cur + 1;
|
|
||||||
loop {
|
|
||||||
match list_k.row_at_index(i) {
|
|
||||||
Some(r) if r.is_visible() => {
|
|
||||||
list_k.select_row(Some(&r));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(_) => i += 1,
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
Key::Up => {
|
Key::Up => {
|
||||||
let cur = list_k.selected_row().map(|r| r.index()).unwrap_or(0);
|
bread_utils::gtk_popup::select_prev_visible(&list_k);
|
||||||
let mut i = cur - 1;
|
|
||||||
loop {
|
|
||||||
if i < 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match list_k.row_at_index(i) {
|
|
||||||
Some(r) if r.is_visible() => {
|
|
||||||
list_k.select_row(Some(&r));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(_) => i -= 1,
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
}
|
}
|
||||||
_ => glib::Propagation::Proceed,
|
_ => glib::Propagation::Proceed,
|
||||||
|
|
@ -521,38 +698,65 @@ fn run_ui(entries: Vec<DesktopEntry>, history: LaunchHistory) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click outside launcher panel → close
|
// Click outside launcher panel → close
|
||||||
let close_outside = Rc::clone(&close_all);
|
|
||||||
let vbox_ref = vbox.clone();
|
|
||||||
let win_ref = window.clone();
|
|
||||||
let outside_click = gtk4::GestureClick::new();
|
|
||||||
outside_click.connect_pressed(move |_, _, x, y| {
|
|
||||||
if let Some(b) = vbox_ref.compute_bounds(&win_ref) {
|
|
||||||
if x < b.x() as f64
|
|
||||||
|| x > (b.x() + b.width()) as f64
|
|
||||||
|| y < b.y() as f64
|
|
||||||
|| y > (b.y() + b.height()) as f64
|
|
||||||
{
|
{
|
||||||
close_outside();
|
let close_outside = Rc::clone(&close_all);
|
||||||
|
bread_utils::gtk_popup::close_on_outside_click(&window, &vbox, move || close_outside());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(req) = screenshot_req.clone() {
|
||||||
|
screenshot::dispatch(&window, req);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
window.add_controller(outside_click);
|
|
||||||
|
|
||||||
window.connect_destroy(|_| cleanup_pid());
|
|
||||||
window.present();
|
window.present();
|
||||||
search.grab_focus();
|
search.grab_focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if is_screenshot_run {
|
||||||
|
// GLib's own option parser otherwise rejects --screenshot/--output
|
||||||
|
// before clap ever sees them (`Cli::parse()` already ran in `main`,
|
||||||
|
// over the real argv).
|
||||||
|
app.run_with_args(&[] as &[&str]);
|
||||||
|
} else {
|
||||||
app.run();
|
app.run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Main -------------------------------------------------------------------
|
// ---- Main -------------------------------------------------------------------
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
if !toggle_or_continue() {
|
if std::env::args().nth(1).as_deref() == Some("listen") {
|
||||||
|
listen::run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
let cli = screenshot::Cli::parse();
|
||||||
|
let screenshot_req = cli.screenshot_request();
|
||||||
|
|
||||||
|
// `toggle_or_kill` kills whatever's holding the single-instance lock —
|
||||||
|
// a real, already-running breadbox included. A screenshot run must
|
||||||
|
// never touch it: it's a separate, disposable instance by design (same
|
||||||
|
// reasoning as breadbar's `allow_multiple_instances`), not a toggle of
|
||||||
|
// the operator's real launcher.
|
||||||
|
//
|
||||||
|
// Kept alive for the rest of `main` — dropping it releases the
|
||||||
|
// single-instance lock and removes the pid file, which happens
|
||||||
|
// naturally once `run_ui` returns (after the window closes).
|
||||||
|
let _singleton_guard = if screenshot_req.is_some() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match bread_utils::singleton::toggle_or_kill("breadbox") {
|
||||||
|
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
|
||||||
|
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"breadbox: single-instance lock unavailable ({e}); continuing without it"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let config = Config::load();
|
let config = Config::load();
|
||||||
let workspace = get_active_workspace().unwrap_or_default();
|
let workspace = get_active_workspace().unwrap_or_default();
|
||||||
let priority = config
|
let priority = config
|
||||||
|
|
@ -564,5 +768,5 @@ fn main() {
|
||||||
let manifest = load_manifest();
|
let manifest = load_manifest();
|
||||||
let entries = load_sorted_entries(&manifest, &priority, &history);
|
let entries = load_sorted_entries(&manifest, &priority, &history);
|
||||||
|
|
||||||
run_ui(entries, history);
|
run_ui(entries, history, screenshot_req);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
95
breadbox/src/screenshot.rs
Normal file
95
breadbox/src/screenshot.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
//! `--screenshot` CLI mode: render breadbox's launcher panel, capture it via
|
||||||
|
//! `bread-screenshots`, then exit — driven by `bread-ecosystem`'s
|
||||||
|
//! `bread-capture` orchestrator, or run standalone for one-off captures.
|
||||||
|
//!
|
||||||
|
//! breadbox has only one view worth capturing: the launcher panel itself
|
||||||
|
//! (search box + app list). It's a `halign: Center` panel over a full-screen
|
||||||
|
//! transparent overlay window, not its own layer surface, so — same
|
||||||
|
//! reasoning as breadbar's control-panel view — the simplest reliable
|
||||||
|
//! capture is the whole known-size canvas, not a hand-tracked panel
|
||||||
|
//! geometry.
|
||||||
|
|
||||||
|
use bread_utils::screenshot_cli::{validate_pair, DEFAULT_HEIGHT, DEFAULT_WIDTH, SETTLE_DELAY};
|
||||||
|
use clap::Parser;
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "breadbox")]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Render the named view, capture it, then exit instead of running
|
||||||
|
/// normally. Known views: "launcher".
|
||||||
|
#[arg(long)]
|
||||||
|
pub screenshot: Option<String>,
|
||||||
|
|
||||||
|
/// PNG path to write the capture to. Required together with --screenshot.
|
||||||
|
#[arg(long)]
|
||||||
|
pub output: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Capture canvas width — matches the isolated compositor's output width
|
||||||
|
/// (`bread-capture --isolate-width`).
|
||||||
|
#[arg(long, default_value_t = DEFAULT_WIDTH)]
|
||||||
|
pub width: u32,
|
||||||
|
|
||||||
|
/// Capture canvas height — see `width`.
|
||||||
|
#[arg(long, default_value_t = DEFAULT_HEIGHT)]
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ScreenshotRequest {
|
||||||
|
pub view: String,
|
||||||
|
pub output: PathBuf,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cli {
|
||||||
|
/// `None` for a normal run. Exits the process with an error if the
|
||||||
|
/// `--screenshot` / `--output` pair is incomplete, before any GTK setup
|
||||||
|
/// happens.
|
||||||
|
pub fn screenshot_request(&self) -> Option<ScreenshotRequest> {
|
||||||
|
if let Err(e) = validate_pair(self.screenshot.as_deref(), self.output.as_deref()) {
|
||||||
|
eprintln!("breadbox: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Some(ScreenshotRequest {
|
||||||
|
view: self.screenshot.clone()?,
|
||||||
|
output: self.output.clone()?,
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire up the given view's screenshot sequence against an already-built,
|
||||||
|
/// not-yet-presented window. Every path here ends by exiting the process —
|
||||||
|
/// it never returns control to the normal launcher UI.
|
||||||
|
pub fn dispatch(window: >k4::ApplicationWindow, req: ScreenshotRequest) {
|
||||||
|
match req.view.as_str() {
|
||||||
|
"launcher" => {
|
||||||
|
let output = req.output;
|
||||||
|
let (width, height) = (req.width as i32, req.height as i32);
|
||||||
|
window.connect_map(move |_| {
|
||||||
|
let output = output.clone();
|
||||||
|
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
|
||||||
|
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("breadbox: unknown screenshot view '{other}' (known: launcher)");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(result: anyhow::Result<()>) {
|
||||||
|
match result {
|
||||||
|
Ok(()) => std::process::exit(0),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("breadbox: screenshot capture failed: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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" breadbox "$ROOT" "$@"
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
|
||||||
|
|
||||||
pkgname=breadbox
|
|
||||||
pkgver=0.1.0
|
|
||||||
pkgrel=1
|
|
||||||
pkgdesc="App launcher for Hyprland / Wayland"
|
|
||||||
arch=('x86_64')
|
|
||||||
url="https://github.com/Breadway/breadbox"
|
|
||||||
license=('MIT')
|
|
||||||
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
|
|
||||||
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
|
|
||||||
# causing undefined-symbol errors. Disable LTO.
|
|
||||||
options=(!lto !debug)
|
|
||||||
depends=('gtk4' 'gtk4-layer-shell' 'librsvg')
|
|
||||||
optdepends=(
|
|
||||||
'hyprland: window and workspace integration'
|
|
||||||
)
|
|
||||||
makedepends=('rust' 'cargo')
|
|
||||||
source=("${pkgname}-${pkgver}.tar.gz")
|
|
||||||
sha256sums=('SKIP')
|
|
||||||
|
|
||||||
build() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
cargo build --release --locked
|
|
||||||
}
|
|
||||||
|
|
||||||
check() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
cargo test --release --locked --workspace
|
|
||||||
}
|
|
||||||
|
|
||||||
package() {
|
|
||||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
|
||||||
install -Dm755 target/release/breadbox "${pkgdir}/usr/bin/breadbox"
|
|
||||||
install -Dm755 target/release/breadbox-sync "${pkgdir}/usr/bin/breadbox-sync"
|
|
||||||
install -Dm644 packaging/breadbox-sync.service \
|
|
||||||
"${pkgdir}/usr/lib/systemd/user/breadbox-sync.service"
|
|
||||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue