Compare commits
No commits in common. "main" and "v0.7.0" have entirely different histories.
211 changed files with 7760 additions and 24523 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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ !contains(github.ref_name, '-rc.') }}
|
|
||||||
runs-on: [self-hosted, hestia]
|
runs-on: [self-hosted, hestia]
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
|
|
@ -16,23 +15,8 @@ jobs:
|
||||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
# 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
|
- name: build
|
||||||
run: cd src/src && cargo build --release --locked
|
run: cd src && cargo build --release --locked
|
||||||
|
|
||||||
- name: prepare artifacts
|
- name: prepare artifacts
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -40,29 +24,19 @@ jobs:
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
PKG_DIR="/srv/breadway-dl/bos-settings/${VERSION}"
|
PKG_DIR="/srv/breadway-dl/bos-settings/${VERSION}"
|
||||||
mkdir -p "${PKG_DIR}"
|
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"
|
strip "${PKG_DIR}/bos-settings-x86_64"
|
||||||
sha256sum "${PKG_DIR}/bos-settings-x86_64" | awk '{print $1}' \
|
sha256sum "${PKG_DIR}/bos-settings-x86_64" | awk '{print $1}' \
|
||||||
> "${PKG_DIR}/bos-settings-x86_64.sha256"
|
> "${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"
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
ln -sfn "${VERSION}" "/srv/breadway-dl/bos-settings/latest"
|
ln -sfn "${VERSION}" "/srv/breadway-dl/bos-settings/latest"
|
||||||
|
|
||||||
- name: regenerate index.json
|
- name: regenerate index.json
|
||||||
env:
|
|
||||||
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
rm -rf /tmp/bread-ecosystem-ci
|
||||||
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)"
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||||
exit 1
|
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||||
fi
|
|
||||||
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
|
||||||
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
|
||||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
|
||||||
bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
|
||||||
rm -rf "${ECOSYSTEM_CI_DIR}"
|
|
||||||
|
|
||||||
- name: upload to GitHub Release
|
- name: upload to GitHub Release
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -32,8 +32,3 @@ logs/
|
||||||
|
|
||||||
# Claude Code local agent state
|
# Claude Code local agent state
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# .freebuff local tool state (not for commit)
|
|
||||||
.freebuff/
|
|
||||||
# graphify knowledge-graph output (local tool cache, not for commit)
|
|
||||||
graphify-out/
|
|
||||||
|
|
|
||||||
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.6.3"
|
||||||
|
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.6.3"
|
||||||
|
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
|
# 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
|
## 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
|
```bash
|
||||||
cd frontend && npm ci && npm run build
|
cargo build --release
|
||||||
cd ../src && cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
Dev (Vite + `cargo tauri dev`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd src && cargo tauri dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Packaging / releasing
|
## 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"
|
name = "bos-settings"
|
||||||
description = "System settings app for Bread OS"
|
description = "System settings app for Bread OS"
|
||||||
binaries = ["bos-settings"]
|
binaries = ["bos-settings"]
|
||||||
system_deps = ["webkit2gtk-4.1", "gtk3", "libsoup3", "librsvg", "hicolor-icon-theme"]
|
system_deps = ["gtk4", "glib2"]
|
||||||
optional_system_deps = ["snapper"]
|
optional_system_deps = ["snapper"]
|
||||||
bread_deps = []
|
bread_deps = []
|
||||||
license_file = "LICENSE"
|
|
||||||
desktop_file = "bos-settings.desktop"
|
|
||||||
|
|
||||||
[config]
|
[config]
|
||||||
dir = "~/.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).
|
|
||||||
1960
frontend/package-lock.json
generated
1960
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,32 +0,0 @@
|
||||||
{
|
|
||||||
"name": "bos-settings-frontend",
|
|
||||||
"version": "0.8.2",
|
|
||||||
"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",
|
|
||||||
"@types/node": "^26.4.0",
|
|
||||||
"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,10 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
import { setContext } from "svelte";
|
|
||||||
import { EMBEDDED_KEY } from "$lib/embed";
|
|
||||||
|
|
||||||
let { children }: { children: Snippet } = $props();
|
|
||||||
setContext(EMBEDDED_KEY, true);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{@render children()}
|
|
||||||
|
|
@ -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: 28px 8px;
|
|
||||||
color: var(--muted);
|
|
||||||
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,62 +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;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
|
|
||||||
border-radius: var(--radius, 14px);
|
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group.wide {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
|
||||||
font-size: var(--font-size-secondary, 12px);
|
|
||||||
line-height: 1.4;
|
|
||||||
margin: -6px 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rows {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rows > :global(*) {
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
let { text }: { text: string } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<p class="hint">{text}</p>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.hint {
|
|
||||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
|
||||||
font-size: var(--font-size-secondary, 12px);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Component } from "svelte";
|
|
||||||
import ViewScaffold from "./ViewScaffold.svelte";
|
|
||||||
import Subnav from "./Subnav.svelte";
|
|
||||||
import Embed from "./Embed.svelte";
|
|
||||||
import { nav } from "$lib/nav.svelte";
|
|
||||||
|
|
||||||
interface Tab {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
component: Component;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { title, lede, tabs }: { title: string; lede: string; tabs: Tab[] } = $props();
|
|
||||||
|
|
||||||
function takeTab(): string {
|
|
||||||
const wanted = nav.tab;
|
|
||||||
nav.tab = null;
|
|
||||||
if (wanted && tabs.some((t) => t.id === wanted)) return wanted;
|
|
||||||
return tabs[0]?.id ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
let tab = $state(takeTab());
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const wanted = nav.tab;
|
|
||||||
if (wanted === null) return;
|
|
||||||
if (wanted === "") {
|
|
||||||
tab = tabs[0]?.id ?? "";
|
|
||||||
nav.tab = null;
|
|
||||||
} else if (tabs.some((t) => t.id === wanted)) {
|
|
||||||
tab = wanted;
|
|
||||||
nav.tab = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let Active = $derived(tabs.find((t) => t.id === tab)?.component);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ViewScaffold {title} {lede}>
|
|
||||||
{#if tabs.length > 1}
|
|
||||||
<Subnav items={tabs.map(({ id, label }) => ({ id, label }))} bind:value={tab} />
|
|
||||||
{/if}
|
|
||||||
<Embed>
|
|
||||||
{#key tab}
|
|
||||||
{#if Active}
|
|
||||||
<Active />
|
|
||||||
{/if}
|
|
||||||
{/key}
|
|
||||||
</Embed>
|
|
||||||
</ViewScaffold>
|
|
||||||
|
|
@ -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-sm, 10px);
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,51 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
let { label, hint, children }: { label: string; hint?: string; children: Snippet } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="field-row">
|
|
||||||
<div class="meta">
|
|
||||||
<span class="label">{label}</span>
|
|
||||||
{#if hint}<span class="hint">{hint}</span>{/if}
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.field-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 11px 2px;
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.field-row + .field-row) {
|
|
||||||
border-top: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,38 +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 class="btn primary" 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: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
|
||||||
font-size: var(--font-size-secondary, 12px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Row from "./Row.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
value = $bindable(),
|
|
||||||
options,
|
|
||||||
labels = {},
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
hint?: string;
|
|
||||||
value: string;
|
|
||||||
options: string[];
|
|
||||||
labels?: Record<string, string>;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Row {label} {hint}>
|
|
||||||
<select bind:value>
|
|
||||||
{#each options as opt (opt)}
|
|
||||||
<option value={opt}>{labels[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-sm, 10px);
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
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}? Other bread apps may stall until it is 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 writes the file. Restart the service to apply." />
|
|
||||||
{/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-2, var(--bg));
|
|
||||||
color: var(--on-surface);
|
|
||||||
border: 1px solid var(--line, transparent);
|
|
||||||
border-radius: var(--radius-sm, 10px);
|
|
||||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--on-surface) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Search from "@lucide/svelte/icons/search";
|
|
||||||
import { SIDEBAR_SECTIONS } from "$lib/sidebar";
|
|
||||||
import { searchSettings } from "$lib/search";
|
|
||||||
import { go, nav } from "$lib/nav.svelte";
|
|
||||||
|
|
||||||
let query = $state("");
|
|
||||||
let hits = $derived(query.trim().length >= 1 ? searchSettings(query, 6) : []);
|
|
||||||
|
|
||||||
function jump(page: string, tab?: string) {
|
|
||||||
query = "";
|
|
||||||
go(page, tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Enter" && hits[0]) {
|
|
||||||
jump(hits[0].page, hits[0].tab);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav class="sidebar">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<Search size={16} />
|
|
||||||
<input bind:value={query} placeholder="Search settings" onkeydown={onKey} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if hits.length > 0}
|
|
||||||
<div class="hits">
|
|
||||||
{#each hits as hit (`${hit.page}:${hit.tab ?? ""}:${hit.label}`)}
|
|
||||||
<button type="button" class="hit" onclick={() => jump(hit.page, hit.tab)}>
|
|
||||||
<span>{hit.label}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="nav">
|
|
||||||
{#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
|
|
||||||
type="button"
|
|
||||||
class="row"
|
|
||||||
class:selected={nav.page === item.id}
|
|
||||||
onclick={() => go(item.id)}
|
|
||||||
>
|
|
||||||
<item.icon size={16} />
|
|
||||||
<span class="label">{item.label}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 248px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-height: 0;
|
|
||||||
background: var(--bg-2, var(--surface));
|
|
||||||
color: var(--on-surface);
|
|
||||||
border-right: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 12px 10px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap {
|
|
||||||
position: relative;
|
|
||||||
margin: 2px 6px 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap :global(svg) {
|
|
||||||
position: absolute;
|
|
||||||
left: 11px;
|
|
||||||
top: 10px;
|
|
||||||
opacity: 0.45;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap input {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 9px 10px 9px 34px;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap input:focus {
|
|
||||||
border-color: color-mix(in oklab, var(--accent) 55%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav,
|
|
||||||
.hits {
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 0 4px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
padding: 14px 10px 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--faint, color-mix(in oklab, var(--fg) 38%, transparent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header:first-child {
|
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row,
|
|
||||||
.hit {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row:hover,
|
|
||||||
.hit:hover {
|
|
||||||
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row.selected {
|
|
||||||
background: var(--accent-soft, color-mix(in oklab, var(--accent) 18%, transparent));
|
|
||||||
color: var(--fg);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row.selected :global(svg) {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 13.5px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Row from "./Row.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
value = $bindable(),
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
step = 1,
|
|
||||||
suffix = "",
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
hint?: string;
|
|
||||||
value: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
step?: number;
|
|
||||||
suffix?: string;
|
|
||||||
onChange?: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let shown = $derived(step < 1 ? Number(value).toFixed(2).replace(/0+$/, "").replace(/\.$/, "") : String(Math.round(value)));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Row {label} {hint}>
|
|
||||||
<input type="range" {min} {max} {step} bind:value oninput={() => onChange?.()} />
|
|
||||||
<span class="val">{shown}{suffix}</span>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
input[type="range"] {
|
|
||||||
width: 160px;
|
|
||||||
accent-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.val {
|
|
||||||
min-width: 4.5ch;
|
|
||||||
text-align: right;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
let {
|
|
||||||
items,
|
|
||||||
value = $bindable(),
|
|
||||||
}: { items: { id: string; label: string }[]; value: string } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="subnav">
|
|
||||||
{#each items as item (item.id)}
|
|
||||||
<button type="button" class:on={value === item.id} onclick={() => (value = item.id)}>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.subnav {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin: -8px 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 6px 11px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
|
||||||
border: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
|
|
||||||
color: var(--fg);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.on {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--on-accent);
|
|
||||||
border-color: transparent;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,50 +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)}
|
|
||||||
>
|
|
||||||
<i></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.switch {
|
|
||||||
width: 42px;
|
|
||||||
height: 24px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: none;
|
|
||||||
background: color-mix(in srgb, var(--fg) 12%, transparent);
|
|
||||||
padding: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
transition: background 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch.on {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #efe8e6;
|
|
||||||
display: block;
|
|
||||||
transform: translateX(0);
|
|
||||||
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch.on i {
|
|
||||||
transform: translateX(18px);
|
|
||||||
background: var(--on-accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Row from "./Row.svelte";
|
|
||||||
import Switch from "./Switch.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
value = $bindable(),
|
|
||||||
}: { label: string; hint?: string; value: boolean } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Row {label} {hint}>
|
|
||||||
<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-sm, 10px);
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
|
|
||||||
function win() {
|
|
||||||
return getCurrentWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
win().close();
|
|
||||||
}
|
|
||||||
function minimize() {
|
|
||||||
win().minimize();
|
|
||||||
}
|
|
||||||
function toggleMax() {
|
|
||||||
win().toggleMaximize();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="titlebar" data-tauri-drag-region>
|
|
||||||
<div class="dots">
|
|
||||||
<button class="dot close" aria-label="Close" onclick={close}></button>
|
|
||||||
<button class="dot min" aria-label="Minimize" onclick={minimize}></button>
|
|
||||||
<button class="dot max" aria-label="Maximize" onclick={toggleMax}></button>
|
|
||||||
</div>
|
|
||||||
<div class="name">Settings</div>
|
|
||||||
<div class="grow"></div>
|
|
||||||
<span class="kbd">Ctrl K</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.titlebar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 14px;
|
|
||||||
background: var(--bg-2, var(--bg));
|
|
||||||
border-bottom: 1px solid var(--line, color-mix(in srgb, var(--fg) 8%, transparent));
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dots {
|
|
||||||
display: flex;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
background: #2a313c;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot.close:hover {
|
|
||||||
background: var(--red);
|
|
||||||
}
|
|
||||||
.dot.min:hover,
|
|
||||||
.dot.max:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grow {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
import { getContext } from "svelte";
|
|
||||||
import { EMBEDDED_KEY } from "$lib/embed";
|
|
||||||
|
|
||||||
let { title, lede, children }: { title: string; lede?: string; children: Snippet } = $props();
|
|
||||||
const embedded = getContext<boolean>(EMBEDDED_KEY) ?? false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if embedded}
|
|
||||||
<div class="embed">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="view">
|
|
||||||
<h1 class="title">{title}</h1>
|
|
||||||
{#if lede}
|
|
||||||
<p class="lede">{lede}</p>
|
|
||||||
{/if}
|
|
||||||
<div class="content">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.view {
|
|
||||||
padding: 28px 36px 56px;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 920px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lede {
|
|
||||||
margin: 6px auto 0;
|
|
||||||
color: var(--muted, color-mix(in oklab, var(--fg) 58%, transparent));
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.45;
|
|
||||||
max-width: 920px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content,
|
|
||||||
.embed {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 18px;
|
|
||||||
max-width: 920px;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 22px;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embed {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
export function debounce(fn: () => void, ms = 280): () => void {
|
|
||||||
let t: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
return () => {
|
|
||||||
clearTimeout(t);
|
|
||||||
t = setTimeout(fn, ms);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
/** When set, ViewScaffold renders inner groups only - used by hub tab panes. */
|
|
||||||
export const EMBEDDED_KEY = "bos-settings-embedded";
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { DEFAULT_PAGE, resolvePage } from "$lib/sidebar";
|
|
||||||
|
|
||||||
/** Shared navigation - module `$state` so sidebar/search/hubs all see the same page. */
|
|
||||||
export const nav = $state({
|
|
||||||
page: DEFAULT_PAGE,
|
|
||||||
tab: null as string | null,
|
|
||||||
searchNonce: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function go(page: string, tab?: string) {
|
|
||||||
const resolved = resolvePage(page);
|
|
||||||
nav.tab = tab ?? resolved.tab ?? "";
|
|
||||||
nav.page = resolved.page;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Navigate = (page: string, tab?: string) => void;
|
|
||||||
export const NAVIGATE_KEY = "bos-settings-navigate";
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
export interface SearchHit {
|
|
||||||
page: string;
|
|
||||||
tab?: string;
|
|
||||||
label: string;
|
|
||||||
keywords: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SEARCH_INDEX: SearchHit[] = [
|
|
||||||
{ page: "home", label: "Home", keywords: "search overview status" },
|
|
||||||
{
|
|
||||||
page: "network",
|
|
||||||
label: "Wi-Fi",
|
|
||||||
keywords: "wifi ssid password scan radio ethernet hotspot dns",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: "network",
|
|
||||||
tab: "vpn",
|
|
||||||
label: "VPN / WireGuard",
|
|
||||||
keywords: "vpn wireguard tailscale tunnel import conf",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: "network",
|
|
||||||
tab: "breadcrumbs",
|
|
||||||
label: "Saved networks",
|
|
||||||
keywords: "wifi profiles breadcrumbs known ssid tailscale",
|
|
||||||
},
|
|
||||||
{ page: "bluetooth", label: "Bluetooth", keywords: "bluetooth headphones pair scan mouse keyboard" },
|
|
||||||
{
|
|
||||||
page: "displays",
|
|
||||||
label: "Displays",
|
|
||||||
keywords: "monitor scale resolution arrange rotate vrr hdr hyprland",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: "displays",
|
|
||||||
tab: "nightlight",
|
|
||||||
label: "Night light",
|
|
||||||
keywords: "night light hyprsunset temperature warmth sunset",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: "displays",
|
|
||||||
tab: "breadmon",
|
|
||||||
label: "Live arrange",
|
|
||||||
keywords: "breadmon mirror profile arrange",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: "appearance",
|
|
||||||
label: "Wallpaper",
|
|
||||||
keywords: "wallpaper theme palette pywal breadpaper accent",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: "appearance",
|
|
||||||
tab: "appearance",
|
|
||||||
label: "Windows",
|
|
||||||
keywords: "gaps blur rounding border shadow tiling dwindle",
|
|
||||||
},
|
|
||||||
{ page: "sound", label: "Sound", keywords: "volume mute microphone output input speaker pavucontrol" },
|
|
||||||
{
|
|
||||||
page: "power",
|
|
||||||
label: "Power & battery",
|
|
||||||
keywords: "battery brightness charge tlp suspend idle lid",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: "input",
|
|
||||||
label: "Keyboard & mouse",
|
|
||||||
keywords: "keybind shortcut layout touchpad tap natural scroll follow mouse",
|
|
||||||
},
|
|
||||||
{ page: "input", tab: "ime", label: "Input method", keywords: "fcitx5 ime cjk chinese japanese korean" },
|
|
||||||
{
|
|
||||||
page: "input",
|
|
||||||
tab: "accessibility",
|
|
||||||
label: "Accessibility",
|
|
||||||
keywords: "orca screen reader zoom magnifier sticky keys",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: "desktop",
|
|
||||||
label: "Bar",
|
|
||||||
keywords: "breadbar modules clock tray workspaces",
|
|
||||||
},
|
|
||||||
{ page: "desktop", tab: "breadbox", label: "Launcher", keywords: "breadbox launcher apps" },
|
|
||||||
{ page: "desktop", tab: "breadlock", label: "Lock screen", keywords: "lock greet breadlock idle" },
|
|
||||||
{ page: "desktop", tab: "breadshot", label: "Screenshots", keywords: "screenshot grim slurp breadshot" },
|
|
||||||
{ page: "desktop", tab: "autostart", label: "Startup apps", keywords: "autostart login startup" },
|
|
||||||
{ page: "desktop", tab: "breadclip", label: "Clipboard", keywords: "clipboard history breadclip" },
|
|
||||||
{ page: "desktop", tab: "breadpad", label: "Notes", keywords: "breadpad notes calendar caldav" },
|
|
||||||
{ page: "desktop", tab: "breadsearch", label: "File search", keywords: "breadsearch breadmill index" },
|
|
||||||
{ page: "desktop", tab: "bread", label: "Daemon", keywords: "breadd daemon adapters lua" },
|
|
||||||
{ page: "apps", label: "Default apps", keywords: "browser terminal pdf mime default" },
|
|
||||||
{ page: "apps", tab: "optional", label: "Optional software", keywords: "steam flatpak libreoffice" },
|
|
||||||
{ page: "apps", tab: "printing", label: "Printing", keywords: "cups printer" },
|
|
||||||
{ page: "privacy", label: "Firewall", keywords: "firewall ufw port allow" },
|
|
||||||
{ page: "privacy", tab: "users", label: "Users", keywords: "users password account" },
|
|
||||||
{ page: "system", label: "Updates", keywords: "update bakery pacman firmware nvidia" },
|
|
||||||
{ page: "system", tab: "packages", label: "Packages", keywords: "packages bakery install" },
|
|
||||||
{ page: "system", tab: "aur", label: "AUR", keywords: "aur yay" },
|
|
||||||
{ page: "system", tab: "snapshots", label: "Snapshots", keywords: "snapper snapshot rollback grub" },
|
|
||||||
{ page: "system", tab: "backup", label: "Backup", keywords: "restic backup restore" },
|
|
||||||
{ page: "system", tab: "channel", label: "Bakery channel", keywords: "track stable beta dev bakery" },
|
|
||||||
{ page: "about", label: "About", keywords: "hostname kernel cpu gpu about machine" },
|
|
||||||
{ page: "about", tab: "datetime", label: "Date & time", keywords: "timezone ntp clock date time" },
|
|
||||||
{ page: "desktop", tab: "breadhelp", label: "Help", keywords: "help breadhelp onboarding" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function searchSettings(query: string, limit = 8): SearchHit[] {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (q.length < 1) return [];
|
|
||||||
const scored = SEARCH_INDEX.map((hit) => {
|
|
||||||
const hay = `${hit.label} ${hit.keywords} ${hit.page}`.toLowerCase();
|
|
||||||
let score = 0;
|
|
||||||
if (hit.label.toLowerCase().startsWith(q)) score += 8;
|
|
||||||
if (hit.label.toLowerCase().includes(q)) score += 4;
|
|
||||||
if (hay.includes(q)) score += 2;
|
|
||||||
for (const part of q.split(/\s+/)) {
|
|
||||||
if (hay.includes(part)) score += 1;
|
|
||||||
}
|
|
||||||
return { hit, score };
|
|
||||||
})
|
|
||||||
.filter((x) => x.score > 0)
|
|
||||||
.sort((a, b) => b.score - a.score);
|
|
||||||
return scored.slice(0, limit).map((x) => x.hit);
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import type { Component } from "svelte";
|
|
||||||
import House from "@lucide/svelte/icons/house";
|
|
||||||
import Wifi from "@lucide/svelte/icons/wifi";
|
|
||||||
import Bluetooth from "@lucide/svelte/icons/bluetooth";
|
|
||||||
import Monitor from "@lucide/svelte/icons/monitor";
|
|
||||||
import Volume2 from "@lucide/svelte/icons/volume-2";
|
|
||||||
import BatteryFull from "@lucide/svelte/icons/battery-full";
|
|
||||||
import Palette from "@lucide/svelte/icons/palette";
|
|
||||||
import AppWindow from "@lucide/svelte/icons/app-window";
|
|
||||||
import Keyboard from "@lucide/svelte/icons/keyboard";
|
|
||||||
import LayoutGrid from "@lucide/svelte/icons/layout-grid";
|
|
||||||
import Shield from "@lucide/svelte/icons/shield";
|
|
||||||
import Download from "@lucide/svelte/icons/download";
|
|
||||||
import Info from "@lucide/svelte/icons/info";
|
|
||||||
|
|
||||||
export interface SidebarItem {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
icon: Component;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarSection {
|
|
||||||
title: string | null;
|
|
||||||
items: SidebarItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SIDEBAR_SECTIONS: SidebarSection[] = [
|
|
||||||
{ title: "Overview", items: [{ id: "home", label: "Home", icon: House }] },
|
|
||||||
{
|
|
||||||
title: "Devices",
|
|
||||||
items: [
|
|
||||||
{ id: "network", label: "Wi-Fi & internet", icon: Wifi },
|
|
||||||
{ id: "bluetooth", label: "Bluetooth", icon: Bluetooth },
|
|
||||||
{ id: "displays", label: "Displays", icon: Monitor },
|
|
||||||
{ id: "sound", label: "Sound", icon: Volume2 },
|
|
||||||
{ id: "power", label: "Power & battery", icon: BatteryFull },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Desktop",
|
|
||||||
items: [
|
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
|
||||||
{ id: "desktop", label: "Bar & apps", icon: AppWindow },
|
|
||||||
{ id: "input", label: "Keyboard & mouse", icon: Keyboard },
|
|
||||||
{ id: "apps", label: "Default apps", icon: LayoutGrid },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "System",
|
|
||||||
items: [
|
|
||||||
{ id: "privacy", label: "Privacy & users", icon: Shield },
|
|
||||||
{ id: "system", label: "Updates & backup", icon: Download },
|
|
||||||
{ id: "about", label: "About", icon: Info },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Old sidebar ids (screenshot CLI, in-app jumps) → hub page + optional tab. */
|
|
||||||
export const PAGE_ALIASES: Record<string, { page: string; tab?: string }> = {
|
|
||||||
home: { page: "home" },
|
|
||||||
network: { page: "network" },
|
|
||||||
vpn: { page: "network", tab: "vpn" },
|
|
||||||
breadcrumbs: { page: "network", tab: "breadcrumbs" },
|
|
||||||
bluetooth: { page: "bluetooth" },
|
|
||||||
displays: { page: "displays" },
|
|
||||||
hyprland: { page: "displays", tab: "hyprland" },
|
|
||||||
nightlight: { page: "displays", tab: "nightlight" },
|
|
||||||
breadmon: { page: "displays", tab: "breadmon" },
|
|
||||||
sound: { page: "sound" },
|
|
||||||
power: { page: "power" },
|
|
||||||
appearance: { page: "appearance" },
|
|
||||||
breadpaper: { page: "appearance", tab: "breadpaper" },
|
|
||||||
desktop: { page: "desktop" },
|
|
||||||
breadbar: { page: "desktop", tab: "breadbar" },
|
|
||||||
breadbox: { page: "desktop", tab: "breadbox" },
|
|
||||||
breadlock: { page: "desktop", tab: "breadlock" },
|
|
||||||
breadshot: { page: "desktop", tab: "breadshot" },
|
|
||||||
autostart: { page: "desktop", tab: "autostart" },
|
|
||||||
breadclip: { page: "desktop", tab: "more" },
|
|
||||||
breadpad: { page: "desktop", tab: "more" },
|
|
||||||
breadsearch: { page: "desktop", tab: "more" },
|
|
||||||
bread: { page: "desktop", tab: "more" },
|
|
||||||
breadhelp: { page: "desktop", tab: "more" },
|
|
||||||
more: { page: "desktop", tab: "more" },
|
|
||||||
input: { page: "input" },
|
|
||||||
keybinds: { page: "input", tab: "keybinds" },
|
|
||||||
ime: { page: "input", tab: "ime" },
|
|
||||||
accessibility: { page: "input", tab: "accessibility" },
|
|
||||||
apps: { page: "apps" },
|
|
||||||
defaults: { page: "apps" },
|
|
||||||
optional: { page: "apps", tab: "optional" },
|
|
||||||
printing: { page: "apps", tab: "printing" },
|
|
||||||
privacy: { page: "privacy" },
|
|
||||||
firewall: { page: "privacy" },
|
|
||||||
users: { page: "privacy", tab: "users" },
|
|
||||||
system: { page: "system" },
|
|
||||||
updates: { page: "system" },
|
|
||||||
packages: { page: "system", tab: "packages" },
|
|
||||||
aur: { page: "system", tab: "aur" },
|
|
||||||
firmware: { page: "system", tab: "firmware" },
|
|
||||||
snapshots: { page: "system", tab: "snapshots" },
|
|
||||||
backup: { page: "system", tab: "backup" },
|
|
||||||
channel: { page: "system", tab: "channel" },
|
|
||||||
about: { page: "about" },
|
|
||||||
datetime: { page: "about", tab: "datetime" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_PAGE = "home";
|
|
||||||
|
|
||||||
export function resolvePage(id: string): { page: string; tab?: string } {
|
|
||||||
return PAGE_ALIASES[id] ?? { page: id };
|
|
||||||
}
|
|
||||||
|
|
@ -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,121 +0,0 @@
|
||||||
:root {
|
|
||||||
--bg-2: color-mix(in oklab, var(--bg) 82%, var(--surface));
|
|
||||||
--surface-2: color-mix(in oklab, var(--surface) 72%, var(--overlay));
|
|
||||||
--muted: color-mix(in oklab, var(--fg) 58%, transparent);
|
|
||||||
--faint: color-mix(in oklab, var(--fg) 38%, transparent);
|
|
||||||
--line: color-mix(in srgb, var(--fg) 8%, transparent);
|
|
||||||
--accent-soft: color-mix(in oklab, var(--accent) 18%, transparent);
|
|
||||||
--radius: 14px;
|
|
||||||
--radius-sm: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
height: 100%;
|
|
||||||
color-scheme: dark;
|
|
||||||
background-color: var(--bg, #12161c);
|
|
||||||
color: var(--fg);
|
|
||||||
font-family: var(--font-family, sans-serif);
|
|
||||||
font-size: var(--font-size-base, 14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
#svelte {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid color-mix(in oklab, var(--accent) 70%, white);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="range"] {
|
|
||||||
accent-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
background: var(--surface-2, var(--surface));
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: var(--radius-sm, 10px);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
background: color-mix(in oklab, var(--surface-2, var(--surface)) 80%, var(--fg));
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.primary {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--on-accent);
|
|
||||||
border-color: transparent;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.primary:hover {
|
|
||||||
filter: brightness(1.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.danger {
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kbd {
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pills {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 11px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
color: var(--fg);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill.on {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--on-accent);
|
|
||||||
border-color: transparent;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
@ -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,14 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Hub from "$lib/components/Hub.svelte";
|
|
||||||
import About from "./About.svelte";
|
|
||||||
import DateTime from "./DateTime.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Hub
|
|
||||||
title="About"
|
|
||||||
lede="This machine and the clock."
|
|
||||||
tabs={[
|
|
||||||
{ id: "about", label: "Machine", component: About },
|
|
||||||
{ id: "datetime", label: "Date & time", component: DateTime },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
@ -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="Zooms around the cursor. This session only.">
|
|
||||||
{#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,162 +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 SliderField from "$lib/components/SliderField.svelte";
|
|
||||||
import HyprColorField from "$lib/components/HyprColorField.svelte";
|
|
||||||
import Hint from "$lib/components/Hint.svelte";
|
|
||||||
import { debounce } from "$lib/debounce";
|
|
||||||
|
|
||||||
// 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: string; label: string }[] = [
|
|
||||||
{ value: "0", label: "Click to focus" },
|
|
||||||
{ value: "1", label: "Follow mouse" },
|
|
||||||
{ value: "2", label: "Follow, keep focus on click" },
|
|
||||||
{ value: "3", label: "Mouse and keys independent" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const KB_LABELS: Record<string, string> = {
|
|
||||||
us: "English (US)",
|
|
||||||
gb: "English (UK)",
|
|
||||||
de: "German",
|
|
||||||
fr: "French",
|
|
||||||
es: "Spanish",
|
|
||||||
it: "Italian",
|
|
||||||
pt: "Portuguese",
|
|
||||||
nl: "Dutch",
|
|
||||||
se: "Swedish",
|
|
||||||
no: "Norwegian",
|
|
||||||
dk: "Danish",
|
|
||||||
fi: "Finnish",
|
|
||||||
pl: "Polish",
|
|
||||||
cz: "Czech",
|
|
||||||
sk: "Slovak",
|
|
||||||
hu: "Hungarian",
|
|
||||||
ro: "Romanian",
|
|
||||||
gr: "Greek",
|
|
||||||
tr: "Turkish",
|
|
||||||
ru: "Russian",
|
|
||||||
ua: "Ukrainian",
|
|
||||||
jp: "Japanese",
|
|
||||||
kr: "Korean",
|
|
||||||
cn: "Chinese",
|
|
||||||
br: "Portuguese (Brazil)",
|
|
||||||
ca: "English (Canada)",
|
|
||||||
ch: "German (Switzerland)",
|
|
||||||
be: "Belgian",
|
|
||||||
at: "German (Austria)",
|
|
||||||
ie: "English (Ireland)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const LAYOUT_LABELS: Record<string, string> = {
|
|
||||||
dwindle: "Tiling",
|
|
||||||
master: "Master stack",
|
|
||||||
};
|
|
||||||
|
|
||||||
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 loaded = $state(false);
|
|
||||||
let followMouse = $state("1");
|
|
||||||
|
|
||||||
let kbLayoutOptions = $derived(
|
|
||||||
cfg && !COMMON_KB_LAYOUTS.includes(cfg.kb_layout) ? [...COMMON_KB_LAYOUTS, cfg.kb_layout] : COMMON_KB_LAYOUTS,
|
|
||||||
);
|
|
||||||
|
|
||||||
const persist = debounce(() => {
|
|
||||||
if (!cfg) return;
|
|
||||||
invoke("save_appearance", { appearance: { ...cfg, follow_mouse: Number(followMouse) } });
|
|
||||||
}, 450);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
cfg = await invoke<Appearance>("get_appearance");
|
|
||||||
followMouse = String(cfg.follow_mouse);
|
|
||||||
loaded = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
let primed = false;
|
|
||||||
$effect(() => {
|
|
||||||
if (!loaded || !cfg) return;
|
|
||||||
JSON.stringify(cfg);
|
|
||||||
void followMouse;
|
|
||||||
if (!primed) {
|
|
||||||
primed = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
persist();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ViewScaffold title="Appearance">
|
|
||||||
{#if cfg}
|
|
||||||
<Group title="Windows">
|
|
||||||
<SliderField label="Gaps between windows" bind:value={cfg.gaps_in} min={0} max={50} />
|
|
||||||
<SliderField label="Gaps around the edge" bind:value={cfg.gaps_out} min={0} max={50} />
|
|
||||||
<SliderField label="Border width" bind:value={cfg.border_size} min={0} max={10} />
|
|
||||||
<HyprColorField label="Active border" bind:value={cfg.active_border} />
|
|
||||||
<HyprColorField label="Inactive border" bind:value={cfg.inactive_border} />
|
|
||||||
<SelectField label="Layout" bind:value={cfg.layout} options={["dwindle", "master"]} labels={LAYOUT_LABELS} />
|
|
||||||
<SwitchField label="Resize by dragging the border" bind:value={cfg.resize_on_border} />
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group title="Effects">
|
|
||||||
<SliderField label="Corner rounding" bind:value={cfg.rounding} min={0} max={30} />
|
|
||||||
<SwitchField label="Blur" bind:value={cfg.blur_enabled} />
|
|
||||||
{#if cfg.blur_enabled}
|
|
||||||
<SliderField label="Blur size" bind:value={cfg.blur_size} min={0} max={20} />
|
|
||||||
<SliderField label="Blur quality" bind:value={cfg.blur_passes} min={1} max={5} />
|
|
||||||
{/if}
|
|
||||||
<SwitchField label="Shadows" bind:value={cfg.shadow_enabled} />
|
|
||||||
{#if cfg.shadow_enabled}
|
|
||||||
<SliderField label="Shadow size" bind:value={cfg.shadow_range} min={0} max={40} />
|
|
||||||
{/if}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group title="Keyboard and mouse">
|
|
||||||
<SelectField label="Keyboard layout" bind:value={cfg.kb_layout} options={kbLayoutOptions} labels={KB_LABELS} />
|
|
||||||
<SelectField
|
|
||||||
label="Focus"
|
|
||||||
bind:value={followMouse}
|
|
||||||
options={FOLLOW_MOUSE_MODES.map((m) => m.value)}
|
|
||||||
labels={Object.fromEntries(FOLLOW_MOUSE_MODES.map((m) => [m.value, m.label]))}
|
|
||||||
/>
|
|
||||||
<SwitchField label="Natural scrolling" hint="Touchpad" bind:value={cfg.natural_scroll} />
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Hint text="Applies as you change it." />
|
|
||||||
{/if}
|
|
||||||
</ViewScaffold>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Hub from "$lib/components/Hub.svelte";
|
|
||||||
import Breadpaper from "./Breadpaper.svelte";
|
|
||||||
import Appearance from "./Appearance.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Hub
|
|
||||||
title="Appearance"
|
|
||||||
lede="Wallpaper sets the colors. Windows follow."
|
|
||||||
tabs={[
|
|
||||||
{ id: "breadpaper", label: "Wallpaper", component: Breadpaper },
|
|
||||||
{ id: "appearance", label: "Windows", component: Appearance },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Hub from "$lib/components/Hub.svelte";
|
|
||||||
import Defaults from "./Defaults.svelte";
|
|
||||||
import Optional from "./Optional.svelte";
|
|
||||||
import Printing from "./Printing.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Hub
|
|
||||||
title="Default apps"
|
|
||||||
lede="Browser, files, terminal, and extras."
|
|
||||||
tabs={[
|
|
||||||
{ id: "defaults", label: "Defaults", component: Defaults },
|
|
||||||
{ id: "optional", label: "Optional", component: Optional },
|
|
||||||
{ id: "printing", label: "Printing", component: Printing },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
@ -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="Opens a terminal. Review the PKGBUILD before you confirm."
|
|
||||||
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="Extra apps after login. Bar and clipboard always start."
|
|
||||||
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 = "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="Local folder or sftp. Password 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="Home directory. Snapshots page is the root filesystem."
|
|
||||||
>
|
|
||||||
<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,195 +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"
|
|
||||||
lede="Paired devices and nearby scan."
|
|
||||||
>
|
|
||||||
{#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="btn" 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="PIN pairing is not supported."
|
|
||||||
>
|
|
||||||
<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);
|
|
||||||
padding: 10px 2px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row:first-child {
|
|
||||||
border-top: none;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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,176 +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 TextField from "$lib/components/TextField.svelte";
|
|
||||||
import SliderField from "$lib/components/SliderField.svelte";
|
|
||||||
import Hint from "$lib/components/Hint.svelte";
|
|
||||||
import { debounce } from "$lib/debounce";
|
|
||||||
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 loaded = $state(false);
|
|
||||||
let advancedOpen = $state(false);
|
|
||||||
let css = $state("");
|
|
||||||
let cssStatus = $state("");
|
|
||||||
let cssSaving = $state(false);
|
|
||||||
|
|
||||||
const persist = debounce(() => {
|
|
||||||
if (!style) return;
|
|
||||||
invoke("save_breadbar_style", { style }).then(() => invoke<string>("get_breadbar_css").then((c) => (css = c)));
|
|
||||||
}, 320);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
style = await invoke<BreadbarStyle>("get_breadbar_style");
|
|
||||||
css = await invoke<string>("get_breadbar_css");
|
|
||||||
loaded = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
let primed = false;
|
|
||||||
$effect(() => {
|
|
||||||
if (!loaded || !style) return;
|
|
||||||
JSON.stringify(style);
|
|
||||||
if (!primed) {
|
|
||||||
primed = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
persist();
|
|
||||||
});
|
|
||||||
|
|
||||||
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="Clock, workspace numbers, and stats.">
|
|
||||||
<TextField label="Font" bind:value={style.font_family} placeholder="Varela Round" />
|
|
||||||
<SliderField label="Font size" bind:value={style.font_size} min={8} max={32} />
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group title="Bar shape">
|
|
||||||
<SliderField label="Corner rounding" bind:value={style.bar_border_radius} min={0} max={40} />
|
|
||||||
<SliderField label="Inner padding" bind:value={style.bar_padding} min={0} max={40} />
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group title="Workspaces">
|
|
||||||
<SliderField label="Number size" bind:value={style.workspace_font_size} min={8} max={40} />
|
|
||||||
<SliderField
|
|
||||||
label="Inactive dimness"
|
|
||||||
bind:value={style.workspace_inactive_opacity}
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group title="Spacing and icons">
|
|
||||||
<SliderField label="Gap between stats" bind:value={style.stat_gap} min={0} max={40} />
|
|
||||||
<SliderField label="Tray icon size" bind:value={style.tray_icon_size} min={8} max={32} />
|
|
||||||
<SliderField label="Notification rounding" bind:value={style.notification_border_radius} min={0} max={24} />
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Hint text="Applies as you change it." />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Group title="Advanced" hint="Raw CSS. Same file as the fields above." 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>
|
|
||||||
.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="Apps and categories shown 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="History popup. Super+V."
|
|
||||||
>
|
|
||||||
<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="Leave password blank to keep the saved one."
|
|
||||||
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="Opens the help app."
|
|
||||||
>
|
|
||||||
<button class="primary" onclick={() => invoke("open_breadhelp")}>Open breadhelp</button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group title="Show on login">
|
|
||||||
<SwitchField label="Show help on login" bind:value={() => autostartOn, (v) => setAutostart(v)} />
|
|
||||||
{#if !helpEntry}
|
|
||||||
<Hint text="Turning this on adds the 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,89 +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">
|
|
||||||
<Group
|
|
||||||
title="How locking works"
|
|
||||||
hint="Login and PAM are packaged. This only styles the lock screen."
|
|
||||||
>
|
|
||||||
<Hint text="Super+L locks. Login screen is packaged separately." />
|
|
||||||
<button class="primary" onclick={() => invoke("lock_session")}>Lock now</button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{#if cfg}
|
|
||||||
<Group title="Lock screen">
|
|
||||||
<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 saved but not drawn yet." />
|
|
||||||
<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,29 +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="Live arrange, mirror, and named profiles."
|
|
||||||
>
|
|
||||||
<Hint text="Layout tab is the login config. This opens the live TUI." />
|
|
||||||
<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="Local model. No network.">
|
|
||||||
<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,164 +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();
|
|
||||||
scanning = true;
|
|
||||||
try {
|
|
||||||
library = await invoke<LibraryEntry[]>("list_wallpaper_library");
|
|
||||||
} finally {
|
|
||||||
scanning = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function apply(path: string) {
|
|
||||||
status = "Setting…";
|
|
||||||
try {
|
|
||||||
await invoke("set_wallpaper", { path });
|
|
||||||
await refreshCurrent();
|
|
||||||
status = "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);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ViewScaffold title="Wallpaper">
|
|
||||||
<Group title="Wallpaper" hint="This also updates desktop colors.">
|
|
||||||
{#if currentPath}
|
|
||||||
<div class="hero" style="background-image: url('{convertFileSrc(currentPath)}')">
|
|
||||||
<span class="cap">{currentPath.split("/").pop()}</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="hero placeholder">No wallpaper</div>
|
|
||||||
{/if}
|
|
||||||
<div class="btn-row">
|
|
||||||
<button class="btn primary" onclick={chooseImage}>Choose image</button>
|
|
||||||
<span class="status">{status}</span>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group title="Library" wide>
|
|
||||||
{#if scanning && library === null}
|
|
||||||
<div class="empty">Loading {libraryDir}…</div>
|
|
||||||
{:else if library && library.length === 0}
|
|
||||||
<div class="empty">Nothing in {libraryDir}</div>
|
|
||||||
{:else if library}
|
|
||||||
<div class="grid">
|
|
||||||
{#each library as item (item.path)}
|
|
||||||
<button
|
|
||||||
class="thumb"
|
|
||||||
class:on={item.path === currentPath}
|
|
||||||
onclick={() => apply(item.path)}
|
|
||||||
title={item.path}
|
|
||||||
>
|
|
||||||
<img src={convertFileSrc(item.path)} alt={item.name} loading="lazy" />
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Group>
|
|
||||||
</ViewScaffold>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.hero {
|
|
||||||
height: 180px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero.placeholder {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cap {
|
|
||||||
position: absolute;
|
|
||||||
left: 12px;
|
|
||||||
bottom: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #fffc;
|
|
||||||
background: #0006;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb {
|
|
||||||
padding: 0;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
background: transparent;
|
|
||||||
aspect-ratio: 16/10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb.on {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb img {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
</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="Indexing is heavy. Pause it on battery if you want."
|
|
||||||
>
|
|
||||||
<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="Hardware used for indexing."
|
|
||||||
>
|
|
||||||
<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="Change shortcuts under Keyboard."
|
|
||||||
>
|
|
||||||
{#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="Capture">
|
|
||||||
<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="Nothing downloads 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,75 +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 Row from "$lib/components/Row.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 } });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ViewScaffold title="Default apps">
|
|
||||||
<Group title="Apps" wide>
|
|
||||||
{#if st}
|
|
||||||
{#each CATEGORIES as cat (cat.id)}
|
|
||||||
<Row label={cat.label}>
|
|
||||||
<select bind:value={st.current[cat.id]} onchange={save}>
|
|
||||||
<option value="">Not set</option>
|
|
||||||
{#each st.options[cat.id] ?? [] as app (app.id)}
|
|
||||||
<option value={app.id}>{app.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</Row>
|
|
||||||
{/each}
|
|
||||||
<Hint text="Applies as you change it." />
|
|
||||||
{:else}
|
|
||||||
<Hint text="Loading…" />
|
|
||||||
{/if}
|
|
||||||
</Group>
|
|
||||||
</ViewScaffold>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
select {
|
|
||||||
color-scheme: dark;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--on-surface);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
min-width: 22ch;
|
|
||||||
max-width: 36ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
select:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Hub from "$lib/components/Hub.svelte";
|
|
||||||
import Breadbar from "./Breadbar.svelte";
|
|
||||||
import Breadbox from "./Breadbox.svelte";
|
|
||||||
import Breadlock from "./Breadlock.svelte";
|
|
||||||
import Breadshot from "./Breadshot.svelte";
|
|
||||||
import Autostart from "./Autostart.svelte";
|
|
||||||
import DesktopMore from "./DesktopMore.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Hub
|
|
||||||
title="Bar & apps"
|
|
||||||
lede="Bar, lock screen, screenshots, and startup."
|
|
||||||
tabs={[
|
|
||||||
{ id: "breadbar", label: "Bar", component: Breadbar },
|
|
||||||
{ id: "breadlock", label: "Lock", component: Breadlock },
|
|
||||||
{ id: "breadshot", label: "Screenshots", component: Breadshot },
|
|
||||||
{ id: "autostart", label: "Startup", component: Autostart },
|
|
||||||
{ id: "breadbox", label: "Launcher", component: Breadbox },
|
|
||||||
{ id: "more", label: "More", component: DesktopMore },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Subnav from "$lib/components/Subnav.svelte";
|
|
||||||
import Embed from "$lib/components/Embed.svelte";
|
|
||||||
import Breadclip from "./Breadclip.svelte";
|
|
||||||
import Breadpad from "./Breadpad.svelte";
|
|
||||||
import Breadsearch from "./Breadsearch.svelte";
|
|
||||||
import Bread from "./Bread.svelte";
|
|
||||||
import Breadhelp from "./Breadhelp.svelte";
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: "breadclip", label: "Clipboard", component: Breadclip },
|
|
||||||
{ id: "breadpad", label: "Notes", component: Breadpad },
|
|
||||||
{ id: "breadsearch", label: "Search", component: Breadsearch },
|
|
||||||
{ id: "bread", label: "Daemon", component: Bread },
|
|
||||||
{ id: "breadhelp", label: "Help", component: Breadhelp },
|
|
||||||
];
|
|
||||||
|
|
||||||
let tab = $state(tabs[0].id);
|
|
||||||
let Active = $derived(tabs.find((t) => t.id === tab)?.component);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Subnav items={tabs.map(({ id, label }) => ({ id, label }))} bind:value={tab} />
|
|
||||||
<Embed>
|
|
||||||
{#key tab}
|
|
||||||
{#if Active}
|
|
||||||
<Active />
|
|
||||||
{/if}
|
|
||||||
{/key}
|
|
||||||
</Embed>
|
|
||||||
|
|
@ -1,370 +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 Row from "$lib/components/Row.svelte";
|
|
||||||
|
|
||||||
interface LiveMonitor {
|
|
||||||
name: string;
|
|
||||||
mode: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
refresh: number;
|
|
||||||
scale: number;
|
|
||||||
transform: number;
|
|
||||||
available_modes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCALES = [
|
|
||||||
{ label: "100%", value: 1 },
|
|
||||||
{ label: "125%", value: 1.25 },
|
|
||||||
{ label: "150%", value: 1.5 },
|
|
||||||
{ label: "200%", value: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TURNS = [
|
|
||||||
{ label: "Landscape", value: 0 },
|
|
||||||
{ label: "Right", value: 1 },
|
|
||||||
{ label: "Upside down", value: 2 },
|
|
||||||
{ label: "Left", value: 3 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PAD = 28;
|
|
||||||
const STAGE_H = 300;
|
|
||||||
|
|
||||||
let monitors = $state<LiveMonitor[]>([]);
|
|
||||||
let selected = $state<string | null>(null);
|
|
||||||
let stageEl: HTMLDivElement | undefined = $state();
|
|
||||||
let stageW = $state(640);
|
|
||||||
let message = $state("");
|
|
||||||
let applying = $state(false);
|
|
||||||
|
|
||||||
let live = $derived(monitors.find((m) => m.name === selected) ?? null);
|
|
||||||
|
|
||||||
let view = $derived.by(() => {
|
|
||||||
if (monitors.length === 0) return { minX: 0, minY: 0, scale: 0.1 };
|
|
||||||
let minX = Infinity;
|
|
||||||
let minY = Infinity;
|
|
||||||
let maxX = -Infinity;
|
|
||||||
let maxY = -Infinity;
|
|
||||||
for (const m of monitors) {
|
|
||||||
const w = boxW(m);
|
|
||||||
const h = boxH(m);
|
|
||||||
minX = Math.min(minX, m.x);
|
|
||||||
minY = Math.min(minY, m.y);
|
|
||||||
maxX = Math.max(maxX, m.x + w);
|
|
||||||
maxY = Math.max(maxY, m.y + h);
|
|
||||||
}
|
|
||||||
const bw = Math.max(maxX - minX, 1);
|
|
||||||
const bh = Math.max(maxY - minY, 1);
|
|
||||||
const scale = Math.min((stageW - PAD * 2) / bw, (STAGE_H - PAD * 2) / bh);
|
|
||||||
return { minX, minY, scale };
|
|
||||||
});
|
|
||||||
|
|
||||||
type Drag = { i: number; grabX: number; grabY: number; pointer: number };
|
|
||||||
let drag = $state<Drag | null>(null);
|
|
||||||
|
|
||||||
function rotated(m: LiveMonitor): boolean {
|
|
||||||
return m.transform === 1 || m.transform === 3 || m.transform === 5 || m.transform === 7;
|
|
||||||
}
|
|
||||||
function boxW(m: LiveMonitor): number {
|
|
||||||
return rotated(m) ? m.height : m.width;
|
|
||||||
}
|
|
||||||
function boxH(m: LiveMonitor): number {
|
|
||||||
return rotated(m) ? m.width : m.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
function screenX(m: LiveMonitor): number {
|
|
||||||
return PAD + (m.x - view.minX) * view.scale;
|
|
||||||
}
|
|
||||||
function screenY(m: LiveMonitor): number {
|
|
||||||
return PAD + (m.y - view.minY) * view.scale;
|
|
||||||
}
|
|
||||||
function screenW(m: LiveMonitor): number {
|
|
||||||
return Math.max(48, boxW(m) * view.scale);
|
|
||||||
}
|
|
||||||
function screenH(m: LiveMonitor): number {
|
|
||||||
return Math.max(32, boxH(m) * view.scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nearestScale(v: number): number {
|
|
||||||
return SCALES.reduce((best, s) => (Math.abs(s.value - v) < Math.abs(best - v) ? s.value : best), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
monitors = await invoke<LiveMonitor[]>("get_live_monitors");
|
|
||||||
if (!selected || !monitors.some((m) => m.name === selected)) {
|
|
||||||
selected = monitors[0]?.name ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(refresh);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const el = stageEl;
|
|
||||||
if (!el) return;
|
|
||||||
stageW = el.clientWidth;
|
|
||||||
const ro = new ResizeObserver(() => {
|
|
||||||
stageW = el.clientWidth;
|
|
||||||
});
|
|
||||||
ro.observe(el);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
function snap(idx: number, x: number, y: number): { x: number; y: number } {
|
|
||||||
const moving = monitors[idx];
|
|
||||||
if (!moving) return { x, y };
|
|
||||||
const mw = boxW(moving);
|
|
||||||
const mh = boxH(moving);
|
|
||||||
const thresh = Math.max(16, 18 / Math.max(view.scale, 0.01));
|
|
||||||
let bestX = x;
|
|
||||||
let bestY = y;
|
|
||||||
let bestXd = thresh + 1;
|
|
||||||
let bestYd = thresh + 1;
|
|
||||||
for (let i = 0; i < monitors.length; i++) {
|
|
||||||
if (i === idx) continue;
|
|
||||||
const o = monitors[i];
|
|
||||||
const ow = boxW(o);
|
|
||||||
const oh = boxH(o);
|
|
||||||
const xs = [x - o.x, x - (o.x + ow), x + mw - o.x, x + mw - (o.x + ow)];
|
|
||||||
for (const d of xs) {
|
|
||||||
const ad = Math.abs(d);
|
|
||||||
if (ad < bestXd) {
|
|
||||||
bestXd = ad;
|
|
||||||
bestX = x - d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ys = [y - o.y, y - (o.y + oh), y + mh - o.y, y + mh - (o.y + oh)];
|
|
||||||
for (const d of ys) {
|
|
||||||
const ad = Math.abs(d);
|
|
||||||
if (ad < bestYd) {
|
|
||||||
bestYd = ad;
|
|
||||||
bestY = y - d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { x: Math.round(bestX), y: Math.round(bestY) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDown(e: PointerEvent, i: number) {
|
|
||||||
const m = monitors[i];
|
|
||||||
if (!m || !stageEl) return;
|
|
||||||
selected = m.name;
|
|
||||||
const rect = stageEl.getBoundingClientRect();
|
|
||||||
const px = e.clientX - rect.left;
|
|
||||||
const py = e.clientY - rect.top;
|
|
||||||
drag = { i, grabX: px - screenX(m), grabY: py - screenY(m), pointer: e.pointerId };
|
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMove(e: PointerEvent) {
|
|
||||||
if (!drag || !stageEl) return;
|
|
||||||
const rect = stageEl.getBoundingClientRect();
|
|
||||||
const px = e.clientX - rect.left - drag.grabX;
|
|
||||||
const py = e.clientY - rect.top - drag.grabY;
|
|
||||||
const wx = view.minX + (px - PAD) / view.scale;
|
|
||||||
const wy = view.minY + (py - PAD) / view.scale;
|
|
||||||
const snapped = snap(drag.i, wx, wy);
|
|
||||||
monitors[drag.i].x = snapped.x;
|
|
||||||
monitors[drag.i].y = snapped.y;
|
|
||||||
monitors = [...monitors];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onUp(e: PointerEvent) {
|
|
||||||
if (!drag) return;
|
|
||||||
if (e.pointerId !== drag.pointer && e.type !== "pointerleave") return;
|
|
||||||
drag = null;
|
|
||||||
await apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apply() {
|
|
||||||
if (monitors.length === 0) return;
|
|
||||||
applying = true;
|
|
||||||
message = "";
|
|
||||||
try {
|
|
||||||
await invoke("apply_monitor_layout", { monitors });
|
|
||||||
await refresh();
|
|
||||||
} catch (err) {
|
|
||||||
message = `${err}`;
|
|
||||||
} finally {
|
|
||||||
applying = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setScale(value: number) {
|
|
||||||
if (!live) return;
|
|
||||||
live.scale = value;
|
|
||||||
monitors = [...monitors];
|
|
||||||
await apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setTransform(value: number) {
|
|
||||||
if (!live) return;
|
|
||||||
live.transform = value;
|
|
||||||
monitors = [...monitors];
|
|
||||||
await apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setMode(modeStr: string) {
|
|
||||||
if (!live) return;
|
|
||||||
const cleaned = modeStr.replace(/Hz$/i, "");
|
|
||||||
const [res, hz] = cleaned.split("@");
|
|
||||||
const [w, h] = (res ?? "").split("x");
|
|
||||||
const width = Number(w);
|
|
||||||
const height = Number(h);
|
|
||||||
const refresh = Number(hz);
|
|
||||||
if (!width || !height) return;
|
|
||||||
live.width = width;
|
|
||||||
live.height = height;
|
|
||||||
if (refresh) live.refresh = refresh;
|
|
||||||
live.mode = `${width}x${height} @ ${Math.round(live.refresh)}Hz`;
|
|
||||||
monitors = [...monitors];
|
|
||||||
await apply();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ViewScaffold title="Displays" lede="Drag to arrange. Edges snap. Changes apply now.">
|
|
||||||
<Group title="Arrangement" wide>
|
|
||||||
{#if monitors.length === 0}
|
|
||||||
<Hint text="No monitors detected." />
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="mon-stage"
|
|
||||||
role="application"
|
|
||||||
aria-label="Monitor arrangement canvas. Drag to reorder monitors."
|
|
||||||
bind:this={stageEl}
|
|
||||||
onpointermove={onMove}
|
|
||||||
onpointerup={onUp}
|
|
||||||
onpointercancel={onUp}
|
|
||||||
>
|
|
||||||
{#each monitors as m, i (m.name)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mon"
|
|
||||||
class:sel={selected === m.name}
|
|
||||||
class:dragging={drag?.i === i}
|
|
||||||
style="left:{screenX(m)}px; top:{screenY(m)}px; width:{screenW(m)}px; height:{screenH(m)}px;"
|
|
||||||
onpointerdown={(e) => onDown(e, i)}
|
|
||||||
>
|
|
||||||
<div class="screen"></div>
|
|
||||||
<div class="chin">{m.name}</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<Hint text={applying ? "Applying…" : "Drag a panel. It sticks when you let go."} />
|
|
||||||
{/if}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{#if live}
|
|
||||||
<Group title={live.name}>
|
|
||||||
<Row label="Resolution">
|
|
||||||
{#if live.available_modes.length > 0}
|
|
||||||
<select value={`${live.width}x${live.height}@${live.refresh.toFixed(2)}Hz`} onchange={(e) => setMode(e.currentTarget.value)}>
|
|
||||||
{#each live.available_modes as mode (mode)}
|
|
||||||
<option value={mode}>{mode}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
{:else}
|
|
||||||
<span class="mode">{live.mode}</span>
|
|
||||||
{/if}
|
|
||||||
</Row>
|
|
||||||
<Row label="Scale">
|
|
||||||
<div class="pills">
|
|
||||||
{#each SCALES as s (s.value)}
|
|
||||||
<button type="button" class="pill" class:on={nearestScale(live.scale) === s.value} onclick={() => setScale(s.value)}>
|
|
||||||
{s.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
<Row label="Rotation">
|
|
||||||
<div class="pills">
|
|
||||||
{#each TURNS as t (t.value)}
|
|
||||||
<button type="button" class="pill" class:on={live.transform % 4 === t.value} onclick={() => setTransform(t.value)}>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Row>
|
|
||||||
<Row label="Position">
|
|
||||||
<span class="mode">{live.x}, {live.y}</span>
|
|
||||||
</Row>
|
|
||||||
{#if message}
|
|
||||||
<Hint text={message} />
|
|
||||||
{/if}
|
|
||||||
</Group>
|
|
||||||
{/if}
|
|
||||||
</ViewScaffold>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.mon-stage {
|
|
||||||
position: relative;
|
|
||||||
height: 300px;
|
|
||||||
background: var(--bg);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
touch-action: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mon {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--surface-2, var(--surface));
|
|
||||||
border: 2px solid color-mix(in srgb, var(--fg) 10%, transparent);
|
|
||||||
box-shadow: 0 12px 28px #0005;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
cursor: grab;
|
|
||||||
padding: 0;
|
|
||||||
color: inherit;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mon.sel {
|
|
||||||
border-color: var(--accent);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mon.dragging {
|
|
||||||
cursor: grabbing;
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.screen {
|
|
||||||
flex: 1;
|
|
||||||
margin: 6px 6px 0;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: linear-gradient(160deg, var(--surface) 10%, var(--accent) 140%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chin {
|
|
||||||
height: 22px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
color-scheme: dark;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--on-surface);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
max-width: 28ch;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Hub from "$lib/components/Hub.svelte";
|
|
||||||
import Display from "./Display.svelte";
|
|
||||||
import NightLight from "./NightLight.svelte";
|
|
||||||
import Breadmon from "./Breadmon.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Hub
|
|
||||||
title="Displays"
|
|
||||||
lede="Drag to arrange. Scale and rotate apply now."
|
|
||||||
tabs={[
|
|
||||||
{ id: "hyprland", label: "Layout", component: Display },
|
|
||||||
{ id: "nightlight", label: "Night light", component: NightLight },
|
|
||||||
{ id: "breadmon", label: "Live arrange", component: Breadmon },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
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[]>([]);
|
|
||||||
|
|
||||||
onMount(refresh);
|
|
||||||
|
|
||||||
function friendlyError(raw: string): string {
|
|
||||||
const s = raw.toLowerCase();
|
|
||||||
if (
|
|
||||||
s.includes("polkit") ||
|
|
||||||
s.includes("pkexec") ||
|
|
||||||
s.includes("password") ||
|
|
||||||
s.includes("authentication") ||
|
|
||||||
s.includes("controlling terminal")
|
|
||||||
) {
|
|
||||||
return "Need your password to read firewall rules.";
|
|
||||||
}
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
try {
|
|
||||||
status = await invoke<FirewallStatus>("get_firewall_status");
|
|
||||||
enabledSensitive = true;
|
|
||||||
} catch (e) {
|
|
||||||
status = { error: friendlyError(`${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="Needs your password.">
|
|
||||||
<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="Loading…" hint="May ask for your password." />
|
|
||||||
{: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="UEFI and devices that speak fwupd."
|
|
||||||
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,315 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { go, nav } from "$lib/nav.svelte";
|
|
||||||
import { searchSettings, type SearchHit } from "$lib/search";
|
|
||||||
import Search from "@lucide/svelte/icons/search";
|
|
||||||
import Wifi from "@lucide/svelte/icons/wifi";
|
|
||||||
import BatteryFull from "@lucide/svelte/icons/battery-full";
|
|
||||||
import Download from "@lucide/svelte/icons/download";
|
|
||||||
import Monitor from "@lucide/svelte/icons/monitor";
|
|
||||||
import Palette from "@lucide/svelte/icons/palette";
|
|
||||||
import Volume2 from "@lucide/svelte/icons/volume-2";
|
|
||||||
import Keyboard from "@lucide/svelte/icons/keyboard";
|
|
||||||
import AppWindow from "@lucide/svelte/icons/app-window";
|
|
||||||
import Shield from "@lucide/svelte/icons/shield";
|
|
||||||
import Package from "@lucide/svelte/icons/package";
|
|
||||||
|
|
||||||
let query = $state("");
|
|
||||||
let searchEl: HTMLInputElement | undefined = $state();
|
|
||||||
let hits = $derived(query.trim() ? searchSettings(query) : []);
|
|
||||||
|
|
||||||
let wifiLabel = $state("Wi-Fi");
|
|
||||||
let wifiDetail = $state("Checking…");
|
|
||||||
let batteryLabel = $state("Battery");
|
|
||||||
let batteryDetail = $state("Checking…");
|
|
||||||
let updateLabel = $state("Updates");
|
|
||||||
let updateDetail = $state("Checking…");
|
|
||||||
|
|
||||||
const tiles: { id: string; tab?: string; title: string; desc: string; icon: typeof Wifi }[] = [
|
|
||||||
{ id: "network", title: "Wi-Fi & internet", desc: "Radio, saved networks, VPN.", icon: Wifi },
|
|
||||||
{ id: "displays", title: "Displays", desc: "Arrange, scale, night light.", icon: Monitor },
|
|
||||||
{ id: "appearance", title: "Appearance", desc: "Wallpaper drives the palette.", icon: Palette },
|
|
||||||
{ id: "sound", title: "Sound", desc: "Devices, volume, mute.", icon: Volume2 },
|
|
||||||
{ id: "power", title: "Power", desc: "Brightness, charge limit, battery.", icon: BatteryFull },
|
|
||||||
{ id: "input", title: "Keyboard & mouse", desc: "Layouts, keybinds, tap-to-click.", icon: Keyboard },
|
|
||||||
{ id: "desktop", title: "Bar & apps", desc: "Bar, launcher, lock, screenshots.", icon: AppWindow },
|
|
||||||
{ id: "privacy", title: "Privacy & users", desc: "Firewall and accounts.", icon: Shield },
|
|
||||||
{ id: "system", title: "Updates", desc: "Bakery, pacman, snapshots, restic.", icon: Package },
|
|
||||||
];
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (nav.searchNonce > 0) {
|
|
||||||
searchEl?.focus();
|
|
||||||
searchEl?.select();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
const net = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info");
|
|
||||||
if (!net.radio_enabled) {
|
|
||||||
wifiLabel = "Wi-Fi off";
|
|
||||||
wifiDetail = "Radio disabled";
|
|
||||||
} else {
|
|
||||||
const networks = await invoke<{ ssid: string; active: boolean; signal: number }[]>("scan_wifi").catch(
|
|
||||||
() => [],
|
|
||||||
);
|
|
||||||
const active = networks.find((n) => n.active);
|
|
||||||
if (active) {
|
|
||||||
wifiLabel = active.ssid;
|
|
||||||
wifiDetail = `Wi-Fi · ${active.signal}%`;
|
|
||||||
} else {
|
|
||||||
wifiLabel = "Wi-Fi";
|
|
||||||
wifiDetail = net.ethernet ? "On · no network" : "On";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
wifiDetail = "Unavailable";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const power = await invoke<{ battery: [string, string][]; power_source: string }>("get_power_info");
|
|
||||||
const charge = power.battery.find(([k]) => k === "Charge")?.[1] ?? "";
|
|
||||||
const remain = power.battery.find(([k]) => k === "Time remaining")?.[1];
|
|
||||||
batteryLabel = charge || power.power_source;
|
|
||||||
batteryDetail = remain ? `${remain}` : power.power_source;
|
|
||||||
} catch {
|
|
||||||
batteryDetail = "Unavailable";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const st = await invoke<{
|
|
||||||
pacman: unknown[];
|
|
||||||
bakery: unknown[];
|
|
||||||
firmware: unknown[];
|
|
||||||
}>("get_updates_status");
|
|
||||||
const n = st.pacman.length + st.bakery.length + st.firmware.length;
|
|
||||||
updateLabel = n === 0 ? "Up to date" : `${n} update${n === 1 ? "" : "s"}`;
|
|
||||||
updateDetail = n === 0 ? "bakery + pacman" : "bakery + pacman + firmware";
|
|
||||||
} catch {
|
|
||||||
updateDetail = "Unavailable";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function goHit(hit: SearchHit) {
|
|
||||||
go(hit.page, hit.tab);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="home">
|
|
||||||
<h1>Settings</h1>
|
|
||||||
<p class="lede">Wi-Fi, lock screen, updates, keybinds.</p>
|
|
||||||
|
|
||||||
<div class="search-wrap">
|
|
||||||
<Search size={18} />
|
|
||||||
<input
|
|
||||||
bind:this={searchEl}
|
|
||||||
class="bigsearch"
|
|
||||||
bind:value={query}
|
|
||||||
placeholder="Try “night light”, “keybinds”, “forget network”…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if hits.length > 0}
|
|
||||||
<div class="results">
|
|
||||||
{#each hits as hit (`${hit.page}:${hit.tab ?? ""}:${hit.label}`)}
|
|
||||||
<button class="hit" onclick={() => goHit(hit)}>
|
|
||||||
<span>{hit.label}</span>
|
|
||||||
<em>{hit.tab ?? hit.page}</em>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="status">
|
|
||||||
<button class="stat" onclick={() => go("network")}>
|
|
||||||
<Wifi size={18} />
|
|
||||||
<div><b>{wifiLabel}</b><span>{wifiDetail}</span></div>
|
|
||||||
</button>
|
|
||||||
<button class="stat" onclick={() => go("power")}>
|
|
||||||
<BatteryFull size={18} />
|
|
||||||
<div><b>{batteryLabel}</b><span>{batteryDetail}</span></div>
|
|
||||||
</button>
|
|
||||||
<button class="stat" onclick={() => go("system")}>
|
|
||||||
<Download size={18} />
|
|
||||||
<div><b>{updateLabel}</b><span>{updateDetail}</span></div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tiles">
|
|
||||||
{#each tiles as tile (tile.id + (tile.tab ?? ""))}
|
|
||||||
<button class="tile" onclick={() => go(tile.id, tile.tab)}>
|
|
||||||
<div class="ico"><tile.icon size={18} /></div>
|
|
||||||
<div class="t">{tile.title}</div>
|
|
||||||
<div class="d">{tile.desc}</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.home {
|
|
||||||
padding: 28px 36px 56px;
|
|
||||||
max-width: 1080px;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lede {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap {
|
|
||||||
position: relative;
|
|
||||||
margin-top: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrap :global(svg) {
|
|
||||||
position: absolute;
|
|
||||||
left: 16px;
|
|
||||||
top: 16px;
|
|
||||||
opacity: 0.45;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bigsearch {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 14px 16px 14px 44px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat :global(svg) {
|
|
||||||
color: var(--accent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat b {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat span {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiles {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile {
|
|
||||||
text-align: left;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 16px 16px 14px;
|
|
||||||
min-height: 108px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
|
||||||
transition:
|
|
||||||
transform 0.2s cubic-bezier(0.22, 1, 0.36, 1),
|
|
||||||
border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: color-mix(in srgb, var(--fg) 18%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ico {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: color-mix(in oklab, var(--fg) 9%, transparent);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.t {
|
|
||||||
font-size: 14.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.d {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hit {
|
|
||||||
text-align: left;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hit:hover {
|
|
||||||
background: var(--surface-2, var(--surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.hit em {
|
|
||||||
font-style: normal;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
|
||||||
.status,
|
|
||||||
.tiles {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Hub from "$lib/components/Hub.svelte";
|
|
||||||
import Keybinds from "./Keybinds.svelte";
|
|
||||||
import InputMethod from "./InputMethod.svelte";
|
|
||||||
import Accessibility from "./Accessibility.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Hub
|
|
||||||
title="Keyboard & mouse"
|
|
||||||
lede="Shortcuts, input method, and accessibility."
|
|
||||||
tabs={[
|
|
||||||
{ id: "keybinds", label: "Shortcuts", component: Keybinds },
|
|
||||||
{ id: "ime", label: "Input method", component: InputMethod },
|
|
||||||
{ id: "accessibility", label: "Accessibility", component: Accessibility },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
@ -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="Starts fcitx5. Running apps need a logout."
|
|
||||||
>
|
|
||||||
{#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 ? "" : " (missing)"}</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,786 +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;
|
|
||||||
capturing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
|
||||||
exec: "Run command",
|
|
||||||
close: "Close window",
|
|
||||||
fullscreen: "Fullscreen",
|
|
||||||
float: "Float window",
|
|
||||||
pseudo: "Fake fullscreen",
|
|
||||||
resize: "Resize",
|
|
||||||
focus: "Go to workspace",
|
|
||||||
focus_last: "Last workspace",
|
|
||||||
move: "Move window to workspace",
|
|
||||||
move_dir: "Move window",
|
|
||||||
resize_dir: "Resize window",
|
|
||||||
layout: "Layout",
|
|
||||||
drag: "Mouse drag",
|
|
||||||
exit: "Exit Hyprland",
|
|
||||||
};
|
|
||||||
|
|
||||||
const KEY_LABELS: Record<string, string> = {
|
|
||||||
RETURN: "Enter",
|
|
||||||
SPACE: "Space",
|
|
||||||
ESCAPE: "Esc",
|
|
||||||
BACKSPACE: "Backspace",
|
|
||||||
TAB: "Tab",
|
|
||||||
SUPER: "Super",
|
|
||||||
CTRL: "Ctrl",
|
|
||||||
ALT: "Alt",
|
|
||||||
SHIFT: "Shift",
|
|
||||||
XF86AudioRaiseVolume: "Vol +",
|
|
||||||
XF86AudioLowerVolume: "Vol -",
|
|
||||||
XF86AudioMute: "Mute",
|
|
||||||
XF86AudioMicMute: "Mic mute",
|
|
||||||
XF86MonBrightnessUp: "Bright +",
|
|
||||||
XF86MonBrightnessDown: "Bright -",
|
|
||||||
XF86AudioNext: "Next",
|
|
||||||
XF86AudioPrev: "Prev",
|
|
||||||
XF86AudioPlay: "Play",
|
|
||||||
Print: "Print",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CODE_TO_HYPR: Record<string, string> = {
|
|
||||||
Space: "SPACE",
|
|
||||||
Enter: "RETURN",
|
|
||||||
Escape: "ESCAPE",
|
|
||||||
Backspace: "BACKSPACE",
|
|
||||||
Tab: "TAB",
|
|
||||||
AudioVolumeUp: "XF86AudioRaiseVolume",
|
|
||||||
AudioVolumeDown: "XF86AudioLowerVolume",
|
|
||||||
AudioVolumeMute: "XF86AudioMute",
|
|
||||||
AudioMicMute: "XF86AudioMicMute",
|
|
||||||
BrightnessUp: "XF86MonBrightnessUp",
|
|
||||||
BrightnessDown: "XF86MonBrightnessDown",
|
|
||||||
MediaTrackNext: "XF86AudioNext",
|
|
||||||
MediaTrackPrevious: "XF86AudioPrev",
|
|
||||||
MediaPlayPause: "XF86AudioPlay",
|
|
||||||
PrintScreen: "Print",
|
|
||||||
};
|
|
||||||
|
|
||||||
function keyLabel(k: string): string {
|
|
||||||
return KEY_LABELS[k] ?? k;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindTitle(row: EditRow): string {
|
|
||||||
if (row.action === "exec") {
|
|
||||||
const cmd = String(row.extraValue.command ?? "");
|
|
||||||
if (cmd.includes("set-volume") && cmd.includes("+")) return "Volume up";
|
|
||||||
if (cmd.includes("set-volume") && cmd.includes("-")) return "Volume down";
|
|
||||||
if (cmd.includes("set-mute") && cmd.includes("SINK")) return "Mute";
|
|
||||||
if (cmd.includes("set-mute") && cmd.includes("SOURCE")) return "Mute mic";
|
|
||||||
if (cmd.includes("brightnessctl") && cmd.includes("+")) return "Brightness up";
|
|
||||||
if (cmd.includes("brightnessctl") && cmd.includes("-")) return "Brightness down";
|
|
||||||
if (cmd.includes("playerctl next")) return "Next track";
|
|
||||||
if (cmd.includes("playerctl previous")) return "Previous track";
|
|
||||||
if (cmd.includes("playerctl play-pause")) return "Play/pause";
|
|
||||||
if (cmd) return cmd.split(/\s+/)[0].split("/").pop() ?? "Command";
|
|
||||||
}
|
|
||||||
return ACTION_LABELS[row.action] ?? row.action;
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyChips(row: EditRow): string[] {
|
|
||||||
const mods = textToMods(row.mods);
|
|
||||||
const key = row.key.trim();
|
|
||||||
return [...mods, key].filter(Boolean).map(keyLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hyprFromEvent(e: KeyboardEvent): { mods: string[]; key: string } | null {
|
|
||||||
if (["Shift", "Control", "Alt", "Meta"].includes(e.key)) return null;
|
|
||||||
const mods: string[] = [];
|
|
||||||
if (e.metaKey) mods.push("SUPER");
|
|
||||||
if (e.ctrlKey) mods.push("CTRL");
|
|
||||||
if (e.altKey) mods.push("ALT");
|
|
||||||
if (e.shiftKey) mods.push("SHIFT");
|
|
||||||
let key = CODE_TO_HYPR[e.code] ?? CODE_TO_HYPR[e.key];
|
|
||||||
if (!key) {
|
|
||||||
if (e.code.startsWith("Key") && e.code.length === 4) key = e.code.slice(3);
|
|
||||||
else if (e.code.startsWith("Digit")) key = e.code.slice(5);
|
|
||||||
else if (e.code.startsWith("F") && /^F\d+$/.test(e.code)) key = e.code;
|
|
||||||
else key = e.key.length === 1 ? e.key.toUpperCase() : e.key;
|
|
||||||
}
|
|
||||||
return { mods, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
function allRows(): EditRow[] {
|
|
||||||
return [...globalsRows, ...commonRows, ...bindingsRows, ...Object.values(layoutRows).flat()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginCapture(row: EditRow) {
|
|
||||||
for (const r of allRows()) r.capturing = false;
|
|
||||||
row.capturing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
capturing: 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,
|
|
||||||
capturing: 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(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
const row = allRows().find((r) => r.capturing);
|
|
||||||
if (!row) return;
|
|
||||||
const mapped = hyprFromEvent(e);
|
|
||||||
if (!mapped) return;
|
|
||||||
e.preventDefault();
|
|
||||||
row.mods = mapped.mods.join(", ");
|
|
||||||
row.modsTouched = true;
|
|
||||||
row.key = mapped.key;
|
|
||||||
row.capturing = false;
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
void loadKeybinds();
|
|
||||||
// Register cleanup synchronously so Svelte types the onMount
|
|
||||||
// callback as returning a function (not a Promise-of-function).
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadKeybinds() {
|
|
||||||
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.</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">
|
|
||||||
<span class="what">{bindTitle(row)}</span>
|
|
||||||
<button type="button" class="capture" class:listening={row.capturing} onclick={() => beginCapture(row)}>
|
|
||||||
{#if row.capturing}
|
|
||||||
Press a key
|
|
||||||
{:else if keyChips(row).length}
|
|
||||||
{#each keyChips(row) as k, ki (`${k}-${ki}`)}
|
|
||||||
{#if ki > 0}<span class="plus">+</span>{/if}
|
|
||||||
<span class="kbd">{k}</span>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
Set shortcut
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<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="Click a shortcut, then press the keys. Save when you are done."
|
|
||||||
>
|
|
||||||
<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="Click a shortcut, then press the keys. Save when you are done."
|
|
||||||
>
|
|
||||||
{#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);
|
|
||||||
padding: 10px 2px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bind-card:first-child {
|
|
||||||
border-top: none;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bind-top,
|
|
||||||
.bind-bottom {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm, 8px);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.what {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 12ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.capture {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
min-height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.capture.listening {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plus {
|
|
||||||
opacity: 0.4;
|
|
||||||
padding: 0 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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,274 +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 InfoRow from "$lib/components/InfoRow.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("");
|
|
||||||
|
|
||||||
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";
|
|
||||||
});
|
|
||||||
let activeNet = $derived(networks?.find((n) => n.active) ?? null);
|
|
||||||
let nearby = $derived(networks?.filter((n) => !n.active) ?? []);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
const info = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info");
|
|
||||||
radioEnabled = info.radio_enabled;
|
|
||||||
ethernet = info.ethernet;
|
|
||||||
if (info.radio_enabled) await scan();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function toggleRadio(enabled: boolean) {
|
|
||||||
radioEnabled = enabled;
|
|
||||||
await invoke("set_wifi_radio", { enabled });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scan() {
|
|
||||||
scanning = true;
|
|
||||||
try {
|
|
||||||
networks = await invoke<WifiNetwork[]>("scan_wifi");
|
|
||||||
} finally {
|
|
||||||
scanning = false;
|
|
||||||
status = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`;
|
|
||||||
await scan();
|
|
||||||
} catch (e) {
|
|
||||||
status = `Failed: ${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}`;
|
|
||||||
await scan();
|
|
||||||
} catch {
|
|
||||||
status = "Wrong password?";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bars(signal: number): number {
|
|
||||||
if (signal >= 75) return 4;
|
|
||||||
if (signal >= 50) return 3;
|
|
||||||
if (signal >= 25) return 2;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ViewScaffold title="Wi-Fi">
|
|
||||||
<Group title="Radio">
|
|
||||||
<SwitchField label="Wi-Fi" hint={radioEnabled ? "On" : "Off"} bind:value={() => radioEnabled, (v) => toggleRadio(v)} />
|
|
||||||
{#if ethernetLabel}
|
|
||||||
<InfoRow label="Ethernet" value={ethernetLabel} />
|
|
||||||
{/if}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{#if activeNet}
|
|
||||||
<Group title="This network">
|
|
||||||
<div class="wifi">
|
|
||||||
<div class="bars on">
|
|
||||||
{#each [1, 2, 3, 4] as n (n)}
|
|
||||||
<b style="height: {3 + n * 3}px" class:lit={bars(activeNet.signal) >= n}></b>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="ssid">
|
|
||||||
<strong>{activeNet.ssid}</strong>
|
|
||||||
<small>{activeNet.secured ? "Secured" : "Open"} · {activeNet.signal}%</small>
|
|
||||||
</div>
|
|
||||||
<span class="pill on">Connected</span>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Group title="Nearby" wide>
|
|
||||||
<div class="list">
|
|
||||||
{#if scanning && networks === null}
|
|
||||||
<div class="empty">
|
|
||||||
<Wifi size={36} />
|
|
||||||
<span>Scanning…</span>
|
|
||||||
</div>
|
|
||||||
{:else if networks === null}
|
|
||||||
<div class="empty">
|
|
||||||
<Wifi size={36} />
|
|
||||||
<span>No scan yet</span>
|
|
||||||
</div>
|
|
||||||
{:else if nearby.length === 0 && !activeNet}
|
|
||||||
<div class="empty">
|
|
||||||
<WifiOff size={36} />
|
|
||||||
<span>No networks found</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
{#each nearby as net (net.ssid)}
|
|
||||||
<div class="wifi">
|
|
||||||
<div class="bars">
|
|
||||||
{#each [1, 2, 3, 4] as n (n)}
|
|
||||||
<b style="height: {3 + n * 3}px" class:lit={bars(net.signal) >= n}></b>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="ssid">
|
|
||||||
{net.ssid}
|
|
||||||
<small>{net.known ? "Saved" : net.secured ? "Secured" : "Open"}</small>
|
|
||||||
</div>
|
|
||||||
{#if net.secured}<Lock size={14} />{/if}
|
|
||||||
<button class="btn" onclick={() => connect(net.ssid, net.secured, net.known)}>Connect</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if pendingSsid}
|
|
||||||
<div class="pw-row">
|
|
||||||
<input type="password" bind:value={password} placeholder="Password for {pendingSsid}" />
|
|
||||||
<button class="btn primary" onclick={connectWithPassword}>Connect</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if status}
|
|
||||||
<span class="status">{status}</span>
|
|
||||||
{/if}
|
|
||||||
<button class="btn" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group title="Advanced">
|
|
||||||
<button class="btn" onclick={() => invoke("open_connection_editor")}>Connection editor</button>
|
|
||||||
</Group>
|
|
||||||
</ViewScaffold>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.list {
|
|
||||||
min-height: 72px;
|
|
||||||
max-height: 280px;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 20px 0;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi + .wifi {
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bars {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 2px;
|
|
||||||
height: 14px;
|
|
||||||
width: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bars b {
|
|
||||||
width: 3px;
|
|
||||||
background: color-mix(in srgb, var(--fg) 22%, transparent);
|
|
||||||
border-radius: 1px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bars b.lit,
|
|
||||||
.bars.on b.lit {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ssid {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ssid small {
|
|
||||||
display: block;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pw-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pw-row input {
|
|
||||||
flex: 1;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: block;
|
|
||||||
margin: 8px 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
margin-top: 10px;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wifi .btn {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Hub from "$lib/components/Hub.svelte";
|
|
||||||
import Network from "./Network.svelte";
|
|
||||||
import Vpn from "./Vpn.svelte";
|
|
||||||
import Breadcrumbs from "./Breadcrumbs.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Hub
|
|
||||||
title="Wi-Fi & internet"
|
|
||||||
lede="Wi-Fi, saved networks, and VPN."
|
|
||||||
tabs={[
|
|
||||||
{ id: "network", label: "Wi-Fi", component: Network },
|
|
||||||
{ id: "vpn", label: "VPN", component: Vpn },
|
|
||||||
{ id: "breadcrumbs", label: "Saved networks", component: Breadcrumbs },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,85 +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 SwitchField from "$lib/components/SwitchField.svelte";
|
|
||||||
import SliderField from "$lib/components/SliderField.svelte";
|
|
||||||
import LogView from "$lib/components/LogView.svelte";
|
|
||||||
import { debounce } from "$lib/debounce";
|
|
||||||
|
|
||||||
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 applyNow() {
|
|
||||||
if (!st) return;
|
|
||||||
message = "";
|
|
||||||
try {
|
|
||||||
st = await invoke<NightlightStatus>("set_nightlight", {
|
|
||||||
enabled: st.enabled,
|
|
||||||
temperature: st.temperature,
|
|
||||||
});
|
|
||||||
if (st.error) message = st.error;
|
|
||||||
} catch (e) {
|
|
||||||
message = `${e}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const persistTemp = debounce(applyNow, 200);
|
|
||||||
|
|
||||||
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="Night light" hint="Warmer screen after dark.">
|
|
||||||
{#if !st}
|
|
||||||
<Hint text="Loading…" />
|
|
||||||
{:else if !st.installed}
|
|
||||||
<Hint text="hyprsunset is not installed." />
|
|
||||||
<button class="btn primary" disabled={busy} onclick={install}>Install</button>
|
|
||||||
{:else}
|
|
||||||
<SwitchField
|
|
||||||
label="Night light"
|
|
||||||
bind:value={() => st!.enabled, (v) => { st!.enabled = v; applyNow(); }}
|
|
||||||
/>
|
|
||||||
{#if st.enabled}
|
|
||||||
<SliderField
|
|
||||||
label="Warmth"
|
|
||||||
bind:value={st.temperature}
|
|
||||||
min={2000}
|
|
||||||
max={6500}
|
|
||||||
step={100}
|
|
||||||
suffix="K"
|
|
||||||
onChange={persistTemp}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if message}<Hint text={message} />{/if}
|
|
||||||
</Group>
|
|
||||||
<LogView lines={log} />
|
|
||||||
</ViewScaffold>
|
|
||||||
|
|
@ -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="Official repos. Needs your password."
|
|
||||||
>
|
|
||||||
<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,101 +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 & battery"
|
|
||||||
lede="Brightness, battery, and charge limits."
|
|
||||||
>
|
|
||||||
{#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="Keeps the battery off 100% when this machine stays plugged in."
|
|
||||||
>
|
|
||||||
<Row label="Start charging below">
|
|
||||||
<input type="range" min="0" max="100" bind:value={chargeStart} onchange={() => setChargeThreshold("start", chargeStart)} />
|
|
||||||
<span class="pct">{chargeStart}%</span>
|
|
||||||
</Row>
|
|
||||||
<Row label="Stop charging at">
|
|
||||||
<input type="range" min="1" max="100" bind:value={chargeEnd} onchange={() => setChargeThreshold("end", chargeEnd)} />
|
|
||||||
<span class="pct">{chargeEnd}%</span>
|
|
||||||
</Row>
|
|
||||||
</Group>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Group
|
|
||||||
title="TLP"
|
|
||||||
hint="TLP picks battery vs AC on its own."
|
|
||||||
>
|
|
||||||
<InfoRow label="Current profile" value={info.tlp_profile ?? "unknown"} />
|
|
||||||
</Group>
|
|
||||||
{/if}
|
|
||||||
</ViewScaffold>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
input[type="range"] {
|
|
||||||
width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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,14 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Hub from "$lib/components/Hub.svelte";
|
|
||||||
import Firewall from "./Firewall.svelte";
|
|
||||||
import Users from "./Users.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Hub
|
|
||||||
title="Privacy & users"
|
|
||||||
lede="Firewall and accounts."
|
|
||||||
tabs={[
|
|
||||||
{ id: "firewall", label: "Firewall", component: Firewall },
|
|
||||||
{ id: "users", label: "Users", component: Users },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
@ -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 is not in ALLOW_USERS for snapper.";
|
|
||||||
} 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="Reboot and pick a snapshot in GRUB to undo an update." />
|
|
||||||
<Group
|
|
||||||
title="What to pick in GRUB"
|
|
||||||
hint="Reboot and choose that entry in GRUB."
|
|
||||||
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,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 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);
|
|
||||||
|
|
||||||
function pick(devices: SoundDevice[], defaultName: string | null): number {
|
|
||||||
const i = devices.findIndex((d) => d.name === defaultName);
|
|
||||||
return i >= 0 ? i : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSection(kind: "sinks" | "sources", title: string): Promise<DeviceSection> {
|
|
||||||
const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", {
|
|
||||||
kind,
|
|
||||||
});
|
|
||||||
return { kind, title, devices: section.devices, selected: pick(section.devices, section.default_name) };
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
output = await loadSection("sinks", "Output");
|
|
||||||
input = await loadSection("sources", "Input");
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
output = { kind: "sinks", title: "Output", devices: [], selected: 0 };
|
|
||||||
input = { kind: "sources", title: "Input", devices: [], selected: 0 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function current(section: DeviceSection): SoundDevice | null {
|
|
||||||
return section.devices[section.selected] ?? section.devices[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectDevice(section: DeviceSection, index: number) {
|
|
||||||
const device = section.devices[index];
|
|
||||||
if (!device) return;
|
|
||||||
section.selected = index;
|
|
||||||
await invoke("set_default_sound_device", { kind: section.kind, name: device.name });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setVolume(section: DeviceSection, percent: number) {
|
|
||||||
const device = current(section);
|
|
||||||
if (!device) return;
|
|
||||||
device.percent = percent;
|
|
||||||
await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent: Math.round(percent) });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setMute(section: DeviceSection, mute: boolean) {
|
|
||||||
const device = current(section);
|
|
||||||
if (!device) return;
|
|
||||||
device.mute = mute;
|
|
||||||
await invoke("set_sound_mute", { kind: section.kind, name: device.name, mute });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet sectionCard(section: DeviceSection)}
|
|
||||||
<Group title={section.title}>
|
|
||||||
{#if section.devices.length === 0}
|
|
||||||
<Hint text="No devices found." />
|
|
||||||
{:else}
|
|
||||||
{#each section.devices as d, i (d.name + i)}
|
|
||||||
<button type="button" class="dev" class:on={section.selected === i} onclick={() => selectDevice(section, i)}>
|
|
||||||
<span class="dev-name">{d.description || d.name}</span>
|
|
||||||
{#if section.selected === i}<span class="mark">In use</span>{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{#if current(section)}
|
|
||||||
<Row label="Volume">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="150"
|
|
||||||
value={Math.round(current(section)!.percent)}
|
|
||||||
oninput={(e) => setVolume(section, Number(e.currentTarget.value))}
|
|
||||||
/>
|
|
||||||
<span class="pct">{Math.round(current(section)!.percent)}%</span>
|
|
||||||
</Row>
|
|
||||||
<SwitchField
|
|
||||||
label="Mute"
|
|
||||||
bind:value={() => current(section)!.mute, (v) => setMute(section, v)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</Group>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<ViewScaffold title="Sound" lede="Output, input, and volume.">
|
|
||||||
{#if output}
|
|
||||||
{@render sectionCard(output)}
|
|
||||||
{/if}
|
|
||||||
{#if input}
|
|
||||||
{@render sectionCard(input)}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Group title="Advanced">
|
|
||||||
<button class="secondary" onclick={() => invoke("open_mixer")}>Per-app volume</button>
|
|
||||||
</Group>
|
|
||||||
</ViewScaffold>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dev {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--line, #ffffff12);
|
|
||||||
padding: 10px 2px;
|
|
||||||
color: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dev:first-child {
|
|
||||||
border-top: none;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dev.on .dev-name {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dev-name {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--on-accent);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="range"] {
|
|
||||||
width: 180px;
|
|
||||||
accent-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pct {
|
|
||||||
margin-left: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.7;
|
|
||||||
min-width: 4ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary {
|
|
||||||
background-color: var(--bg);
|
|
||||||
color: var(--on-surface);
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Hub from "$lib/components/Hub.svelte";
|
|
||||||
import Updates from "./Updates.svelte";
|
|
||||||
import Packages from "./Packages.svelte";
|
|
||||||
import Aur from "./Aur.svelte";
|
|
||||||
import Firmware from "./Firmware.svelte";
|
|
||||||
import Snapshots from "./Snapshots.svelte";
|
|
||||||
import Backup from "./Backup.svelte";
|
|
||||||
import Channel from "./Channel.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Hub
|
|
||||||
title="Updates & backup"
|
|
||||||
lede="Updates, snapshots, and backups."
|
|
||||||
tabs={[
|
|
||||||
{ id: "updates", label: "Updates", component: Updates },
|
|
||||||
{ id: "packages", label: "Packages", component: Packages },
|
|
||||||
{ id: "aur", label: "AUR", component: Aur },
|
|
||||||
{ id: "firmware", label: "Firmware", component: Firmware },
|
|
||||||
{ id: "snapshots", label: "Snapshots", component: Snapshots },
|
|
||||||
{ id: "backup", label: "Backup", component: Backup },
|
|
||||||
{ id: "channel", label: "Channel", component: Channel },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
@ -1,250 +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 { go } from "$lib/nav.svelte";
|
|
||||||
import Download from "@lucide/svelte/icons/download";
|
|
||||||
import Cpu from "@lucide/svelte/icons/cpu";
|
|
||||||
|
|
||||||
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 packages waiting to update."
|
|
||||||
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="Pick the snapshot in GRUB. bakery rollback undoes one package." />
|
|
||||||
<div class="btn-row">
|
|
||||||
<button onclick={() => go("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>
|
|
||||||
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