Compare commits
No commits in common. "main" and "v0.7.1" have entirely different histories.
190 changed files with 7762 additions and 23001 deletions
|
|
@ -1,91 +0,0 @@
|
|||
name: dev release
|
||||
|
||||
# Publishes a dev-track build on every push to `main` (the trunk
|
||||
# branch — there is no separate `dev` branch). See bread-ecosystem's
|
||||
# docs/release-channels.md for the release-track policy this is part of.
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch main --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
# bos-settings is a Tauri app: Cargo.toml lives at src/src/Cargo.toml
|
||||
# (checkout root is named "src" by convention above; the repo's own
|
||||
# Rust backend is also in a dir called "src", not the usual
|
||||
# src-tauri — hence src/src below), and the Rust build expects the
|
||||
# frontend already built at frontend/build (tauri.conf.json's
|
||||
# frontendDist) — that only happens automatically under `cargo
|
||||
# tauri build`, so it's run explicitly here since this workflow
|
||||
# just uses plain `cargo build`.
|
||||
- name: build frontend
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: build
|
||||
run: cd src/src && 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, which would make a dev build sort as OLDER than
|
||||
# what's already installed and bakery would correctly refuse it.
|
||||
LATEST_TAG="$(git ls-remote --tags --refs \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
|
||||
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
|
||||
if [ -n "${LATEST_TAG}" ]; then
|
||||
CUR="${LATEST_TAG}"
|
||||
else
|
||||
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||
fi
|
||||
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||
SHA="$(git rev-parse --short HEAD)"
|
||||
TS="$(date -u +%Y%m%d%H%M%S)"
|
||||
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR="/srv/breadway-dl/dev/bos-settings/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/src/target/release/bos-settings" "${PKG_DIR}/bos-settings-x86_64"
|
||||
strip "${PKG_DIR}/bos-settings-x86_64"
|
||||
sha256sum "${PKG_DIR}/bos-settings-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/bos-settings-x86_64.sha256"
|
||||
cp src/packaging/bos-settings.desktop "${PKG_DIR}/"
|
||||
cp src/LICENSE "${PKG_DIR}/"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/bos-settings/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}"
|
||||
21
.forgejo/workflows/mirror.yml
Normal file
21
.forgejo/workflows/mirror.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['**']
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: Mirror to GitHub
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
|
||||
cd repo.git
|
||||
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
|
||||
# --prune deletes GitHub refs that no longer exist on Forgejo.
|
||||
git push --prune \
|
||||
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/bos-settings.git" \
|
||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
40
.forgejo/workflows/package.yml
Normal file
40
.forgejo/workflows/package.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: Build and publish package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
package:
|
||||
runs-on: [self-hosted, hestia]
|
||||
container:
|
||||
image: archlinux:latest
|
||||
steps:
|
||||
# Note: no actions/checkout — the archlinux image has no Node, which JS
|
||||
# actions require. Everything runs as shell steps and clones manually.
|
||||
- name: Build and publish
|
||||
env:
|
||||
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
pacman -Syu --noconfirm base-devel git rust cargo gtk4 glib2
|
||||
useradd -m builder
|
||||
git config --global --add safe.directory '*'
|
||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
|
||||
cd /home/builder/src
|
||||
git archive --format=tar.gz --prefix="bos-settings-${VERSION}/" HEAD \
|
||||
> packaging/bos-settings-${VERSION}.tar.gz
|
||||
SHA=$(sha256sum packaging/bos-settings-${VERSION}.tar.gz | awk '{print $1}')
|
||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/PKGBUILD
|
||||
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/PKGBUILD
|
||||
chown -R builder:builder /home/builder/src
|
||||
# --nocheck: packaging builds the artifact; tests belong in a CI job.
|
||||
su builder -c "cd /home/builder/src/packaging && makepkg -f --noconfirm --nocheck"
|
||||
PKG=$(find /home/builder/src/packaging -name '*.pkg.tar.zst' | head -1)
|
||||
curl -fsS -X PUT \
|
||||
-H "Authorization: token ${PUBLISH_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${PKG}" \
|
||||
"https://git.breadway.dev/api/packages/Breadway/arch/os"
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
name: beta (rc) release
|
||||
|
||||
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
|
||||
# pushed to `main` — there is no separate `beta` branch; "freezing" is
|
||||
# just pausing pushes to main while an RC gets tested. See
|
||||
# bread-ecosystem's docs/release-channels.md for the release-track policy.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf src && mkdir src
|
||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/beta/bos-settings/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/src/target/release/bos-settings" "${PKG_DIR}/bos-settings-x86_64"
|
||||
strip "${PKG_DIR}/bos-settings-x86_64"
|
||||
sha256sum "${PKG_DIR}/bos-settings-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/bos-settings-x86_64.sha256"
|
||||
cp src/packaging/bos-settings.desktop "${PKG_DIR}/"
|
||||
cp src/LICENSE "${PKG_DIR}/"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/bos-settings/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}"
|
||||
|
|
@ -6,7 +6,6 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ !contains(github.ref_name, '-rc.') }}
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: checkout
|
||||
|
|
@ -16,23 +15,8 @@ jobs:
|
|||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||
|
||||
# bos-settings is a Tauri app: Cargo.toml lives at src/src/Cargo.toml
|
||||
# (checkout root is named "src" by convention above; the repo's own
|
||||
# Rust backend is also in a dir called "src", not the usual
|
||||
# src-tauri — hence src/src below), and the Rust build expects the
|
||||
# frontend already built at frontend/build (tauri.conf.json's
|
||||
# frontendDist) — that only happens automatically under `cargo
|
||||
# tauri build`, so it's run explicitly here since this workflow
|
||||
# just uses plain `cargo build`.
|
||||
- name: build frontend
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd src/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: build
|
||||
run: cd src/src && cargo build --release --locked
|
||||
run: cd src && cargo build --release --locked
|
||||
|
||||
- name: prepare artifacts
|
||||
run: |
|
||||
|
|
@ -40,29 +24,19 @@ jobs:
|
|||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PKG_DIR="/srv/breadway-dl/bos-settings/${VERSION}"
|
||||
mkdir -p "${PKG_DIR}"
|
||||
cp "src/src/target/release/bos-settings" "${PKG_DIR}/bos-settings-x86_64"
|
||||
cp "src/target/release/bos-settings" "${PKG_DIR}/bos-settings-x86_64"
|
||||
strip "${PKG_DIR}/bos-settings-x86_64"
|
||||
sha256sum "${PKG_DIR}/bos-settings-x86_64" | awk '{print $1}' \
|
||||
> "${PKG_DIR}/bos-settings-x86_64.sha256"
|
||||
cp src/packaging/bos-settings.desktop "${PKG_DIR}/"
|
||||
cp src/LICENSE "${PKG_DIR}/"
|
||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||
ln -sfn "${VERSION}" "/srv/breadway-dl/bos-settings/latest"
|
||||
|
||||
- name: regenerate index.json
|
||||
env:
|
||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||
bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||
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:
|
||||
|
|
|
|||
33
AGENTS.md
33
AGENTS.md
|
|
@ -1,33 +0,0 @@
|
|||
# AGENTS.md — Repo hygiene
|
||||
|
||||
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
|
||||
|
||||
This repo is a **Tauri 2 + Svelte 5** settings app, bakery-distributed. It follows the branch/release workflow in `CONTRIBUTING.md` — read and follow it for any git, branch, or release work here (the single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, etc). Don't improvise a different workflow. The short version: there is one long-lived branch, `main` — no `dev` or `beta` branch exists. `main` auto-publishes a bakery **dev-track** build on every push. "Beta" and "stable" are both just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track build, push a plain `vX.Y.Z` tag to cut the signed stable release. "Freezing" for stabilization means pausing pushes to `main`, not moving a branch.
|
||||
|
||||
This is not a GTK4 app. There is no `package.yml` pacman workflow here; bakery is the distribution channel.
|
||||
|
||||
## Layout
|
||||
|
||||
- `frontend/` — Svelte 5 + SvelteKit (static adapter) + TypeScript
|
||||
- `src/` — Tauri 2 Rust crate (`bos-settings`). Commands live in `src/src/commands/`.
|
||||
- Config edits are non-destructive (`toml_edit` / `bread_utils::tomlcfg`) except for JSON files that have no comments to preserve.
|
||||
|
||||
## Remotes
|
||||
|
||||
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
|
||||
- `github` — GitHub mirror. Push `origin` only; the github remote auto-mirrors.
|
||||
|
||||
## CI
|
||||
|
||||
- `dev-release.yml` — push to `main`.
|
||||
- `rc-release.yml` — `vX.Y.Z-rc.N` tags.
|
||||
- `release.yml` — other `v*` tags (signed stable).
|
||||
|
||||
No build/lint/test CI runs on ordinary commits or PRs to `main` beyond the dev-track workflow above.
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't commit `frontend/node_modules`.
|
||||
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||
- Don't write Wi-Fi passwords back into `breadcrumbs.toml`. Networks live in `~/.config/breadcrumbs/networks.toml` (0600).
|
||||
- Don't expose a generic argv runner to the webview. Streaming updates are typed commands (`bakery_update`, `pacman_system_update`, `fwupd_*`).
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
# Contributing
|
||||
|
||||
`bos-settings` — System settings app for Bread OS.
|
||||
|
||||
Part of the bread ecosystem; this repo follows the same branch/release
|
||||
workflow as every other ecosystem product.
|
||||
|
||||
## Branches
|
||||
|
||||
There is one long-lived branch: **`main`**. All day-to-day work lands here.
|
||||
Every push to `main` automatically builds and publishes a **dev-track**
|
||||
build (see Tracks below) — a real install you can test before cutting
|
||||
anything more formal.
|
||||
|
||||
New work — features and bug fixes alike — goes on a short-lived branch:
|
||||
|
||||
```
|
||||
feature/<short-name>
|
||||
fix/<issue-number-or-short-name>
|
||||
```
|
||||
|
||||
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
|
||||
branches get deleted on merge — they never accumulate the kind of drift a
|
||||
second long-lived branch does.
|
||||
|
||||
## The release cycle
|
||||
|
||||
There's no separate `beta` or release branch — "stable" and "beta" are both
|
||||
just **tags** on `main`, not branches that need to be kept in sync:
|
||||
|
||||
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
|
||||
auto-publishes a dev build — install it with `bakery track set dev` and
|
||||
`bakery update --all`, then fix anything broken with another push.
|
||||
2. When you want to stabilize before a real release, tag a release
|
||||
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
|
||||
both remotes). That tag alone triggers a beta-track build —
|
||||
"freezing" is just pausing pushes to `main` while you test it, not a
|
||||
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
|
||||
3. Once an RC has gone without issues, tag the real release:
|
||||
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
|
||||
signed stable release build.
|
||||
|
||||
## Tracks, from a user's perspective
|
||||
|
||||
```
|
||||
bakery track show # what you're currently on (defaults to stable)
|
||||
bakery track set dev # or beta, or stable
|
||||
bakery update --all # pull the latest build on your current track
|
||||
```
|
||||
|
||||
| Track | What it is | Published from |
|
||||
|--------|-----------|-----------------|
|
||||
| `stable` | The last tagged release | a `vX.Y.Z` tag |
|
||||
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
|
||||
| `dev` | Bleeding edge | `main`, on every push |
|
||||
|
||||
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
|
||||
latest published stable tag, so they always sort as newer than what you
|
||||
have installed — no manual version bumping needed. Beta versions are just
|
||||
the RC tag itself (already valid semver, already sorts below the real
|
||||
release it's a candidate for).
|
||||
|
||||
## Local development
|
||||
|
||||
`bos-settings` is a Tauri app: the Svelte frontend lives in `frontend/`,
|
||||
the Rust backend in `src/src/` (the checkout root is conventionally named
|
||||
`src`, and the backend's own crate is also called `src`, not `src-tauri`).
|
||||
The frontend must be built before the Rust build — `tauri.conf.json`'s
|
||||
`beforeBuildCommand` hook only fires under `cargo tauri build`, not plain
|
||||
`cargo build`, so CI runs it explicitly:
|
||||
|
||||
```sh
|
||||
cd frontend && npm ci && npm run build
|
||||
cd ../src/src && cargo build --release --locked
|
||||
cargo test --release --locked
|
||||
```
|
||||
|
||||
## 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.
|
||||
990
Cargo.lock
generated
Normal file
990
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,990 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bos-settings"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"bread-theme",
|
||||
"bread-utils",
|
||||
"glib",
|
||||
"gtk4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml 0.8.23",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-theme"
|
||||
version = "0.2.3"
|
||||
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"gtk4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bread-utils"
|
||||
version = "0.3.0"
|
||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.3.0#8e82d2d833e992ce939a5b836f910ee109f2e939"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-sys-rs"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.20.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-macro",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk-pixbuf"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646"
|
||||
dependencies = [
|
||||
"gdk-pixbuf-sys",
|
||||
"gio",
|
||||
"glib",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk-pixbuf-sys"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb"
|
||||
dependencies = [
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk4"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39"
|
||||
dependencies = [
|
||||
"cairo-rs",
|
||||
"gdk-pixbuf",
|
||||
"gdk4-sys",
|
||||
"gio",
|
||||
"glib",
|
||||
"libc",
|
||||
"pango",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk4-sys"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13"
|
||||
dependencies = [
|
||||
"cairo-sys-rs",
|
||||
"gdk-pixbuf-sys",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"pkg-config",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.22.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"gio-sys",
|
||||
"glib",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio-sys"
|
||||
version = "0.22.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.22.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"gio-sys",
|
||||
"glib-macros",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-macros"
|
||||
version = "0.22.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "506d23499707c7142898429757e8d9a3871d965239a2cb66dfa05052be6d6f19"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-sys"
|
||||
version = "0.22.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.22.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22a861859b887a79cf461359c192c97a57d8fb0229dd291232e57aa11f6fa72c"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "graphene-rs"
|
||||
version = "0.22.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff"
|
||||
dependencies = [
|
||||
"glib",
|
||||
"graphene-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "graphene-sys"
|
||||
version = "0.22.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gsk4"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff"
|
||||
dependencies = [
|
||||
"cairo-rs",
|
||||
"gdk4",
|
||||
"glib",
|
||||
"graphene-rs",
|
||||
"gsk4-sys",
|
||||
"libc",
|
||||
"pango",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gsk4-sys"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088"
|
||||
dependencies = [
|
||||
"cairo-sys-rs",
|
||||
"gdk4-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"graphene-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk4"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9"
|
||||
dependencies = [
|
||||
"cairo-rs",
|
||||
"field-offset",
|
||||
"futures-channel",
|
||||
"gdk-pixbuf",
|
||||
"gdk4",
|
||||
"gio",
|
||||
"glib",
|
||||
"graphene-rs",
|
||||
"gsk4",
|
||||
"gtk4-macros",
|
||||
"gtk4-sys",
|
||||
"libc",
|
||||
"pango",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk4-macros"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk4-sys"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01"
|
||||
dependencies = [
|
||||
"cairo-sys-rs",
|
||||
"gdk-pixbuf-sys",
|
||||
"gdk4-sys",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"graphene-sys",
|
||||
"gsk4-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.22.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c"
|
||||
dependencies = [
|
||||
"gio",
|
||||
"glib",
|
||||
"pango-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango-sys"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.13+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "7.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
|
||||
dependencies = [
|
||||
"cfg-expr",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml 1.1.3+spec-1.1.0",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.13+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "bos-settings"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
glib = "0.22"
|
||||
# Shared ecosystem theming — bos-settings loads the same generated stylesheet as
|
||||
# breadbar/breadbox/breadpad so the whole desktop looks consistent.
|
||||
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
# toml_edit drives non-destructive config editing: it preserves comments and
|
||||
# any keys the UI doesn't model, so saving a single field never rewrites or
|
||||
# drops the rest of the user's config file.
|
||||
toml_edit = "0.22"
|
||||
async-channel = "2"
|
||||
# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern
|
||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0", features = ["toml"] }
|
||||
17
README.md
17
README.md
|
|
@ -1,24 +1,15 @@
|
|||
# bos-settings
|
||||
|
||||
System settings app for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — Tauri 2 + Svelte 5. Configures every bread\* app's config plus core system settings (network, sound, power, users, firewall, snapshots, packages, AUR, firmware, Hyprland display/appearance/autostart) non-destructively.
|
||||
System settings app for [BOS (Bread Operating System)](https://git.breadway.dev/Breadway/bos) — GTK4, configures every bread\* app's config plus core system settings (network, sound, power, users, firewall, snapshots, packages, AUR, firmware, Hyprland display/appearance/autostart) non-destructively.
|
||||
|
||||
Distributed via `bakery`. There is one long-lived branch, `main`; see `CONTRIBUTING.md` for the single-trunk / RC-tag release model shared across the bread ecosystem.
|
||||
Split out of the `bos` repo into its own repo so a bos-settings release doesn't require a BOS ISO release, and vice versa. Still the only pacman-packaged (not bakery-managed) bread app — see `packaging/README.md`.
|
||||
|
||||
## Building
|
||||
|
||||
The Svelte frontend lives in `frontend/`, the Rust backend in `src/` (this repo's crate is not named `src-tauri`). `cargo tauri build` runs the frontend build hook; a plain `cargo build` does not.
|
||||
|
||||
```bash
|
||||
cd frontend && npm ci && npm run build
|
||||
cd ../src && cargo build --release
|
||||
```
|
||||
|
||||
Dev (Vite + `cargo tauri dev`):
|
||||
|
||||
```bash
|
||||
cd src && cargo tauri dev
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Packaging / releasing
|
||||
|
||||
Bump `src/Cargo.toml` (and `frontend/package.json`) version, then follow `CONTRIBUTING.md`: work lands on `main` via `feature/` / `fix/` branches (every push to `main` publishes a bakery **dev** build). Tag `vX.Y.Z-rc.N` for beta, `vX.Y.Z` for the signed stable release. Do not push to a `dev` branch — there isn't one.
|
||||
See `packaging/README.md`. In short: bump `Cargo.toml`'s version, tag `vX.Y.Z`, push the tag to both remotes — `.forgejo/workflows/package.yml` builds and publishes to the `[breadway]` pacman repo automatically.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
name = "bos-settings"
|
||||
description = "System settings app for Bread OS"
|
||||
binaries = ["bos-settings"]
|
||||
system_deps = ["webkit2gtk-4.1", "gtk3", "libsoup3", "librsvg", "hicolor-icon-theme"]
|
||||
system_deps = ["gtk4", "glib2"]
|
||||
optional_system_deps = ["snapper"]
|
||||
bread_deps = []
|
||||
license_file = "LICENSE"
|
||||
desktop_file = "bos-settings.desktop"
|
||||
|
||||
[config]
|
||||
dir = "~/.config"
|
||||
|
|
|
|||
10
frontend/.gitignore
vendored
10
frontend/.gitignore
vendored
|
|
@ -1,10 +0,0 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# Tauri + SvelteKit + TypeScript
|
||||
|
||||
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
||||
1942
frontend/package-lock.json
generated
1942
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"name": "bos-settings-frontend",
|
||||
"version": "0.8.0",
|
||||
"description": "Frontend for BOS Settings",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.25.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>BOS Settings</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
<script lang="ts">
|
||||
let {
|
||||
label,
|
||||
value = $bindable(),
|
||||
options,
|
||||
emptyOptionsHint = "Nothing to pick from yet.",
|
||||
}: { label: string; value: string[]; options: string[]; emptyOptionsHint?: string } = $props();
|
||||
|
||||
let adding = $state(false);
|
||||
|
||||
let available = $derived(options.filter((o) => !value.includes(o)));
|
||||
|
||||
function add(name: string) {
|
||||
if (!name) return;
|
||||
value = [...value, name];
|
||||
adding = false;
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
value = value.filter((v) => v !== name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<div class="header">
|
||||
<span class="label">{label}</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
{#each value as name (name)}
|
||||
<span class="chip">
|
||||
{name}
|
||||
<button type="button" class="remove" onclick={() => remove(name)} aria-label={`Remove ${name}`}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
{#if adding}
|
||||
{#if available.length > 0}
|
||||
<!-- svelte-ignore a11y_autofocus -- responds to the user's own
|
||||
"+" click just now, not a page-load focus grab -->
|
||||
<select autofocus onchange={(e) => add(e.currentTarget.value)}>
|
||||
<option value="" selected disabled>Choose…</option>
|
||||
{#each available as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<span class="empty-hint">{emptyOptionsHint}</span>
|
||||
{/if}
|
||||
{:else if available.length > 0}
|
||||
<button type="button" class="chip add" onclick={() => (adding = true)}>+</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: var(--overlay);
|
||||
color: var(--on-overlay);
|
||||
border-radius: 999px;
|
||||
padding: 4px 6px 4px 12px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.chip.add {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
font-weight: bold;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.chip.add:hover {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
select {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: 4px var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
opacity: 0.5;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
let { label, value = $bindable(), placeholder = "" }: { label: string; value: string[]; placeholder?: string } =
|
||||
$props();
|
||||
|
||||
let text = $state(value.join(", "));
|
||||
|
||||
function onInput() {
|
||||
value = text
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<input type="text" bind:value={text} oninput={onInput} {placeholder} />
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
input {
|
||||
width: 28ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
|
||||
let { icon: Icon, title, hint }: { icon: Component; title: string; hint: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="empty">
|
||||
<Icon size={40} />
|
||||
<span class="title">{title}</span>
|
||||
<span class="hint">{hint}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--space-xl, 20px) 0;
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
max-width: 40ch;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import Row from "./Row.svelte";
|
||||
import FolderOpen from "@lucide/svelte/icons/folder-open";
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable(),
|
||||
placeholder = "",
|
||||
mode = "file",
|
||||
extensions,
|
||||
}: { label: string; value: string; placeholder?: string; mode?: "file" | "folder"; extensions?: string[] } =
|
||||
$props();
|
||||
|
||||
async function browse() {
|
||||
const picked = await open({
|
||||
directory: mode === "folder",
|
||||
filters: extensions ? [{ name: "Files", extensions }] : undefined,
|
||||
});
|
||||
if (typeof picked === "string") value = picked;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<div class="wrap">
|
||||
<input type="text" bind:value {placeholder} />
|
||||
<button type="button" onclick={browse} aria-label="Browse…">
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
gap: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 22ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: color-mix(in srgb, var(--on-surface) 10%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
title,
|
||||
hint,
|
||||
wide = false,
|
||||
children,
|
||||
}: { title: string; hint?: string; wide?: boolean; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="group" class:wide>
|
||||
<h2 class="title">{title}</h2>
|
||||
{#if hint}<p class="hint">{hint}</p>{/if}
|
||||
<div class="rows">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 1.05em;
|
||||
margin: 0 0 var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.hint {
|
||||
opacity: 0.75;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
line-height: 1.4;
|
||||
margin: 0 0 var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Flex children default to refusing to shrink below their content's
|
||||
natural width (min-width: auto) — without this, a button/input
|
||||
with enough text overflows past the grid column's actual pixel
|
||||
width instead of wrapping, visually spilling into the next
|
||||
column. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rows > :global(*) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<script lang="ts">
|
||||
let { text }: { text: string } = $props();
|
||||
</script>
|
||||
|
||||
<p class="hint">{text}</p>
|
||||
|
||||
<style>
|
||||
.hint {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
margin: 0 0 var(--space-xs, 4px);
|
||||
/* No-op unless this Hint is a direct child of ViewScaffold's CSS
|
||||
grid (i.e. used standalone between Groups, not nested inside
|
||||
one) — in that case it spans full width instead of getting
|
||||
squeezed into whichever grid column it happened to land in. */
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
// Hyprland's own color format: "rgba(RRGGBBAA)" — a plain hex color input
|
||||
// has no alpha channel, so this pairs one with an alpha slider and
|
||||
// recombines them into that exact string on every change.
|
||||
let { label, value = $bindable() }: { label: string; value: string } = $props();
|
||||
|
||||
function parse(v: string): { hex: string; alpha: number } {
|
||||
const inner = v.match(/^rgba\(([0-9a-fA-F]{8})\)$/)?.[1];
|
||||
if (!inner) return { hex: "#808080", alpha: 255 };
|
||||
return { hex: `#${inner.slice(0, 6)}`, alpha: parseInt(inner.slice(6, 8), 16) };
|
||||
}
|
||||
|
||||
let parsed = $derived(parse(value));
|
||||
|
||||
function update(hex: string, alpha: number) {
|
||||
const a = alpha.toString(16).padStart(2, "0");
|
||||
value = `rgba(${hex.slice(1)}${a})`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<div class="wrap">
|
||||
<input type="color" value={parsed.hex} oninput={(e) => update(e.currentTarget.value, parsed.alpha)} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="255"
|
||||
value={parsed.alpha}
|
||||
oninput={(e) => update(parsed.hex, Number(e.currentTarget.value))}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
width: 36px;
|
||||
height: 26px;
|
||||
border: 1px solid color-mix(in srgb, var(--on-surface) 20%, transparent);
|
||||
border-radius: var(--radius-tertiary, 4px);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
let { label, value }: { label: string; value: string } = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<span class="value">{value}</span>
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
.value {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<script lang="ts">
|
||||
let { lines }: { lines: string[] } = $props();
|
||||
</script>
|
||||
|
||||
{#if lines.length > 0}
|
||||
<pre class="log">{lines.join("\n")}</pre>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.log {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable(),
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
}: { label: string; value: number; min: number; max: number; step?: number } = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<input type="number" bind:value {min} {max} {step} />
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
input {
|
||||
width: 10ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
import Eye from "@lucide/svelte/icons/eye";
|
||||
import EyeOff from "@lucide/svelte/icons/eye-off";
|
||||
|
||||
let { label, value = $bindable() }: { label: string; value: string } = $props();
|
||||
|
||||
let reveal = $state(false);
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<div class="wrap">
|
||||
<input type={reveal ? "text" : "password"} bind:value />
|
||||
<button type="button" class="peek" onclick={() => (reveal = !reveal)}>
|
||||
{#if reveal}<EyeOff size={14} />{:else}<Eye size={14} />{/if}
|
||||
</button>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 28ch;
|
||||
background-color: var(--bg);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding-inline-end: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.wrap:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.peek {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--on-surface);
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.peek:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
|
||||
let { label, value = $bindable(), hint }: { label: string; value: string[]; hint?: string } = $props();
|
||||
|
||||
async function addFolder() {
|
||||
const picked = await open({ directory: true });
|
||||
if (typeof picked === "string" && !value.includes(picked)) {
|
||||
value = [...value, picked];
|
||||
}
|
||||
}
|
||||
|
||||
function remove(path: string) {
|
||||
value = value.filter((p) => p !== path);
|
||||
}
|
||||
|
||||
function shorten(path: string): string {
|
||||
const home = "/home/";
|
||||
const idx = path.indexOf(home);
|
||||
if (idx === -1) return path;
|
||||
const afterHome = path.slice(idx + home.length);
|
||||
const slash = afterHome.indexOf("/");
|
||||
return slash === -1 ? path : `~/${afterHome.slice(slash + 1)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<div class="header">
|
||||
<span class="label">{label}</span>
|
||||
<button type="button" class="add" onclick={addFolder}>
|
||||
<Plus size={13} />
|
||||
Add folder…
|
||||
</button>
|
||||
</div>
|
||||
{#if hint}<p class="hint">{hint}</p>{/if}
|
||||
{#if value.length === 0}
|
||||
<p class="empty">None added.</p>
|
||||
{:else}
|
||||
<div class="chips">
|
||||
{#each value as path (path)}
|
||||
<span class="chip" title={path}>
|
||||
{shorten(path)}
|
||||
<button type="button" class="remove" onclick={() => remove(path)} aria-label={`Remove ${path}`}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.5;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
margin: var(--space-sm, 8px) 0 0;
|
||||
}
|
||||
|
||||
.add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: none;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.add:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: var(--overlay);
|
||||
color: var(--on-overlay);
|
||||
border-radius: 999px;
|
||||
padding: 4px 6px 4px 12px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
opacity: 1;
|
||||
background-color: color-mix(in srgb, var(--on-overlay) 15%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<script lang="ts">
|
||||
let { page }: { page: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="placeholder">
|
||||
<p>Unknown page "{page}".</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { label, children }: { label: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="field-row">
|
||||
<span class="label">{label}</span>
|
||||
<div class="control">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* GNOME Settings' "boxed list" look: consecutive rows merge into one
|
||||
card with a thin divider between them, rounding only the outer
|
||||
corners of the run — not each row individually. `.field-row` is a
|
||||
distinctive name (not just `.row`) so the :global() adjacency rules
|
||||
below can't collide with an unrelated `.row` class elsewhere (e.g.
|
||||
Sidebar's own nav rows) — every Row instance (and everything that
|
||||
wraps it — InfoRow, the field components) renders this same element,
|
||||
so a run of them is just plain DOM adjacency; no parent wrapper
|
||||
component needed. */
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg, 16px);
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
:global(.field-row + .field-row) {
|
||||
margin-top: calc(var(--space-sm, 8px) * -1);
|
||||
border-top: 1px solid var(--bg);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
:global(.field-row:has(+ .field-row)) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
<script lang="ts">
|
||||
let { onSave }: { onSave: () => Promise<void> } = $props();
|
||||
|
||||
let status = $state("");
|
||||
let saving = $state(false);
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
await onSave();
|
||||
status = "Saved";
|
||||
setTimeout(() => (status = ""), 3000);
|
||||
} catch (e) {
|
||||
status = `Error: ${e}`;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="row">
|
||||
<button disabled={saving} onclick={save}>Save</button>
|
||||
<span class="status">{status}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
margin-top: var(--space-lg, 16px);
|
||||
/* Always spans the full grid width, regardless of how many
|
||||
Group columns the rest of the page laid out above it. */
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
let { label, value = $bindable(), options }: { label: string; value: string; options: string[] } = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<select bind:value>
|
||||
{#each options as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
select {
|
||||
color-scheme: dark;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import Row from "./Row.svelte";
|
||||
import Group from "./Group.svelte";
|
||||
import Hint from "./Hint.svelte";
|
||||
|
||||
let { unit, critical = false, hasConfig = false }: { unit: string; critical?: boolean; hasConfig?: boolean } =
|
||||
$props();
|
||||
|
||||
let active = $state(false);
|
||||
let enabled = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
const status = await invoke<{ active: boolean; enabled: boolean }>("get_service_status", { unit });
|
||||
active = status.active;
|
||||
enabled = status.enabled;
|
||||
}
|
||||
|
||||
async function run(action: "Start" | "Stop" | "Restart") {
|
||||
busy = true;
|
||||
try {
|
||||
await invoke("service_action", { unit, action });
|
||||
} finally {
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (active && critical) {
|
||||
if (!confirm(`Stop ${unit}? This is a core part of the desktop's event handling — stopping it may affect other bread apps until it's restarted.`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
run(active ? "Stop" : "Start");
|
||||
}
|
||||
|
||||
function openLogs() {
|
||||
invoke("open_logs", { unit });
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
</script>
|
||||
|
||||
<Group title="Service">
|
||||
<Row label={unit}>
|
||||
<span class="dim">{active ? "Running" : "Stopped"}</span>
|
||||
</Row>
|
||||
<Row label="Starts at login">
|
||||
<span class="dim">{enabled ? "Yes" : "No"}</span>
|
||||
</Row>
|
||||
<div class="buttons">
|
||||
<button disabled={busy} onclick={toggle}>{active ? "Stop" : "Start"}</button>
|
||||
<button disabled={busy} onclick={() => run("Restart")}>Restart</button>
|
||||
<button onclick={openLogs}>View logs</button>
|
||||
</div>
|
||||
{#if hasConfig}
|
||||
<Hint text="Save (below) only writes the config file — click Restart above for the running service to pick up the change." />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<style>
|
||||
.dim {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: color-mix(in srgb, var(--on-surface) 14%, transparent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { SIDEBAR_SECTIONS } from "$lib/sidebar";
|
||||
|
||||
let { activePage = $bindable() }: { activePage: string } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="sidebar">
|
||||
{#each SIDEBAR_SECTIONS as section (section.title ?? "untitled")}
|
||||
{#if section.title}
|
||||
<div class="section-header">{section.title}</div>
|
||||
{/if}
|
||||
{#each section.items as item (item.id)}
|
||||
<button
|
||||
class="row"
|
||||
class:selected={activePage === item.id}
|
||||
onclick={() => (activePage = item.id)}
|
||||
>
|
||||
<item.icon size={16} />
|
||||
<span class="label">{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--space-sm, 8px);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: var(--space-lg, 16px) var(--space-sm, 8px) var(--space-xs, 4px);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.section-header:first-child {
|
||||
padding-top: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 9px var(--space-md, 12px);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background-color: color-mix(in srgb, var(--on-surface) 8%, transparent);
|
||||
}
|
||||
|
||||
.row.selected {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
<script lang="ts">
|
||||
let { value = $bindable(), ariaLabel }: { value: boolean; ariaLabel?: string } = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="switch"
|
||||
class:on={value}
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
aria-label={ariaLabel}
|
||||
onclick={() => (value = !value)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
import Switch from "./Switch.svelte";
|
||||
|
||||
let { label, value = $bindable() }: { label: string; value: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<Switch bind:value ariaLabel={label} />
|
||||
</Row>
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
<script lang="ts">
|
||||
let { label, value = $bindable() }: { label: string; value: string[] } = $props();
|
||||
|
||||
let text = $state("");
|
||||
|
||||
function commit() {
|
||||
const v = text.trim();
|
||||
if (v && !value.includes(v)) {
|
||||
value = [...value, v];
|
||||
}
|
||||
text = "";
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === "Backspace" && text === "" && value.length > 0) {
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
value = value.filter((v) => v !== name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<div class="header">
|
||||
<span class="label">{label}</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
{#each value as name (name)}
|
||||
<span class="chip">
|
||||
{name}
|
||||
<button type="button" class="remove" onclick={() => remove(name)} aria-label={`Remove ${name}`}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
<input type="text" bind:value={text} onkeydown={onKeydown} placeholder="Type and press Enter…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: var(--overlay);
|
||||
color: var(--on-overlay);
|
||||
border-radius: 999px;
|
||||
padding: 4px 6px 4px 12px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 12ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: 4px var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable(),
|
||||
placeholder = "",
|
||||
}: { label: string; value: string; placeholder?: string } = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<input type="text" bind:value {placeholder} />
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
input {
|
||||
width: 28ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { title, children }: { title: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="view">
|
||||
<h1 class="title">{title}</h1>
|
||||
<div class="content">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.view {
|
||||
padding: var(--space-xl, 20px) var(--space-xl, 20px) 48px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.6em;
|
||||
font-weight: bold;
|
||||
margin: 0 0 var(--space-xl, 20px);
|
||||
}
|
||||
|
||||
/* Groups lay out as a responsive grid rather than a single narrow
|
||||
column — on a wide (tiled/maximized) window that means multiple
|
||||
independent setting groups sit side by side instead of one column
|
||||
with a wall of empty space either side. `.group.wide` (device
|
||||
lists, thumbnail grids, log views — anything that reads badly
|
||||
squeezed into a card) opts out via `grid-column: 1 / -1`. */
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: var(--space-xl, 20px) 32px;
|
||||
align-items: start;
|
||||
max-width: 1400px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
/** Sidebar page switch. Set from +page.svelte; views call it to jump. */
|
||||
export type Navigate = (page: string) => void;
|
||||
export const NAVIGATE_KEY = "bos-settings-navigate";
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
// Ports src/ui/sidebar.rs's declarative item lists verbatim. Grouped by task,
|
||||
// not "app vs system internals" — a user thinks "I want to change my Wi-Fi",
|
||||
// not "which of these is a bread-ecosystem app" (why breadcrumbs/Wi-Fi
|
||||
// Profiles lives in System, not Personalization).
|
||||
|
||||
import type { Component } from "svelte";
|
||||
import Wifi from "@lucide/svelte/icons/wifi";
|
||||
import Network from "@lucide/svelte/icons/network";
|
||||
import Bluetooth from "@lucide/svelte/icons/bluetooth";
|
||||
import Shield from "@lucide/svelte/icons/shield";
|
||||
import Volume2 from "@lucide/svelte/icons/volume-2";
|
||||
import BatteryFull from "@lucide/svelte/icons/battery-full";
|
||||
import Clock from "@lucide/svelte/icons/clock";
|
||||
import Monitor from "@lucide/svelte/icons/monitor";
|
||||
import Keyboard from "@lucide/svelte/icons/keyboard";
|
||||
import Rocket from "@lucide/svelte/icons/rocket";
|
||||
import Users from "@lucide/svelte/icons/users";
|
||||
import Palette from "@lucide/svelte/icons/palette";
|
||||
import Image from "@lucide/svelte/icons/image";
|
||||
import LayoutGrid from "@lucide/svelte/icons/layout-grid";
|
||||
import Grid3x3 from "@lucide/svelte/icons/grid-3x3";
|
||||
import Clipboard from "@lucide/svelte/icons/clipboard";
|
||||
import NotebookPen from "@lucide/svelte/icons/notebook-pen";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import Cog from "@lucide/svelte/icons/cog";
|
||||
import Package from "@lucide/svelte/icons/package";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
import History from "@lucide/svelte/icons/history";
|
||||
import Info from "@lucide/svelte/icons/info";
|
||||
import Lock from "@lucide/svelte/icons/lock";
|
||||
import Camera from "@lucide/svelte/icons/camera";
|
||||
import AppWindow from "@lucide/svelte/icons/app-window";
|
||||
import CircleHelp from "@lucide/svelte/icons/circle-help";
|
||||
import Download from "@lucide/svelte/icons/download";
|
||||
import Printer from "@lucide/svelte/icons/printer";
|
||||
import ShieldEllipsis from "@lucide/svelte/icons/shield-ellipsis";
|
||||
import Moon from "@lucide/svelte/icons/moon";
|
||||
import Languages from "@lucide/svelte/icons/languages";
|
||||
import Accessibility from "@lucide/svelte/icons/accessibility";
|
||||
import AppWindowMac from "@lucide/svelte/icons/app-window-mac";
|
||||
import GitBranch from "@lucide/svelte/icons/git-branch";
|
||||
import Archive from "@lucide/svelte/icons/archive";
|
||||
import Boxes from "@lucide/svelte/icons/boxes";
|
||||
|
||||
export interface SidebarItem {
|
||||
/** Must match a key in the view component map (see routing in +page.svelte). */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Dim second line — the underlying binary/config name, for items whose
|
||||
* human label doesn't already make that obvious. */
|
||||
sublabel?: string;
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
export const SYSTEM_ITEMS: SidebarItem[] = [
|
||||
{ id: "network", label: "Network", icon: Wifi },
|
||||
{ id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network },
|
||||
{ id: "vpn", label: "VPN / WireGuard", sublabel: "NetworkManager", icon: ShieldEllipsis },
|
||||
{ id: "bluetooth", label: "Bluetooth", icon: Bluetooth },
|
||||
{ id: "printing", label: "Printing", sublabel: "CUPS", icon: Printer },
|
||||
{ id: "firewall", label: "Firewall", icon: Shield },
|
||||
{ id: "sound", label: "Sound", icon: Volume2 },
|
||||
{ id: "power", label: "Power", icon: BatteryFull },
|
||||
{ id: "datetime", label: "Date & Time", icon: Clock },
|
||||
{ id: "hyprland", label: "Display", sublabel: "monitors.json", icon: Monitor },
|
||||
{ id: "nightlight", label: "Night light", sublabel: "hyprsunset", icon: Moon },
|
||||
{ id: "breadmon", label: "Monitors", sublabel: "breadmon", icon: AppWindow },
|
||||
{ id: "breadlock", label: "Lock & greet", sublabel: "breadlock", icon: Lock },
|
||||
{ id: "keybinds", label: "Keybinds", sublabel: "binds.json", icon: Keyboard },
|
||||
{ id: "ime", label: "Input method", sublabel: "fcitx5", icon: Languages },
|
||||
{ id: "accessibility", label: "Accessibility", icon: Accessibility },
|
||||
{ id: "breadshot", label: "Screenshots", sublabel: "breadshot", icon: Camera },
|
||||
{ id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket },
|
||||
{ id: "users", label: "Users", icon: Users },
|
||||
];
|
||||
|
||||
export const PERSONALIZATION_ITEMS: SidebarItem[] = [
|
||||
{ id: "appearance", label: "Appearance", sublabel: "settings.json", icon: Palette },
|
||||
{ id: "breadpaper", label: "Wallpaper", sublabel: "breadpaper", icon: Image },
|
||||
{ id: "breadbar", label: "Bar", sublabel: "breadbar", icon: LayoutGrid },
|
||||
{ id: "breadbox", label: "Launcher", sublabel: "breadbox", icon: Grid3x3 },
|
||||
{ id: "breadclip", label: "Clipboard", sublabel: "breadclipd", icon: Clipboard },
|
||||
{ id: "breadpad", label: "Notes", sublabel: "breadpad", icon: NotebookPen },
|
||||
{ id: "breadsearch", label: "File Search", sublabel: "breadsearch", icon: Search },
|
||||
{ id: "defaults", label: "Default apps", sublabel: "mimeapps.list", icon: AppWindowMac },
|
||||
{ id: "bread", label: "Daemon", sublabel: "breadd", icon: Cog },
|
||||
];
|
||||
|
||||
export const MAINTENANCE_ITEMS: SidebarItem[] = [
|
||||
{ id: "updates", label: "Updates", icon: Download },
|
||||
{ id: "packages", label: "Packages", icon: Package },
|
||||
{ id: "aur", label: "AUR", icon: Search },
|
||||
{ id: "firmware", label: "Firmware", icon: RefreshCw },
|
||||
{ id: "snapshots", label: "Snapshots", icon: History },
|
||||
{ id: "channel", label: "Bakery channel", sublabel: "track", icon: GitBranch },
|
||||
{ id: "backup", label: "Backup", sublabel: "restic", icon: Archive },
|
||||
{ id: "optional", label: "Optional software", icon: Boxes },
|
||||
];
|
||||
|
||||
export const ABOUT_ITEMS: SidebarItem[] = [
|
||||
{ id: "breadhelp", label: "Help", sublabel: "breadhelp", icon: CircleHelp },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
];
|
||||
|
||||
export interface SidebarSection {
|
||||
title: string | null;
|
||||
items: SidebarItem[];
|
||||
}
|
||||
|
||||
export const SIDEBAR_SECTIONS: SidebarSection[] = [
|
||||
{ title: "System", items: SYSTEM_ITEMS },
|
||||
{ title: "Personalization", items: PERSONALIZATION_ITEMS },
|
||||
{ title: "Maintenance", items: MAINTENANCE_ITEMS },
|
||||
{ title: null, items: ABOUT_ITEMS },
|
||||
];
|
||||
|
||||
export const DEFAULT_PAGE = "about";
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// Frontend half of the event-streaming command pattern (see
|
||||
// src/src/commands/streaming.rs) — listens for `cmd-output` lines from a
|
||||
// typed Tauri command that runs a hardcoded program, then resolves once
|
||||
// the process exits.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export async function runStreamed(
|
||||
command: string,
|
||||
args: Record<string, unknown>,
|
||||
onLine: (line: string) => void,
|
||||
): Promise<boolean> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
const unlisten = await listen<{ session_id: string; line: string }>("cmd-output", (event) => {
|
||||
if (event.payload.session_id === sessionId) onLine(event.payload.line);
|
||||
});
|
||||
|
||||
try {
|
||||
return await invoke<boolean>(command, { sessionId, ...args });
|
||||
} finally {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
// Bridges bread-theme's pywal-derived palette into the webview. Mirrors
|
||||
// bread_theme::gtk::apply_shared()'s two-phase pattern: fetch once at
|
||||
// startup, then keep it live via a backend-pushed event — see
|
||||
// src-tauri/src/commands/theme.rs for the file-watch side of this.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
const STYLE_ELEMENT_ID = "bread-theme";
|
||||
|
||||
function applyThemeCss(css: string) {
|
||||
let style = document.getElementById(STYLE_ELEMENT_ID);
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = STYLE_ELEMENT_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = css;
|
||||
}
|
||||
|
||||
/** Call once at startup (e.g. from a root `$effect`/`onMount`). */
|
||||
export async function initTheme(): Promise<void> {
|
||||
const css = await invoke<string>("get_theme_css");
|
||||
applyThemeCss(css);
|
||||
|
||||
await listen<string>("theme-changed", (event) => {
|
||||
applyThemeCss(event.payload);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
|
||||
interface SystemInfo {
|
||||
os: string;
|
||||
kernel: string;
|
||||
cpu: string;
|
||||
gpu: string;
|
||||
memory: string;
|
||||
disk: string;
|
||||
uptime: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
let info = $state<SystemInfo | null>(null);
|
||||
let hostnameInput = $state("");
|
||||
let status = $state("");
|
||||
let applying = $state(false);
|
||||
|
||||
// The backend reports the GPU as a raw lspci device string, e.g.
|
||||
// "Advanced Micro Devices, Inc. [AMD/ATI] Krackan [Radeon 840M / 860M Graphics] (rev c2)".
|
||||
// That's too technical for a general settings page, so extract just the
|
||||
// marketing model name. Heuristic (not exhaustive — just needs to read
|
||||
// well for common vendors): drop the trailing "(rev ..)", prefer the
|
||||
// text inside the last [...] bracket group (usually the model name),
|
||||
// collapse "840M / 860M" style multi-model lists to the last variant,
|
||||
// and drop a trailing generic word like "Graphics"/"Controller".
|
||||
function friendlyGpu(raw: string): string {
|
||||
if (!raw) return raw;
|
||||
let s = raw.replace(/\s*\(rev [^)]*\)\s*$/i, "").trim();
|
||||
const brackets = [...s.matchAll(/\[([^\]]+)\]/g)];
|
||||
if (brackets.length > 0) {
|
||||
s = brackets[brackets.length - 1][1];
|
||||
} else {
|
||||
s = s.replace(/^.*?,\s*Inc\.\s*/i, "").trim();
|
||||
}
|
||||
s = s.replace(/\b(\w+)\s+[\w.]+\s*\/\s*(\w+)\b/, "$1 $2");
|
||||
s = s.replace(/\s+(Graphics|Controller|Series)\s*$/i, "");
|
||||
return s.trim() || raw;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
info = await invoke<SystemInfo>("get_system_info");
|
||||
hostnameInput = info.hostname;
|
||||
});
|
||||
|
||||
async function applyHostname() {
|
||||
if (!hostnameInput.trim()) {
|
||||
status = "Hostname can't be empty";
|
||||
return;
|
||||
}
|
||||
applying = true;
|
||||
status = "Applying…";
|
||||
try {
|
||||
await invoke("set_hostname", { name: hostnameInput.trim() });
|
||||
status = "Applied";
|
||||
} catch (e) {
|
||||
status = `Error: ${e}`;
|
||||
} finally {
|
||||
applying = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="About">
|
||||
{#if info}
|
||||
<Group title="This machine">
|
||||
<InfoRow label="Operating system" value={info.os} />
|
||||
<InfoRow label="Kernel" value={info.kernel} />
|
||||
<InfoRow label="CPU" value={info.cpu} />
|
||||
<InfoRow label="GPU" value={friendlyGpu(info.gpu)} />
|
||||
<InfoRow label="Memory" value={info.memory} />
|
||||
<InfoRow label="Disk (/)" value={info.disk} />
|
||||
<InfoRow label="Uptime" value={info.uptime} />
|
||||
</Group>
|
||||
|
||||
<Group title="Hostname" hint="Changes the machine's network name. Takes effect immediately; needs your password (polkit).">
|
||||
<div class="hostname-row">
|
||||
<input type="text" bind:value={hostnameInput} disabled={applying} />
|
||||
<button disabled={applying} onclick={applyHostname}>Apply</button>
|
||||
</div>
|
||||
{#if status}
|
||||
<span class="status">{status}</span>
|
||||
{/if}
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.hostname-row {
|
||||
display: flex;
|
||||
gap: var(--space-md, 12px);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
|
||||
interface A11yStatus {
|
||||
orca_installed: boolean;
|
||||
orca_running: boolean;
|
||||
zoom_factor: number;
|
||||
sticky_keys_supported: boolean;
|
||||
slow_keys_supported: boolean;
|
||||
kmag_installed: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
let st = $state<A11yStatus | null>(null);
|
||||
let zoom = $state(1);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
let message = $state("");
|
||||
|
||||
async function refresh() {
|
||||
st = await invoke<A11yStatus>("get_a11y_status");
|
||||
zoom = st.zoom_factor;
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function install(packages: string[]) {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("pacman_install", { packages }, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function toggleOrca(running: boolean) {
|
||||
message = "";
|
||||
try {
|
||||
await invoke("set_orca_running", { running });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyZoom() {
|
||||
message = "";
|
||||
try {
|
||||
zoom = await invoke<number>("set_cursor_zoom", { factor: zoom });
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function openKmag() {
|
||||
message = "";
|
||||
try {
|
||||
await invoke("open_kmag");
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Accessibility">
|
||||
<Group title="Screen reader" hint="Orca. Works on Wayland; start/stop is process-level, not a compositor setting.">
|
||||
{#if !st}
|
||||
<Hint text="Loading…" />
|
||||
{:else if !st.orca_installed}
|
||||
<Hint text="orca is not installed." />
|
||||
<button class="primary" disabled={busy} onclick={() => install(["orca"])}>Install orca</button>
|
||||
{:else}
|
||||
<div class="row-switch">
|
||||
<span>Orca</span>
|
||||
<button
|
||||
class="switch"
|
||||
class:on={st.orca_running}
|
||||
role="switch"
|
||||
aria-checked={st.orca_running}
|
||||
aria-label="Orca"
|
||||
onclick={() => toggleOrca(!st!.orca_running)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Magnifier" hint="Hyprland’s real magnifier is cursor:zoom_factor — pointer-centered zoom. Applies to this session.">
|
||||
{#if st}
|
||||
<NumberField label="Zoom factor" bind:value={zoom} min={1} max={8} step={0.25} />
|
||||
<button class="primary" onclick={applyZoom}>Apply zoom</button>
|
||||
<Hint text="1.0 is off. Values above 1 enlarge around the cursor." />
|
||||
{#if !st.kmag_installed}
|
||||
<Hint text="kmag is a separate KDE magnifier and is not wired into Hyprland. Optional install only." />
|
||||
<button disabled={busy} onclick={() => install(["kmag"])}>Install kmag</button>
|
||||
{:else}
|
||||
<button onclick={openKmag}>Open kmag</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Sticky keys / Slow keys">
|
||||
{#if st}
|
||||
<div class="row-switch disabled">
|
||||
<span>Sticky keys</span>
|
||||
<button class="switch" disabled role="switch" aria-checked="false" aria-label="Sticky keys">
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row-switch disabled">
|
||||
<span>Slow keys</span>
|
||||
<button class="switch" disabled role="switch" aria-checked="false" aria-label="Slow keys">
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<Hint text={st.note} />
|
||||
{/if}
|
||||
</Group>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.row-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.row-switch.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.switch:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
display: block;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import HyprColorField from "$lib/components/HyprColorField.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
// Common XKB layout codes. Not exhaustive — if the config holds
|
||||
// something else, it's merged in below so it's never dropped from the
|
||||
// dropdown.
|
||||
const COMMON_KB_LAYOUTS = [
|
||||
"us", "gb", "de", "fr", "es", "it", "pt", "nl", "se", "no", "dk", "fi",
|
||||
"pl", "cz", "sk", "hu", "ro", "gr", "tr", "ru", "ua", "jp", "kr", "cn",
|
||||
"br", "ca", "ch", "be", "at", "ie",
|
||||
];
|
||||
|
||||
// Hyprland's follow_mouse variable (see the wiki's Variables page):
|
||||
// 0 = cursor movement never changes focus; 1 = focus always follows the
|
||||
// window under the cursor; 2 = focus follows the cursor, but clicking a
|
||||
// window keeps keyboard focus there until the mouse moves again; 3 =
|
||||
// cursor and keyboard focus are fully independent.
|
||||
const FOLLOW_MOUSE_MODES: { value: number; label: string }[] = [
|
||||
{ value: 0, label: "Off — click to focus" },
|
||||
{ value: 1, label: "Follow mouse" },
|
||||
{ value: 2, label: "Follow mouse, detach on click" },
|
||||
{ value: 3, label: "Fully detached from keyboard focus" },
|
||||
];
|
||||
|
||||
interface Appearance {
|
||||
gaps_in: number;
|
||||
gaps_out: number;
|
||||
border_size: number;
|
||||
active_border: string;
|
||||
inactive_border: string;
|
||||
layout: string;
|
||||
resize_on_border: boolean;
|
||||
rounding: number;
|
||||
blur_enabled: boolean;
|
||||
blur_size: number;
|
||||
blur_passes: number;
|
||||
shadow_enabled: boolean;
|
||||
shadow_range: number;
|
||||
shadow_render_power: number;
|
||||
kb_layout: string;
|
||||
follow_mouse: number;
|
||||
natural_scroll: boolean;
|
||||
}
|
||||
|
||||
let cfg = $state<Appearance | null>(null);
|
||||
|
||||
let kbLayoutOptions = $derived(
|
||||
cfg && !COMMON_KB_LAYOUTS.includes(cfg.kb_layout) ? [...COMMON_KB_LAYOUTS, cfg.kb_layout] : COMMON_KB_LAYOUTS,
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
cfg = await invoke<Appearance>("get_appearance");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await invoke("save_appearance", { appearance: cfg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Appearance">
|
||||
{#if cfg}
|
||||
<Group title="Windows & borders" hint="Gaps, borders, and tiling — the same settings.json Hyprland reads at login.">
|
||||
<NumberField label="Gaps between windows" bind:value={cfg.gaps_in} min={0} max={50} />
|
||||
<NumberField label="Gaps around screen edge" bind:value={cfg.gaps_out} min={0} max={50} />
|
||||
<NumberField label="Border width" bind:value={cfg.border_size} min={0} max={10} />
|
||||
<HyprColorField label="Active border color" bind:value={cfg.active_border} />
|
||||
<HyprColorField label="Inactive border color" bind:value={cfg.inactive_border} />
|
||||
<SelectField label="Tiling layout" bind:value={cfg.layout} options={["dwindle", "master"]} />
|
||||
<SwitchField label="Resize by dragging borders" bind:value={cfg.resize_on_border} />
|
||||
</Group>
|
||||
|
||||
<Group title="Effects">
|
||||
<NumberField label="Corner rounding" bind:value={cfg.rounding} min={0} max={30} />
|
||||
<SwitchField label="Blur" bind:value={cfg.blur_enabled} />
|
||||
<NumberField label="Blur size" bind:value={cfg.blur_size} min={0} max={20} />
|
||||
<NumberField label="Blur passes" bind:value={cfg.blur_passes} min={1} max={5} />
|
||||
<SwitchField label="Window shadows" bind:value={cfg.shadow_enabled} />
|
||||
<NumberField label="Shadow range" bind:value={cfg.shadow_range} min={0} max={40} />
|
||||
<NumberField label="Shadow render power" bind:value={cfg.shadow_render_power} min={1} max={4} />
|
||||
</Group>
|
||||
|
||||
<Group title="Input">
|
||||
<SelectField label="Keyboard layout" bind:value={cfg.kb_layout} options={kbLayoutOptions} />
|
||||
<Row label="Focus-follows-mouse mode">
|
||||
<select bind:value={cfg.follow_mouse}>
|
||||
{#each FOLLOW_MOUSE_MODES as mode (mode.value)}
|
||||
<option value={mode.value}>{mode.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
<SwitchField label="Natural scrolling (touchpad)" bind:value={cfg.natural_scroll} />
|
||||
</Group>
|
||||
|
||||
<Hint text="Changes apply on next login or Hyprland reload — this saves settings.json, it doesn't reload Hyprland live." />
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
select {
|
||||
color-scheme: dark;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
|
||||
interface AurResult {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
let query = $state("");
|
||||
let results = $state<AurResult[] | null>(null);
|
||||
let status = $state("Search for a package to see results here.");
|
||||
let searching = $state(false);
|
||||
|
||||
async function search() {
|
||||
if (!query.trim()) return;
|
||||
searching = true;
|
||||
status = "Searching…";
|
||||
results = await invoke<AurResult[]>("search_aur", { query });
|
||||
status = results.length === 0 ? "No results." : `${results.length} result(s)`;
|
||||
searching = false;
|
||||
}
|
||||
|
||||
function install(pkg: string) {
|
||||
invoke("install_aur_package", { pkg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="AUR">
|
||||
<Group
|
||||
title="Search"
|
||||
hint="Search the Arch User Repository via yay. Installing opens a terminal — AUR packages run arbitrary build scripts, and reviewing what yay is about to do (and entering your password) is a real safety step."
|
||||
wide
|
||||
>
|
||||
<div class="search-row">
|
||||
<input type="text" bind:value={query} placeholder="Search the AUR…" onkeydown={(e) => e.key === "Enter" && search()} />
|
||||
<button disabled={searching} onclick={search}>Search</button>
|
||||
</div>
|
||||
|
||||
<Hint text={status} />
|
||||
|
||||
<div class="results">
|
||||
{#if results && results.length === 0}
|
||||
<EmptyState icon={Search} title="No results" hint="Try a different search term." />
|
||||
{:else if results}
|
||||
{#each results as r (r.name)}
|
||||
<div class="card">
|
||||
<div class="top">
|
||||
<span class="name" title={r.name}>{r.name}</span>
|
||||
<span class="version" title={r.version}>{r.version}</span>
|
||||
<button class="install" onclick={() => install(r.name)}>Install</button>
|
||||
</div>
|
||||
<span class="desc">{r.description}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.search-row input {
|
||||
flex: 1;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.search-row button {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-row button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.results {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 6px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version {
|
||||
opacity: 0.75;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desc {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.install {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import FolderOpen from "@lucide/svelte/icons/folder-open";
|
||||
|
||||
interface AutostartEntry {
|
||||
command: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
let entries = $state<AutostartEntry[] | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
entries = await invoke<AutostartEntry[]>("get_autostart_entries");
|
||||
});
|
||||
|
||||
function addEntry() {
|
||||
entries = [...(entries ?? []), { command: "", label: "", enabled: true }];
|
||||
}
|
||||
|
||||
function removeEntry(i: number) {
|
||||
entries = entries!.filter((_, idx) => idx !== i);
|
||||
}
|
||||
|
||||
async function browseFor(entry: AutostartEntry) {
|
||||
const picked = await open({ directory: false, defaultPath: "/usr/bin" });
|
||||
if (typeof picked === "string") entry.command = picked;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await invoke("save_autostart_entries", { entries });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Startup Apps">
|
||||
{#if entries}
|
||||
<Group
|
||||
title="Extra autostart apps"
|
||||
hint="What launches after login, beyond the core desktop (bar, theme, clipboard, etc. always start regardless). Toggle off, edit, or add your own."
|
||||
wide
|
||||
>
|
||||
{#each entries as entry, i (i)}
|
||||
<div class="row">
|
||||
<button
|
||||
class="switch"
|
||||
class:on={entry.enabled}
|
||||
role="switch"
|
||||
aria-checked={entry.enabled}
|
||||
aria-label="Enabled"
|
||||
onclick={() => (entry.enabled = !entry.enabled)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
<div class="fields">
|
||||
<input type="text" bind:value={entry.label} placeholder="Label" class="label" title={entry.label} />
|
||||
<input type="text" bind:value={entry.command} placeholder="command" class="command" title={entry.command} />
|
||||
</div>
|
||||
<button class="browse" onclick={() => browseFor(entry)} aria-label="Browse for program">
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
<button class="remove" onclick={() => removeEntry(i)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="add" onclick={addEntry}>Add app</button>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.fields {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.label,
|
||||
.command {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command {
|
||||
opacity: 0.65;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browse {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.switch {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import FileField from "$lib/components/FileField.svelte";
|
||||
import PasswordField from "$lib/components/PasswordField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import Archive from "@lucide/svelte/icons/archive";
|
||||
|
||||
interface ResticSnapshot {
|
||||
id: string;
|
||||
time: string;
|
||||
paths: string[];
|
||||
}
|
||||
interface BackupStatus {
|
||||
restic_installed: boolean;
|
||||
repo: string;
|
||||
has_password: boolean;
|
||||
snapshots: ResticSnapshot[];
|
||||
error: string | null;
|
||||
home: string;
|
||||
}
|
||||
|
||||
let st = $state<BackupStatus | null>(null);
|
||||
let repo = $state("");
|
||||
let password = $state("");
|
||||
let snapshots = $state<ResticSnapshot[]>([]);
|
||||
let selected = $state("latest");
|
||||
let restoreTarget = $state("");
|
||||
let lastAutoTarget = $state("");
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
let message = $state("");
|
||||
|
||||
function defaultTarget(id: string): string {
|
||||
const home = st?.home ?? "";
|
||||
if (!home) return "";
|
||||
return `${home}/bos-restore-${id || "latest"}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!st?.home) return;
|
||||
const auto = defaultTarget(selected || "latest");
|
||||
if (restoreTarget === "" || restoreTarget === lastAutoTarget) {
|
||||
if (restoreTarget !== auto) restoreTarget = auto;
|
||||
if (lastAutoTarget !== auto) lastAutoTarget = auto;
|
||||
}
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
st = await invoke<BackupStatus>("get_backup_config");
|
||||
repo = st.repo;
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function save() {
|
||||
await invoke("save_backup_config", { input: { repo, password: password || null } });
|
||||
password = "";
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function installRestic() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("pacman_install", { packages: ["restic"] }, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function run(command: string, args: Record<string, unknown> = {}) {
|
||||
log = [];
|
||||
busy = true;
|
||||
message = "";
|
||||
const ok = await runStreamed(command, args, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
if (!ok) message = "Command failed — see the log.";
|
||||
}
|
||||
|
||||
async function listSnaps() {
|
||||
message = "";
|
||||
try {
|
||||
snapshots = await invoke<ResticSnapshot[]>("list_restic_snapshots");
|
||||
if (snapshots.length === 0) message = "No snapshots in this repo yet.";
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
snapshots = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(dryRun: boolean) {
|
||||
const snap = selected || "latest";
|
||||
const target = restoreTarget.trim() || defaultTarget(snap);
|
||||
const home = st?.home ?? "";
|
||||
if (!dryRun && home && (target === home || target === `${home}/`)) {
|
||||
message = "Refusing to restore onto $HOME. Leave the default ~/bos-restore-<id> or pick another folder.";
|
||||
return;
|
||||
}
|
||||
if (!dryRun) {
|
||||
const ok = confirm(
|
||||
`Restore snapshot ${snap} into ${target}?\n\nFiles go into that directory. Your live home is not overwritten.`,
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
await run(dryRun ? "restic_restore_dry_run" : "restic_restore", {
|
||||
snapshot: snap,
|
||||
target,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Backup">
|
||||
<Group
|
||||
title="Repository"
|
||||
hint="restic to a local directory or sftp:user@host:/path. Path and password are stored in ~/.config/bos-settings/backup.toml (mode 0600). The password field is write-only."
|
||||
wide
|
||||
>
|
||||
{#if !st}
|
||||
<Hint text="Loading…" />
|
||||
{:else if !st.restic_installed}
|
||||
<Hint text="restic is not installed." />
|
||||
<button class="primary" disabled={busy} onclick={installRestic}>Install restic</button>
|
||||
{:else}
|
||||
<FileField label="Local path" bind:value={repo} mode="folder" placeholder="/mnt/backup/bos" />
|
||||
<TextField label="Or SFTP" bind:value={repo} placeholder="sftp:user@host:/backups/bos" />
|
||||
<PasswordField label={st.has_password ? "Password (leave empty to keep)" : "Password"} bind:value={password} />
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Actions"
|
||||
hint="This backs up your @home life — documents, configs, the stuff snapper does not. Snapshots on the Snapshots page are root (@) only. Skips caches, Trash, Steam, containers, cargo/rustup, Flatpak, node_modules, target, and .git."
|
||||
>
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} onclick={() => run("restic_init")}>Init repo</button>
|
||||
<button class="primary" disabled={busy} onclick={() => run("restic_backup")}>Backup home</button>
|
||||
<button disabled={busy} onclick={listSnaps}>List snapshots</button>
|
||||
</div>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Restore"
|
||||
hint="Writes into a new folder (default ~/bos-restore-<id>). Does not overwrite $HOME. Dry-run previews the same target."
|
||||
>
|
||||
<FileField label="Restore into" bind:value={restoreTarget} mode="folder" placeholder="/home/you/bos-restore-latest" />
|
||||
<div class="btn-row">
|
||||
<button disabled={busy || !st?.restic_installed} onclick={() => restore(true)}>Restore dry-run</button>
|
||||
<button class="primary" disabled={busy || !st?.restic_installed} onclick={() => restore(false)}>Restore</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Snapshots" wide>
|
||||
{#if snapshots.length === 0}
|
||||
<EmptyState icon={Archive} title="No snapshots loaded" hint="Init, backup, then list." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each snapshots as s (s.id)}
|
||||
<button class="row" class:selected={selected === s.id} onclick={() => (selected = s.id)}>
|
||||
<span class="id">{s.id}</span>
|
||||
<span class="time">{s.time}</span>
|
||||
<span class="paths">{s.paths.join(", ")}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Group>
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
}
|
||||
|
||||
.row.selected {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.id {
|
||||
width: 10ch;
|
||||
flex-shrink: 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.time {
|
||||
width: 22ch;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.paths {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import BluetoothIcon from "@lucide/svelte/icons/bluetooth";
|
||||
import BluetoothOff from "@lucide/svelte/icons/bluetooth-off";
|
||||
|
||||
interface BtDevice {
|
||||
address: string;
|
||||
name: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
let powered = $state<boolean | null>(null);
|
||||
let paired = $state<BtDevice[] | null>(null);
|
||||
let scanResults = $state<BtDevice[] | null>(null);
|
||||
let scanning = $state(false);
|
||||
let log = $state<string[]>([]);
|
||||
|
||||
async function refreshPaired() {
|
||||
paired = await invoke<BtDevice[]>("get_paired_devices");
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
powered = await invoke<boolean | null>("get_adapter_powered");
|
||||
if (powered !== null) await refreshPaired();
|
||||
});
|
||||
|
||||
async function togglePower(on: boolean) {
|
||||
powered = on;
|
||||
await invoke("set_adapter_powered", { on });
|
||||
}
|
||||
|
||||
async function toggleConnect(dev: BtDevice) {
|
||||
log = [];
|
||||
try {
|
||||
await invoke(dev.connected ? "bt_disconnect" : "bt_connect", { address: dev.address });
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
await refreshPaired();
|
||||
}
|
||||
|
||||
async function forget(dev: BtDevice) {
|
||||
if (!confirm(`Forget "${dev.name}"? You'll need to pair it again to reconnect.`)) return;
|
||||
log = [];
|
||||
try {
|
||||
await invoke("bt_forget", { address: dev.address });
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
await refreshPaired();
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
scanning = true;
|
||||
scanResults = await invoke<BtDevice[]>("scan_bluetooth");
|
||||
scanning = false;
|
||||
}
|
||||
|
||||
async function pair(dev: BtDevice) {
|
||||
log = [];
|
||||
try {
|
||||
await invoke("bt_pair", { address: dev.address });
|
||||
await refreshPaired();
|
||||
scanResults = scanResults!.filter((d) => d.address !== dev.address);
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Bluetooth">
|
||||
{#if powered === null}
|
||||
<EmptyState
|
||||
icon={BluetoothOff}
|
||||
title="No Bluetooth adapter found"
|
||||
hint="This machine doesn't have Bluetooth hardware, or the kernel module isn't loaded."
|
||||
/>
|
||||
{:else}
|
||||
<Group title="Adapter">
|
||||
<SwitchField label="Bluetooth" bind:value={() => powered ?? false, (v) => togglePower(v)} />
|
||||
</Group>
|
||||
|
||||
<Group title="Paired devices">
|
||||
<div class="list">
|
||||
{#if paired === null}
|
||||
<Hint text="Loading…" />
|
||||
{:else if paired.length === 0}
|
||||
<EmptyState icon={BluetoothIcon} title="No paired devices" hint="Scan and pair a device to see it here." />
|
||||
{:else}
|
||||
{#each paired as dev (dev.address)}
|
||||
<div class="row">
|
||||
<span class="name" class:active={dev.connected}>{dev.name}{dev.connected ? " (connected)" : ""}</span>
|
||||
<button class="action" onclick={() => toggleConnect(dev)}>{dev.connected ? "Disconnect" : "Connect"}</button>
|
||||
<button class="remove" onclick={() => forget(dev)}>Forget</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Available devices"
|
||||
hint="Scanning takes a few seconds. Devices needing a PIN aren't supported — only "just works" pairing (most headphones, speakers, keyboards, and mice)."
|
||||
>
|
||||
<div class="list">
|
||||
{#if scanResults === null}
|
||||
<EmptyState icon={BluetoothIcon} title="Not scanned yet" hint="Press Scan to look for nearby devices." />
|
||||
{:else if scanResults.length === 0}
|
||||
<EmptyState icon={BluetoothIcon} title="No new devices found" hint="Make sure the device is powered on and in pairing mode, then Scan again." />
|
||||
{:else}
|
||||
{#each scanResults as dev (dev.address)}
|
||||
<div class="row">
|
||||
<span class="name">{dev.name}</span>
|
||||
<button class="action" onclick={() => pair(dev)}>Pair</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<button class="scan" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
|
||||
</Group>
|
||||
|
||||
<LogView lines={log} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
min-height: 60px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.name.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
.scan {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.scan:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import ServiceControl from "$lib/components/ServiceControl.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import TagsField from "$lib/components/TagsField.svelte";
|
||||
import ChipPickerField from "$lib/components/ChipPickerField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
interface BreadConfig {
|
||||
log_level: string;
|
||||
socket_path: string;
|
||||
lua_entry_point: string;
|
||||
lua_module_path: string;
|
||||
modules_builtin: boolean;
|
||||
modules_disable: string[];
|
||||
adapter_hyprland: boolean;
|
||||
adapter_udev: boolean;
|
||||
udev_subsystems: string[];
|
||||
adapter_power: boolean;
|
||||
power_poll_interval_secs: number;
|
||||
adapter_network: boolean;
|
||||
adapter_bluetooth: boolean;
|
||||
dedup_window_ms: number;
|
||||
notif_default_timeout_ms: number;
|
||||
notif_default_urgency: string;
|
||||
notif_notify_send_path: string;
|
||||
}
|
||||
|
||||
let cfg = $state<BreadConfig | null>(null);
|
||||
// The daemon's 4 compiled-in modules plus every *.lua file actually
|
||||
// sitting in the configured module directory — real installed modules,
|
||||
// not a guess, so picking one to disable is a click.
|
||||
let knownModules = $state<string[]>([]);
|
||||
|
||||
onMount(async () => {
|
||||
cfg = await invoke<BreadConfig>("get_bread_config");
|
||||
knownModules = await invoke<string[]>("list_bread_modules");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await invoke("save_bread_config", { cfg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Daemon">
|
||||
<ServiceControl unit="breadd.service" critical hasConfig />
|
||||
|
||||
{#if cfg}
|
||||
<Group title="Daemon">
|
||||
<SelectField label="Log level" bind:value={cfg.log_level} options={["error", "warn", "info", "debug", "trace"]} />
|
||||
<TextField label="Socket path" bind:value={cfg.socket_path} placeholder="default (XDG runtime dir)" />
|
||||
</Group>
|
||||
|
||||
<Group title="Lua">
|
||||
<TextField label="Entry point" bind:value={cfg.lua_entry_point} placeholder="~/.config/bread/init.lua" />
|
||||
<TextField label="Module path" bind:value={cfg.lua_module_path} placeholder="~/.config/bread/modules" />
|
||||
</Group>
|
||||
|
||||
<Group title="Modules">
|
||||
<SwitchField label="Load built-in modules" bind:value={cfg.modules_builtin} />
|
||||
<ChipPickerField
|
||||
label="Disabled modules"
|
||||
bind:value={cfg.modules_disable}
|
||||
options={knownModules}
|
||||
emptyOptionsHint="No other modules found to disable."
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group title="Adapters" hint="Sources breadd normalises into events. Disable any you don't use.">
|
||||
<SwitchField label="Hyprland" bind:value={cfg.adapter_hyprland} />
|
||||
<SwitchField label="udev (devices)" bind:value={cfg.adapter_udev} />
|
||||
<TagsField label="udev subsystems" bind:value={cfg.udev_subsystems} />
|
||||
<SwitchField label="Power" bind:value={cfg.adapter_power} />
|
||||
<NumberField label="Power poll interval (s)" bind:value={cfg.power_poll_interval_secs} min={1} max={3600} />
|
||||
<SwitchField label="Network" bind:value={cfg.adapter_network} />
|
||||
<SwitchField label="Bluetooth" bind:value={cfg.adapter_bluetooth} />
|
||||
</Group>
|
||||
|
||||
<Group title="Events">
|
||||
<NumberField label="Dedup window (ms)" bind:value={cfg.dedup_window_ms} min={0} max={10000} step={50} />
|
||||
</Group>
|
||||
|
||||
<Group title="Notifications">
|
||||
<NumberField label="Default timeout (ms)" bind:value={cfg.notif_default_timeout_ms} min={0} max={60000} step={500} />
|
||||
<SelectField label="Default urgency" bind:value={cfg.notif_default_urgency} options={["low", "normal", "critical"]} />
|
||||
<TextField label="notify-send path" bind:value={cfg.notif_notify_send_path} placeholder="auto-detected" />
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
|
||||
interface BreadbarStyle {
|
||||
font_family: string;
|
||||
font_size: number;
|
||||
bar_border_radius: number;
|
||||
bar_padding: number;
|
||||
workspace_inactive_opacity: number;
|
||||
workspace_font_size: number;
|
||||
stat_gap: number;
|
||||
tray_icon_size: number;
|
||||
notification_border_radius: number;
|
||||
}
|
||||
|
||||
let style = $state<BreadbarStyle | null>(null);
|
||||
let advancedOpen = $state(false);
|
||||
let css = $state("");
|
||||
let cssStatus = $state("");
|
||||
let cssSaving = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
style = await invoke<BreadbarStyle>("get_breadbar_style");
|
||||
css = await invoke<string>("get_breadbar_css");
|
||||
});
|
||||
|
||||
async function saveStyle() {
|
||||
await invoke("save_breadbar_style", { style });
|
||||
css = await invoke<string>("get_breadbar_css");
|
||||
}
|
||||
|
||||
async function saveCss() {
|
||||
cssSaving = true;
|
||||
try {
|
||||
const reloaded = await invoke<boolean>("save_breadbar_css", { css });
|
||||
cssStatus = reloaded ? "Saved & reloaded" : "Saved";
|
||||
style = await invoke<BreadbarStyle>("get_breadbar_style");
|
||||
setTimeout(() => (cssStatus = ""), 3000);
|
||||
} catch (e) {
|
||||
cssStatus = `Error: ${e}`;
|
||||
} finally {
|
||||
cssSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Bar">
|
||||
{#if style}
|
||||
<Group title="Text" hint="Applies to the clock, workspace numbers, and stat labels.">
|
||||
<TextField label="Font" bind:value={style.font_family} placeholder="Varela Round" />
|
||||
<NumberField label="Font size" bind:value={style.font_size} min={8} max={32} />
|
||||
</Group>
|
||||
|
||||
<Group title="Bar shape">
|
||||
<NumberField label="Corner rounding" bind:value={style.bar_border_radius} min={0} max={40} />
|
||||
<NumberField label="Inner padding" bind:value={style.bar_padding} min={0} max={40} />
|
||||
</Group>
|
||||
|
||||
<Group title="Workspace indicator">
|
||||
<NumberField label="Size" bind:value={style.workspace_font_size} min={8} max={40} />
|
||||
<Row label="Inactive dimness">
|
||||
<div class="opacity-row">
|
||||
<input type="range" min="0" max="1" step="0.05" bind:value={style.workspace_inactive_opacity} />
|
||||
<span class="pct">{Math.round(style.workspace_inactive_opacity * 100)}%</span>
|
||||
</div>
|
||||
</Row>
|
||||
</Group>
|
||||
|
||||
<Group title="Spacing & icons">
|
||||
<NumberField label="Gap between stats" bind:value={style.stat_gap} min={0} max={40} />
|
||||
<NumberField label="Tray icon size" bind:value={style.tray_icon_size} min={8} max={32} />
|
||||
<NumberField label="Notification corner rounding" bind:value={style.notification_border_radius} min={0} max={24} />
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={saveStyle} />
|
||||
{/if}
|
||||
|
||||
<Group title="Advanced" hint="Raw stylesheet. Anything set here can also be changed above — those fields edit this same file." wide>
|
||||
<button class="toggle" onclick={() => (advancedOpen = !advancedOpen)}>
|
||||
{#if advancedOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
|
||||
Edit raw CSS
|
||||
</button>
|
||||
|
||||
{#if advancedOpen}
|
||||
<textarea bind:value={css} spellcheck="false"></textarea>
|
||||
<div class="row">
|
||||
<button class="save-css" disabled={cssSaving} onclick={saveCss}>Save CSS</button>
|
||||
<span class="status">{cssStatus}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.opacity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.opacity-row input[type="range"] {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.pct {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
width: 4ch;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs, 4px);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--on-surface);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
padding: var(--space-xs, 4px) 0;
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
margin-top: var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.save-css {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-css:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import ServiceControl from "$lib/components/ServiceControl.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
interface Context {
|
||||
name: string;
|
||||
priority: string[];
|
||||
}
|
||||
|
||||
let contexts = $state<Context[] | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
contexts = await invoke<Context[]>("get_breadbox_contexts");
|
||||
});
|
||||
|
||||
function addContext() {
|
||||
contexts = [...(contexts ?? []), { name: "new", priority: [] }];
|
||||
}
|
||||
|
||||
function removeContext(i: number) {
|
||||
contexts = contexts!.filter((_, idx) => idx !== i);
|
||||
}
|
||||
|
||||
function priorityText(ctx: Context) {
|
||||
return ctx.priority.join(", ");
|
||||
}
|
||||
|
||||
function setPriority(ctx: Context, text: string) {
|
||||
ctx.priority = text
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await invoke("save_breadbox_contexts", { contexts });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Launcher">
|
||||
{#if contexts}
|
||||
<Group
|
||||
title="Contexts"
|
||||
hint="Launcher contexts — each lists, in priority order, the apps/categories surfaced first."
|
||||
wide
|
||||
>
|
||||
{#if contexts.length === 0}
|
||||
<div class="empty">No launcher contexts yet. Add one to control which apps/categories breadbox surfaces first.</div>
|
||||
{/if}
|
||||
{#each contexts as ctx, i (i)}
|
||||
<div class="row">
|
||||
<input type="text" bind:value={ctx.name} placeholder="name" class="name" />
|
||||
<input
|
||||
type="text"
|
||||
value={priorityText(ctx)}
|
||||
oninput={(e) => setPriority(ctx, e.currentTarget.value)}
|
||||
placeholder="firefox, code, Development, ..."
|
||||
class="priority"
|
||||
/>
|
||||
<button class="remove" onclick={() => removeContext(i)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="add" onclick={addContext}>Add context</button>
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
|
||||
<ServiceControl unit="breadbox-sync.service" hasConfig />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 14ch;
|
||||
}
|
||||
|
||||
.priority {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
padding: var(--space-md, 12px) 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import ServiceControl from "$lib/components/ServiceControl.svelte";
|
||||
|
||||
function openHistory() {
|
||||
invoke("open_breadclip");
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Clipboard">
|
||||
<Group
|
||||
title="Clipboard history"
|
||||
hint="Keeps a history of copied text/images and shows it as a popup. breadclipd (right) is the background daemon that watches the clipboard — breadclip itself is just the popup UI, launched on demand by the keybind or the button below."
|
||||
>
|
||||
<button class="open-btn" onclick={openHistory}>Open history (SUPER+V)</button>
|
||||
</Group>
|
||||
<ServiceControl unit="breadclipd.service" />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.open-btn {
|
||||
align-self: flex-start;
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import ServiceControl from "$lib/components/ServiceControl.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import ChipPickerField from "$lib/components/ChipPickerField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import Switch from "$lib/components/Switch.svelte";
|
||||
|
||||
interface Settings {
|
||||
default_profile: string;
|
||||
dns: string;
|
||||
exit_node: string;
|
||||
ping_host: string;
|
||||
connectivity_url: string;
|
||||
nmcli_wait: number;
|
||||
watch_interval: number;
|
||||
}
|
||||
interface Network {
|
||||
ssid: string;
|
||||
// Write-only. The backend never returns a stored PSK; empty means
|
||||
// "keep the on-disk secret / let NetworkManager remember."
|
||||
password?: string | null;
|
||||
hidden: boolean;
|
||||
}
|
||||
interface Profile {
|
||||
name: string;
|
||||
networks: string[];
|
||||
detect_ssids: string[];
|
||||
bootstrap: string;
|
||||
exit_node: string;
|
||||
tailscale: boolean;
|
||||
include_all_known: boolean;
|
||||
}
|
||||
interface BreadcrumbsConfig {
|
||||
settings: Settings;
|
||||
networks: Network[];
|
||||
profiles: Profile[];
|
||||
}
|
||||
|
||||
let cfg = $state<BreadcrumbsConfig | null>(null);
|
||||
// Profile names are free-text (set via "Add profile" below), not a fixed
|
||||
// enum — populate the default-profile options from whatever profiles
|
||||
// actually exist, rather than a hardcoded guess that can't match a
|
||||
// user's real profile names.
|
||||
let profileNames = $derived(cfg?.profiles.map((p) => p.name) ?? []);
|
||||
// Saved network SSIDs feed the per-profile network pickers below — no
|
||||
// typing an SSID by hand and hoping it matches one saved above.
|
||||
let savedSsids = $derived(cfg?.networks.map((n) => n.ssid).filter((s) => s.trim().length > 0) ?? []);
|
||||
|
||||
onMount(async () => {
|
||||
const loaded = await invoke<BreadcrumbsConfig>("get_breadcrumbs_config");
|
||||
loaded.networks = loaded.networks.map((n) => ({ ...n, password: "" }));
|
||||
cfg = loaded;
|
||||
});
|
||||
|
||||
function addNetwork() {
|
||||
cfg!.networks = [...cfg!.networks, { ssid: "", password: "", hidden: false }];
|
||||
}
|
||||
function removeNetwork(i: number) {
|
||||
cfg!.networks = cfg!.networks.filter((_, idx) => idx !== i);
|
||||
}
|
||||
function addProfile() {
|
||||
cfg!.profiles = [
|
||||
...cfg!.profiles,
|
||||
{ name: "new", networks: [], detect_ssids: [], bootstrap: "", exit_node: "", tailscale: false, include_all_known: false },
|
||||
];
|
||||
}
|
||||
function removeProfile(i: number) {
|
||||
cfg!.profiles = cfg!.profiles.filter((_, idx) => idx !== i);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const payload = {
|
||||
...cfg!,
|
||||
networks: cfg!.networks.map((n) => ({
|
||||
ssid: n.ssid,
|
||||
hidden: n.hidden,
|
||||
// Omit empty so the backend treats it as "keep / let NM remember"
|
||||
// rather than writing password = "".
|
||||
...(n.password && n.password.length > 0 ? { password: n.password } : {}),
|
||||
})),
|
||||
};
|
||||
await invoke("save_breadcrumbs_config", { input: payload });
|
||||
// Clear typed secrets from the UI after a successful save so a later
|
||||
// glance at the field doesn't look like a stored PSK came back.
|
||||
cfg!.networks = cfg!.networks.map((n) => ({ ...n, password: "" }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Wi-Fi Profiles">
|
||||
<ServiceControl unit="breadcrumbs.service" hasConfig />
|
||||
|
||||
{#if cfg}
|
||||
<Group title="Settings">
|
||||
{#if profileNames.length > 0}
|
||||
<SelectField label="Default profile" bind:value={cfg.settings.default_profile} options={profileNames} />
|
||||
{:else}
|
||||
<TextField label="Default profile" bind:value={cfg.settings.default_profile} placeholder="add a profile below first" />
|
||||
{/if}
|
||||
<TextField label="DNS" bind:value={cfg.settings.dns} placeholder="1.1.1.1" />
|
||||
<TextField label="Exit node" bind:value={cfg.settings.exit_node} placeholder="tailscale exit node" />
|
||||
<TextField label="Ping host" bind:value={cfg.settings.ping_host} placeholder="1.1.1.1" />
|
||||
<TextField label="Connectivity check URL" bind:value={cfg.settings.connectivity_url} />
|
||||
<NumberField label="Connection timeout (s)" bind:value={cfg.settings.nmcli_wait} min={1} max={120} />
|
||||
<NumberField label="Check connectivity every (s)" bind:value={cfg.settings.watch_interval} min={1} max={600} />
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Saved networks"
|
||||
hint="Password is write-only — leave it blank to keep an already-saved secret or let NetworkManager remember it after the first connect. Breadcrumbs never writes a PSK back into breadcrumbs.toml; new passwords go only to networks.toml (0600) and are cleared there after the first successful connect."
|
||||
wide
|
||||
>
|
||||
<div class="list">
|
||||
{#each cfg.networks as net, i (i)}
|
||||
<div class="net-row">
|
||||
<input type="text" bind:value={net.ssid} placeholder="Network name (SSID)" class="ssid" />
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={net.password}
|
||||
placeholder="New password (blank = keep / NM remembers)"
|
||||
class="pass"
|
||||
/>
|
||||
<div class="hidden-label">
|
||||
<Switch bind:value={net.hidden} ariaLabel="Hidden network" />
|
||||
<button type="button" class="label-text" onclick={() => (net.hidden = !net.hidden)}>Hidden network</button>
|
||||
</div>
|
||||
<button class="remove" onclick={() => removeNetwork(i)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="add" onclick={addNetwork}>Add network</button>
|
||||
</Group>
|
||||
|
||||
<Group title="Location profiles" hint="Each profile switches DNS/exit-node/routing when you're on one of its networks." wide>
|
||||
{#each cfg.profiles as profile, i (i)}
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<input type="text" bind:value={profile.name} placeholder="Profile name (e.g. Home)" class="profile-name" />
|
||||
<button class="remove" onclick={() => removeProfile(i)}>Remove</button>
|
||||
</div>
|
||||
|
||||
<ChipPickerField
|
||||
label="Networks in this profile"
|
||||
bind:value={profile.networks}
|
||||
options={savedSsids}
|
||||
emptyOptionsHint="Add a saved network above first."
|
||||
/>
|
||||
<ChipPickerField
|
||||
label="Auto-switch here when nearby"
|
||||
bind:value={profile.detect_ssids}
|
||||
options={savedSsids}
|
||||
emptyOptionsHint="Add a saved network above first."
|
||||
/>
|
||||
|
||||
<label class="field-label">
|
||||
Exit node
|
||||
<input type="text" bind:value={profile.exit_node} placeholder="Tailscale exit node (optional)" />
|
||||
</label>
|
||||
<label class="field-label">
|
||||
Run on connect
|
||||
<input type="text" bind:value={profile.bootstrap} placeholder="Optional command (optional)" />
|
||||
</label>
|
||||
<div class="check-label">
|
||||
<Switch bind:value={profile.tailscale} ariaLabel="Enable Tailscale" />
|
||||
<button type="button" class="label-text" onclick={() => (profile.tailscale = !profile.tailscale)}>Enable Tailscale</button>
|
||||
</div>
|
||||
<div class="check-label">
|
||||
<Switch bind:value={profile.include_all_known} ariaLabel="Also allow any other saved network" />
|
||||
<button
|
||||
type="button"
|
||||
class="label-text"
|
||||
onclick={() => (profile.include_all_known = !profile.include_all_known)}
|
||||
>
|
||||
Also allow any other saved network, not just the ones listed above
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="add" onclick={addProfile}>Add profile</button>
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 4px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.net-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm, 8px);
|
||||
align-items: center;
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.ssid {
|
||||
width: 20ch;
|
||||
}
|
||||
|
||||
.pass {
|
||||
width: 14ch;
|
||||
}
|
||||
|
||||
.hidden-label,
|
||||
.check-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.field-label input[type="text"] {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
|
||||
interface AutostartEntry {
|
||||
command: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const BREADHELP_CMD = "breadhelp --autostart";
|
||||
const BREADHELP_LABEL = "BOS Help (first-run onboarding)";
|
||||
|
||||
let entries = $state<AutostartEntry[] | null>(null);
|
||||
let status = $state("");
|
||||
|
||||
let helpEntry = $derived(entries?.find((e) => e.command.trim().startsWith("breadhelp")) ?? null);
|
||||
let autostartOn = $derived(helpEntry?.enabled ?? false);
|
||||
|
||||
onMount(async () => {
|
||||
entries = await invoke<AutostartEntry[]>("get_autostart_entries");
|
||||
});
|
||||
|
||||
async function setAutostart(enabled: boolean) {
|
||||
if (!entries) return;
|
||||
const next = entries.map((e) => ({ ...e }));
|
||||
const i = next.findIndex((e) => e.command.trim().startsWith("breadhelp"));
|
||||
if (i >= 0) {
|
||||
next[i].enabled = enabled;
|
||||
} else if (enabled) {
|
||||
next.push({ command: BREADHELP_CMD, label: BREADHELP_LABEL, enabled: true });
|
||||
}
|
||||
try {
|
||||
await invoke("save_autostart_entries", { entries: next });
|
||||
entries = next;
|
||||
status = enabled ? "First-run help will launch at login." : "First-run help won't launch at login.";
|
||||
} catch (e) {
|
||||
status = `Error: ${e}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Help">
|
||||
<Group
|
||||
title="BOS Help"
|
||||
hint="breadhelp is the onboarding and help center — searchable guides, a keybind cheatsheet, a troubleshoot wizard, and a live tour. This panel just launches it; it doesn't copy the help app into Settings."
|
||||
>
|
||||
<button class="primary" onclick={() => invoke("open_breadhelp")}>Open breadhelp</button>
|
||||
</Group>
|
||||
|
||||
<Group title="First-run autostart" hint="The extra autostart entry in hypr/autostart.json (breadhelp --autostart). Core desktop launch is separate and always runs.">
|
||||
<SwitchField label="Show help on login" bind:value={() => autostartOn, (v) => setAutostart(v)} />
|
||||
{#if !helpEntry}
|
||||
<Hint text="No breadhelp entry in autostart.json yet — turning this on adds the default first-run command." />
|
||||
{/if}
|
||||
{#if status}
|
||||
<Hint text={status} />
|
||||
{/if}
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.primary {
|
||||
align-self: flex-start;
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import FileField from "$lib/components/FileField.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
interface BreadlockConfig {
|
||||
background_mode: string;
|
||||
background_path: string;
|
||||
background_blur: boolean;
|
||||
clock_format: string;
|
||||
font_family: string;
|
||||
fail_timeout_ms: number;
|
||||
}
|
||||
|
||||
let cfg = $state<BreadlockConfig | null>(null);
|
||||
let examplePath = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
cfg = await invoke<BreadlockConfig>("get_breadlock_config");
|
||||
examplePath = await invoke<string | null>("breadlock_example_path");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await invoke("save_breadlock_config", { cfg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Lock & greet">
|
||||
<Group
|
||||
title="How locking works"
|
||||
hint="This panel does not configure PAM. Authentication is the breadlock PAM service (/etc/pam.d/breadlock) plus greetd for login — both are packaged, not user-editable from Settings."
|
||||
>
|
||||
<Hint
|
||||
text="Super+L runs loginctl lock-session. hypridle's lock_cmd / idle listener then starts breadlock, which owns the already-running Hyprland session via ext-session-lock-v1."
|
||||
/>
|
||||
<Hint
|
||||
text="breadgreet is the graphical greetd greeter (replacing tuigreet). Its live config is typically /etc/greetd/breadgreet.toml, owned by the greeter user — not written from here."
|
||||
/>
|
||||
<button class="primary" onclick={() => invoke("lock_session")}>Lock now</button>
|
||||
</Group>
|
||||
|
||||
{#if cfg}
|
||||
<Group title="Lock screen" hint="~/.config/breadlock/breadlock.toml — every field is optional; breadlock runs with these defaults if the file is missing.">
|
||||
<SelectField label="Background" bind:value={cfg.background_mode} options={["color", "image"]} />
|
||||
{#if cfg.background_mode === "image"}
|
||||
<FileField label="Image" bind:value={cfg.background_path} placeholder="PNG, cover-fit" extensions={["png"]} />
|
||||
{/if}
|
||||
<SwitchField label="Blur background" bind:value={cfg.background_blur} />
|
||||
<Hint text="Blur is accepted in the file but not implemented yet (needs a wlr-screencopy capture). breadlock logs a warning and shows the background unblurred." />
|
||||
<TextField label="Clock format" bind:value={cfg.clock_format} placeholder="%H:%M" />
|
||||
<TextField label="Font" bind:value={cfg.font_family} placeholder="Varela Round" />
|
||||
<NumberField label="Wrong-password timeout (ms)" bind:value={cfg.fail_timeout_ms} min={0} max={10000} />
|
||||
<SaveButton onSave={save} />
|
||||
</Group>
|
||||
{/if}
|
||||
|
||||
<Group title="Files">
|
||||
<button class="secondary" onclick={() => invoke("open_breadlock_config")}>Open breadlock.toml in editor</button>
|
||||
{#if examplePath}
|
||||
<button class="secondary" onclick={() => invoke("open_breadlock_example")}>Open example ({examplePath})</button>
|
||||
{:else}
|
||||
<Hint text="No packaged breadlock.example.toml found. The schema is [background] mode/path/blur, [clock] format, [font] family, [input] fail_timeout_ms." />
|
||||
{/if}
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Monitors">
|
||||
<Group
|
||||
title="Two different jobs"
|
||||
hint="This is not a second Display editor. Display (monitors.json) is the login-time layout Hyprland itself reads. breadmon is a TUI for live arrange / mirror / named profiles."
|
||||
>
|
||||
<Hint
|
||||
text="Use Display when you want the persistent Hyprland rule set (output / mode / position / scale) that applies on next login or hyprctl reload."
|
||||
/>
|
||||
<Hint
|
||||
text="Use breadmon when you want to drag monitors around live, pick a common mirror mode, or save/load a profile under ~/.config/breadmon/profiles/. Settings never writes those profiles."
|
||||
/>
|
||||
<button class="primary" onclick={() => invoke("open_breadmon")}>Open breadmon</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.primary {
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import FileField from "$lib/components/FileField.svelte";
|
||||
import PasswordField from "$lib/components/PasswordField.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import TagsField from "$lib/components/TagsField.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
interface BreadpadConfig {
|
||||
default_type: string;
|
||||
workspace_tag: boolean;
|
||||
snooze_options: string[];
|
||||
archive_after_days: number;
|
||||
model_path: string;
|
||||
tokenizer_path: string;
|
||||
ollama_enabled: boolean;
|
||||
ollama_endpoint: string;
|
||||
ollama_model: string;
|
||||
ollama_confidence_threshold: number;
|
||||
reminders_default_morning: string;
|
||||
reminders_missed_grace_minutes: number;
|
||||
calendar_enabled: boolean;
|
||||
calendar_url: string;
|
||||
calendar_username: string;
|
||||
calendar_password: string;
|
||||
}
|
||||
|
||||
let cfg = $state<BreadpadConfig | null>(null);
|
||||
|
||||
// The confidence threshold is an f64 (0-1, step 0.05); binding a plain
|
||||
// number input directly to it shows raw float noise (e.g. "0.6000000")
|
||||
// once the value has been nudged by the spinner. Instead we mirror it
|
||||
// into a formatted display string, and only parse it back into `cfg` as
|
||||
// a value rounded to 2 decimals.
|
||||
let confidenceText = $state("0.6");
|
||||
let confidenceLoaded = false;
|
||||
|
||||
function formatConfidence(n: number): string {
|
||||
return (Math.round(n * 100) / 100).toString();
|
||||
}
|
||||
|
||||
function onConfidenceInput(e: Event) {
|
||||
confidenceText = (e.currentTarget as HTMLInputElement).value;
|
||||
const parsed = parseFloat(confidenceText);
|
||||
if (!Number.isNaN(parsed) && cfg) {
|
||||
cfg.ollama_confidence_threshold = Math.round(parsed * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
function onConfidenceBlur() {
|
||||
if (cfg) confidenceText = formatConfidence(cfg.ollama_confidence_threshold);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (cfg && !confidenceLoaded) {
|
||||
confidenceText = formatConfidence(cfg.ollama_confidence_threshold);
|
||||
confidenceLoaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
cfg = await invoke<BreadpadConfig>("get_breadpad_config");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await invoke("save_breadpad_config", { cfg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Notes">
|
||||
{#if cfg}
|
||||
<Group title="Capture">
|
||||
<SelectField label="Default note type" bind:value={cfg.default_type} options={["note", "reminder", "task"]} />
|
||||
<SwitchField label="Tag with active workspace" bind:value={cfg.workspace_tag} />
|
||||
<TagsField label="Snooze options" bind:value={cfg.snooze_options} />
|
||||
<NumberField label="Archive after (days)" bind:value={cfg.archive_after_days} min={0} max={3650} />
|
||||
</Group>
|
||||
|
||||
<Group title="Classifier model" hint="The local model breadpad uses to guess note vs. reminder vs. task — no network needed.">
|
||||
<FileField label="ONNX model" bind:value={cfg.model_path} placeholder="~/.local/share/breadpad/model/classifier.onnx" extensions={["onnx"]} />
|
||||
<FileField label="Tokenizer" bind:value={cfg.tokenizer_path} placeholder="~/.local/share/breadpad/model/tokenizer.json" extensions={["json"]} />
|
||||
</Group>
|
||||
|
||||
<Group title="Ollama (LLM classifier)" hint="Optional: ask a locally-running LLM (via Ollama) to classify notes instead of, or alongside, the ONNX model above.">
|
||||
<SwitchField label="Use Ollama" bind:value={cfg.ollama_enabled} />
|
||||
<TextField label="Endpoint" bind:value={cfg.ollama_endpoint} placeholder="http://localhost:11434" />
|
||||
<TextField label="Model" bind:value={cfg.ollama_model} placeholder="e.g. fastflowlm" />
|
||||
<Row label="Confidence threshold">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={confidenceText}
|
||||
oninput={onConfidenceInput}
|
||||
onblur={onConfidenceBlur}
|
||||
/>
|
||||
</Row>
|
||||
</Group>
|
||||
|
||||
<Group title="Reminders">
|
||||
<TextField label="Default morning time" bind:value={cfg.reminders_default_morning} placeholder="7:00" />
|
||||
<NumberField label="Missed grace (minutes)" bind:value={cfg.reminders_missed_grace_minutes} min={0} max={1440} step={5} />
|
||||
</Group>
|
||||
|
||||
<Group title="Calendar (CalDAV)">
|
||||
<SwitchField label="Sync to calendar" bind:value={cfg.calendar_enabled} />
|
||||
<TextField label="CalDAV URL" bind:value={cfg.calendar_url} placeholder="https://host/remote.php/dav/calendars/..." />
|
||||
<TextField label="Username" bind:value={cfg.calendar_username} />
|
||||
<PasswordField label="Password" bind:value={cfg.calendar_password} />
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
input[type="number"] {
|
||||
width: 10ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke, convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
|
||||
interface LibraryEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
let currentPath = $state<string | null>(null);
|
||||
let status = $state("");
|
||||
let libraryDir = $state("");
|
||||
let library = $state<LibraryEntry[] | null>(null);
|
||||
let scanning = $state(false);
|
||||
|
||||
async function refreshCurrent() {
|
||||
currentPath = await invoke<string | null>("get_current_wallpaper");
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
libraryDir = await invoke<string>("wallpaper_library_dir_display");
|
||||
await refreshCurrent();
|
||||
});
|
||||
|
||||
async function apply(path: string) {
|
||||
status = "Setting…";
|
||||
try {
|
||||
await invoke("set_wallpaper", { path });
|
||||
await refreshCurrent();
|
||||
status = "Wallpaper set";
|
||||
} catch (e) {
|
||||
status = `${e}`;
|
||||
} finally {
|
||||
setTimeout(() => (status = ""), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseImage() {
|
||||
const path = await open({
|
||||
title: "Choose a wallpaper",
|
||||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] }],
|
||||
});
|
||||
if (typeof path === "string") {
|
||||
await apply(path);
|
||||
}
|
||||
}
|
||||
|
||||
async function browseLibrary() {
|
||||
scanning = true;
|
||||
library = await invoke<LibraryEntry[]>("list_wallpaper_library");
|
||||
scanning = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Wallpaper">
|
||||
<Group
|
||||
title="Current wallpaper"
|
||||
hint="Sets the desktop wallpaper, generates a matching pywal palette, and reloads the shared bread-theme stylesheet — the wallpaper drives the whole desktop's accent colors."
|
||||
>
|
||||
<div class="preview-card">
|
||||
{#if currentPath}
|
||||
<img class="preview" src={convertFileSrc(currentPath)} alt="Current wallpaper" />
|
||||
<span class="path">{currentPath.split("/").pop()}</span>
|
||||
{:else}
|
||||
<div class="preview placeholder">No wallpaper set</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="choose" onclick={chooseImage}>Choose image…</button>
|
||||
<span class="status">{status}</span>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Library" wide>
|
||||
{#if library === null}
|
||||
<button class="browse" disabled={scanning} onclick={browseLibrary}>
|
||||
{scanning ? "Scanning…" : `Browse ${libraryDir}`}
|
||||
</button>
|
||||
{:else if library.length === 0}
|
||||
<div class="empty">Nothing under {libraryDir} — use Choose image… above instead.</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each library as item (item.path)}
|
||||
<button class="thumb" onclick={() => apply(item.path)} title={item.path}>
|
||||
<img src={convertFileSrc(item.path)} alt={item.name} loading="lazy" />
|
||||
<span class="name">{item.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.preview-card {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: block;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
}
|
||||
|
||||
.preview.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.path {
|
||||
display: block;
|
||||
margin-top: var(--space-xs, 4px);
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
justify-content: center;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.choose,
|
||||
.browse {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browse {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.browse:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 88px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-tertiary, 4px);
|
||||
}
|
||||
|
||||
.thumb .name {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import ServiceControl from "$lib/components/ServiceControl.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import PathListField from "$lib/components/PathListField.svelte";
|
||||
import TagsField from "$lib/components/TagsField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
const BACKEND_LABELS: Record<string, string> = {
|
||||
cpu: "CPU",
|
||||
npu: "NPU (AMD Ryzen AI)",
|
||||
rocm: "AMD GPU (ROCm)",
|
||||
cuda: "NVIDIA GPU (CUDA)",
|
||||
openvino: "Intel GPU (OpenVINO)",
|
||||
};
|
||||
|
||||
interface BreadsearchConfig {
|
||||
power_enabled: boolean;
|
||||
run_on_battery: boolean;
|
||||
backend: string;
|
||||
index_roots: string[];
|
||||
index_excludes: string[];
|
||||
index_extensions: string[];
|
||||
max_file_mb: number;
|
||||
search_limit: number;
|
||||
snippet_len: number;
|
||||
}
|
||||
|
||||
let cfg = $state<BreadsearchConfig | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
cfg = await invoke<BreadsearchConfig>("get_breadsearch_config");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await invoke("save_breadsearch_config", { cfg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="File Search">
|
||||
<ServiceControl unit="breadmill.service" hasConfig />
|
||||
|
||||
{#if cfg}
|
||||
<Group
|
||||
title="Power"
|
||||
hint="breadmill's embedding step is CPU/NPU/GPU-heavy. Turn it off entirely, or just pause it on battery — it resumes automatically on AC power."
|
||||
>
|
||||
<SwitchField label="Enabled" bind:value={cfg.power_enabled} />
|
||||
<SwitchField label="Index while on battery" bind:value={cfg.run_on_battery} />
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Model"
|
||||
hint="What hardware does the indexing. Check journalctl --user -u breadmill after restarting to confirm it registered."
|
||||
>
|
||||
<Row label="Search acceleration">
|
||||
<select bind:value={cfg.backend}>
|
||||
{#each Object.entries(BACKEND_LABELS) as [code, label] (code)}
|
||||
<option value={code}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
<NumberField label="Max file size (MB)" bind:value={cfg.max_file_mb} min={0.1} max={500} step={0.5} />
|
||||
</Group>
|
||||
|
||||
<Group title="Search">
|
||||
<NumberField label="Result limit" bind:value={cfg.search_limit} min={1} max={100} />
|
||||
<NumberField label="Snippet length" bind:value={cfg.snippet_len} min={20} max={2000} step={20} />
|
||||
</Group>
|
||||
|
||||
<Group title="Index" wide>
|
||||
<PathListField label="Folders to index" bind:value={cfg.index_roots} />
|
||||
<PathListField label="Excluded folders" bind:value={cfg.index_excludes} hint="Skipped even if inside an indexed folder above." />
|
||||
<TagsField label="File extensions" bind:value={cfg.index_extensions} />
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
select {
|
||||
color-scheme: dark;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import FileField from "$lib/components/FileField.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
interface ShotBind {
|
||||
shortcut: string;
|
||||
command: string;
|
||||
}
|
||||
interface BreadshotConfig {
|
||||
save_dir: string;
|
||||
silent: boolean;
|
||||
freeze: boolean;
|
||||
notif_timeout: number;
|
||||
date_format: string;
|
||||
}
|
||||
|
||||
let binds = $state<ShotBind[] | null>(null);
|
||||
let cfg = $state<BreadshotConfig | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
binds = await invoke<ShotBind[]>("get_breadshot_binds");
|
||||
cfg = await invoke<BreadshotConfig>("get_breadshot_config");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await invoke("save_breadshot_config", { cfg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Screenshots">
|
||||
<Group
|
||||
title="Keybinds"
|
||||
hint="Read-only from binds.json. Super+Shift+S/C/P is the BOS default (region→file / region→clipboard / screen→file). Change them on the Keybinds panel."
|
||||
>
|
||||
{#if binds && binds.length > 0}
|
||||
{#each binds as b (`${b.shortcut}:${b.command}`)}
|
||||
<InfoRow label={b.shortcut} value={b.command} />
|
||||
{/each}
|
||||
{:else}
|
||||
<Hint text="No breadshot binds found." />
|
||||
{/if}
|
||||
<button class="primary" onclick={() => invoke("breadshot_region_clipboard")}>Capture region to clipboard</button>
|
||||
</Group>
|
||||
|
||||
{#if cfg}
|
||||
<Group title="breadshot" hint="~/.config/breadshot/config.toml — grim + slurp + wl-copy, Hyprland-aware. All keys optional.">
|
||||
<FileField label="Save directory" bind:value={cfg.save_dir} placeholder="~/Pictures/Screenshots" mode="folder" />
|
||||
<SwitchField label="Silent (no notifications)" bind:value={cfg.silent} />
|
||||
<SwitchField label="Freeze screen during select" bind:value={cfg.freeze} />
|
||||
<Hint text="Freeze needs hyprpicker. Filenames are <date_format>_breadshot.png." />
|
||||
<NumberField label="Notification timeout (ms)" bind:value={cfg.notif_timeout} min={0} max={60000} />
|
||||
<TextField label="Filename date format" bind:value={cfg.date_format} placeholder="%Y-%m-%d-%H%M%S" />
|
||||
<SaveButton onSave={save} />
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.primary {
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
|
||||
interface BakeryTrack {
|
||||
current: string;
|
||||
tracks: string[];
|
||||
}
|
||||
|
||||
const BLURBS: Record<string, string> = {
|
||||
stable: "Last tagged release.",
|
||||
beta: "Latest release candidate (vX.Y.Z-rc.N).",
|
||||
dev: "Every push to main.",
|
||||
};
|
||||
|
||||
let track = $state<BakeryTrack | null>(null);
|
||||
let message = $state("");
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
track = await invoke<BakeryTrack>("get_bakery_track");
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function setTrack(name: string) {
|
||||
message = "";
|
||||
try {
|
||||
track = await invoke<BakeryTrack>("set_bakery_track", { track: name });
|
||||
message = `Now on ${name}. Run bakery update --all to install this track’s builds.`;
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("bakery_update_all", {}, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Bakery channel">
|
||||
<Group
|
||||
title="Track"
|
||||
hint="bakery track show / bakery track set. This only changes the preference — nothing is downloaded until you update."
|
||||
>
|
||||
{#if !track}
|
||||
<Hint text="Loading…" />
|
||||
{:else}
|
||||
<div class="tracks">
|
||||
{#each track.tracks as name (name)}
|
||||
<button class="track" class:on={track.current === name} onclick={() => setTrack(name)}>
|
||||
<strong>{name}</strong>
|
||||
<span>{BLURBS[name] ?? ""}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<Hint text={`Current track: ${track.current}`} />
|
||||
{/if}
|
||||
<button class="primary" disabled={busy} onclick={updateAll}>Update all on this track</button>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
background-color: var(--surface);
|
||||
color: inherit;
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.track span {
|
||||
opacity: 0.7;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.track.on {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
|
||||
interface DateTimeInfo {
|
||||
current_time: string;
|
||||
timezones: string[];
|
||||
current_tz: string;
|
||||
ntp_enabled: boolean;
|
||||
ntp_synced: boolean;
|
||||
}
|
||||
|
||||
let info = $state<DateTimeInfo | null>(null);
|
||||
let tzStatus = $state("");
|
||||
let ntpEnabled = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
info = await invoke<DateTimeInfo>("get_datetime_info");
|
||||
ntpEnabled = info.ntp_enabled;
|
||||
});
|
||||
|
||||
async function applyTimezone(tz: string) {
|
||||
tzStatus = "Applying…";
|
||||
try {
|
||||
await invoke("set_timezone", { tz });
|
||||
tzStatus = "Applied";
|
||||
} catch (e) {
|
||||
tzStatus = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNtp(enabled: boolean) {
|
||||
ntpEnabled = enabled;
|
||||
await invoke("set_ntp_enabled", { enabled });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Date & Time">
|
||||
{#if info}
|
||||
<Group title="Current time">
|
||||
<InfoRow label="Right now" value={info.current_time} />
|
||||
</Group>
|
||||
|
||||
<Group title="Timezone">
|
||||
{#if info.timezones.length === 0}
|
||||
<Hint text="Couldn't list timezones (is timedatectl available?)." />
|
||||
{:else}
|
||||
<Row label="Timezone">
|
||||
<select value={info.current_tz} onchange={(e) => applyTimezone(e.currentTarget.value)}>
|
||||
{#each info.timezones as tz (tz)}
|
||||
<option value={tz}>{tz}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
{#if tzStatus}
|
||||
<span class="status">{tzStatus}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Network time" hint={info.ntp_synced ? "Synchronized." : "Not synchronized yet (needs network)."}>
|
||||
<Row label="Synchronize automatically">
|
||||
<button
|
||||
class="switch"
|
||||
class:on={ntpEnabled}
|
||||
role="switch"
|
||||
aria-checked={ntpEnabled}
|
||||
aria-label="Synchronize automatically"
|
||||
onclick={() => toggleNtp(!ntpEnabled)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</Row>
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
select {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
interface DesktopApp {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface DefaultsStatus {
|
||||
path: string;
|
||||
current: Record<string, string>;
|
||||
options: Record<string, DesktopApp[]>;
|
||||
}
|
||||
|
||||
const CATEGORIES: { id: string; label: string }[] = [
|
||||
{ id: "browser", label: "Browser" },
|
||||
{ id: "files", label: "File manager" },
|
||||
{ id: "terminal", label: "Terminal" },
|
||||
{ id: "image", label: "Images" },
|
||||
{ id: "pdf", label: "PDF" },
|
||||
{ id: "editor", label: "Editor" },
|
||||
];
|
||||
|
||||
let st = $state<DefaultsStatus | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
st = await invoke<DefaultsStatus>("get_default_apps");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!st) return;
|
||||
await invoke("save_default_apps", { input: { current: st.current } });
|
||||
st = await invoke<DefaultsStatus>("get_default_apps");
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Default apps">
|
||||
<Group title="MIME associations" hint={st ? `Read and written at ${st.path}. Terminal also writes ~/.config/xdg-terminals.list.` : "Loading…"} wide>
|
||||
{#if st}
|
||||
{#each CATEGORIES as cat (cat.id)}
|
||||
<div class="field-row">
|
||||
<span class="label">{cat.label}</span>
|
||||
<select bind:value={st.current[cat.id]}>
|
||||
<option value="">—</option>
|
||||
{#each st.options[cat.id] ?? [] as app (app.id)}
|
||||
<option value={app.id}>{app.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/each}
|
||||
<SaveButton onSave={save} />
|
||||
{:else}
|
||||
<Hint text="Loading…" />
|
||||
{/if}
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg, 16px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
:global(.field-row + .field-row) {
|
||||
margin-top: calc(var(--space-sm, 8px) * -1);
|
||||
border-top: 1px solid var(--bg);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
:global(.field-row:has(+ .field-row)) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
select {
|
||||
color-scheme: dark;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
max-width: 28ch;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
interface LiveMonitor {
|
||||
name: string;
|
||||
mode: string;
|
||||
}
|
||||
interface MonitorRule {
|
||||
output: string;
|
||||
mode: string;
|
||||
position: string;
|
||||
scale: string;
|
||||
}
|
||||
|
||||
let liveMonitors = $state<LiveMonitor[] | null>(null);
|
||||
let rules = $state<MonitorRule[] | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
liveMonitors = await invoke<LiveMonitor[]>("get_live_monitors");
|
||||
rules = await invoke<MonitorRule[]>("get_monitor_rules");
|
||||
});
|
||||
|
||||
function addRule() {
|
||||
rules = [...(rules ?? []), { output: "", mode: "preferred", position: "auto", scale: "auto" }];
|
||||
}
|
||||
|
||||
function removeRule(i: number) {
|
||||
rules = rules!.filter((_, idx) => idx !== i);
|
||||
if (rules.length === 0) {
|
||||
rules = [{ output: "", mode: "preferred", position: "auto", scale: "auto" }];
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await invoke("save_monitor_rules", { rules });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Display">
|
||||
<Group title="Connected monitors">
|
||||
{#if liveMonitors && liveMonitors.length > 0}
|
||||
{#each liveMonitors as m (m.name)}
|
||||
<InfoRow label={m.name} value={m.mode} />
|
||||
{/each}
|
||||
{:else}
|
||||
<Hint text="No monitors detected (is Hyprland running?)" />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Advanced">
|
||||
<button class="secondary" onclick={() => invoke("open_hyprland_conf")}>Open hyprland.lua in editor</button>
|
||||
<button class="secondary" onclick={() => invoke("open_keybinds_viewer")}>View keybinds (breadhelp)</button>
|
||||
</Group>
|
||||
|
||||
{#if rules}
|
||||
<Group
|
||||
title="Layout"
|
||||
hint="One row per monitor rule. Leave Output blank to match any monitor (the default — works on any hardware). Applies on next login/reload."
|
||||
wide
|
||||
>
|
||||
{#each rules as rule, i (i)}
|
||||
<div class="rule-row">
|
||||
<label>Output <input type="text" bind:value={rule.output} placeholder="any (blank = all)" class="w-output" /></label>
|
||||
<label>Mode <input type="text" bind:value={rule.mode} placeholder="preferred / 1920x1080@60" class="w-mode" /></label>
|
||||
<label>Position <input type="text" bind:value={rule.position} placeholder="auto / 0x0" class="w-position" /></label>
|
||||
<label>Scale <input type="text" bind:value={rule.scale} placeholder="auto / 1" class="w-scale" /></label>
|
||||
<button class="remove" onclick={() => removeRule(i)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="add" onclick={addRule}>Add monitor rule</button>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.rule-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rule-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.w-output {
|
||||
width: 12ch;
|
||||
}
|
||||
.w-mode {
|
||||
width: 18ch;
|
||||
}
|
||||
.w-position {
|
||||
width: 10ch;
|
||||
}
|
||||
.w-scale {
|
||||
width: 8ch;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
align-self: flex-start;
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import Shield from "@lucide/svelte/icons/shield";
|
||||
import ShieldAlert from "@lucide/svelte/icons/shield-alert";
|
||||
|
||||
interface FirewallRule {
|
||||
number: string;
|
||||
text: string;
|
||||
}
|
||||
interface FirewallStatus {
|
||||
active: boolean;
|
||||
rules: FirewallRule[];
|
||||
}
|
||||
|
||||
let status = $state<FirewallStatus | "unloaded" | { error: string }>("unloaded");
|
||||
let enabledSensitive = $state(false);
|
||||
let newRule = $state("");
|
||||
let log = $state<string[]>([]);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
status = await invoke<FirewallStatus>("get_firewall_status");
|
||||
enabledSensitive = true;
|
||||
} catch (e) {
|
||||
status = { error: `${e}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(enabled: boolean) {
|
||||
if (typeof status !== "object" || !("active" in status)) return;
|
||||
log = [];
|
||||
try {
|
||||
await invoke("set_firewall_enabled", { enabled });
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function removeRule(number: string) {
|
||||
log = [];
|
||||
try {
|
||||
await invoke("remove_firewall_rule", { number });
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function addRule() {
|
||||
if (!newRule.trim()) return;
|
||||
log = [];
|
||||
try {
|
||||
await invoke("add_firewall_rule", { rule: newRule.trim() });
|
||||
newRule = "";
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Firewall">
|
||||
<Group
|
||||
title="Firewall"
|
||||
hint="Reading and changing firewall state needs your password (polkit) — ufw requires root even just to check status."
|
||||
>
|
||||
<Row label="Firewall enabled">
|
||||
<button
|
||||
class="switch"
|
||||
class:on={typeof status === "object" && "active" in status && status.active}
|
||||
disabled={!enabledSensitive}
|
||||
role="switch"
|
||||
aria-checked={typeof status === "object" && "active" in status && status.active}
|
||||
aria-label="Firewall enabled"
|
||||
onclick={() => toggleEnabled(!(typeof status === "object" && "active" in status && status.active))}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</Row>
|
||||
</Group>
|
||||
|
||||
<Group title="Add rule" hint={'e.g. "8080/tcp", "22/tcp", or a service name like "OpenSSH".'}>
|
||||
<div class="add-row">
|
||||
<input type="text" bind:value={newRule} placeholder="port/proto or service name" />
|
||||
<button class="allow" onclick={addRule}>Allow</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Rules" wide>
|
||||
<div class="list">
|
||||
{#if status === "unloaded"}
|
||||
<EmptyState icon={Shield} title="Status not loaded" hint="Click Refresh below to check the firewall's current state." />
|
||||
{:else if "error" in status}
|
||||
<EmptyState icon={ShieldAlert} title="Couldn't read firewall status" hint={status.error} />
|
||||
{:else if status.rules.length === 0}
|
||||
<EmptyState icon={Shield} title="No rules" hint="Only the default policy (deny incoming, allow outgoing) applies." />
|
||||
{:else}
|
||||
{#each status.rules as rule (rule.number)}
|
||||
<div class="rule-row">
|
||||
<span class="text">{rule.text}</span>
|
||||
<button class="remove" onclick={() => removeRule(rule.number)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="refresh" onclick={refresh}>Refresh status</button>
|
||||
</Group>
|
||||
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.switch:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
}
|
||||
|
||||
.list {
|
||||
min-height: 60px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rule-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
.refresh {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.add-row input {
|
||||
flex: 1;
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.allow {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
|
||||
interface FwDevice {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
let devices = $state<FwDevice[] | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
devices = await invoke<FwDevice[]>("get_updatable_firmware");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
function appendLine(line: string) {
|
||||
log = [...log, line];
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("fwupd_refresh", {}, appendLine);
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("fwupd_update", {}, appendLine);
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Firmware">
|
||||
<Group
|
||||
title="Updatable devices"
|
||||
hint="Firmware updates for hardware that supports them (UEFI, some peripherals). fwupd-refresh.timer already keeps update metadata current in the background."
|
||||
wide
|
||||
>
|
||||
<div class="list">
|
||||
{#if devices === null}
|
||||
<Hint text="Loading…" />
|
||||
{:else if devices.length === 0}
|
||||
<EmptyState icon={RefreshCw} title="No updatable firmware devices found" hint="Not every device supports firmware updates through fwupd." />
|
||||
{:else}
|
||||
{#each devices as dev (dev.name)}
|
||||
<div class="row">
|
||||
<span class="name" title={dev.name}>{dev.name}</span>
|
||||
<span class="version" title={dev.version}>{dev.version}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} onclick={checkForUpdates}>Check for updates</button>
|
||||
<button disabled={busy} class="primary" onclick={updateAll}>Update all</button>
|
||||
<button disabled={busy} onclick={refresh}>Refresh list</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version {
|
||||
opacity: 0.75;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
|
||||
interface ImePackage {
|
||||
name: string;
|
||||
installed: boolean;
|
||||
}
|
||||
interface ImeStatus {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
packages: ImePackage[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let st = $state<ImeStatus | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
let message = $state("");
|
||||
|
||||
const installSet = ["fcitx5-im", "fcitx5-gtk", "fcitx5-qt", "fcitx5-chinese-addons"];
|
||||
|
||||
async function refresh() {
|
||||
st = await invoke<ImeStatus>("get_ime_status");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function toggle(enabled: boolean) {
|
||||
message = "";
|
||||
try {
|
||||
st = await invoke<ImeStatus>("set_ime_enabled", { enabled });
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function installMissing() {
|
||||
const missing = st?.packages.filter((p) => !p.installed).map((p) => p.name) ?? [];
|
||||
const packages = ["fcitx5-im", ...missing.filter((n) => n !== "fcitx5")];
|
||||
const unique = [...new Set(packages.length ? packages : installSet)];
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("pacman_install", { packages: unique }, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
let missing = $derived(st?.packages.filter((p) => !p.installed) ?? []);
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Input method">
|
||||
<Group
|
||||
title="fcitx5"
|
||||
hint="Writes ~/.config/environment.d/90-fcitx5.conf and a Hyprland source fragment, then starts fcitx5 for this user. Already-running apps keep their old IM until you log out."
|
||||
>
|
||||
{#if !st}
|
||||
<Hint text="Loading…" />
|
||||
{:else}
|
||||
<div class="row-switch">
|
||||
<span>Enable fcitx5 for this session</span>
|
||||
<button
|
||||
class="switch"
|
||||
class:on={st.enabled}
|
||||
role="switch"
|
||||
aria-checked={st.enabled}
|
||||
aria-label="Enable fcitx5"
|
||||
onclick={() => toggle(!st!.enabled)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<Hint text={st.running ? "fcitx5 is running." : "fcitx5 is not running."} />
|
||||
<button onclick={() => invoke("open_fcitx_config")}>Open fcitx5 config</button>
|
||||
{/if}
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Packages" hint="fcitx5-im (group) plus GTK/Qt modules and a CJK table (fcitx5-chinese-addons).">
|
||||
{#if st}
|
||||
<ul>
|
||||
{#each st.packages as p (p.name)}
|
||||
<li class:missing={!p.installed}>{p.name}{p.installed ? "" : " — not installed"}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if missing.length}
|
||||
<button class="primary" disabled={busy} onclick={installMissing}>Install missing</button>
|
||||
{/if}
|
||||
</Group>
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.row-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
display: block;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0 0 var(--space-sm, 8px);
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.missing {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,653 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import Switch from "$lib/components/Switch.svelte";
|
||||
|
||||
// Mirrors src-tauri/src/commands/keybinds.rs's `Bind` — `action`/`key`/
|
||||
// `mods` are the only fields every bind has; everything else (`command`,
|
||||
// `direction`, `workspace`, `options`, breadhelp's `label`/`category`/
|
||||
// `demo_cmd`, ...) round-trips through serde's `#[serde(flatten)]` as
|
||||
// arbitrary sibling keys on the same JSON object.
|
||||
interface Bind {
|
||||
action: string;
|
||||
key?: string;
|
||||
mods?: string[];
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
interface BindsFile {
|
||||
active_layout: string;
|
||||
default_mods: string[];
|
||||
globals: Bind[];
|
||||
common: Bind[];
|
||||
layouts: Record<string, Bind[]>;
|
||||
bindings: Bind[];
|
||||
}
|
||||
type SchemaKind = "flat" | "multi_layout" | "unknown";
|
||||
interface BindsPayload {
|
||||
kind: SchemaKind;
|
||||
file: BindsFile;
|
||||
}
|
||||
|
||||
// Every action actually seen in real binds.json files (BOS's shipped
|
||||
// flat schema and this app's own dev MultiLayout config) — an `action`
|
||||
// dropdown beats free-typing a dispatcher name from memory. "Custom…"
|
||||
// keeps anything not in this list reachable without blocking on it.
|
||||
const KNOWN_ACTIONS = [
|
||||
"exec",
|
||||
"close",
|
||||
"fullscreen",
|
||||
"float",
|
||||
"pseudo",
|
||||
"resize",
|
||||
"focus",
|
||||
"focus_last",
|
||||
"move",
|
||||
"move_dir",
|
||||
"resize_dir",
|
||||
"drag",
|
||||
"layout",
|
||||
"exit",
|
||||
] as const;
|
||||
|
||||
// The editable, per-row UI state a bind gets flattened into on load and
|
||||
// reconstructed back into a `Bind` on save — same trade-off the GTK
|
||||
// version made with individual `Entry` widgets per field.
|
||||
interface EditRow {
|
||||
action: string;
|
||||
key: string;
|
||||
mods: string;
|
||||
// Whether `mods` has been explicitly set (present in the loaded JSON,
|
||||
// or touched by the user since). `None`/absent means "fall back to
|
||||
// default_mods"; `Some([])` — an explicitly *empty* mods list — means
|
||||
// "use no modifiers at all, even though default_mods exists" (real
|
||||
// BOS binds rely on this for e.g. bare media keys). Collapsing both
|
||||
// cases to "omit when empty" would silently turn an explicit
|
||||
// no-mods override back into "use the default" on next save.
|
||||
modsTouched: boolean;
|
||||
// The single source of truth for everything beyond action/key/mods
|
||||
// (command, direction, workspace, x/y, layout, options, breadhelp's
|
||||
// label/category/demo_cmd, ...). Dedicated per-action fields below
|
||||
// read/write specific keys of this object directly, so any sibling
|
||||
// key they don't know about (label/category/demo_cmd, or an action
|
||||
// shape this editor has no dedicated fields for) survives untouched.
|
||||
extraValue: Record<string, unknown>;
|
||||
// Raw-JSON view of `extraValue`, kept in sync both directions —
|
||||
// only actually shown when `advancedOpen` is true, or for an action
|
||||
// not in `KNOWN_ACTIONS` (nothing dedicated to show instead).
|
||||
extraText: string;
|
||||
extraError: boolean;
|
||||
advancedOpen: boolean;
|
||||
}
|
||||
|
||||
function syncExtraText(row: EditRow) {
|
||||
row.extraText = Object.keys(row.extraValue).length ? JSON.stringify(row.extraValue) : "";
|
||||
row.extraError = false;
|
||||
}
|
||||
|
||||
/** Mutates one top-level key of `row.extraValue` (deleting it when `value` is empty/undefined) and re-syncs the raw-JSON view. */
|
||||
function setExtra(row: EditRow, key: string, value: unknown) {
|
||||
if (value === undefined || value === "") {
|
||||
delete row.extraValue[key];
|
||||
} else {
|
||||
row.extraValue[key] = value;
|
||||
}
|
||||
syncExtraText(row);
|
||||
}
|
||||
|
||||
function getOption(row: EditRow, key: "locked" | "repeating"): boolean {
|
||||
const options = row.extraValue.options as Record<string, unknown> | undefined;
|
||||
return Boolean(options?.[key]);
|
||||
}
|
||||
function setOption(row: EditRow, key: "locked" | "repeating", value: boolean) {
|
||||
const options = { ...((row.extraValue.options as Record<string, unknown>) ?? {}) };
|
||||
if (value) options[key] = true;
|
||||
else delete options[key];
|
||||
if (Object.keys(options).length) row.extraValue.options = options;
|
||||
else delete row.extraValue.options;
|
||||
syncExtraText(row);
|
||||
}
|
||||
|
||||
// Hyprland workspace refs mix bare integers ("1") and relative tokens
|
||||
// ("e+1", "e-1") in the same field — keep whichever shape the user typed
|
||||
// instead of forcing everything through one type.
|
||||
function parseWorkspaceValue(s: string): string | number | undefined {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return /^-?\d+$/.test(trimmed) ? Number(trimmed) : trimmed;
|
||||
}
|
||||
|
||||
function modsToText(mods?: string[]): string {
|
||||
return (mods ?? []).join(", ");
|
||||
}
|
||||
function textToMods(s: string): string[] {
|
||||
return s
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
function bindToRow(b: Bind): EditRow {
|
||||
const { action, key, mods, ...extra } = b;
|
||||
const row: EditRow = {
|
||||
action: action ?? "",
|
||||
key: (key as string | undefined) ?? "",
|
||||
mods: modsToText(mods),
|
||||
modsTouched: mods !== undefined,
|
||||
extraValue: extra,
|
||||
extraText: "",
|
||||
extraError: false,
|
||||
advancedOpen: false,
|
||||
};
|
||||
syncExtraText(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function rowToBind(r: EditRow): Bind {
|
||||
// `drag` binds are only meaningful as a mouse bind — there's no
|
||||
// dedicated field for `options.mouse` (nothing to configure, it's
|
||||
// always true), so pin it here rather than exposing a checkbox
|
||||
// whose only correct state is "on".
|
||||
const extra = r.action === "drag" ? { ...r.extraValue, options: { ...(r.extraValue.options as object), mouse: true } } : r.extraValue;
|
||||
return {
|
||||
action: r.action,
|
||||
...(r.key.trim() ? { key: r.key.trim() } : {}),
|
||||
...(r.modsTouched ? { mods: textToMods(r.mods) } : {}),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function onExtraInput(row: EditRow, text: string) {
|
||||
row.extraText = text;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
row.extraValue = {};
|
||||
row.extraError = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row.extraValue = parsed;
|
||||
row.extraError = false;
|
||||
} else {
|
||||
row.extraError = true;
|
||||
}
|
||||
} catch {
|
||||
row.extraError = true;
|
||||
}
|
||||
}
|
||||
|
||||
function emptyRow(): EditRow {
|
||||
return {
|
||||
action: "exec",
|
||||
key: "",
|
||||
mods: "",
|
||||
modsTouched: false,
|
||||
extraValue: {},
|
||||
extraText: "",
|
||||
extraError: false,
|
||||
advancedOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
let loaded = $state(false);
|
||||
let kind = $state<SchemaKind>("unknown");
|
||||
let activeLayout = $state("");
|
||||
let defaultMods = $state("");
|
||||
let globalsRows = $state<EditRow[]>([]);
|
||||
let commonRows = $state<EditRow[]>([]);
|
||||
let bindingsRows = $state<EditRow[]>([]);
|
||||
let layoutRows = $state<Record<string, EditRow[]>>({});
|
||||
let layoutOrder = $state<string[]>([]);
|
||||
let newLayoutName = $state("");
|
||||
let loadError = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const p = await invoke<BindsPayload>("get_keybinds");
|
||||
kind = p.kind;
|
||||
// Rust's `#[serde(skip_serializing_if = ...)]` on every one of
|
||||
// these fields means an empty one is OMITTED from the JSON
|
||||
// entirely, not sent as `[]`/`{}`/`""` — every field here needs
|
||||
// a `??` fallback, not just the ones that are "usually" empty.
|
||||
activeLayout = p.file.active_layout ?? "";
|
||||
defaultMods = modsToText(p.file.default_mods ?? []);
|
||||
globalsRows = (p.file.globals ?? []).map(bindToRow);
|
||||
commonRows = (p.file.common ?? []).map(bindToRow);
|
||||
bindingsRows = (p.file.bindings ?? []).map(bindToRow);
|
||||
const layouts: Record<string, EditRow[]> = {};
|
||||
for (const [name, binds] of Object.entries(p.file.layouts ?? {})) layouts[name] = binds.map(bindToRow);
|
||||
layoutRows = layouts;
|
||||
layoutOrder = Object.keys(layouts);
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
loadError = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
function addLayout() {
|
||||
const name = newLayoutName.trim();
|
||||
if (!name || layoutOrder.includes(name)) return;
|
||||
layoutRows[name] = [];
|
||||
layoutOrder = [...layoutOrder, name];
|
||||
newLayoutName = "";
|
||||
}
|
||||
function removeLayout(name: string) {
|
||||
if (!confirm(`Remove layout "${name}"? Deletes every bind defined under this layout. This can't be undone here.`)) return;
|
||||
delete layoutRows[name];
|
||||
layoutOrder = layoutOrder.filter((n) => n !== name);
|
||||
if (activeLayout === name) activeLayout = layoutOrder[0] ?? "";
|
||||
}
|
||||
|
||||
// A row whose "extra" column currently holds text that doesn't parse as
|
||||
// JSON keeps its *last successfully parsed* value in `extraValue` (see
|
||||
// `onExtraInput`) rather than losing it on every keystroke while the
|
||||
// user is mid-edit — but that means saving while such a row is still
|
||||
// showing invalid/incomplete text would silently write that stale (or,
|
||||
// for a brand new row, empty) value instead of what's actually on
|
||||
// screen. Block save entirely until every row's extra JSON is valid, so
|
||||
// an in-progress edit can never be the thing that quietly drops a
|
||||
// bind's `command`/`direction`/`workspace`/etc.
|
||||
function hasInvalidExtraJson(): boolean {
|
||||
const all = [...globalsRows, ...commonRows, ...bindingsRows, ...Object.values(layoutRows).flat()];
|
||||
return all.some((r) => r.extraError);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (hasInvalidExtraJson()) {
|
||||
throw new Error("Fix the invalid JSON in the highlighted field(s) before saving.");
|
||||
}
|
||||
const file: BindsFile = {
|
||||
active_layout: activeLayout,
|
||||
default_mods: textToMods(defaultMods),
|
||||
globals: globalsRows.map(rowToBind),
|
||||
common: commonRows.map(rowToBind),
|
||||
layouts: Object.fromEntries(layoutOrder.map((name) => [name, (layoutRows[name] ?? []).map(rowToBind)])),
|
||||
bindings: bindingsRows.map(rowToBind),
|
||||
};
|
||||
await invoke("save_keybinds", { file, kind });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet extraFields(row: EditRow)}
|
||||
{#if row.action === "exec"}
|
||||
<input
|
||||
class="extra-wide"
|
||||
type="text"
|
||||
placeholder="Command to run"
|
||||
value={(row.extraValue.command as string) ?? ""}
|
||||
oninput={(e) => setExtra(row, "command", e.currentTarget.value)}
|
||||
/>
|
||||
<label class="mini-switch">
|
||||
<Switch
|
||||
ariaLabel="Repeat while held"
|
||||
bind:value={() => getOption(row, "repeating"), (v) => setOption(row, "repeating", v)}
|
||||
/>
|
||||
Repeat while held
|
||||
</label>
|
||||
<label class="mini-switch">
|
||||
<Switch
|
||||
ariaLabel="Ignore keyboard lock"
|
||||
bind:value={() => getOption(row, "locked"), (v) => setOption(row, "locked", v)}
|
||||
/>
|
||||
Works when locked
|
||||
</label>
|
||||
{:else if row.action === "focus" || row.action === "move"}
|
||||
<input
|
||||
class="extra-narrow"
|
||||
type="text"
|
||||
placeholder="Workspace (e.g. 3, e+1)"
|
||||
value={String(row.extraValue.workspace ?? "")}
|
||||
oninput={(e) => setExtra(row, "workspace", parseWorkspaceValue(e.currentTarget.value))}
|
||||
/>
|
||||
{:else if row.action === "move_dir"}
|
||||
<select
|
||||
class="extra-narrow"
|
||||
value={(row.extraValue.direction as string) ?? ""}
|
||||
onchange={(e) => setExtra(row, "direction", e.currentTarget.value || undefined)}
|
||||
>
|
||||
<option value="" disabled>Direction…</option>
|
||||
<option value="left">Left</option>
|
||||
<option value="right">Right</option>
|
||||
<option value="up">Up</option>
|
||||
<option value="down">Down</option>
|
||||
</select>
|
||||
{:else if row.action === "resize_dir"}
|
||||
<input
|
||||
class="extra-tiny"
|
||||
type="number"
|
||||
placeholder="x"
|
||||
value={row.extraValue.x !== undefined ? String(row.extraValue.x) : ""}
|
||||
oninput={(e) => setExtra(row, "x", e.currentTarget.value === "" ? undefined : Number(e.currentTarget.value))}
|
||||
/>
|
||||
<input
|
||||
class="extra-tiny"
|
||||
type="number"
|
||||
placeholder="y"
|
||||
value={row.extraValue.y !== undefined ? String(row.extraValue.y) : ""}
|
||||
oninput={(e) => setExtra(row, "y", e.currentTarget.value === "" ? undefined : Number(e.currentTarget.value))}
|
||||
/>
|
||||
<label class="mini-switch">
|
||||
<Switch
|
||||
ariaLabel="Repeat while held"
|
||||
bind:value={() => getOption(row, "repeating"), (v) => setOption(row, "repeating", v)}
|
||||
/>
|
||||
Repeat while held
|
||||
</label>
|
||||
{:else if row.action === "layout"}
|
||||
<input
|
||||
class="extra-narrow"
|
||||
type="text"
|
||||
placeholder="Layout command (e.g. togglesplit)"
|
||||
value={(row.extraValue.layout as string) ?? ""}
|
||||
oninput={(e) => setExtra(row, "layout", e.currentTarget.value)}
|
||||
/>
|
||||
{:else if row.action === "drag"}
|
||||
<span class="extra-note">Mouse-drag bind — nothing else to set.</span>
|
||||
{:else if row.action === "close" || row.action === "fullscreen" || row.action === "float" || row.action === "pseudo" || row.action === "resize" || row.action === "focus_last" || row.action === "exit"}
|
||||
<span class="extra-note">No extra options for this action.</span>
|
||||
{:else}
|
||||
<input
|
||||
class="extra-wide"
|
||||
class:error={row.extraError}
|
||||
type="text"
|
||||
placeholder={'{"command": "..."}'}
|
||||
value={row.extraText}
|
||||
oninput={(e) => onExtraInput(row, e.currentTarget.value)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) && row.action !== "drag"}
|
||||
<button type="button" class="advanced-toggle" onclick={() => (row.advancedOpen = !row.advancedOpen)}>
|
||||
{row.advancedOpen ? "Hide raw JSON" : "Advanced"}
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet section(rows: EditRow[], onAdd: () => void, onRemove: (i: number) => void)}
|
||||
<div class="rows">
|
||||
{#each rows as row, i (i)}
|
||||
<div class="bind-card">
|
||||
<div class="bind-top">
|
||||
<input
|
||||
class="mods"
|
||||
type="text"
|
||||
placeholder="SUPER, SHIFT"
|
||||
bind:value={row.mods}
|
||||
oninput={() => (row.modsTouched = true)}
|
||||
/>
|
||||
<input class="key" type="text" placeholder="key" bind:value={row.key} />
|
||||
<select
|
||||
class="action-select"
|
||||
value={KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) ? row.action : "__custom__"}
|
||||
onchange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
row.action = v === "__custom__" ? "" : v;
|
||||
}}
|
||||
>
|
||||
{#each KNOWN_ACTIONS as a (a)}
|
||||
<option value={a}>{a}</option>
|
||||
{/each}
|
||||
<option value="__custom__">Custom…</option>
|
||||
</select>
|
||||
{#if !KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number])}
|
||||
<input class="action-custom" type="text" placeholder="action name" bind:value={row.action} />
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => onRemove(i)}>Remove</button>
|
||||
</div>
|
||||
<div class="bind-bottom">
|
||||
{@render extraFields(row)}
|
||||
</div>
|
||||
{#if row.advancedOpen && KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) && row.action !== "drag"}
|
||||
<input
|
||||
class="extra-wide"
|
||||
class:error={row.extraError}
|
||||
type="text"
|
||||
placeholder={'{"command": "..."}'}
|
||||
value={row.extraText}
|
||||
oninput={(e) => onExtraInput(row, e.currentTarget.value)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="add" onclick={onAdd}>Add bind</button>
|
||||
{/snippet}
|
||||
|
||||
<ViewScaffold title="Keybinds">
|
||||
{#if loadError}
|
||||
<Group title="Failed to load" wide>
|
||||
<Hint text={loadError} />
|
||||
</Group>
|
||||
{/if}
|
||||
{#if loaded}
|
||||
{#if kind === "unknown"}
|
||||
<Group title="Keybinds" wide>
|
||||
<Hint
|
||||
text={`binds.json's schema wasn't recognized (expected a "bindings" key, or one of "globals"/"common"/"layouts"). Nothing below is editable, and Save is disabled, so the file on disk isn't at risk of being silently overwritten with the wrong shape. Fix or remove the file by hand, then reopen this panel.`}
|
||||
/>
|
||||
</Group>
|
||||
{:else if kind === "flat"}
|
||||
<Group
|
||||
title="Defaults"
|
||||
hint={`Mods/Key pick the shortcut; Action picks what it does. Choosing a known action (exec, move_dir, focus, ...) shows the fields it actually needs — e.g. a Command box for exec — instead of raw JSON. "Advanced" reveals the underlying JSON per bind for anything not covered (or breadhelp's label/category metadata). This machine's binds.json uses BOS's flat schema (no keyboard-layout switching), so that's all there is. Applies on next login/reload.`}
|
||||
>
|
||||
<TextField label="Default mods" bind:value={defaultMods} placeholder="SUPER" />
|
||||
</Group>
|
||||
|
||||
<Group title="Bindings" wide>
|
||||
{@render section(
|
||||
bindingsRows,
|
||||
() => bindingsRows.push(emptyRow()),
|
||||
(i) => bindingsRows.splice(i, 1)
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{:else}
|
||||
<Group
|
||||
title="Layout"
|
||||
hint={`Mods/Key pick the shortcut; Action picks what it does. Choosing a known action (exec, move_dir, focus, ...) shows the fields it actually needs — e.g. a Command box for exec — instead of raw JSON. "Advanced" reveals the underlying JSON per bind for anything not covered. Applies on next login/reload.`}
|
||||
>
|
||||
{#if layoutOrder.length > 0}
|
||||
<SelectField label="Active layout" bind:value={activeLayout} options={layoutOrder} />
|
||||
{/if}
|
||||
<TextField label="Default mods" bind:value={defaultMods} placeholder="SUPER" />
|
||||
</Group>
|
||||
|
||||
<Group title="Media & function keys (globals)" wide>
|
||||
{@render section(
|
||||
globalsRows,
|
||||
() => globalsRows.push(emptyRow()),
|
||||
(i) => globalsRows.splice(i, 1)
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group title="Common (every layout)" wide>
|
||||
{@render section(
|
||||
commonRows,
|
||||
() => commonRows.push(emptyRow()),
|
||||
(i) => commonRows.splice(i, 1)
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{#each layoutOrder as name (name)}
|
||||
<Group title={`Layout: ${name}`} wide>
|
||||
<button type="button" class="remove-layout" onclick={() => removeLayout(name)}>Remove layout</button>
|
||||
{@render section(
|
||||
layoutRows[name] ?? [],
|
||||
() => (layoutRows[name] ?? (layoutRows[name] = [])).push(emptyRow()),
|
||||
(i) => layoutRows[name]?.splice(i, 1)
|
||||
)}
|
||||
</Group>
|
||||
{/each}
|
||||
|
||||
<Group title="Add layout">
|
||||
<div class="add-layout-row">
|
||||
<input type="text" bind:value={newLayoutName} placeholder="new layout name" />
|
||||
<button type="button" onclick={addLayout}>Add layout</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.bind-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 4px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.bind-top,
|
||||
.bind-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bind-card input,
|
||||
.bind-card select {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.bind-card input:focus,
|
||||
.bind-card select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.bind-card input.error {
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
.mods {
|
||||
width: 14ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.key {
|
||||
width: 9ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-select {
|
||||
width: 11ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-custom {
|
||||
width: 11ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extra-wide {
|
||||
flex: 1;
|
||||
min-width: 16ch;
|
||||
font-family: monospace;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.extra-narrow {
|
||||
width: 20ch;
|
||||
}
|
||||
|
||||
.extra-tiny {
|
||||
width: 7ch;
|
||||
}
|
||||
|
||||
.extra-note {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.mini-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.advanced-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--on-surface);
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.advanced-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.remove-layout {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.add-layout-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.add-layout-row input {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.add-layout-row button {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import Wifi from "@lucide/svelte/icons/wifi";
|
||||
import WifiOff from "@lucide/svelte/icons/wifi-off";
|
||||
import Lock from "@lucide/svelte/icons/lock";
|
||||
|
||||
interface WifiNetwork {
|
||||
ssid: string;
|
||||
signal: number;
|
||||
secured: boolean;
|
||||
active: boolean;
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
let radioEnabled = $state(false);
|
||||
let ethernet = $state<string | null>(null);
|
||||
let networks = $state<WifiNetwork[] | null>(null);
|
||||
let scanning = $state(false);
|
||||
let status = $state("");
|
||||
let pendingSsid = $state<string | null>(null);
|
||||
let password = $state("");
|
||||
|
||||
// The backend reports the first `nmcli`-visible device of TYPE=ethernet,
|
||||
// which on a machine running containers/VMs can be a virtual interface
|
||||
// (docker/podman veth pairs, libvirt bridges, VPN tuns) rather than a
|
||||
// real NIC — nmcli doesn't distinguish these from physical ethernet.
|
||||
// Names like "vethYCF3ZA: unmanaged" are meaningless to a non-technical
|
||||
// user, so hide the card rather than show raw interface jargon.
|
||||
const VIRTUAL_IFACE_PREFIXES = ["veth", "docker", "br-", "virbr", "vnet", "tun", "tap", "vmnet", "podman"];
|
||||
const ETHERNET_STATE_LABELS: Record<string, string> = {
|
||||
connected: "Connected",
|
||||
connecting: "Connecting…",
|
||||
disconnected: "Not connected",
|
||||
disconnecting: "Disconnecting…",
|
||||
unavailable: "Not connected",
|
||||
unmanaged: "Not connected",
|
||||
};
|
||||
let ethernetLabel = $derived.by(() => {
|
||||
if (!ethernet) return null;
|
||||
const [iface, rawState] = ethernet.split(": ");
|
||||
if (!iface || VIRTUAL_IFACE_PREFIXES.some((prefix) => iface.startsWith(prefix))) return null;
|
||||
return ETHERNET_STATE_LABELS[rawState ?? ""] ?? "Not connected";
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const info = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info");
|
||||
radioEnabled = info.radio_enabled;
|
||||
ethernet = info.ethernet;
|
||||
});
|
||||
|
||||
async function toggleRadio(enabled: boolean) {
|
||||
radioEnabled = enabled;
|
||||
await invoke("set_wifi_radio", { enabled });
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
scanning = true;
|
||||
status = "Scanning…";
|
||||
networks = await invoke<WifiNetwork[]>("scan_wifi");
|
||||
status = `Found ${networks.length} network(s)`;
|
||||
scanning = false;
|
||||
}
|
||||
|
||||
async function connect(ssid: string, secured: boolean, known: boolean) {
|
||||
if (secured && !known) {
|
||||
pendingSsid = ssid;
|
||||
return;
|
||||
}
|
||||
status = `Connecting to ${ssid}…`;
|
||||
try {
|
||||
await invoke("connect_wifi", { ssid, password: null });
|
||||
status = `Connected to ${ssid}`;
|
||||
} catch (e) {
|
||||
status = `Failed to connect to ${ssid}: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWithPassword() {
|
||||
const ssid = pendingSsid!;
|
||||
const pw = password;
|
||||
pendingSsid = null;
|
||||
password = "";
|
||||
status = `Connecting to ${ssid}…`;
|
||||
try {
|
||||
await invoke("connect_wifi", { ssid, password: pw });
|
||||
status = `Connected to ${ssid}`;
|
||||
} catch {
|
||||
status = `Failed to connect to ${ssid}: wrong password?`;
|
||||
}
|
||||
}
|
||||
|
||||
function signalLabel(signal: number): string {
|
||||
return `${Math.min(100, Math.max(0, signal))}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Network">
|
||||
<Group title="Wi-Fi">
|
||||
<Row label="Wi-Fi radio">
|
||||
<button class="switch" class:on={radioEnabled} role="switch" aria-checked={radioEnabled} aria-label="Wi-Fi radio" onclick={() => toggleRadio(!radioEnabled)}>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</Row>
|
||||
</Group>
|
||||
|
||||
{#if ethernetLabel}
|
||||
<Group title="Ethernet">
|
||||
<InfoRow label="Status" value={ethernetLabel} />
|
||||
</Group>
|
||||
{/if}
|
||||
|
||||
<Group title="Available networks" wide>
|
||||
<div class="list">
|
||||
{#if networks === null}
|
||||
<div class="empty">
|
||||
<Wifi size={40} />
|
||||
<span>Not scanned yet</span>
|
||||
<span class="hint">Press Scan to see nearby networks.</span>
|
||||
</div>
|
||||
{:else if networks.length === 0}
|
||||
<div class="empty">
|
||||
<WifiOff size={40} />
|
||||
<span>No networks found</span>
|
||||
<span class="hint">Try Scan again, or check Wi-Fi radio is on.</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each networks as net (net.ssid)}
|
||||
<div class="net-row">
|
||||
<span class="ssid" class:active={net.active}>{net.ssid}{net.active ? " (connected)" : ""}</span>
|
||||
{#if net.secured}<Lock size={14} />{/if}
|
||||
<span class="signal">{signalLabel(net.signal)}</span>
|
||||
{#if !net.active}
|
||||
<button class="connect" onclick={() => connect(net.ssid, net.secured, net.known)}>Connect</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if pendingSsid}
|
||||
<div class="pw-row">
|
||||
<input type="password" bind:value={password} placeholder="Password" />
|
||||
<button class="connect" onclick={connectWithPassword}>Connect</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if status}
|
||||
<span class="status">{status}</span>
|
||||
{/if}
|
||||
|
||||
<button class="scan" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
|
||||
</Group>
|
||||
|
||||
<Group title="Advanced" hint="VPN, 802.1x, and static IP configuration aren't covered here.">
|
||||
<button class="secondary" onclick={() => invoke("open_connection_editor")}>Open connection editor</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
}
|
||||
|
||||
.list {
|
||||
min-height: 100px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--space-xl, 20px) 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.net-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.ssid {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ssid.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.signal {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.pw-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.pw-row input {
|
||||
flex: 1;
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
button.connect,
|
||||
button.scan,
|
||||
button.secondary {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
align-self: flex-start;
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
}
|
||||
|
||||
button.scan {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
|
||||
interface NightlightStatus {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
enabled: boolean;
|
||||
temperature: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let st = $state<NightlightStatus | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
let message = $state("");
|
||||
|
||||
async function refresh() {
|
||||
st = await invoke<NightlightStatus>("get_nightlight");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function apply(enabled: boolean) {
|
||||
if (!st) return;
|
||||
message = "";
|
||||
try {
|
||||
st = await invoke<NightlightStatus>("set_nightlight", {
|
||||
enabled,
|
||||
temperature: st.temperature,
|
||||
});
|
||||
if (st.error) message = st.error;
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function install() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("pacman_install", { packages: ["hyprsunset"] }, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Night light">
|
||||
<Group
|
||||
title="Hyprland twilight"
|
||||
hint="hyprsunset owns the compositor color filter. hyprctl hyprsunset talks to its socket — the binary has to be installed and running."
|
||||
>
|
||||
{#if !st}
|
||||
<Hint text="Loading…" />
|
||||
{:else if !st.installed}
|
||||
<Hint text="hyprsunset is not installed. Night light cannot run without it." />
|
||||
<button class="primary" disabled={busy} onclick={install}>Install hyprsunset</button>
|
||||
{:else}
|
||||
<div class="row-switch">
|
||||
<span>Night light</span>
|
||||
<button
|
||||
class="switch"
|
||||
class:on={st.enabled}
|
||||
role="switch"
|
||||
aria-checked={st.enabled}
|
||||
aria-label="Night light"
|
||||
onclick={() => apply(!st!.enabled)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<NumberField label="Temperature (K)" bind:value={st.temperature} min={2000} max={6500} step={100} />
|
||||
<button disabled={!st.enabled} onclick={() => apply(true)}>Apply temperature</button>
|
||||
<Hint text={st.running ? "hyprsunset is running." : "hyprsunset will start when you enable night light."} />
|
||||
{/if}
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.row-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
display: block;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
|
||||
interface OptionalItem {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
installed: boolean;
|
||||
via: string;
|
||||
}
|
||||
interface OptionalStatus {
|
||||
items: OptionalItem[];
|
||||
flathub: boolean;
|
||||
}
|
||||
|
||||
let st = $state<OptionalStatus | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
let message = $state("");
|
||||
|
||||
async function refresh() {
|
||||
st = await invoke<OptionalStatus>("get_optional_software");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function stream(command: string, args: Record<string, unknown> = {}) {
|
||||
log = [];
|
||||
busy = true;
|
||||
message = "";
|
||||
const ok = await runStreamed(command, args, (line) => {
|
||||
log = [...log, line];
|
||||
});
|
||||
busy = false;
|
||||
if (!ok) message = "Install failed — see the log.";
|
||||
await refresh();
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function install(id: string) {
|
||||
if (id === "breadcast") {
|
||||
await stream("bakery_install", { name: "breadcast" });
|
||||
return;
|
||||
}
|
||||
if (id === "flatpak") {
|
||||
const ok = await stream("pacman_install", { packages: ["flatpak"] });
|
||||
if (ok) {
|
||||
try {
|
||||
await invoke("enable_flathub");
|
||||
message = "Flathub user remote added.";
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id === "office") {
|
||||
await stream("pacman_install", { packages: ["libreoffice-fresh", "papers"] });
|
||||
return;
|
||||
}
|
||||
if (id === "steam") {
|
||||
await stream("pacman_install", { packages: ["steam"] });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Optional software">
|
||||
<Group
|
||||
title="Curated extras"
|
||||
hint="Not an AUR browser. breadcast is bakery-only and is not on the ISO. Pacman items use the allowlisted installer."
|
||||
wide
|
||||
>
|
||||
{#if !st}
|
||||
<Hint text="Loading…" />
|
||||
{:else}
|
||||
{#each st.items as item (item.id)}
|
||||
<div class="card">
|
||||
<div class="meta">
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.detail}</p>
|
||||
<span class="via">{item.via}{item.installed ? " · installed" : ""}</span>
|
||||
</div>
|
||||
{#if item.installed}
|
||||
<span class="done">Installed</span>
|
||||
{:else}
|
||||
<button class="primary" disabled={busy} onclick={() => install(item.id)}>Install</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta p {
|
||||
margin: 4px 0;
|
||||
opacity: 0.8;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.via,
|
||||
.done {
|
||||
opacity: 0.65;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import Package from "@lucide/svelte/icons/package";
|
||||
|
||||
interface InstalledPackage {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
let packages = $state<InstalledPackage[] | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
packages = await invoke<InstalledPackage[]>("get_installed_packages");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
function appendLine(line: string) {
|
||||
log = [...log, line];
|
||||
}
|
||||
|
||||
async function updatePackage(name: string) {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("bakery_update", { name }, appendLine);
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function listInstalled() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("bakery_list", {}, appendLine);
|
||||
busy = false;
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("bakery_update_all", {}, appendLine);
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function updateSystem() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed("pacman_system_update", {}, appendLine);
|
||||
busy = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Packages">
|
||||
<Group title="Bread ecosystem (bakery)" wide>
|
||||
<div class="list">
|
||||
{#if packages === null}
|
||||
<Hint text="Loading…" />
|
||||
{:else if packages.length === 0}
|
||||
<EmptyState icon={Package} title="No bakery packages found" hint="~/.local/state/bakery/installed.json is missing or empty." />
|
||||
{:else}
|
||||
{#each packages as pkg (pkg.name)}
|
||||
<div class="row">
|
||||
<span class="name" title={pkg.name}>{pkg.name}</span>
|
||||
<span class="version" title={pkg.version}>{pkg.version}</span>
|
||||
<button disabled={busy} onclick={() => updatePackage(pkg.name)}>Update</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} onclick={listInstalled}>List installed</button>
|
||||
<button disabled={busy} onclick={updateAll}>Update all</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="System packages (pacman)"
|
||||
hint="Base system, kernel, bos-settings, and republished AUR packages — the other half of what bos-update covers. Needs your password (polkit) since pacman requires root."
|
||||
>
|
||||
<button disabled={busy} onclick={updateSystem}>Update system (pacman -Syu)</button>
|
||||
</Group>
|
||||
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version {
|
||||
opacity: 0.75;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
|
||||
interface PowerInfo {
|
||||
battery: [string, string][];
|
||||
power_source: string;
|
||||
brightness_pct: number | null;
|
||||
charge_start: number | null;
|
||||
charge_end: number | null;
|
||||
tlp_profile: string | null;
|
||||
}
|
||||
|
||||
let info = $state<PowerInfo | null>(null);
|
||||
let brightness = $state(0);
|
||||
let chargeStart = $state(0);
|
||||
let chargeEnd = $state(100);
|
||||
|
||||
onMount(async () => {
|
||||
info = await invoke<PowerInfo>("get_power_info");
|
||||
brightness = info.brightness_pct ?? 0;
|
||||
chargeStart = info.charge_start ?? 0;
|
||||
chargeEnd = info.charge_end ?? 100;
|
||||
});
|
||||
|
||||
async function setBrightness(percent: number) {
|
||||
brightness = percent;
|
||||
await invoke("set_brightness", { percent });
|
||||
}
|
||||
|
||||
async function setChargeThreshold(which: "start" | "end", percent: number) {
|
||||
if (which === "start") chargeStart = percent;
|
||||
else chargeEnd = percent;
|
||||
await invoke("set_charge_threshold", { which, percent });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Power">
|
||||
{#if info}
|
||||
<Group title="Battery">
|
||||
{#each info.battery as [label, value] (label)}
|
||||
<InfoRow {label} {value} />
|
||||
{/each}
|
||||
<InfoRow label="Power source" value={info.power_source} />
|
||||
</Group>
|
||||
|
||||
<Group title="Brightness">
|
||||
{#if info.brightness_pct !== null}
|
||||
<Row label="Screen brightness">
|
||||
<input type="range" min="1" max="100" bind:value={brightness} onchange={() => setBrightness(brightness)} />
|
||||
<span class="pct">{brightness}%</span>
|
||||
</Row>
|
||||
{:else}
|
||||
<Hint text="No controllable backlight found." />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
{#if info.charge_start !== null && info.charge_end !== null}
|
||||
<Group
|
||||
title="Charge limits"
|
||||
hint="Some laptops let you cap charging below 100% to slow battery wear on a machine that's mostly plugged in."
|
||||
>
|
||||
<Row label="Start charging below (%)">
|
||||
<input type="number" min="0" max="100" bind:value={chargeStart} onchange={() => setChargeThreshold("start", chargeStart)} />
|
||||
</Row>
|
||||
<Row label="Stop charging at (%)">
|
||||
<input type="number" min="1" max="100" bind:value={chargeEnd} onchange={() => setChargeThreshold("end", chargeEnd)} />
|
||||
</Row>
|
||||
</Group>
|
||||
{/if}
|
||||
|
||||
<Group
|
||||
title="TLP"
|
||||
hint="TLP automatically applies a power-saving profile on battery and a performance profile on AC — there's no manual switch by design."
|
||||
>
|
||||
<InfoRow label="Current profile" value={info.tlp_profile ?? "unknown"} />
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
input[type="range"] {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 8ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.pct {
|
||||
margin-left: var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import Printer from "@lucide/svelte/icons/printer";
|
||||
|
||||
interface PrinterRow {
|
||||
name: string;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
is_default: boolean;
|
||||
}
|
||||
interface PrintingStatus {
|
||||
printers: PrinterRow[];
|
||||
default: string | null;
|
||||
cups_ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let status = $state<PrintingStatus | null>(null);
|
||||
let message = $state("");
|
||||
let newName = $state("");
|
||||
let newUri = $state("");
|
||||
|
||||
async function refresh() {
|
||||
status = await invoke<PrintingStatus>("get_printers");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function setDefault(name: string) {
|
||||
message = "";
|
||||
try {
|
||||
await invoke("set_default_printer", { name });
|
||||
message = `${name} is the default printer.`;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function addIpp() {
|
||||
message = "";
|
||||
try {
|
||||
await invoke("add_ipp_printer", { name: newName.trim(), uri: newUri.trim() });
|
||||
message = `Added ${newName.trim()}.`;
|
||||
newName = "";
|
||||
newUri = "";
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Printing">
|
||||
<Group title="Printers" hint="CUPS + Avahi ship on the ISO. Discovery and drivers for odd hardware live in system-config-printer." wide>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.error}
|
||||
<EmptyState icon={Printer} title="Couldn't talk to CUPS" hint={status.error} />
|
||||
{:else if status.printers.length === 0}
|
||||
<EmptyState icon={Printer} title="No printers yet" hint="Add one below, or open the CUPS printer wizard." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.printers as p (p.name)}
|
||||
<div class="row">
|
||||
<div class="meta">
|
||||
<span class="name">{p.name}{p.is_default ? " (default)" : ""}</span>
|
||||
<span class="status">{p.enabled ? p.status : "disabled"}</span>
|
||||
</div>
|
||||
<button disabled={p.is_default} onclick={() => setDefault(p.name)}>Set default</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button onclick={refresh}>Refresh</button>
|
||||
<button class="primary" onclick={() => invoke("open_printer_settings")}>Add printer…</button>
|
||||
</div>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="IPP Everywhere" hint="For a printer that already speaks IPP. Name must be letters, digits, dash, underscore.">
|
||||
<TextField label="Name" bind:value={newName} placeholder="Office" />
|
||||
<TextField label="URI" bind:value={newUri} placeholder="ipp://192.168.1.20/ipp/print" />
|
||||
<button disabled={!newName.trim() || !newUri.trim()} onclick={addIpp}>Add IPP printer</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.7;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import History from "@lucide/svelte/icons/history";
|
||||
import CircleAlert from "@lucide/svelte/icons/circle-alert";
|
||||
|
||||
interface SnapshotRow {
|
||||
number: string;
|
||||
date: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
let snapshots = $state<SnapshotRow[] | "loading">("loading");
|
||||
let errorHint = $state<string | null>(null);
|
||||
let selected = $state<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
errorHint = null;
|
||||
try {
|
||||
snapshots = await invoke<SnapshotRow[]>("get_snapshots");
|
||||
} catch (e) {
|
||||
const msg = `${e}`.toLowerCase();
|
||||
if (msg.includes("no permission")) {
|
||||
errorHint = "This user isn't allowed to run snapper. Check ALLOW_USERS in /etc/snapper/configs/root — it should list your username.";
|
||||
} else if (msg.includes("unknown config") || msg.includes("no such file")) {
|
||||
errorHint = "No snapper config exists for root yet, so nothing is being snapshotted. This should be set up automatically at install.";
|
||||
} else {
|
||||
errorHint = `${e}`;
|
||||
}
|
||||
snapshots = [];
|
||||
}
|
||||
selected = null;
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
function bootIntoSelected() {
|
||||
if (!selected) return;
|
||||
if (
|
||||
confirm(
|
||||
`Reboot to pick snapshot #${selected} in GRUB? BOS boots snapshots from the GRUB “BOS snapshots” submenu. This does not run snapper rollback.`,
|
||||
)
|
||||
) {
|
||||
invoke("reboot_system");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (!selected) return;
|
||||
if (!confirm(`Delete snapshot #${selected}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await invoke("delete_snapshot", { number: selected });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(`${e}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Snapshots">
|
||||
<Hint text="This is how you undo a bad update: reboot and pick the snapshot in GRUB (BOS snapshots)." />
|
||||
<Group
|
||||
title="What to pick in GRUB"
|
||||
hint="Number, date, and description match the GRUB “BOS snapshots” submenu. Reboot, then choose that entry. This page does not run snapper rollback."
|
||||
wide
|
||||
>
|
||||
<div class="list">
|
||||
{#if snapshots === "loading"}
|
||||
<Hint text="Loading…" />
|
||||
{:else if errorHint}
|
||||
<EmptyState icon={CircleAlert} title="Couldn't read snapshots" hint={errorHint} />
|
||||
{:else if snapshots.length === 0}
|
||||
<EmptyState icon={History} title="No snapshots yet" hint="Snapshots are created automatically on every pacman transaction." />
|
||||
{:else}
|
||||
<div class="row header" aria-hidden="true">
|
||||
<span class="number">#</span>
|
||||
<span class="date">Date</span>
|
||||
<span class="desc">Description</span>
|
||||
</div>
|
||||
{#each snapshots as snap (snap.number)}
|
||||
<button
|
||||
class="row"
|
||||
class:selected={selected === snap.number}
|
||||
onclick={() => (selected = snap.number)}
|
||||
>
|
||||
<span class="number">{snap.number}</span>
|
||||
<span class="date">{snap.date}</span>
|
||||
<span class="desc" title={snap.description}>{snap.description}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button onclick={refresh}>Refresh</button>
|
||||
<button disabled={!selected} onclick={bootIntoSelected}>Reboot to pick in GRUB</button>
|
||||
<button class="destructive" disabled={!selected} onclick={deleteSelected}>Delete selected</button>
|
||||
</div>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row.header {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.row:hover:not(.header) {
|
||||
background-color: color-mix(in srgb, var(--surface), var(--on-surface) 8%);
|
||||
}
|
||||
|
||||
.row.selected {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.number {
|
||||
width: 4ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.date {
|
||||
width: 22ch;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.desc {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.destructive {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
|
||||
interface SoundDevice {
|
||||
name: string;
|
||||
description: string;
|
||||
mute: boolean;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
interface DeviceSection {
|
||||
kind: "sinks" | "sources";
|
||||
title: string;
|
||||
devices: SoundDevice[];
|
||||
selected: number;
|
||||
}
|
||||
|
||||
let output = $state<DeviceSection | null>(null);
|
||||
let input = $state<DeviceSection | null>(null);
|
||||
|
||||
async function loadSection(kind: "sinks" | "sources", title: string): Promise<DeviceSection> {
|
||||
const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", { kind });
|
||||
const selected = Math.max(0, section.devices.findIndex((d) => d.name === section.default_name));
|
||||
return { kind, title, devices: section.devices, selected };
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
output = await loadSection("sinks", "Output");
|
||||
input = await loadSection("sources", "Input");
|
||||
});
|
||||
|
||||
async function selectDevice(section: DeviceSection, index: number) {
|
||||
section.selected = index;
|
||||
await invoke("set_default_sound_device", { kind: section.kind, name: section.devices[index].name });
|
||||
}
|
||||
|
||||
async function setVolume(section: DeviceSection, percent: number) {
|
||||
const device = section.devices[section.selected];
|
||||
device.percent = percent;
|
||||
await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent });
|
||||
}
|
||||
|
||||
async function setMute(section: DeviceSection, mute: boolean) {
|
||||
const device = section.devices[section.selected];
|
||||
device.mute = mute;
|
||||
await invoke("set_sound_mute", { kind: section.kind, name: device.name, mute });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet deviceSection(section: DeviceSection | null)}
|
||||
{#if section}
|
||||
<Group title={section.title}>
|
||||
{#if section.devices.length === 0}
|
||||
<Hint text="No devices found." />
|
||||
{:else}
|
||||
<Row label="Device">
|
||||
<select
|
||||
value={section.selected}
|
||||
onchange={(e) => selectDevice(section, Number(e.currentTarget.value))}
|
||||
>
|
||||
{#each section.devices as d, i (d.name)}
|
||||
<option value={i}>{d.description}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
<Row label="Volume">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="150"
|
||||
value={section.devices[section.selected].percent}
|
||||
oninput={(e) => setVolume(section, Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="pct">{section.devices[section.selected].percent}%</span>
|
||||
</Row>
|
||||
<SwitchField
|
||||
label="Mute"
|
||||
bind:value={
|
||||
() => section.devices[section.selected].mute,
|
||||
(v) => setMute(section, v)
|
||||
}
|
||||
/>
|
||||
{/if}
|
||||
</Group>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<ViewScaffold title="Sound">
|
||||
{@render deviceSection(output)}
|
||||
{@render deviceSection(input)}
|
||||
|
||||
<Group title="Advanced" hint="Per-app volume, port selection, and profile switching aren't covered here.">
|
||||
<button class="secondary" onclick={() => invoke("open_mixer")}>Open advanced mixer (pavucontrol)</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
select {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.pct {
|
||||
margin-left: var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamed } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import { NAVIGATE_KEY, type Navigate } from "$lib/nav";
|
||||
import Download from "@lucide/svelte/icons/download";
|
||||
import Cpu from "@lucide/svelte/icons/cpu";
|
||||
|
||||
const navigate = getContext<Navigate | undefined>(NAVIGATE_KEY);
|
||||
|
||||
interface PendingUpdate {
|
||||
name: string;
|
||||
current: string;
|
||||
latest: string;
|
||||
}
|
||||
interface FwDevice {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
interface NvidiaOffer {
|
||||
gpu: string;
|
||||
reason: string;
|
||||
packages: string[];
|
||||
installed: boolean;
|
||||
}
|
||||
interface UpdatesStatus {
|
||||
pacman: PendingUpdate[];
|
||||
pacman_error: string | null;
|
||||
bakery: PendingUpdate[];
|
||||
bakery_error: string | null;
|
||||
firmware: FwDevice[];
|
||||
nvidia: NvidiaOffer | null;
|
||||
}
|
||||
|
||||
let status = $state<UpdatesStatus | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
status = await invoke<UpdatesStatus>("get_updates_status");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
function appendLine(line: string) {
|
||||
log = [...log, line];
|
||||
}
|
||||
|
||||
async function run(command: string, args: Record<string, unknown> = {}) {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamed(command, args, appendLine);
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Updates">
|
||||
{#if status?.nvidia}
|
||||
<Group title="NVIDIA driver" wide>
|
||||
<div class="offer">
|
||||
<Cpu size={20} />
|
||||
<div class="offer-text">
|
||||
<strong>{status.nvidia.gpu}</strong>
|
||||
<p>{status.nvidia.reason}</p>
|
||||
{#if status.nvidia.installed}
|
||||
<Hint text="Driver and Hyprland env drop-in are in place. Reboot to start a working session." />
|
||||
{:else}
|
||||
<Hint text="Installs nvidia + nvidia-utils (not cuda) and writes ~/.config/hypr/nvidia.lua. hyprland.lua loads that file only if it exists. Reboot after." />
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="primary"
|
||||
disabled={busy}
|
||||
onclick={() => run("nvidia_setup")}
|
||||
>
|
||||
{status.nvidia.installed ? "Re-run setup" : "Install driver"}
|
||||
</button>
|
||||
</div>
|
||||
</Group>
|
||||
{/if}
|
||||
|
||||
<Group
|
||||
title="System packages (pacman)"
|
||||
hint="The same set Packages covers with pacman -Syu. Needs your password (polkit)."
|
||||
wide
|
||||
>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.pacman_error}
|
||||
<Hint text={status.pacman_error} />
|
||||
{:else if status.pacman.length === 0}
|
||||
<EmptyState icon={Download} title="Pacman is up to date" hint="No pending official-repo upgrades." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.pacman as pkg (pkg.name)}
|
||||
<div class="row">
|
||||
<span class="name">{pkg.name}</span>
|
||||
<span class="version">{pkg.current} → {pkg.latest}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} class="primary" onclick={() => run("pacman_system_update")}>Update system</button>
|
||||
<button disabled={busy} onclick={refresh}>Refresh</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Bread ecosystem (bakery)"
|
||||
hint="bakery --dry-run update --all — bakery has no separate outdated command. Per-package install still lives on Packages."
|
||||
wide
|
||||
>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.bakery_error}
|
||||
<Hint text={status.bakery_error} />
|
||||
{:else if status.bakery.length === 0}
|
||||
<EmptyState icon={Download} title="Bakery packages are current" hint="Nothing on this track wants an update." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.bakery as pkg (pkg.name)}
|
||||
<div class="row">
|
||||
<span class="name">{pkg.name}</span>
|
||||
<span class="version">{pkg.current ? `${pkg.current} → ` : ""}{pkg.latest}</span>
|
||||
<button disabled={busy} onclick={() => run("bakery_update", { name: pkg.name })}>Update</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} class="primary" onclick={() => run("bakery_update_all")}>Update all bakery</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Firmware (fwupd)" hint="Same list as the Firmware page. fwupd-refresh.timer already refreshes metadata in the background." wide>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.firmware.length === 0}
|
||||
<EmptyState icon={Download} title="No updatable firmware" hint="Not every device speaks fwupd." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.firmware as dev (dev.name)}
|
||||
<div class="row">
|
||||
<span class="name">{dev.name}</span>
|
||||
<span class="version">{dev.version}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} onclick={() => run("fwupd_refresh")}>Check for updates</button>
|
||||
<button disabled={busy} class="primary" onclick={() => run("fwupd_update")}>Update firmware</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Rollback"
|
||||
hint="Boot a snapshot from GRUB (BOS snapshots)."
|
||||
>
|
||||
<Hint text="Snapshots lists number, date, and description so you know which GRUB entry to pick. snapper rollback will not change what GRUB boots (rootflags=subvol=@)." />
|
||||
<Hint text="Bakery also has bakery rollback <pkg> for a single ecosystem binary — that is not a system rollback." />
|
||||
<div class="btn-row">
|
||||
<button disabled={!navigate} onclick={() => navigate?.("snapshots")}>Open Snapshots</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.offer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.offer-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.offer-text p {
|
||||
margin: 4px 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version {
|
||||
opacity: 0.75;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
|
||||
interface Account {
|
||||
username: string;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
let accounts = $state<Account[] | null>(null);
|
||||
let currentUser = $state("");
|
||||
let openPasswordFor = $state<string | null>(null);
|
||||
let passwordInput = $state("");
|
||||
let rowStatus = $state<Record<string, string>>({});
|
||||
|
||||
let newUsername = $state("");
|
||||
let newFullName = $state("");
|
||||
let newPassword = $state("");
|
||||
let addStatus = $state("");
|
||||
|
||||
async function refresh() {
|
||||
const info = await invoke<{ accounts: Account[]; current_user: string }>("get_users_info");
|
||||
accounts = info.accounts;
|
||||
currentUser = info.current_user;
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function applyPassword(username: string) {
|
||||
if (!passwordInput) return;
|
||||
rowStatus = { ...rowStatus, [username]: "Applying…" };
|
||||
try {
|
||||
await invoke("change_password", { username, password: passwordInput });
|
||||
rowStatus = { ...rowStatus, [username]: "Password changed" };
|
||||
openPasswordFor = null;
|
||||
passwordInput = "";
|
||||
} catch {
|
||||
rowStatus = { ...rowStatus, [username]: "Failed to change password" };
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAccount(username: string) {
|
||||
if (!confirm(`Remove user ${username}? Deletes the account and its home directory. This cannot be undone.`)) return;
|
||||
await invoke("remove_user", { username });
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
if (!newUsername.trim() || !newPassword) {
|
||||
addStatus = "Username and password are required.";
|
||||
return;
|
||||
}
|
||||
addStatus = "Adding…";
|
||||
try {
|
||||
await invoke("add_user", { username: newUsername.trim(), fullName: newFullName.trim(), password: newPassword });
|
||||
addStatus = "User added.";
|
||||
newUsername = "";
|
||||
newFullName = "";
|
||||
newPassword = "";
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
addStatus = `${e}`;
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Users">
|
||||
<Group title="Accounts" hint="Real login accounts on this machine (system/service accounts aren't shown). Your own account can't be removed from here." wide>
|
||||
<div class="list">
|
||||
{#if accounts}
|
||||
{#each accounts as acc (acc.username)}
|
||||
<div class="card">
|
||||
<div class="top">
|
||||
<span class="name">{acc.username}{acc.full_name ? ` (${acc.full_name})` : ""}</span>
|
||||
<button class="action" onclick={() => (openPasswordFor = openPasswordFor === acc.username ? null : acc.username)}>
|
||||
Change password
|
||||
</button>
|
||||
<button class="remove" disabled={acc.username === currentUser} onclick={() => removeAccount(acc.username)}>Remove</button>
|
||||
</div>
|
||||
{#if openPasswordFor === acc.username}
|
||||
<div class="pw-row">
|
||||
<input type="password" bind:value={passwordInput} placeholder="New password" />
|
||||
<button class="action" onclick={() => applyPassword(acc.username)}>Apply</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if rowStatus[acc.username]}
|
||||
<span class="status">{rowStatus[acc.username]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Add user">
|
||||
<Row label="Username">
|
||||
<input type="text" bind:value={newUsername} placeholder="username" />
|
||||
</Row>
|
||||
<Row label="Full name">
|
||||
<input type="text" bind:value={newFullName} placeholder="Full name (optional)" />
|
||||
</Row>
|
||||
<Row label="Password">
|
||||
<input type="password" bind:value={newPassword} placeholder="password" />
|
||||
</Row>
|
||||
<button class="add" onclick={addUser}>Add user</button>
|
||||
{#if addStatus}
|
||||
<span class="status">{addStatus}</span>
|
||||
{/if}
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pw-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.pw-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
.remove:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import FileField from "$lib/components/FileField.svelte";
|
||||
import ShieldEllipsis from "@lucide/svelte/icons/shield-ellipsis";
|
||||
|
||||
interface VpnConnection {
|
||||
name: string;
|
||||
kind: string;
|
||||
active: boolean;
|
||||
autoconnect: boolean;
|
||||
}
|
||||
interface VpnStatus {
|
||||
connections: VpnConnection[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
let status = $state<VpnStatus | null>(null);
|
||||
let importPath = $state("");
|
||||
let message = $state("");
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
status = await invoke<VpnStatus>("get_vpn_connections");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function connect(name: string, up: boolean) {
|
||||
busy = true;
|
||||
message = "";
|
||||
try {
|
||||
await invoke(up ? "vpn_connect" : "vpn_disconnect", { name });
|
||||
message = up ? `Connected ${name}` : `Disconnected ${name}`;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
if (!importPath.trim()) return;
|
||||
busy = true;
|
||||
message = "";
|
||||
try {
|
||||
await invoke("vpn_import", { path: importPath.trim() });
|
||||
message = "Imported. Connect it from the list.";
|
||||
importPath = "";
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
message = `${e}`;
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="VPN / WireGuard">
|
||||
<Group
|
||||
title="NetworkManager tunnels"
|
||||
hint="Lists connection type vpn and wireguard only. Wi-Fi profiles stay on breadcrumbs; Tailscale is not managed here."
|
||||
wide
|
||||
>
|
||||
{#if !status}
|
||||
<Hint text="Loading…" />
|
||||
{:else if status.error}
|
||||
<EmptyState icon={ShieldEllipsis} title="Couldn't list connections" hint={status.error} />
|
||||
{:else if status.connections.length === 0}
|
||||
<EmptyState icon={ShieldEllipsis} title="No VPN or WireGuard connections" hint="Import a .conf below, or use nm-connection-editor from Network." />
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each status.connections as c (c.name)}
|
||||
<div class="row">
|
||||
<div class="meta">
|
||||
<span class="name">{c.name}</span>
|
||||
<span class="kind">{c.kind}{c.active ? " · connected" : ""}{c.autoconnect ? " · autoconnect" : ""}</span>
|
||||
</div>
|
||||
{#if c.active}
|
||||
<button disabled={busy} onclick={() => connect(c.name, false)}>Disconnect</button>
|
||||
{:else}
|
||||
<button class="primary" disabled={busy} onclick={() => connect(c.name, true)}>Connect</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={refresh}>Refresh</button>
|
||||
{#if message}<Hint text={message} />{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Import" hint="WireGuard .conf or OpenVPN .ovpn. NetworkManager stores the secret after import.">
|
||||
<FileField label="Config" bind:value={importPath} placeholder="wg0.conf" extensions={["conf", "ovpn"]} />
|
||||
<button class="primary" disabled={busy || !importPath.trim()} onclick={doImport}>Import</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kind {
|
||||
opacity: 0.7;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
// Maps a sidebar page id to its view component. Every sidebar id has a
|
||||
// real view — +page.svelte's Placeholder is only a safety net for typos.
|
||||
|
||||
import type { Component } from "svelte";
|
||||
import About from "./About.svelte";
|
||||
import Breadclip from "./Breadclip.svelte";
|
||||
import Bread from "./Bread.svelte";
|
||||
import Breadbar from "./Breadbar.svelte";
|
||||
import Breadbox from "./Breadbox.svelte";
|
||||
import Breadpad from "./Breadpad.svelte";
|
||||
import Breadpaper from "./Breadpaper.svelte";
|
||||
import Breadsearch from "./Breadsearch.svelte";
|
||||
import Breadcrumbs from "./Breadcrumbs.svelte";
|
||||
import Appearance from "./Appearance.svelte";
|
||||
import Autostart from "./Autostart.svelte";
|
||||
import Display from "./Display.svelte";
|
||||
import Keybinds from "./Keybinds.svelte";
|
||||
import Sound from "./Sound.svelte";
|
||||
import DateTime from "./DateTime.svelte";
|
||||
import Power from "./Power.svelte";
|
||||
import Network from "./Network.svelte";
|
||||
import Bluetooth from "./Bluetooth.svelte";
|
||||
import Firewall from "./Firewall.svelte";
|
||||
import Users from "./Users.svelte";
|
||||
import Packages from "./Packages.svelte";
|
||||
import Aur from "./Aur.svelte";
|
||||
import Firmware from "./Firmware.svelte";
|
||||
import Snapshots from "./Snapshots.svelte";
|
||||
import Breadlock from "./Breadlock.svelte";
|
||||
import Breadshot from "./Breadshot.svelte";
|
||||
import Breadmon from "./Breadmon.svelte";
|
||||
import Breadhelp from "./Breadhelp.svelte";
|
||||
import Updates from "./Updates.svelte";
|
||||
import Printing from "./Printing.svelte";
|
||||
import Vpn from "./Vpn.svelte";
|
||||
import NightLight from "./NightLight.svelte";
|
||||
import InputMethod from "./InputMethod.svelte";
|
||||
import Accessibility from "./Accessibility.svelte";
|
||||
import Defaults from "./Defaults.svelte";
|
||||
import Channel from "./Channel.svelte";
|
||||
import Backup from "./Backup.svelte";
|
||||
import Optional from "./Optional.svelte";
|
||||
|
||||
export const VIEWS: Record<string, Component> = {
|
||||
about: About,
|
||||
breadclip: Breadclip,
|
||||
bread: Bread,
|
||||
breadbar: Breadbar,
|
||||
breadbox: Breadbox,
|
||||
breadpad: Breadpad,
|
||||
breadpaper: Breadpaper,
|
||||
breadsearch: Breadsearch,
|
||||
breadcrumbs: Breadcrumbs,
|
||||
appearance: Appearance,
|
||||
autostart: Autostart,
|
||||
hyprland: Display,
|
||||
keybinds: Keybinds,
|
||||
sound: Sound,
|
||||
datetime: DateTime,
|
||||
power: Power,
|
||||
network: Network,
|
||||
bluetooth: Bluetooth,
|
||||
firewall: Firewall,
|
||||
users: Users,
|
||||
packages: Packages,
|
||||
aur: Aur,
|
||||
firmware: Firmware,
|
||||
snapshots: Snapshots,
|
||||
breadlock: Breadlock,
|
||||
breadshot: Breadshot,
|
||||
breadmon: Breadmon,
|
||||
breadhelp: Breadhelp,
|
||||
updates: Updates,
|
||||
printing: Printing,
|
||||
vpn: Vpn,
|
||||
nightlight: NightLight,
|
||||
ime: InputMethod,
|
||||
accessibility: Accessibility,
|
||||
defaults: Defaults,
|
||||
channel: Channel,
|
||||
backup: Backup,
|
||||
optional: Optional,
|
||||
};
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount, setContext } from "svelte";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Sidebar from "$lib/components/Sidebar.svelte";
|
||||
import Placeholder from "$lib/components/Placeholder.svelte";
|
||||
import { DEFAULT_PAGE } from "$lib/sidebar";
|
||||
import { NAVIGATE_KEY, type Navigate } from "$lib/nav";
|
||||
import { initTheme } from "$lib/theme";
|
||||
import { VIEWS } from "$lib/views/registry";
|
||||
|
||||
let activePage = $state(DEFAULT_PAGE);
|
||||
let ActiveView = $derived(VIEWS[activePage]);
|
||||
setContext<Navigate>(NAVIGATE_KEY, (page) => {
|
||||
activePage = page;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
initTheme();
|
||||
// Screenshot mode only (src-tauri's screenshot.rs) — lets the Rust
|
||||
// core drive which sidebar section is showing for a capture without
|
||||
// a real user ever clicking the sidebar.
|
||||
const unlisten = listen<string>("screenshot-set-view", (event) => {
|
||||
activePage = event.payload;
|
||||
});
|
||||
return () => {
|
||||
unlisten.then((f) => f());
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<Sidebar bind:activePage />
|
||||
<main class="content">
|
||||
{#if ActiveView}
|
||||
<ActiveView />
|
||||
{:else}
|
||||
<Placeholder page={activePage} />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(html, body) {
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
height: 100%;
|
||||
color-scheme: dark;
|
||||
background-color: var(--bg, #0c0c0c);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-family, sans-serif);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
}
|
||||
|
||||
:global(#svelte) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: var(--bg, #0c0c0c);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,18 +0,0 @@
|
|||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// The Rust/Tauri project dir (../src relative to this file) — an absolute
|
||||
// path, not a bare "**/src/**" glob, because this repo has two directories
|
||||
// named `src`: this one (frontend/src, which must be watched for HMR) and
|
||||
// the sibling Rust crate (../src, which must not be).
|
||||
const rustProjectDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../src");
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [sveltekit()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching the Tauri/Rust project dir
|
||||
ignored: [`${rustProjectDir}/**`],
|
||||
},
|
||||
},
|
||||
}));
|
||||
38
packaging/PKGBUILD
Normal file
38
packaging/PKGBUILD
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Maintainer: Breadway <plasticbread849@gmail.com>
|
||||
|
||||
pkgname=bos-settings
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="System settings app for Bread OS"
|
||||
arch=('x86_64')
|
||||
url="https://git.breadway.dev/Breadway/bos-settings"
|
||||
license=('MIT')
|
||||
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
|
||||
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
|
||||
# causing undefined-symbol errors. Disable LTO.
|
||||
options=(!lto !debug)
|
||||
depends=('gtk4' 'glib2' 'hicolor-icon-theme')
|
||||
optdepends=(
|
||||
'snapper: snapshot management view'
|
||||
)
|
||||
makedepends=('rust' 'cargo')
|
||||
source=("${pkgname}-${pkgver}.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
cargo build --release --locked
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
cargo test --release --locked
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
install -Dm755 target/release/bos-settings "${pkgdir}/usr/bin/bos-settings"
|
||||
install -Dm644 packaging/bos-settings.desktop \
|
||||
"${pkgdir}/usr/share/applications/bos-settings.desktop"
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
25
packaging/README.md
Normal file
25
packaging/README.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
Arch packaging
|
||||
==============
|
||||
|
||||
`PKGBUILD` builds and installs `bos-settings` from source.
|
||||
|
||||
## Local build
|
||||
|
||||
```bash
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## Before publishing to [breadway] repo
|
||||
|
||||
1. Tag a release on GitHub.
|
||||
2. Update `pkgver` to match the tag.
|
||||
3. Update `source` to the release tarball URL.
|
||||
4. Run `updpkgsums` (or manually set `sha256sums`).
|
||||
|
||||
## Runtime dependencies
|
||||
|
||||
| Package | Required | Notes |
|
||||
|---------|----------|-------|
|
||||
| `gtk4` | yes | UI toolkit |
|
||||
| `glib2` | yes | always |
|
||||
| `snapper` | optional | snapshot management view |
|
||||
7
src/.gitignore
vendored
7
src/.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
5424
src/Cargo.lock
generated
5424
src/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,42 +0,0 @@
|
|||
[package]
|
||||
name = "bos-settings"
|
||||
version = "0.8.2"
|
||||
description = "System settings app for BOS (Bread Operating System)"
|
||||
authors = ["Breadway"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "bos_settings_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[features]
|
||||
# Default-on so a plain `cargo build --release` produces a standalone binary
|
||||
# that embeds the built frontend instead of loading it from `devUrl` —
|
||||
# `cargo tauri dev` strips this back off via `--no-default-features` so dev
|
||||
# builds keep talking to the Vite dev server. See
|
||||
# https://v2.tauri.app/reference/cargo-features/#custom-protocol
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml_edit = "0.22"
|
||||
tokio = { version = "1", features = ["process", "io-util", "time", "macros"] }
|
||||
notify = "7"
|
||||
regex = "1"
|
||||
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.4" }
|
||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["toml"] }
|
||||
anyhow = "1"
|
||||
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
|
@ -2,8 +2,7 @@
|
|||
//!
|
||||
//! Every bread* app owns a TOML config that may contain keys, sections, and
|
||||
//! comments this settings app does not model (e.g. breadpad's calendar
|
||||
//! credentials). Saved-network passwords live in breadcrumbs' separate
|
||||
//! `networks.toml`, not in breadcrumbs.toml. To edit safely we parse
|
||||
//! credentials, breadcrumbs' saved-network passwords). To edit safely we parse
|
||||
//! the file into a `toml_edit::DocumentMut`, mutate only the specific keys the
|
||||
//! UI exposes, and write the document back — preserving everything else,
|
||||
//! formatting and comments included.
|
||||
|
|
@ -19,7 +18,7 @@ use toml_edit::{value, Array, DocumentMut, Item, Table, Value};
|
|||
/// falling back to an empty document there means the next Save (see
|
||||
/// `save_doc`) overwrites it with only the UI-modelled keys, silently
|
||||
/// destroying anything else in the file (breadpad's calendar credentials,
|
||||
/// unmodelled keys, ...). Back up the unparseable file
|
||||
/// breadcrumbs' saved network passwords, ...). Back up the unparseable file
|
||||
/// once before falling back, so a bad edit is always recoverable.
|
||||
pub fn load_doc(path: &Path) -> DocumentMut {
|
||||
bread_utils::tomlcfg::load_doc("bos-settings", path)
|
||||
|
|
@ -70,8 +69,7 @@ pub fn get_i64(doc: &DocumentMut, path: &[&str]) -> Option<i64> {
|
|||
}
|
||||
pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option<f64> {
|
||||
let item = get(doc, path)?;
|
||||
item.as_float()
|
||||
.or_else(|| item.as_integer().map(|i| i as f64))
|
||||
item.as_float().or_else(|| item.as_integer().map(|i| i as f64))
|
||||
}
|
||||
/// Read an array of strings (e.g. modules.disable, contexts[].priority).
|
||||
pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec<String> {
|
||||
|
|
@ -178,10 +176,7 @@ password = \"secret\" # keep me
|
|||
let mut doc = DocumentMut::new();
|
||||
set_bool(&mut doc, &["adapters", "power", "enabled"], false);
|
||||
set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], 45);
|
||||
assert_eq!(
|
||||
get_bool(&doc, &["adapters", "power", "enabled"]),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(get_bool(&doc, &["adapters", "power", "enabled"]), Some(false));
|
||||
assert_eq!(
|
||||
get_i64(&doc, &["adapters", "power", "poll_interval_secs"]),
|
||||
Some(45)
|
||||
|
|
@ -205,20 +200,14 @@ password = \"secret\" # keep me
|
|||
|
||||
#[test]
|
||||
fn atomic_write_backs_up_previous_contents_and_no_tmp_file_left_behind() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bos-settings-atomic-write-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let dir = std::env::temp_dir().join(format!("bos-settings-atomic-write-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.toml");
|
||||
let backup = dir.join("config.toml.bak");
|
||||
|
||||
atomic_write(&path, "first").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first");
|
||||
assert!(
|
||||
!backup.exists(),
|
||||
"no backup should be made when there's nothing to back up yet"
|
||||
);
|
||||
assert!(!backup.exists(), "no backup should be made when there's nothing to back up yet");
|
||||
|
||||
atomic_write(&path, "second").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
|
||||
|
|
@ -230,10 +219,7 @@ password = \"secret\" # keep me
|
|||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.contains(".tmp."))
|
||||
.collect();
|
||||
assert!(
|
||||
leftover_tmp.is_empty(),
|
||||
"temp file should be renamed away, not left behind: {leftover_tmp:?}"
|
||||
);
|
||||
assert!(leftover_tmp.is_empty(), "temp file should be renamed away, not left behind: {leftover_tmp:?}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 974 B |
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB |
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