diff --git a/.claude/rules/hook.md b/.claude/rules/hook.md
index 2f76a2bf0f3e04982dc758a8ade41b0cf3aab1f4..1a36f08bca755f9e8d9b4197e05e89e60cf598cc 100644
--- a/.claude/rules/hook.md
+++ b/.claude/rules/hook.md
@@ -10,6 +10,12 @@ paths:
run-loop slice — a stopped watcher after grant once froze all input on the machine.
Don't restructure it casually, and don't migrate the tap to `objc2-core-graphics`
(the `NSWorkspace` read is the only part that moved to `objc2`).
+- The tap callback must never block and never panic: use `try_read`/`try_lock` only,
+ queue bound actions off-thread, wrap the user callback in `catch_unwind`, and keep
+ the stuck-callback watchdog that force-exits the agent if the budget is exceeded.
+ An active HID-level tap serialises every pointer event; a hang freezes clicks
+ machine-wide. Only suppress events from remappable Logitech sources
+ (`source_is_remappable`) — never the built-in trackpad.
- The off-main `frontmost_bundle_id` read keeps its explicit `autoreleasepool` — the
watcher thread has no run loop; that is the only place in this crate a pool belongs.
- This crate ships non-macOS implementations (evdev/uinput, WH_MOUSE_LL) that a
diff --git a/.claude/rules/i18n.md b/.claude/rules/i18n.md
index 601abd129ca619a0060410866318a43caa130f04..21984a8d40fc3cc81b8df6dc180757453b88fbba 100644
--- a/.claude/rules/i18n.md
+++ b/.claude/rules/i18n.md
@@ -6,19 +6,25 @@ paths:
# i18n (rust-i18n + Crowdin)
-- `locales/en.yml` is the source of truth (the English text IS the key); every other
- `<code>.yml` is a flat `"English key": "translation"` map. The YAML is parsed at
- **compile time** — a syntax error fails the entire GUI build. Quote values containing
- `: ` or a trailing `:`.
-- The parity test (`i18n.rs`, `locale_files_have_the_same_keys`) compares **ordered**
- key lists against `en.yml`. A new string must be inserted at the same position in
- every locale file, all in the same change, or the test blocks the push.
-- When translating a new key, echo the file's existing wording for sibling terms
- (e.g. match how it already renders "Middle Click").
+- **`locales/en.yml` is the only source of truth** for UI strings (the English
+ text IS the key). When adding or changing copy, edit **`en.yml` only**.
+- **Do not hand-edit** other `locales/<code>.yml` files for new strings. Crowdin
+ owns non-English catalogs; the Crowdin workflow downloads them into
+ `crowdin/i18n`. Untranslated keys fall back to English at runtime
+ (`rust_i18n` fallback `"en"`).
+- YAML is parsed at **compile time** — a syntax error fails the entire GUI
+ build. Quote values containing `: ` or a trailing `:`.
+- The locale key test (`i18n.rs`, `locale_files_keys_are_subset_of_en`) only
+ checks that non-English files do **not** invent keys missing from `en.yml`.
+ They may lag `en.yml` until Crowdin syncs; they no longer must match key-for-key
+ in the same change.
- Adding a locale: drop the `.yml` in `locales/` and add the `SUPPORTED` entry in
- `i18n.rs` (picker order: native-name alphabetical per script). The test derives the
- locale list from both and asserts they match.
-- Check with `cargo test -p openlogi-gui i18n`. CI's Linux tests exclude the GUI, so
- this runs on macOS CI and locally (needs the Xcode/Metal env from devenv).
-- Crowdin syncs `en.yml` (root `crowdin.yml`); hand-written translations for new keys
- should be seeded into Crowdin, or the next download reverts them.
+ `i18n.rs` (picker order: native-name alphabetical per script). Keep the
+ include list in the subset test in sync.
+- Check with `cargo test -p openlogi-gui i18n`. CI's Linux tests exclude the GUI,
+ so this runs on macOS CI and locally (needs the Xcode/Metal env from devenv).
+- Crowdin workflow (root `crowdin.yml` + `.github/workflows/crowdin.yml`):
+ uploads **`en.yml` sources** and **per-language translations from git**, then
+ downloads Crowdin’s export. That seeds Crowdin so real de/ja/… strings are not
+ wiped by English fill-in, and brings translator progress back via
+ `crowdin/i18n`. Feature PRs still only need `en.yml` for new copy.
diff --git a/.github/actions/github-app-token-from-1password/action.yml b/.github/actions/github-app-token-from-1password/action.yml
index d7b43e53fa6e628383e46214f1dc3120cea7a0f9..f95450178db66ca479512ef2efba8711b6485255 100644
--- a/.github/actions/github-app-token-from-1password/action.yml
+++ b/.github/actions/github-app-token-from-1password/action.yml
@@ -41,7 +41,8 @@ runs:
# The App private key is stored base64-encoded in 1Password (a raw PEM's
# newlines get mangled to spaces on paste). Decode it here; base64 ignores
- # whitespace, so it survives re-mangling.
+ # whitespace, so it survives re-mangling. Mask each PEM line so the runner
+ # does not echo the key when create-github-app-token logs its `with:` block.
- name: Decode GitHub App private key
id: app-key
shell: bash
@@ -49,6 +50,11 @@ runs:
APP_KEY_B64: ${{ steps.load-secrets.outputs.GITHUB_APP_PRIVATE_KEY }}
run: |
key="$(printf '%s' "$APP_KEY_B64" | tr -d '[:space:]' | base64 -d)"
+ while IFS= read -r line || [ -n "${line}" ]; do
+ if [ -n "${line}" ]; then
+ echo "::add-mask::${line}"
+ fi
+ done <<< "${key}"
{
echo 'pem<<__PEM__'
printf '%s\n' "$key"
diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml
index 575a854dfd332182c5ec3f852bc9380464bec9d5..3abd9cf5afbf8c09c862ef7a410c86f3ebc0bf00 100644
--- a/.github/workflows/crowdin.yml
+++ b/.github/workflows/crowdin.yml
@@ -8,9 +8,11 @@ on:
branches:
- master
paths:
- - crowdin.yml
+ # English source is SoT; non-English catalogs come from Crowdin only.
- crates/openlogi-gui/locales/en.yml
+ - crowdin.yml
- .github/workflows/crowdin.yml
+ - .github/actions/github-app-token-from-1password/**
permissions:
contents: read
@@ -26,8 +28,13 @@ jobs:
if: github.repository == 'AprilNEA/OpenLogi'
steps:
+ # persist-credentials must stay false: checkout's default GITHUB_TOKEN
+ # http.extraheader would win over the app token when crowdin/github-action
+ # pushes crowdin/i18n (403 as github-actions[bot] despite env GITHUB_TOKEN).
- name: Checkout
uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- name: Mint GitHub App token
id: github_app
@@ -36,6 +43,13 @@ jobs:
op-service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
op-github-app-item: ${{ secrets.OP_GITHUB_APP_ITEM }}
+ - name: Configure git for App token
+ env:
+ GH_TOKEN: ${{ steps.github_app.outputs.token }}
+ run: |
+ set -euo pipefail
+ git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
+
- name: Load Crowdin credentials from 1Password
id: crowdin_config
uses: 1password/load-secrets-action@eb2efd0703da22a93c467f2d1ffbb6826c11e19c # v4.1.1
@@ -46,21 +60,32 @@ jobs:
CROWDIN_PROJECT_ID: ${{ secrets.OP_CROWDIN_SECRET_ITEM }}/CROWDIN_PROJECT_ID
CROWDIN_PERSONAL_TOKEN: ${{ secrets.OP_CROWDIN_SECRET_ITEM }}/CROWDIN_PERSONAL_TOKEN
+ # en.yml = English source of truth. Non-English catalogs in git are seeded
+ # into Crowdin (per language) so the download does not wipe real
+ # translations with English fill-in. Crowdin is where people improve
+ # translations; the bot PR brings their work back into the repo.
- name: Synchronize with Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
with:
config: crowdin.yml
upload_sources: true
- upload_translations: false
+ # Seed each language from git so Crowdin learns existing translations
+ # (de/ja/…). import_eq_suggestions false: value==English is not a
+ # translation and must not overwrite Crowdin work as "done".
+ upload_translations: true
+ import_eq_suggestions: false
download_translations: true
localization_branch_name: crowdin/i18n
commit_message: "chore(i18n): sync Crowdin translations"
create_pull_request: true
pull_request_title: "chore(i18n): sync Crowdin translations"
pull_request_body: |
- Automated translation sync from Crowdin.
+ Automated per-language translation sync from Crowdin.
- This workflow is intentionally limited by `export_languages` in `crowdin.yml` so unfinished Crowdin target languages are not exported into the app.
+ - `en.yml` is the English source of truth (new UI strings go there).
+ - Existing non-English catalogs are uploaded to Crowdin first so real translations are not replaced with English fill-in.
+ - This PR is Crowdin → git for translated locales only (`export_languages` in `crowdin.yml`).
+ - Truly untranslated keys may still match English until translators finish them in Crowdin.
github_user_name: Crowdin Bot
github_user_email: support+bot@crowdin.com
env:
diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml
index cd12b90965c11803beeb542e664c17bfee8d486c..ec58fbaba9db1523ae0c7bd71af245eb97eae05f 100644
--- a/.github/workflows/release-plz.yml
+++ b/.github/workflows/release-plz.yml
@@ -58,6 +58,48 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
CARGO_REGISTRY_TOKEN: ${{ steps.app-token.outputs.cargo-registry-token }}
+ # Whole-repo CHANGELOG via git-cliff (cliff.toml). release-plz is
+ # package-path-scoped and skips release=false app crates; git-cliff is the
+ # GoReleaser-style "every conventional commit since last tag" path.
+ - name: Write root changelog with git-cliff
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: |
+ set -euo pipefail
+ branch="$(
+ gh pr list \
+ --repo "${{ github.repository }}" \
+ --state open \
+ --json headRefName \
+ --jq '.[] | select(.headRefName | startswith("release-plz/")) | .headRefName' \
+ | head -n1
+ )"
+ if [[ -z "${branch}" ]]; then
+ echo "No open release-plz PR — nothing to write."
+ exit 0
+ fi
+ git config user.name "aprilnea[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
+ # Script/config may not be on the release-plz branch yet — copy from master.
+ cp cliff.toml "${RUNNER_TEMP}/cliff.toml"
+ cp scripts/release/write-changelog.sh "${RUNNER_TEMP}/write-changelog.sh"
+ chmod +x "${RUNNER_TEMP}/write-changelog.sh"
+ git fetch origin "${branch}"
+ git checkout --force "origin/${branch}"
+ cp "${RUNNER_TEMP}/cliff.toml" cliff.toml
+ # git-cliff: same binary release-plz uses under the hood for formatting.
+ curl -sL "https://github.com/orhun/git-cliff/releases/download/v2.13.1/git-cliff-2.13.1-x86_64-unknown-linux-gnu.tar.gz" \
+ | tar -xz -C "${RUNNER_TEMP}"
+ export PATH="${RUNNER_TEMP}/git-cliff-2.13.1:${PATH}"
+ "${RUNNER_TEMP}/write-changelog.sh"
+ if git diff --quiet -- CHANGELOG.md; then
+ echo "CHANGELOG already complete."
+ exit 0
+ fi
+ git add CHANGELOG.md cliff.toml 2>/dev/null || git add CHANGELOG.md
+ git commit -m "chore(release): write whole-repo changelog"
+ git push origin "HEAD:refs/heads/${branch}"
# `release-plz/action` swallows a release-pr HTTP 422 as a warning and
# reports no PR, which silently stalls releases (it looks identical to a
# quiet week of commits). Fail loudly when release-plz opened/updated no
@@ -131,11 +173,16 @@ jobs:
}
core.info(`No release PR and no release-worthy commits since ${lastTag || "repo start"}; nothing to release.`);
- # On every push to master, publishes any crate whose manifest version is not yet
- # on crates.io — i.e. a no-op until the release PR is merged, at which point it
- # publishes the whole workspace and cuts one `v{version}` tag + GitHub Release.
+ # Publishes crates + cuts `v{version}` only from the release PR merge commit
+ # (`chore: release v*`). release_always=false in release-plz.toml is the primary
+ # gate; this job-level filter is defense in depth so a later feature push cannot
+ # tag HEAD after a failed-then-retried crates.io publish. On publish failure,
+ # re-run this workflow on the release commit SHA — never on a later master tip.
release:
name: release-plz release
+ if: >-
+ github.event_name == 'workflow_dispatch' ||
+ startsWith(github.event.head_commit.message, 'chore: release')
runs-on: ubuntu-latest
permissions:
contents: write
@@ -145,25 +192,85 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
+ # Pin to the version-bump commit even if workflow_dispatch is fired from a
+ # later master tip (or a re-run that somehow resolves to the wrong SHA).
+ - name: Check out the version-bump commit
+ shell: bash
+ run: |
+ set -euo pipefail
+ version="$(
+ python3 - <<'PY'
+ import pathlib, re, sys
+ text = pathlib.Path("Cargo.toml").read_text()
+ m = re.search(
+ r'(?ms)^\[workspace\.package\].*?^version\s*=\s*"([^"]+)"',
+ text,
+ )
+ if not m:
+ sys.exit("workspace.package version not found in Cargo.toml")
+ print(m.group(1))
+ PY
+ )"
+ tag="v${version}"
+ if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
+ echo "Tag ${tag} already exists — nothing to release."
+ echo "skip=true" >> "$GITHUB_ENV"
+ exit 0
+ fi
+ # Prefer the conventional release-PR squash subject; fall back to the
+ # first commit that set this workspace version in Cargo.toml.
+ bump_sha="$(git log -1 --format=%H --grep="^chore: release v${version}" || true)"
+ if [[ -z "${bump_sha}" ]]; then
+ bump_sha="$(
+ git log -G '^version = "' --format=%H -- Cargo.toml \
+ | while read -r sha; do
+ if git show "${sha}:Cargo.toml" \
+ | python3 -c "import pathlib,re,sys; t=sys.stdin.read(); m=re.search(r'(?ms)^\[workspace\.package\].*?^version\s*=\s*\"([^\"]+)\"', t); sys.exit(0 if m and m.group(1)==sys.argv[1] else 1)" \
+ "${version}"
+ then
+ echo "${sha}"
+ break
+ fi
+ done
+ )"
+ fi
+ if [[ -z "${bump_sha}" ]]; then
+ echo "::error::Could not locate the version-bump commit for ${tag}"
+ exit 1
+ fi
+ echo "Checking out version-bump commit ${bump_sha} for ${tag}"
+ # Stay on a real branch with @{upstream} set. A bare SHA checkout
+ # detaches HEAD and release-plz aborts with "cannot determine current
+ # branch". Pin master to the bump commit only for this job's workspace
+ # (does not push, does not bump versions).
+ git switch --force -C master "${bump_sha}"
+ git branch --set-upstream-to=origin/master master
+ echo "skip=false" >> "$GITHUB_ENV"
+ echo "release_sha=${bump_sha}" >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@stable
+ if: env.skip != 'true'
- uses: Swatinem/rust-cache@v2
+ if: env.skip != 'true'
with:
prefix-key: v1-rust
shared-key: linux-stable-debug
# CI master is the canonical writer; release-plz only restores.
save-if: false
- name: Install Linux build deps
+ if: env.skip != 'true'
run: |
sudo apt-get update
sudo apt-get install -y \
libudev-dev pkg-config gcc g++ clang libssl-dev libzstd-dev
- name: Mint GitHub App token
+ if: env.skip != 'true'
id: app-token
uses: ./.github/actions/github-app-token-from-1password
with:
op-service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
op-github-app-item: ${{ secrets.OP_GITHUB_APP_ITEM }}
- name: Run release-plz (release)
+ if: env.skip != 'true'
uses: release-plz/action@v0.5
with:
command: release
diff --git a/AGENTS.md b/AGENTS.md
index c69aa752c2661e24782982132178b4be0dcf1561..85c760bc09497804eec7f4ca7ed0fe5740bfbbcd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -52,12 +52,76 @@ direnv exec . cargo clippy --workspace --all-targets -- -D warnings
direnv exec . git commit …
```
-- Full local gate (same as CI): `cargo fmt --all -- --check` +
- `cargo clippy --workspace --all-targets -- -D warnings` +
- `cargo test --workspace` (or `devenv tasks run openlogi:check`). Must
- pass before every commit.
-- prek hooks (`prek.toml`): `cargo fmt` at commit; full-workspace clippy at push
- (rust-scoped, so non-Rust pushes skip it).
+### Local gate (hard stop — do this before every push)
+
+**Never `git push` until the final tree has passed the full local gate.**
+`cargo check` alone is not enough. Conflict resolution + "it compiles on my
+Mac" is not enough. Run **all three** on the commit you are about to push:
+
+```sh
+cargo fmt --all -- --check
+cargo clippy --workspace --all-targets -- -D warnings
+cargo test --workspace
+# or: devenv tasks run openlogi:check
+```
+
+Exit non-zero on any of those → fix, re-run the **whole** triple, then push.
+Do not push "to see if CI likes it." CI is confirmation, not the first compile.
+
+prek hooks (`prek.toml`): `cargo fmt` at commit; full-workspace clippy at push
+(rust-scoped, so non-Rust pushes skip it). Hooks are a backstop, not a substitute
+for running the gate yourself after a rebase.
+
+### Platform / cfg-gated code (macOS-green is a trap)
+
+macOS-green proves **nothing** about `#[cfg(target_os = "linux")]` /
+`windows` code. Recent agent failures that only showed up on CI Linux:
+
+- Shadowing a crate-level constant with a local `const` of a different type
+ (e.g. `LOGITECH_VENDOR_ID: u16` next to `use crate::LOGITECH_VENDOR_ID`
+ which is `u32`) — E0255 / E0308, **only compiles on Linux**.
+- Importing a name that only exists on another OS, or redefining one that
+ master already exports from `lib.rs`.
+
+When the diff touches any of:
+
+- `crates/openlogi-hook/src/linux.rs` / `windows.rs`
+- `crates/openlogi-inject/src/inject/linux.rs` / `windows.rs`
+- `crates/openlogi-hid/src/transport.rs` (has `#[cfg]` branches)
+- any `#[cfg(target_os = …)]` block
+
+you MUST either:
+
+1. Cross-check with devenv when available:
+ `devenv tasks run openlogi:check-windows` (and any linux check the repo has), or
+2. Manually re-read every changed cfg-gated file against **current master** for:
+ - name collisions with existing `pub use` / `pub const` items
+ - type mismatches (`u16` vs `u32`, `Option` arity, new enum fields)
+ - call sites that gained args on master (e.g. `with_runtime`, `build_device_list`,
+ `dispatch_action`) but the PR still uses the old signature
+
+Do not claim "cross-platform green" without CI (or a local cross-lint) having
+actually run those targets. `RUSTFLAGS=-D warnings` is global in CI — plain
+warnings fail there too.
+
+### Wire format / IPC (another silent CI red)
+
+If the change touches anything that crosses the agent↔GUI boundary
+(`ipc.rs`, serde enums in hid write errors, `DeviceKind`, …):
+
+- Enums are **append-only** (serde index = wire). New variants go at the end.
+- Bump `PROTOCOL_VERSION` and regenerate
+ `crates/openlogi-agent-core/tests/wire_format.rs` goldens from the failure
+ message (`left` is the new encoding).
+- Run `cargo test -p openlogi-agent-core --test wire_format` before push.
+
+### i18n
+
+New GUI strings: insert the same key in the **same position** in every
+`crates/openlogi-gui/locales/*.yml`. Run `cargo test -p openlogi-gui i18n`.
+
+### App / agent runtime notes
+
- The macOS GUI build needs full Xcode for GPUI's Metal shaders. devenv sets
`DEVELOPER_DIR`/`SDKROOT` when present; without it, use system Xcode. If the
shader compile fails under devenv, `direnv reload` first.
@@ -65,10 +129,6 @@ direnv exec . git commit …
into `target/dev/OpenLogi.app`. `cargo build` does NOT refresh that bundle,
and a second instance exits on the singleton lock: quit the old instance and
re-`run` before judging a UI change "not applied".
-- macOS-green proves nothing about cfg-gated code. CI's linux/windows jobs are the
- authoritative check (`RUSTFLAGS=-D warnings` globally, so plain warnings fail
- too); with devenv, `devenv tasks run openlogi:check-windows` cross-lints the
- ring-free subset locally. Don't claim cross-platform success without CI.
## Rust standards
@@ -119,6 +179,8 @@ House style:
worktree so parallel work doesn't collide; trivial fixes may go straight to master.
- Commits are small and focused — split unrelated concerns into separate commits; never
one giant unreviewable diff.
+- **Always `git fetch upstream master` (or origin) immediately before a rebase.** Rebase
+ onto the refreshed tip, not a stale local `master`.
- Merging PRs: **squash by default** with a hand-written subject
`type(scope): description (#N)` (release-plz parses it; merge commits are disabled).
Rebase-merge only when every commit on the branch is already release-quality
@@ -134,12 +196,25 @@ House style:
- Never post to external repos or reply publicly on the maintainer's behalf — draft the
text for approval. Keep public drafts short, casual, and problem-focused.
- Contributor PRs are adopted, not rejected: check `maintainerCanModify`, rebase onto
- master in a worktree, fix review findings, push to the fork branch; preserve
- authorship (`Co-authored-by` when re-homing work).
+ **fresh** master in a worktree, fix review findings, run the **full local gate** on
+ the rebased tip, **then** push to the fork branch; preserve authorship
+ (`Co-authored-by` when re-homing work). Squash-then-rebase is fine when the PR is
+ far behind and commit-by-commit conflicts thrash.
- Issues use the bug/feature/device forms and the `type:`/`area:`/`platform:`/`needs:`/
`status:` label families. Deferred or out-of-scope work becomes a linked issue, not a
TODO comment.
+### CI / Actions when adopting PRs
+
+- CI concurrency is **per branch** (`ci-${{ workflow }}-${{ ref }}` with
+ `cancel-in-progress: true`). Approving or re-running an **old SHA** on the same
+ branch cancels the current-head run. Only approve / re-run workflows whose
+ `head_sha` equals the PR's current head.
+- After a force-push, wait for the new runs; do not re-approve stale
+ `action_required` jobs from earlier commits on that branch.
+- First-time-fork PRs may sit in `action_required` until a maintainer approves the
+ workflow run — that is fine; still do not push until the local gate is green.
+
## Releases
release-plz drives releases: one unified workspace version, ONE root `CHANGELOG.md`
@@ -156,6 +231,26 @@ and loop on that check. Real-hardware verification (physical mice, receivers) is
maintainer's job: every fix PR states how to test it. Report outcomes honestly,
including what was NOT verified.
+**Push checklist (agents):**
+
+1. Rebase/merge conflicts fully resolved — no `<<<<<<<` left, no half-ported APIs.
+2. Full local gate green on the **final** tree (fmt + clippy `-D warnings` + test).
+3. If cfg-gated files changed: cross-lint or hand-audit against master (see above).
+4. If wire types changed: `wire_format` tests green + `PROTOCOL_VERSION` bumped.
+5. If locales changed: only `en.yml` is required for new strings; run
+ `cargo test -p openlogi-gui i18n` (non-English may lag until Crowdin).
+6. Only then `git push` / force-push to the PR branch.
+
+## i18n (English only in feature work)
+
+- **Only edit** `crates/openlogi-gui/locales/en.yml` when adding or changing UI
+ strings. The English text is the key; `rust_i18n` falls back to English for
+ missing translations.
+- **Do not** update `da`/`de`/`ja`/… locale files in the same PR to “keep parity.”
+ Crowdin owns non-English catalogs; the Crowdin workflow opens `crowdin/i18n`
+ after `en.yml` lands on master.
+- Details: [`.claude/rules/i18n.md`](.claude/rules/i18n.md).
+
## Subsystem rules — read before touching
Claude Code loads these automatically per path; other agents: read the listed file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e23baa3f2f800d6545051131905c0bf6b4589de..915e514f028e8b2735f989e40016c66b5fdc861f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.6.24] - 2026-08-10
+
+### Added
+
+- *(hid)* recognize Lightspeed receiver (046d:c539) as Unifying-compatible ([#510](https://github.com/AprilNEA/OpenLogi/pull/510))
+- add function key remapper ([#344](https://github.com/AprilNEA/OpenLogi/pull/344))
+- *(hid)* add standalone Litra light support ([#513](https://github.com/AprilNEA/OpenLogi/pull/513))
+- keyboard F-row key remapping and fn-lock over HID++ ([#395](https://github.com/AprilNEA/OpenLogi/pull/395))
+- *(hook)* Wayland frontmost-window backends (wlroots + GNOME Shell) ([#191](https://github.com/AprilNEA/OpenLogi/pull/191))
+- *(camera)* add Logitech webcam support ([#531](https://github.com/AprilNEA/OpenLogi/pull/531))
+- *(backlight)* support HID++ 0x1982 ([#470](https://github.com/AprilNEA/OpenLogi/pull/470))
+- *(battery)* support legacy 0x1000 BatteryStatus and its charging quirk ([#312](https://github.com/AprilNEA/OpenLogi/pull/312))
+
+### Fixed
+
+- *(agent-core)* retry volatile DPI re-apply on cold boot ([#449](https://github.com/AprilNEA/OpenLogi/pull/449))
+- *(agent)* prefer online device for input capture ([#453](https://github.com/AprilNEA/OpenLogi/pull/453))
+- *(hidpp)* keep events when a field carries an unknown enum value ([#432](https://github.com/AprilNEA/OpenLogi/pull/432))
+- *(agent)* rearm control capture after device reconnect ([#450](https://github.com/AprilNEA/OpenLogi/pull/450))
+- *(linux)* grant uaccess on Logitech input event nodes ([#530](https://github.com/AprilNEA/OpenLogi/pull/530))
+- *(agent)* reapply volatile settings after macOS resume ([#506](https://github.com/AprilNEA/OpenLogi/pull/506))
+- *(hook)* never wedge system pointer input ([#534](https://github.com/AprilNEA/OpenLogi/pull/534))
+- *(agent)* route hardware operations through inventory channels ([#532](https://github.com/AprilNEA/OpenLogi/pull/532))
+- *(agent)* reuse inventory channels for input capture ([#522](https://github.com/AprilNEA/OpenLogi/pull/522))
+- *(i18n)* complete Crowdin synchronization ([#508](https://github.com/AprilNEA/OpenLogi/pull/508))
+
## [0.6.23](https://github.com/AprilNEA/OpenLogi/compare/openlogi-core-v0.6.22...openlogi-core-v0.6.23) - 2026-08-02
### Fixed
diff --git a/Cargo.lock b/Cargo.lock
index 30c916aebb226001b1454cfda2f1219655eff857..f56defeaa3817b3174f59370a04260dc0989c9a1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -685,6 +685,29 @@ dependencies = [
"serde",
]
+[[package]]
+name = "bindgen"
+version = "0.65.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5"
+dependencies = [
+ "bitflags 1.3.2",
+ "cexpr",
+ "clang-sys",
+ "lazy_static",
+ "lazycell",
+ "log",
+ "peeking_take_while",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash 1.1.0",
+ "shlex",
+ "syn",
+ "which 4.4.2",
+]
+
[[package]]
name = "bindgen"
version = "0.71.1"
@@ -694,7 +717,7 @@ dependencies = [
"bitflags 2.13.1",
"cexpr",
"clang-sys",
- "itertools 0.11.0",
+ "itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
@@ -1726,7 +1749,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1993,7 +2016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2601,7 +2624,7 @@ dependencies = [
"log",
"presser",
"thiserror 2.0.18",
- "windows 0.58.0",
+ "windows 0.62.2",
]
[[package]]
@@ -2633,7 +2656,7 @@ dependencies = [
"anyhow",
"async-channel",
"async-task",
- "bindgen",
+ "bindgen 0.71.1",
"bitflags 2.13.1",
"block",
"cbindgen",
@@ -3361,7 +3384,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.58.0",
+ "windows-core 0.62.2",
]
[[package]]
@@ -3857,6 +3880,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+[[package]]
+name = "lazycell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
+
[[package]]
name = "leak"
version = "0.1.2"
@@ -4173,7 +4202,7 @@ version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba"
dependencies = [
"anyhow",
- "bindgen",
+ "bindgen 0.71.1",
"core-foundation 0.10.0",
"core-video",
"ctor",
@@ -4451,7 +4480,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -4991,7 +5020,7 @@ dependencies = [
[[package]]
name = "openlogi"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"anyhow",
"openlogi-cli",
@@ -5000,7 +5029,7 @@ dependencies = [
[[package]]
name = "openlogi-agent"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"embed-resource",
"futures",
@@ -5025,7 +5054,7 @@ dependencies = [
[[package]]
name = "openlogi-agent-core"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"async-hid",
"bincode",
@@ -5043,7 +5072,7 @@ dependencies = [
[[package]]
name = "openlogi-assets"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"atomic-write-file",
"backon",
@@ -5056,15 +5085,31 @@ dependencies = [
"ureq",
]
+[[package]]
+name = "openlogi-camera"
+version = "0.6.24"
+dependencies = [
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "serde",
+ "tracing",
+ "v4l",
+ "windows 0.61.3",
+ "zune-core 0.5.1",
+ "zune-jpeg 0.5.15",
+]
+
[[package]]
name = "openlogi-cli"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"anyhow",
"clap",
"openlogi-assets",
+ "openlogi-camera",
"openlogi-core",
"openlogi-hid",
+ "png 0.17.16",
"tokio",
"tracing",
"tracing-subscriber",
@@ -5072,10 +5117,11 @@ dependencies = [
[[package]]
name = "openlogi-core"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"atomic-write-file",
"etcetera",
+ "plist",
"serde",
"tempfile",
"thiserror 2.0.18",
@@ -5086,7 +5132,7 @@ dependencies = [
[[package]]
name = "openlogi-gui"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"anyhow",
"backon",
@@ -5106,6 +5152,7 @@ dependencies = [
"opener",
"openlogi-agent-core",
"openlogi-assets",
+ "openlogi-camera",
"openlogi-core",
"openlogi-hid",
"openlogi-hook",
@@ -5126,12 +5173,14 @@ dependencies = [
[[package]]
name = "openlogi-hid"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"async-hid",
"futures-concurrency",
"futures-lite",
"num_enum",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
"openlogi-core",
"openlogi-hidpp",
"serde",
@@ -5143,7 +5192,7 @@ dependencies = [
[[package]]
name = "openlogi-hidpp"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"async-channel",
"async-trait",
@@ -5162,7 +5211,7 @@ dependencies = [
[[package]]
name = "openlogi-hook"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"core-foundation 0.10.0",
"core-graphics 0.25.0",
@@ -5177,13 +5226,16 @@ dependencies = [
"openlogi-inject",
"thiserror 2.0.18",
"tracing",
+ "wayland-client",
+ "wayland-protocols-wlr",
"windows-sys 0.61.2",
"x11rb",
+ "zbus",
]
[[package]]
name = "openlogi-inject"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"core-foundation 0.10.0",
"core-graphics 0.25.0",
@@ -5344,6 +5396,12 @@ dependencies = [
"hmac",
]
+[[package]]
+name = "peeking_take_while"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -5492,13 +5550,13 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plist"
-version = "1.9.0"
+version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64",
"indexmap",
- "quick-xml 0.39.4",
+ "quick-xml 0.41.0",
"serde",
"time",
]
@@ -5758,9 +5816,9 @@ dependencies = [
[[package]]
name = "quick-xml"
-version = "0.39.4"
+version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
@@ -6356,7 +6414,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7325,7 +7383,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -8066,6 +8124,26 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "v4l"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8fbfea44a46799d62c55323f3c55d06df722fbe577851d848d328a1041c3403"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+ "v4l2-sys-mit",
+]
+
+[[package]]
+name = "v4l2-sys-mit"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6779878362b9bacadc7893eac76abe69612e8837ef746573c4a5239daf11990b"
+dependencies = [
+ "bindgen 0.65.1",
+]
+
[[package]]
name = "v_frame"
version = "0.3.9"
@@ -8385,12 +8463,12 @@ dependencies = [
[[package]]
name = "wayland-scanner"
-version = "0.31.10"
+version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
+checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
- "quick-xml 0.39.4",
+ "quick-xml 0.41.0",
"quote",
]
@@ -8609,6 +8687,18 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "which"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
+dependencies = [
+ "either",
+ "home",
+ "once_cell",
+ "rustix 0.38.44",
+]
+
[[package]]
name = "which"
version = "6.0.3"
@@ -8658,7 +8748,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -9479,7 +9569,7 @@ checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547"
[[package]]
name = "xtask"
-version = "0.6.23"
+version = "0.6.24"
dependencies = [
"anyhow",
"clap",
diff --git a/Cargo.toml b/Cargo.toml
index b7424291e85566a1e32aef2c0917c017ec3073e4..7252358403862fc40fab7381587c34f95f12f6e4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,7 +12,7 @@ categories = ["command-line-utilities", "hardware-support"]
readme = "README.md"
[dependencies]
-openlogi-cli = { path = "crates/openlogi-cli", version = "0.6.23" }
+openlogi-cli = { path = "crates/openlogi-cli", version = "0.6.24" }
anyhow = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros"] }
@@ -26,6 +26,7 @@ members = [
"crates/openlogi-inject",
"crates/openlogi-hidpp",
"crates/openlogi-hid",
+ "crates/openlogi-camera",
"crates/openlogi-assets",
"crates/openlogi-cli",
"crates/openlogi-agent-core",
@@ -36,7 +37,7 @@ members = [
]
[workspace.package]
-version = "0.6.23"
+version = "0.6.24"
edition = "2024"
rust-version = "1.96"
license = "MIT OR Apache-2.0"
@@ -45,7 +46,7 @@ authors = ["AprilNEA <dev@aprilnea.me>"]
description = "Lightweight, local-first alternative to Logitech Options+ for HID++ devices"
[workspace.dependencies]
-hidpp = { package = "openlogi-hidpp", path = "crates/openlogi-hidpp", version = "0.6.23" }
+hidpp = { package = "openlogi-hidpp", path = "crates/openlogi-hidpp", version = "0.6.24" }
async-hid = "0.5.2"
# Cross-platform local IPC for the agent <-> GUI tarpc transport: a Unix-domain
# socket on Unix, a named pipe on Windows. The `tokio` feature gives the async
diff --git a/README.md b/README.md
index 6edb10e595077fd89a11682bd87817e4f48120be..e0a911aabb27cc53d9fd77796b9fd6548be2d91c 100644
--- a/README.md
+++ b/README.md
@@ -147,7 +147,8 @@ sudo pacman -U openlogi-*.pkg.tar.zst
Packages are published for both `x86_64`/`amd64` and `arm64`/`aarch64`.
The package installs udev rules that grant your user access to
-`/dev/hidraw*` and `/dev/uinput` without `sudo`. After installation,
+`/dev/hidraw*`, `/dev/uinput` and your Logitech mouse's `/dev/input/event*`
+node without `sudo`. After installation,
enable the background agent for your user:
```sh
diff --git a/cliff.toml b/cliff.toml
new file mode 100644
index 0000000000000000000000000000000000000000..19f6601ac15df24480a32ee4f7d457b6507b2e6a
--- /dev/null
+++ b/cliff.toml
@@ -0,0 +1,51 @@
+# Whole-repo changelog (git-cliff). release-plz only bumps versions; it is
+# package-path-scoped and cannot see `release = false` app crates, so the root
+# CHANGELOG is owned here — same model as GoReleaser's `changelog.use: git`.
+# https://git-cliff.org/docs/configuration
+
+[changelog]
+header = """# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+"""
+# Keep-a-Changelog layout matching historical OpenLogi sections.
+body = """
+## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+
+{% for group, commits in commits | group_by(attribute="group") -%}
+### {{ group | upper_first }}
+
+{% for commit in commits -%}
+- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}
+{% endfor %}
+{% endfor -%}
+"""
+trim = true
+
+[git]
+conventional_commits = true
+filter_unconventional = true
+require_conventional = true
+split_commits = false
+commit_preprocessors = [
+ { pattern = "\\(#([0-9]+)\\)", replace = "([#${1}](https://github.com/AprilNEA/OpenLogi/pull/${1}))" },
+]
+commit_parsers = [
+ { message = "^feat", group = "Added" },
+ { message = "^fix", group = "Fixed" },
+ { message = "^perf", group = "Changed" },
+ { message = "^security", group = "Security" },
+ { message = "^.*", skip = true },
+]
+protect_breaking_commits = true
+filter_commits = false
+tag_pattern = "v[0-9].*"
+sort_commits = "newest"
+# No include_path / exclude_path — every conventional commit since the last tag
+# counts, including gui/agent work that never touches a crates.io package.
diff --git a/crates/openlogi-agent-core/src/device_order.rs b/crates/openlogi-agent-core/src/device_order.rs
index 6eae03381f02baebe6f6d53f47bf89992f79fe84..88e8552382e65066a790078970467f1a6460b009 100644
--- a/crates/openlogi-agent-core/src/device_order.rs
+++ b/crates/openlogi-agent-core/src/device_order.rs
@@ -3,9 +3,9 @@
//!
//! HID enumeration order shifts as devices wake, sleep, or are reselected, so
//! both processes order devices by a stable, route-derived identity instead.
-//! Sharing the key here is what keeps them agreeing on "the first device": when
-//! no `selected_device` is persisted, the GUI shows index 0 of its sorted list
-//! and the agent targets index 0 of its own — they must be the same device.
+//! Sharing the key keeps device order deterministic. The agent additionally
+//! excludes standalone raw-HID devices when choosing its input-capture target;
+//! they remain ordered here for inventory, display, and settings re-apply.
use openlogi_hid::DeviceRoute;
@@ -16,10 +16,10 @@ pub struct PhysicalDeviceKey(String);
/// A route-derived identity used to order devices deterministically.
///
-/// Receiver UID + slot and direct serial/non-zero unit identities are stable
-/// and unique. A direct all-zero unit identity is deliberately retained here
-/// only so a transient inventory record can still be ordered; it cannot become
-/// a [`PhysicalDeviceKey`].
+/// Receiver UID + slot and serial/non-zero unit identities are stable and
+/// unique. OS-node identities on raw HID routes are deliberately retained here
+/// only so a transient inventory record can still be ordered; they cannot
+/// become a [`PhysicalDeviceKey`].
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum DeviceStableId {
Bolt {
@@ -31,6 +31,13 @@ pub enum DeviceStableId {
product_id: u16,
identity: DeviceIdentity,
},
+ RawHid {
+ vendor_id: u16,
+ product_id: u16,
+ usage_page: u16,
+ usage_id: u16,
+ identity: String,
+ },
Unknown {
slot: u8,
identity: DeviceIdentity,
@@ -83,6 +90,19 @@ impl DeviceStableId {
product_id: *product_id,
identity: DeviceIdentity::from_parts(serial, unit_id),
},
+ Some(DeviceRoute::RawHid {
+ vendor_id,
+ product_id,
+ usage_page,
+ usage_id,
+ identity,
+ }) => Self::RawHid {
+ vendor_id: *vendor_id,
+ product_id: *product_id,
+ usage_page: *usage_page,
+ usage_id: *usage_id,
+ identity: identity.to_ascii_lowercase(),
+ },
None => Self::Unknown {
slot,
identity: DeviceIdentity::from_parts(serial, unit_id),
@@ -109,6 +129,15 @@ impl DeviceStableId {
product_id,
identity,
} => format!("direct:{vendor_id:04x}:{product_id:04x}:{}", identity.key()),
+ Self::RawHid {
+ vendor_id,
+ product_id,
+ usage_page,
+ usage_id,
+ identity,
+ } => format!(
+ "raw:{vendor_id:04x}:{product_id:04x}:{usage_page:04x}:{usage_id:04x}:{identity}"
+ ),
Self::Unknown { slot, identity } => format!("unknown:slot:{slot}:{}", identity.key()),
}
}
@@ -117,8 +146,9 @@ impl DeviceStableId {
///
/// Receiver-connected devices are identified by receiver UID + pairing
/// slot. Direct and routeless devices require either a non-empty serial
- /// number or a non-zero unit id; an all-zero unit id is a transient probe
- /// result, not a physical identity.
+ /// number or a non-zero unit id. Raw HID devices require a serial-backed
+ /// route identity; OS node identities and all-zero unit ids are transient
+ /// probe results, not physical identities.
#[must_use]
pub fn physical_key(&self) -> Option<PhysicalDeviceKey> {
match self {
@@ -126,6 +156,9 @@ impl DeviceStableId {
Self::Direct { identity, .. } | Self::Unknown { identity, .. } => identity
.is_physical()
.then(|| PhysicalDeviceKey(self.runtime_key())),
+ Self::RawHid { identity, .. } => {
+ raw_identity_is_physical(identity).then(|| PhysicalDeviceKey(self.runtime_key()))
+ }
}
}
}
@@ -156,6 +189,7 @@ impl PhysicalDeviceKey {
pub fn parse(value: &str) -> Option<Self> {
if receiver_key_is_valid(value)
|| direct_identity_fragment(value).is_some_and(identity_fragment_is_physical)
+ || raw_identity_fragment(value).is_some_and(raw_identity_is_physical)
|| unknown_identity_fragment(value).is_some_and(identity_fragment_is_physical)
{
Some(Self(value.to_string()))
@@ -171,6 +205,8 @@ impl PhysicalDeviceKey {
direct_identity_fragment(value)
.or_else(|| unknown_identity_fragment(value))
.is_some_and(|identity| identity == "unit:00000000")
+ || raw_identity_fragment(value)
+ .is_some_and(|identity| identity.starts_with("id:") || identity.is_empty())
}
/// Borrow the serialized configuration key.
@@ -201,6 +237,21 @@ fn direct_identity_fragment(value: &str) -> Option<&str> {
(is_hex_word(vendor_id) && is_hex_word(product_id)).then_some(identity)
}
+fn raw_identity_fragment(value: &str) -> Option<&str> {
+ let mut parts = value.strip_prefix("raw:")?.splitn(5, ':');
+ let vendor_id = parts.next()?;
+ let product_id = parts.next()?;
+ let usage_page = parts.next()?;
+ let usage_id = parts.next()?;
+ let identity = parts.next()?;
+ (is_hex_word(vendor_id)
+ && is_hex_word(product_id)
+ && is_hex_word(usage_page)
+ && is_hex_word(usage_id)
+ && !identity.is_empty())
+ .then_some(identity)
+}
+
fn unknown_identity_fragment(value: &str) -> Option<&str> {
let (slot, identity) = value.strip_prefix("unknown:slot:")?.split_once(':')?;
slot.parse::<u8>().ok().map(|_| identity)
@@ -217,6 +268,15 @@ fn identity_fragment_is_physical(value: &str) -> bool {
})
}
+fn raw_identity_is_physical(value: &str) -> bool {
+ value
+ .strip_prefix("serial:")
+ .is_some_and(|serial| !serial.is_empty())
+ || value
+ .strip_prefix("stable:")
+ .is_some_and(|identity| !identity.is_empty())
+}
+
fn is_hex_word(value: &str) -> bool {
value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
@@ -327,4 +387,42 @@ mod tests {
assert!(PhysicalDeviceKey::parse("direct:046d:b023:unit:a393cae0").is_some());
assert!(PhysicalDeviceKey::parse("2b034").is_none());
}
+
+ #[test]
+ fn raw_os_identity_is_transient_across_reconnects() {
+ let old = DeviceRoute::RawHid {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "id:old-node".into(),
+ };
+ let new = DeviceRoute::RawHid {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "id:new-node".into(),
+ };
+ let old_id = DeviceStableId::from_parts(Some(&old), 0xff, None, [0; 4]);
+ let new_id = DeviceStableId::from_parts(Some(&new), 0xff, None, [0; 4]);
+ assert_ne!(old_id.runtime_key(), new_id.runtime_key());
+ assert!(old_id.physical_key().is_none());
+ assert!(new_id.physical_key().is_none());
+ }
+
+ #[test]
+ fn raw_serial_identity_survives_a_changed_os_node() {
+ let route = DeviceRoute::RawHid {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:glow-1".into(),
+ };
+ let key = DeviceStableId::from_parts(Some(&route), 0xff, Some("glow-1"), [0; 4])
+ .physical_key()
+ .map(PhysicalDeviceKey::into_string);
+ assert_eq!(key, Some("raw:046d:c900:ff43:0202:serial:glow-1".into()));
+ }
}
diff --git a/crates/openlogi-agent-core/src/event_monitor.rs b/crates/openlogi-agent-core/src/event_monitor.rs
index d83d2dfa15f3251aec650ed584cb4a8dfbee5889..2680d299c025043103107be5d5b38e2385ec4abd 100644
--- a/crates/openlogi-agent-core/src/event_monitor.rs
+++ b/crates/openlogi-agent-core/src/event_monitor.rs
@@ -55,7 +55,7 @@ impl EventMonitor {
return;
}
let mapped = match event {
- MouseEvent::Button { id, pressed } => MonitorEvent::Button {
+ MouseEvent::Button { id, pressed, .. } => MonitorEvent::Button {
button: id.to_string(),
pressed: *pressed,
},
@@ -68,7 +68,9 @@ impl EventMonitor {
MouseEvent::CaptureInterrupted => MonitorEvent::CaptureInterrupted,
MouseEvent::Moved { .. } => return,
};
- if let Ok(mut buf) = self.buf.lock() {
+ // `try_lock` only — the freeze-sensitive hook callback must never block
+ // on the monitor buffer (a contended `lock` stalls every pointer event).
+ if let Ok(mut buf) = self.buf.try_lock() {
if buf.len() == CAPACITY {
buf.pop_front();
}
@@ -137,6 +139,7 @@ mod tests {
m.record(&MouseEvent::Button {
id: ButtonId::Back,
pressed: true,
+ device: None,
});
assert!(!m.enabled());
@@ -152,6 +155,7 @@ mod tests {
m.record(&MouseEvent::Button {
id: ButtonId::Forward,
pressed: false,
+ device: None,
});
assert_eq!(
m.poll(),
diff --git a/crates/openlogi-agent-core/src/hardware.rs b/crates/openlogi-agent-core/src/hardware.rs
index 37d76689a5e1a033f430d2501442eb180a3462e4..69ea06c6067d167cfefa4b191832a93213743dca 100644
--- a/crates/openlogi-agent-core/src/hardware.rs
+++ b/crates/openlogi-agent-core/src/hardware.rs
@@ -6,22 +6,26 @@
//! press) and avoids holding a long-lived async runtime alongside GPUI's
//! executor.
//!
-//! When the HID++ capture session already has the target device open, these
-//! reuse that channel ([`openlogi_hid::CaptureChannel`]) instead of
-//! re-enumerating and opening a fresh one — the dominant cost of a write. The
-//! transient open is kept as a fallback for callers (e.g. the CGEventTap hook)
-//! firing while no session is connected.
+//! Agent calls select a registry-confirmed capture channel or the exact current
+//! inventory channel. A registry miss is unavailable; the daemon never falls
+//! back to re-enumerating and opening a competing connection.
use std::future::Future;
use std::time::Duration;
use openlogi_core::config::Lighting;
use openlogi_hid::{
- CaptureChannel, DeviceRoute, DpiInfo, HidppFeatureErrorKind, HidppOperation, ScrollResolution,
- SharedChannel, SmartShiftMode, SmartShiftStatus, WriteError,
+ CaptureChannel, ChannelRegistry, DeviceRoute, DpiInfo, HidppFeatureErrorKind, HidppOperation,
+ ScrollResolution, SharedChannel, SmartShiftMode, SmartShiftStatus, WriteError,
};
use tracing::{debug, warn};
+use crate::receiver_access::ReceiverAccess;
+
+mod light;
+
+pub use light::{apply_light, cancel_light_reapply, set_light_in_background};
+
/// Upper bound on a single HID++ write. `hidpp` has no request timeout of its
/// own, so without this an asleep / unresponsive device would hang (and leak)
/// this background thread forever; a write to a live device completes in well
@@ -32,7 +36,12 @@ const WRITE_BUDGET: Duration = Duration::from_secs(5);
///
/// This helper is intentionally blocking so GPUI callers can run it via
/// `cx.background_spawn` without making the UI thread own a Tokio runtime.
-pub fn read_dpi_info_blocking(target: &DeviceRoute) -> Result<DpiInfo, WriteError> {
+pub fn read_dpi_info_blocking(
+ capture: Option<&CaptureChannel>,
+ registry: &ChannelRegistry,
+ target: &DeviceRoute,
+) -> Result<DpiInfo, WriteError> {
+ let shared = authoritative_channel(capture, registry, target)?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
@@ -41,7 +50,7 @@ pub fn read_dpi_info_blocking(target: &DeviceRoute) -> Result<DpiInfo, WriteErro
})?;
rt.block_on(async {
- tokio::time::timeout(WRITE_BUDGET, openlogi_hid::get_dpi_info(target))
+ tokio::time::timeout(WRITE_BUDGET, openlogi_hid::get_dpi_info_on(&shared))
.await
.map_err(|_| WriteError::RequestTimedOut {
operation: HidppOperation::ReadDpiCapabilities,
@@ -49,34 +58,54 @@ pub fn read_dpi_info_blocking(target: &DeviceRoute) -> Result<DpiInfo, WriteErro
})
}
-/// Clone out the capture session's channel when it reaches `route`. `None` when
-/// no capture session is connected or the open channel points at a different
-/// device.
-fn reusable_channel(
+/// Select the only Agent-authoritative channel for `route`.
+fn authoritative_channel(
capture: Option<&CaptureChannel>,
+ registry: &ChannelRegistry,
route: &DeviceRoute,
-) -> Option<SharedChannel> {
- capture?
- .read()
- .ok()
+) -> Result<SharedChannel, WriteError> {
+ let capture = capture
+ .and_then(|capture| capture.read().ok())
.and_then(|slot| (*slot).clone())
- .filter(|chan| chan.matches(route))
+ .filter(|channel| channel.matches(route));
+ choose_authoritative(
+ capture,
+ |channel| registry.is_current(channel),
+ || registry.lookup(route),
+ )
+ .ok_or(WriteError::DeviceNotFound)
+}
+
+fn choose_authoritative<T>(
+ capture: Option<T>,
+ capture_is_current: impl FnOnce(&T) -> bool,
+ registry_lookup: impl FnOnce() -> Option<T>,
+) -> Option<T> {
+ match capture {
+ Some(capture) if capture_is_current(&capture) => Some(capture),
+ _ => registry_lookup(),
+ }
}
/// Spawn an OS thread that toggles SmartShift (free ↔ ratchet) on the
-/// device at `target` via `openlogi_hid::toggle_smartshift`. Returns
+/// device at `target` via its current shared channel. Returns
/// immediately; failures (incl. devices that expose neither `0x2111` nor
/// the older `0x2110` SmartShift feature) are logged.
pub fn toggle_smartshift_in_background(
capture: Option<&CaptureChannel>,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
target: Option<DeviceRoute>,
) {
let Some(target) = target else {
debug!("no target device — SmartShift toggle skipped");
return;
};
- let shared = reusable_channel(capture, &target);
- let reused = shared.is_some();
+ let Ok(shared) = authoritative_channel(capture, registry, &target) else {
+ debug!(route = %target, "no inventory channel — SmartShift toggle skipped");
+ return;
+ };
+ let receiver_access = receiver_access.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -89,17 +118,15 @@ pub fn toggle_smartshift_in_background(
}
};
let result = rt.block_on(async {
+ let _lease = receiver_access.acquire_for_io().await;
tokio::time::timeout(WRITE_BUDGET, async {
- match &shared {
- Some(shared) => openlogi_hid::toggle_smartshift_on(shared).await,
- None => openlogi_hid::toggle_smartshift(&target).await,
- }
+ openlogi_hid::toggle_smartshift_on(&shared).await
})
.await
});
let index = target.device_index();
match result {
- Ok(Ok(mode)) => debug!(index, ?mode, reused, "SmartShift toggled"),
+ Ok(Ok(mode)) => debug!(index, ?mode, "SmartShift toggled"),
Ok(Err(e)) => warn!(error = ?e, "SmartShift toggle failed"),
Err(_) => warn!(
index,
@@ -115,8 +142,11 @@ pub fn toggle_smartshift_in_background(
/// Blocking, like [`read_dpi_info_blocking`], so the SmartShift panel can run
/// it off a dedicated OS thread without the UI thread owning a Tokio runtime.
pub fn read_smartshift_status_blocking(
+ capture: Option<&CaptureChannel>,
+ registry: &ChannelRegistry,
target: &DeviceRoute,
) -> Result<SmartShiftStatus, WriteError> {
+ let shared = authoritative_channel(capture, registry, target)?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
@@ -125,22 +155,27 @@ pub fn read_smartshift_status_blocking(
})?;
rt.block_on(async {
- tokio::time::timeout(WRITE_BUDGET, openlogi_hid::get_smartshift_status(target))
- .await
- .map_err(|_| WriteError::RequestTimedOut {
- operation: HidppOperation::ReadSmartShift,
- })?
+ tokio::time::timeout(
+ WRITE_BUDGET,
+ openlogi_hid::get_smartshift_status_on(&shared),
+ )
+ .await
+ .map_err(|_| WriteError::RequestTimedOut {
+ operation: HidppOperation::ReadSmartShift,
+ })?
})
}
/// Spawn an OS thread that writes a full SmartShift configuration to the device
-/// at `target` via [`openlogi_hid::set_smartshift`]. Returns immediately;
+/// at `target` via its current shared channel. Returns immediately;
/// failures (incl. devices that expose neither `0x2111` nor the older `0x2110`
/// SmartShift feature) are logged.
///
/// `target == None` is a no-op (dev environment without a real device).
pub fn write_smartshift_in_background(
capture: Option<&CaptureChannel>,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
target: Option<DeviceRoute>,
mode: SmartShiftMode,
auto_disengage: u8,
@@ -150,8 +185,11 @@ pub fn write_smartshift_in_background(
debug!("no target device — SmartShift write skipped");
return;
};
- let shared = reusable_channel(capture, &target);
- let reused = shared.is_some();
+ let Ok(shared) = authoritative_channel(capture, registry, &target) else {
+ debug!(route = %target, "no inventory channel — SmartShift write skipped");
+ return;
+ };
+ let receiver_access = receiver_access.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -164,22 +202,9 @@ pub fn write_smartshift_in_background(
}
};
let result = rt.block_on(async {
+ let _lease = receiver_access.acquire_for_io().await;
tokio::time::timeout(WRITE_BUDGET, async {
- match &shared {
- Some(shared) => {
- openlogi_hid::set_smartshift_on(
- shared,
- mode,
- auto_disengage,
- tunable_torque,
- )
- .await
- }
- None => {
- openlogi_hid::set_smartshift(&target, mode, auto_disengage, tunable_torque)
- .await
- }
- }
+ openlogi_hid::set_smartshift_on(&shared, mode, auto_disengage, tunable_torque).await
})
.await
});
@@ -190,7 +215,6 @@ pub fn write_smartshift_in_background(
?mode,
auto_disengage,
tunable_torque,
- reused,
"SmartShift config written"
),
Ok(Err(e)) => warn!(error = ?e, "SmartShift write failed"),
@@ -202,6 +226,55 @@ pub fn write_smartshift_in_background(
});
}
+/// Spawn an OS thread that writes the keyboard Fn-lock state to the device at
+/// `target` via [`openlogi_hid::set_fn_lock_on`]. Returns immediately; failures
+/// (incl. keyboards that expose neither `0x40a3` nor `0x40a2` fn inversion)
+/// are logged.
+///
+/// `target == None` is a no-op (dev environment without a real device).
+pub fn write_fn_lock_in_background(
+ capture: Option<&CaptureChannel>,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
+ target: Option<DeviceRoute>,
+ on: bool,
+) {
+ let Some(target) = target else {
+ debug!(on, "no target device — Fn-lock write skipped");
+ return;
+ };
+ let Ok(shared) = authoritative_channel(capture, registry, &target) else {
+ debug!(route = %target, "no inventory channel — Fn-lock write skipped");
+ return;
+ };
+ let receiver_access = receiver_access.clone();
+ std::thread::spawn(move || {
+ let rt = match tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ {
+ Ok(rt) => rt,
+ Err(e) => {
+ warn!(error = %e, "tokio runtime init failed; Fn-lock write skipped");
+ return;
+ }
+ };
+ let result = rt.block_on(async {
+ let _lease = receiver_access.acquire_for_io().await;
+ tokio::time::timeout(WRITE_BUDGET, openlogi_hid::set_fn_lock_on(&shared, on)).await
+ });
+ let index = target.device_index();
+ match result {
+ Ok(Ok(())) => debug!(index, on, "Fn-lock written"),
+ Ok(Err(e)) => warn!(error = ?e, "Fn-lock write failed"),
+ Err(_) => warn!(
+ index,
+ "Fn-lock write timed out (device asleep/unresponsive)"
+ ),
+ }
+ });
+}
+
/// Desired SmartShift values for a reconnect re-apply.
#[derive(Debug, Clone, Copy)]
pub struct SmartShiftApply {
@@ -214,7 +287,7 @@ pub struct SmartShiftApply {
}
/// Re-apply every volatile mouse setting for one device on a **single**
-/// background thread, sequentially, reusing the capture channel when available.
+/// background thread, sequentially, on the current inventory-owned channel.
///
/// Agent-start reapply used to fire DPI / SmartShift / wheel-mode each on its
/// own thread, and each opened a fresh HID++ channel when capture was not yet
@@ -222,16 +295,25 @@ pub struct SmartShiftApply {
/// stream while correlating responses only by software id — they cross-talk and
/// produce the intermittent SmartShift `InvalidArgument` seen in #485. One
/// sequential writer removes that self-race.
+#[expect(
+ clippy::too_many_arguments,
+ reason = "background reapply keeps one device write lifecycle together"
+)]
pub fn reapply_mouse_volatile_in_background(
capture: Option<&CaptureChannel>,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
target: DeviceRoute,
resolution: Option<ScrollResolution>,
inverted: Option<bool>,
dpi: Option<u32>,
smartshift: Option<SmartShiftApply>,
) {
- let shared = reusable_channel(capture, &target);
- let reused = shared.is_some();
+ let Ok(shared) = authoritative_channel(capture, registry, &target) else {
+ debug!(route = %target, "no inventory channel — volatile reapply skipped");
+ return;
+ };
+ let receiver_access = receiver_access.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -245,26 +327,24 @@ pub fn reapply_mouse_volatile_in_background(
};
let index = target.device_index();
rt.block_on(async {
+ let _lease = receiver_access.acquire_for_io().await;
if resolution.is_some() || inverted.is_some() {
let result = tokio::time::timeout(WRITE_BUDGET, async {
- apply_wheel_mode(shared.as_ref(), &target, resolution, inverted).await
+ apply_wheel_mode(&shared, resolution, inverted).await
})
.await;
- log_wheel_result(index, resolution, inverted, reused, result);
+ log_wheel_result(index, resolution, inverted, result);
}
if let Some(dpi) = dpi {
match u16::try_from(dpi) {
Ok(dpi_u16) => {
let result = tokio::time::timeout(WRITE_BUDGET, async {
- match &shared {
- Some(shared) => openlogi_hid::set_dpi_on(shared, dpi_u16).await,
- None => openlogi_hid::set_dpi(&target, dpi_u16).await,
- }
+ openlogi_hid::set_dpi_on(&shared, dpi_u16).await
})
.await;
match result {
Ok(Ok(())) => {
- debug!(index, dpi = dpi_u16, reused, "DPI written to device");
+ debug!(index, dpi = dpi_u16, "DPI written to device");
}
Ok(Err(e)) => warn!(error = ?e, "DPI write failed"),
Err(_) => warn!(
@@ -280,26 +360,13 @@ pub fn reapply_mouse_volatile_in_background(
}
if let Some(ss) = smartshift {
let result = tokio::time::timeout(WRITE_BUDGET, async {
- match &shared {
- Some(shared) => {
- openlogi_hid::set_smartshift_on(
- shared,
- ss.mode,
- ss.auto_disengage,
- ss.tunable_torque,
- )
- .await
- }
- None => {
- openlogi_hid::set_smartshift(
- &target,
- ss.mode,
- ss.auto_disengage,
- ss.tunable_torque,
- )
- .await
- }
- }
+ openlogi_hid::set_smartshift_on(
+ &shared,
+ ss.mode,
+ ss.auto_disengage,
+ ss.tunable_torque,
+ )
+ .await
})
.await;
match result {
@@ -308,7 +375,6 @@ pub fn reapply_mouse_volatile_in_background(
mode = ?ss.mode,
auto_disengage = ss.auto_disengage,
tunable_torque = ss.tunable_torque,
- reused,
"SmartShift config written"
),
Ok(Err(e)) => warn!(error = ?e, "SmartShift write failed"),
@@ -323,35 +389,21 @@ pub fn reapply_mouse_volatile_in_background(
}
async fn apply_wheel_mode(
- shared: Option<&SharedChannel>,
- target: &DeviceRoute,
+ shared: &SharedChannel,
resolution: Option<ScrollResolution>,
inverted: Option<bool>,
) -> Result<(), WriteError> {
- match (resolution, inverted, shared) {
- (Some(resolution), Some(inverted), Some(shared)) => {
+ match (resolution, inverted) {
+ (Some(resolution), Some(inverted)) => {
openlogi_hid::set_scroll_wheel_mode_on(shared, resolution, inverted)
.await
.map(|_| ())
}
- (Some(resolution), Some(inverted), None) => {
- openlogi_hid::set_scroll_wheel_mode(target, resolution, inverted)
- .await
- .map(|_| ())
- }
- (Some(resolution), None, Some(shared)) => {
- openlogi_hid::set_scroll_resolution_on(shared, resolution)
- .await
- .map(|_| ())
- }
- (Some(resolution), None, None) => openlogi_hid::set_scroll_resolution(target, resolution)
+ (Some(resolution), None) => openlogi_hid::set_scroll_resolution_on(shared, resolution)
.await
.map(|_| ()),
- (None, Some(inverted), Some(shared)) => {
- openlogi_hid::set_scroll_inversion_on(shared, inverted).await
- }
- (None, Some(inverted), None) => openlogi_hid::set_scroll_inversion(target, inverted).await,
- (None, None, _) => Ok(()),
+ (None, Some(inverted)) => openlogi_hid::set_scroll_inversion_on(shared, inverted).await,
+ (None, None) => Ok(()),
}
}
@@ -359,17 +411,10 @@ fn log_wheel_result(
index: u8,
resolution: Option<ScrollResolution>,
inverted: Option<bool>,
- reused: bool,
result: Result<Result<(), WriteError>, tokio::time::error::Elapsed>,
) {
match result {
- Ok(Ok(())) => debug!(
- index,
- ?resolution,
- ?inverted,
- reused,
- "native wheel mode written"
- ),
+ Ok(Ok(())) => debug!(index, ?resolution, ?inverted, "native wheel mode written"),
Ok(Err(WriteError::FeatureUnsupported { feature_hex })) => debug!(
index,
?resolution,
@@ -387,12 +432,14 @@ fn log_wheel_result(
}
}
-/// Spawn an OS thread that writes `dpi` to the device at `target` via
-/// `openlogi_hid::set_dpi`. Returns immediately; failures are logged.
+/// Spawn an OS thread that writes `dpi` to the device at `target` via its
+/// current shared channel. Returns immediately; failures are logged.
///
/// `target == None` is a no-op (dev environment without a real device).
pub fn write_dpi_in_background(
capture: Option<&CaptureChannel>,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
target: Option<DeviceRoute>,
dpi: u32,
) {
@@ -400,8 +447,11 @@ pub fn write_dpi_in_background(
debug!(dpi, "no target device — DPI write skipped");
return;
};
- let shared = reusable_channel(capture, &target);
- let reused = shared.is_some();
+ let Ok(shared) = authoritative_channel(capture, registry, &target) else {
+ debug!(route = %target, "no inventory channel — DPI write skipped");
+ return;
+ };
+ let receiver_access = receiver_access.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -420,11 +470,9 @@ pub fn write_dpi_in_background(
return;
};
let result = rt.block_on(async {
+ let _lease = receiver_access.acquire_for_io().await;
tokio::time::timeout(WRITE_BUDGET, async {
- match &shared {
- Some(shared) => openlogi_hid::set_dpi_on(shared, dpi_u16).await,
- None => openlogi_hid::set_dpi(&target, dpi_u16).await,
- }
+ openlogi_hid::set_dpi_on(&shared, dpi_u16).await
})
.await
});
@@ -432,7 +480,6 @@ pub fn write_dpi_in_background(
Ok(Ok(())) => debug!(
index = target.device_index(),
dpi = dpi_u16,
- reused,
"DPI written to device"
),
Ok(Err(e)) => warn!(error = ?e, "DPI write failed"),
@@ -462,6 +509,8 @@ enum ScrollWheelModeChange {
/// at debug level.
pub fn write_scroll_wheel_mode_in_background(
capture: Option<&CaptureChannel>,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
target: Option<DeviceRoute>,
resolution: Option<ScrollResolution>,
inverted: Option<bool>,
@@ -486,8 +535,11 @@ pub fn write_scroll_wheel_mode_in_background(
return;
}
};
- let shared = reusable_channel(capture, &target);
- let reused = shared.is_some();
+ let Ok(shared) = authoritative_channel(capture, registry, &target) else {
+ debug!(route = %target, "no inventory channel — wheel mode write skipped");
+ return;
+ };
+ let receiver_access = receiver_access.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -500,41 +552,22 @@ pub fn write_scroll_wheel_mode_in_background(
}
};
let result = rt.block_on(async {
+ let _lease = receiver_access.acquire_for_io().await;
tokio::time::timeout(WRITE_BUDGET, async {
- match (change, &shared) {
- (
- ScrollWheelModeChange::ResolutionAndInversion {
- resolution,
- inverted,
- },
- Some(shared),
- ) => openlogi_hid::set_scroll_wheel_mode_on(shared, resolution, inverted)
- .await
- .map(|_| ()),
- (
- ScrollWheelModeChange::ResolutionAndInversion {
- resolution,
- inverted,
- },
- None,
- ) => openlogi_hid::set_scroll_wheel_mode(&target, resolution, inverted)
+ match change {
+ ScrollWheelModeChange::ResolutionAndInversion {
+ resolution,
+ inverted,
+ } => openlogi_hid::set_scroll_wheel_mode_on(&shared, resolution, inverted)
.await
.map(|_| ()),
- (ScrollWheelModeChange::Resolution(resolution), Some(shared)) => {
- openlogi_hid::set_scroll_resolution_on(shared, resolution)
- .await
- .map(|_| ())
- }
- (ScrollWheelModeChange::Resolution(resolution), None) => {
- openlogi_hid::set_scroll_resolution(&target, resolution)
+ ScrollWheelModeChange::Resolution(resolution) => {
+ openlogi_hid::set_scroll_resolution_on(&shared, resolution)
.await
.map(|_| ())
}
- (ScrollWheelModeChange::Inversion(inverted), Some(shared)) => {
- openlogi_hid::set_scroll_inversion_on(shared, inverted).await
- }
- (ScrollWheelModeChange::Inversion(inverted), None) => {
- openlogi_hid::set_scroll_inversion(&target, inverted).await
+ ScrollWheelModeChange::Inversion(inverted) => {
+ openlogi_hid::set_scroll_inversion_on(&shared, inverted).await
}
}
})
@@ -542,13 +575,7 @@ pub fn write_scroll_wheel_mode_in_background(
});
let index = target.device_index();
match result {
- Ok(Ok(())) => debug!(
- index,
- ?resolution,
- ?inverted,
- reused,
- "native wheel mode written"
- ),
+ Ok(Ok(())) => debug!(index, ?resolution, ?inverted, "native wheel mode written"),
Ok(Err(WriteError::FeatureUnsupported { feature_hex })) => debug!(
index,
?resolution,
@@ -571,14 +598,26 @@ pub fn write_scroll_wheel_mode_in_background(
///
/// Resolves the configured colour (scaled by brightness, or black when the
/// lighting is off) and writes every key over HID++ via
-/// [`openlogi_hid::set_keyboard_color`]. A `None` target is a no-op (dev runs
-/// without a device); failures are logged, not surfaced.
-pub fn set_lighting_in_background(target: Option<DeviceRoute>, lighting: &Lighting) {
+/// [`openlogi_hid::set_keyboard_color_on`]. A `None` target is a no-op (dev
+/// runs without a device); a registry miss and write failures are logged, not
+/// surfaced.
+pub fn set_lighting_in_background(
+ capture: Option<&CaptureChannel>,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
+ target: Option<DeviceRoute>,
+ lighting: &Lighting,
+) {
let Some(target) = target else {
debug!("no target device — lighting write skipped");
return;
};
+ let Ok(shared) = authoritative_channel(capture, registry, &target) else {
+ debug!(route = %target, "no inventory channel — lighting write skipped");
+ return;
+ };
let (r, g, b) = lighting_rgb(lighting);
+ let receiver_access = receiver_access.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -590,7 +629,11 @@ pub fn set_lighting_in_background(target: Option<DeviceRoute>, lighting: &Lighti
return;
}
};
- match rt.block_on(openlogi_hid::set_keyboard_color(&target, r, g, b)) {
+ let result = rt.block_on(async {
+ let _lease = receiver_access.acquire_for_io().await;
+ openlogi_hid::set_keyboard_color_on(&shared, r, g, b).await
+ });
+ match result {
Ok(()) => debug!(r, g, b, "lighting written to keyboard"),
Err(e) => warn!(error = ?e, "lighting write failed"),
}
@@ -611,13 +654,15 @@ fn lighting_rgb(lighting: &Lighting) -> (u8, u8, u8) {
// Async, awaitable variants used by the IPC server (the GUI routes "apply now"
// / "read" device commands through the agent, which awaits and reports the
-// result). Writes reuse the capture session's open channel when it targets the
-// same device, exactly like the fire-and-forget `*_in_background` helpers, so
-// the daemon never opens a second channel to a device it already holds.
+// result). They use a registry-confirmed capture channel or the exact current
+// inventory channel, exactly like the fire-and-forget `*_in_background`
+// helpers, so the daemon never opens a second channel to a device it holds.
-/// Apply `dpi` to `route`, reusing the capture session's channel when possible.
+/// Apply `dpi` to `route` on its current inventory-owned channel.
pub async fn apply_dpi(
capture: &CaptureChannel,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
route: &DeviceRoute,
dpi: u32,
) -> Result<(), WriteError> {
@@ -628,60 +673,80 @@ pub async fn apply_dpi(
feature_hex: 0x2201,
kind: HidppFeatureErrorKind::OutOfRange,
})?;
- let shared = reusable_channel(Some(capture), route);
- timed(HidppOperation::WriteDpi, async {
- match &shared {
- Some(shared) => openlogi_hid::set_dpi_on(shared, dpi).await,
- None => openlogi_hid::set_dpi(route, dpi).await,
- }
- })
+ let _lease = receiver_access.acquire_for_io().await;
+ let shared = authoritative_channel(Some(capture), registry, route)?;
+ timed(
+ HidppOperation::WriteDpi,
+ openlogi_hid::set_dpi_on(&shared, dpi),
+ )
.await
}
/// Apply a full SmartShift config to `route` (capture-channel-aware).
pub async fn apply_smartshift(
capture: &CaptureChannel,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
route: &DeviceRoute,
mode: SmartShiftMode,
auto_disengage: u8,
tunable_torque: u8,
) -> Result<(), WriteError> {
- let shared = reusable_channel(Some(capture), route);
- timed(HidppOperation::WriteSmartShift, async {
- match &shared {
- Some(shared) => {
- openlogi_hid::set_smartshift_on(shared, mode, auto_disengage, tunable_torque).await
- }
- None => openlogi_hid::set_smartshift(route, mode, auto_disengage, tunable_torque).await,
- }
- })
+ let _lease = receiver_access.acquire_for_io().await;
+ let shared = authoritative_channel(Some(capture), registry, route)?;
+ timed(
+ HidppOperation::WriteSmartShift,
+ openlogi_hid::set_smartshift_on(&shared, mode, auto_disengage, tunable_torque),
+ )
.await
}
/// Apply a lighting config to the keyboard at `route`.
-pub async fn apply_lighting(route: &DeviceRoute, lighting: &Lighting) -> Result<(), WriteError> {
+pub async fn apply_lighting(
+ capture: &CaptureChannel,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
+ route: &DeviceRoute,
+ lighting: &Lighting,
+) -> Result<(), WriteError> {
+ let _lease = receiver_access.acquire_for_io().await;
+ let shared = authoritative_channel(Some(capture), registry, route)?;
let (r, g, b) = lighting_rgb(lighting);
timed(
HidppOperation::Lighting,
- openlogi_hid::set_keyboard_color(route, r, g, b),
+ openlogi_hid::set_keyboard_color_on(&shared, r, g, b),
)
.await
}
/// Read the current DPI + supported values from `route`.
-pub async fn read_dpi(route: &DeviceRoute) -> Result<DpiInfo, WriteError> {
+pub async fn read_dpi(
+ capture: &CaptureChannel,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
+ route: &DeviceRoute,
+) -> Result<DpiInfo, WriteError> {
+ let _lease = receiver_access.acquire_for_io().await;
+ let shared = authoritative_channel(Some(capture), registry, route)?;
timed(
HidppOperation::ReadDpiCapabilities,
- openlogi_hid::get_dpi_info(route),
+ openlogi_hid::get_dpi_info_on(&shared),
)
.await
}
/// Read the current SmartShift config from `route`.
-pub async fn read_smartshift(route: &DeviceRoute) -> Result<SmartShiftStatus, WriteError> {
+pub async fn read_smartshift(
+ capture: &CaptureChannel,
+ registry: &ChannelRegistry,
+ receiver_access: &ReceiverAccess,
+ route: &DeviceRoute,
+) -> Result<SmartShiftStatus, WriteError> {
+ let _lease = receiver_access.acquire_for_io().await;
+ let shared = authoritative_channel(Some(capture), registry, route)?;
timed(
HidppOperation::ReadSmartShift,
- openlogi_hid::get_smartshift_status(route),
+ openlogi_hid::get_smartshift_status_on(&shared),
)
.await
}
@@ -696,3 +761,40 @@ async fn timed<T>(
.await
.map_err(|_| WriteError::RequestTimedOut { operation })?
}
+
+#[cfg(test)]
+mod tests {
+ use std::cell::Cell;
+
+ use super::choose_authoritative;
+
+ #[test]
+ fn current_capture_wins_without_consulting_the_registry_again() {
+ let looked_up = Cell::new(false);
+ let selected = choose_authoritative(
+ Some("capture"),
+ |_| true,
+ || {
+ looked_up.set(true);
+ Some("registry")
+ },
+ );
+
+ assert_eq!(selected, Some("capture"));
+ assert!(!looked_up.get());
+ }
+
+ #[test]
+ fn stale_capture_falls_through_to_the_registry_winner() {
+ let selected = choose_authoritative(Some("stale"), |_| false, || Some("registry-current"));
+
+ assert_eq!(selected, Some("registry-current"));
+ }
+
+ #[test]
+ fn registry_miss_has_no_route_open_fallback() {
+ let selected = choose_authoritative(Some("stale"), |_| false, || None);
+
+ assert_eq!(selected, None);
+ }
+}
diff --git a/crates/openlogi-agent-core/src/hardware/light.rs b/crates/openlogi-agent-core/src/hardware/light.rs
new file mode 100644
index 0000000000000000000000000000000000000000..33eaf2d4b0b1aaa2bf256bf36ccbd18ff051b864
--- /dev/null
+++ b/crates/openlogi-agent-core/src/hardware/light.rs
@@ -0,0 +1,229 @@
+//! Serialized standalone-light writes and reconnect re-application.
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, LazyLock, Mutex, mpsc};
+use std::thread;
+
+use openlogi_core::config::LightSettings;
+use openlogi_core::device::LightCapabilities;
+use openlogi_hid::{
+ DeviceRoute, HidppOperation, LightCommand, LitraModel, WriteError, commands_for_light_settings,
+};
+use tracing::{debug, info, warn};
+
+struct LightApplyRequest {
+ settings: LightSettings,
+ capabilities: LightCapabilities,
+ generation: u64,
+}
+
+#[derive(Clone)]
+struct LightWorkerHandle {
+ sender: mpsc::Sender<LightApplyRequest>,
+ generation: Arc<AtomicU64>,
+}
+
+/// One coalescing worker per physical light. Reconnect and config transitions
+/// can overlap. Keeping one worker per route gives us ordered writes for that
+/// light, coalesces a burst to the latest desired state, and avoids creating a
+/// Tokio runtime and OS thread for every transition.
+static LIGHT_WORKERS: LazyLock<Mutex<HashMap<String, LightWorkerHandle>>> =
+ LazyLock::new(|| Mutex::new(HashMap::new()));
+
+type LightWriteLock = Arc<tokio::sync::Mutex<()>>;
+
+/// Serialize complete light-setting sequences with individual user commands.
+/// The HID layer already serializes each packet, while reconnect/config
+/// re-application writes power, brightness, and temperature as one operation.
+static LIGHT_WRITE_LOCKS: LazyLock<Mutex<HashMap<String, LightWriteLock>>> =
+ LazyLock::new(|| Mutex::new(HashMap::new()));
+
+/// Apply standalone-light settings during reconnect or config re-application.
+/// Failures are logged because this path is best-effort; an explicit IPC
+/// command returns the typed error to the caller instead.
+pub fn set_light_in_background(
+ target: Option<DeviceRoute>,
+ light: &LightSettings,
+ capabilities: LightCapabilities,
+) {
+ let Some(target) = target else {
+ debug!("no target device — light write skipped");
+ return;
+ };
+ let key = target.to_string();
+ let Some(worker) = light_worker(&key, target) else {
+ return;
+ };
+ let generation = worker.generation.fetch_add(1, Ordering::AcqRel) + 1;
+ if worker
+ .sender
+ .send(LightApplyRequest {
+ settings: *light,
+ capabilities,
+ generation,
+ })
+ .is_err()
+ {
+ warn!(route = %key, "light re-apply worker stopped");
+ remove_light_worker(&key, generation);
+ }
+}
+
+/// Invalidate pending best-effort writes before an explicit user command.
+/// Already-running writes are serialized by the HID driver's device lock; a
+/// newer explicit command therefore remains the final state.
+pub fn cancel_light_reapply(target: &DeviceRoute) {
+ let key = target.to_string();
+ let Ok(workers) = LIGHT_WORKERS.lock() else {
+ warn!(route = %key, "light worker registry poisoned — cannot cancel stale write");
+ return;
+ };
+ if let Some(worker) = workers.get(&key) {
+ worker.generation.fetch_add(1, Ordering::AcqRel);
+ }
+}
+
+fn light_worker(key: &str, target: DeviceRoute) -> Option<LightWorkerHandle> {
+ let Ok(mut workers) = LIGHT_WORKERS.lock() else {
+ warn!(
+ route = key,
+ "light worker registry poisoned — write skipped"
+ );
+ return None;
+ };
+ if let Some(worker) = workers.get(key) {
+ return Some(worker.clone());
+ }
+
+ let (sender, receiver) = mpsc::channel();
+ let generation = Arc::new(AtomicU64::new(0));
+ let worker_generation = Arc::clone(&generation);
+ let worker_key = key.to_string();
+ if let Err(error) = thread::Builder::new()
+ .name(format!("openlogi-light-{}", key.replace(':', "-")))
+ .spawn(move || light_worker_loop(target, receiver, worker_generation))
+ {
+ warn!(route = %worker_key, error = %error, "could not spawn light worker");
+ return None;
+ }
+ let worker = LightWorkerHandle { sender, generation };
+ workers.insert(worker_key, worker.clone());
+ Some(worker)
+}
+
+fn remove_light_worker(key: &str, generation: u64) {
+ let Ok(mut workers) = LIGHT_WORKERS.lock() else {
+ return;
+ };
+ if workers
+ .get(key)
+ .is_some_and(|worker| worker.generation.load(Ordering::Acquire) == generation)
+ {
+ workers.remove(key);
+ }
+}
+
+#[expect(
+ clippy::needless_pass_by_value,
+ reason = "the worker thread must own its route, receiver, and generation state"
+)]
+fn light_worker_loop(
+ target: DeviceRoute,
+ receiver: mpsc::Receiver<LightApplyRequest>,
+ generation: Arc<AtomicU64>,
+) {
+ let rt = match tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ {
+ Ok(rt) => rt,
+ Err(error) => {
+ warn!(route = %target, error = %error, "light worker runtime init failed");
+ return;
+ }
+ };
+ while let Ok(mut request) = receiver.recv() {
+ while let Ok(next) = receiver.try_recv() {
+ request = next;
+ }
+ if generation.load(Ordering::Acquire) != request.generation {
+ debug!(route = %target, "skipping superseded light re-apply");
+ continue;
+ }
+ let result = rt.block_on(apply_light_settings(
+ &target,
+ &request.settings,
+ request.capabilities,
+ &generation,
+ request.generation,
+ ));
+ match result {
+ Ok(true) => info!(
+ route = %target,
+ enabled = request.settings.enabled,
+ brightness = request.settings.brightness_percent,
+ temperature = ?request.settings.temperature_kelvin,
+ "light re-apply completed"
+ ),
+ Ok(false) => debug!(route = %target, "skipping canceled light re-apply"),
+ Err(error) => warn!(route = %target, error = ?error, "light settings re-apply failed"),
+ }
+ }
+}
+
+async fn apply_light_settings(
+ target: &DeviceRoute,
+ light: &LightSettings,
+ capabilities: LightCapabilities,
+ generation: &AtomicU64,
+ expected_generation: u64,
+) -> Result<bool, WriteError> {
+ let lock = light_write_lock(target);
+ let _guard = lock.lock().await;
+ // The request may have passed the queue check while an explicit command
+ // held the route lock. Re-check under that lock before writing anything so
+ // a canceled re-apply cannot overwrite the newer explicit state.
+ if generation.load(Ordering::Acquire) != expected_generation {
+ return Ok(false);
+ }
+ for command in commands_for_light_settings(*light, capabilities) {
+ apply_light_unlocked(target, command).await?;
+ }
+ Ok(true)
+}
+
+/// Apply a semantic command to a supported standalone light.
+pub async fn apply_light(route: &DeviceRoute, command: LightCommand) -> Result<(), WriteError> {
+ let lock = light_write_lock(route);
+ let _guard = lock.lock().await;
+ apply_light_unlocked(route, command).await
+}
+
+async fn apply_light_unlocked(
+ route: &DeviceRoute,
+ command: LightCommand,
+) -> Result<(), WriteError> {
+ let Some(model) = LitraModel::from_route(route) else {
+ return Err(WriteError::LightUnsupported {
+ control: "raw_hid_route".into(),
+ });
+ };
+ super::timed(
+ HidppOperation::Light,
+ openlogi_hid::apply_litra(route, model, command),
+ )
+ .await
+}
+
+fn light_write_lock(route: &DeviceRoute) -> LightWriteLock {
+ let key = route.to_string();
+ let Ok(mut locks) = LIGHT_WRITE_LOCKS.lock() else {
+ warn!(route = %key, "light write lock registry poisoned — using an isolated lock");
+ return Arc::new(tokio::sync::Mutex::new(()));
+ };
+ locks
+ .entry(key)
+ .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
+ .clone()
+}
diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs
index dc140597338012087887c525e315c0ebf1372657..125bc2c1553a0ee265e48ed0051743982983c49c 100644
--- a/crates/openlogi-agent-core/src/hook_runtime.rs
+++ b/crates/openlogi-agent-core/src/hook_runtime.rs
@@ -6,19 +6,26 @@
//! and gesture events.
use std::cell::RefCell;
-use std::collections::BTreeMap;
-use std::sync::{Arc, RwLock};
+use std::collections::{BTreeMap, HashSet};
+use std::sync::mpsc;
+use std::sync::{Arc, Mutex, PoisonError, RwLock};
+use std::thread;
+use std::time::{Duration, Instant};
use openlogi_core::binding::{
Action, ButtonId, GestureDirection, SwipeAccumulator, default_binding,
};
-use openlogi_hid::CaptureChannel;
-use openlogi_hook::{EventDisposition, Hook, MouseEvent};
+use openlogi_core::config::{KeyModifiers, KeyTrigger};
+use openlogi_hid::{CaptureChannel, ChannelRegistry};
+use openlogi_hook::{
+ EventDevice, EventDisposition, Hook, HookEvent, MouseEvent, source_is_remappable,
+};
use tracing::{info, warn};
use crate::DpiCycleState;
use crate::event_monitor::SharedEventMonitor;
use crate::hardware::{toggle_smartshift_in_background, write_dpi_in_background};
+use crate::receiver_access::ReceiverAccess;
/// The two button maps the OS-hook callback reads, kept behind ONE lock so a
/// config rebuild publishes both atomically — a press during an owner switch can
@@ -39,6 +46,24 @@ pub struct HookMaps {
/// (orchestrator), the OS-hook callback, and the gesture watcher.
pub type SharedHookMaps = Arc<RwLock<HookMaps>>;
+/// Shared keyboard trigger→action map for the function-key remapper. Unlike
+/// mouse bindings these are not per-app-profile (M1 scope — per the spec's
+/// non-goals), so a single map suffices. Keyed by the config `KeyTrigger`
+/// (keycode + modifiers).
+pub type SharedKeyboardBindings = Arc<RwLock<std::collections::HashMap<KeyTrigger, Action>>>;
+
+/// Convert the hook-layer modifier state into the config-layer type (the two
+/// live in different crates — core is leaf-level and duplicates the four
+/// bools). Drop-in identity once the field names align.
+fn convert_modifiers(m: openlogi_hook::KeyModifiers) -> KeyModifiers {
+ KeyModifiers {
+ shift: m.shift,
+ control: m.control,
+ option: m.option,
+ command: m.command,
+ }
+}
+
/// Tracks which OS-hook button (Middle/Back/Forward) is mid-hold and defers the
/// swipe detection itself to a shared [`SwipeAccumulator`], which commits a swipe
/// *mid-motion* like the HID++ gesture-button path in `openlogi-hid`. This wrapper
@@ -94,14 +119,184 @@ thread_local! {
/// Thread-local rather than a shared `Mutex` keeps the hot path lock-free and
/// free of cross-thread contention on the freeze-sensitive callback.
static HOLD: RefCell<HoldState> = RefCell::new(HoldState::default());
+ /// Buttons whose physical press was delivered because the action queue
+ /// rejected the remap. Their matching release must also pass through so
+ /// apps never see a stuck auxiliary button (down without up).
+ static FAIL_OPEN_PRESSES: RefCell<HashSet<ButtonId>> = RefCell::new(HashSet::new());
+}
+
+/// Whether a button event's physical source may be remapped/suppressed.
+///
+/// macOS attributes every CGEvent to an IOKit sender and fails closed: only
+/// known Logitech non-trackpad devices are remappable, so the built-in
+/// trackpad can never be swallowed. Linux/Windows often lack attribution
+/// (`device: None`); those platforms already restrict which devices the hook
+/// attaches to, so unknown sources stay remappable.
+fn button_source_may_remap(device: Option<&EventDevice>) -> bool {
+ match device {
+ Some(d) => source_is_remappable(Some(d)),
+ None => {
+ // Attribution missing: safe on Linux/Windows (device selection is
+ // upstream of the callback). On macOS fail closed — an unattributed
+ // event is more likely a trackpad/system source than a Logi mouse.
+ !cfg!(target_os = "macos")
+ }
+ }
+}
+
+/// Off-thread worker for bound actions so the tap callback never injects input.
+fn spawn_action_worker(
+ dpi_cycle: Arc<RwLock<DpiCycleState>>,
+ capture: CaptureChannel,
+ registry: ChannelRegistry,
+ receiver_access: ReceiverAccess,
+) -> mpsc::SyncSender<Action> {
+ let (tx, rx) = mpsc::sync_channel::<Action>(64);
+ let _ = thread::Builder::new()
+ .name("openlogi-action".into())
+ .spawn(move || {
+ while let Ok(action) = rx.recv() {
+ dispatch_action(
+ &action,
+ &dpi_cycle,
+ &capture,
+ Some(®istry),
+ &receiver_access,
+ );
+ }
+ });
+ tx
+}
+
+/// Queue a bound action without blocking the tap callback. Returns `false` if
+/// the queue is full (caller should fail open and pass the physical event).
+fn try_queue_action(tx: &mpsc::SyncSender<Action>, action: Action) -> bool {
+ if tx.try_send(action).is_err() {
+ warn!("action queue full — dropping bound action to keep the input hook live");
+ false
+ } else {
+ true
+ }
+}
+
+/// Remap path for Middle/Back/Forward. Must stay lock-light and non-blocking.
+fn handle_button(
+ id: ButtonId,
+ pressed: bool,
+ device: Option<&EventDevice>,
+ hooks: &SharedHookMaps,
+ action_tx: &mpsc::SyncSender<Action>,
+) -> EventDisposition {
+ // Primary L/R always pass through (suppressing them would brick the mouse).
+ if !id.is_os_hook_button() || !button_source_may_remap(device) {
+ return EventDisposition::PassThrough;
+ }
+
+ // `try_read` only: a blocking read on the tap thread freezes every pointer
+ // event while a config rebuild holds the write lock. Fail open if unavailable.
+ if pressed {
+ let is_gesture = hooks.try_read().is_ok_and(|m| m.gestures.contains_key(&id));
+ if is_gesture {
+ HOLD.with_borrow_mut(|h| h.begin(id));
+ return EventDisposition::Suppress;
+ }
+ } else {
+ // Drop the HOLD borrow before any queueing (re-entrancy freeze hazard).
+ let ended = HOLD.with_borrow_mut(|h| h.end(id));
+ if let Some(was_click) = ended {
+ if was_click {
+ let action = hooks
+ .try_read()
+ .ok()
+ .map(|m| resolve_gesture_click(&m.gestures, id));
+ if let Some(action) = action {
+ info!(button = %id, action = %action.label(), "gesture click → executing bound action");
+ let _ = try_queue_action(action_tx, action);
+ }
+ }
+ return EventDisposition::Suppress;
+ }
+ }
+
+ let action = hooks
+ .try_read()
+ .ok()
+ .and_then(|m| m.bindings.get(&id).cloned());
+ let Some(action) = action else {
+ return EventDisposition::PassThrough;
+ };
+ if is_native_click(id, &action) {
+ return EventDisposition::PassThrough;
+ }
+ if pressed {
+ info!(button = %id, action = %action.label(), "button → executing bound action");
+ let queued = try_queue_action(action_tx, action);
+ return FAIL_OPEN_PRESSES.with_borrow_mut(|s| remapped_press_disposition(id, queued, s));
+ }
+ FAIL_OPEN_PRESSES.with_borrow_mut(|s| remapped_release_disposition(id, s))
+}
+
+/// Press of a remapped single-action button: suppress when the action was
+/// queued, otherwise pass through and mark `id` so the release pairs.
+fn remapped_press_disposition(
+ id: ButtonId,
+ queued: bool,
+ fail_open: &mut HashSet<ButtonId>,
+) -> EventDisposition {
+ if queued {
+ fail_open.remove(&id);
+ EventDisposition::Suppress
+ } else {
+ fail_open.insert(id);
+ EventDisposition::PassThrough
+ }
+}
+
+/// Release of a remapped single-action button: pass through only when the
+/// matching press was fail-opened (queue rejection), else suppress.
+fn remapped_release_disposition(
+ id: ButtonId,
+ fail_open: &mut HashSet<ButtonId>,
+) -> EventDisposition {
+ if fail_open.remove(&id) {
+ EventDisposition::PassThrough
+ } else {
+ EventDisposition::Suppress
+ }
+}
+
+/// Feed an in-progress gesture hold; always pass motion through so the cursor moves.
+fn handle_moved(
+ delta_x: i32,
+ delta_y: i32,
+ hooks: &SharedHookMaps,
+ action_tx: &mpsc::SyncSender<Action>,
+) -> EventDisposition {
+ let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y));
+ if let Some((button, dir)) = commit {
+ let action = hooks.try_read().ok().map(|m| {
+ m.gestures
+ .get(&button)
+ .and_then(|dirs| dirs.get(&dir).cloned())
+ .unwrap_or_else(|| resolve_gesture_click(&m.gestures, button))
+ });
+ if let Some(action) = action {
+ info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action");
+ let _ = try_queue_action(action_tx, action);
+ }
+ }
+ EventDisposition::PassThrough
}
/// Attempt to start the OS hook. Returns `None` if Accessibility is not
/// granted or on an unsupported platform — the app continues without crashing.
pub fn start(
hooks: SharedHookMaps,
+ keyboard_bindings: SharedKeyboardBindings,
dpi_cycle: Arc<RwLock<DpiCycleState>>,
capture: CaptureChannel,
+ registry: ChannelRegistry,
+ receiver_access: ReceiverAccess,
monitor: SharedEventMonitor,
) -> Option<Hook> {
if !Hook::has_accessibility() {
@@ -112,152 +307,96 @@ pub fn start(
return None;
}
+ // Actions never run on the tap callback thread (HID CGEventTap freeze hazard).
+ let action_tx = spawn_action_worker(dpi_cycle, capture, registry, receiver_access);
+
// The per-hold pointer accumulator lives in the thread-local `HOLD`; the
// callback must never block — see the freeze-hazard note in `macos.rs`.
- let result = Hook::start(move |event| {
- // Mirror the raw event to the GUI's live monitor first (a single relaxed
- // atomic load while monitoring is off — see `event_monitor`), before any
- // remapping decides its disposition.
- monitor.record(&event);
- match event {
- MouseEvent::Button { id, pressed } => {
- // The CGEventTap only sees standard buttons 0-4. We remap
- // Middle/Back/Forward; the primary L/R clicks always pass through
- // (suppressing them would brick the mouse), and the DPI / thumb /
- // dedicated gesture button aren't visible to the tap at all — the
- // dedicated gesture button is captured separately over HID++.
- if !id.is_os_hook_button() {
- return EventDisposition::PassThrough;
+ let result = Hook::start(move |event| match event {
+ HookEvent::Mouse(event) => {
+ monitor.record(&event);
+ match event {
+ MouseEvent::Button {
+ id,
+ pressed,
+ device,
+ } => handle_button(id, pressed, device.as_ref(), &hooks, &action_tx),
+ MouseEvent::Moved { delta_x, delta_y } => {
+ handle_moved(delta_x, delta_y, &hooks, &action_tx)
}
-
- // Gesture button: suppress the native click and begin a hold. The
- // swipe commits mid-motion in the `Moved` arm; here, on release, we
- // only fire the plain `Click` when no swipe committed. The cursor is
- // free to drift via the pass-through `Moved` events during the hold.
- if pressed {
- let is_gesture = hooks.read().is_ok_and(|m| m.gestures.contains_key(&id));
- if is_gesture {
- HOLD.with_borrow_mut(|h| h.begin(id));
- return EventDisposition::Suppress;
- }
- } else {
- // Release: end the hold and release the `HOLD` borrow *before* any
- // dispatch — the callback must stay lock-light, since a
- // synthesized event could otherwise re-enter the tap and re-borrow
- // `HOLD` (a RefCell double-borrow panic, freeze hazard).
- let ended = HOLD.with_borrow_mut(|h| h.end(id));
- if let Some(was_click) = ended {
- if was_click {
- // No swipe committed → fire the plain click. Resolve to an
- // owned action (so no lock is held across dispatch), then
- // dispatch with the guard already dropped.
- let action = hooks
- .read()
- .ok()
- .map(|m| resolve_gesture_click(&m.gestures, id));
- if let Some(action) = action {
- info!(button = %id, action = %action.label(), "gesture click → executing bound action");
- dispatch_action(&action, &dpi_cycle, &capture);
- }
- }
- return EventDisposition::Suppress;
- }
+ MouseEvent::CaptureInterrupted => {
+ HOLD.with_borrow_mut(HoldState::cancel);
+ EventDisposition::PassThrough
}
-
- // Single-action button.
- let action = hooks.read().ok().and_then(|m| m.bindings.get(&id).cloned());
- let Some(action) = action else {
- // Unbound → leave the physical button to the OS.
- return EventDisposition::PassThrough;
- };
-
- // A button left on its own native click (e.g. Middle → MiddleClick)
- // should just do that click; suppressing and re-synthesising it
- // would be pointless churn.
- if is_native_click(id, &action) {
- return EventDisposition::PassThrough;
- }
-
- if pressed {
- info!(button = %id, action = %action.label(), "button → executing bound action");
- dispatch_action(&action, &dpi_cycle, &capture);
- }
- EventDisposition::Suppress
- }
- MouseEvent::Moved { delta_x, delta_y } => {
- // Feed an in-progress hold; a committed swipe fires here, mid-motion.
- // Always pass through so the cursor keeps moving — the swipe is read,
- // not consumed (the B2 cursor-drift tradeoff vs. a HID++ raw-XY divert
- // that would freeze the pointer).
- let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y));
- if let Some((button, dir)) = commit {
- // Resolve to an owned action and drop the read guard before
- // dispatch (same lock-light rule as the release arm). The button
- // can leave the gesture set mid-hold (a per-app rebuild); the
- // commit has already armed `fired`, so the release won't fire a
- // click. Fall back to the same click action the release path uses
- // so the suppressed press is never swallowed into nothing —
- // symmetric with `resolve_gesture_click`.
- let action = hooks.read().ok().map(|m| {
- m.gestures
- .get(&button)
- .and_then(|dirs| dirs.get(&dir).cloned())
- .unwrap_or_else(|| resolve_gesture_click(&m.gestures, button))
- });
- if let Some(action) = action {
- info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action");
- dispatch_action(&action, &dpi_cycle, &capture);
+ MouseEvent::Scroll {
+ delta_x, delta_y, ..
+ } => {
+ #[cfg(not(target_os = "windows"))]
+ let _ = (delta_x, delta_y);
+ #[cfg(target_os = "windows")]
+ if delta_y == 0.0
+ && let Some((button, action)) = hooks
+ .try_read()
+ .ok()
+ .and_then(|maps| rebound_thumbwheel_action(&maps, delta_x))
+ {
+ info!(button = %button, action = %action.label(), "native thumb wheel → executing bound action");
+ if try_queue_action(&action_tx, action) {
+ return EventDisposition::Suppress;
+ }
}
+ EventDisposition::PassThrough
}
- EventDisposition::PassThrough
}
- MouseEvent::CaptureInterrupted => {
- // The OS dropped events (tap disabled); cancel any hold so a lost
- // button-up can't later commit a phantom swipe off ordinary motion.
- HOLD.with_borrow_mut(HoldState::cancel);
- EventDisposition::PassThrough
+ }
+ // Function-key remapper: on key-down, look up a [keyboard.bindings]
+ // entry for this keycode + modifier mask. A match queues its action
+ // (suppressing the original key so it doesn't also type / trigger its
+ // native function); an unmatched key passes through untouched. Key-up
+ // is ignored to avoid double-firing the action.
+ HookEvent::Key(openlogi_hook::KeyEvent {
+ keycode,
+ pressed,
+ modifiers,
+ }) => {
+ if !pressed {
+ return EventDisposition::PassThrough;
}
- MouseEvent::Scroll {
- delta_x, delta_y, ..
- } => {
- // Older MX mice (MX Master 2S) report the thumb wheel through
- // native horizontal HID scroll rather than 0x2150. Windows'
- // low-level hook sees those WM_MOUSEHWHEEL ticks, so when a
- // direction is rebound we consume the native tick and dispatch
- // the configured action. Newer 0x2150 devices are diverted by
- // the HID++ capture session and therefore produce no duplicate
- // OS event here. Leave the default left/right scroll untouched.
- #[cfg(not(target_os = "windows"))]
- let _ = (delta_x, delta_y);
- #[cfg(target_os = "windows")]
- if delta_y == 0.0
- && let Some((button, action)) = hooks
- .read()
- .ok()
- .and_then(|maps| rebound_thumbwheel_action(&maps, delta_x))
- {
- info!(button = %button, action = %action.label(), "native thumb wheel → executing bound action");
- dispatch_action(&action, &dpi_cycle, &capture);
- return EventDisposition::Suppress;
+ let trigger = KeyTrigger {
+ keycode,
+ modifiers: convert_modifiers(modifiers),
+ };
+ match keyboard_bindings
+ .try_read()
+ .ok()
+ .and_then(|m| m.get(&trigger).cloned())
+ {
+ Some(action) => {
+ info!(keycode, action = %action.label(), "key → executing bound action");
+ if try_queue_action(&action_tx, action) {
+ EventDisposition::Suppress
+ } else {
+ EventDisposition::PassThrough
+ }
}
- EventDisposition::PassThrough
+ None => EventDisposition::PassThrough,
}
}
});
match result {
Ok(hook) => {
- info!("OS mouse hook installed");
+ info!("OS input hook installed");
Some(hook)
}
Err(e) => {
- warn!(error = %e, "could not install OS mouse hook — events will not be captured");
+ warn!(error = %e, "could not install OS input hook — events will not be captured");
None
}
}
}
-/// Resolve a native horizontal wheel tick to a rebound thumb-wheel action.
+/// Resolve a native horizontal-wheel tick to a rebound thumb-wheel action.
/// The built-in horizontal-scroll defaults intentionally return `None` so the
/// physical wheel stays native unless the user changed that direction. On
/// Windows/MX Master 2S, positive `WM_MOUSEHWHEEL` delta is the physical
@@ -307,17 +446,55 @@ fn is_native_click(id: ButtonId, action: &Action) -> bool {
)
}
+/// Minimum time between two BrowserBack (or two BrowserForward) keyboard
+/// dispatches, shared across the CGEventTap hook and the HID++ gesture
+/// watcher — both call [`dispatch_action`] independently, and on devices
+/// where one physical press is visible through both paths, a naive dispatch
+/// would fire the keyboard shortcut twice for one click. Same window as the
+/// HID++ path's own intra-press debounce (`BACK_FORWARD_DEBOUNCE` in
+/// `openlogi-hid`), for consistency.
+const BROWSER_NAV_DEBOUNCE: Duration = Duration::from_millis(150);
+
+/// Per-direction last-dispatch timestamps backing [`browser_nav_debounce_ok`].
+/// `(last_back, last_forward)`.
+static BROWSER_NAV_LAST: Mutex<(Option<Instant>, Option<Instant>)> = Mutex::new((None, None));
+
+/// Whether a BrowserBack/BrowserForward keyboard dispatch for `action` should
+/// proceed, or be suppressed as a duplicate of one already sent (from either
+/// dispatch path) within [`BROWSER_NAV_DEBOUNCE`]. Records the dispatch time
+/// on every `true` return so the *next* call — from either path — sees it.
+fn browser_nav_debounce_ok(action: &Action) -> bool {
+ let mut last = BROWSER_NAV_LAST
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner);
+ let slot = if matches!(action, Action::BrowserForward) {
+ &mut last.1
+ } else {
+ &mut last.0
+ };
+ let now = Instant::now();
+ let fire = slot.is_none_or(|t| now.duration_since(t) >= BROWSER_NAV_DEBOUNCE);
+ if fire {
+ *slot = Some(now);
+ }
+ fire
+}
+
/// Route a bound action either to OS-level event synthesis
/// ([`Action::execute`]) or to one of OpenLogi's hardware-side handlers.
///
/// `dpi_cycle` is held across a write lock long enough to advance the index
/// and snapshot the new DPI + target; the actual HID write spawns its own
/// thread via [`write_dpi_in_background`] to keep event callbacks non-blocking.
-/// `capture` lets those writes reuse the capture session's open channel.
+/// `registry` confirms that `capture` is still current or supplies the current
+/// inventory channel. Hardware actions are skipped when standalone callers do
+/// not provide a registry.
pub fn dispatch_action(
action: &Action,
dpi_cycle: &Arc<RwLock<DpiCycleState>>,
capture: &CaptureChannel,
+ registry: Option<&ChannelRegistry>,
+ receiver_access: &ReceiverAccess,
) {
let next = match action {
Action::CycleDpiPresets => match dpi_cycle.write() {
@@ -337,9 +514,29 @@ pub fn dispatch_action(
Action::ToggleSmartShift => {
let target = dpi_cycle.read().ok().and_then(|g| g.target.clone());
info!("SmartShift toggle → flipping wheel mode");
- toggle_smartshift_in_background(Some(capture), target);
+ if let Some(registry) = registry {
+ toggle_smartshift_in_background(Some(capture), registry, receiver_access, target);
+ } else {
+ warn!("no inventory registry — SmartShift toggle skipped");
+ }
return;
}
+ // BrowserBack/BrowserForward fall through to the keyboard shortcut
+ // (Cmd+[ / Cmd+]) here — for Chrome and other apps that respond to
+ // it, and as the HID++ gesture watcher's own fallback when its
+ // AXPress attempt (Safari) fails. On devices where one physical press
+ // is visible through both the CGEventTap hook and the HID++ diverted
+ // path (e.g. MX Vertical), both independently reach this arm for the
+ // *same* press, so it's cross-path debounced — otherwise a
+ // keyboard-driven browser like Chrome would navigate twice per click.
+ Action::BrowserBack | Action::BrowserForward => {
+ if browser_nav_debounce_ok(action) {
+ openlogi_inject::execute(action);
+ } else {
+ info!(action = %action.label(), "browser nav debounced — duplicate dispatch path suppressed");
+ }
+ None
+ }
other => {
openlogi_inject::execute(other);
None
@@ -347,7 +544,11 @@ pub fn dispatch_action(
};
if let Some((dpi, target)) = next {
info!(dpi, "DPI action → writing to device");
- write_dpi_in_background(Some(capture), target, dpi);
+ if let Some(registry) = registry {
+ write_dpi_in_background(Some(capture), registry, receiver_access, target, dpi);
+ } else {
+ warn!("no inventory registry — DPI action skipped");
+ }
} else if matches!(action, Action::CycleDpiPresets | Action::SetDpiPreset(_)) {
info!(
action = %action.label(),
@@ -416,6 +617,34 @@ mod tests {
assert_eq!(resolve_gesture_click(&off, ButtonId::Back), Action::None);
}
+ #[test]
+ fn fail_open_press_pairs_release() {
+ let mut fail_open = HashSet::new();
+ // Queue accepted → suppress press and release.
+ assert_eq!(
+ remapped_press_disposition(ButtonId::Back, true, &mut fail_open),
+ EventDisposition::Suppress
+ );
+ assert_eq!(
+ remapped_release_disposition(ButtonId::Back, &mut fail_open),
+ EventDisposition::Suppress
+ );
+ // Queue rejected → pass through press *and* matching release.
+ assert_eq!(
+ remapped_press_disposition(ButtonId::Forward, false, &mut fail_open),
+ EventDisposition::PassThrough
+ );
+ assert_eq!(
+ remapped_release_disposition(ButtonId::Forward, &mut fail_open),
+ EventDisposition::PassThrough
+ );
+ // A later unpaired release of that button suppresses again.
+ assert_eq!(
+ remapped_release_disposition(ButtonId::Forward, &mut fail_open),
+ EventDisposition::Suppress
+ );
+ }
+
#[test]
fn rebound_horizontal_wheel_maps_to_thumbwheel_directions() {
let maps = HookMaps {
diff --git a/crates/openlogi-agent-core/src/ipc.rs b/crates/openlogi-agent-core/src/ipc.rs
index b7b7c7d43bc60df086ea5ad8a624ba2228e4e59d..152d9b96508d445488fe858b4f7bd14a408c8085 100644
--- a/crates/openlogi-agent-core/src/ipc.rs
+++ b/crates/openlogi-agent-core/src/ipc.rs
@@ -7,10 +7,10 @@
//! pairing event arrives or the request deadline elapses.
use openlogi_core::config::Lighting;
-use openlogi_core::device::DeviceInventory;
+use openlogi_core::device::{DeviceInventory, StandaloneDevice};
use openlogi_hid::{
- DeviceRoute, DpiInfo, PairingError, PasskeyMethod, ReceiverSelector, SmartShiftMode,
- SmartShiftStatus, WriteError,
+ DeviceRoute, DpiInfo, LightCommand, PairingError, PasskeyMethod, ReceiverSelector,
+ SmartShiftMode, SmartShiftStatus, WriteError,
};
use serde::{Deserialize, Serialize};
@@ -28,8 +28,10 @@ use serde::{Deserialize, Serialize};
/// v8: [`WriteError`] carries typed HID++ operation failures.
/// v9: `poll_event_monitor` appended + [`MonitorEvent`] (live event monitor).
/// v10: `Capabilities::hires_wheel` appended.
-/// v11: `Capabilities::thumbwheel` appended.
-pub const PROTOCOL_VERSION: u32 = 11;
+/// v11: standalone raw-HID inventory, camera state, and light methods appended.
+/// v12: standalone registry model identity appended to `StandaloneDevice`.
+/// v13: `Capabilities::thumbwheel` appended.
+pub const PROTOCOL_VERSION: u32 = 13;
/// Where the agent's device enumeration stands. The distinction matters
/// because an empty inventory list is ambiguous on its own: the GUI must keep
@@ -72,6 +74,12 @@ pub struct AgentStatus {
pub struct AgentSnapshot {
pub status: AgentStatus,
pub inventory: Vec<DeviceInventory>,
+ /// Recognized standalone raw-HID devices, kept separate from receiver
+ /// pairing inventories.
+ pub standalone: Vec<StandaloneDevice>,
+ /// Whether at least one host camera stream is currently in use.
+ /// Runtime-only state used by camera-linked light rendering.
+ pub camera_active: bool,
}
/// A nearby unpaired device surfaced during Bolt discovery, in the minimal form
@@ -276,4 +284,9 @@ pub trait Agent {
/// there is no explicit stop. Appended last — see the method-order note on
/// [`Agent::protocol_version`].
async fn poll_event_monitor() -> Vec<MonitorEvent>;
+ /// Apply a semantic standalone-light command to a raw HID route.
+ async fn set_light(route: DeviceRoute, command: LightCommand) -> Result<(), WriteError>;
+ /// Manually override effective power for a camera-linked light until the
+ /// next aggregate camera-use transition.
+ async fn set_light_manual_power(route: DeviceRoute, enabled: bool) -> Result<(), WriteError>;
}
diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs
index 8f3e288bd27d0296e992658c1da0b5f61f079a11..ca18798fe6f4db84498b5178960b351f99f78ef8 100644
--- a/crates/openlogi-agent-core/src/orchestrator.rs
+++ b/crates/openlogi-agent-core/src/orchestrator.rs
@@ -10,14 +10,20 @@
//! [`DpiCycleState::capabilities`] stays `None` and presets cycle at their raw
//! (still valid) values — exactly the GUI's "window never opened" behaviour.
-use std::collections::{BTreeMap, HashSet};
-use std::sync::atomic::{AtomicI32, Ordering};
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::sync::atomic::{AtomicI32, AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
-use openlogi_core::config::{Config, ScrollResolution};
-use openlogi_core::device::{Capabilities, DeviceInventory};
-use openlogi_hid::{CaptureChannel, DeviceRoute};
-use tracing::warn;
+use openlogi_core::binding::Action;
+use openlogi_core::config::{Config, LightSettings, ScrollResolution};
+use openlogi_core::device::{
+ Capabilities, DeviceInventory, DeviceKind, LightCapabilities, StandaloneDevice,
+};
+use openlogi_hid::{
+ CaptureChannel, ChannelPool, ChannelRegistry, DIRECT_DEVICE_INDEX, DeviceRoute,
+ KEYBOARD_KEY_CIDS,
+};
+use tracing::{debug, info, warn};
use crate::DpiCycleState;
use crate::bindings::{bindings_for, gesture_bindings_for, oshook_gestures_for};
@@ -26,6 +32,8 @@ use crate::hook_runtime::{HookMaps, SharedHookMaps};
use crate::ipc::InventoryHealth;
use crate::receiver_access::ReceiverAccess;
use crate::watchers::gesture::GestureBindings;
+use crate::watchers::host_switch::{HostSwitchLink, HostSwitchLinks};
+use crate::watchers::keyboard::{KeyboardSpec, SharedKeyboardSpec};
/// The minimal per-device facts the agent needs: the config key (binding /
/// preset lookup), the HID++ route (DPI/SmartShift writes + capture target), and
@@ -39,6 +47,11 @@ struct AgentDevice {
serial: Option<String>,
unit_id: [u8; 4],
capabilities: Option<Capabilities>,
+ /// HID++-reported device kind — identity only (capability decisions come
+ /// from the feature table). Used to find the keyboard the key-capture
+ /// watcher should target.
+ kind: DeviceKind,
+ light_capabilities: Option<LightCapabilities>,
/// Live link state from the inventory snapshot. An offline→online
/// transition is a reconnect — the device may have power-cycled, so its
/// volatile settings need re-applying (#189).
@@ -54,13 +67,32 @@ pub struct SharedRuntime {
/// rebuild publishes both atomically (see [`HookMaps`]). Also read by the
/// gesture watcher for the thumb-wheel/DPI-button single actions.
pub hook_maps: SharedHookMaps,
+ /// Function-key remapper bindings (keycode+modifiers → action). Not
+ /// per-app-profile in M1 (spec non-goal), so a single shared map.
+ pub keyboard_bindings: crate::hook_runtime::SharedKeyboardBindings,
pub gesture_bindings: GestureBindings,
pub dpi_cycle: Arc<RwLock<DpiCycleState>>,
pub thumbwheel_sensitivity: Arc<AtomicI32>,
pub capture_channel: CaptureChannel,
- /// Exclusive receiver access shared by HID++ capture and pairing. Capture
- /// and pairing must never open the same receiver HID node concurrently.
+ /// Exact-route channels owned and published by the inventory enumerator.
+ pub channel_registry: ChannelRegistry,
+ /// Shared transport pool used by long-running host-switch sessions.
+ pub channel_pool: ChannelPool,
+ /// The keyboard key-capture watcher's target + bindings, `None` while no
+ /// online keyboard has bound keys.
+ pub keyboard_spec: SharedKeyboardSpec,
+ /// The keyboard capture session's open channel, reused by Fn-lock writes
+ /// (the mouse-oriented [`Self::capture_channel`] points elsewhere).
+ pub keyboard_channel: CaptureChannel,
+ /// Incremented when the selected device reconnects or the system wakes, so
+ /// the gesture watcher re-arms volatile HID++ control diversion even when
+ /// the receiver route itself never changed.
+ pub capture_rearm_generation: Arc<AtomicU64>,
+ /// Receiver access shared by HID++ sessions and pairing. Pairing/host
+ /// transitions are exclusive; capture sessions share under read leases.
pub receiver_access: ReceiverAccess,
+ /// Keyboard → pointing-device routes resolved from `config.toml`.
+ pub host_switch_links: HostSwitchLinks,
}
/// Owns the config + device selection and keeps [`SharedRuntime`] in sync.
@@ -80,9 +112,18 @@ pub struct Orchestrator {
/// set/route/online state looks identical across the sleep gap, so the
/// next refresh re-applies volatile settings to every online device.
reapply_all_next_refresh: bool,
- /// Config keys of devices first sighted last refresh, due one confirming
- /// re-apply: the first write can race the device's own boot and be lost.
- reapply_followup: HashSet<String>,
+ /// Config keys of devices first sighted last refresh, mapped to a remaining
+ /// count of confirming re-applies. The first write can race the device's own
+ /// boot and be lost — a cold restart leaves the MX Master 3s slow to enumerate,
+ /// so the volatile write (DPI/SmartShift/wheel/lighting) is retried for a
+ /// bounded run of inventory ticks until the device finishes booting (#189).
+ reapply_followup: HashMap<String, u8>,
+ /// Last successful aggregate camera-use sample. `None` means the macOS
+ /// watcher has not produced its first usable observation yet.
+ camera_active: Option<bool>,
+ /// Transient manual power choices for camera-linked lights. A camera-use
+ /// transition clears them; they are never written to the config.
+ manual_light_overrides: BTreeMap<String, bool>,
shared: SharedRuntime,
}
@@ -92,7 +133,10 @@ enum InventoryState {
/// No enumeration has completed yet; the device set is unknown.
Pending,
/// The latest completed snapshot — empty means "checked, no devices".
- Ready(Vec<DeviceInventory>),
+ Ready {
+ inventories: Vec<DeviceInventory>,
+ standalone: Vec<StandaloneDevice>,
+ },
/// Enumeration has never succeeded (broken HID backend / dead watcher).
Unavailable,
}
@@ -105,13 +149,20 @@ impl Orchestrator {
pub fn new(config: Config) -> Self {
let shared = SharedRuntime {
hook_maps: Arc::new(RwLock::new(HookMaps::default())),
+ keyboard_bindings: Arc::new(RwLock::new(config.keyboard.bindings.clone())),
gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())),
dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())),
thumbwheel_sensitivity: Arc::new(AtomicI32::new(
config.app_settings.thumbwheel_sensitivity,
)),
capture_channel: Arc::new(RwLock::new(None)),
+ channel_registry: ChannelRegistry::default(),
+ channel_pool: ChannelPool::default(),
+ keyboard_spec: Arc::new(RwLock::new(None)),
+ keyboard_channel: Arc::new(RwLock::new(None)),
+ capture_rearm_generation: Arc::new(AtomicU64::new(0)),
receiver_access: ReceiverAccess::default(),
+ host_switch_links: Arc::new(RwLock::new(Vec::new())),
};
let orch = Self {
config,
@@ -120,7 +171,9 @@ impl Orchestrator {
current_app: None,
inventory: InventoryState::Pending,
reapply_all_next_refresh: false,
- reapply_followup: HashSet::new(),
+ reapply_followup: HashMap::new(),
+ camera_active: None,
+ manual_light_overrides: BTreeMap::new(),
shared,
};
orch.rebuild();
@@ -136,11 +189,34 @@ impl Orchestrator {
fn current_key(&self) -> Option<&str> {
self.devices
.get(self.current)
+ .filter(|device| is_hidpp_device(device))
.map(|d| d.config_key.as_str())
}
fn current_route(&self) -> Option<DeviceRoute> {
- self.devices.get(self.current).and_then(|d| d.route.clone())
+ self.devices
+ .get(self.current)
+ .filter(|device| device.online && is_hidpp_device(device))
+ .and_then(|device| device.route.clone())
+ }
+
+ /// Keep the capture/DPI write target aligned with the selected device's
+ /// live connection state without rebuilding the rest of the DPI cycle.
+ ///
+ /// Inventory-only online transitions do not warrant [`Self::rebuild`]
+ /// (which intentionally resets the cycle index), but they do have to stop
+ /// and restart HID++ capture. Easy-Switch preserves the receiver route
+ /// while the device is away, and its volatile control diversion is lost;
+ /// publishing `None` while offline and the route again on return makes the
+ /// capture watcher open a fresh session and re-arm those controls.
+ fn sync_current_route(&self) {
+ let target = self.current_route();
+ match self.shared.dpi_cycle.write() {
+ Ok(mut state) => state.target = target,
+ Err(error) => {
+ warn!(%error, lock = "dpi_cycle", "lock poisoned — keeping stale value");
+ }
+ }
}
/// Build the OS-hook callback's maps for `key` + foreground `app`. Both hook
@@ -154,6 +230,46 @@ impl Orchestrator {
}
}
+ /// The keyboard key-capture spec for the first known keyboard, or `None`
+ /// when no keyboard is paired or none of its capturable keys carries a
+ /// real binding (an unbound key must never be diverted).
+ ///
+ /// Deliberately does NOT require the keyboard to be online: an idle
+ /// keyboard sleeps within minutes and probe timeouts can flap it offline,
+ /// and tearing the capture session down on every nap would hand the
+ /// diverted keys back to the firmware (dead bindings) until the re-arm
+ /// races through. The session instead stays up across sleeps — its
+ /// channel is to the always-present receiver — and re-arms diversion on
+ /// the device's `0x1d4b` reconnection broadcast.
+ fn keyboard_spec_for(&self) -> Option<KeyboardSpec> {
+ let dev = self
+ .devices
+ .iter()
+ .find(|d| d.kind == DeviceKind::Keyboard && d.route.is_some())?;
+ let bindings = bindings_for(
+ &self.config,
+ Some(&dev.config_key),
+ self.current_app.as_deref(),
+ );
+ let wanted: BTreeMap<u16, _> = KEYBOARD_KEY_CIDS
+ .iter()
+ .filter(|(_, button)| {
+ bindings
+ .get(button)
+ .is_some_and(|action| *action != Action::None)
+ })
+ .copied()
+ .collect();
+ if wanted.is_empty() {
+ return None;
+ }
+ Some(KeyboardSpec {
+ route: dev.route.clone()?,
+ wanted,
+ bindings,
+ })
+ }
+
/// Rewrite every shared map from the current config + selected device.
fn rebuild(&self) {
let key = self.current_key();
@@ -179,25 +295,52 @@ impl Orchestrator {
},
"dpi_cycle",
);
+ // Keyboard F-key bindings are global (not per-device), so they key off
+ // the top-level config map rather than the selected device. Published
+ // here so `reload_config` (GUI commit) takes effect live, not only on
+ // agent restart.
+ write_value(
+ &self.shared.keyboard_bindings,
+ self.config.keyboard.bindings.clone(),
+ "keyboard_bindings",
+ );
self.shared.thumbwheel_sensitivity.store(
self.config.app_settings.thumbwheel_sensitivity,
Ordering::Relaxed,
);
+ write_value(
+ &self.shared.host_switch_links,
+ host_switch_links(&self.config, &self.devices),
+ "host_switch_links",
+ );
+ write_value(
+ &self.shared.keyboard_spec,
+ self.keyboard_spec_for(),
+ "keyboard_spec",
+ );
}
/// Apply a fresh inventory snapshot. Always refreshes the snapshot the IPC
/// `inventory()` poll serves (battery / online state changes without
/// altering the device *set*), but only re-picks the selection and rebuilds
- /// the shared maps when the device set actually changed — `rebuild()` is
- /// driven solely by `config_key` + route and resets the live DPI-cycle
- /// index, so running it every 2s tick on an unchanged set would snap DPI
- /// back to `preset[0]` (and burn three `RwLock` writes) for nothing.
- pub fn refresh_inventory(&mut self, inventories: &[DeviceInventory]) {
+ /// the shared maps when the device set or runtime selection changed —
+ /// `rebuild()` is driven by `config_key` + route and resets the live
+ /// DPI-cycle index, so running it every 2s tick on a steady selection
+ /// would snap DPI back to `preset[0]` (and burn three `RwLock` writes)
+ /// for nothing.
+ pub fn refresh_inventory(
+ &mut self,
+ inventories: &[DeviceInventory],
+ standalone: &[StandaloneDevice],
+ ) {
// Even an empty snapshot is a *completed* enumeration — the watcher
// skips failed ticks — so the device set is now known either way (and
// a recovered backend upgrades `Unavailable` back to live data).
- self.inventory = InventoryState::Ready(inventories.to_vec());
- let devices = build_devices(inventories);
+ self.inventory = InventoryState::Ready {
+ inventories: inventories.to_vec(),
+ standalone: standalone.to_vec(),
+ };
+ let devices = build_devices(inventories, standalone);
// Volatile settings (lighting colour, sensor DPI, SmartShift, native
// wheel mode) live in device RAM and reset on a power cycle. Every
// reconnect shape re-applies the persisted values (#189): a first
@@ -205,6 +348,9 @@ impl Orchestrator {
// (offline→online), or — via the
// flag — a system wake where none of those are observable.
let reapply_all = std::mem::take(&mut self.reapply_all_next_refresh);
+ let next_current = pick_current(&devices, self.config.selected_device());
+ let rearm_capture =
+ selected_needs_capture_rearm(&self.devices, &devices, next_current, reapply_all);
let followup = std::mem::take(&mut self.reapply_followup);
let (targets, next_followup) =
plan_reapply(&self.devices, &devices, &followup, reapply_all);
@@ -212,21 +358,38 @@ impl Orchestrator {
for idx in targets {
self.reapply_volatile_settings(&devices[idx]);
}
- let changed = devices.len() != self.devices.len()
+ let changed = next_current != self.current
+ || devices.len() != self.devices.len()
|| devices.iter().zip(&self.devices).any(|(a, b)| {
a.config_key != b.config_key
|| a.route != b.route
|| a.capabilities != b.capabilities
+ || a.light_capabilities != b.light_capabilities
});
- if !changed {
- // Same set and routes — but keep the fresh `online` flags, or a
- // device that woke this tick would read as a transition forever.
+ if changed {
self.devices = devices;
- return;
+ self.current = next_current;
+ self.rebuild();
+ } else {
+ // Same set, routes, and runtime selection — but keep the fresh
+ // `online` flags, or a device that woke this tick would read as a
+ // transition forever.
+ self.devices = devices;
+ self.sync_current_route();
+ write_value(
+ &self.shared.host_switch_links,
+ host_switch_links(&self.config, &self.devices),
+ "host_switch_links",
+ );
+ }
+ if rearm_capture {
+ let generation = self
+ .shared
+ .capture_rearm_generation
+ .fetch_add(1, Ordering::Relaxed)
+ .wrapping_add(1);
+ debug!(generation, "selected device requires capture re-arm");
}
- self.devices = devices;
- self.current = pick_current(&self.devices, self.config.selected_device());
- self.rebuild();
}
/// Force a volatile-settings re-apply for every online device on the next
@@ -261,6 +424,8 @@ impl Orchestrator {
if resolution.is_some() || inverted.is_some() || dpi.is_some() || smartshift.is_some() {
crate::hardware::reapply_mouse_volatile_in_background(
Some(&self.shared.capture_channel),
+ &self.shared.channel_registry,
+ &self.shared.receiver_access,
route.clone(),
resolution,
inverted,
@@ -269,8 +434,89 @@ impl Orchestrator {
);
}
if let Some(lighting) = self.config.lighting(key).filter(|l| l.enabled) {
- crate::hardware::set_lighting_in_background(Some(route), &lighting);
+ crate::hardware::set_lighting_in_background(
+ Some(&self.shared.capture_channel),
+ &self.shared.channel_registry,
+ &self.shared.receiver_access,
+ Some(route.clone()),
+ &lighting,
+ );
+ }
+ if let Some(fn_lock) = self.config.fn_lock(key) {
+ crate::hardware::write_fn_lock_in_background(
+ Some(&self.shared.keyboard_channel),
+ &self.shared.channel_registry,
+ &self.shared.receiver_access,
+ Some(route.clone()),
+ fn_lock,
+ );
+ }
+ if let Some(capabilities) = dev.light_capabilities
+ && let Some(light) = self.effective_light_settings(key)
+ {
+ crate::hardware::set_light_in_background(Some(route), &light, capabilities);
+ }
+ }
+
+ /// Apply an aggregate camera-use transition to every opted-in online
+ /// light. Only effective power is transient; persisted manual power and
+ /// the remaining light settings are unchanged.
+ pub fn set_camera_active(&mut self, active: bool) {
+ if self.camera_active == Some(active) {
+ return;
+ }
+ let previous = self.camera_active;
+ self.camera_active = Some(active);
+ self.manual_light_overrides.clear();
+ let mut applied = 0;
+ for dev in self
+ .devices
+ .iter()
+ .filter(|dev| dev.online && dev.route.is_some())
+ {
+ let (Some(capabilities), Some(mut light)) = (
+ dev.light_capabilities,
+ self.config
+ .light(&dev.config_key)
+ .filter(|light| light.auto_camera),
+ ) else {
+ continue;
+ };
+ light.enabled = active;
+ crate::hardware::set_light_in_background(dev.route.clone(), &light, capabilities);
+ applied += 1;
+ }
+ info!(previous = ?previous, active, lights = applied, "applied camera-linked light state");
+ }
+
+ /// Resolve settings for reconnect/config re-application. Camera policy and
+ /// a transient manual override replace only the effective power field.
+ fn effective_light_settings(&self, key: &str) -> Option<LightSettings> {
+ let mut light = self.config.light(key)?;
+ if light.auto_camera {
+ if let Some(override_enabled) = self.manual_light_overrides.get(key) {
+ light.enabled = *override_enabled;
+ } else if let Some(active) = self.camera_active {
+ light.enabled = active;
+ }
}
+ Some(light)
+ }
+
+ /// Store a transient manual power choice for a known light route. The IPC
+ /// write can race the config reload that first enabled camera automation,
+ /// so route/capability identity—not the possibly-stale config bit—is the
+ /// acceptance condition. A reload retains it only while the new config is
+ /// camera-linked.
+ pub fn set_manual_light_power(&mut self, route: &DeviceRoute, enabled: bool) -> bool {
+ let Some(device) = self.devices.iter().find(|device| {
+ device.route.as_ref() == Some(route) && device.light_capabilities.is_some()
+ }) else {
+ return false;
+ };
+ self.manual_light_overrides
+ .insert(device.config_key.clone(), enabled);
+ true
}
/// Push the saved native wheel resolution/inversion to every currently online
@@ -289,6 +535,8 @@ impl Orchestrator {
let (resolution, inverted) = configured_wheel_mode(&self.config, dev);
crate::hardware::write_scroll_wheel_mode_in_background(
Some(&self.shared.capture_channel),
+ &self.shared.channel_registry,
+ &self.shared.receiver_access,
(resolution.is_some() || inverted.is_some())
.then(|| dev.route.clone())
.flatten(),
@@ -304,17 +552,33 @@ impl Orchestrator {
#[must_use]
pub fn inventory(&self) -> Vec<DeviceInventory> {
match &self.inventory {
- InventoryState::Ready(inventories) => inventories.clone(),
+ InventoryState::Ready { inventories, .. } => inventories.clone(),
InventoryState::Pending | InventoryState::Unavailable => Vec::new(),
}
}
+ /// The latest standalone raw-HID inventory snapshot.
+ #[must_use]
+ pub fn standalone(&self) -> Vec<StandaloneDevice> {
+ match &self.inventory {
+ InventoryState::Ready { standalone, .. } => standalone.clone(),
+ InventoryState::Pending | InventoryState::Unavailable => Vec::new(),
+ }
+ }
+
+ /// The latest aggregate camera-use sample, or `false` before the first
+ /// successful macOS observation.
+ #[must_use]
+ pub fn camera_active(&self) -> bool {
+ self.camera_active.unwrap_or(false)
+ }
+
/// Where enumeration stands, for the IPC `status` poll.
#[must_use]
pub fn inventory_health(&self) -> InventoryHealth {
match self.inventory {
InventoryState::Pending => InventoryHealth::Scanning,
- InventoryState::Ready(_) => InventoryHealth::Ready,
+ InventoryState::Ready { .. } => InventoryHealth::Ready,
InventoryState::Unavailable => InventoryHealth::Unavailable,
}
}
@@ -351,14 +615,74 @@ impl Orchestrator {
self.hook_maps_for(self.current_key(), self.current_app.as_deref()),
"hook_maps",
);
+ // The keyboard's effective bindings are app-scoped too.
+ write_value(
+ &self.shared.keyboard_spec,
+ self.keyboard_spec_for(),
+ "keyboard_spec",
+ );
}
/// Replace the config (after `config.toml` changed) and rebuild everything.
pub fn reload_config(&mut self, config: Config) {
+ // Parameter-only edits must not erase a transient manual choice while
+ // the light remains camera-linked. Changing the policy invalidates it.
self.config = config;
+ let retained_overrides: HashSet<String> = self
+ .manual_light_overrides
+ .keys()
+ .filter(|key| {
+ self.config
+ .light(key)
+ .is_some_and(|light| light.auto_camera)
+ })
+ .cloned()
+ .collect();
+ self.manual_light_overrides
+ .retain(|key, _| retained_overrides.contains(key));
self.current = pick_current(&self.devices, self.config.selected_device());
self.rebuild();
self.apply_native_wheel_modes();
+ self.apply_fn_locks();
+ self.reapply_light_settings();
+ }
+
+ /// Push the saved Fn-lock state to every online keyboard that has one.
+ /// Runs on config reloads (the reconnect path is
+ /// [`Self::reapply_volatile_settings`]); the write is a single HID++ call,
+ /// so re-applying an unchanged state is cheap.
+ fn apply_fn_locks(&self) {
+ for dev in self
+ .devices
+ .iter()
+ .filter(|dev| dev.online && dev.route.is_some())
+ {
+ if let Some(fn_lock) = self.config.fn_lock(&dev.config_key) {
+ crate::hardware::write_fn_lock_in_background(
+ Some(&self.shared.keyboard_channel),
+ &self.shared.channel_registry,
+ &self.shared.receiver_access,
+ dev.route.clone(),
+ fn_lock,
+ );
+ }
+ }
+ }
+
+ /// Re-apply standalone-light settings after a config reload.
+ fn reapply_light_settings(&self) {
+ for dev in self
+ .devices
+ .iter()
+ .filter(|dev| dev.online && dev.route.is_some() && dev.light_capabilities.is_some())
+ {
+ if let (Some(light), Some(capabilities)) = (
+ self.effective_light_settings(&dev.config_key),
+ dev.light_capabilities,
+ ) {
+ crate::hardware::set_light_in_background(dev.route.clone(), &light, capabilities);
+ }
+ }
}
}
@@ -385,7 +709,10 @@ fn configured_wheel_mode(
/// `build_device_list` minus the asset/display fields: a device is included
/// only once its HID++ DeviceInformation (`model_info`) has resolved, since the
/// model key is derived from it.
-fn build_devices(inventories: &[DeviceInventory]) -> Vec<AgentDevice> {
+fn build_devices(
+ inventories: &[DeviceInventory],
+ standalone: &[StandaloneDevice],
+) -> Vec<AgentDevice> {
let mut devices = Vec::new();
for inv in inventories {
for paired in &inv.paired {
@@ -410,10 +737,42 @@ fn build_devices(inventories: &[DeviceInventory]) -> Vec<AgentDevice> {
serial: model.serial_number.clone(),
unit_id: model.unit_id,
capabilities: paired.capabilities,
+ kind: paired.kind,
+ light_capabilities: None,
online: paired.online,
});
}
}
+ for device in standalone {
+ let route = DeviceRoute::RawHid {
+ vendor_id: device.address.vendor_id,
+ product_id: device.address.product_id,
+ usage_page: device.address.usage_page,
+ usage_id: device.address.usage_id,
+ identity: device.address.identity.clone(),
+ };
+ let stable_id = DeviceStableId::from_parts(
+ Some(&route),
+ DIRECT_DEVICE_INDEX,
+ device.serial_number.as_deref(),
+ device.unit_id,
+ );
+ let Some(config_key) = stable_id.physical_key() else {
+ continue;
+ };
+ devices.push(AgentDevice {
+ config_key: config_key.into_string(),
+ model_key: device.display_name.clone(),
+ route: Some(route),
+ slot: DIRECT_DEVICE_INDEX,
+ serial: device.serial_number.clone(),
+ unit_id: device.unit_id,
+ capabilities: device.capabilities,
+ kind: device.kind,
+ light_capabilities: device.light_capabilities,
+ online: device.online,
+ });
+ }
// Order by the same canonical key the GUI carousel uses, so the
// no-saved-selection fallback (`pick_current` -> index 0) targets the device
// the GUI shows first rather than whatever HID node enumerated first.
@@ -426,6 +785,31 @@ fn build_devices(inventories: &[DeviceInventory]) -> Vec<AgentDevice> {
devices
}
+fn host_switch_links(config: &Config, devices: &[AgentDevice]) -> Vec<HostSwitchLink> {
+ config
+ .devices
+ .iter()
+ .filter_map(|(keyboard_key, settings)| {
+ let keyboard = devices
+ .iter()
+ .find(|device| device.config_key == *keyboard_key && device.online)?
+ .route
+ .clone()?;
+ let targets = settings
+ .host_switch_targets
+ .iter()
+ .filter_map(|target_key| {
+ devices
+ .iter()
+ .find(|device| device.config_key == *target_key)
+ .and_then(|device| device.route.clone())
+ })
+ .collect::<Vec<_>>();
+ (!targets.is_empty()).then_some(HostSwitchLink { keyboard, targets })
+ })
+ .collect()
+}
+
/// The canonical identity of one device: what the GUI carousel orders by, what
/// the config key is derived from, and what [`reapply_targets`] matches a device
/// against across inventory ticks.
@@ -466,46 +850,97 @@ fn reapply_targets(prev: &[AgentDevice], next: &[AgentDevice], reapply_all: bool
.collect()
}
+/// Whether this refresh invalidated the selected device's volatile control
+/// diversion. Receiver routes stay connected while a paired mouse sleeps, so
+/// route equality alone cannot tell the capture watcher to re-arm on wake.
+fn selected_needs_capture_rearm(
+ prev: &[AgentDevice],
+ next: &[AgentDevice],
+ selected: usize,
+ reapply_all: bool,
+) -> bool {
+ reapply_targets(prev, next, reapply_all).contains(&selected)
+}
+
+/// How many inventory ticks a first-sighted device keeps re-applying its
+/// volatile settings after the initial write. A cold restart leaves a Bolt/
+/// Unifying mouse slow to enumerate, so the first write (and a single confirm)
+/// can both time out against a still-booting device; retrying for ~8s at the 2s
+/// cadence lets the write land once it finishes booting. Bounded rather than
+/// read-back-confirmed — see the note on [`plan_reapply`].
+const VOLATILE_REAPPLY_CONFIRM_RETRIES: u8 = 4;
+
/// Plan this refresh's volatile-settings writes: the [`reapply_targets`] set
-/// plus one confirming re-apply for devices first sighted last refresh, and
-/// the follow-up keys to confirm next refresh.
+/// plus a bounded run of confirming re-applies for devices first sighted
+/// recently, and the follow-up keys (with remaining retry counts) to confirm
+/// next refresh. Reconnects (offline→online) re-apply once — the device was
+/// already booted, so it needs no boot-race retry.
fn plan_reapply(
prev: &[AgentDevice],
next: &[AgentDevice],
- followup: &HashSet<String>,
+ followup: &HashMap<String, u8>,
reapply_all: bool,
-) -> (Vec<usize>, HashSet<String>) {
+) -> (Vec<usize>, HashMap<String, u8>) {
let mut targets = reapply_targets(prev, next, reapply_all);
- let next_followup = targets
+ let mut next_followup: HashMap<String, u8> = targets
.iter()
.filter(|&&idx| {
let id = stable_id(&next[idx]);
!prev.iter().any(|p| stable_id(p) == id)
})
- .map(|&idx| next[idx].config_key.clone())
+ .map(|&idx| {
+ (
+ next[idx].config_key.clone(),
+ VOLATILE_REAPPLY_CONFIRM_RETRIES,
+ )
+ })
.collect();
for (idx, dev) in next.iter().enumerate() {
- if dev.online
- && dev.route.is_some()
- && followup.contains(&dev.config_key)
- && !targets.contains(&idx)
- {
- targets.push(idx);
+ if dev.online && dev.route.is_some() && !targets.contains(&idx) {
+ // ponytail: bounded retry, not read-back-confirmed. The upgrade is
+ // to confirm the write took (read DPI back via openlogi_hid and
+ // stop retrying on a match) instead of running out a fixed budget —
+ // that converges faster and drops the redundant writes on a device
+ // that accepted the first one.
+ if let Some(&remaining) = followup.get(&dev.config_key) {
+ targets.push(idx);
+ if remaining > 1 {
+ next_followup.insert(dev.config_key.clone(), remaining - 1);
+ }
+ }
}
}
(targets, next_followup)
}
-/// Index of the selected device: the one whose `config_key` matches the saved
-/// selection, else the first. `build_devices` sorts by the same canonical key
-/// the GUI carousel uses, so "the first" is the same physical device in both
-/// processes even when nothing is persisted yet.
+/// Index of the selected HID++ input device. Prefer the saved selection while
+/// it is an online input route, otherwise the first online input route. If
+/// every input device is offline, preserve the saved selection (or the first
+/// input route) so its configuration remains stable. Standalone raw-HID
+/// devices participate in inventory and settings re-apply but must never
+/// replace the mouse/keyboard capture target when selected in the GUI.
fn pick_current(devices: &[AgentDevice], saved: Option<&str>) -> usize {
+ let saved = saved.and_then(|key| {
+ devices
+ .iter()
+ .position(|device| device.config_key == key && is_hidpp_device(device))
+ });
saved
- .and_then(|key| devices.iter().position(|d| d.config_key == key))
+ .filter(|&idx| devices[idx].online)
+ .or_else(|| {
+ devices
+ .iter()
+ .position(|device| device.online && is_hidpp_device(device))
+ })
+ .or(saved)
+ .or_else(|| devices.iter().position(is_hidpp_device))
.unwrap_or(0)
}
+fn is_hidpp_device(device: &AgentDevice) -> bool {
+ !matches!(device.route, Some(DeviceRoute::RawHid { .. }))
+}
+
/// Replace the value behind an `RwLock`, logging (not panicking) on poison so a
/// background thread that paniced while holding the lock can't take the agent
/// down — it just keeps the stale value until the next successful rebuild.
@@ -517,236 +952,4 @@ fn write_value<T>(lock: &RwLock<T>, value: T, name: &str) {
}
#[cfg(test)]
-mod tests {
- use super::{
- AgentDevice, InventoryHealth, Orchestrator, build_devices, configured_wheel_mode,
- plan_reapply, reapply_targets,
- };
- use openlogi_core::config::{Config, ScrollResolution};
- use openlogi_core::device::{
- Capabilities, DeviceInventory, DeviceKind, DeviceModelInfo, DeviceTransports, PairedDevice,
- ReceiverInfo,
- };
- use openlogi_hid::{DIRECT_DEVICE_INDEX, DeviceRoute};
-
- fn dev(key: &str, slot: u8, online: bool) -> AgentDevice {
- AgentDevice {
- config_key: key.to_string(),
- model_key: key.to_string(),
- route: Some(DeviceRoute::Bolt {
- receiver_uid: "AA00".to_string(),
- slot,
- }),
- slot,
- serial: None,
- unit_id: [0; 4],
- capabilities: None,
- online,
- }
- }
-
- fn direct_inventory(serial_number: Option<&str>, unit_id: [u8; 4]) -> DeviceInventory {
- DeviceInventory {
- receiver: ReceiverInfo {
- name: "MX Master 3S".to_string(),
- vendor_id: 0x046d,
- product_id: 0xb023,
- unique_id: None,
- },
- paired: vec![PairedDevice {
- slot: DIRECT_DEVICE_INDEX,
- codename: Some("MX Master 3S".to_string()),
- wpid: None,
- kind: DeviceKind::Mouse,
- online: true,
- battery: None,
- model_info: Some(DeviceModelInfo {
- entity_count: 1,
- serial_number: serial_number.map(str::to_string),
- unit_id,
- transports: DeviceTransports::default(),
- model_ids: [0xb034, 0, 0],
- extended_model_id: 2,
- }),
- capabilities: Some(Capabilities::presumed_from_kind(DeviceKind::Mouse)),
- }],
- }
- }
-
- #[test]
- fn build_devices_skips_transient_zero_unit_direct_identity() {
- assert!(build_devices(&[direct_inventory(None, [0; 4])]).is_empty());
-
- let devices = build_devices(&[direct_inventory(Some("ABC123"), [0; 4])]);
- assert_eq!(devices.len(), 1);
- assert_eq!(devices[0].config_key, "direct:046d:b023:serial:abc123");
- }
-
- #[test]
- fn configured_wheel_mode_gates_resolution_and_inversion_independently() {
- let mut config = Config::default();
- config.set_scroll_resolution("a", Some(ScrollResolution::Low));
- config.set_invert_scroll("a", true);
- let mut device = dev("a", 1, true);
-
- device.capabilities = Some(Capabilities {
- hires_wheel: true,
- thumbwheel: false,
- scroll_inversion: false,
- ..Capabilities::default()
- });
- assert_eq!(
- configured_wheel_mode(&config, &device),
- (Some(ScrollResolution::Low), None)
- );
-
- device.capabilities = Some(Capabilities {
- hires_wheel: false,
- thumbwheel: false,
- scroll_inversion: true,
- ..Capabilities::default()
- });
- assert_eq!(configured_wheel_mode(&config, &device), (None, Some(true)));
-
- device.capabilities = None;
- assert_eq!(configured_wheel_mode(&config, &device), (None, None));
- }
-
- #[test]
- fn configured_wheel_mode_leaves_unset_resolution_unmanaged() {
- let config = Config::default();
- let mut device = dev("a", 1, true);
- device.capabilities = Some(Capabilities {
- hires_wheel: true,
- thumbwheel: false,
- scroll_inversion: false,
- ..Capabilities::default()
- });
-
- assert_eq!(configured_wheel_mode(&config, &device), (None, None));
- }
-
- #[test]
- fn reapply_targets_new_arrivals_and_transitions() {
- // First sighting of an online device → re-apply.
- assert_eq!(reapply_targets(&[], &[dev("a", 1, true)], false), vec![0]);
- // Steady state → nothing.
- assert!(reapply_targets(&[dev("a", 1, true)], &[dev("a", 1, true)], false).is_empty());
- // Replug under a new route (same key, new slot) → re-apply.
- assert_eq!(
- reapply_targets(&[dev("a", 1, true)], &[dev("a", 2, true)], false),
- vec![0]
- );
- // Waking from device sleep (offline → online) → re-apply.
- assert_eq!(
- reapply_targets(&[dev("a", 1, false)], &[dev("a", 1, true)], false),
- vec![0]
- );
- // Going to sleep (online → offline) → nothing.
- assert!(reapply_targets(&[dev("a", 1, true)], &[dev("a", 1, false)], false).is_empty());
- }
-
- #[test]
- fn reapply_targets_disambiguates_same_model_duplicates() {
- // Two devices can share a model key but are distinct physical units at
- // different Bolt slots, so they have distinct stable ids. A steady tick
- // with both already online must target NEITHER.
- let prev = [dev("dup", 1, true), dev("dup", 2, true)];
- let next = [dev("dup", 1, true), dev("dup", 2, true)];
- assert!(reapply_targets(&prev, &next, false).is_empty());
- }
-
- #[test]
- fn reapply_targets_skip_offline_and_routeless_devices() {
- // A paired-but-asleep new arrival waits for its online transition —
- // writing now would only time out against a sleeping device.
- assert!(reapply_targets(&[], &[dev("a", 1, false)], false).is_empty());
- let routeless = AgentDevice {
- route: None,
- ..dev("b", 2, true)
- };
- assert!(reapply_targets(&[], &[routeless], false).is_empty());
- }
-
- #[test]
- fn reapply_all_targets_every_online_device() {
- let prev = [dev("a", 1, true), dev("b", 2, false)];
- let next = [dev("a", 1, true), dev("b", 2, false)];
- // The post-wake snapshot looks identical to the pre-sleep one; the
- // flag still re-applies to the online device (and only that one).
- assert_eq!(reapply_targets(&prev, &next, true), vec![0]);
- }
-
- #[test]
- fn plan_reapply_confirms_a_first_sighting_once() {
- use std::collections::HashSet;
- // First sighting: applied now, queued for one confirming re-apply.
- let (targets, followup) = plan_reapply(&[], &[dev("a", 1, true)], &HashSet::new(), false);
- assert_eq!(targets, vec![0]);
- assert_eq!(followup, HashSet::from(["a".to_string()]));
- // Next refresh: the confirming apply fires, then the queue drains.
- let prev = [dev("a", 1, true)];
- let (targets, followup) = plan_reapply(&prev, &prev, &followup, false);
- assert_eq!(targets, vec![0]);
- assert!(followup.is_empty());
- // Steady state after that: nothing.
- let (targets, _) = plan_reapply(&prev, &prev, &followup, false);
- assert!(targets.is_empty());
- }
-
- #[test]
- fn plan_reapply_transitions_are_not_queued_for_confirmation() {
- use std::collections::HashSet;
- // A wake from device sleep re-applies once — the device was already
- // booted, so no confirming write is queued.
- let (targets, followup) = plan_reapply(
- &[dev("a", 1, false)],
- &[dev("a", 1, true)],
- &HashSet::new(),
- false,
- );
- assert_eq!(targets, vec![0]);
- assert!(followup.is_empty());
- }
-
- #[test]
- fn plan_reapply_skips_a_followup_that_went_offline() {
- use std::collections::HashSet;
- let prev = [dev("a", 1, true)];
- let (targets, followup) = plan_reapply(
- &prev,
- &[dev("a", 1, false)],
- &HashSet::from(["a".to_string()]),
- false,
- );
- assert!(targets.is_empty());
- assert!(followup.is_empty());
- }
-
- /// An *empty* snapshot still flips the health to `Ready`: the watcher only
- /// forwards completed enumerations, so "checked and found nothing" must not
- /// be reported as "still scanning" — that's the whole distinction the
- /// health exists to carry.
- #[test]
- fn empty_refresh_marks_inventory_ready() {
- let mut orch = Orchestrator::new(Config::default());
- assert_eq!(orch.inventory_health(), InventoryHealth::Scanning);
- orch.refresh_inventory(&[]);
- assert_eq!(orch.inventory_health(), InventoryHealth::Ready);
- }
-
- /// `Unavailable` is a startup-only downgrade: it reports "enumeration has
- /// never worked", recovers when a snapshot finally lands, and never
- /// clobbers a live device set on a mid-session failure (mirroring the
- /// watcher's keep-last-snapshot policy).
- #[test]
- fn unavailable_only_downgrades_a_pending_inventory() {
- let mut orch = Orchestrator::new(Config::default());
- orch.mark_inventory_unavailable();
- assert_eq!(orch.inventory_health(), InventoryHealth::Unavailable);
- orch.refresh_inventory(&[]);
- assert_eq!(orch.inventory_health(), InventoryHealth::Ready);
- orch.mark_inventory_unavailable();
- assert_eq!(orch.inventory_health(), InventoryHealth::Ready);
- }
-}
+mod tests;
diff --git a/crates/openlogi-agent-core/src/orchestrator/tests.rs b/crates/openlogi-agent-core/src/orchestrator/tests.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b15fcdc801c5864b4773fc89b8336fe67f284975
--- /dev/null
+++ b/crates/openlogi-agent-core/src/orchestrator/tests.rs
@@ -0,0 +1,660 @@
+//! Orchestrator inventory/reapply/camera tests.
+
+use super::{
+ AgentDevice, InventoryHealth, Orchestrator, VOLATILE_REAPPLY_CONFIRM_RETRIES, build_devices,
+ configured_wheel_mode, host_switch_links, pick_current, plan_reapply, reapply_targets,
+ selected_needs_capture_rearm,
+};
+use openlogi_core::config::{Config, LightSettings, ScrollResolution};
+use openlogi_core::device::{
+ Capabilities, DeviceInventory, DeviceKind, DeviceModelInfo, DeviceTransports,
+ LightCapabilities, PairedDevice, RawDeviceAddress, ReceiverInfo, StandaloneDevice,
+};
+use openlogi_hid::{DIRECT_DEVICE_INDEX, DeviceRoute};
+
+fn dev(key: &str, slot: u8, online: bool) -> AgentDevice {
+ AgentDevice {
+ config_key: key.to_string(),
+ model_key: key.to_string(),
+ route: Some(DeviceRoute::Bolt {
+ receiver_uid: "AA00".to_string(),
+ slot,
+ }),
+ slot,
+ serial: None,
+ unit_id: [0; 4],
+ capabilities: None,
+ kind: openlogi_core::device::DeviceKind::Mouse,
+ light_capabilities: None,
+ online,
+ }
+}
+
+fn raw_light_dev(key: &str) -> AgentDevice {
+ AgentDevice {
+ config_key: key.to_string(),
+ model_key: "Litra Glow".to_string(),
+ route: Some(DeviceRoute::RawHid {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:glow-1".to_string(),
+ }),
+ slot: DIRECT_DEVICE_INDEX,
+ serial: Some("glow-1".to_string()),
+ unit_id: [0; 4],
+ capabilities: None,
+ kind: DeviceKind::Light,
+ light_capabilities: Some(openlogi_core::device::LightCapabilities {
+ power: true,
+ ..openlogi_core::device::LightCapabilities::default()
+ }),
+ online: true,
+ }
+}
+
+fn direct_inventory(serial_number: Option<&str>, unit_id: [u8; 4]) -> DeviceInventory {
+ DeviceInventory {
+ receiver: ReceiverInfo {
+ name: "MX Master 3S".to_string(),
+ vendor_id: 0x046d,
+ product_id: 0xb023,
+ unique_id: None,
+ },
+ paired: vec![PairedDevice {
+ slot: DIRECT_DEVICE_INDEX,
+ codename: Some("MX Master 3S".to_string()),
+ wpid: None,
+ kind: DeviceKind::Mouse,
+ online: true,
+ battery: None,
+ model_info: Some(DeviceModelInfo {
+ entity_count: 1,
+ serial_number: serial_number.map(str::to_string),
+ unit_id,
+ transports: DeviceTransports::default(),
+ model_ids: [0xb034, 0, 0],
+ extended_model_id: 2,
+ }),
+ capabilities: Some(Capabilities::presumed_from_kind(DeviceKind::Mouse)),
+ }],
+ }
+}
+
+fn direct_inventory_state(
+ product_id: u16,
+ serial_number: Option<&str>,
+ unit_id: [u8; 4],
+ online: bool,
+) -> DeviceInventory {
+ DeviceInventory {
+ receiver: ReceiverInfo {
+ name: "MX Master 3S".to_string(),
+ vendor_id: 0x046d,
+ product_id,
+ unique_id: None,
+ },
+ paired: vec![PairedDevice {
+ slot: DIRECT_DEVICE_INDEX,
+ codename: Some("MX Master 3S".to_string()),
+ wpid: None,
+ kind: DeviceKind::Mouse,
+ online,
+ battery: None,
+ model_info: Some(DeviceModelInfo {
+ entity_count: 1,
+ serial_number: serial_number.map(str::to_string),
+ unit_id,
+ transports: DeviceTransports::default(),
+ model_ids: [product_id, 0, 0],
+ extended_model_id: 2,
+ }),
+ capabilities: Some(Capabilities::presumed_from_kind(DeviceKind::Mouse)),
+ }],
+ }
+}
+
+#[test]
+fn build_devices_skips_transient_zero_unit_direct_identity() {
+ assert!(build_devices(&[direct_inventory(None, [0; 4])], &[]).is_empty());
+
+ let devices = build_devices(&[direct_inventory(Some("ABC123"), [0; 4])], &[]);
+ assert_eq!(devices.len(), 1);
+ assert_eq!(devices[0].config_key, "direct:046d:b023:serial:abc123");
+}
+
+#[test]
+fn build_devices_keeps_serial_backed_standalone_lights_beside_hidpp_devices() {
+ let light_capabilities = openlogi_core::device::LightCapabilities {
+ power: true,
+ ..openlogi_core::device::LightCapabilities::default()
+ };
+ let standalone = StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:glow-1".to_string(),
+ },
+ display_name: "Litra Glow".to_string(),
+ manufacturer: Some("Logitech".to_string()),
+ serial_number: Some("Glow-1".to_string()),
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: Some(light_capabilities),
+ driver_id: "litra".to_string(),
+ registry_model_id: Some("8c900".to_string()),
+ };
+
+ let devices = build_devices(&[direct_inventory(Some("ABC123"), [0; 4])], &[standalone]);
+
+ assert_eq!(devices.len(), 2);
+ let Some(light) = devices
+ .iter()
+ .find(|device| device.model_key == "Litra Glow")
+ else {
+ panic!("standalone light should be retained");
+ };
+ assert_eq!(light.config_key, "raw:046d:c900:ff43:0202:serial:glow-1");
+ assert_eq!(light.light_capabilities, Some(light_capabilities));
+ assert!(matches!(light.route, Some(DeviceRoute::RawHid { .. })));
+}
+
+#[test]
+fn standalone_selection_never_replaces_the_hidpp_capture_target() {
+ let devices = [raw_light_dev("light"), dev("mouse", 1, true)];
+
+ assert_eq!(pick_current(&devices, Some("light")), 1);
+ assert_eq!(pick_current(&devices, None), 1);
+}
+
+#[test]
+fn runtime_selection_falls_back_from_saved_offline_device_to_online_device() {
+ let devices = [dev("saved", 1, false), dev("online", 2, true)];
+
+ assert_eq!(pick_current(&devices, Some("saved")), 1);
+}
+
+#[test]
+fn runtime_selection_keeps_saved_device_when_it_is_online() {
+ let devices = [dev("other", 1, true), dev("saved", 2, true)];
+
+ assert_eq!(pick_current(&devices, Some("saved")), 1);
+}
+
+#[test]
+fn runtime_selection_keeps_saved_device_when_all_devices_are_offline() {
+ let devices = [dev("other", 1, false), dev("saved", 2, false)];
+
+ assert_eq!(pick_current(&devices, Some("saved")), 1);
+}
+
+#[test]
+fn runtime_selection_tracks_online_transition_without_device_set_change() {
+ let saved_key = "direct:046d:b023:unit:01000000";
+ let other_key = "direct:046d:b034:unit:02000000";
+ let mut config = Config::default();
+ config.set_selected_device(Some(saved_key.to_string()));
+ let mut orchestrator = Orchestrator::new(config);
+
+ orchestrator.refresh_inventory(
+ &[
+ direct_inventory_state(0xb023, None, [1, 0, 0, 0], true),
+ direct_inventory_state(0xb034, None, [2, 0, 0, 0], false),
+ ],
+ &[],
+ );
+ assert_eq!(orchestrator.current_key(), Some(saved_key));
+
+ orchestrator.refresh_inventory(
+ &[
+ direct_inventory_state(0xb023, None, [1, 0, 0, 0], false),
+ direct_inventory_state(0xb034, None, [2, 0, 0, 0], true),
+ ],
+ &[],
+ );
+ assert_eq!(orchestrator.current_key(), Some(other_key));
+}
+
+#[test]
+fn configured_wheel_mode_gates_resolution_and_inversion_independently() {
+ let mut config = Config::default();
+ config.set_scroll_resolution("a", Some(ScrollResolution::Low));
+ config.set_invert_scroll("a", true);
+ let mut device = dev("a", 1, true);
+
+ device.capabilities = Some(Capabilities {
+ hires_wheel: true,
+ scroll_inversion: false,
+ ..Capabilities::default()
+ });
+ assert_eq!(
+ configured_wheel_mode(&config, &device),
+ (Some(ScrollResolution::Low), None)
+ );
+
+ device.capabilities = Some(Capabilities {
+ hires_wheel: false,
+ scroll_inversion: true,
+ ..Capabilities::default()
+ });
+ assert_eq!(configured_wheel_mode(&config, &device), (None, Some(true)));
+
+ device.capabilities = None;
+ assert_eq!(configured_wheel_mode(&config, &device), (None, None));
+}
+
+#[test]
+fn configured_wheel_mode_leaves_unset_resolution_unmanaged() {
+ let config = Config::default();
+ let mut device = dev("a", 1, true);
+ device.capabilities = Some(Capabilities {
+ hires_wheel: true,
+ scroll_inversion: false,
+ ..Capabilities::default()
+ });
+
+ assert_eq!(configured_wheel_mode(&config, &device), (None, None));
+}
+
+#[test]
+fn host_switch_links_keep_sleeping_targets_but_require_online_keyboard() {
+ let mut config = Config::default();
+ config
+ .devices
+ .entry("keyboard".into())
+ .or_default()
+ .host_switch_targets = vec!["mouse".into(), "offline".into(), "missing".into()];
+ let devices = [
+ dev("keyboard", 1, true),
+ dev("mouse", 2, true),
+ dev("offline", 3, false),
+ ];
+
+ let links = host_switch_links(&config, &devices);
+
+ assert_eq!(links.len(), 1);
+ assert_eq!(
+ links[0].keyboard,
+ DeviceRoute::Bolt {
+ receiver_uid: "AA00".into(),
+ slot: 1,
+ }
+ );
+ assert_eq!(
+ links[0].targets,
+ vec![
+ DeviceRoute::Bolt {
+ receiver_uid: "AA00".into(),
+ slot: 2,
+ },
+ DeviceRoute::Bolt {
+ receiver_uid: "AA00".into(),
+ slot: 3,
+ }
+ ]
+ );
+}
+
+#[test]
+fn reapply_targets_new_arrivals_and_transitions() {
+ // First sighting of an online device → re-apply.
+ assert_eq!(reapply_targets(&[], &[dev("a", 1, true)], false), vec![0]);
+ // Steady state → nothing.
+ assert!(reapply_targets(&[dev("a", 1, true)], &[dev("a", 1, true)], false).is_empty());
+ // Replug under a new route (same key, new slot) → re-apply.
+ assert_eq!(
+ reapply_targets(&[dev("a", 1, true)], &[dev("a", 2, true)], false),
+ vec![0]
+ );
+ // Waking from device sleep (offline → online) → re-apply.
+ assert_eq!(
+ reapply_targets(&[dev("a", 1, false)], &[dev("a", 1, true)], false),
+ vec![0]
+ );
+ // Going to sleep (online → offline) → nothing.
+ assert!(reapply_targets(&[dev("a", 1, true)], &[dev("a", 1, false)], false).is_empty());
+}
+
+#[test]
+fn capture_target_tracks_online_state_without_resetting_dpi_cycle() {
+ let mut orch = Orchestrator::new(Config::default());
+ orch.devices = vec![dev("mouse", 1, true)];
+ orch.rebuild();
+ {
+ let Ok(mut dpi) = orch.shared.dpi_cycle.write() else {
+ panic!("DPI cycle lock should not be poisoned");
+ };
+ dpi.index = 3;
+ }
+
+ orch.devices[0].online = false;
+ orch.sync_current_route();
+ {
+ let Ok(dpi) = orch.shared.dpi_cycle.read() else {
+ panic!("DPI cycle lock should not be poisoned");
+ };
+ assert_eq!(dpi.target, None);
+ assert_eq!(dpi.index, 3);
+ }
+
+ orch.devices[0].online = true;
+ orch.sync_current_route();
+ let Ok(dpi) = orch.shared.dpi_cycle.read() else {
+ panic!("DPI cycle lock should not be poisoned");
+ };
+ assert_eq!(dpi.target, orch.devices[0].route);
+ assert_eq!(dpi.index, 3);
+}
+
+#[test]
+fn reapply_targets_disambiguates_same_model_duplicates() {
+ // Two devices can share a model key but are distinct physical units at
+ // different Bolt slots, so they have distinct stable ids. A steady tick
+ // with both already online must target NEITHER.
+ let prev = [dev("dup", 1, true), dev("dup", 2, true)];
+ let next = [dev("dup", 1, true), dev("dup", 2, true)];
+ assert!(reapply_targets(&prev, &next, false).is_empty());
+}
+
+#[test]
+fn reapply_targets_skip_offline_and_routeless_devices() {
+ // A paired-but-asleep new arrival waits for its online transition —
+ // writing now would only time out against a sleeping device.
+ assert!(reapply_targets(&[], &[dev("a", 1, false)], false).is_empty());
+ let routeless = AgentDevice {
+ route: None,
+ ..dev("b", 2, true)
+ };
+ assert!(reapply_targets(&[], &[routeless], false).is_empty());
+}
+
+#[test]
+fn reapply_all_targets_every_online_device() {
+ let prev = [dev("a", 1, true), dev("b", 2, false)];
+ let next = [dev("a", 1, true), dev("b", 2, false)];
+ // The post-wake snapshot looks identical to the pre-sleep one; the
+ // flag still re-applies to the online device (and only that one).
+ assert_eq!(reapply_targets(&prev, &next, true), vec![0]);
+}
+
+#[test]
+fn selected_receiver_reconnect_requests_capture_rearm() {
+ let prev = [dev("selected", 1, false), dev("other", 2, true)];
+ let next = [dev("selected", 1, true), dev("other", 2, true)];
+
+ assert!(selected_needs_capture_rearm(&prev, &next, 0, false));
+ assert!(!selected_needs_capture_rearm(&prev, &next, 1, false));
+}
+
+#[test]
+fn system_wake_requests_capture_rearm_for_selected_online_device() {
+ let devices = [dev("selected", 1, true), dev("other", 2, true)];
+
+ assert!(selected_needs_capture_rearm(&devices, &devices, 0, true));
+}
+
+#[test]
+fn steady_inventory_does_not_cycle_capture() {
+ let devices = [dev("selected", 1, true)];
+
+ assert!(!selected_needs_capture_rearm(&devices, &devices, 0, false));
+}
+
+#[test]
+fn plan_reapply_retries_a_first_sighting_for_a_bounded_run() {
+ use std::collections::HashMap;
+ // First sighting: applied now, queued for VOLATILE_REAPPLY_CONFIRM_RETRIES
+ // confirming re-applies. A cold restart can leave the device still
+ // booting, so the initial write and a single confirm need a retry run,
+ // not a one-shot confirm.
+ let (targets, followup) = plan_reapply(&[], &[dev("a", 1, true)], &HashMap::new(), false);
+ assert_eq!(targets, vec![0]);
+ assert_eq!(
+ followup,
+ HashMap::from([("a".to_string(), VOLATILE_REAPPLY_CONFIRM_RETRIES)])
+ );
+ // Each steady tick after a first sighting re-applies once and decrements
+ // the remaining retry budget — the device may still be booting.
+ let prev = [dev("a", 1, true)];
+ let followup_in = HashMap::from([("a".to_string(), VOLATILE_REAPPLY_CONFIRM_RETRIES)]);
+ let (targets, followup) = plan_reapply(&prev, &prev, &followup_in, false);
+ assert_eq!(targets, vec![0]);
+ assert_eq!(
+ followup,
+ HashMap::from([("a".to_string(), VOLATILE_REAPPLY_CONFIRM_RETRIES - 1)])
+ );
+ // The budget exhausts: a last retry fires but queues no further ones.
+ let followup_in = HashMap::from([("a".to_string(), 1)]);
+ let (targets, followup) = plan_reapply(&prev, &prev, &followup_in, false);
+ assert_eq!(targets, vec![0]);
+ assert!(followup.is_empty());
+ // Steady state after that: nothing.
+ let (targets, _) = plan_reapply(&prev, &prev, &HashMap::new(), false);
+ assert!(targets.is_empty());
+}
+
+#[test]
+fn plan_reapply_transitions_are_not_queued_for_confirmation() {
+ use std::collections::HashMap;
+ // A wake from device sleep re-applies once — the device was already
+ // booted, so no confirming write is queued.
+ let (targets, followup) = plan_reapply(
+ &[dev("a", 1, false)],
+ &[dev("a", 1, true)],
+ &HashMap::new(),
+ false,
+ );
+ assert_eq!(targets, vec![0]);
+ assert!(followup.is_empty());
+}
+
+#[test]
+fn plan_reapply_skips_a_followup_that_went_offline() {
+ use std::collections::HashMap;
+ let prev = [dev("a", 1, true)];
+ let (targets, followup) = plan_reapply(
+ &prev,
+ &[dev("a", 1, false)],
+ &HashMap::from([("a".to_string(), VOLATILE_REAPPLY_CONFIRM_RETRIES)]),
+ false,
+ );
+ assert!(targets.is_empty());
+ assert!(followup.is_empty());
+}
+
+/// An *empty* snapshot still flips the health to `Ready`: the watcher only
+/// forwards completed enumerations, so "checked and found nothing" must not
+/// be reported as "still scanning" — that's the whole distinction the
+/// health exists to carry.
+#[test]
+fn empty_refresh_marks_inventory_ready() {
+ let mut orch = Orchestrator::new(Config::default());
+ assert_eq!(orch.inventory_health(), InventoryHealth::Scanning);
+ orch.refresh_inventory(&[], &[]);
+ assert_eq!(orch.inventory_health(), InventoryHealth::Ready);
+}
+
+/// `Unavailable` is a startup-only downgrade: it reports "enumeration has
+/// never worked", recovers when a snapshot finally lands, and never
+/// clobbers a live device set on a mid-session failure (mirroring the
+/// watcher's keep-last-snapshot policy).
+#[test]
+fn unavailable_only_downgrades_a_pending_inventory() {
+ let mut orch = Orchestrator::new(Config::default());
+ orch.mark_inventory_unavailable();
+ assert_eq!(orch.inventory_health(), InventoryHealth::Unavailable);
+ orch.refresh_inventory(&[], &[]);
+ assert_eq!(orch.inventory_health(), InventoryHealth::Ready);
+ orch.mark_inventory_unavailable();
+ assert_eq!(orch.inventory_health(), InventoryHealth::Ready);
+}
+
+#[test]
+fn camera_automation_overrides_only_effective_power() {
+ let key = "raw:046d:c900:ff43:0202:serial:glow";
+ let mut config = Config::default();
+ config.set_light(
+ key,
+ LightSettings {
+ enabled: true,
+ auto_camera: true,
+ brightness_percent: 65,
+ temperature_kelvin: Some(4600),
+ color: None,
+ },
+ );
+ let mut orch = Orchestrator::new(config);
+
+ orch.set_camera_active(false);
+ assert_eq!(
+ orch.effective_light_settings(key).map(|light| (
+ light.enabled,
+ light.brightness_percent,
+ light.temperature_kelvin
+ )),
+ Some((false, 65, Some(4600)))
+ );
+
+ orch.set_camera_active(true);
+ assert_eq!(
+ orch.effective_light_settings(key).map(|light| (
+ light.enabled,
+ light.brightness_percent,
+ light.temperature_kelvin
+ )),
+ Some((true, 65, Some(4600)))
+ );
+}
+
+#[test]
+fn manual_camera_light_override_is_transient() {
+ let key = "raw:046d:c900:ff43:0202:serial:glow";
+ let route = DeviceRoute::Bolt {
+ receiver_uid: "AA00".to_string(),
+ slot: 1,
+ };
+ let mut config = Config::default();
+ config.set_light(
+ key,
+ LightSettings {
+ enabled: true,
+ auto_camera: true,
+ brightness_percent: 65,
+ temperature_kelvin: Some(4600),
+ color: None,
+ },
+ );
+ let mut orch = Orchestrator::new(config);
+ orch.set_camera_active(true);
+ let mut device = dev(key, 1, true);
+ device.light_capabilities = Some(LightCapabilities {
+ power: true,
+ ..LightCapabilities::default()
+ });
+ orch.devices.push(AgentDevice {
+ config_key: key.to_string(),
+ route: Some(route.clone()),
+ ..device
+ });
+
+ assert!(orch.set_manual_light_power(&route, false));
+ assert_eq!(
+ orch.effective_light_settings(key)
+ .map(|light| light.enabled),
+ Some(false)
+ );
+
+ orch.devices.clear();
+ orch.set_camera_active(false);
+ orch.set_camera_active(true);
+ assert_eq!(
+ orch.effective_light_settings(key)
+ .map(|light| light.enabled),
+ Some(true)
+ );
+}
+
+#[test]
+fn config_reload_keeps_manual_override_for_parameter_edits() {
+ let key = "raw:046d:c900:ff43:0202:serial:glow";
+ let mut config = Config::default();
+ config.set_light(
+ key,
+ LightSettings {
+ enabled: false,
+ auto_camera: true,
+ brightness_percent: 65,
+ temperature_kelvin: Some(4600),
+ color: None,
+ },
+ );
+ let mut orch = Orchestrator::new(config.clone());
+ orch.set_camera_active(false);
+ orch.manual_light_overrides.insert(key.to_string(), true);
+
+ let mut updated = config;
+ updated.set_light(
+ key,
+ LightSettings {
+ enabled: false,
+ auto_camera: true,
+ brightness_percent: 80,
+ temperature_kelvin: Some(6500),
+ color: None,
+ },
+ );
+ orch.reload_config(updated);
+
+ assert_eq!(
+ orch.effective_light_settings(key).map(|light| (
+ light.enabled,
+ light.brightness_percent,
+ light.temperature_kelvin
+ )),
+ Some((true, 80, Some(6500)))
+ );
+ assert_eq!(orch.manual_light_overrides.get(key), Some(&true));
+}
+
+#[test]
+fn config_reload_clears_override_when_camera_mode_changes() {
+ let key = "raw:046d:c900:ff43:0202:serial:glow";
+ let mut config = Config::default();
+ config.set_light(
+ key,
+ LightSettings {
+ enabled: true,
+ auto_camera: true,
+ brightness_percent: 65,
+ temperature_kelvin: Some(4600),
+ color: None,
+ },
+ );
+ let mut orch = Orchestrator::new(config.clone());
+ orch.manual_light_overrides.insert(key.to_string(), false);
+
+ let mut updated = config;
+ updated.set_light(
+ key,
+ LightSettings {
+ enabled: true,
+ auto_camera: false,
+ brightness_percent: 65,
+ temperature_kelvin: Some(4600),
+ color: None,
+ },
+ );
+ orch.reload_config(updated);
+
+ assert_eq!(orch.manual_light_overrides.get(key), None);
+ assert_eq!(
+ orch.effective_light_settings(key)
+ .map(|light| light.enabled),
+ Some(true)
+ );
+}
diff --git a/crates/openlogi-agent-core/src/receiver_access.rs b/crates/openlogi-agent-core/src/receiver_access.rs
index 66cb6ac3fd7c3ffc2391759ba9b5e2e383a21479..0a1f368c91e48c1351f239b4140ff99c4e5835b8 100644
--- a/crates/openlogi-agent-core/src/receiver_access.rs
+++ b/crates/openlogi-agent-core/src/receiver_access.rs
@@ -1,15 +1,13 @@
-//! Exclusive receiver access coordination between HID++ capture and pairing.
+//! Shared and exclusive access coordination for receiver HID++ sessions.
//!
-//! The active-device capture session and a pairing session cannot both open the
-//! same receiver HID node. This small arbiter makes that ownership explicit: the
-//! capture watcher may run only while it holds a capture lease, and pairing first
-//! announces its intent (so capture stops) before awaiting an exclusive pairing
-//! lease.
+//! Long-running HID++ sessions share pooled receiver channels under read leases.
+//! Pairing and coordinated host transitions announce their intent so those
+//! sessions stop, then wait for an exclusive write lease.
use std::sync::Arc;
-use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::atomic::{AtomicU8, Ordering};
-use tokio::sync::{Mutex, OwnedMutexGuard};
+use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
/// Coordinates exclusive access to the receiver HID node.
#[derive(Clone, Default)]
@@ -19,89 +17,108 @@ pub struct ReceiverAccess {
#[derive(Default)]
struct ReceiverAccessInner {
- lease: Arc<Mutex<()>>,
- pairing_requested: Arc<AtomicBool>,
+ lease: Arc<RwLock<()>>,
+ exclusive_requests: Arc<AtomicU8>,
}
-/// Exclusive receiver lease held by the capture watcher.
-pub struct CaptureReceiverLease {
- _guard: OwnedMutexGuard<()>,
+/// Operation requiring sole ownership of a receiver transport.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ExclusiveAccessReason {
+ /// Receiver discovery and pairing.
+ Pairing,
+ /// Coordinated movement of linked devices to another host.
+ HostTransition,
}
-/// Exclusive receiver lease held by a pairing session.
-pub struct PairingReceiverLease {
- _guard: OwnedMutexGuard<()>,
- pairing_requested: Arc<AtomicBool>,
+impl ExclusiveAccessReason {
+ const fn bit(self) -> u8 {
+ match self {
+ Self::Pairing => 1 << 0,
+ Self::HostTransition => 1 << 1,
+ }
+ }
}
-impl Drop for PairingReceiverLease {
- fn drop(&mut self) {
- self.pairing_requested.store(false, Ordering::Release);
- }
+/// Shared receiver lease held by a long-running HID++ session.
+pub struct SessionReceiverLease {
+ _guard: OwnedRwLockReadGuard<()>,
+}
+
+/// Exclusive receiver lease held by a pairing or host-transition operation.
+pub struct ExclusiveReceiverLease {
+ _guard: OwnedRwLockWriteGuard<()>,
+ _request: ExclusiveRequest,
}
impl ReceiverAccess {
- /// Whether a pairing session is waiting for or holding receiver access.
+ /// Whether any exclusive operation is waiting for or holding receiver access.
#[must_use]
- pub fn pairing_requested(&self) -> bool {
- self.inner.pairing_requested.load(Ordering::Acquire)
+ pub fn exclusive_requested(&self) -> bool {
+ self.inner.exclusive_requests.load(Ordering::Acquire) != 0
}
- /// Try to acquire receiver access for the capture watcher.
+ /// Whether `reason` is waiting for or holding receiver access.
+ #[must_use]
+ pub fn requested(&self, reason: ExclusiveAccessReason) -> bool {
+ self.inner.exclusive_requests.load(Ordering::Acquire) & reason.bit() != 0
+ }
+
+ /// Try to acquire receiver access for a pooled HID++ session.
///
/// Capture is opportunistic: if pairing is waiting or active, capture should
/// stay idle and retry on its next management tick.
#[must_use]
- pub fn try_acquire_for_capture(&self) -> Option<CaptureReceiverLease> {
- if self.pairing_requested() {
+ pub fn try_acquire_for_session(&self) -> Option<SessionReceiverLease> {
+ if self.exclusive_requested() {
return None;
}
- let guard = Arc::clone(&self.inner.lease).try_lock_owned().ok()?;
- if self.pairing_requested() {
+ let guard = Arc::clone(&self.inner.lease).try_read_owned().ok()?;
+ if self.exclusive_requested() {
return None;
}
- Some(CaptureReceiverLease { _guard: guard })
+ Some(SessionReceiverLease { _guard: guard })
}
- /// Request and acquire exclusive receiver access for pairing.
+ /// Wait for shared access for a bounded device-I/O operation.
+ ///
+ /// Unlike long-running sessions, ordinary reads and writes must not be
+ /// dropped merely because an exclusive operation is queued. Tokio's fair
+ /// lock ordering makes them wait behind that operation instead.
+ pub async fn acquire_for_io(&self) -> SessionReceiverLease {
+ let guard = Arc::clone(&self.inner.lease).read_owned().await;
+ SessionReceiverLease { _guard: guard }
+ }
+
+ /// Request and acquire exclusive receiver access for `reason`.
///
/// If the returned future is cancelled while waiting, the pairing request is
/// withdrawn automatically so capture can resume.
- pub async fn acquire_for_pairing(&self) -> PairingReceiverLease {
- let request = PairingRequest::new(Arc::clone(&self.inner.pairing_requested));
- let guard = Arc::clone(&self.inner.lease).lock_owned().await;
- request.disarm();
- PairingReceiverLease {
+ pub async fn acquire_exclusive(&self, reason: ExclusiveAccessReason) -> ExclusiveReceiverLease {
+ let request = ExclusiveRequest::new(Arc::clone(&self.inner.exclusive_requests), reason);
+ let guard = Arc::clone(&self.inner.lease).write_owned().await;
+ ExclusiveReceiverLease {
_guard: guard,
- pairing_requested: Arc::clone(&self.inner.pairing_requested),
+ _request: request,
}
}
}
-struct PairingRequest {
- pairing_requested: Arc<AtomicBool>,
- armed: bool,
+struct ExclusiveRequest {
+ requests: Arc<AtomicU8>,
+ reason: ExclusiveAccessReason,
}
-impl PairingRequest {
- fn new(pairing_requested: Arc<AtomicBool>) -> Self {
- pairing_requested.store(true, Ordering::Release);
- Self {
- pairing_requested,
- armed: true,
- }
- }
-
- fn disarm(mut self) {
- self.armed = false;
+impl ExclusiveRequest {
+ fn new(requests: Arc<AtomicU8>, reason: ExclusiveAccessReason) -> Self {
+ requests.fetch_or(reason.bit(), Ordering::AcqRel);
+ Self { requests, reason }
}
}
-impl Drop for PairingRequest {
+impl Drop for ExclusiveRequest {
fn drop(&mut self) {
- if self.armed {
- self.pairing_requested.store(false, Ordering::Release);
- }
+ self.requests
+ .fetch_and(!self.reason.bit(), Ordering::AcqRel);
}
}
@@ -113,35 +130,87 @@ mod tests {
async fn pairing_request_blocks_new_capture_until_pairing_lease_drops() {
let access = ReceiverAccess::default();
- let pairing = access.acquire_for_pairing().await;
+ let pairing = access
+ .acquire_exclusive(ExclusiveAccessReason::Pairing)
+ .await;
- assert!(access.pairing_requested());
- assert!(access.try_acquire_for_capture().is_none());
+ assert!(access.requested(ExclusiveAccessReason::Pairing));
+ assert!(access.exclusive_requested());
+ assert!(access.try_acquire_for_session().is_none());
drop(pairing);
- assert!(!access.pairing_requested());
- assert!(access.try_acquire_for_capture().is_some());
+ assert!(!access.exclusive_requested());
+ assert!(access.try_acquire_for_session().is_some());
+ }
+
+ #[tokio::test]
+ async fn pooled_sessions_share_access_before_pairing() {
+ let access = ReceiverAccess::default();
+
+ let first = access.try_acquire_for_session().unwrap_or_else(|| {
+ panic!("fresh receiver access should grant first session lease");
+ });
+ let second = access.try_acquire_for_session().unwrap_or_else(|| {
+ panic!("pooled sessions should share receiver access");
+ });
+
+ drop((first, second));
}
#[tokio::test]
async fn cancelled_pairing_wait_withdraws_request() {
let access = ReceiverAccess::default();
- let capture = access.try_acquire_for_capture().unwrap_or_else(|| {
+ let capture = access.try_acquire_for_session().unwrap_or_else(|| {
panic!("fresh receiver access should grant capture lease");
});
let waiting = tokio::spawn({
let access = access.clone();
- async move { access.acquire_for_pairing().await }
+ async move {
+ access
+ .acquire_exclusive(ExclusiveAccessReason::Pairing)
+ .await
+ }
});
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
- assert!(access.pairing_requested());
+ assert!(access.requested(ExclusiveAccessReason::Pairing));
waiting.abort();
let _ = waiting.await;
- assert!(!access.pairing_requested());
+ assert!(!access.exclusive_requested());
drop(capture);
- assert!(access.try_acquire_for_capture().is_some());
+ assert!(access.try_acquire_for_session().is_some());
+ }
+
+ #[tokio::test]
+ async fn host_transition_blocks_shared_sessions() {
+ let access = ReceiverAccess::default();
+
+ let transition = access
+ .acquire_exclusive(ExclusiveAccessReason::HostTransition)
+ .await;
+
+ assert!(access.requested(ExclusiveAccessReason::HostTransition));
+ assert!(access.try_acquire_for_session().is_none());
+ drop(transition);
+ assert!(access.try_acquire_for_session().is_some());
+ }
+
+ #[tokio::test]
+ async fn bounded_io_waits_for_host_transition() {
+ let access = ReceiverAccess::default();
+ let transition = access
+ .acquire_exclusive(ExclusiveAccessReason::HostTransition)
+ .await;
+ let waiting = tokio::spawn({
+ let access = access.clone();
+ async move { access.acquire_for_io().await }
+ });
+
+ tokio::task::yield_now().await;
+ assert!(!waiting.is_finished());
+ drop(transition);
+ assert!(waiting.await.is_ok());
}
}
diff --git a/crates/openlogi-agent-core/src/transport.rs b/crates/openlogi-agent-core/src/transport.rs
index 6ec093ba2a7b4e9c4cd02b0c787b7f01cb904a49..51c036020930b1aad2ccafe9fbb210f83d7696c4 100644
--- a/crates/openlogi-agent-core/src/transport.rs
+++ b/crates/openlogi-agent-core/src/transport.rs
@@ -21,9 +21,10 @@ use tarpc::tokio_serde::formats::Bincode;
/// Resolve the IPC endpoint name.
///
/// On Unix this is the filesystem Unix-domain socket at
-/// [`agent_socket_path`](openlogi_core::paths::agent_socket_path) (preserving
-/// the existing `~/.config/openlogi/agent.sock` location, so macOS/Linux see no
-/// behavior change). On Windows it is a named pipe in the OS namespace
+/// [`agent_socket_path`](openlogi_core::paths::agent_socket_path). Production
+/// builds keep `~/.config/openlogi/agent.sock`; local macOS `.dev` bundles use
+/// the sibling `openlogi-dev` profile so development agents cannot occupy the
+/// installed app's endpoint. On Windows it is a named pipe in the OS namespace
/// (`\\.\pipe\openlogi-agent.sock`).
///
/// # Errors
diff --git a/crates/openlogi-agent-core/src/watchers/camera.rs b/crates/openlogi-agent-core/src/watchers/camera.rs
new file mode 100644
index 0000000000000000000000000000000000000000..4c95af804f91ae5339464ce12e0933fdcbc749f4
--- /dev/null
+++ b/crates/openlogi-agent-core/src/watchers/camera.rs
@@ -0,0 +1,300 @@
+//! Camera-use watcher used by standalone-light automation.
+//!
+//! CoreMediaIO exposes whether each camera device is running in any client.
+//! Polling that read-only property covers physical webcams, virtual cameras,
+//! capture cards, and SLR devices without coupling the policy to a particular
+//! meeting or recording application.
+
+use std::time::Duration;
+
+#[cfg(target_os = "macos")]
+use std::thread;
+use tokio::sync::mpsc;
+#[cfg(target_os = "macos")]
+use tracing::{debug, info, warn};
+
+/// CoreMediaIO can briefly report no running stream while a camera client
+/// renegotiates or switches capture mode. Requiring two consecutive inactive
+/// samples prevents that gap from turning linked lights off and back on.
+#[cfg(any(target_os = "macos", test))]
+const INACTIVE_CONFIRMATIONS: u8 = 2;
+
+#[cfg(any(target_os = "macos", test))]
+#[derive(Default)]
+struct CameraDebouncer {
+ emitted: Option<bool>,
+ inactive_samples: u8,
+}
+
+#[cfg(any(target_os = "macos", test))]
+impl CameraDebouncer {
+ fn observe(&mut self, active: bool) -> Option<bool> {
+ if active {
+ self.inactive_samples = 0;
+ return (self.emitted != Some(true)).then(|| {
+ self.emitted = Some(true);
+ true
+ });
+ }
+
+ if self.emitted != Some(true) {
+ return (self.emitted != Some(false)).then(|| {
+ self.emitted = Some(false);
+ false
+ });
+ }
+
+ self.inactive_samples = self.inactive_samples.saturating_add(1);
+ if self.inactive_samples < INACTIVE_CONFIRMATIONS {
+ return None;
+ }
+ self.inactive_samples = 0;
+ self.emitted = Some(false);
+ Some(false)
+ }
+
+ fn retain_last_state_after_probe_error(&mut self) {
+ self.inactive_samples = 0;
+ }
+}
+
+/// Start the macOS camera-use watcher. The first successful sample is emitted
+/// immediately; later samples are emitted only after a debounced state change.
+/// Dropping the receiver stops the worker on its next attempted send.
+#[cfg(target_os = "macos")]
+#[must_use]
+pub fn spawn(period: Duration) -> mpsc::UnboundedReceiver<bool> {
+ let (tx, rx) = mpsc::unbounded_channel();
+ let spawn_result = thread::Builder::new()
+ .name("openlogi-camera-watcher".into())
+ .spawn(move || {
+ let mut debouncer = CameraDebouncer::default();
+ loop {
+ match camera_in_use() {
+ Ok(active) => {
+ if let Some(active) = debouncer.observe(active) {
+ info!(active, "camera usage state changed");
+ if tx.send(active).is_err() {
+ debug!("camera watcher receiver dropped — exiting");
+ return;
+ }
+ }
+ }
+ Err(error) => {
+ debouncer.retain_last_state_after_probe_error();
+ warn!(error, "camera state probe failed — retaining last state");
+ }
+ }
+ thread::sleep(period);
+ }
+ });
+ if let Err(error) = spawn_result {
+ warn!(error = %error, "could not spawn camera watcher");
+ }
+ rx
+}
+
+/// Return an inert watcher on platforms that do not yet expose a supported
+/// aggregate camera-use provider. Camera-linked settings retain manual power.
+#[cfg(not(target_os = "macos"))]
+#[must_use]
+pub fn spawn(_period: Duration) -> mpsc::UnboundedReceiver<bool> {
+ let (_tx, rx) = mpsc::unbounded_channel();
+ rx
+}
+
+#[cfg(target_os = "macos")]
+fn camera_in_use() -> Result<bool, i32> {
+ macos::camera_in_use()
+}
+
+#[cfg(target_os = "macos")]
+#[expect(
+ unsafe_code,
+ reason = "CoreMediaIO exposes the camera-running property through a C API"
+)]
+mod macos {
+ use std::ffi::c_void;
+ use std::mem::size_of;
+ use std::ptr;
+
+ type ObjectId = u32;
+ type Selector = u32;
+ type Scope = u32;
+ type Element = u32;
+
+ #[repr(C)]
+ struct PropertyAddress {
+ selector: Selector,
+ scope: Scope,
+ element: Element,
+ }
+
+ // CoreMediaIO constants are four-character codes from CMIOTypes.h.
+ const SYSTEM_OBJECT: ObjectId = 1;
+ const SCOPE_GLOBAL: Scope = u32::from_be_bytes(*b"glob");
+ const ELEMENT_MASTER: Element = 0;
+ const HARDWARE_DEVICES: Selector = u32::from_be_bytes(*b"dev#");
+ const DEVICE_RUNNING_SOMEWHERE: Selector = u32::from_be_bytes(*b"gone");
+
+ #[link(name = "CoreMediaIO", kind = "framework")]
+ unsafe extern "C" {
+ fn CMIOObjectGetPropertyDataSize(
+ object_id: ObjectId,
+ address: *const PropertyAddress,
+ qualifier_data_size: u32,
+ qualifier_data: *const c_void,
+ data_size: *mut u32,
+ ) -> i32;
+
+ fn CMIOObjectGetPropertyData(
+ object_id: ObjectId,
+ address: *const PropertyAddress,
+ qualifier_data_size: u32,
+ qualifier_data: *const c_void,
+ data_size: u32,
+ data_used: *mut u32,
+ data: *mut c_void,
+ ) -> i32;
+ }
+
+ pub(super) fn camera_in_use() -> Result<bool, i32> {
+ let devices_address = PropertyAddress {
+ selector: HARDWARE_DEVICES,
+ scope: SCOPE_GLOBAL,
+ element: ELEMENT_MASTER,
+ };
+ let mut data_size = 0;
+ // SAFETY: CoreMediaIO receives a valid system-object property address
+ // and a writable UInt32 for the byte count; no qualifier is used.
+ let status = unsafe {
+ CMIOObjectGetPropertyDataSize(
+ SYSTEM_OBJECT,
+ &raw const devices_address,
+ 0,
+ ptr::null(),
+ &raw mut data_size,
+ )
+ };
+ if status != 0 {
+ return Err(status);
+ }
+
+ let object_size = size_of::<ObjectId>();
+ let Some(device_count) = usize::try_from(data_size)
+ .ok()
+ .filter(|bytes| bytes % object_size == 0)
+ .map(|bytes| bytes / object_size)
+ else {
+ return Err(-1);
+ };
+ if device_count == 0 {
+ return Ok(false);
+ }
+
+ let mut devices = vec![0; device_count];
+ let mut data_used = 0;
+ // SAFETY: `devices` has the byte capacity reported by the preceding
+ // size query and remains alive for the duration of the call.
+ let status = unsafe {
+ CMIOObjectGetPropertyData(
+ SYSTEM_OBJECT,
+ &raw const devices_address,
+ 0,
+ ptr::null(),
+ data_size,
+ &raw mut data_used,
+ devices.as_mut_ptr().cast(),
+ )
+ };
+ if status != 0 {
+ return Err(status);
+ }
+ let Some(used_count) = usize::try_from(data_used)
+ .ok()
+ .filter(|bytes| bytes % object_size == 0)
+ .map(|bytes| bytes / object_size)
+ .filter(|count| *count <= devices.len())
+ else {
+ return Err(-1);
+ };
+ devices.truncate(used_count);
+
+ let running_address = PropertyAddress {
+ selector: DEVICE_RUNNING_SOMEWHERE,
+ scope: SCOPE_GLOBAL,
+ element: ELEMENT_MASTER,
+ };
+ let mut last_error = None;
+ let mut read_any = false;
+ for device in devices {
+ let mut running = 0_u32;
+ let property_size = u32::try_from(size_of::<u32>()).unwrap_or(u32::MAX);
+ let mut property_used = 0;
+ // SAFETY: `running` and the size counter are valid writable
+ // buffers; each device ID came from CoreMediaIO itself.
+ let status = unsafe {
+ CMIOObjectGetPropertyData(
+ device,
+ &raw const running_address,
+ 0,
+ ptr::null(),
+ property_size,
+ &raw mut property_used,
+ (&raw mut running).cast(),
+ )
+ };
+ if status != 0 {
+ last_error = Some(status);
+ continue;
+ }
+ if property_used != property_size {
+ last_error = Some(-1);
+ continue;
+ }
+ read_any = true;
+ if running != 0 {
+ return Ok(true);
+ }
+ }
+ if read_any {
+ Ok(false)
+ } else {
+ Err(last_error.unwrap_or(-1))
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::CameraDebouncer;
+
+ #[test]
+ fn inactive_transition_requires_two_consecutive_samples() {
+ let mut debouncer = CameraDebouncer::default();
+ assert_eq!(debouncer.observe(false), Some(false));
+ assert_eq!(debouncer.observe(true), Some(true));
+ assert_eq!(debouncer.observe(false), None);
+ assert_eq!(debouncer.observe(false), Some(false));
+ }
+
+ #[test]
+ fn active_sample_cancels_pending_inactive_transition() {
+ let mut debouncer = CameraDebouncer::default();
+ assert_eq!(debouncer.observe(true), Some(true));
+ assert_eq!(debouncer.observe(false), None);
+ assert_eq!(debouncer.observe(true), None);
+ assert_eq!(debouncer.observe(false), None);
+ assert_eq!(debouncer.observe(false), Some(false));
+ }
+
+ #[test]
+ fn probe_error_cancels_pending_inactive_transition() {
+ let mut debouncer = CameraDebouncer::default();
+ assert_eq!(debouncer.observe(true), Some(true));
+ assert_eq!(debouncer.observe(false), None);
+ debouncer.retain_last_state_after_probe_error();
+ assert_eq!(debouncer.observe(false), None);
+ assert_eq!(debouncer.observe(false), Some(false));
+ }
+}
diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs
index 806596827ffc1d6a33e050bd8e0f52ef29a6198c..4ba014c4b2e3978764db9e78e23b555d532a0595 100644
--- a/crates/openlogi-agent-core/src/watchers/gesture.rs
+++ b/crates/openlogi-agent-core/src/watchers/gesture.rs
@@ -19,20 +19,23 @@
//! way regardless.
use std::collections::BTreeMap;
-use std::sync::atomic::{AtomicI32, Ordering};
+use std::sync::atomic::{AtomicI32, AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::{Duration, Instant};
use openlogi_core::binding::{Action, ButtonId, GestureDirection, default_binding};
use openlogi_core::config::DEFAULT_THUMBWHEEL_SENSITIVITY;
-use openlogi_hid::{CaptureChannel, CapturedInput, DeviceRoute, run_capture_session};
+use openlogi_hid::{
+ CaptureChannel, CaptureStop, CapturedInput, ChannelRegistry, DeviceRoute,
+ run_capture_session_with_registry, run_capture_session_with_stop_reason,
+};
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, warn};
use crate::DpiCycleState;
use crate::hook_runtime::{self, SharedHookMaps};
-use crate::receiver_access::ReceiverAccess;
+use crate::receiver_access::{ReceiverAccess, SessionReceiverLease};
/// Shared gesture-direction binding map, mirrored from `AppState` (keyed by
/// direction). The watcher reads it to map a captured swipe to a bound action.
@@ -80,7 +83,61 @@ pub fn spawn(
dpi_cycle: Arc<RwLock<DpiCycleState>>,
capture_channel: CaptureChannel,
thumbwheel_sensitivity: ThumbwheelSensitivity,
+ capture_rearm_generation: Arc<AtomicU64>,
receiver_access: ReceiverAccess,
+) {
+ spawn_inner(
+ hook_maps,
+ gesture_bindings,
+ dpi_cycle,
+ capture_channel,
+ thumbwheel_sensitivity,
+ capture_rearm_generation,
+ receiver_access,
+ None,
+ );
+}
+
+/// Spawn capture in Agent mode, reusing only inventory-published channels.
+#[expect(
+ clippy::too_many_arguments,
+ reason = "agent mode needs maps, dpi, rearm, leases, and registry"
+)]
+pub fn spawn_with_registry(
+ hook_maps: SharedHookMaps,
+ gesture_bindings: GestureBindings,
+ dpi_cycle: Arc<RwLock<DpiCycleState>>,
+ capture_channel: CaptureChannel,
+ thumbwheel_sensitivity: ThumbwheelSensitivity,
+ capture_rearm_generation: Arc<AtomicU64>,
+ receiver_access: ReceiverAccess,
+ registry: ChannelRegistry,
+) {
+ spawn_inner(
+ hook_maps,
+ gesture_bindings,
+ dpi_cycle,
+ capture_channel,
+ thumbwheel_sensitivity,
+ capture_rearm_generation,
+ receiver_access,
+ Some(registry),
+ );
+}
+
+#[expect(
+ clippy::too_many_arguments,
+ reason = "capture manager needs maps, dpi, rearm, leases, and registry"
+)]
+fn spawn_inner(
+ hook_maps: SharedHookMaps,
+ gesture_bindings: GestureBindings,
+ dpi_cycle: Arc<RwLock<DpiCycleState>>,
+ capture_channel: CaptureChannel,
+ thumbwheel_sensitivity: ThumbwheelSensitivity,
+ capture_rearm_generation: Arc<AtomicU64>,
+ receiver_access: ReceiverAccess,
+ registry: Option<ChannelRegistry>,
) {
thread::spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
@@ -99,7 +156,9 @@ pub fn spawn(
dpi_cycle,
capture_channel,
thumbwheel_sensitivity,
+ capture_rearm_generation,
receiver_access,
+ registry,
));
});
}
@@ -137,25 +196,150 @@ fn thumbwheel_armed(hook_maps: &SharedHookMaps, sensitivity: i32) -> bool {
/// respawn when it is the *current* one (not a stale session already superseded
/// by a deliberate restart, whose epoch no longer matches) and a target is still
/// set (not a deliberate stop-to-idle, e.g. while pairing owns the receiver).
-fn should_rearm(done_epoch: u64, live_epoch: u64, has_target: bool) -> bool {
+pub(crate) fn should_rearm(done_epoch: u64, live_epoch: u64, has_target: bool) -> bool {
done_epoch == live_epoch && has_target
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct CaptureTarget {
+ route: DeviceRoute,
+ capture_thumbwheel: bool,
+ divert_gesture_button: bool,
+ rearm_generation: u64,
+}
+
+/// Why to stop the current session when the desired target changes.
+fn stop_for_target_change(
+ want: Option<&CaptureTarget>,
+ current: Option<&CaptureTarget>,
+ connection_is_current: bool,
+) -> Option<CaptureStop> {
+ let cur = current?;
+ if want == Some(cur) {
+ return (!connection_is_current).then_some(CaptureStop::Revoked);
+ }
+ // Same route, new generation (reconnect/wake): skip restore — firmware already reset.
+ if want.is_some_and(|next| {
+ next.route == cur.route && next.rearm_generation != cur.rearm_generation
+ }) {
+ return Some(CaptureStop::Revoked);
+ }
+ Some(CaptureStop::Graceful)
+}
+
+#[cfg_attr(
+ not(test),
+ allow(dead_code, reason = "kept for unit tests of stop policy")
+)]
+fn stop_reason<T: PartialEq>(
+ want: Option<&T>,
+ current: Option<&T>,
+ connection_is_current: bool,
+) -> Option<CaptureStop> {
+ current?;
+ if want == current {
+ (!connection_is_current).then_some(CaptureStop::Revoked)
+ } else {
+ Some(CaptureStop::Graceful)
+ }
+}
+
+fn acknowledge_stopping(stopping_epoch: &mut Option<u64>, done_epoch: u64) -> bool {
+ if *stopping_epoch == Some(done_epoch) {
+ *stopping_epoch = None;
+ true
+ } else {
+ false
+ }
+}
+
+fn capture_connection_is_current(
+ registry: Option<&ChannelRegistry>,
+ capture_channel: &CaptureChannel,
+) -> bool {
+ let Some(registry) = registry else {
+ return true;
+ };
+ capture_channel
+ .read()
+ .ok()
+ .and_then(|slot| slot.clone())
+ .is_some_and(|shared| registry.is_current(&shared))
+}
+
+struct CaptureLaunch {
+ route: DeviceRoute,
+ capture_thumbwheel: bool,
+ divert_gesture_button: bool,
+ sink: mpsc::UnboundedSender<CapturedInput>,
+ channel_slot: CaptureChannel,
+ receiver_lease: SessionReceiverLease,
+ registry: Option<ChannelRegistry>,
+ done: mpsc::UnboundedSender<u64>,
+ epoch: u64,
+}
+
+fn spawn_capture_session(launch: CaptureLaunch) -> oneshot::Sender<CaptureStop> {
+ let (stop_tx, stop_rx) = oneshot::channel();
+ tokio::spawn(async move {
+ let result = {
+ let receiver_lease = launch.receiver_lease;
+ let result = if let Some(registry) = launch.registry.as_ref() {
+ run_capture_session_with_registry(
+ launch.route,
+ launch.capture_thumbwheel,
+ launch.divert_gesture_button,
+ launch.sink,
+ stop_rx,
+ launch.channel_slot,
+ registry,
+ )
+ .await
+ } else {
+ run_capture_session_with_stop_reason(
+ launch.route,
+ launch.capture_thumbwheel,
+ launch.divert_gesture_button,
+ launch.sink,
+ stop_rx,
+ launch.channel_slot,
+ )
+ .await
+ };
+ drop(receiver_lease);
+ result
+ };
+ if let Err(error) = result {
+ debug!(%error, "capture session ended");
+ }
+ // Completion is sent only after the capture future released its channel
+ // and the receiver lease above, so the manager can safely replace it.
+ let _ = launch.done.send(launch.epoch);
+ });
+ stop_tx
+}
+
/// Keep one capture session alive for the active device, restarting it when the
/// device or the thumb-wheel arming changes, and dispatch incoming inputs. Runs
/// for the lifetime of the process.
+#[expect(
+ clippy::too_many_arguments,
+ reason = "capture manager needs maps, dpi, rearm, leases, and registry"
+)]
async fn manage(
hook_maps: SharedHookMaps,
gesture_bindings: GestureBindings,
dpi_cycle: Arc<RwLock<DpiCycleState>>,
capture_channel: CaptureChannel,
thumbwheel_sensitivity: ThumbwheelSensitivity,
+ capture_rearm_generation: Arc<AtomicU64>,
receiver_access: ReceiverAccess,
+ registry: Option<ChannelRegistry>,
) {
let (tx, mut rx) = mpsc::unbounded_channel::<CapturedInput>();
- // (route, capture_thumbwheel, divert_gesture_button)
- let mut current: Option<(DeviceRoute, bool, bool)> = None;
- let mut stop: Option<oneshot::Sender<()>> = None;
+ let mut current: Option<CaptureTarget> = None;
+ let mut stop: Option<oneshot::Sender<CaptureStop>> = None;
+ let mut stopping_epoch: Option<u64> = None;
let mut ticker = tokio::time::interval(TARGET_POLL);
let mut accumulators = WheelAccumulators::default();
// Capture sessions run as detached tasks, so an unexpected exit (a transient
@@ -177,8 +361,12 @@ async fn manage(
&mut accumulators,
&hook_maps,
&gesture_bindings,
- &dpi_cycle,
- &capture_channel,
+ DispatchHardware {
+ dpi_cycle: &dpi_cycle,
+ capture: &capture_channel,
+ registry: registry.as_ref(),
+ receiver_access: &receiver_access,
+ },
&thumbwheel_sensitivity,
);
}
@@ -186,7 +374,7 @@ async fn manage(
// While pairing is waiting or active, release the capture
// session so run_pairing can own the receiver's HID node (one
// process can't read it through two channels).
- let want = if receiver_access.pairing_requested() {
+ let want = if receiver_access.exclusive_requested() {
None
} else {
let target = dpi_cycle.read().ok().and_then(|guard| guard.target.clone());
@@ -197,63 +385,62 @@ async fn manage(
// thread the full config in. Re-evaluated each tick, so a
// ReloadConfig owner change restarts the session accordingly.
let divert_gesture = gesture_bindings.read().is_ok_and(|g| !g.is_empty());
- target.map(|t| {
- (
- t,
- thumbwheel_armed(&hook_maps, sensitivity),
- divert_gesture,
- )
+ let rearm_generation = capture_rearm_generation.load(Ordering::Relaxed);
+ target.map(|route| CaptureTarget {
+ route,
+ capture_thumbwheel: thumbwheel_armed(&hook_maps, sensitivity),
+ divert_gesture_button: divert_gesture,
+ rearm_generation,
})
};
- if want == current {
+ if stopping_epoch.is_some() {
continue;
}
- // Target or thumb-wheel arming changed (or first tick): stop the
- // old session and start one for the new state. Sending on the
- // oneshot lets the old session restore the diverted controls.
- if let Some(stop) = stop.take() {
- let _ = stop.send(());
- }
- if current.is_some() {
+ let connection_is_current =
+ capture_connection_is_current(registry.as_ref(), &capture_channel);
+ if let Some(reason) =
+ stop_for_target_change(want.as_ref(), current.as_ref(), connection_is_current)
+ {
+ if let Some(stop) = stop.take() {
+ let _ = stop.send(reason);
+ }
+ stopping_epoch = Some(epoch);
current = None;
continue;
}
- if let Some((route, capture_thumbwheel, divert_gesture_button)) = want {
- let Some(receiver_lease) = receiver_access.try_acquire_for_capture() else {
+ if want == current {
+ continue;
+ }
+ if let Some(target) = want {
+ let Some(receiver_lease) = receiver_access.try_acquire_for_session() else {
current = None;
continue;
};
- current = Some((route.clone(), capture_thumbwheel, divert_gesture_button));
- let (stop_tx, stop_rx) = oneshot::channel();
- let sink = tx.clone();
- let slot = Arc::clone(&capture_channel);
+ current = Some(target.clone());
epoch = epoch.wrapping_add(1);
- let session_epoch = epoch;
- let done = done_tx.clone();
- tokio::spawn(async move {
- let _receiver_lease = receiver_lease;
- if let Err(e) = run_capture_session(
- route,
- capture_thumbwheel,
- divert_gesture_button,
- sink,
- stop_rx,
- slot,
- )
- .await
- {
- debug!(error = %e, "capture session ended");
- }
- // Report completion so the manager can re-arm if this exit
- // was unexpected rather than a deliberate stop.
- let _ = done.send(session_epoch);
- });
- stop = Some(stop_tx);
+ stop = Some(spawn_capture_session(CaptureLaunch {
+ route: target.route,
+ capture_thumbwheel: target.capture_thumbwheel,
+ divert_gesture_button: target.divert_gesture_button,
+ sink: tx.clone(),
+ channel_slot: Arc::clone(&capture_channel),
+ receiver_lease,
+ registry: registry.clone(),
+ done: done_tx.clone(),
+ epoch,
+ }));
} else {
current = None;
}
}
Some(done_epoch) = done_rx.recv() => {
+ if acknowledge_stopping(&mut stopping_epoch, done_epoch) {
+ // The stopped task has cleared its slot, dropped the
+ // listener/channel and released any receiver lease. A later
+ // poll may now arm the desired replacement.
+ stop = None;
+ continue;
+ }
// A capture session ended on its own. Re-arm only when it is the
// session we currently believe is live for an active target;
// clearing `current` lets the next tick start a fresh session.
@@ -308,13 +495,20 @@ enum WheelOutput {
}
/// Route one captured input to its bound action (or re-synthesised scroll).
+#[derive(Clone, Copy)]
+struct DispatchHardware<'a> {
+ dpi_cycle: &'a Arc<RwLock<DpiCycleState>>,
+ capture: &'a CaptureChannel,
+ registry: Option<&'a ChannelRegistry>,
+ receiver_access: &'a ReceiverAccess,
+}
+
fn dispatch(
input: CapturedInput,
accumulators: &mut WheelAccumulators,
hook_maps: &SharedHookMaps,
gesture_bindings: &GestureBindings,
- dpi_cycle: &Arc<RwLock<DpiCycleState>>,
- capture: &CaptureChannel,
+ hardware: DispatchHardware<'_>,
thumbwheel_sensitivity: &ThumbwheelSensitivity,
) {
match input {
@@ -325,19 +519,42 @@ fn dispatch(
.and_then(|guard| guard.get(&direction).cloned());
if let Some(action) = action {
debug!(?direction, action = %action.label(), "gesture → action");
- hook_runtime::dispatch_action(&action, dpi_cycle, capture);
+ hook_runtime::dispatch_action(
+ &action,
+ hardware.dpi_cycle,
+ hardware.capture,
+ hardware.registry,
+ hardware.receiver_access,
+ );
} else {
debug!(?direction, "gesture with no binding — ignored");
}
}
- CapturedInput::ButtonPressed(button) => {
+ CapturedInput::ButtonPressed(button, frontmost_pid) => {
let action = hook_maps
.read()
.ok()
.and_then(|maps| maps.bindings.get(&button).cloned());
if let Some(action) = action {
debug!(?button, action = %action.label(), "HID++ button → action");
- hook_runtime::dispatch_action(&action, dpi_cycle, capture);
+ // For browser navigation, use the AX API with the PID captured
+ // at press time (before async dispatch could shift focus).
+ let ax_handled = matches!(action, Action::BrowserBack | Action::BrowserForward)
+ && frontmost_pid.is_some_and(|pid| {
+ openlogi_inject::ax_navigate_browser(
+ pid,
+ matches!(action, Action::BrowserForward),
+ )
+ });
+ if !ax_handled {
+ hook_runtime::dispatch_action(
+ &action,
+ hardware.dpi_cycle,
+ hardware.capture,
+ hardware.registry,
+ hardware.receiver_access,
+ );
+ }
} else {
debug!(?button, "HID++ button with no binding — ignored");
}
@@ -369,7 +586,13 @@ fn dispatch(
}
WheelOutput::FireAction => {
debug!(?button, action = %action.label(), "thumb wheel → action");
- hook_runtime::dispatch_action(&action, dpi_cycle, capture);
+ hook_runtime::dispatch_action(
+ &action,
+ hardware.dpi_cycle,
+ hardware.capture,
+ hardware.registry,
+ hardware.receiver_access,
+ );
}
}
}
@@ -445,6 +668,9 @@ fn advance(
#[cfg(test)]
mod tests {
+ use std::panic::{AssertUnwindSafe, catch_unwind};
+ use std::sync::PoisonError;
+
use super::*;
#[test]
@@ -598,4 +824,58 @@ mod tests {
// device is targeted): no target means there is nothing to re-arm.
assert!(!should_rearm(7, 7, false));
}
+
+ #[test]
+ fn unchanged_target_keeps_the_current_connection() {
+ assert_eq!(
+ stop_reason(Some(&"device-a"), Some(&"device-a"), true),
+ None
+ );
+ }
+
+ #[test]
+ fn revoked_connection_stops_without_disarming_it() {
+ assert_eq!(
+ stop_reason(Some(&"device-a"), Some(&"device-a"), false),
+ Some(CaptureStop::Revoked)
+ );
+ }
+
+ #[test]
+ fn target_change_stops_gracefully_while_connection_is_current() {
+ assert_eq!(
+ stop_reason(Some(&"device-b"), Some(&"device-a"), true),
+ Some(CaptureStop::Graceful)
+ );
+ assert_eq!(
+ stop_reason::<&str>(None, Some(&"device-a"), true),
+ Some(CaptureStop::Graceful)
+ );
+ }
+
+ #[test]
+ fn replacement_waits_for_the_stopped_epochs_acknowledgement() {
+ let mut stopping_epoch = Some(7);
+
+ assert!(!acknowledge_stopping(&mut stopping_epoch, 6));
+ assert_eq!(stopping_epoch, Some(7));
+
+ assert!(acknowledge_stopping(&mut stopping_epoch, 7));
+ assert_eq!(stopping_epoch, None);
+ }
+
+ #[test]
+ fn poisoned_capture_slot_fails_the_current_connection_check_closed() {
+ let capture: CaptureChannel = Arc::new(RwLock::new(None));
+ let poison = Arc::clone(&capture);
+ let _ = catch_unwind(AssertUnwindSafe(move || {
+ let _guard = poison.write().unwrap_or_else(PoisonError::into_inner);
+ panic!("poison capture slot");
+ }));
+
+ assert!(!capture_connection_is_current(
+ Some(&ChannelRegistry::default()),
+ &capture
+ ));
+ }
}
diff --git a/crates/openlogi-agent-core/src/watchers/host_switch.rs b/crates/openlogi-agent-core/src/watchers/host_switch.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2d888d5cb164ccfee025f255e0f3000148264f74
--- /dev/null
+++ b/crates/openlogi-agent-core/src/watchers/host_switch.rs
@@ -0,0 +1,194 @@
+//! Keep configured keyboard → pointing-device host-switch links armed.
+
+use std::sync::{Arc, RwLock};
+use std::thread;
+use std::time::{Duration, Instant};
+
+use openlogi_hid::{
+ ChannelPool, DeviceRoute, HostSwitchStopReason, run_host_switch_session, switch_linked_hosts,
+};
+use tokio::sync::{mpsc, oneshot};
+use tracing::{debug, warn};
+
+use crate::receiver_access::{ExclusiveAccessReason, ReceiverAccess};
+
+const DEPARTURE_TIMEOUT: Duration = Duration::from_secs(10);
+const DEPARTURE_POLL: Duration = Duration::from_millis(100);
+
+/// One resolved link. Config keys are converted to live routes by the
+/// orchestrator so the transport watcher never needs to understand inventory.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct HostSwitchLink {
+ /// Keyboard whose host switch keys initiate the transition.
+ pub keyboard: DeviceRoute,
+ /// Pointing devices that follow the keyboard.
+ pub targets: Vec<DeviceRoute>,
+}
+
+/// Shared resolved links, refreshed with config and inventory.
+pub type HostSwitchLinks = Arc<RwLock<Vec<HostSwitchLink>>>;
+
+/// Spawn the host switch session manager.
+pub fn spawn(links: HostSwitchLinks, channel_pool: ChannelPool, receiver_access: ReceiverAccess) {
+ thread::spawn(move || {
+ let runtime = match tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ {
+ Ok(runtime) => runtime,
+ Err(error) => {
+ warn!(%error, "host switch watcher: could not build tokio runtime");
+ return;
+ }
+ };
+ runtime.block_on(manage(links, channel_pool, receiver_access));
+ });
+}
+
+async fn manage(
+ links: HostSwitchLinks,
+ channel_pool: ChannelPool,
+ receiver_access: ReceiverAccess,
+) {
+ let mut sessions = Vec::new();
+ let (done_tx, mut done_rx) = mpsc::unbounded_channel::<SessionCompletion>();
+ let mut next_generation = 0_u64;
+ let mut ticker = tokio::time::interval(Duration::from_secs(1));
+
+ loop {
+ tokio::select! {
+ _ = ticker.tick() => {
+ let wanted = if receiver_access.exclusive_requested() {
+ Vec::new()
+ } else {
+ links.read().map_or_else(|_| Vec::new(), |guard| guard.clone())
+ };
+ stop_unwanted(&mut sessions, &wanted).await;
+ for link in wanted {
+ if sessions.iter().any(|session| session.link == link) {
+ continue;
+ }
+ let (stop_tx, stop_rx) = oneshot::channel();
+ let done = done_tx.clone();
+ let pool = channel_pool.clone();
+ let Some(receiver_lease) = receiver_access.try_acquire_for_session() else {
+ break;
+ };
+ next_generation = next_generation.wrapping_add(1);
+ let session_generation = next_generation;
+ let session_link = link.clone();
+ let task = tokio::spawn(async move {
+ let _receiver_lease = receiver_lease;
+ let keyboard = link.keyboard.clone();
+ let request = match run_host_switch_session(
+ link.keyboard.clone(),
+ stop_rx,
+ pool,
+ )
+ .await
+ {
+ Ok(host) => host.map(|host| (link, host)),
+ Err(error) => {
+ debug!(%error, route = %keyboard, "host switch session ended");
+ None
+ }
+ };
+ let _ = done.send(SessionCompletion {
+ generation: session_generation,
+ request,
+ });
+ });
+ sessions.push(RunningSession {
+ link: session_link,
+ generation: session_generation,
+ stop: stop_tx,
+ task,
+ });
+ }
+ }
+ Some(completion) = done_rx.recv() => {
+ if let Some(index) = sessions
+ .iter()
+ .position(|session| session.generation == completion.generation)
+ {
+ let completed = sessions.remove(index);
+ let _ = completed.task.await;
+ if let Some((link, host)) = completion.request {
+ stop_all(&mut sessions, HostSwitchStopReason::Graceful).await;
+ run_transition(&links, &channel_pool, &receiver_access, link, host).await;
+ }
+ }
+ }
+ }
+ }
+}
+
+struct RunningSession {
+ link: HostSwitchLink,
+ generation: u64,
+ stop: oneshot::Sender<HostSwitchStopReason>,
+ task: tokio::task::JoinHandle<()>,
+}
+
+struct SessionCompletion {
+ generation: u64,
+ request: Option<(HostSwitchLink, u8)>,
+}
+
+async fn stop_all(sessions: &mut Vec<RunningSession>, reason: HostSwitchStopReason) {
+ let running = std::mem::take(sessions);
+ let mut tasks = Vec::with_capacity(running.len());
+ for RunningSession { stop, task, .. } in running {
+ let _ = stop.send(reason);
+ tasks.push(task);
+ }
+ for task in tasks {
+ let _ = task.await;
+ }
+}
+
+async fn stop_unwanted(sessions: &mut Vec<RunningSession>, wanted: &[HostSwitchLink]) {
+ let mut index = 0;
+ while index < sessions.len() {
+ if wanted.contains(&sessions[index].link) {
+ index += 1;
+ continue;
+ }
+ let RunningSession { stop, task, .. } = sessions.remove(index);
+ let _ = stop.send(HostSwitchStopReason::Graceful);
+ let _ = task.await;
+ }
+}
+
+async fn run_transition(
+ links: &HostSwitchLinks,
+ channel_pool: &ChannelPool,
+ receiver_access: &ReceiverAccess,
+ link: HostSwitchLink,
+ host: u8,
+) {
+ let _lease = receiver_access
+ .acquire_exclusive(ExclusiveAccessReason::HostTransition)
+ .await;
+ match switch_linked_hosts(&link.keyboard, &link.targets, host, channel_pool).await {
+ Ok(true) => wait_for_departure(links, &link.keyboard).await,
+ Ok(false) => {}
+ Err(error) => {
+ debug!(%error, route = %link.keyboard, host, "keyboard host switch failed");
+ }
+ }
+}
+
+async fn wait_for_departure(links: &HostSwitchLinks, keyboard: &DeviceRoute) {
+ let deadline = Instant::now() + DEPARTURE_TIMEOUT;
+ while Instant::now() < deadline {
+ let departed = links.read().map_or(true, |current| {
+ !current.iter().any(|link| link.keyboard == *keyboard)
+ });
+ if departed {
+ return;
+ }
+ tokio::time::sleep(DEPARTURE_POLL).await;
+ }
+ warn!(route = %keyboard, "host transition departure was not observed");
+}
diff --git a/crates/openlogi-agent-core/src/watchers/inventory.rs b/crates/openlogi-agent-core/src/watchers/inventory.rs
index 107ddd6b98ebe65c874213d0d583d8f8afaae6ed..d5104f4c5d80d7316e53b8b57f3f996bd48577a1 100644
--- a/crates/openlogi-agent-core/src/watchers/inventory.rs
+++ b/crates/openlogi-agent-core/src/watchers/inventory.rs
@@ -11,11 +11,13 @@
//! the reconciliation pass (battery refresh, missed events, platforms where
//! the hotplug stream is unavailable).
+use std::collections::{BTreeMap, HashSet};
use std::thread;
use std::time::{Duration, SystemTime};
use futures_lite::StreamExt as _;
-use openlogi_core::device::DeviceInventory;
+use openlogi_core::device::{DeviceInventory, StandaloneDevice};
+use openlogi_hid::ChannelRegistry;
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
@@ -26,6 +28,12 @@ use tracing::{debug, info, warn};
/// live inventory.
const INITIAL_FAILURE_LIMIT: u8 = 3;
+/// Number of successful raw-HID snapshots an omitted node may miss before it
+/// is treated as a real detach. OS enumeration can briefly omit a registered
+/// interface during unplug/replug and hotplug bursts, so a successful empty
+/// snapshot is not immediately destructive for standalone lights.
+const RAW_NODE_MISS_GRACE: u8 = 2;
+
/// Pause between a hotplug event and the early enumerate, so a just-connected
/// node finishes registering with the OS before the probe opens it.
const HOTPLUG_SETTLE: Duration = Duration::from_millis(400);
@@ -41,7 +49,12 @@ const WAKE_GAP: Duration = Duration::from_mins(1);
#[derive(Debug)]
pub enum InventoryEvent {
/// A completed enumeration — empty means "checked, no devices".
- Snapshot(Vec<DeviceInventory>),
+ Snapshot {
+ /// HID++ receiver/direct inventory.
+ inventories: Vec<DeviceInventory>,
+ /// Recognized standalone raw-HID devices.
+ standalone: Vec<StandaloneDevice>,
+ },
/// Enumeration has never succeeded and won't be treated as "still
/// starting" any longer; without this the GUI would show its scanning
/// state forever on a broken HID backend.
@@ -64,9 +77,103 @@ struct WatchState {
succeeded: bool,
/// Consecutive failures, counted only before the first success.
initial_failures: u8,
+ raw_nodes: RawNodeLedger,
+}
+
+#[derive(Default)]
+struct RawNodeLedger {
+ entries: BTreeMap<String, RawNodeEntry>,
+}
+
+struct RawNodeEntry {
+ device: StandaloneDevice,
+ misses: u8,
+}
+
+impl RawNodeLedger {
+ /// Reconcile one successful raw enumeration with the last good per-node
+ /// records. Omitted nodes stay offline for a bounded grace; a node seen
+ /// again is replaced by its fresh descriptor and its miss count resets.
+ fn reconcile(&mut self, live: Vec<StandaloneDevice>) -> Vec<StandaloneDevice> {
+ let live_keys: HashSet<String> = live.iter().map(raw_node_key).collect();
+ for device in live {
+ self.entries
+ .insert(raw_node_key(&device), RawNodeEntry { device, misses: 0 });
+ }
+
+ let missing: Vec<String> = self
+ .entries
+ .keys()
+ .filter(|key| !live_keys.contains(*key))
+ .cloned()
+ .collect();
+ for key in missing {
+ let Some(entry) = self.entries.get_mut(&key) else {
+ continue;
+ };
+ entry.misses = entry.misses.saturating_add(1);
+ if entry.misses > RAW_NODE_MISS_GRACE {
+ self.entries.remove(&key);
+ } else {
+ entry.device.online = false;
+ }
+ }
+
+ self.entries
+ .values()
+ .map(|entry| entry.device.clone())
+ .collect()
+ }
+
+ /// Return the last completed raw-HID snapshot without counting a miss.
+ /// Used when standalone enumeration itself fails: absence was not
+ /// observed, so advancing detach grace would manufacture a disconnect.
+ fn snapshot(&self) -> Vec<StandaloneDevice> {
+ self.entries
+ .values()
+ .map(|entry| entry.device.clone())
+ .collect()
+ }
+}
+
+fn raw_node_key(device: &StandaloneDevice) -> String {
+ let address = &device.address;
+ format!(
+ "{:04x}:{:04x}:{:04x}:{:04x}:{}",
+ address.vendor_id,
+ address.product_id,
+ address.usage_page,
+ address.usage_id,
+ address.identity
+ )
}
impl WatchState {
+ /// Combine a successful HID++ enumeration with the independently fallible
+ /// raw-HID pass. A raw backend failure must not suppress fresh mouse and
+ /// keyboard inventory, nor count every remembered light as detached.
+ fn classify_parts(
+ &mut self,
+ inventories: Vec<DeviceInventory>,
+ standalone: Result<Vec<StandaloneDevice>, openlogi_hid::InventoryError>,
+ ) -> InventoryEvent {
+ self.succeeded = true;
+ let standalone = match standalone {
+ Ok(devices) => self.raw_nodes.reconcile(devices),
+ Err(e) => {
+ warn!(
+ error = ?e,
+ "standalone enumerate failed during watch tick — keeping last raw snapshot"
+ );
+ self.raw_nodes.snapshot()
+ }
+ };
+ InventoryEvent::Snapshot {
+ inventories,
+ standalone,
+ }
+ }
+
/// Decide what (if anything) a watch tick emits.
///
/// - `Ok(snapshot)` — a completed enumeration (an empty one included: that's
@@ -82,13 +189,10 @@ impl WatchState {
/// retrying and a later success recovers.
fn classify(
&mut self,
- result: Result<Vec<DeviceInventory>, openlogi_hid::InventoryError>,
+ result: Result<(Vec<DeviceInventory>, Vec<StandaloneDevice>), openlogi_hid::InventoryError>,
) -> Option<InventoryEvent> {
match result {
- Ok(inv) => {
- self.succeeded = true;
- Some(InventoryEvent::Snapshot(inv))
- }
+ Ok((inventories, standalone)) => Some(self.classify_parts(inventories, Ok(standalone))),
Err(e) => {
warn!(error = ?e, "enumerate failed during watch tick — keeping last snapshot");
if self.succeeded {
@@ -110,7 +214,25 @@ impl WatchState {
/// the loop exits cleanly. The watcher dying instead (a panic inside the HID
/// backend) closes the channel — the agent select loop maps that closure to
/// `Unavailable` too.
+#[must_use]
pub fn spawn(period: Duration) -> mpsc::UnboundedReceiver<InventoryEvent> {
+ spawn_inner(period, None)
+}
+
+/// Spawn the persistent watcher and publish its inventory-owned HID++ channels
+/// into `registry` for Agent capture and hardware operations.
+#[must_use]
+pub fn spawn_with_registry(
+ period: Duration,
+ registry: ChannelRegistry,
+) -> mpsc::UnboundedReceiver<InventoryEvent> {
+ spawn_inner(period, Some(registry))
+}
+
+fn spawn_inner(
+ period: Duration,
+ registry: Option<ChannelRegistry>,
+) -> mpsc::UnboundedReceiver<InventoryEvent> {
let (tx, rx) = mpsc::unbounded_channel();
let worker_tx = tx.clone();
let spawn_result = thread::Builder::new()
@@ -129,7 +251,10 @@ pub fn spawn(period: Duration) -> mpsc::UnboundedReceiver<InventoryEvent> {
// A persistent enumerator so its per-device probe cache survives
// across ticks — a known device's immutable data (model, features)
// is reused instead of being re-handshaked every poll.
- let mut enumerator = openlogi_hid::Enumerator::default();
+ let mut enumerator = registry.map_or_else(
+ openlogi_hid::Enumerator::default,
+ openlogi_hid::Enumerator::with_registry,
+ );
let mut state = WatchState::default();
let mut last_tick = SystemTime::now();
// `block_on` installs runtime context so a backend that registers an
@@ -156,8 +281,14 @@ pub fn spawn(period: Duration) -> mpsc::UnboundedReceiver<InventoryEvent> {
}
}
last_tick = now;
- let result = rt.block_on(enumerator.enumerate());
- if let Some(event) = state.classify(result)
+ let event = match rt.block_on(enumerator.enumerate()) {
+ Ok(inventories) => {
+ let standalone = rt.block_on(openlogi_hid::enumerate_standalone());
+ Some(state.classify_parts(inventories, standalone))
+ }
+ Err(error) => state.classify(Err(error)),
+ };
+ if let Some(event) = event
&& worker_tx.send(event).is_err()
{
debug!("inventory watcher receiver dropped — exiting");
@@ -212,6 +343,7 @@ pub fn spawn(period: Duration) -> mpsc::UnboundedReceiver<InventoryEvent> {
mod tests {
use std::assert_matches;
+ use openlogi_core::device::{DeviceKind, RawDeviceAddress, StandaloneDevice};
use openlogi_hid::InventoryError;
use super::{INITIAL_FAILURE_LIMIT, InventoryEvent, WatchState};
@@ -228,8 +360,8 @@ mod tests {
// A genuine "checked, nothing there" still propagates as a disconnect —
// the resilience must not swallow a real empty.
assert_matches!(
- state.classify(Ok(vec![])),
- Some(InventoryEvent::Snapshot(snap)) if snap.is_empty()
+ state.classify(Ok((vec![], vec![]))),
+ Some(InventoryEvent::Snapshot { inventories, standalone }) if inventories.is_empty() && standalone.is_empty()
);
assert!(state.succeeded);
}
@@ -239,8 +371,8 @@ mod tests {
let mut state = WatchState::default();
// A good tick first, so there is a last-known-good set to preserve.
assert_matches!(
- state.classify(Ok(vec![])),
- Some(InventoryEvent::Snapshot(_))
+ state.classify(Ok((vec![], vec![]))),
+ Some(InventoryEvent::Snapshot { .. })
);
// Then transient enumerate failures emit nothing — the agent keeps the
// last snapshot instead of flapping to "No devices" (#218).
@@ -248,6 +380,20 @@ mod tests {
assert!(state.classify(Err(enumerate_failed())).is_none());
}
+ #[test]
+ fn standalone_failure_keeps_raw_nodes_without_suppressing_the_snapshot() {
+ let mut state = WatchState::default();
+ let _ = state.classify(Ok((vec![], vec![raw_light("serial:glow-1")])));
+
+ assert_matches!(
+ state.classify_parts(vec![], Err(enumerate_failed())),
+ InventoryEvent::Snapshot { inventories, standalone }
+ if inventories.is_empty()
+ && standalone.len() == 1
+ && standalone[0].online
+ );
+ }
+
#[test]
fn persistent_initial_failure_reports_unavailable_once_then_recovers() {
let mut state = WatchState::default();
@@ -264,8 +410,59 @@ mod tests {
assert!(state.classify(Err(enumerate_failed())).is_none());
// …and a later success recovers with a live snapshot.
assert_matches!(
- state.classify(Ok(vec![])),
- Some(InventoryEvent::Snapshot(_))
+ state.classify(Ok((vec![], vec![]))),
+ Some(InventoryEvent::Snapshot { .. })
+ );
+ }
+
+ fn raw_light(identity: &str) -> StandaloneDevice {
+ StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: identity.into(),
+ },
+ display_name: "Litra Glow".into(),
+ manufacturer: Some("Logi".into()),
+ serial_number: None,
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: None,
+ driver_id: "litra".into(),
+ registry_model_id: None,
+ }
+ }
+
+ #[test]
+ fn raw_node_omission_is_graced_and_recovers() {
+ let mut state = WatchState::default();
+ assert_matches!(
+ state.classify(Ok((vec![], vec![raw_light("id:node")]))) ,
+ Some(InventoryEvent::Snapshot { standalone, .. }) if standalone.len() == 1 && standalone[0].online
+ );
+ assert_matches!(
+ state.classify(Ok((vec![], vec![]))),
+ Some(InventoryEvent::Snapshot { standalone, .. }) if standalone.len() == 1 && !standalone[0].online
+ );
+ assert_matches!(
+ state.classify(Ok((vec![], vec![raw_light("id:node")]))) ,
+ Some(InventoryEvent::Snapshot { standalone, .. }) if standalone.len() == 1 && standalone[0].online
+ );
+ }
+
+ #[test]
+ fn raw_node_is_removed_after_grace_is_exhausted() {
+ let mut state = WatchState::default();
+ let _ = state.classify(Ok((vec![], vec![raw_light("id:node")])));
+ let _ = state.classify(Ok((vec![], vec![])));
+ let _ = state.classify(Ok((vec![], vec![])));
+ assert_matches!(
+ state.classify(Ok((vec![], vec![]))),
+ Some(InventoryEvent::Snapshot { standalone, .. }) if standalone.is_empty()
);
}
}
diff --git a/crates/openlogi-agent-core/src/watchers/keyboard.rs b/crates/openlogi-agent-core/src/watchers/keyboard.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d073c0717c667162215c944a89e124e450ef9eb0
--- /dev/null
+++ b/crates/openlogi-agent-core/src/watchers/keyboard.rs
@@ -0,0 +1,204 @@
+//! Background HID++ key-capture watcher for a bound keyboard.
+//!
+//! Runs [`openlogi_hid::run_keyboard_capture_session_with_registry`] on a
+//! dedicated thread for the keyboard the orchestrator publishes in
+//! [`SharedKeyboardSpec`], restarts it when the keyboard (or the set of bound
+//! keys) changes, and dispatches each captured key press through the common
+//! action path ([`crate::hook_runtime::dispatch_action`]).
+//!
+//! The mouse capture watcher ([`super::gesture`]) and this one hold *shared*
+//! receiver leases, so both run concurrently; pairing still waits for (and
+//! excludes) both. Like the gesture watcher, this needs no macOS Accessibility
+//! permission — the key events arrive over HID++.
+
+use std::collections::BTreeMap;
+use std::sync::{Arc, RwLock};
+use std::thread;
+use std::time::Duration;
+
+use openlogi_core::binding::{Action, ButtonId};
+use openlogi_hid::{
+ CaptureChannel, CapturedInput, ChannelRegistry, DeviceRoute,
+ run_keyboard_capture_session_with_registry,
+};
+use tokio::sync::{mpsc, oneshot};
+use tracing::{debug, info, warn};
+
+use crate::DpiCycleState;
+use crate::hook_runtime;
+use crate::receiver_access::ReceiverAccess;
+use crate::watchers::gesture::should_rearm;
+
+/// Everything the watcher needs to capture one keyboard: where it is, which
+/// `0x1b04` controls to divert (only keys carrying a real binding), and the
+/// per-key action map presses dispatch through. Rebuilt by the orchestrator on
+/// config / inventory / foreground-app changes.
+#[derive(Clone)]
+pub struct KeyboardSpec {
+ /// HID++ route of the keyboard.
+ pub route: DeviceRoute,
+ /// `0x1b04` control ID → button, for exactly the bound keys.
+ pub wanted: BTreeMap<u16, ButtonId>,
+ /// Effective per-key single-action map (per-app overlay applied).
+ pub bindings: BTreeMap<ButtonId, Action>,
+}
+
+/// Shared keyboard-capture spec, `None` when no online keyboard has bound
+/// keys. Written by the orchestrator, read by the watcher.
+pub type SharedKeyboardSpec = Arc<RwLock<Option<KeyboardSpec>>>;
+
+/// How often to re-read the spec so a config edit, per-app overlay change, or
+/// keyboard reconnect re-points the capture session.
+const TARGET_POLL: Duration = Duration::from_secs(1);
+
+/// Spawn the keyboard-capture manager thread. It owns a current-thread tokio
+/// runtime that keeps one capture session pointed at the bound keyboard and
+/// dispatches each captured key press.
+pub fn spawn(
+ spec: SharedKeyboardSpec,
+ dpi_cycle: Arc<RwLock<DpiCycleState>>,
+ mouse_capture: CaptureChannel,
+ keyboard_channel: CaptureChannel,
+ receiver_access: ReceiverAccess,
+ registry: ChannelRegistry,
+) {
+ thread::spawn(move || {
+ let runtime = match tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ {
+ Ok(rt) => rt,
+ Err(e) => {
+ warn!(error = %e, "keyboard watcher: could not build tokio runtime");
+ return;
+ }
+ };
+ runtime.block_on(manage(
+ spec,
+ dpi_cycle,
+ mouse_capture,
+ keyboard_channel,
+ receiver_access,
+ registry,
+ ));
+ });
+}
+
+/// Keep one keyboard capture session alive for the published spec, restarting
+/// it when the keyboard or its bound-key set changes, and dispatch incoming
+/// presses. Runs for the lifetime of the process.
+async fn manage(
+ spec: SharedKeyboardSpec,
+ dpi_cycle: Arc<RwLock<DpiCycleState>>,
+ mouse_capture: CaptureChannel,
+ keyboard_channel: CaptureChannel,
+ receiver_access: ReceiverAccess,
+ registry: ChannelRegistry,
+) {
+ let (tx, mut rx) = mpsc::unbounded_channel::<CapturedInput>();
+ let mut current: Option<(DeviceRoute, BTreeMap<u16, ButtonId>)> = None;
+ let mut stop: Option<oneshot::Sender<()>> = None;
+ let mut ticker = tokio::time::interval(TARGET_POLL);
+ // Sessions report completion tagged with their start epoch, so an
+ // unexpected exit of the *current* session re-arms while stale completions
+ // are ignored — same pacing/starvation reasoning as the gesture watcher.
+ let (done_tx, mut done_rx) = mpsc::unbounded_channel::<u64>();
+ let mut epoch: u64 = 0;
+
+ loop {
+ tokio::select! {
+ Some(input) = rx.recv() => {
+ // The keyboard session only emits ButtonPressed; other inputs
+ // (gesture/scroll) never originate here.
+ let CapturedInput::ButtonPressed(button, _) = input else {
+ continue;
+ };
+ let action = spec
+ .read()
+ .ok()
+ .and_then(|guard| {
+ guard.as_ref().and_then(|s| s.bindings.get(&button).cloned())
+ });
+ if let Some(action) = action {
+ info!(button = %button, action = %action.label(), "keyboard key → executing bound action");
+ hook_runtime::dispatch_action(
+ &action,
+ &dpi_cycle,
+ &mouse_capture,
+ Some(®istry),
+ &receiver_access,
+ );
+ } else {
+ debug!(?button, "keyboard key with no binding — ignored");
+ }
+ }
+ _ = ticker.tick() => {
+ // While pairing is waiting or active, release the capture
+ // session so run_pairing can own the receiver's HID node.
+ let want = if receiver_access.exclusive_requested() {
+ None
+ } else {
+ spec.read()
+ .ok()
+ .and_then(|guard| guard.clone())
+ .map(|s| (s.route, s.wanted))
+ };
+ if want == current {
+ continue;
+ }
+ // Spec changed (or first tick): stop the old session and start
+ // one for the new state. Sending on the oneshot lets the old
+ // session restore the diverted controls.
+ if let Some(stop) = stop.take() {
+ let _ = stop.send(());
+ }
+ if current.is_some() {
+ current = None;
+ continue;
+ }
+ if let Some((route, wanted)) = want {
+ let Some(receiver_lease) = receiver_access.try_acquire_for_session() else {
+ current = None;
+ continue;
+ };
+ current = Some((route.clone(), wanted.clone()));
+ let (stop_tx, stop_rx) = oneshot::channel();
+ let sink = tx.clone();
+ let slot = Arc::clone(&keyboard_channel);
+ let session_registry = registry.clone();
+ epoch = epoch.wrapping_add(1);
+ let session_epoch = epoch;
+ let done = done_tx.clone();
+ tokio::spawn(async move {
+ let _receiver_lease = receiver_lease;
+ if let Err(e) = run_keyboard_capture_session_with_registry(
+ route,
+ wanted,
+ sink,
+ stop_rx,
+ slot,
+ &session_registry,
+ )
+ .await
+ {
+ debug!(error = %e, "keyboard capture session ended");
+ }
+ let _ = done.send(session_epoch);
+ });
+ stop = Some(stop_tx);
+ } else {
+ current = None;
+ }
+ }
+ Some(done_epoch) = done_rx.recv() => {
+ // A capture session ended on its own; re-arm only the live one
+ // (see gesture watcher for the epoch/pacing rationale).
+ if should_rearm(done_epoch, epoch, current.is_some()) {
+ warn!("keyboard capture session ended unexpectedly, re-arming");
+ current = None;
+ stop = None;
+ }
+ }
+ }
+ }
+}
diff --git a/crates/openlogi-agent-core/src/watchers/mod.rs b/crates/openlogi-agent-core/src/watchers/mod.rs
index cf2985dff820b524a8e285e0c756abf3743b50d6..baf2b60ff5ee84971beef13169bf8ad471b88ad4 100644
--- a/crates/openlogi-agent-core/src/watchers/mod.rs
+++ b/crates/openlogi-agent-core/src/watchers/mod.rs
@@ -3,7 +3,10 @@
//! consumer (the agent's orchestrator, or the GUI).
pub mod accessibility;
+pub mod camera;
pub mod foreground_app;
pub mod gesture;
+pub mod host_switch;
pub mod inventory;
+pub mod keyboard;
pub mod pairing;
diff --git a/crates/openlogi-agent-core/tests/wire_format.rs b/crates/openlogi-agent-core/tests/wire_format.rs
index 38fbd82c2fde42688c952e833e3635486560add8..62f34419785533cc2e4cd780e8a19b7a5ab6f6ce 100644
--- a/crates/openlogi-agent-core/tests/wire_format.rs
+++ b/crates/openlogi-agent-core/tests/wire_format.rs
@@ -29,11 +29,12 @@ use openlogi_agent_core::ipc::{
use openlogi_core::config::Lighting;
use openlogi_core::device::{
BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceInventory, DeviceKind,
- DeviceModelInfo, DeviceTransports, PairedDevice, ReceiverInfo,
+ DeviceModelInfo, DeviceTransports, LightCapabilities, LightValueRange, LightValueUnit,
+ PairedDevice, RawDeviceAddress, ReceiverInfo, StandaloneDevice,
};
use openlogi_hid::{
Click, DeviceRoute, DpiCapabilities, DpiInfo, HidppFeatureErrorKind, HidppOperation,
- PasskeyMethod, ReceiverSelector, SmartShiftMode, SmartShiftStatus, WriteError,
+ LightCommand, PasskeyMethod, ReceiverSelector, SmartShiftMode, SmartShiftStatus, WriteError,
};
/// Serialize exactly as the transport does (`tokio_serde::formats::Bincode`
@@ -61,7 +62,7 @@ fn assert_wire<T: serde::Serialize>(value: &T, golden: &str) {
/// that makes that visible in the same diff.
#[test]
fn protocol_version_is_pinned() {
- assert_eq!(PROTOCOL_VERSION, 11);
+ assert_eq!(PROTOCOL_VERSION, 13);
}
/// tarpc encodes the request enum's variant index, so trait *method order* is
@@ -83,6 +84,32 @@ fn request_variant_order() {
assert_wire(&AgentRequest::NextPairing {}, "0d");
assert_wire(&AgentRequest::Snapshot {}, "0e");
assert_wire(&AgentRequest::PollEventMonitor {}, "0f");
+ assert_wire(
+ &AgentRequest::SetLight {
+ route: DeviceRoute::RawHid {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:ABC123".into(),
+ },
+ command: LightCommand::Power(true),
+ },
+ "1003fb6d04fb00c9fb43fffb02020d73657269616c3a4142433132330001",
+ );
+ assert_wire(
+ &AgentRequest::SetLightManualPower {
+ route: DeviceRoute::RawHid {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:ABC123".into(),
+ },
+ enabled: false,
+ },
+ "1103fb6d04fb00c9fb43fffb02020d73657269616c3a41424331323300",
+ );
}
#[test]
@@ -135,12 +162,17 @@ fn agent_snapshot() {
agent_version: "0.6.6".into(),
},
inventory: Vec::new(),
+ standalone: Vec::new(),
+ camera_active: false,
};
- assert_wire(&snapshot, "010001010705302e362e3600");
+ assert_wire(&snapshot, "010001010705302e362e36000000");
}
#[test]
fn device_inventory() {
+ // `Light` was appended after `Unknown`; preserve every existing kind's
+ // bincode discriminant.
+ assert_wire(&DeviceKind::Light, "0d");
let inventory = vec![DeviceInventory {
receiver: ReceiverInfo {
name: "Bolt Receiver".into(),
@@ -244,6 +276,12 @@ fn device_settings_payloads() {
},
"0804",
);
+ assert_wire(
+ &WriteError::RequestTimedOut {
+ operation: HidppOperation::Light,
+ },
+ "080d",
+ );
assert_wire(
&WriteError::HidppFeature {
operation: HidppOperation::WriteDpi,
@@ -293,3 +331,69 @@ fn device_settings_payloads() {
"01084630304443414645",
);
}
+
+#[test]
+fn standalone_light_dtos_commands_and_errors() {
+ let brightness =
+ LightValueRange::new(20, 250, 1, LightValueUnit::Lumens).expect("valid brightness range");
+ let temperature = LightValueRange::new(2700, 6500, 100, LightValueUnit::Kelvin)
+ .expect("valid temperature range");
+ let capabilities = LightCapabilities {
+ power: true,
+ brightness: Some(brightness),
+ temperature: Some(temperature),
+ color: false,
+ zones: false,
+ };
+ let standalone = StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:glow-1".into(),
+ },
+ display_name: "Litra Glow".into(),
+ manufacturer: Some("Logi".into()),
+ serial_number: Some("glow-1".into()),
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: Some(capabilities),
+ driver_id: "litra".into(),
+ registry_model_id: Some("8c900".into()),
+ };
+
+ assert_wire(
+ &standalone,
+ "fb6d04fb00c9fb43fffb02020d73657269616c3a676c6f772d310a4c6974726120476c6f7701044c6f67690106676c6f772d31000000000d010001010114fa010101fb8c0afb641964020000056c6974726101053863393030",
+ );
+ let mut legacy = standalone.clone();
+ legacy.registry_model_id = None;
+ assert_wire(
+ &legacy,
+ "fb6d04fb00c9fb43fffb02020d73657269616c3a676c6f772d310a4c6974726120476c6f7701044c6f67690106676c6f772d31000000000d010001010114fa010101fb8c0afb641964020000056c69747261",
+ );
+ assert_wire(&capabilities, "010114fa010101fb8c0afb641964020000");
+ assert_wire(&brightness, "14fa0101");
+ assert_wire(&temperature, "fb8c0afb64196402");
+ assert_wire(&LightCommand::Power(true), "0001");
+ assert_wire(&LightCommand::BrightnessPercent(65), "0141");
+ assert_wire(&LightCommand::TemperatureKelvin(4600), "02fbf811");
+ assert_wire(&LightCommand::BrightnessNative(136), "0388");
+ assert_wire(
+ &WriteError::InvalidLightValue {
+ control: "temperature_kelvin".into(),
+ value: 2750,
+ },
+ "0b1274656d70657261747572655f6b656c76696efbbe0a",
+ );
+ assert_wire(
+ &WriteError::LightUnsupported {
+ control: "color".into(),
+ },
+ "0c05636f6c6f72",
+ );
+ assert_wire(&WriteError::AmbiguousRawDevice, "0d");
+}
diff --git a/crates/openlogi-agent/Cargo.toml b/crates/openlogi-agent/Cargo.toml
index e99f143d697efa844be18a27766163b88902b943..a3b652492acf57e97ead5781168d4a7169d8fb14 100644
--- a/crates/openlogi-agent/Cargo.toml
+++ b/crates/openlogi-agent/Cargo.toml
@@ -39,9 +39,9 @@ embed-resource = "3.0.9"
# the GUI's so the resolve doesn't move the gpui Cargo.lock pin.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = { workspace = true }
-objc2-app-kit = { workspace = true, features = ["NSStatusBar", "NSStatusItem", "NSStatusBarButton", "NSButton", "NSControl", "NSResponder", "NSView", "NSMenu", "NSMenuItem", "NSImage", "NSApplication", "NSRunningApplication"] }
-objc2-foundation = { workspace = true, features = ["NSString", "NSData", "NSArray"] }
-plist = "1.9.0"
+objc2-app-kit = { workspace = true, features = ["NSStatusBar", "NSStatusItem", "NSStatusBarButton", "NSButton", "NSControl", "NSResponder", "NSView", "NSMenu", "NSMenuItem", "NSImage", "NSApplication", "NSRunningApplication", "NSWorkspace"] }
+objc2-foundation = { workspace = true, features = ["NSString", "NSData", "NSArray", "NSNotification"] }
+plist = "1.10.0"
# Autostart via the HKCU Run key (launch_agent.rs); winreg also answers the
# taskbar light/dark theme read for the tray glyph (tray_windows.rs). The
diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs
index 6ce40c456e2d9d7031d13e03c07ed04a14d49bca..083f9da37a58c9dc496cad90c574444c99098cc7 100644
--- a/crates/openlogi-agent/src/main.rs
+++ b/crates/openlogi-agent/src/main.rs
@@ -32,7 +32,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use openlogi_agent_core::event_monitor::EventMonitor;
-use openlogi_agent_core::orchestrator::Orchestrator;
+use openlogi_agent_core::orchestrator::{Orchestrator, SharedRuntime};
use openlogi_agent_core::{hook_runtime, watchers};
use openlogi_core::config::Config;
use openlogi_hook::Hook;
@@ -101,14 +101,16 @@ fn main() {
// Read the menu-bar preference before `config` moves into the core
// thread; the main thread hosts the tray.
let show_in_menu_bar = config.app_settings.show_in_menu_bar;
+ let resume_pending = Arc::new(AtomicBool::new(false));
+ let core_resume_pending = Arc::clone(&resume_pending);
if let Err(e) = std::thread::Builder::new()
.name("openlogi-agent-core".into())
- .spawn(move || runtime.block_on(run(config)))
+ .spawn(move || runtime.block_on(run(config, core_resume_pending)))
{
warn!(error = %e, "could not spawn the agent core thread; exiting");
return;
}
- tray::run_app_loop(show_in_menu_bar);
+ tray::run_app_loop(show_in_menu_bar, resume_pending);
}
#[cfg(not(target_os = "macos"))]
{
@@ -120,7 +122,34 @@ fn main() {
}
}
-async fn run(config: Config) {
+/// Start the HID++ background sessions that do not need Accessibility.
+fn spawn_hidpp_watchers(shared: &SharedRuntime) {
+ watchers::gesture::spawn_with_registry(
+ shared.hook_maps.clone(),
+ shared.gesture_bindings.clone(),
+ shared.dpi_cycle.clone(),
+ shared.capture_channel.clone(),
+ shared.thumbwheel_sensitivity.clone(),
+ shared.capture_rearm_generation.clone(),
+ shared.receiver_access.clone(),
+ shared.channel_registry.clone(),
+ );
+ watchers::host_switch::spawn(
+ shared.host_switch_links.clone(),
+ shared.channel_pool.clone(),
+ shared.receiver_access.clone(),
+ );
+ watchers::keyboard::spawn(
+ shared.keyboard_spec.clone(),
+ shared.dpi_cycle.clone(),
+ shared.capture_channel.clone(),
+ shared.keyboard_channel.clone(),
+ shared.receiver_access.clone(),
+ shared.channel_registry.clone(),
+ );
+}
+
+async fn run(config: Config, #[cfg(target_os = "macos")] resume_pending: Arc<AtomicBool>) {
// Reconcile the agent's launch-at-login autostart and clear the legacy GUI
// LaunchAgent, before `config` moves into the orchestrator.
launch_agent::reconcile(config.app_settings.launch_at_login);
@@ -158,20 +187,14 @@ async fn run(config: Config) {
// Pairing runs in the agent (it owns device I/O); the GUI drives it over IPC.
let pairing = Arc::new(pairing::PairingManager::new(shared.clone()));
- // The HID++ control watcher (gesture button, DPI/ModeShift button, thumb
- // wheel) needs no Accessibility permission — start it up front. It reads the
- // shared maps and dispatches bound actions itself; the two pairing flags let
- // it release its capture session while a pairing session owns the receiver.
- watchers::gesture::spawn(
- shared.hook_maps.clone(),
- shared.gesture_bindings.clone(),
- shared.dpi_cycle.clone(),
- shared.capture_channel.clone(),
- shared.thumbwheel_sensitivity.clone(),
- shared.receiver_access.clone(),
- );
+ // HID++ watchers need no Accessibility permission — start them up front.
+ spawn_hidpp_watchers(&shared);
- let mut inventory_rx = watchers::inventory::spawn(Duration::from_secs(2));
+ let mut inventory_rx = watchers::inventory::spawn_with_registry(
+ Duration::from_secs(2),
+ shared.channel_registry.clone(),
+ );
+ let mut camera_rx = watchers::camera::spawn(Duration::from_secs(1));
let mut app_rx = watchers::foreground_app::spawn(Duration::from_secs(1));
let mut accessibility_rx = watchers::accessibility::spawn(Duration::from_millis(1200));
@@ -196,11 +219,22 @@ async fn run(config: Config) {
// Set once the inventory channel closes (the watcher thread died), so the
// select stops polling a permanently-ready closed receiver.
let mut inventory_open = true;
+ let mut camera_open = true;
loop {
tokio::select! {
event = inventory_rx.recv(), if inventory_open => match event {
- Some(watchers::inventory::InventoryEvent::Snapshot(inventories)) => {
- orchestrator.lock().await.refresh_inventory(&inventories);
+ Some(watchers::inventory::InventoryEvent::Snapshot { inventories, standalone }) => {
+ let mut orchestrator = orchestrator.lock().await;
+ // The portable watcher catches long sleeps from a polling
+ // gap. Native macOS notifications also cover short sleeps,
+ // display wakes, and returning user sessions; consume the
+ // coalesced signal at the exact point that can replay it.
+ #[cfg(target_os = "macos")]
+ if resume_pending.swap(false, Ordering::Relaxed) {
+ info!("macOS resume notification — replaying volatile settings");
+ orchestrator.reapply_volatile_on_next_refresh();
+ }
+ orchestrator.refresh_inventory(&inventories, &standalone);
}
Some(watchers::inventory::InventoryEvent::Unavailable) => {
orchestrator.lock().await.mark_inventory_unavailable();
@@ -218,6 +252,13 @@ async fn run(config: Config) {
inventory_open = false;
}
},
+ event = camera_rx.recv(), if camera_open => if let Some(active) = event {
+ orchestrator.lock().await.set_camera_active(active);
+ } else {
+ #[cfg(target_os = "macos")]
+ warn!("camera watcher channel closed — disabling camera automation updates");
+ camera_open = false;
+ },
Some(bundle) = app_rx.recv() => {
orchestrator.lock().await.set_current_app(bundle);
}
@@ -231,8 +272,11 @@ async fn run(config: Config) {
info!("accessibility granted — installing OS mouse hook");
hook = hook_runtime::start(
shared.hook_maps.clone(),
+ shared.keyboard_bindings.clone(),
shared.dpi_cycle.clone(),
shared.capture_channel.clone(),
+ shared.channel_registry.clone(),
+ shared.receiver_access.clone(),
Arc::clone(&event_monitor),
);
hook_installed.store(hook.is_some(), Ordering::Relaxed);
diff --git a/crates/openlogi-agent/src/pairing.rs b/crates/openlogi-agent/src/pairing.rs
index ef365590a4e81637287ef0fcb53e67b1abd923f5..385e6a9ee92d050ee873f71c3971b7cf2b128a4c 100644
--- a/crates/openlogi-agent/src/pairing.rs
+++ b/crates/openlogi-agent/src/pairing.rs
@@ -20,7 +20,7 @@ use std::time::Duration;
use openlogi_agent_core::ipc::{FoundDevice, PairingCommandError, PairingUpdate};
use openlogi_agent_core::orchestrator::SharedRuntime;
-use openlogi_agent_core::receiver_access::PairingReceiverLease;
+use openlogi_agent_core::receiver_access::{ExclusiveAccessReason, ExclusiveReceiverLease};
use openlogi_agent_core::watchers::pairing::{self, Control};
use openlogi_hid::{DiscoveredDevice, PairingEvent, ReceiverSelector};
use tokio::sync::{Mutex, mpsc};
@@ -36,7 +36,7 @@ const RECEIVER_LEASE_TIMEOUT: Duration = Duration::from_secs(5);
/// Address-keyed cache of the full discovered devices, so the GUI can pair by
/// address without round-tripping the non-serializable `DiscoveredDevice`.
type DeviceCache = Arc<StdMutex<HashMap<[u8; 6], DiscoveredDevice>>>;
-type ReceiverLeaseSlot = Arc<StdMutex<Option<PairingReceiverLease>>>;
+type ReceiverLeaseSlot = Arc<StdMutex<Option<ExclusiveReceiverLease>>>;
/// Owns the pairing watcher and translates its event stream for the IPC layer.
pub struct PairingManager {
@@ -96,7 +96,9 @@ impl PairingManager {
}
let Ok(receiver_lease) = tokio::time::timeout(
RECEIVER_LEASE_TIMEOUT,
- self.shared.receiver_access.acquire_for_pairing(),
+ self.shared
+ .receiver_access
+ .acquire_exclusive(ExclusiveAccessReason::Pairing),
)
.await
else {
@@ -159,7 +161,7 @@ impl PairingManager {
fn with_receiver_lease_slot<T>(
receiver_lease: &ReceiverLeaseSlot,
- f: impl FnOnce(&mut Option<PairingReceiverLease>) -> T,
+ f: impl FnOnce(&mut Option<ExclusiveReceiverLease>) -> T,
) -> T {
match receiver_lease.lock() {
Ok(mut slot) => f(&mut slot),
@@ -266,11 +268,18 @@ mod tests {
fn shared_runtime() -> SharedRuntime {
SharedRuntime {
hook_maps: Arc::new(RwLock::new(HookMaps::default())),
+ keyboard_bindings: Arc::new(RwLock::new(std::collections::HashMap::new())),
gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())),
dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())),
thumbwheel_sensitivity: Arc::new(0.into()),
capture_channel: Arc::new(RwLock::new(None)),
+ channel_registry: openlogi_hid::ChannelRegistry::default(),
+ channel_pool: openlogi_hid::ChannelPool::default(),
+ keyboard_spec: Arc::new(RwLock::new(None)),
+ keyboard_channel: Arc::new(RwLock::new(None)),
+ capture_rearm_generation: Arc::new(0.into()),
receiver_access: ReceiverAccess::default(),
+ host_switch_links: Arc::new(RwLock::new(Vec::new())),
}
}
@@ -296,12 +305,12 @@ mod tests {
assert_eq!(result, Err(PairingCommandError::WatcherUnavailable));
assert_eq!(manager.sessions.load(Ordering::Acquire), 0);
- assert!(!manager.shared.receiver_access.pairing_requested());
+ assert!(!manager.shared.receiver_access.exclusive_requested());
assert!(
manager
.shared
.receiver_access
- .try_acquire_for_capture()
+ .try_acquire_for_session()
.is_some()
);
}
@@ -321,11 +330,20 @@ mod tests {
async fn release_receiver_lease_recovers_poisoned_slot() {
let (ctrl_tx, _ctrl_rx) = mpsc::unbounded_channel();
let manager = manager_with_ctrl(ctrl_tx);
- let receiver_lease = manager.shared.receiver_access.acquire_for_pairing().await;
+ let receiver_lease = manager
+ .shared
+ .receiver_access
+ .acquire_exclusive(ExclusiveAccessReason::Pairing)
+ .await;
with_receiver_lease_slot(&manager.receiver_lease, |slot| {
*slot = Some(receiver_lease);
});
- assert!(manager.shared.receiver_access.pairing_requested());
+ assert!(
+ manager
+ .shared
+ .receiver_access
+ .requested(ExclusiveAccessReason::Pairing)
+ );
let slot = Arc::clone(&manager.receiver_lease);
let _ = std::panic::catch_unwind(move || {
@@ -337,12 +355,12 @@ mod tests {
manager.release_receiver_lease();
- assert!(!manager.shared.receiver_access.pairing_requested());
+ assert!(!manager.shared.receiver_access.exclusive_requested());
assert!(
manager
.shared
.receiver_access
- .try_acquire_for_capture()
+ .try_acquire_for_session()
.is_some()
);
}
diff --git a/crates/openlogi-agent/src/server.rs b/crates/openlogi-agent/src/server.rs
index 4e69d75b6732ff2f7e81150a4099daada732a795..dfe793125df5bd089bf16ca2a946223c407b2501 100644
--- a/crates/openlogi-agent/src/server.rs
+++ b/crates/openlogi-agent/src/server.rs
@@ -19,7 +19,8 @@ use openlogi_agent_core::{hardware, transport};
use openlogi_core::config::{Config, Lighting};
use openlogi_core::device::DeviceInventory;
use openlogi_hid::{
- DeviceRoute, DpiInfo, ReceiverSelector, SmartShiftMode, SmartShiftStatus, WriteError,
+ DeviceRoute, DpiInfo, LightCommand, ReceiverSelector, SmartShiftMode, SmartShiftStatus,
+ WriteError,
};
use crate::pairing::PairingManager;
@@ -80,7 +81,14 @@ impl Agent for AgentServer {
}
async fn set_dpi(self, _: Context, route: DeviceRoute, dpi: u32) -> Result<(), WriteError> {
- hardware::apply_dpi(&self.shared.capture_channel, &route, dpi).await
+ hardware::apply_dpi(
+ &self.shared.capture_channel,
+ &self.shared.channel_registry,
+ &self.shared.receiver_access,
+ &route,
+ dpi,
+ )
+ .await
}
async fn set_lighting(
@@ -89,7 +97,14 @@ impl Agent for AgentServer {
route: DeviceRoute,
lighting: Lighting,
) -> Result<(), WriteError> {
- hardware::apply_lighting(&route, &lighting).await
+ hardware::apply_lighting(
+ &self.shared.capture_channel,
+ &self.shared.channel_registry,
+ &self.shared.receiver_access,
+ &route,
+ &lighting,
+ )
+ .await
}
async fn set_smartshift(
@@ -102,6 +117,8 @@ impl Agent for AgentServer {
) -> Result<(), WriteError> {
hardware::apply_smartshift(
&self.shared.capture_channel,
+ &self.shared.channel_registry,
+ &self.shared.receiver_access,
&route,
mode,
auto_disengage,
@@ -111,7 +128,13 @@ impl Agent for AgentServer {
}
async fn read_dpi(self, _: Context, route: DeviceRoute) -> Result<DpiInfo, WriteError> {
- hardware::read_dpi(&route).await
+ hardware::read_dpi(
+ &self.shared.capture_channel,
+ &self.shared.channel_registry,
+ &self.shared.receiver_access,
+ &route,
+ )
+ .await
}
async fn read_smartshift(
@@ -119,7 +142,13 @@ impl Agent for AgentServer {
_: Context,
route: DeviceRoute,
) -> Result<SmartShiftStatus, WriteError> {
- hardware::read_smartshift(&route).await
+ hardware::read_smartshift(
+ &self.shared.capture_channel,
+ &self.shared.channel_registry,
+ &self.shared.receiver_access,
+ &route,
+ )
+ .await
}
async fn request_accessibility_prompt(self, _: Context) {
@@ -147,12 +176,14 @@ impl Agent for AgentServer {
}
async fn snapshot(self, _: Context) -> AgentSnapshot {
- let (launch_at_login, inventory_health, inventory) = {
+ let (launch_at_login, inventory_health, inventory, standalone, camera_active) = {
let orch = self.orchestrator.lock().await;
(
orch.launch_at_login(),
orch.inventory_health(),
orch.inventory(),
+ orch.standalone(),
+ orch.camera_active(),
)
};
AgentSnapshot {
@@ -165,12 +196,43 @@ impl Agent for AgentServer {
agent_version: env!("CARGO_PKG_VERSION").to_string(),
},
inventory,
+ standalone,
+ camera_active,
}
}
async fn poll_event_monitor(self, _: Context) -> Vec<MonitorEvent> {
self.event_monitor.poll()
}
+
+ async fn set_light(
+ self,
+ _: Context,
+ route: DeviceRoute,
+ command: LightCommand,
+ ) -> Result<(), WriteError> {
+ hardware::cancel_light_reapply(&route);
+ hardware::apply_light(&route, command).await
+ }
+
+ async fn set_light_manual_power(
+ self,
+ _: Context,
+ route: DeviceRoute,
+ enabled: bool,
+ ) -> Result<(), WriteError> {
+ hardware::cancel_light_reapply(&route);
+ hardware::apply_light(&route, LightCommand::Power(enabled)).await?;
+ if !self
+ .orchestrator
+ .lock()
+ .await
+ .set_manual_light_power(&route, enabled)
+ {
+ warn!(?route, "manual light power applied without camera override");
+ }
+ Ok(())
+ }
}
/// Bind the agent's IPC socket and serve [`Agent`] requests until the process
diff --git a/crates/openlogi-agent/src/tray.rs b/crates/openlogi-agent/src/tray.rs
index 61395cdb6aca95610bb133af84b21a6882c20731..c24ecb643532b321f60df4e1708225498399d1c8 100644
--- a/crates/openlogi-agent/src/tray.rs
+++ b/crates/openlogi-agent/src/tray.rs
@@ -1,4 +1,4 @@
-//! The agent's menu-bar status item.
+//! The agent's macOS AppKit loop, menu-bar item, and resume notifications.
//!
//! The always-on agent hosts the menu bar (the GUI is on-demand). The item
//! carries GUI-directed actions ("Show Main Window", Settings, About, Check for
@@ -15,19 +15,56 @@
#![expect(
unsafe_code,
- reason = "objc2 calls: super-init, init-with-action/set-target — localized here and in status_item"
+ reason = "objc2 calls: super-init, action targets, and selector-based workspace notifications"
)]
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, NSObject};
-use objc2::{MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
-use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSImage, NSRunningApplication};
-use objc2_foundation::NSString;
+use objc2::{
+ AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel,
+};
+use objc2_app_kit::{
+ NSApplication, NSApplicationActivationPolicy, NSImage, NSRunningApplication, NSWorkspace,
+ NSWorkspaceDidWakeNotification, NSWorkspaceScreensDidWakeNotification,
+ NSWorkspaceSessionDidBecomeActiveNotification,
+};
+use objc2_foundation::{NSNotification, NSNotificationName, NSString};
use openlogi_core::brand::DeeplinkCommand;
use tracing::{info, warn};
use crate::status_item;
+struct ResumeTargetIvars {
+ pending: Arc<AtomicBool>,
+}
+
+define_class!(
+ // SAFETY: NSObject has no subclassing requirements, and `ResumeTarget`
+ // does not implement `Drop`.
+ #[unsafe(super(NSObject))]
+ #[ivars = ResumeTargetIvars]
+ #[name = "OpenLogiAgentWorkspaceResumeTarget"]
+ struct ResumeTarget;
+
+ impl ResumeTarget {
+ #[unsafe(method(workspaceDidResume:))]
+ fn workspace_did_resume(&self, _notification: &NSNotification) {
+ self.ivars().pending.store(true, Ordering::Relaxed);
+ }
+ }
+);
+
+impl ResumeTarget {
+ fn new(pending: Arc<AtomicBool>) -> Retained<Self> {
+ let this = Self::alloc().set_ivars(ResumeTargetIvars { pending });
+ // SAFETY: `init` initializes our freshly allocated NSObject subclass.
+ unsafe { msg_send![super(this), init] }
+ }
+}
+
define_class!(
// SAFETY: NSObject has no subclassing requirements, and `MenuTarget` does
// not implement `Drop`.
@@ -121,7 +158,8 @@ fn gui_is_running() -> bool {
/// tokio core still does all the work). The toggle takes effect on the agent's
/// next launch — a no-restart live toggle would need a main-thread hop from the
/// IPC reload path (deferred; it can't be verified headlessly).
-pub fn run_app_loop(show_in_menu_bar: bool) -> ! {
+/// `resume_pending` forwards coalesced workspace resume notifications to that core.
+pub fn run_app_loop(show_in_menu_bar: bool, resume_pending: Arc<AtomicBool>) -> ! {
let Some(mtm) = MainThreadMarker::new() else {
warn!("agent AppKit loop not started off the main thread — exiting");
std::process::exit(1);
@@ -132,12 +170,45 @@ pub fn run_app_loop(show_in_menu_bar: bool) -> ! {
// Bind the status item (+ its target/menu) so they outlive `run()` — the
// menu items only weakly reference the target. `None` when hidden.
let _tray = show_in_menu_bar.then(|| install_status_item(mtm));
+ let _resume_target = install_resume_observer(resume_pending);
info!(show_in_menu_bar, "agent AppKit loop started");
app.run();
std::process::exit(0);
}
+/// Observe native resume transitions that the inventory polling-gap heuristic
+/// cannot see. The returned target must live for the AppKit loop's lifetime.
+fn install_resume_observer(pending: Arc<AtomicBool>) -> Retained<ResumeTarget> {
+ let target = ResumeTarget::new(pending);
+ let workspace = NSWorkspace::sharedWorkspace();
+ let center = workspace.notificationCenter();
+ for name in resume_notification_names() {
+ // SAFETY: `ResumeTarget` implements `workspaceDidResume:` with the
+ // exact one-NSNotification argument signature, and the caller retains
+ // the target for the AppKit loop's lifetime.
+ unsafe {
+ center.addObserver_selector_name_object(
+ &target,
+ sel!(workspaceDidResume:),
+ Some(name),
+ Some(&workspace),
+ );
+ }
+ }
+ target
+}
+
+fn resume_notification_names() -> [&'static NSNotificationName; 3] {
+ // SAFETY: AppKit exports each name as an immutable process-lifetime constant.
+ let system_wake = unsafe { NSWorkspaceDidWakeNotification };
+ // SAFETY: AppKit exports each name as an immutable process-lifetime constant.
+ let screen_wake = unsafe { NSWorkspaceScreensDidWakeNotification };
+ // SAFETY: AppKit exports each name as an immutable process-lifetime constant.
+ let session_active = unsafe { NSWorkspaceSessionDidBecomeActiveNotification };
+ [system_wake, screen_wake, session_active]
+}
+
/// Build and install the menu-bar status item, returning the objects that must
/// stay alive for the app's lifetime (the status item, the action target the
/// menu items weakly reference, and the menu itself).
@@ -193,3 +264,32 @@ fn install_status_item(
info!("menu-bar item installed");
(status_item, target, menu)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn resume_notifications_are_forwarded_and_coalesced() {
+ let pending = Arc::new(AtomicBool::new(false));
+ let target = install_resume_observer(Arc::clone(&pending));
+ let workspace = NSWorkspace::sharedWorkspace();
+ let center = workspace.notificationCenter();
+
+ for name in resume_notification_names() {
+ // SAFETY: `workspace` is live, matches the registration filter,
+ // and notification delivery completes synchronously.
+ unsafe { center.postNotificationName_object(name, Some(&workspace)) };
+ assert!(pending.swap(false, Ordering::Relaxed));
+ }
+ for name in resume_notification_names() {
+ // SAFETY: Same live object and synchronous delivery as above.
+ unsafe { center.postNotificationName_object(name, Some(&workspace)) };
+ }
+ assert!(pending.swap(false, Ordering::Relaxed));
+ assert!(!pending.swap(false, Ordering::Relaxed));
+
+ // SAFETY: This is the same live target registered with `center` above.
+ unsafe { center.removeObserver(&target) };
+ }
+}
diff --git a/crates/openlogi-assets/src/index.rs b/crates/openlogi-assets/src/index.rs
index 06d610bce4e2973201289af521eb54e48f7e0f8e..662a42e303e465e2ce1f299586c4d168e4c03f3a 100644
--- a/crates/openlogi-assets/src/index.rs
+++ b/crates/openlogi-assets/src/index.rs
@@ -71,11 +71,18 @@ pub struct FileEntry {
/// Filename schemas Logi ships, most-preferred first. Newer depots use the
/// `*_core` names; older ones — most keyboards, the MX Vertical, older mice —
-/// ship the bare names. A depot commits to one schema, never a mix, so
-/// resolving each slot to the first name the registry actually lists picks
-/// the right one. The manifest then maps `device_image` /
-/// `device_buttons_image` to the concrete render for colour variants.
-pub const METADATA_FILES: [&str; 2] = ["core_metadata.json", "metadata.json"];
+/// ship the bare names. Render schemas never mix within a depot, so resolving
+/// each slot to the first name the registry actually lists picks the right
+/// one. The manifest then maps `device_image` / `device_buttons_image` to the
+/// concrete render for colour variants.
+///
+/// Metadata is the one slot where legacy keyboard depots *do* ship two files:
+/// the G513 family's `metadata.json` is authored against the G512 banner
+/// renders, while `metadata_full.json` matches the `front.png` the primary
+/// model actually fetches (the manifest's `image_metadata` for `g513` names
+/// it). Preferring `metadata_full.json` keeps the key markers on the render
+/// we display.
+pub const METADATA_FILES: [&str; 3] = ["core_metadata.json", "metadata_full.json", "metadata.json"];
pub const FRONT_RENDER_FILES: [&str; 2] = ["front_core.png", "front.png"];
pub const BUTTONS_RENDER_FILES: [&str; 2] = ["side_core.png", "side.png"];
@@ -242,6 +249,33 @@ mod tests {
);
}
+ #[test]
+ fn illumination_light_entries_use_the_same_bundle_baseline() {
+ let mut e = entry("8c900", "Litra Glow");
+ e.kind = "ILLUMINATION_LIGHT".into();
+ e.files = vec![
+ FileEntry {
+ name: "front.png".into(),
+ sha256: "front".into(),
+ bytes: 1,
+ },
+ FileEntry {
+ name: "manifest.json".into(),
+ sha256: "manifest".into(),
+ bytes: 1,
+ },
+ FileEntry {
+ name: "metadata.json".into(),
+ sha256: "metadata".into(),
+ bytes: 1,
+ },
+ ];
+ assert_eq!(
+ e.baseline_files(),
+ vec!["metadata.json", "manifest.json", "front.png"]
+ );
+ }
+
#[test]
fn find_by_model_id_suffix_matches_secondary_id() {
// The BTLE MX Master 3S reports bolt pid `b034`; listing it next to
diff --git a/crates/openlogi-assets/src/metadata.rs b/crates/openlogi-assets/src/metadata.rs
index 9379b6bf6c625d087685f8bd5a118d463e1451c9..7f0494c877837c23912e17cbf1fe01ac0d3df9b5 100644
--- a/crates/openlogi-assets/src/metadata.rs
+++ b/crates/openlogi-assets/src/metadata.rs
@@ -75,12 +75,16 @@ pub struct Assignment {
/// `map_slot_name`-style consumers treat unknown names as "no hotspot".
#[serde(rename = "slotName", default)]
pub slot_name: String,
+ /// Camera depots ship marker-less settings-slot assignments (under the
+ /// `device_camera_image` entry, which no hotspot consumer reads); a
+ /// missing marker defaults to the origin rather than failing the file.
+ #[serde(default)]
pub marker: Point,
#[serde(default)]
pub label: Direction,
}
-#[derive(Debug, Deserialize, Clone, Copy)]
+#[derive(Debug, Deserialize, Clone, Copy, Default)]
pub struct Point {
pub x: f32,
pub y: f32,
@@ -147,4 +151,30 @@ mod tests {
assert_eq!((origin.width, origin.height), (3598, 1315));
assert_eq!(meta.images[0].assignments[0].slot_name, "");
}
+
+ /// Camera depots (StreamCam) list settings-slot assignments with no
+ /// `marker` under their `device_camera_image` entry. Parsing must not
+ /// fail wholesale, and `assignments()` must not surface them (it reads
+ /// only the `device_buttons_image` entry).
+ #[test]
+ fn camera_metadata_without_markers_parses() {
+ let json = r#"{
+ "images": [
+ { "key": "device_image", "origin": { "width": 1280, "height": 800 } },
+ {
+ "key": "device_camera_image",
+ "origin": { "width": 396, "height": 396 },
+ "assignments": [
+ { "slotId": "streamcam-0893_webcam_camera_settings",
+ "slotName": "SLOT_NAME_WEBCAM_CAMERA_SETTINGS",
+ "disableAssignmentClick": true }
+ ]
+ }
+ ]
+ }"#;
+ let meta: Metadata = serde_json::from_str(json).expect("camera schema must parse");
+ let origin = meta.origin().expect("origin survives");
+ assert_eq!((origin.width, origin.height), (1280, 800));
+ assert_eq!(meta.assignments().count(), 0);
+ }
}
diff --git a/crates/openlogi-camera/Cargo.toml b/crates/openlogi-camera/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..def6e0633c8cd19b9e380dc95bd6cf8c2665e63a
--- /dev/null
+++ b/crates/openlogi-camera/Cargo.toml
@@ -0,0 +1,58 @@
+[package]
+name = "openlogi-camera"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+authors.workspace = true
+description = "Generic Logitech UVC webcam discovery for OpenLogi (AVFoundation on macOS, DirectShow on Windows, V4L2 on Linux)."
+keywords = ["logitech", "uvc", "webcam", "camera", "v4l2"]
+categories = ["hardware-support"]
+
+[dependencies]
+serde = { workspace = true }
+tracing = { workspace = true }
+
+# AVFoundation (AVCaptureDevice enumeration + AVCaptureSession capture) on the
+# same objc2 toolchain as the rest of the workspace's ObjC FFI (GUI menu bar /
+# permissions, the hook's NSWorkspace read): leak-proof `Retained<T>` ownership,
+# `define_class!` for the frame delegate (matching the agent tray target), and
+# `block2` for the `requestAccess…` completion block.
+[target.'cfg(target_os = "macos")'.dependencies]
+objc2 = { workspace = true }
+block2 = "0.6"
+
+# DirectShow enumeration + IAMVideoProcAmp / IAMCameraControl UVC controls.
+[target.'cfg(target_os = "windows")'.dependencies]
+windows = { version = "0.61", features = [
+ "Win32_Foundation",
+ "Win32_Media_DirectShow",
+ "Win32_Media_MediaFoundation",
+ "Win32_System_Com",
+ "Win32_System_Com_StructuredStorage",
+ "Win32_System_Variant",
+ "Win32_System_Ole",
+] }
+
+# V4L2 enumeration, UVC controls and mmap capture. `v4l` wraps every ioctl in a
+# safe API, so the Linux backend needs no `unsafe` of its own. MJPEG frames are
+# decoded by `zune-jpeg`, which already ships in the tree (via `image`) and can
+# emit BGRA directly — gpui's texture order, so the preview needs no swap.
+[target.'cfg(target_os = "linux")'.dependencies]
+v4l = "0.14"
+zune-jpeg = "0.5"
+zune-core = "0.5"
+
+# Mirrors [workspace.lints]. unsafe_code stays denied; the AVFoundation modules
+# opt in locally with #[expect(unsafe_code)]. (The `unexpected_cfgs` allow the
+# old objc 0.2 macros needed is gone — objc2 doesn't emit `cfg(cargo-clippy)`.)
+[lints.rust]
+unsafe_code = "deny"
+
+[lints.clippy]
+pedantic = { level = "warn", priority = -1 }
+unwrap_used = "warn"
+expect_used = "warn"
+missing_errors_doc = "allow"
+doc_markdown = "allow"
diff --git a/crates/openlogi-camera/src/capture.rs b/crates/openlogi-camera/src/capture.rs
new file mode 100644
index 0000000000000000000000000000000000000000..07a1fe2ae14e7c4b8d0e3e99df4939015cd4ca1d
--- /dev/null
+++ b/crates/openlogi-camera/src/capture.rs
@@ -0,0 +1,468 @@
+//! AVFoundation camera capture: a one-shot snapshot and a live frame stream.
+//!
+//! Both open an `AVCaptureSession` on the chosen camera and read BGRA frames
+//! through an `AVCaptureVideoDataOutput` delegate. Frames are kept in gpui's
+//! native **BGRA** order so the preview uploads them with no channel swap; the
+//! snapshot path swaps to RGBA once when it encodes the PNG. Capturing (unlike
+//! enumeration) needs Camera permission *and* an app bundle carrying
+//! `NSCameraUsageDescription`; from an unbundled binary macOS denies access,
+//! which surfaces as [`CaptureError::AccessDenied`].
+//!
+//! FFI is `objc2` (matching the rest of the workspace's ObjC surface). The
+//! `AVFoundation` classes aren't in a typed framework crate, so this uses the
+//! `objc2` runtime (`class!` + `msg_send!`) with **`Retained<T>` for every
+//! retained object** (session / output / delegate), so ownership is leak-proof
+//! by construction rather than hand-balanced `retain`/`release`. The frame
+//! delegate is an `NSObject` subclass declared with [`define_class!`] (the same
+//! macro the agent's tray target uses); it is stateless and driven on a
+//! background dispatch queue, so it inherits `NSObject`'s any-thread kind and
+//! needs no main-thread affinity (no `MainThreadMarker`) — AVFoundation's
+//! session start/stop and its sample-buffer callback are all off-main.
+
+#![expect(
+ unsafe_code,
+ reason = "AVFoundation / CoreMedia / CoreVideo capture FFI"
+)]
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_sign_loss,
+ clippy::cast_possible_wrap,
+ reason = "pixel dimensions and FourCC constants are bounded and copied verbatim"
+)]
+
+use std::ffi::{CString, c_void};
+use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
+use std::sync::{Arc, Mutex, OnceLock};
+use std::time::{Duration, Instant};
+
+use block2::RcBlock;
+use objc2::rc::Retained;
+use objc2::runtime::{AnyObject, Bool, NSObject};
+use objc2::{AnyThread, class, define_class, msg_send};
+
+pub use crate::capture_types::{CaptureError, Frame};
+
+/// kCVPixelFormatType_32BGRA ('BGRA').
+const PIXEL_FORMAT_32BGRA: u32 = 0x4247_5241;
+/// kCVPixelBufferLock_ReadOnly.
+const LOCK_READ_ONLY: u64 = 1;
+
+// The most recent frame the delegate decoded, behind an `Arc` so the preview's
+// poll hands out a cheap refcount bump instead of copying the whole buffer. A
+// process previews one camera at a time, so a single global sink is enough and
+// keeps the delegate stateless.
+static LATEST: OnceLock<Mutex<Option<Arc<Frame>>>> = OnceLock::new();
+fn latest() -> &'static Mutex<Option<Arc<Frame>>> {
+ LATEST.get_or_init(|| Mutex::new(None))
+}
+
+/// Increments on every delivered frame, so a poller can tell a new frame from a
+/// repeat without comparing pixel buffers.
+static FRAME_GEN: AtomicU64 = AtomicU64::new(0);
+
+/// Target max width for delegate downsampling (0 = full resolution). Previews
+/// set this so an oversized buffer decimates down in one strided pass instead
+/// of copying (and uploading) pixels the preview can never show.
+static PREVIEW_TARGET_W: AtomicU32 = AtomicU32::new(0);
+
+#[link(name = "AVFoundation", kind = "framework")]
+unsafe extern "C" {
+ static AVMediaTypeVideo: *const AnyObject;
+ static AVCaptureSessionPreset1280x720: *const AnyObject;
+}
+
+#[link(name = "CoreMedia", kind = "framework")]
+unsafe extern "C" {
+ fn CMSampleBufferGetImageBuffer(sbuf: *mut AnyObject) -> *mut AnyObject;
+}
+
+#[link(name = "CoreVideo", kind = "framework")]
+unsafe extern "C" {
+ static kCVPixelBufferPixelFormatTypeKey: *const AnyObject;
+ fn CVPixelBufferLockBaseAddress(pb: *mut AnyObject, flags: u64) -> i32;
+ fn CVPixelBufferUnlockBaseAddress(pb: *mut AnyObject, flags: u64) -> i32;
+ fn CVPixelBufferGetBaseAddress(pb: *mut AnyObject) -> *mut c_void;
+ fn CVPixelBufferGetBytesPerRow(pb: *mut AnyObject) -> usize;
+ fn CVPixelBufferGetWidth(pb: *mut AnyObject) -> usize;
+ fn CVPixelBufferGetHeight(pb: *mut AnyObject) -> usize;
+}
+
+#[link(name = "CoreFoundation", kind = "framework")]
+unsafe extern "C" {
+ static kCFRunLoopDefaultMode: *const c_void;
+ // The last parameter is a CoreFoundation `Boolean` (unsigned char); `0` is
+ // false. Kept as `u8` rather than an objc `BOOL` to match the C type exactly.
+ fn CFRunLoopRunInMode(
+ mode: *const c_void,
+ seconds: f64,
+ return_after_source_handled: u8,
+ ) -> i32;
+}
+
+unsafe extern "C" {
+ fn dispatch_queue_create(label: *const i8, attr: *const c_void) -> *mut AnyObject;
+}
+
+define_class!(
+ // SAFETY: NSObject has no subclassing requirements, and `FrameDelegate` does
+ // not implement `Drop`. It carries no ivars and its one method only touches
+ // process-global state, so it is safe to use from the background dispatch
+ // queue AVFoundation drives it on (default any-thread kind, no `thread_kind`).
+ #[unsafe(super(NSObject))]
+ #[name = "OLCameraFrameDelegate"]
+ struct FrameDelegate;
+
+ impl FrameDelegate {
+ /// `captureOutput:didOutputSampleBuffer:fromConnection:` — copies the
+ /// sample buffer's BGRA pixels (optionally decimated) into [`latest`].
+ #[unsafe(method(captureOutput:didOutputSampleBuffer:fromConnection:))]
+ fn did_output(
+ &self,
+ _output: *mut AnyObject,
+ sbuf: *mut AnyObject,
+ _conn: *mut AnyObject,
+ ) {
+ // SAFETY: `sbuf` is a valid CMSampleBuffer delivered by AVFoundation;
+ // the image buffer is locked for the read and unlocked before return.
+ unsafe {
+ let pb = CMSampleBufferGetImageBuffer(sbuf);
+ if pb.is_null() || CVPixelBufferLockBaseAddress(pb, LOCK_READ_ONLY) != 0 {
+ return;
+ }
+ let base = CVPixelBufferGetBaseAddress(pb).cast::<u8>();
+ let bytes_per_row = CVPixelBufferGetBytesPerRow(pb);
+ let width = CVPixelBufferGetWidth(pb);
+ let height = CVPixelBufferGetHeight(pb);
+ let target = PREVIEW_TARGET_W.load(Ordering::Relaxed) as usize;
+ let step = if target > 0 && width > target {
+ width.div_ceil(target)
+ } else {
+ 1
+ };
+ let out_w = width / step;
+ let out_h = height / step;
+ if !base.is_null() && out_w > 0 && out_h > 0 {
+ let mut bgra = vec![0u8; out_w * out_h * 4];
+ let dst = bgra.as_mut_ptr();
+ if step == 1 {
+ // Source is already BGRA (kCVPixelFormatType_32BGRA) — one
+ // memcpy per row, skipping any driver row padding.
+ for oy in 0..out_h {
+ let row = base.add(oy * bytes_per_row);
+ std::ptr::copy_nonoverlapping(row, dst.add(oy * out_w * 4), out_w * 4);
+ }
+ } else {
+ for oy in 0..out_h {
+ let row = base.add(oy * step * bytes_per_row);
+ for ox in 0..out_w {
+ let src = row.add(ox * step * 4);
+ let out = (oy * out_w + ox) * 4;
+ std::ptr::copy_nonoverlapping(src, dst.add(out), 4);
+ }
+ }
+ }
+ if let Ok(mut slot) = latest().lock() {
+ *slot = Some(Arc::new(Frame {
+ width: out_w as u32,
+ height: out_h as u32,
+ bgra,
+ }));
+ FRAME_GEN.fetch_add(1, Ordering::Relaxed);
+ }
+ }
+ CVPixelBufferUnlockBaseAddress(pb, LOCK_READ_ONLY);
+ }
+ }
+ }
+);
+
+impl FrameDelegate {
+ fn new() -> Retained<Self> {
+ let this = Self::alloc().set_ivars(());
+ // SAFETY: `init` initializes our freshly-allocated NSObject subclass and
+ // returns it (the two-phase construction objc2's `define_class!` uses).
+ unsafe { msg_send![super(this), init] }
+ }
+}
+
+/// Current Camera authorization: `Some(true)` usable, `Some(false)` denied,
+/// `None` undetermined (caller should request access).
+fn authorization() -> Option<bool> {
+ let cls = class!(AVCaptureDevice);
+ // SAFETY: documented class method returning an AVAuthorizationStatus NSInteger;
+ // `AVMediaTypeVideo` is AVFoundation's exported `NSString` constant.
+ let status: isize =
+ unsafe { msg_send![cls, authorizationStatusForMediaType: AVMediaTypeVideo] };
+ match status {
+ 3 => Some(true),
+ 1 | 2 => Some(false),
+ _ => None,
+ }
+}
+
+/// Request Camera access and block until the user answers (or `timeout`).
+fn request_access(timeout: Duration) -> bool {
+ let answered = std::sync::Arc::new(Mutex::new(None::<bool>));
+ let sink = answered.clone();
+ // `void(^)(BOOL)` completion block. `RcBlock` is heap-allocated and
+ // reference-counted, so it outlives the async call below on its own.
+ let handler = RcBlock::new(move |granted: Bool| {
+ if let Ok(mut slot) = sink.lock() {
+ *slot = Some(granted.as_bool());
+ }
+ });
+ let cls = class!(AVCaptureDevice);
+ // SAFETY: documented async class method taking an AVMediaType + a
+ // `void(^)(BOOL)` completion block; the block outlives the call.
+ unsafe {
+ let _: () = msg_send![
+ cls,
+ requestAccessForMediaType: AVMediaTypeVideo,
+ completionHandler: &*handler
+ ];
+ }
+ let deadline = Instant::now() + timeout;
+ loop {
+ if let Ok(slot) = answered.lock()
+ && let Some(granted) = *slot
+ {
+ return granted;
+ }
+ if Instant::now() >= deadline {
+ return false;
+ }
+ run_loop_tick(0.05);
+ }
+}
+
+/// Ensure the process may use the camera, requesting access if undetermined.
+fn ensure_access() -> Result<(), CaptureError> {
+ match authorization() {
+ Some(true) => Ok(()),
+ None if request_access(Duration::from_secs(30)) => Ok(()),
+ _ => Err(CaptureError::AccessDenied),
+ }
+}
+
+/// Whether the process currently holds Camera permission, without prompting.
+/// Lets the GUI start a preview only when access is already granted (so it never
+/// blocks the UI thread on the permission dialog).
+#[must_use]
+pub fn camera_access_granted() -> bool {
+ matches!(authorization(), Some(true))
+}
+
+/// Current Camera permission as a tri-state, without prompting. Lets the
+/// Settings window distinguish Denied from not-yet-asked.
+#[must_use]
+pub fn camera_authorization() -> crate::CameraAuthorization {
+ match authorization() {
+ Some(true) => crate::CameraAuthorization::Granted,
+ Some(false) => crate::CameraAuthorization::Denied,
+ None => crate::CameraAuthorization::Undetermined,
+ }
+}
+
+/// Pump the current thread's run loop briefly so AVFoundation callbacks fire.
+fn run_loop_tick(seconds: f64) {
+ // SAFETY: `kCFRunLoopDefaultMode` is a valid mode constant; the call returns
+ // after `seconds` or the first handled source (`0` = don't return early).
+ unsafe {
+ CFRunLoopRunInMode(kCFRunLoopDefaultMode, seconds, 0);
+ }
+}
+
+fn device_with_unique_id(unique_id: &str) -> Option<Retained<AnyObject>> {
+ let cls = class!(AVCaptureDevice);
+ let Ok(ns) = CString::new(unique_id) else {
+ return None;
+ };
+ // SAFETY: building an autoreleased NSString from a valid C string, then a
+ // `deviceWithUniqueID:` lookup; the autoreleased (+0) result is retained into
+ // an owned `Retained` (`None` when the lookup returns nil).
+ unsafe {
+ let nsstr: *mut AnyObject = msg_send![class!(NSString), stringWithUTF8String: ns.as_ptr()];
+ let device: *mut AnyObject = msg_send![cls, deviceWithUniqueID: nsstr];
+ Retained::retain(device)
+ }
+}
+
+/// A running capture session. Frames flow to the delegate on a background
+/// dispatch queue and land in [`latest`]; dropping the session stops it. The
+/// `Retained` fields keep the output + delegate alive for the session's life
+/// (the session references them, but we hold owning handles for clarity).
+struct Session {
+ handle: Retained<AnyObject>,
+ _output: Retained<AnyObject>,
+ _delegate: Retained<FrameDelegate>,
+}
+
+impl Drop for Session {
+ fn drop(&mut self) {
+ // SAFETY: `self.handle` is a valid, retained AVCaptureSession.
+ unsafe {
+ let _: () = msg_send![&*self.handle, stopRunning];
+ }
+ }
+}
+
+/// Authorize, wire up, and start a capture session on `unique_id`. Frames begin
+/// arriving in [`latest`] shortly after this returns.
+fn open_session(unique_id: &str, low_res: bool) -> Result<Session, CaptureError> {
+ ensure_access()?;
+ let device = device_with_unique_id(unique_id).ok_or(CaptureError::NotFound)?;
+ if let Ok(mut slot) = latest().lock() {
+ *slot = None;
+ }
+ // Previews cap at 720p-wide frames (the preview preset below already
+ // delivers exactly that; the decimator only kicks in if a camera ignores
+ // the preset and streams wider). Snapshots keep full resolution.
+ PREVIEW_TARGET_W.store(if low_res { 1280 } else { 0 }, Ordering::Relaxed);
+
+ // SAFETY: standard AVCaptureSession wiring with documented selectors; every
+ // object added is retained by the session, and the session is stopped on Drop.
+ unsafe {
+ let session: *mut AnyObject = msg_send![class!(AVCaptureSession), new];
+ let Some(session) = Retained::from_raw(session) else {
+ return Err(CaptureError::Setup("AVCaptureSession".into()));
+ };
+
+ let mut err: *mut AnyObject = std::ptr::null_mut();
+ let input: *mut AnyObject = msg_send![
+ class!(AVCaptureDeviceInput),
+ deviceInputWithDevice: &*device,
+ error: &mut err
+ ];
+ if input.is_null() {
+ return Err(CaptureError::Setup("AVCaptureDeviceInput".into()));
+ }
+ let can_in: bool = msg_send![&*session, canAddInput: input];
+ if !can_in {
+ return Err(CaptureError::Setup("session rejected input".into()));
+ }
+ let _: () = msg_send![&*session, addInput: input];
+
+ // Preview streams capture at 720p — sharp on a Retina-scale preview
+ // (the 480pt box is 960 physical pixels wide) while still a fraction
+ // of the native 1080p per-frame copy + texture upload.
+ if low_res {
+ let can: bool =
+ msg_send![&*session, canSetSessionPreset: AVCaptureSessionPreset1280x720];
+ if can {
+ let _: () = msg_send![&*session, setSessionPreset: AVCaptureSessionPreset1280x720];
+ }
+ }
+
+ let output: *mut AnyObject = msg_send![class!(AVCaptureVideoDataOutput), new];
+ let Some(output) = Retained::from_raw(output) else {
+ return Err(CaptureError::Setup("AVCaptureVideoDataOutput".into()));
+ };
+ let num: *mut AnyObject =
+ msg_send![class!(NSNumber), numberWithUnsignedInt: PIXEL_FORMAT_32BGRA];
+ let settings: *mut AnyObject = msg_send![
+ class!(NSDictionary),
+ dictionaryWithObject: num,
+ forKey: kCVPixelBufferPixelFormatTypeKey
+ ];
+ let _: () = msg_send![&*output, setVideoSettings: settings];
+ let _: () = msg_send![&*output, setAlwaysDiscardsLateVideoFrames: true];
+
+ let delegate = FrameDelegate::new();
+ let queue = dispatch_queue_create(c"org.openlogi.camera".as_ptr(), std::ptr::null());
+ let _: () = msg_send![&*output, setSampleBufferDelegate: &*delegate, queue: queue];
+
+ let can_out: bool = msg_send![&*session, canAddOutput: &*output];
+ if !can_out {
+ return Err(CaptureError::Setup("session rejected output".into()));
+ }
+ let _: () = msg_send![&*session, addOutput: &*output];
+
+ // Selfie-mirror the live preview (not snapshots): a webcam self-view is
+ // expected to read like a mirror. The driver flips on the connection, so
+ // it costs zero per-frame CPU and never alters the outbound camera feed.
+ if low_res {
+ let conn: *mut AnyObject =
+ msg_send![&*output, connectionWithMediaType: AVMediaTypeVideo];
+ if !conn.is_null() {
+ let supported: bool = msg_send![conn, isVideoMirroringSupported];
+ if supported {
+ let _: () = msg_send![conn, setAutomaticallyAdjustsVideoMirroring: false];
+ let _: () = msg_send![conn, setVideoMirrored: true];
+ }
+ }
+ }
+
+ let _: () = msg_send![&*session, startRunning];
+
+ Ok(Session {
+ handle: session,
+ _output: output,
+ _delegate: delegate,
+ })
+ }
+}
+
+/// Capture a single full-resolution [`Frame`] (BGRA) from the camera with
+/// `unique_id`.
+///
+/// # Errors
+/// [`CaptureError::AccessDenied`] when Camera permission isn't (and can't be)
+/// granted, [`CaptureError::NotFound`] for an unknown id, [`CaptureError::Timeout`]
+/// when no frame arrives, or [`CaptureError::Setup`] on AVFoundation errors.
+pub fn capture_frame(unique_id: &str, timeout: Duration) -> Result<Frame, CaptureError> {
+ let _session = open_session(unique_id, false)?;
+ let deadline = Instant::now() + timeout;
+ loop {
+ if let Ok(mut slot) = latest().lock()
+ && let Some(frame) = slot.take()
+ {
+ return Ok(Arc::unwrap_or_clone(frame));
+ }
+ if Instant::now() >= deadline {
+ return Err(CaptureError::Timeout);
+ }
+ run_loop_tick(0.03);
+ }
+}
+
+/// A live preview stream. Holds the session open; [`CameraStream::latest_frame`]
+/// returns the most recent frame each time it's polled. Dropping it stops the
+/// camera.
+pub struct CameraStream {
+ _session: Session,
+}
+
+impl CameraStream {
+ /// The most recently delivered frame, or `None` before the first arrives.
+ /// Returns a shared [`Arc`] so polling at preview rate never copies the
+ /// pixel buffer.
+ #[must_use]
+ pub fn latest_frame(&self) -> Option<Arc<Frame>> {
+ latest().lock().ok().and_then(|slot| slot.clone())
+ }
+
+ /// Take the most recent frame out of the slot (the next delivered frame
+ /// refills it). A sole consumer that unwraps the [`Arc`] gets the pixel
+ /// buffer without copying it.
+ #[must_use]
+ pub fn take_frame(&self) -> Option<Arc<Frame>> {
+ latest().lock().ok().and_then(|mut slot| slot.take())
+ }
+
+ /// A counter that increments on every delivered frame, so the preview can
+ /// skip rebuilding its texture when no new frame has arrived.
+ #[must_use]
+ pub fn frame_generation(&self) -> u64 {
+ FRAME_GEN.load(Ordering::Relaxed)
+ }
+}
+
+/// Start a live capture stream on the camera with `unique_id`.
+///
+/// # Errors
+/// Same as [`capture_frame`], minus `Timeout` (frames are polled, not awaited).
+pub fn start_stream(unique_id: &str) -> Result<CameraStream, CaptureError> {
+ Ok(CameraStream {
+ _session: open_session(unique_id, true)?,
+ })
+}
diff --git a/crates/openlogi-camera/src/capture_linux.rs b/crates/openlogi-camera/src/capture_linux.rs
new file mode 100644
index 0000000000000000000000000000000000000000..fa854c9eaffc3c27287dd8b043441f8f018b7bff
--- /dev/null
+++ b/crates/openlogi-camera/src/capture_linux.rs
@@ -0,0 +1,534 @@
+//! V4L2 camera capture on Linux: a one-shot snapshot and a live frame stream.
+//!
+//! Buffers are mmap'd from the kernel and decoded to **BGRA**, gpui's native
+//! texture order, so the preview uploads them without a channel swap.
+//!
+//! Format choice prefers **MJPEG**: at 720p a YUYV stream is ~27 MB/s over USB
+//! and starves other bandwidth on the same controller, while MJPEG is a tenth
+//! of that, and `zune-jpeg` decodes it straight to BGRA in one pass. YUYV is
+//! the fallback for the few cameras that don't offer MJPEG.
+//!
+//! Resolution follows the session's [`Quality`]: the live preview streams 720p,
+//! while a snapshot takes the camera's largest mode. Note this only sizes
+//! *OpenLogi's own* stream — resolution is negotiated per handle, so it is not
+//! a device setting other applications observe, unlike the UVC controls in
+//! `uvc_linux`.
+//!
+//! Unlike macOS, Linux has no per-app camera consent model — access is decided
+//! by filesystem permission on `/dev/video*` (the `video` group). So
+//! [`camera_authorization`] reports `Granted`/`Denied` by probing whether the
+//! node actually opens, and never `Undetermined`: there is nothing to prompt.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_sign_loss,
+ clippy::cast_possible_wrap,
+ reason = "pixel arithmetic is bounded by the negotiated frame size"
+)]
+
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+use std::time::{Duration, Instant};
+
+use v4l::buffer::Type;
+use v4l::io::mmap::Stream as MmapStream;
+use v4l::io::traits::{CaptureStream, Stream as StreamTrait};
+use v4l::video::Capture;
+use v4l::{Device, Format, FourCC};
+use zune_core::bytestream::ZCursor;
+use zune_core::colorspace::ColorSpace;
+use zune_core::options::DecoderOptions;
+use zune_jpeg::JpegDecoder;
+
+pub use crate::capture_types::{CaptureError, Frame};
+use crate::{CameraAuthorization, linux};
+
+/// Preview target. The driver picks the nearest size it supports, so this is a
+/// request rather than a guarantee — the negotiated format is read back.
+const PREVIEW_WIDTH: u32 = 1280;
+const PREVIEW_HEIGHT: u32 = 720;
+
+/// Size requested for a native-resolution session when the driver reports only
+/// stepwise or continuous frame sizes, with no discrete list to pick a maximum
+/// from. `VIDIOC_S_FMT` clamps a request to what the device supports, so asking
+/// for more than any current sensor offers resolves to its largest mode.
+const OVERSIZED_REQUEST: u32 = 16384;
+
+/// Mapped buffers to keep in flight. Four is the usual V4L2 default: enough to
+/// absorb a scheduling hiccup without adding a frame of latency.
+const BUFFER_COUNT: u32 = 4;
+
+/// How long a live stream waits for one frame before giving up on the camera.
+///
+/// Sized for **stream start-up**, not the steady state: a UVC camera negotiates
+/// bandwidth and spins up its sensor on the first `STREAMON`, which measured
+/// ~730 ms on an MX Brio, against ~32 ms per frame once running. A timeout is
+/// unrecoverable (see [`run_stream`]), so this must clear the slowest start-up
+/// comfortably rather than sit close to it.
+const STREAM_TIMEOUT: Duration = Duration::from_secs(3);
+
+/// The pixel layouts this backend decodes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Encoding {
+ /// Motion-JPEG: one baseline JPEG per frame.
+ Mjpeg,
+ /// Packed YUV 4:2:2, two pixels per four bytes (`Y0 Cb Y1 Cr`).
+ Yuyv,
+}
+
+impl Encoding {
+ /// FourCCs in preference order — MJPEG first, for the bandwidth reason in
+ /// the module docs.
+ const PREFERRED: [(Self, &'static [u8; 4]); 2] =
+ [(Self::Mjpeg, b"MJPG"), (Self::Yuyv, b"YUYV")];
+}
+
+/// What a capture session optimises for.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Quality {
+ /// 720p. Sharp in the preview box at a fraction of a 4K frame's decode,
+ /// copy and texture upload — and a live stream pays that cost 30 times a
+ /// second.
+ Preview,
+ /// The camera's largest mode. A snapshot is taken once and kept, so it is
+ /// worth the sensor's full detail; this mirrors the macOS backend, where
+ /// only the preview session carries a 720p preset.
+ Native,
+}
+
+/// A negotiated capture session: the open device plus what its frames contain.
+struct Session {
+ device: Device,
+ encoding: Encoding,
+ width: u32,
+ height: u32,
+}
+
+/// Open `unique_id` and negotiate a decodable format on it.
+fn open_session(unique_id: &str, quality: Quality) -> Result<Session, CaptureError> {
+ let path = linux::node_for_unique_id(unique_id).ok_or(CaptureError::NotFound)?;
+ let device = Device::with_path(&path).map_err(|error| {
+ if error.kind() == std::io::ErrorKind::PermissionDenied {
+ CaptureError::AccessDenied
+ } else {
+ CaptureError::Setup(format!("{}: {error}", path.display()))
+ }
+ })?;
+
+ let available = device
+ .enum_formats()
+ .map_err(|error| CaptureError::Setup(error.to_string()))?;
+
+ let (encoding, fourcc) = Encoding::PREFERRED
+ .into_iter()
+ .find(|(_, fourcc)| {
+ available
+ .iter()
+ .any(|format| format.fourcc == FourCC::new(fourcc))
+ })
+ .ok_or_else(|| {
+ CaptureError::Setup(format!(
+ "camera offers no MJPEG or YUYV format (has: {})",
+ available
+ .iter()
+ .map(|format| format.fourcc.to_string())
+ .collect::<Vec<_>>()
+ .join(", ")
+ ))
+ })?;
+
+ let (width, height) = match quality {
+ Quality::Preview => (PREVIEW_WIDTH, PREVIEW_HEIGHT),
+ Quality::Native => largest_size(&device, FourCC::new(fourcc)),
+ };
+ let requested = Format::new(width, height, FourCC::new(fourcc));
+ let actual = with_busy_retry(|| device.set_format(&requested))
+ .map_err(|error| CaptureError::Setup(error.to_string()))?;
+
+ // The driver may substitute a format it prefers; decoding the wrong layout
+ // would render as noise, so fail loudly instead.
+ if actual.fourcc != FourCC::new(fourcc) {
+ return Err(CaptureError::Setup(format!(
+ "driver substituted {} for the requested {}",
+ actual.fourcc,
+ FourCC::new(fourcc)
+ )));
+ }
+
+ Ok(Session {
+ device,
+ encoding,
+ width: actual.width,
+ height: actual.height,
+ })
+}
+
+/// The largest frame size the camera offers for `fourcc`, by pixel count.
+///
+/// Only discrete sizes can be compared directly; a driver advertising a
+/// stepwise or continuous range gets [`OVERSIZED_REQUEST`] instead and clamps
+/// it down itself.
+fn largest_size(device: &Device, fourcc: FourCC) -> (u32, u32) {
+ device
+ .enum_framesizes(fourcc)
+ .into_iter()
+ .flatten()
+ .flat_map(|size| size.size.to_discrete())
+ .map(|discrete| (discrete.width, discrete.height))
+ .max_by_key(|&(width, height)| u64::from(width) * u64::from(height))
+ .unwrap_or((OVERSIZED_REQUEST, OVERSIZED_REQUEST))
+}
+
+/// `EBUSY` — the device is still streaming, here always on another handle.
+const BUSY: i32 = 16;
+
+/// How long to keep retrying `REQBUFS` while a previous stream finishes tearing
+/// down. Covers the ~600 ms `STREAMOFF` that [`CameraStream::drop`] leaves
+/// running, with headroom for a slower camera.
+const REOPEN_GRACE: Duration = Duration::from_millis(1500);
+
+/// Gap between `REQBUFS` attempts while waiting out a teardown.
+const REOPEN_POLL: Duration = Duration::from_millis(25);
+
+/// Map buffers for a capture stream and arm its per-frame timeout.
+///
+/// The stream is deliberately **not** started here: `MmapStream::next` enqueues
+/// every buffer and issues `STREAMON` itself on first use. Calling `start()`
+/// first would mark the stream active with an empty queue, so `next` would take
+/// its steady-state path and only ever cycle one buffer.
+///
+/// `REQBUFS` is retried while the device reports `EBUSY`, which happens when a
+/// just-dropped stream is still in `STREAMOFF` — reselecting the same camera
+/// within ~600 ms otherwise fails outright. Waiting here (rarely, and only on a
+/// re-open) is the trade for never blocking the UI thread in `drop`.
+fn build_stream(session: &Session, timeout: Duration) -> Result<MmapStream<'static>, CaptureError> {
+ let mut stream = with_busy_retry(|| {
+ MmapStream::with_buffers(&session.device, Type::VideoCapture, BUFFER_COUNT)
+ })
+ .map_err(|error| CaptureError::Setup(error.to_string()))?;
+ stream.set_timeout(timeout);
+ Ok(stream)
+}
+
+/// Run a V4L2 setup ioctl, retrying while the driver reports the device busy.
+///
+/// Both `VIDIOC_S_FMT` and `VIDIOC_REQBUFS` return `EBUSY` while *any* handle
+/// is still streaming, so a re-open racing a previous stream's `STREAMOFF` hits
+/// this on whichever call comes first.
+fn with_busy_retry<T>(mut step: impl FnMut() -> std::io::Result<T>) -> std::io::Result<T> {
+ let deadline = Instant::now() + REOPEN_GRACE;
+ loop {
+ match step() {
+ Err(error) if error.raw_os_error() == Some(BUSY) && Instant::now() < deadline => {
+ std::thread::sleep(REOPEN_POLL);
+ }
+ outcome => return outcome,
+ }
+ }
+}
+
+/// Capture a single frame from the camera with `unique_id`, at the camera's
+/// native resolution.
+///
+/// A partially-filled MJPEG buffer (a dropped USB packet) fails to decode, so
+/// this keeps reading until one decodes or `timeout` elapses. The caller's whole
+/// budget is given to the dequeue, since the first frame carries the stream
+/// start-up cost described on [`STREAM_TIMEOUT`] — and more of it at full
+/// resolution, where the sensor has more to read out per frame.
+///
+/// # Errors
+/// [`CaptureError::NotFound`] when no camera matches, [`CaptureError::AccessDenied`]
+/// without permission on the node, [`CaptureError::Timeout`] when no frame
+/// decodes in time.
+pub fn capture_frame(unique_id: &str, timeout: Duration) -> Result<Frame, CaptureError> {
+ let session = open_session(unique_id, Quality::Native)?;
+ let mut stream = build_stream(&session, timeout)?;
+ let deadline = Instant::now() + timeout;
+
+ while Instant::now() < deadline {
+ // A dequeue error leaves the stream unusable (see `run_stream`), so
+ // there is nothing to retry — only a torn frame is worth another pass.
+ let Ok((buffer, meta)) = stream.next() else {
+ break;
+ };
+ if let Some(frame) = decode(&buffer[..used(buffer, meta.bytesused)], &session) {
+ return Ok(frame);
+ }
+ }
+
+ Err(CaptureError::Timeout)
+}
+
+/// The filled prefix of a mapped buffer. `bytesused` is what the driver wrote;
+/// the mapping itself is the larger negotiated buffer size, and the tail is
+/// stale data from an earlier frame.
+fn used(buffer: &[u8], bytesused: u32) -> usize {
+ (bytesused as usize).min(buffer.len())
+}
+
+/// Frame slot shared between the capture thread and the UI's polling.
+struct Shared {
+ latest: Mutex<Option<Arc<Frame>>>,
+ generation: AtomicU64,
+}
+
+/// A running capture stream. Dropping it stops the thread and releases the
+/// camera, which is what turns the hardware LED back off.
+pub struct CameraStream {
+ shared: Arc<Shared>,
+ stop: Arc<AtomicBool>,
+}
+
+impl CameraStream {
+ /// The most recently delivered frame, or `None` before the first arrives.
+ /// Returns a shared [`Arc`] so polling at preview rate never copies the
+ /// pixel buffer.
+ #[must_use]
+ pub fn latest_frame(&self) -> Option<Arc<Frame>> {
+ self.shared.latest.lock().ok().and_then(|slot| slot.clone())
+ }
+
+ /// Take the most recent frame out of the slot (the next delivered frame
+ /// refills it). A sole consumer that unwraps the [`Arc`] gets the pixel
+ /// buffer without copying it.
+ #[must_use]
+ pub fn take_frame(&self) -> Option<Arc<Frame>> {
+ self.shared
+ .latest
+ .lock()
+ .ok()
+ .and_then(|mut slot| slot.take())
+ }
+
+ /// A counter that increments on every delivered frame, so the preview can
+ /// skip rebuilding its texture when no new frame has arrived.
+ #[must_use]
+ pub fn frame_generation(&self) -> u64 {
+ self.shared.generation.load(Ordering::Relaxed)
+ }
+}
+
+impl Drop for CameraStream {
+ fn drop(&mut self) {
+ // Signal and return: the worker tears the stream down on its own.
+ //
+ // Deliberately *not* a join. `VIDIOC_STREAMOFF` blocks for ~600 ms on a
+ // UVC camera while the kernel gives back the USB isochronous bandwidth
+ // reservation, and the GUI drops the preview from `set_target` on the
+ // UI thread — joining would freeze the window for that long on every
+ // switch away from the Camera tab. The cost of not waiting is that the
+ // device stays busy briefly, which [`build_stream`] absorbs.
+ self.stop.store(true, Ordering::Relaxed);
+ }
+}
+
+/// Start a live capture stream on the camera with `unique_id`.
+///
+/// # Errors
+/// Same as [`capture_frame`], minus `Timeout` (frames are polled, not awaited).
+pub fn start_stream(unique_id: &str) -> Result<CameraStream, CaptureError> {
+ let session = open_session(unique_id, Quality::Preview)?;
+ let stream = build_stream(&session, STREAM_TIMEOUT)?;
+
+ let shared = Arc::new(Shared {
+ latest: Mutex::new(None),
+ generation: AtomicU64::new(0),
+ });
+ let stop = Arc::new(AtomicBool::new(false));
+
+ std::thread::Builder::new()
+ .name("openlogi-camera".into())
+ .spawn({
+ let shared = Arc::clone(&shared);
+ let stop = Arc::clone(&stop);
+ move || run_stream(stream, &session, &shared, &stop)
+ })
+ .map_err(|error| CaptureError::Setup(error.to_string()))?;
+
+ Ok(CameraStream { shared, stop })
+}
+
+/// Pump frames into `shared` until `stop` is set or the camera stops delivering.
+///
+/// A dequeue error ends the loop rather than retrying. `MmapStream::next` only
+/// re-queues the buffer it last dequeued, so after a timeout the buffer it
+/// points at is still queued and every later call fails `VIDIOC_QBUF` with
+/// `EINVAL` — retrying would spin the CPU forever without ever recovering. The
+/// preview freezes on the last good frame, which the stalled frame generation
+/// makes visible to the caller.
+fn run_stream(
+ mut stream: MmapStream<'static>,
+ session: &Session,
+ shared: &Shared,
+ stop: &AtomicBool,
+) {
+ while !stop.load(Ordering::Relaxed) {
+ let (buffer, meta) = match stream.next() {
+ Ok(frame) => frame,
+ Err(error) => {
+ tracing::warn!(%error, "camera stream ended");
+ break;
+ }
+ };
+ let Some(frame) = decode(&buffer[..used(buffer, meta.bytesused)], session) else {
+ continue;
+ };
+ if let Ok(mut slot) = shared.latest.lock() {
+ *slot = Some(Arc::new(frame));
+ }
+ shared.generation.fetch_add(1, Ordering::Relaxed);
+ }
+ let _ = stream.stop();
+}
+
+/// Decode one raw buffer into a BGRA frame, or `None` when the buffer is
+/// truncated or malformed (a dropped USB packet mid-frame).
+fn decode(buffer: &[u8], session: &Session) -> Option<Frame> {
+ match session.encoding {
+ Encoding::Mjpeg => decode_mjpeg(buffer),
+ Encoding::Yuyv => decode_yuyv(buffer, session.width, session.height),
+ }
+}
+
+/// Decode a Motion-JPEG frame straight to BGRA.
+///
+/// Dimensions come from the JPEG header rather than the negotiated format:
+/// they agree in practice, but trusting the header keeps the buffer length and
+/// the reported size consistent even if a driver lies.
+fn decode_mjpeg(buffer: &[u8]) -> Option<Frame> {
+ let options = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::BGRA);
+ let mut decoder = JpegDecoder::new_with_options(ZCursor::new(buffer), options);
+ let bgra = decoder.decode().ok()?;
+ let info = decoder.info()?;
+ let (width, height) = (u32::from(info.width), u32::from(info.height));
+
+ // A frame whose payload doesn't match its header is a torn capture.
+ if bgra.len() < (width as usize) * (height as usize) * 4 {
+ return None;
+ }
+
+ Some(Frame {
+ width,
+ height,
+ bgra,
+ })
+}
+
+/// Convert packed YUYV 4:2:2 to BGRA using BT.601, the colour space UVC
+/// cameras encode in.
+///
+/// Coefficients are scaled by 256 so the whole conversion is integer work; at
+/// 720p30 this runs per pixel on the capture thread.
+fn decode_yuyv(buffer: &[u8], width: u32, height: u32) -> Option<Frame> {
+ let pixels = (width as usize).checked_mul(height as usize)?;
+ if buffer.len() < pixels * 2 {
+ return None;
+ }
+
+ let mut bgra = vec![0u8; pixels * 4];
+ for (pair, out) in buffer[..pixels * 2]
+ .chunks_exact(4)
+ .zip(bgra.chunks_exact_mut(8))
+ {
+ let (y0, u, y1, v) = (
+ i32::from(pair[0]),
+ i32::from(pair[1]) - 128,
+ i32::from(pair[2]),
+ i32::from(pair[3]) - 128,
+ );
+ write_bgra(&mut out[..4], y0, u, v);
+ write_bgra(&mut out[4..], y1, u, v);
+ }
+
+ Some(Frame {
+ width,
+ height,
+ bgra,
+ })
+}
+
+/// Write one BT.601 YUV sample as a BGRA pixel.
+fn write_bgra(out: &mut [u8], y: i32, u: i32, v: i32) {
+ let y = y * 256;
+ out[0] = clamp_u8(y + 452 * u);
+ out[1] = clamp_u8(y - 88 * u - 183 * v);
+ out[2] = clamp_u8(y + 359 * v);
+ out[3] = 0xFF;
+}
+
+/// Saturate a fixed-point channel (scaled by 256) into a byte.
+fn clamp_u8(scaled: i32) -> u8 {
+ (scaled / 256).clamp(0, 255) as u8
+}
+
+/// Whether this process can open the camera nodes it can see.
+#[must_use]
+pub fn camera_access_granted() -> bool {
+ camera_authorization() == CameraAuthorization::Granted
+}
+
+/// Report camera access by probing a node.
+///
+/// Linux has no consent prompt: a node either opens or it doesn't, decided by
+/// its group permissions. `Undetermined` is therefore never returned — with no
+/// camera present at all there is nothing to authorize, which reads as
+/// `Granted` (nothing is being withheld).
+#[must_use]
+pub fn camera_authorization() -> CameraAuthorization {
+ let nodes = linux::nodes();
+ if nodes.is_empty() {
+ return CameraAuthorization::Granted;
+ }
+ if nodes
+ .iter()
+ .any(|node| Device::with_path(&node.path).is_ok())
+ {
+ CameraAuthorization::Granted
+ } else {
+ CameraAuthorization::Denied
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn yuyv_rejects_a_short_buffer() {
+ // One byte short of a single 2x1 macropixel.
+ assert!(decode_yuyv(&[0; 3], 2, 1).is_none());
+ }
+
+ #[test]
+ fn yuyv_decodes_grey_to_grey() {
+ // Y=128 with neutral chroma is mid-grey in every channel.
+ let frame = decode_yuyv(&[128, 128, 128, 128], 2, 1).expect("2x1 frame");
+ assert_eq!(frame.width, 2);
+ assert_eq!(frame.height, 1);
+ assert_eq!(frame.bgra, vec![128, 128, 128, 255, 128, 128, 128, 255]);
+ }
+
+ #[test]
+ fn yuyv_saturates_out_of_gamut_chroma() {
+ // Peak luma with peak chroma drives blue to 479 and red to 433 before
+ // clamping. They must saturate at 255, not wrap (479 as a truncated
+ // byte would be 223 — a vivid colour turning muddy). Green lands at
+ // 120 legitimately, inside the range, so it pins the coefficients too.
+ let frame = decode_yuyv(&[255, 255, 255, 255], 2, 1).expect("2x1 frame");
+ assert_eq!(&frame.bgra[..4], &[255, 120, 255, 255]);
+ }
+
+ #[test]
+ fn mjpeg_rejects_a_non_jpeg_buffer() {
+ assert!(decode_mjpeg(&[0xFF; 64]).is_none());
+ }
+
+ #[test]
+ fn used_clamps_a_driver_overreporting_bytesused() {
+ // A driver claiming more than the mapping holds must not panic the
+ // slice below.
+ assert_eq!(used(&[0; 10], 99), 10);
+ assert_eq!(used(&[0; 10], 4), 4);
+ }
+}
diff --git a/crates/openlogi-camera/src/capture_types.rs b/crates/openlogi-camera/src/capture_types.rs
new file mode 100644
index 0000000000000000000000000000000000000000..99a57225057609f40cac422d5eff1dd2b56747ba
--- /dev/null
+++ b/crates/openlogi-camera/src/capture_types.rs
@@ -0,0 +1,45 @@
+//! Platform-independent capture vocabulary shared by every capture backend
+//! (AVFoundation on macOS, Media Foundation on Windows, stubs elsewhere).
+
+/// One decoded camera frame, tightly-packed BGRA8 (`width * height * 4` bytes) —
+/// gpui's native texture order, so the preview uploads it without a channel
+/// swap. The snapshot path swaps to RGBA when it writes the PNG.
+#[derive(Clone)]
+pub struct Frame {
+ pub width: u32,
+ pub height: u32,
+ pub bgra: Vec<u8>,
+}
+
+/// Why a capture attempt failed.
+#[derive(Debug, Clone)]
+pub enum CaptureError {
+ /// Camera permission is denied/restricted, or this process can't request
+ /// it (e.g. an unbundled macOS binary with no `NSCameraUsageDescription`).
+ AccessDenied,
+ /// No camera matched the requested unique id.
+ NotFound,
+ /// The session ran but produced no frame within the timeout.
+ Timeout,
+ /// A platform capture object failed to construct.
+ Setup(String),
+ /// Capture has no backend on this platform.
+ Unsupported,
+}
+
+impl std::fmt::Display for CaptureError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::AccessDenied => write!(
+ f,
+ "camera access denied — grant Camera permission (on macOS, run inside an app bundle with NSCameraUsageDescription)"
+ ),
+ Self::NotFound => write!(f, "no camera matched that id"),
+ Self::Timeout => write!(f, "camera produced no frame in time"),
+ Self::Setup(s) => write!(f, "capture setup failed: {s}"),
+ Self::Unsupported => write!(f, "camera capture is not implemented on this platform"),
+ }
+ }
+}
+
+impl std::error::Error for CaptureError {}
diff --git a/crates/openlogi-camera/src/capture_windows.rs b/crates/openlogi-camera/src/capture_windows.rs
new file mode 100644
index 0000000000000000000000000000000000000000..5a9782bcb99c58b6a449373590718f1f39a3c2a4
--- /dev/null
+++ b/crates/openlogi-camera/src/capture_windows.rs
@@ -0,0 +1,475 @@
+//! Media Foundation camera capture (Windows): a one-shot snapshot and a live
+//! frame stream.
+//!
+//! A dedicated reader thread owns the whole Media Foundation object graph —
+//! device activation, `IMFSourceReader`, format negotiation — and pulls
+//! samples synchronously, decoding into the same tightly-packed BGRA
+//! [`Frame`]s the macOS backend produces. RGB32 sample memory is BGRX in
+//! little-endian byte order — the channel order gpui wants, but with an
+//! undefined fourth byte that is forced opaque during the copy.
+//! Dropping the [`CameraStream`] stops the thread, which releases the device
+//! (camera LED off).
+//!
+//! There is no per-app consent prompt to drive here: desktop apps see the
+//! camera unless the system-wide privacy toggle blocks them, which surfaces
+//! as an activation error — reported as [`CaptureError::AccessDenied`].
+
+#![expect(
+ unsafe_code,
+ reason = "Media Foundation COM (device activation + IMFSourceReader sample loop)"
+)]
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_possible_wrap,
+ clippy::cast_sign_loss,
+ reason = "pixel dimensions and strides are bounded and copied verbatim"
+)]
+
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::{Arc, Mutex, mpsc};
+use std::time::{Duration, Instant};
+
+use windows::Win32::Media::MediaFoundation::{
+ IMFActivate, IMFMediaSource, IMFMediaType, IMFSourceReader, MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE,
+ MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_GUID,
+ MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_SYMBOLIC_LINK, MF_MT_DEFAULT_STRIDE,
+ MF_MT_FRAME_SIZE, MF_MT_MAJOR_TYPE, MF_MT_SUBTYPE, MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING,
+ MF_SOURCE_READER_FIRST_VIDEO_STREAM, MF_VERSION, MFCreateAttributes, MFCreateMediaType,
+ MFCreateSourceReaderFromMediaSource, MFEnumDeviceSources, MFMediaType_Video, MFSTARTUP_LITE,
+ MFStartup, MFVideoFormat_NV12, MFVideoFormat_RGB24, MFVideoFormat_RGB32, MFVideoFormat_YUY2,
+};
+use windows::Win32::System::Com::{COINIT_MULTITHREADED, CoInitializeEx, CoTaskMemFree};
+
+pub use crate::capture_types::{CaptureError, Frame};
+
+/// The preview's target frame width: matches the macOS backend's 720p preset —
+/// Retina-sharp in the 480pt preview box without 1080p copy/upload cost. The
+/// native format closest to this width wins.
+const TARGET_WIDTH: u32 = 1280;
+
+/// How long [`start_stream`] waits for the reader thread to finish setup.
+const SETUP_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// The latest decoded frame plus its generation counter, shared between the
+/// reader thread and the polling preview.
+struct Shared {
+ latest: Mutex<Option<Arc<Frame>>>,
+ generation: AtomicU64,
+ stop: AtomicBool,
+}
+
+/// A live preview stream. Holds the reader thread; [`CameraStream::take_frame`]
+/// hands out the most recent frame each time it's polled. Dropping it stops
+/// the camera.
+pub struct CameraStream {
+ shared: Arc<Shared>,
+ reader: Option<std::thread::JoinHandle<()>>,
+}
+
+impl CameraStream {
+ /// The most recently delivered frame, or `None` before the first arrives.
+ #[must_use]
+ pub fn latest_frame(&self) -> Option<Arc<Frame>> {
+ self.shared.latest.lock().ok().and_then(|slot| slot.clone())
+ }
+
+ /// Take the most recent frame out of the slot (the next delivered frame
+ /// refills it). A sole consumer that unwraps the [`Arc`] gets the pixel
+ /// buffer without copying it.
+ #[must_use]
+ pub fn take_frame(&self) -> Option<Arc<Frame>> {
+ self.shared
+ .latest
+ .lock()
+ .ok()
+ .and_then(|mut slot| slot.take())
+ }
+
+ /// A counter that increments on every delivered frame, so the preview can
+ /// skip rebuilding its texture when no new frame has arrived.
+ #[must_use]
+ pub fn frame_generation(&self) -> u64 {
+ self.shared.generation.load(Ordering::Relaxed)
+ }
+}
+
+impl Drop for CameraStream {
+ fn drop(&mut self) {
+ self.shared.stop.store(true, Ordering::Relaxed);
+ // The reader wakes from its blocking ReadSample within a frame
+ // interval, sees the flag, and releases the device on its way out.
+ if let Some(reader) = self.reader.take() {
+ let _ = reader.join();
+ }
+ }
+}
+
+/// Start a live capture stream on the camera with `unique_id`.
+///
+/// # Errors
+/// [`CaptureError::NotFound`] for an unknown id, [`CaptureError::AccessDenied`]
+/// when the system privacy toggle blocks cameras, or [`CaptureError::Setup`]
+/// on Media Foundation errors.
+pub fn start_stream(unique_id: &str) -> Result<CameraStream, CaptureError> {
+ let shared = Arc::new(Shared {
+ latest: Mutex::new(None),
+ generation: AtomicU64::new(0),
+ stop: AtomicBool::new(false),
+ });
+ let (setup_tx, setup_rx) = mpsc::channel();
+ let thread_shared = Arc::clone(&shared);
+ let id = unique_id.to_string();
+ let reader = std::thread::Builder::new()
+ .name("openlogi-camera-reader".into())
+ .spawn(move || reader_thread(&id, &thread_shared, &setup_tx))
+ .map_err(|e| CaptureError::Setup(e.to_string()))?;
+
+ match setup_rx.recv_timeout(SETUP_TIMEOUT) {
+ Ok(Ok(())) => Ok(CameraStream {
+ shared,
+ reader: Some(reader),
+ }),
+ Ok(Err(e)) => {
+ let _ = reader.join();
+ Err(e)
+ }
+ Err(_) => {
+ shared.stop.store(true, Ordering::Relaxed);
+ Err(CaptureError::Timeout)
+ }
+ }
+}
+
+/// Capture a single [`Frame`] from the camera with `unique_id`.
+///
+/// # Errors
+/// As [`start_stream`], plus [`CaptureError::Timeout`] when no frame arrives.
+pub fn capture_frame(unique_id: &str, timeout: Duration) -> Result<Frame, CaptureError> {
+ let stream = start_stream(unique_id)?;
+ let deadline = Instant::now() + timeout;
+ loop {
+ if let Some(frame) = stream.take_frame() {
+ return Ok(Arc::unwrap_or_clone(frame));
+ }
+ if Instant::now() >= deadline {
+ return Err(CaptureError::Timeout);
+ }
+ std::thread::sleep(Duration::from_millis(30));
+ }
+}
+
+/// Desktop apps are governed only by the system-wide privacy toggle, which
+/// can't be queried up front — report usable and let activation surface a
+/// denial.
+#[must_use]
+pub fn camera_access_granted() -> bool {
+ true
+}
+
+/// Windows has no per-app camera consent for desktop apps.
+#[must_use]
+pub fn camera_authorization() -> crate::CameraAuthorization {
+ crate::CameraAuthorization::Granted
+}
+
+/// The reader thread: builds the Media Foundation graph, reports the outcome
+/// through `setup`, then pulls and decodes samples until told to stop.
+fn reader_thread(unique_id: &str, shared: &Shared, setup: &mpsc::Sender<Result<(), CaptureError>>) {
+ // SAFETY: COM + MF init on this thread; every interface is released by the
+ // `windows` wrappers when the thread's locals drop.
+ let reader = unsafe {
+ let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
+ if let Err(e) = MFStartup(MF_VERSION, MFSTARTUP_LITE) {
+ let _ = setup.send(Err(CaptureError::Setup(e.to_string())));
+ return;
+ }
+ match open_reader(unique_id) {
+ Ok(opened) => opened,
+ Err(e) => {
+ let _ = setup.send(Err(e));
+ return;
+ }
+ }
+ };
+ let (reader, stride_hint) = reader;
+ let _ = setup.send(Ok(()));
+
+ while !shared.stop.load(Ordering::Relaxed) {
+ // SAFETY: synchronous ReadSample with documented out-params; the
+ // sample and its buffer are released when the wrappers drop.
+ unsafe {
+ let (mut flags, mut sample) = (0u32, None);
+ if reader
+ .ReadSample(
+ MF_SOURCE_READER_FIRST_VIDEO_STREAM.0 as u32,
+ 0,
+ None,
+ Some(&raw mut flags),
+ None,
+ Some(&raw mut sample),
+ )
+ .is_err()
+ {
+ break;
+ }
+ let Some(sample) = sample else { continue };
+ let Ok(buffer) = sample.ConvertToContiguousBuffer() else {
+ continue;
+ };
+ let (mut data, mut len) = (std::ptr::null_mut(), 0u32);
+ if buffer
+ .Lock(&raw mut data, None, Some(&raw mut len))
+ .is_err()
+ {
+ continue;
+ }
+ store_frame(shared, data, len as usize, stride_hint);
+ let _ = buffer.Unlock();
+ }
+ }
+}
+
+/// Frame geometry negotiated at setup: dimensions plus the RGB32 stride (a
+/// negative stride means the rows arrive bottom-up and must be flipped).
+#[derive(Clone, Copy)]
+struct StrideHint {
+ width: u32,
+ height: u32,
+ stride: i32,
+}
+
+/// Build the source reader for `unique_id`: activate the matching device,
+/// pick the native format closest to [`TARGET_WIDTH`], and negotiate RGB32
+/// output (Media Foundation inserts the decoder/converter).
+unsafe fn open_reader(unique_id: &str) -> Result<(IMFSourceReader, StrideHint), CaptureError> {
+ unsafe {
+ let source = activate_source(unique_id)?;
+
+ let mut reader_attrs = None;
+ MFCreateAttributes(&raw mut reader_attrs, 1).map_err(setup_err)?;
+ let reader_attrs = reader_attrs.ok_or_else(|| setup_err("MFCreateAttributes"))?;
+ reader_attrs
+ .SetUINT32(&MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, 1)
+ .map_err(setup_err)?;
+ let reader = MFCreateSourceReaderFromMediaSource(&source, &reader_attrs)
+ .map_err(|e| access_or_setup(&e))?;
+
+ // Prefer the native type closest to the preview's target width, so a
+ // 4K-capable camera doesn't stream (and we don't convert) 8x the
+ // pixels the preview can show. Only formats the reader's (legacy)
+ // processor can convert to RGB32 count — a compressed 720p mode it
+ // can't decode would fail below, while a convertible mode at another
+ // 16:9 size still previews fine.
+ let stream = MF_SOURCE_READER_FIRST_VIDEO_STREAM.0 as u32;
+ let mut best: Option<(u32, IMFMediaType)> = None;
+ let mut index = 0u32;
+ while let Ok(native) = reader.GetNativeMediaType(stream, index) {
+ index += 1;
+ let convertible = native.GetGUID(&MF_MT_SUBTYPE).is_ok_and(|subtype| {
+ [
+ MFVideoFormat_NV12,
+ MFVideoFormat_YUY2,
+ MFVideoFormat_RGB24,
+ MFVideoFormat_RGB32,
+ ]
+ .contains(&subtype)
+ });
+ if !convertible {
+ continue;
+ }
+ if let Ok(size) = native.GetUINT64(&MF_MT_FRAME_SIZE) {
+ let width = (size >> 32) as u32;
+ let score = width.abs_diff(TARGET_WIDTH);
+ if best.as_ref().is_none_or(|(s, _)| score < *s) {
+ best = Some((score, native));
+ }
+ }
+ }
+ // Selecting the native type switches the device to that mode — a size
+ // hint on the RGB32 output type alone is quietly dropped (the legacy
+ // processor converts but never scales), leaving whatever mode the
+ // device was in.
+ if let Some((_, native)) = &best {
+ reader
+ .SetCurrentMediaType(stream, None, native)
+ .map_err(setup_err)?;
+ }
+
+ let output = MFCreateMediaType().map_err(setup_err)?;
+ output
+ .SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)
+ .map_err(setup_err)?;
+ output
+ .SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_RGB32)
+ .map_err(setup_err)?;
+ reader
+ .SetCurrentMediaType(stream, None, &output)
+ .map_err(setup_err)?;
+
+ // Read the negotiated geometry back — the converter may have kept the
+ // native size, and the stride tells us whether rows arrive bottom-up.
+ let current = reader.GetCurrentMediaType(stream).map_err(setup_err)?;
+ let size = current.GetUINT64(&MF_MT_FRAME_SIZE).map_err(setup_err)?;
+ let width = (size >> 32) as u32;
+ let height = (size & 0xFFFF_FFFF) as u32;
+ let stride = current
+ .GetUINT32(&MF_MT_DEFAULT_STRIDE)
+ .map_or(width as i32 * 4, |s| s as i32);
+ Ok((
+ reader,
+ StrideHint {
+ width,
+ height,
+ stride,
+ },
+ ))
+ }
+}
+
+/// Activate the video-capture device whose Media Foundation symbolic link
+/// identifies the same physical device as `unique_id` (the stored DirectShow
+/// device path). The two APIs register the camera under different
+/// interface-class GUIDs, so they are matched on the shared device-instance
+/// portion (see [`device_instance`]).
+unsafe fn activate_source(unique_id: &str) -> Result<IMFMediaSource, CaptureError> {
+ unsafe {
+ let mut enum_attrs = None;
+ MFCreateAttributes(&raw mut enum_attrs, 1).map_err(setup_err)?;
+ let enum_attrs = enum_attrs.ok_or_else(|| setup_err("MFCreateAttributes"))?;
+ enum_attrs
+ .SetGUID(
+ &MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE,
+ &MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_GUID,
+ )
+ .map_err(setup_err)?;
+
+ let (mut devices, mut count) = (std::ptr::null_mut::<Option<IMFActivate>>(), 0u32);
+ MFEnumDeviceSources(&enum_attrs, &raw mut devices, &raw mut count).map_err(setup_err)?;
+ let list = std::slice::from_raw_parts(devices, count as usize);
+ let mut chosen = None;
+ for activate in list.iter().flatten() {
+ let (mut link, mut len) = (windows::core::PWSTR::null(), 0u32);
+ if activate
+ .GetAllocatedString(
+ &MF_DEVSOURCE_ATTRIBUTE_SOURCE_TYPE_VIDCAP_SYMBOLIC_LINK,
+ &raw mut link,
+ &raw mut len,
+ )
+ .is_err()
+ {
+ continue;
+ }
+ let link_str = link.to_string().unwrap_or_default();
+ CoTaskMemFree(Some(link.as_ptr().cast()));
+ if device_instance(&link_str).eq_ignore_ascii_case(device_instance(unique_id)) {
+ chosen = Some(activate.clone());
+ break;
+ }
+ }
+ let result = match chosen {
+ Some(activate) => activate
+ .ActivateObject::<IMFMediaSource>()
+ .map_err(|e| access_or_setup(&e)),
+ None => Err(CaptureError::NotFound),
+ };
+ CoTaskMemFree(Some(devices.cast()));
+ result
+ }
+}
+
+/// The device-instance portion of a Windows device-interface path, dropping the
+/// trailing `#{interface-class-guid}\reference`. DirectShow (the id we enumerate
+/// and persist) tags a camera under `KSCATEGORY_VIDEO`, while Media Foundation
+/// tags the same physical device under `KSCATEGORY_VIDEO_CAMERA` — so the paths
+/// differ only by that GUID, and comparing the instance links the two.
+fn device_instance(interface_path: &str) -> &str {
+ interface_path.split("#{").next().unwrap_or(interface_path)
+}
+
+/// Copy one locked RGB32 sample into a tightly-packed BGRA [`Frame`] in the
+/// shared slot, flipping bottom-up rows when the stride is negative.
+fn store_frame(shared: &Shared, data: *mut u8, len: usize, hint: StrideHint) {
+ let (width, height) = (hint.width as usize, hint.height as usize);
+ let row_bytes = width * 4;
+ let stride = hint.stride.unsigned_abs() as usize;
+ if width == 0 || height == 0 || data.is_null() || stride * (height - 1) + row_bytes > len {
+ return;
+ }
+ let mut bgra = vec![0u8; row_bytes * height];
+ for y in 0..height {
+ // A negative stride means the buffer's first row is the bottom line.
+ let src_row = if hint.stride < 0 { height - 1 - y } else { y };
+ // SAFETY: both row offsets are bounds-checked against `len` above.
+ unsafe {
+ std::ptr::copy_nonoverlapping(
+ data.add(src_row * stride),
+ bgra.as_mut_ptr().add(y * row_bytes),
+ row_bytes,
+ );
+ }
+ }
+ // RGB32 is really BGRX: Media Foundation leaves the fourth byte undefined
+ // (zero in practice), which gpui would alpha-blend into an invisible frame.
+ // Force every pixel opaque to make the buffer true BGRA.
+ for px in bgra.chunks_exact_mut(4) {
+ px[3] = 0xFF;
+ }
+ if let Ok(mut slot) = shared.latest.lock() {
+ *slot = Some(Arc::new(Frame {
+ width: hint.width,
+ height: hint.height,
+ bgra,
+ }));
+ shared.generation.fetch_add(1, Ordering::Relaxed);
+ }
+}
+
+fn setup_err(e: impl std::fmt::Display) -> CaptureError {
+ CaptureError::Setup(e.to_string())
+}
+
+/// Map an activation failure to AccessDenied when the system privacy toggle
+/// is the cause (E_ACCESSDENIED), Setup otherwise.
+fn access_or_setup(e: &windows::core::Error) -> CaptureError {
+ const E_ACCESSDENIED: windows::core::HRESULT = windows::core::HRESULT(0x8007_0005_u32 as i32);
+ if e.code() == E_ACCESSDENIED {
+ CaptureError::AccessDenied
+ } else {
+ CaptureError::Setup(e.to_string())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::device_instance;
+
+ // The same StreamCam function, as DirectShow enumerates it (KSCATEGORY_VIDEO)
+ // vs. as Media Foundation enumerates it (KSCATEGORY_VIDEO_CAMERA): identical
+ // but for the trailing interface-class GUID.
+ const DIRECTSHOW: &str = r"\\?\usb#vid_046d&pid_0893&mi_00#9&56d9c30&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global";
+ const MEDIA_FOUNDATION: &str = r"\\?\usb#vid_046d&pid_0893&mi_00#9&56d9c30&0&0000#{e5323777-f976-4f5b-9b55-b94699c46e44}\global";
+
+ #[test]
+ fn instance_matches_across_interface_class_guids() {
+ assert_eq!(
+ device_instance(DIRECTSHOW),
+ device_instance(MEDIA_FOUNDATION),
+ "the stored DirectShow id must match MF's symbolic link"
+ );
+ assert_eq!(
+ device_instance(DIRECTSHOW),
+ r"\\?\usb#vid_046d&pid_0893&mi_00#9&56d9c30&0&0000"
+ );
+ }
+
+ #[test]
+ fn distinct_devices_stay_distinct() {
+ let other = r"\\?\usb#vid_046d&pid_0825&mi_00#7&1a2b3c&0&0000#{e5323777-f976-4f5b-9b55-b94699c46e44}\global";
+ assert_ne!(device_instance(DIRECTSHOW), device_instance(other));
+ }
+
+ #[test]
+ fn path_without_interface_guid_is_returned_whole() {
+ assert_eq!(device_instance("not-a-device-path"), "not-a-device-path");
+ }
+}
diff --git a/crates/openlogi-camera/src/controls.rs b/crates/openlogi-camera/src/controls.rs
new file mode 100644
index 0000000000000000000000000000000000000000..8b889caeab7282c3d5b8c18a59bad8a7b30255b4
--- /dev/null
+++ b/crates/openlogi-camera/src/controls.rs
@@ -0,0 +1,135 @@
+//! Platform-independent control vocabulary shared by every UVC backend
+//! (IOKit on macOS, DirectShow on Windows, stubs elsewhere).
+
+/// One adjustable camera control, mapped to a UVC selector by each backend.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CameraControl {
+ Zoom,
+ Focus,
+ Exposure,
+ Brightness,
+ Contrast,
+ Saturation,
+ Sharpness,
+ WhiteBalance,
+ Tint,
+}
+
+impl CameraControl {
+ /// Every control, in the order the UI lists them (lens first, then image).
+ pub const ALL: [Self; 9] = [
+ Self::Zoom,
+ Self::Focus,
+ Self::Exposure,
+ Self::Brightness,
+ Self::Contrast,
+ Self::Saturation,
+ Self::Sharpness,
+ Self::WhiteBalance,
+ Self::Tint,
+ ];
+
+ /// Stable snake_case identifier used for config persistence and the CLI.
+ #[must_use]
+ pub fn name(self) -> &'static str {
+ match self {
+ Self::Zoom => "zoom",
+ Self::Focus => "focus",
+ Self::Exposure => "exposure",
+ Self::Brightness => "brightness",
+ Self::Contrast => "contrast",
+ Self::Saturation => "saturation",
+ Self::Sharpness => "sharpness",
+ Self::WhiteBalance => "white_balance",
+ Self::Tint => "tint",
+ }
+ }
+
+ /// The auto-mode toggle that gates this control, if the device has one.
+ #[must_use]
+ pub fn auto_toggle(self) -> Option<AutoToggle> {
+ match self {
+ Self::Focus => Some(AutoToggle::Focus),
+ Self::Exposure => Some(AutoToggle::Exposure),
+ Self::WhiteBalance => Some(AutoToggle::WhiteBalance),
+ _ => None,
+ }
+ }
+}
+
+/// An auto-mode toggle paired with a manual control (focus / exposure / white
+/// balance).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AutoToggle {
+ Focus,
+ Exposure,
+ WhiteBalance,
+}
+
+impl AutoToggle {
+ /// Every toggle, matching [`CameraControl::auto_toggle`] pairs.
+ pub const ALL: [Self; 3] = [Self::Focus, Self::Exposure, Self::WhiteBalance];
+
+ /// Stable snake_case identifier used for config persistence and the CLI.
+ #[must_use]
+ pub fn name(self) -> &'static str {
+ match self {
+ Self::Focus => "focus_auto",
+ Self::Exposure => "exposure_auto",
+ Self::WhiteBalance => "white_balance_auto",
+ }
+ }
+}
+
+/// One auto toggle's live and default state, read from the device.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct AutoState {
+ pub current: bool,
+ pub default: bool,
+}
+
+/// Everything the controls UI needs, read in a single device-open: each
+/// supported control's range and each supported auto toggle's state.
+#[derive(Debug, Clone, Default)]
+pub struct CameraState {
+ pub controls: Vec<(CameraControl, ControlRange)>,
+ pub autos: Vec<(AutoToggle, AutoState)>,
+}
+
+/// The device's reported range and current value for a control.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ControlRange {
+ pub min: i32,
+ pub max: i32,
+ pub default: i32,
+ pub current: i32,
+}
+
+/// Why a UVC control operation failed.
+#[derive(Debug, Clone)]
+pub enum ControlError {
+ /// No matching camera device (or it exposes no controllable unit).
+ NotFound,
+ /// The selected camera can't be uniquely identified: its unique id didn't
+ /// resolve to a USB location and more than one Logitech camera is attached,
+ /// so a write could hit the wrong device. Fails closed instead of guessing.
+ Ambiguous,
+ /// The camera rejected or didn't support the control — or the platform
+ /// has no UVC control backend at all.
+ Unsupported,
+ /// A platform API call failed (open, bind, or the control transfer).
+ Io(String),
+}
+
+impl std::fmt::Display for ControlError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::NotFound => write!(f, "no matching UVC device"),
+ Self::Ambiguous => write!(f, "camera could not be uniquely identified"),
+ Self::Unsupported => write!(f, "camera does not support that control"),
+ Self::Io(s) => write!(f, "platform error: {s}"),
+ }
+ }
+}
+
+impl std::error::Error for ControlError {}
diff --git a/crates/openlogi-camera/src/lib.rs b/crates/openlogi-camera/src/lib.rs
new file mode 100644
index 0000000000000000000000000000000000000000..37ce32afe14ad91886d96fb8d9f27185d56683d0
--- /dev/null
+++ b/crates/openlogi-camera/src/lib.rs
@@ -0,0 +1,432 @@
+//! Generic discovery of Logitech USB Video Class (UVC) webcams.
+//!
+//! Mice and keyboards speak Logitech's proprietary HID++ (over a Bolt/Unifying
+//! receiver or directly) — see the `openlogi-hid` crate. Webcams don't: every
+//! Logitech camera (StreamCam, Brio, C920, C922, C270, C930e, …) is a standard
+//! UVC device and enumerates the same way. So detection keys off the USB vendor
+//! id (`0x046d`) rather than any per-model quirk — plug in *any* Logitech
+//! camera and it's recognised, with no model table to maintain.
+//!
+//! macOS has the full backend (AVFoundation capture + IOKit UVC controls);
+//! Windows matches it with Media Foundation capture and DirectShow controls;
+//! Linux uses V4L2 for both, through the kernel's `uvcvideo` driver. Other
+//! platforms return an empty list.
+
+use serde::Serialize;
+
+mod controls;
+pub use controls::{AutoState, AutoToggle, CameraControl, CameraState, ControlError, ControlRange};
+
+mod capture_types;
+pub use capture_types::{CaptureError, Frame};
+
+#[cfg(target_os = "macos")]
+mod macos;
+
+#[cfg(target_os = "macos")]
+mod capture;
+#[cfg(target_os = "macos")]
+pub use capture::{
+ CameraStream, camera_access_granted, camera_authorization, capture_frame, start_stream,
+};
+
+#[cfg(target_os = "windows")]
+mod capture_windows;
+#[cfg(target_os = "windows")]
+pub use capture_windows::{
+ CameraStream, camera_access_granted, camera_authorization, capture_frame, start_stream,
+};
+
+#[cfg(target_os = "macos")]
+mod uvc;
+#[cfg(target_os = "macos")]
+pub use uvc::{
+ apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
+};
+
+#[cfg(target_os = "windows")]
+mod uvc_windows;
+#[cfg(target_os = "windows")]
+pub use uvc_windows::{
+ apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
+};
+
+#[cfg(target_os = "linux")]
+mod linux;
+
+#[cfg(target_os = "linux")]
+mod capture_linux;
+#[cfg(target_os = "linux")]
+pub use capture_linux::{
+ CameraStream, camera_access_granted, camera_authorization, capture_frame, start_stream,
+};
+
+#[cfg(target_os = "linux")]
+mod uvc_linux;
+#[cfg(target_os = "linux")]
+pub use uvc_linux::{
+ apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
+};
+
+#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
+mod capture {
+ //! Stub capture backend for platforms without one.
+ use std::sync::Arc;
+ use std::time::Duration;
+
+ use crate::capture_types::{CaptureError, Frame};
+
+ /// Stub: returns [`CaptureError::Unsupported`] on this platform.
+ pub fn capture_frame(_unique_id: &str, _timeout: Duration) -> Result<Frame, CaptureError> {
+ Err(CaptureError::Unsupported)
+ }
+
+ /// Stub live stream (never yields a frame on this platform).
+ pub struct CameraStream;
+
+ impl CameraStream {
+ #[must_use]
+ pub fn latest_frame(&self) -> Option<Arc<Frame>> {
+ None
+ }
+
+ #[must_use]
+ pub fn take_frame(&self) -> Option<Arc<Frame>> {
+ None
+ }
+
+ #[must_use]
+ pub fn frame_generation(&self) -> u64 {
+ 0
+ }
+ }
+
+ /// Stub: returns [`CaptureError::Unsupported`] on this platform.
+ pub fn start_stream(_unique_id: &str) -> Result<CameraStream, CaptureError> {
+ Err(CaptureError::Unsupported)
+ }
+
+ /// Stub: camera access is never granted on this platform.
+ #[must_use]
+ pub fn camera_access_granted() -> bool {
+ false
+ }
+
+ /// Stub: camera permission is always undetermined on this platform.
+ #[must_use]
+ pub fn camera_authorization() -> crate::CameraAuthorization {
+ crate::CameraAuthorization::Undetermined
+ }
+}
+#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
+pub use capture::{
+ CameraStream, camera_access_granted, camera_authorization, capture_frame, start_stream,
+};
+
+#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
+mod uvc {
+ //! Stub UVC control backend for platforms without one.
+ use crate::controls::{AutoToggle, CameraControl, CameraState, ControlError, ControlRange};
+
+ /// Stub: no UVC backend on this platform.
+ pub fn control_range(_id: &str, _c: CameraControl) -> Result<ControlRange, ControlError> {
+ Err(ControlError::Unsupported)
+ }
+
+ /// Stub: no UVC backend on this platform.
+ pub fn control_ranges(_id: &str) -> Result<Vec<(CameraControl, ControlRange)>, ControlError> {
+ Ok(Vec::new())
+ }
+
+ /// Stub: no UVC backend on this platform.
+ pub fn read_camera_state(_id: &str) -> Result<CameraState, ControlError> {
+ Ok(CameraState::default())
+ }
+
+ /// Stub: no UVC backend on this platform.
+ pub fn set_control(_id: &str, _c: CameraControl, _value: i32) -> Result<(), ControlError> {
+ Err(ControlError::Unsupported)
+ }
+
+ /// Stub: no UVC backend on this platform.
+ pub fn set_auto(_id: &str, _t: AutoToggle, _on: bool) -> Result<(), ControlError> {
+ Err(ControlError::Unsupported)
+ }
+
+ /// Stub: no UVC backend on this platform.
+ pub fn apply_settings(
+ _id: &str,
+ _autos: &[(AutoToggle, bool)],
+ _values: &[(CameraControl, i32)],
+ ) -> Result<(), ControlError> {
+ Err(ControlError::Unsupported)
+ }
+}
+#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
+pub use uvc::{
+ apply_settings, control_range, control_ranges, read_camera_state, set_auto, set_control,
+};
+
+/// Logitech's USB vendor id. Reported in decimal (`1133`) inside an
+/// `AVCaptureDevice` modelID, and in hex (`046d`) most everywhere else.
+pub const LOGITECH_VID: u16 = 0x046d;
+
+/// Tri-state Camera permission, mirroring macOS `AVAuthorizationStatus`.
+///
+/// Only macOS has a consent model with a pending state. Linux decides access
+/// by filesystem permission on the device node, so it reports `Granted` or
+/// `Denied` but never `Undetermined`; platforms with no backend at all report
+/// `Undetermined`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CameraAuthorization {
+ /// The process may open cameras.
+ Granted,
+ /// The user denied access, or the system restricts it.
+ Denied,
+ /// Not yet requested — opening a camera will prompt.
+ Undetermined,
+}
+
+/// A connected USB Video Class camera.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct Camera {
+ /// Human-readable name, e.g. `"Logitech StreamCam"`.
+ pub name: String,
+ /// OS capture-layer identifier (AVFoundation `uniqueID`, DirectShow device
+ /// path). Used to open preview/controls; may embed a USB location and so
+ /// change when the camera is moved to another port.
+ pub unique_id: String,
+ /// USB `iSerialNumber` when the device reports one. Port-stable; preferred
+ /// for persisted config keys via [`Self::config_key`].
+ pub serial_number: Option<String>,
+ /// USB vendor id (`0x046d` for Logitech).
+ pub vendor_id: u16,
+ /// USB product id (e.g. `0x0893` for the StreamCam).
+ pub product_id: u16,
+ /// Largest supported frame size `(width, height)`, when the OS reports the
+ /// device's formats. Read from metadata only — no capture, no permission.
+ pub max_resolution: Option<(u32, u32)>,
+ /// Highest supported frame rate (fps) across all formats, when known.
+ pub max_fps: Option<u32>,
+}
+
+impl Camera {
+ /// Persistence key that is stable across USB ports.
+ ///
+ /// Prefers the USB serial when the device reports one. When it doesn't,
+ /// falls back to a model-scoped key (`camera:vid:pid`) so settings survive
+ /// a port change. Two serial-less units of the same model share this key
+ /// (no stronger USB identity); the GUI keeps them as separate live cards
+ /// via the OS capture id, not via this settings key.
+ #[must_use]
+ pub fn config_key(&self) -> String {
+ if let Some(serial) = self
+ .serial_number
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ {
+ format!(
+ "camera:{:04x}:{:04x}:serial:{}",
+ self.vendor_id,
+ self.product_id,
+ serial.to_ascii_lowercase()
+ )
+ } else {
+ format!("camera:{:04x}:{:04x}", self.vendor_id, self.product_id)
+ }
+ }
+}
+
+/// Whether this platform has a live-capture backend (preview + snapshot).
+/// Enumeration and UVC controls can be supported without it.
+#[must_use]
+pub const fn capture_supported() -> bool {
+ cfg!(any(
+ target_os = "macos",
+ target_os = "windows",
+ target_os = "linux"
+ ))
+}
+
+/// Serializes UVC device seizes against enumeration within this process.
+/// `USBDeviceOpenSeize` briefly detaches the camera's kernel driver, and an
+/// enumeration racing that window sees no camera at all — which read as the
+/// camera "disappearing" from the device list mid-slider-drag once
+/// enumeration moved off the UI thread. Control paths hold this for the
+/// seize's lifetime; enumeration takes it for the duration of the scan.
+#[cfg(target_os = "macos")]
+pub(crate) static USB_QUIESCE: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+/// Enumerate every connected **Logitech** UVC camera.
+///
+/// Non-Logitech cameras (the built-in FaceTime camera, virtual cameras, other
+/// vendors' webcams) are filtered out. Returns an empty list on platforms with
+/// no capture backend, or when no Logitech camera is attached.
+#[must_use]
+pub fn enumerate_cameras() -> Vec<Camera> {
+ enumerate_all()
+ .into_iter()
+ .filter(|camera| camera.vendor_id == LOGITECH_VID)
+ .collect()
+}
+
+#[cfg(target_os = "macos")]
+fn enumerate_all() -> Vec<Camera> {
+ // Wait out any in-flight control seize so the scan can't land in the
+ // window where the kernel driver is detached (poisoning is impossible —
+ // holders never panic — but recover anyway rather than unwrap).
+ let _quiesce = USB_QUIESCE
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let serials = uvc::usb_serials_by_location();
+ macos::enumerate()
+ .iter()
+ .filter_map(|raw| {
+ let mut camera = Camera::from_raw(&raw.name, &raw.unique_id, &raw.model_id)?;
+ if raw.max_width > 0 && raw.max_height > 0 {
+ camera.max_resolution = Some((raw.max_width, raw.max_height));
+ }
+ if raw.max_fps > 0 {
+ camera.max_fps = Some(raw.max_fps);
+ }
+ if let Some(location) = uvc::location_hint(&raw.unique_id) {
+ camera.serial_number = serials.get(&location).cloned();
+ }
+ Some(camera)
+ })
+ .collect()
+}
+
+#[cfg(target_os = "windows")]
+fn enumerate_all() -> Vec<Camera> {
+ uvc_windows::enumerate()
+}
+
+#[cfg(target_os = "linux")]
+fn enumerate_all() -> Vec<Camera> {
+ linux::nodes().iter().map(linux::describe).collect()
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
+fn enumerate_all() -> Vec<Camera> {
+ Vec::new()
+}
+
+#[cfg(any(test, target_os = "macos"))]
+impl Camera {
+ /// Build a [`Camera`] from an OS-reported `(name, unique_id, model_id)`.
+ ///
+ /// Returns `None` when `model_id` carries no USB vendor/product id — i.e.
+ /// it isn't a real USB camera (the macOS FaceTime camera's modelID is just
+ /// `"FaceTime HD Camera"`), so it can't be attributed to a vendor and is
+ /// dropped before the Logitech filter even runs. Format fields start `None`;
+ /// the platform backend fills them in.
+ fn from_raw(name: &str, unique_id: &str, model_id: &str) -> Option<Self> {
+ let (vendor_id, product_id) = parse_vid_pid(model_id)?;
+ Some(Self {
+ name: name.to_string(),
+ unique_id: unique_id.to_string(),
+ serial_number: None,
+ vendor_id,
+ product_id,
+ max_resolution: None,
+ max_fps: None,
+ })
+ }
+}
+
+/// Pull the USB vendor/product id out of an `AVCaptureDevice` modelID such as
+/// `"UVC Camera VendorID_1133 ProductID_2195"`. Both ids are **decimal** in
+/// that string (1133 == 0x046d, 2195 == 0x0893). `None` if either marker is
+/// absent.
+#[cfg(any(test, target_os = "macos"))]
+fn parse_vid_pid(model_id: &str) -> Option<(u16, u16)> {
+ let vendor_id = parse_marker(model_id, "VendorID_")?;
+ let product_id = parse_marker(model_id, "ProductID_")?;
+ Some((vendor_id, product_id))
+}
+
+/// Read the decimal number immediately following `marker` in `haystack`.
+#[cfg(any(test, target_os = "macos"))]
+fn parse_marker(haystack: &str, marker: &str) -> Option<u16> {
+ let rest = haystack.split(marker).nth(1)?;
+ let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
+ digits.parse().ok()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_logitech_streamcam_model_id() {
+ assert_eq!(
+ parse_vid_pid("UVC Camera VendorID_1133 ProductID_2195"),
+ Some((0x046d, 0x0893))
+ );
+ }
+
+ #[test]
+ fn rejects_model_id_without_usb_ids() {
+ assert_eq!(parse_vid_pid("FaceTime HD Camera"), None);
+ assert_eq!(parse_vid_pid("VendorID_1133 only"), None);
+ }
+
+ #[test]
+ fn from_raw_keeps_usb_cameras_and_drops_the_rest() {
+ assert_eq!(
+ Camera::from_raw(
+ "Logitech StreamCam",
+ "0x1123000046d0893",
+ "UVC Camera VendorID_1133 ProductID_2195",
+ ),
+ Some(Camera {
+ name: "Logitech StreamCam".to_string(),
+ unique_id: "0x1123000046d0893".to_string(),
+ serial_number: None,
+ vendor_id: LOGITECH_VID,
+ product_id: 0x0893,
+ max_resolution: None,
+ max_fps: None,
+ })
+ );
+ assert_eq!(
+ Camera::from_raw("FaceTime HD Camera", "uuid", "FaceTime HD Camera"),
+ None
+ );
+ }
+
+ #[test]
+ fn config_key_prefers_usb_serial_over_capture_id() {
+ let with_serial = Camera {
+ name: "Logitech StreamCam".into(),
+ unique_id: "0x1123000046d0893".into(),
+ serial_number: Some("ABC123".into()),
+ vendor_id: LOGITECH_VID,
+ product_id: 0x0893,
+ max_resolution: None,
+ max_fps: None,
+ };
+ assert_eq!(with_serial.config_key(), "camera:046d:0893:serial:abc123");
+ // Same physical camera on another USB port → same config key.
+ let moved = Camera {
+ unique_id: "0x14110000046d0893".into(),
+ ..with_serial.clone()
+ };
+ assert_eq!(moved.config_key(), with_serial.config_key());
+
+ let no_serial = Camera {
+ serial_number: None,
+ unique_id: "0x1123000046d0893".into(),
+ ..with_serial.clone()
+ };
+ // Model-scoped — same key after a port change even without a serial.
+ assert_eq!(no_serial.config_key(), "camera:046d:0893");
+ let moved_no_serial = Camera {
+ unique_id: "0x14110000046d0893".into(),
+ ..no_serial
+ };
+ assert_eq!(moved_no_serial.config_key(), "camera:046d:0893");
+ }
+}
diff --git a/crates/openlogi-camera/src/linux.rs b/crates/openlogi-camera/src/linux.rs
new file mode 100644
index 0000000000000000000000000000000000000000..901e80a5399369d53bd9503a037ae218ea0ee501
--- /dev/null
+++ b/crates/openlogi-camera/src/linux.rs
@@ -0,0 +1,215 @@
+//! V4L2 device discovery on Linux.
+//!
+//! A UVC camera exposes several `/dev/video*` nodes — one for capture, plus a
+//! metadata node carrying UVC timing data. `VIDIOC_QUERYCAP`'s `capabilities`
+//! field reports the *union* across the physical device's nodes, so it reads
+//! `VIDEO_CAPTURE` on the metadata node too; the `v4l` crate doesn't surface
+//! the per-node `device_caps`. Nodes are therefore classified by whether
+//! `VIDIOC_ENUM_FMT` yields any capture format, which only the capture node
+//! does.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use v4l::frameinterval::FrameIntervalEnum;
+use v4l::video::Capture;
+use v4l::{Device, FourCC};
+
+use crate::Camera;
+
+/// Where the kernel lists V4L2 nodes, one directory per `/dev/video*`.
+const SYSFS_V4L: &str = "/sys/class/video4linux";
+
+/// Stable-by-serial symlink farm `udev` maintains for V4L2 nodes.
+const BY_ID_DIR: &str = "/dev/v4l/by-id";
+
+/// A discovered capture node and the USB identity behind it.
+pub(crate) struct Node {
+ pub(crate) path: PathBuf,
+ pub(crate) name: String,
+ pub(crate) vendor_id: u16,
+ pub(crate) product_id: u16,
+ /// USB `iSerialNumber` from sysfs when the device reports one.
+ pub(crate) serial_number: Option<String>,
+}
+
+/// Enumerate every V4L2 capture node, newest-first by node index.
+///
+/// Non-USB devices (virtual cameras, loopback nodes) have no `idVendor` in
+/// sysfs and are skipped — they can't be attributed to a vendor, so the
+/// Logitech filter in [`crate::enumerate_cameras`] couldn't judge them anyway.
+pub(crate) fn nodes() -> Vec<Node> {
+ let Ok(entries) = fs::read_dir(SYSFS_V4L) else {
+ return Vec::new();
+ };
+
+ let mut nodes: Vec<Node> = entries
+ .flatten()
+ .filter_map(|entry| {
+ let sysfs = entry.path();
+ let dev_path = PathBuf::from("/dev").join(entry.file_name());
+ let (vendor_id, product_id) = usb_ids(&sysfs)?;
+ if !is_capture_node(&dev_path) {
+ return None;
+ }
+ Some(Node {
+ name: read_trimmed(&sysfs.join("name"))
+ .unwrap_or_else(|| dev_path.display().to_string()),
+ path: dev_path,
+ vendor_id,
+ product_id,
+ serial_number: usb_serial(&sysfs),
+ })
+ })
+ .collect();
+
+ nodes.sort_by(|a, b| a.path.cmp(&b.path));
+ nodes
+}
+
+/// Resolve a [`Camera::unique_id`] back to the `/dev/video*` node it names.
+///
+/// Ids are `by-id` symlinks when udev provides one, so this canonicalizes
+/// before comparing — a `by-id` path and its `/dev/videoN` target must resolve
+/// to the same node.
+pub(crate) fn node_for_unique_id(unique_id: &str) -> Option<PathBuf> {
+ let target = fs::canonicalize(unique_id).ok()?;
+ nodes()
+ .into_iter()
+ .find(|node| fs::canonicalize(&node.path).is_ok_and(|p| p == target))
+ .map(|node| node.path)
+}
+
+/// Build the [`Camera`] view of a node, including its largest frame size and
+/// highest frame rate. Format probing is metadata-only — `VIDIOC_ENUM_*`
+/// never starts a stream, so this costs no LED and needs no permission beyond
+/// opening the node.
+pub(crate) fn describe(node: &Node) -> Camera {
+ let (max_resolution, max_fps) =
+ Device::with_path(&node.path).map_or((None, None), |device| max_format(&device));
+
+ Camera {
+ name: node.name.clone(),
+ unique_id: unique_id_for(&node.path),
+ serial_number: node.serial_number.clone(),
+ vendor_id: node.vendor_id,
+ product_id: node.product_id,
+ max_resolution,
+ max_fps,
+ }
+}
+
+/// The `by-id` symlink for `path` when udev created one (it embeds the USB
+/// serial, so it survives replugging into another port), else the raw node
+/// path. Either way it round-trips through [`node_for_unique_id`].
+fn unique_id_for(path: &Path) -> String {
+ let canonical = fs::canonicalize(path).ok();
+ let by_id = fs::read_dir(BY_ID_DIR).ok().and_then(|entries| {
+ entries
+ .flatten()
+ .map(|entry| entry.path())
+ .find(|link| fs::canonicalize(link).ok() == canonical)
+ });
+ by_id
+ .unwrap_or_else(|| path.to_path_buf())
+ .display()
+ .to_string()
+}
+
+/// Read `idVendor`/`idProduct` from the USB device behind a V4L2 node.
+///
+/// `<sysfs>/device` is the USB *interface*; its parent holds the ids.
+fn usb_ids(sysfs: &Path) -> Option<(u16, u16)> {
+ let usb = usb_device_sysfs(sysfs)?;
+ let vendor = read_trimmed(&usb.join("idVendor"))?;
+ let product = read_trimmed(&usb.join("idProduct"))?;
+ Some((
+ u16::from_str_radix(&vendor, 16).ok()?,
+ u16::from_str_radix(&product, 16).ok()?,
+ ))
+}
+
+/// USB `iSerialNumber` from the parent USB device, when present and non-empty.
+fn usb_serial(sysfs: &Path) -> Option<String> {
+ let usb = usb_device_sysfs(sysfs)?;
+ let serial = read_trimmed(&usb.join("serial"))?;
+ let serial = serial.trim();
+ // Kernel placeholder when the descriptor has no iSerialNumber.
+ if serial.is_empty() || serial == "0" {
+ return None;
+ }
+ Some(serial.to_string())
+}
+
+/// Sysfs directory of the USB *device* behind a V4L2 node (parent of the
+/// interface entry at `<sysfs>/device`).
+fn usb_device_sysfs(sysfs: &Path) -> Option<PathBuf> {
+ fs::canonicalize(sysfs.join("device").join("..")).ok()
+}
+
+/// Whether the node serves video capture, i.e. enumerates at least one capture
+/// format. Metadata nodes open fine but enumerate none.
+fn is_capture_node(dev_path: &Path) -> bool {
+ Device::with_path(dev_path)
+ .and_then(|device| device.enum_formats())
+ .is_ok_and(|formats| !formats.is_empty())
+}
+
+/// Largest frame size across all formats, and the highest frame rate offered
+/// at any size. Both are `None` when the driver reports only stepwise or
+/// continuous ranges, which carry no single "max" worth showing.
+fn max_format(device: &Device) -> (Option<(u32, u32)>, Option<u32>) {
+ let Ok(formats) = device.enum_formats() else {
+ return (None, None);
+ };
+
+ let mut max_resolution: Option<(u32, u32)> = None;
+ let mut max_fps: Option<u32> = None;
+
+ for format in formats {
+ let Ok(sizes) = device.enum_framesizes(format.fourcc) else {
+ continue;
+ };
+ for size in sizes {
+ for discrete in size.size.to_discrete() {
+ let candidate = (discrete.width, discrete.height);
+ if max_resolution.is_none_or(|(w, h)| {
+ u64::from(candidate.0) * u64::from(candidate.1) > u64::from(w) * u64::from(h)
+ }) {
+ max_resolution = Some(candidate);
+ }
+ if let Some(fps) = max_discrete_fps(device, format.fourcc, candidate) {
+ max_fps = Some(max_fps.map_or(fps, |best: u32| best.max(fps)));
+ }
+ }
+ }
+ }
+
+ (max_resolution, max_fps)
+}
+
+/// Highest discrete frame rate the driver offers for one format and size.
+///
+/// Intervals are periods (seconds per frame), so the highest rate is the
+/// smallest interval. Stepwise/continuous ranges are skipped — they describe a
+/// span rather than an offered rate — as are zero-numerator entries, which
+/// would divide by zero.
+fn max_discrete_fps(device: &Device, fourcc: FourCC, size: (u32, u32)) -> Option<u32> {
+ let intervals = device.enum_frameintervals(fourcc, size.0, size.1).ok()?;
+ intervals
+ .into_iter()
+ .filter_map(|interval| match interval.interval {
+ FrameIntervalEnum::Discrete(fraction) if fraction.numerator > 0 => {
+ Some(fraction.denominator / fraction.numerator)
+ }
+ _ => None,
+ })
+ .max()
+}
+
+/// Read a sysfs attribute, trimming the trailing newline the kernel appends.
+fn read_trimmed(path: &Path) -> Option<String> {
+ fs::read_to_string(path)
+ .ok()
+ .map(|text| text.trim().to_string())
+}
diff --git a/crates/openlogi-camera/src/macos.rs b/crates/openlogi-camera/src/macos.rs
new file mode 100644
index 0000000000000000000000000000000000000000..a8351830ff63df0511a8713eb710e015ae9f723c
--- /dev/null
+++ b/crates/openlogi-camera/src/macos.rs
@@ -0,0 +1,223 @@
+//! AVFoundation-backed camera enumeration.
+//!
+//! `+[AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo]` returns every
+//! video-capable capture device macOS knows about; for each we read the same
+//! `localizedName` / `uniqueID` / `modelID` strings `system_profiler
+//! SPCameraDataType` reports, plus the device's supported formats (resolution +
+//! frame rate). Vendor parsing + the Logitech filter live in the
+//! platform-independent parent module.
+//!
+//! All of this is metadata — no capture session is opened, so no Camera
+//! permission is required.
+//!
+//! FFI is `objc2` (matching the rest of the workspace's ObjC surface): the
+//! dynamic `AVCaptureDevice` classes aren't in a typed framework crate, so this
+//! uses the `objc2` runtime (`AnyClass::get` + `msg_send!`) like
+//! `platform/permissions.rs`. There is no long-lived ownership here — every
+//! object AVFoundation hands back is autoreleased and copied into an owned Rust
+//! value before the enclosing [`autoreleasepool`] drains — so no `Retained<T>`
+//! is needed (an off-run-loop caller thread has no pool of its own).
+
+#![expect(
+ unsafe_code,
+ reason = "AVFoundation (AVCaptureDevice) camera-enumeration FFI"
+)]
+
+use std::ffi::CStr;
+use std::os::raw::c_char;
+
+use objc2::encode::{Encoding, RefEncode};
+use objc2::msg_send;
+use objc2::rc::autoreleasepool;
+use objc2::runtime::{AnyClass, AnyObject};
+
+/// Raw camera metadata as reported by `AVCaptureDevice`, before vendor parsing.
+pub(crate) struct RawCamera {
+ pub name: String,
+ pub unique_id: String,
+ pub model_id: String,
+ /// Largest supported frame size, `(0, 0)` if none was reported.
+ pub max_width: u32,
+ pub max_height: u32,
+ /// Highest supported frame rate (fps) at any format, `0` if none.
+ pub max_fps: u32,
+}
+
+// `AVMediaTypeVideo` is an `NSString` constant exported by AVFoundation; the
+// framework must be linked for it and the `AVCaptureDevice` class to resolve.
+#[link(name = "AVFoundation", kind = "framework")]
+unsafe extern "C" {
+ static AVMediaTypeVideo: *const AnyObject;
+}
+
+#[repr(C)]
+struct CMVideoDimensions {
+ width: i32,
+ height: i32,
+}
+
+/// Opaque `CMFormatDescriptionRef`. `AVCaptureDeviceFormat.formatDescription`
+/// hands back a CoreMedia handle, not an Objective-C object, so it needs its own
+/// type encoding: objc2 verifies msg_send return types against the runtime and
+/// panics if we claim `AnyObject` (`@`) for what CoreMedia reports as
+/// `^{opaqueCMFormatDescription=}`.
+#[repr(C)]
+struct CMFormatDescription {
+ _private: [u8; 0],
+}
+
+// SAFETY: only ever handled behind a pointer (a `CMFormatDescriptionRef`); the
+// encoding mirrors CoreMedia's `^{opaqueCMFormatDescription=}`.
+unsafe impl RefEncode for CMFormatDescription {
+ const ENCODING_REF: Encoding =
+ Encoding::Pointer(&Encoding::Struct("opaqueCMFormatDescription", &[]));
+}
+
+#[link(name = "CoreMedia", kind = "framework")]
+unsafe extern "C" {
+ fn CMVideoFormatDescriptionGetDimensions(desc: *mut CMFormatDescription) -> CMVideoDimensions;
+}
+
+/// Enumerate every video `AVCaptureDevice`, as raw metadata. The Logitech
+/// filter is applied by the caller in `lib.rs`.
+pub(crate) fn enumerate() -> Vec<RawCamera> {
+ let Some(device_cls) = AnyClass::get(c"AVCaptureDevice") else {
+ return Vec::new();
+ };
+
+ // An explicit pool brackets the work: the returned array and its devices are
+ // autoreleased, and a caller thread with no run loop drains none on its own.
+ // Every string is copied into an owned `String` before the pool drops.
+ autoreleasepool(|_| {
+ // SAFETY: `AVCaptureDevice` exists once AVFoundation is linked. Every
+ // message uses a documented selector and matching types; `AVMediaTypeVideo`
+ // is the framework's exported `NSString` constant.
+ unsafe {
+ let devices: *mut AnyObject =
+ msg_send![device_cls, devicesWithMediaType: AVMediaTypeVideo];
+
+ let mut out = Vec::new();
+ if !devices.is_null() {
+ let count: usize = msg_send![devices, count];
+ out.reserve(count);
+ for i in 0..count {
+ let device: *mut AnyObject = msg_send![devices, objectAtIndex: i];
+ if device.is_null() {
+ continue;
+ }
+ let name_obj: *mut AnyObject = msg_send![device, localizedName];
+ let uid_obj: *mut AnyObject = msg_send![device, uniqueID];
+ let model_obj: *mut AnyObject = msg_send![device, modelID];
+ if let (Some(name), Some(unique_id), Some(model_id)) =
+ (nsstring(name_obj), nsstring(uid_obj), nsstring(model_obj))
+ {
+ let (max_width, max_height, max_fps) = best_format(device);
+ out.push(RawCamera {
+ name,
+ unique_id,
+ model_id,
+ max_width,
+ max_height,
+ max_fps,
+ });
+ }
+ }
+ }
+ out
+ }
+ })
+}
+
+/// Largest `(width, height, max_fps)` among the device's supported formats, or
+/// `(0, 0, 0)` when none are reported. Reads format metadata only — no capture
+/// session, so no Camera permission is needed.
+fn best_format(device: *mut AnyObject) -> (u32, u32, u32) {
+ // SAFETY: `device` is a valid `AVCaptureDevice`; `formats` is an autoreleased
+ // `NSArray` of `AVCaptureDeviceFormat`, each exposing a `CMFormatDescription`
+ // and frame-rate ranges via documented selectors.
+ unsafe {
+ let formats: *mut AnyObject = msg_send![device, formats];
+ if formats.is_null() {
+ return (0, 0, 0);
+ }
+ let count: usize = msg_send![formats, count];
+ let mut best = (0u32, 0u32, 0u32);
+ for i in 0..count {
+ let format: *mut AnyObject = msg_send![formats, objectAtIndex: i];
+ if format.is_null() {
+ continue;
+ }
+ let desc: *mut CMFormatDescription = msg_send![format, formatDescription];
+ if desc.is_null() {
+ continue;
+ }
+ let dims = CMVideoFormatDescriptionGetDimensions(desc);
+ let w = u32::try_from(dims.width).unwrap_or(0);
+ let h = u32::try_from(dims.height).unwrap_or(0);
+ let fps = max_frame_rate(format);
+ let area = u64::from(w) * u64::from(h);
+ let best_area = u64::from(best.0) * u64::from(best.1);
+ if area > best_area || (w == best.0 && h == best.1 && fps > best.2) {
+ best = (w, h, fps);
+ }
+ }
+ best
+ }
+}
+
+/// Highest `maxFrameRate` across a format's `videoSupportedFrameRateRanges`.
+fn max_frame_rate(format: *mut AnyObject) -> u32 {
+ // SAFETY: documented selectors on a valid `AVCaptureDeviceFormat` /
+ // `AVFrameRateRange`; `maxFrameRate` returns a `double`.
+ unsafe {
+ let ranges: *mut AnyObject = msg_send![format, videoSupportedFrameRateRanges];
+ if ranges.is_null() {
+ return 0;
+ }
+ let count: usize = msg_send![ranges, count];
+ let mut max = 0.0f64;
+ for i in 0..count {
+ let range: *mut AnyObject = msg_send![ranges, objectAtIndex: i];
+ if range.is_null() {
+ continue;
+ }
+ let r: f64 = msg_send![range, maxFrameRate];
+ if r > max {
+ max = r;
+ }
+ }
+ round_fps(max)
+ }
+}
+
+/// Round a frame rate to the nearest whole fps (so 59.94 reads as 60).
+#[allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_sign_loss,
+ reason = "fps is rounded, finite, and clamped to a small non-negative range"
+)]
+fn round_fps(rate: f64) -> u32 {
+ if rate.is_finite() && rate > 0.0 {
+ rate.round() as u32
+ } else {
+ 0
+ }
+}
+
+/// Copy an `NSString` into an owned Rust `String`. `None` for a null pointer or
+/// non-UTF-8 contents.
+fn nsstring(s: *mut AnyObject) -> Option<String> {
+ if s.is_null() {
+ return None;
+ }
+ // SAFETY: `s` is a non-null `NSString`; `UTF8String` yields a NUL-terminated
+ // C string valid for the lifetime of the (autoreleased) string, which we
+ // copy out of immediately.
+ unsafe {
+ let utf8: *const c_char = msg_send![s, UTF8String];
+ if utf8.is_null() {
+ return None;
+ }
+ Some(CStr::from_ptr(utf8).to_string_lossy().into_owned())
+ }
+}
diff --git a/crates/openlogi-camera/src/uvc.rs b/crates/openlogi-camera/src/uvc.rs
new file mode 100644
index 0000000000000000000000000000000000000000..0d38193db5f65cadb4e29c60d300f223ff70b653
--- /dev/null
+++ b/crates/openlogi-camera/src/uvc.rs
@@ -0,0 +1,975 @@
+//! Device-level UVC Processing-Unit controls (brightness/contrast/…) over IOKit.
+//!
+//! These are *not* AVFoundation settings: they're USB Video Class control
+//! transfers to the camera's Processing Unit, so a change lands in the camera's
+//! own registers and is seen by every app — Google Meet, Zoom, OBS — not just
+//! our preview. This is the same mechanism `uvc-util` and "Webcam Settings" use,
+//! and it works while the camera is streaming because the request rides the
+//! default control endpoint, which the streaming driver does not own.
+//!
+//! Flow: match the USB device by vendor/product id (disambiguating on the
+//! AVFoundation `unique_id`'s location id when several identical cameras are
+//! attached), open it via the IOKit `IOUSBDeviceInterface` plug-in, parse the
+//! configuration descriptor for the VideoControl interface number and the
+//! Processing-Unit id, then issue UVC `GET_*`/`SET_CUR` requests.
+
+#![expect(
+ unsafe_code,
+ reason = "IOKit USB (IOUSBDeviceInterface) control-transfer FFI for UVC Processing-Unit controls"
+)]
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_possible_wrap,
+ clippy::cast_sign_loss,
+ reason = "UVC payloads are bounded 16-bit values copied verbatim"
+)]
+
+use std::ffi::{CString, c_void};
+use std::ptr;
+
+/// Which UVC entity a control request addresses: the Camera Terminal (lens:
+/// zoom/focus/exposure) or the Processing Unit (image: brightness/…).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Unit {
+ CameraTerminal,
+ Processing,
+}
+
+pub use crate::controls::{
+ AutoState, AutoToggle, CameraControl, CameraState, ControlError, ControlRange,
+};
+
+impl CameraControl {
+ fn unit(self) -> Unit {
+ match self {
+ Self::Zoom | Self::Focus | Self::Exposure => Unit::CameraTerminal,
+ _ => Unit::Processing,
+ }
+ }
+
+ /// UVC control selector (Camera Terminal §A.9.4, Processing Unit §A.9.5).
+ #[allow(
+ clippy::match_same_arms,
+ reason = "Focus (CT) and Tint (PU) share 0x06 by coincidence — they address different units"
+ )]
+ fn selector(self) -> u16 {
+ match self {
+ Self::Zoom => 0x0B, // CT_ZOOM_ABSOLUTE_CONTROL
+ Self::Focus => 0x06, // CT_FOCUS_ABSOLUTE_CONTROL
+ Self::Exposure => 0x04, // CT_EXPOSURE_TIME_ABSOLUTE_CONTROL
+ Self::Brightness => 0x02, // PU_BRIGHTNESS_CONTROL
+ Self::Contrast => 0x03, // PU_CONTRAST_CONTROL
+ Self::Saturation => 0x07, // PU_SATURATION_CONTROL
+ Self::Sharpness => 0x08, // PU_SHARPNESS_CONTROL
+ Self::WhiteBalance => 0x0A, // PU_WHITE_BALANCE_TEMPERATURE_CONTROL
+ Self::Tint => 0x06, // PU_HUE_CONTROL
+ }
+ }
+
+ /// Payload size in bytes (exposure time is a dwExposureTimeAbsolute u32).
+ fn len(self) -> usize {
+ match self {
+ Self::Exposure => 4,
+ _ => 2,
+ }
+ }
+
+ /// Brightness and hue are signed controls; the rest are unsigned.
+ fn signed(self) -> bool {
+ matches!(self, Self::Brightness | Self::Tint)
+ }
+}
+
+impl AutoToggle {
+ fn unit(self) -> Unit {
+ match self {
+ Self::Focus | Self::Exposure => Unit::CameraTerminal,
+ Self::WhiteBalance => Unit::Processing,
+ }
+ }
+
+ fn selector(self) -> u16 {
+ match self {
+ Self::Focus => 0x08, // CT_FOCUS_AUTO_CONTROL
+ Self::Exposure => 0x02, // CT_AE_MODE_CONTROL
+ Self::WhiteBalance => 0x0B, // PU_WHITE_BALANCE_TEMPERATURE_AUTO_CONTROL
+ }
+ }
+}
+
+// ── UVC constants ────────────────────────────────────────────────────────────
+const UVC_SET_CUR: u8 = 0x01;
+const UVC_GET_CUR: u8 = 0x81;
+const UVC_GET_MIN: u8 = 0x82;
+const UVC_GET_MAX: u8 = 0x83;
+const UVC_GET_DEF: u8 = 0x87;
+// bmRequestType: class request to an interface recipient. Bit 7 = data direction.
+const RT_GET: u8 = 0xA1; // device-to-host | class | interface
+const RT_SET: u8 = 0x21; // host-to-device | class | interface
+
+const CC_VIDEO: u8 = 0x0E;
+const SC_VIDEOCONTROL: u8 = 0x01;
+const DESC_INTERFACE: u8 = 0x04;
+const DESC_CS_INTERFACE: u8 = 0x24;
+const VC_INPUT_TERMINAL: u8 = 0x02;
+const VC_PROCESSING_UNIT: u8 = 0x05;
+/// wTerminalType for a camera sensor input terminal (ITT_CAMERA).
+const ITT_CAMERA: u16 = 0x0201;
+
+// UVC AE-mode bitmap bits (CT_AE_MODE_CONTROL): everything except fully
+// manual counts as "auto" for the toggle.
+const AE_MANUAL: u8 = 0x01;
+/// Auto modes to try when enabling auto-exposure, most- to least-automatic
+/// (full auto, aperture priority, shutter priority) — cameras support subsets.
+const AE_AUTO_MODES: [u8; 3] = [0x02, 0x08, 0x04];
+
+const KIO_RETURN_SUCCESS: i32 = 0;
+
+/// Hold the process-wide seize/enumeration lock — see [`crate::USB_QUIESCE`].
+fn quiesce() -> std::sync::MutexGuard<'static, ()> {
+ crate::USB_QUIESCE
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+}
+
+/// Read a control's min/max/default/current straight from the device.
+///
+/// # Errors
+/// [`ControlError::NotFound`] when no USB device matches, [`ControlError::Io`]
+/// on an IOKit failure, or [`ControlError::Unsupported`] if the camera NAKs the
+/// request.
+pub fn control_range(
+ unique_id: &str,
+ control: CameraControl,
+) -> Result<ControlRange, ControlError> {
+ let _quiesce = quiesce();
+ let dev = UsbDevice::open_for(unique_id)?;
+ let min = dev.get(control, UVC_GET_MIN)?;
+ let max = dev.get(control, UVC_GET_MAX)?;
+ let default = dev.get(control, UVC_GET_DEF)?;
+ let current = dev.get(control, UVC_GET_CUR).unwrap_or(default);
+ Ok(ControlRange {
+ min,
+ max,
+ default,
+ current,
+ })
+}
+
+/// Read every supported control in a single device-open (controls the camera
+/// NAKs are skipped). Batching keeps the device-seize count down — important
+/// while the camera is streaming.
+///
+/// # Errors
+/// [`ControlError::NotFound`] when no USB device matches.
+pub fn control_ranges(unique_id: &str) -> Result<Vec<(CameraControl, ControlRange)>, ControlError> {
+ Ok(read_camera_state(unique_id)?.controls)
+}
+
+/// Read every supported control range *and* auto-toggle state in a single
+/// device-open — what the GUI controls panel builds itself from.
+///
+/// # Errors
+/// [`ControlError::NotFound`] when no USB device matches.
+pub fn read_camera_state(unique_id: &str) -> Result<CameraState, ControlError> {
+ let _quiesce = quiesce();
+ let dev = UsbDevice::open_for(unique_id)?;
+ let mut state = CameraState::default();
+ for control in CameraControl::ALL {
+ if let (Ok(min), Ok(max), Ok(default)) = (
+ dev.get(control, UVC_GET_MIN),
+ dev.get(control, UVC_GET_MAX),
+ dev.get(control, UVC_GET_DEF),
+ ) {
+ let current = dev.get(control, UVC_GET_CUR).unwrap_or(default);
+ state.controls.push((
+ control,
+ ControlRange {
+ min,
+ max,
+ default,
+ current,
+ },
+ ));
+ }
+ }
+ for toggle in AutoToggle::ALL {
+ if let (Ok(current), Ok(default)) = (
+ dev.get_auto(toggle, UVC_GET_CUR),
+ dev.get_auto(toggle, UVC_GET_DEF),
+ ) {
+ state.autos.push((toggle, AutoState { current, default }));
+ }
+ }
+ Ok(state)
+}
+
+/// Write a control's current value to the device. Persists in the camera's
+/// registers, so other apps observe it too.
+///
+/// # Errors
+/// As [`control_range`].
+pub fn set_control(
+ unique_id: &str,
+ control: CameraControl,
+ value: i32,
+) -> Result<(), ControlError> {
+ let _quiesce = quiesce();
+ let dev = UsbDevice::open_for(unique_id)?;
+ dev.set(control, value)
+}
+
+/// Switch an auto mode (focus / exposure / white balance) on or off.
+///
+/// # Errors
+/// As [`control_range`].
+pub fn set_auto(unique_id: &str, toggle: AutoToggle, on: bool) -> Result<(), ControlError> {
+ let _quiesce = quiesce();
+ let dev = UsbDevice::open_for(unique_id)?;
+ dev.set_auto(toggle, on)
+}
+
+/// Apply a batch of auto toggles and control values in a single device-open —
+/// how profiles and saved-state reapplication write, so the seize count stays
+/// at one no matter how many controls change. Autos land first so a manual
+/// value isn't rejected by a still-armed auto mode. Every write is attempted
+/// (one rejection doesn't abandon the rest), but any failure surfaces so
+/// callers never persist or display a batch the hardware didn't take.
+///
+/// # Errors
+/// [`ControlError::NotFound`] when no USB device matches; otherwise the first
+/// per-write error after attempting the whole batch.
+pub fn apply_settings(
+ unique_id: &str,
+ autos: &[(AutoToggle, bool)],
+ values: &[(CameraControl, i32)],
+) -> Result<(), ControlError> {
+ let _quiesce = quiesce();
+ let dev = UsbDevice::open_for(unique_id)?;
+ let mut first_err = None;
+ for (toggle, on) in autos {
+ if let Err(e) = dev.set_auto(*toggle, *on) {
+ first_err.get_or_insert(e);
+ }
+ }
+ for (control, value) in values {
+ if let Err(e) = dev.set(*control, *value) {
+ first_err.get_or_insert(e);
+ }
+ }
+ first_err.map_or(Ok(()), Err)
+}
+
+// ── AVFoundation unique-id → USB location id ─────────────────────────────────
+// macOS UVC `uniqueID`s are `<location hex><vid %04x><pid %04x>` — but the
+// location comes out *unpadded* (a StreamCam on bus 0x01123000 yields
+// `0x1123000046d0893`, 15 digits). So the location is everything **except**
+// the trailing 8 vid+pid digits; taking a fixed leading 8 would swallow a
+// nibble of the vid and shift the location. Only used to pick between two
+// identical cameras; matching is primarily by vendor id.
+pub(crate) fn location_hint(unique_id: &str) -> Option<u32> {
+ let hex = unique_id.strip_prefix("0x").unwrap_or(unique_id);
+ let location = hex.get(..hex.len().checked_sub(8)?)?;
+ if location.is_empty() {
+ return None;
+ }
+ u32::from_str_radix(location, 16).ok()
+}
+
+/// USB `iSerialNumber` for every attached `IOUSBDevice`, keyed by location id.
+///
+/// Read from the IORegistry only — no device open — so enumeration can prefer
+/// the port-stable serial for config keys without racing a control seize.
+pub(crate) fn usb_serials_by_location() -> std::collections::HashMap<u32, String> {
+ use std::collections::HashMap;
+
+ let mut out = HashMap::new();
+ let Ok(class) = CString::new("IOUSBDevice") else {
+ return out;
+ };
+ // SAFETY: matching dict is consumed by GetMatchingServices; each service
+ // is released after we read its registry properties.
+ unsafe {
+ let matching = IOServiceMatching(class.as_ptr());
+ if matching.is_null() {
+ return out;
+ }
+ let mut iter: IoIterator = 0;
+ if IOServiceGetMatchingServices(kIOMainPortDefault, matching, &raw mut iter)
+ != KIO_RETURN_SUCCESS
+ {
+ return out;
+ }
+ let key = cf_string("USB Serial Number");
+ loop {
+ let service = IOIteratorNext(iter);
+ if service == 0 {
+ break;
+ }
+ if let Some((location, serial)) = registry_location_and_serial(service, key) {
+ out.entry(location).or_insert(serial);
+ }
+ IOObjectRelease(service);
+ }
+ if !key.is_null() {
+ CFRelease(key);
+ }
+ IOObjectRelease(iter);
+ }
+ out
+}
+
+/// Location id + USB serial from an `IOUSBDevice` service, without opening it.
+unsafe fn registry_location_and_serial(
+ service: IoService,
+ serial_key: *const c_void,
+) -> Option<(u32, String)> {
+ unsafe {
+ // Prefer the USB device interface for location (matches control open);
+ // fall back to the registry number property when the plug-in is busy.
+ let location = device_location_id(service).or_else(|| {
+ let key = cf_string("locationID");
+ let loc = cf_number_u32(IORegistryEntryCreateCFProperty(
+ service,
+ key,
+ ptr::null(),
+ 0,
+ ));
+ if !key.is_null() {
+ CFRelease(key);
+ }
+ loc
+ })?;
+ let serial_ref = IORegistryEntryCreateCFProperty(service, serial_key, ptr::null(), 0);
+ let serial = cf_string_value(serial_ref)?;
+ if serial.is_empty() {
+ return None;
+ }
+ Some((location, serial))
+ }
+}
+
+/// Location id via a transient `IOUSBDeviceInterface` (no open/seize).
+unsafe fn device_location_id(service: IoService) -> Option<u32> {
+ unsafe {
+ let user_client = make_uuid(&KIO_USB_DEVICE_USER_CLIENT_TYPE_ID);
+ let plugin_type = make_uuid(&KIO_CF_PLUGIN_INTERFACE_ID);
+ let mut plugin: *mut *mut PlugInInterface = ptr::null_mut();
+ let mut score: i32 = 0;
+ let rc = IOCreatePlugInInterfaceForService(
+ service,
+ user_client,
+ plugin_type,
+ &raw mut plugin,
+ &raw mut score,
+ );
+ CFRelease(user_client);
+ CFRelease(plugin_type);
+ if rc != KIO_RETURN_SUCCESS || plugin.is_null() {
+ return None;
+ }
+ let dev_uuid_ref = make_uuid(&KIO_USB_DEVICE_INTERFACE_ID);
+ let dev_uuid = CFUUIDGetUUIDBytes(dev_uuid_ref);
+ CFRelease(dev_uuid_ref);
+ let mut dev_ptr: *mut c_void = ptr::null_mut();
+ let qrc = ((**plugin).query_interface)(plugin.cast::<c_void>(), dev_uuid, &raw mut dev_ptr);
+ IODestroyPlugInInterface(plugin);
+ if qrc != 0 || dev_ptr.is_null() {
+ return None;
+ }
+ let dev = dev_ptr.cast::<*mut UsbDeviceInterface>();
+ let mut location: u32 = 0;
+ let ok = ((**dev).get_location_id)(dev.cast::<c_void>(), &raw mut location)
+ == KIO_RETURN_SUCCESS;
+ ((**dev).release)(dev.cast::<c_void>());
+ ok.then_some(location)
+ }
+}
+
+fn cf_string(s: &str) -> *const c_void {
+ let Ok(c) = CString::new(s) else {
+ return ptr::null();
+ };
+ // SAFETY: UTF-8 C string; returned CFString is owned by the caller.
+ unsafe { CFStringCreateWithCString(ptr::null(), c.as_ptr(), K_CF_STRING_ENCODING_UTF8) }
+}
+
+unsafe fn cf_string_value(cf: *const c_void) -> Option<String> {
+ if cf.is_null() {
+ return None;
+ }
+ unsafe {
+ if CFGetTypeID(cf) != CFStringGetTypeID() {
+ CFRelease(cf);
+ return None;
+ }
+ let len = CFStringGetLength(cf);
+ // UTF-8 worst case 4 bytes/char + NUL.
+ let cap = usize::try_from(len).ok()?.checked_mul(4)?.checked_add(1)?;
+ let mut buf = vec![0u8; cap];
+ let ok = CFStringGetCString(
+ cf,
+ buf.as_mut_ptr().cast(),
+ isize::try_from(cap).ok()?,
+ K_CF_STRING_ENCODING_UTF8,
+ ) != 0;
+ CFRelease(cf);
+ if !ok {
+ return None;
+ }
+ let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
+ String::from_utf8(buf[..nul].to_vec()).ok()
+ }
+}
+
+unsafe fn cf_number_u32(cf: *const c_void) -> Option<u32> {
+ if cf.is_null() {
+ return None;
+ }
+ unsafe {
+ if CFGetTypeID(cf) != CFNumberGetTypeID() {
+ CFRelease(cf);
+ return None;
+ }
+ let mut value: u32 = 0;
+ let ok = CFNumberGetValue(cf, K_CF_NUMBER_SINT32_TYPE, (&raw mut value).cast()) != 0;
+ CFRelease(cf);
+ ok.then_some(value)
+ }
+}
+
+// ── IOKit / CoreFoundation FFI ───────────────────────────────────────────────
+type IoReturn = i32;
+type IoService = u32;
+type IoIterator = u32;
+type CfUuidRef = *const c_void;
+
+#[repr(C)]
+#[derive(Clone, Copy)]
+struct CfUuidBytes {
+ bytes: [u8; 16],
+}
+
+#[repr(C)]
+struct IoUsbDevRequest {
+ bm_request_type: u8,
+ b_request: u8,
+ w_value: u16,
+ w_index: u16,
+ w_length: u16,
+ p_data: *mut c_void,
+ w_len_done: u32,
+}
+
+// IOCFPlugInInterface — we only need the IUnknown head (QueryInterface/Release).
+#[repr(C)]
+struct PlugInInterface {
+ _reserved: *mut c_void,
+ query_interface: extern "C" fn(*mut c_void, CfUuidBytes, *mut *mut c_void) -> i32,
+ add_ref: extern "C" fn(*mut c_void) -> u32,
+ release: extern "C" fn(*mut c_void) -> u32,
+}
+
+// IOUSBDeviceInterface vtable (IOUSBLib.h). Slots we don't call are typed as
+// opaque pointers so the offsets of the ones we *do* call stay correct.
+#[repr(C)]
+struct UsbDeviceInterface {
+ _reserved: *mut c_void,
+ query_interface: extern "C" fn(*mut c_void, CfUuidBytes, *mut *mut c_void) -> i32,
+ add_ref: extern "C" fn(*mut c_void) -> u32,
+ release: extern "C" fn(*mut c_void) -> u32,
+ create_device_async_event_source: *const c_void,
+ get_device_async_event_source: *const c_void,
+ create_device_async_port: *const c_void,
+ get_device_async_port: *const c_void,
+ usb_device_open: extern "C" fn(*mut c_void) -> IoReturn,
+ usb_device_close: extern "C" fn(*mut c_void) -> IoReturn,
+ get_device_class: *const c_void,
+ get_device_sub_class: *const c_void,
+ get_device_protocol: *const c_void,
+ get_device_vendor: extern "C" fn(*mut c_void, *mut u16) -> IoReturn,
+ get_device_product: extern "C" fn(*mut c_void, *mut u16) -> IoReturn,
+ get_device_release_number: *const c_void,
+ get_device_address: *const c_void,
+ get_device_bus_power_available: *const c_void,
+ get_device_speed: *const c_void,
+ get_number_of_configurations: extern "C" fn(*mut c_void, *mut u8) -> IoReturn,
+ get_location_id: extern "C" fn(*mut c_void, *mut u32) -> IoReturn,
+ get_configuration_descriptor_ptr: extern "C" fn(*mut c_void, u8, *mut *const u8) -> IoReturn,
+ get_configuration: *const c_void,
+ set_configuration: *const c_void,
+ get_bus_frame_number: *const c_void,
+ reset_device: *const c_void,
+ device_request: extern "C" fn(*mut c_void, *mut IoUsbDevRequest) -> IoReturn,
+ device_request_async: *const c_void,
+ create_interface_iterator: *const c_void,
+ // IOUSBDeviceInterface182 adds OpenSeize: open even while the kernel video
+ // driver holds the device for streaming, so controls work during a preview.
+ usb_device_open_seize: extern "C" fn(*mut c_void) -> IoReturn,
+ // remaining methods unused
+}
+
+#[link(name = "IOKit", kind = "framework")]
+unsafe extern "C" {
+ static kIOMainPortDefault: u32;
+ fn IOServiceMatching(name: *const i8) -> *mut c_void;
+ fn IOServiceGetMatchingServices(
+ main_port: u32,
+ matching: *mut c_void,
+ existing: *mut IoIterator,
+ ) -> IoReturn;
+ fn IOIteratorNext(iterator: IoIterator) -> IoService;
+ fn IOObjectRelease(object: u32) -> IoReturn;
+ fn IOCreatePlugInInterfaceForService(
+ service: IoService,
+ plugin_type: CfUuidRef,
+ interface_type: CfUuidRef,
+ plug_in: *mut *mut *mut PlugInInterface,
+ score: *mut i32,
+ ) -> IoReturn;
+ fn IODestroyPlugInInterface(interface: *mut *mut PlugInInterface) -> IoReturn;
+ fn IORegistryEntryCreateCFProperty(
+ entry: IoService,
+ key: *const c_void,
+ allocator: *const c_void,
+ options: u32,
+ ) -> *const c_void;
+}
+
+#[link(name = "CoreFoundation", kind = "framework")]
+unsafe extern "C" {
+ fn CFUUIDCreateFromUUIDBytes(allocator: *const c_void, bytes: CfUuidBytes) -> CfUuidRef;
+ fn CFUUIDGetUUIDBytes(uuid: CfUuidRef) -> CfUuidBytes;
+ fn CFRelease(cf: *const c_void);
+ fn CFStringCreateWithCString(
+ allocator: *const c_void,
+ c_str: *const i8,
+ encoding: u32,
+ ) -> *const c_void;
+ fn CFStringGetTypeID() -> usize;
+ fn CFNumberGetTypeID() -> usize;
+ fn CFGetTypeID(cf: *const c_void) -> usize;
+ fn CFStringGetLength(s: *const c_void) -> isize;
+ fn CFStringGetCString(s: *const c_void, buf: *mut i8, buffer_size: isize, encoding: u32) -> u8;
+ fn CFNumberGetValue(number: *const c_void, the_type: i32, value_ptr: *mut c_void) -> u8;
+}
+
+/// `kCFStringEncodingUTF8`.
+const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
+/// `kCFNumberSInt32Type` — locationID is a 32-bit number in the registry.
+const K_CF_NUMBER_SINT32_TYPE: i32 = 3;
+
+// IOUSBLib UUIDs, as raw bytes (UUID order).
+const KIO_USB_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [
+ 0x9d, 0xc7, 0xb7, 0x80, 0x9e, 0xc0, 0x11, 0xd4, 0xa5, 0x4f, 0x00, 0x0a, 0x27, 0x05, 0x28, 0x61,
+];
+const KIO_CF_PLUGIN_INTERFACE_ID: [u8; 16] = [
+ 0xc2, 0x44, 0xe8, 0x58, 0x10, 0x9c, 0x11, 0xd4, 0x91, 0xd4, 0x00, 0x50, 0xe4, 0xc6, 0x42, 0x6f,
+];
+// kIOUSBDeviceInterfaceID182 — the first version exposing USBDeviceOpenSeize.
+const KIO_USB_DEVICE_INTERFACE_ID: [u8; 16] = [
+ 0x15, 0x2f, 0xc4, 0x96, 0x48, 0x91, 0x11, 0xd5, 0x9d, 0x52, 0x00, 0x0a, 0x27, 0x80, 0x1e, 0x86,
+];
+
+/// An opened IOKit USB device interface, with its UVC topology resolved. Closes
+/// and releases on drop.
+struct UsbDevice {
+ dev: *mut *mut UsbDeviceInterface,
+ vc_interface: u8,
+ /// Processing-Unit id (image controls).
+ unit_id: u8,
+ /// Camera (input) Terminal id (lens controls); `None` when the descriptor
+ /// lists no camera terminal — lens controls then report `Unsupported`.
+ terminal_id: Option<u8>,
+}
+
+impl UsbDevice {
+ /// Find and open the Logitech USB device backing `unique_id`, resolving its
+ /// VideoControl interface and Processing-Unit id.
+ fn open_for(unique_id: &str) -> Result<Self, ControlError> {
+ let want_vid = crate::LOGITECH_VID;
+ // The pid is the trailing 4 hex of the uniqueID's id portion; we don't
+ // strictly need it for matching (we open every Logitech UVC device and
+ // pick the one whose location matches), but parse it as a fallback.
+ let want_location = location_hint(unique_id);
+
+ // SAFETY: standard IOKit device enumeration; each retained object is
+ // released, and the matching dictionary is consumed by the call.
+ unsafe {
+ let class = CString::new("IOUSBDevice").map_err(|e| ControlError::Io(e.to_string()))?;
+ let matching = IOServiceMatching(class.as_ptr());
+ if matching.is_null() {
+ return Err(ControlError::Io("IOServiceMatching".into()));
+ }
+ let mut iter: IoIterator = 0;
+ if IOServiceGetMatchingServices(kIOMainPortDefault, matching, &raw mut iter)
+ != KIO_RETURN_SUCCESS
+ {
+ return Err(ControlError::Io("IOServiceGetMatchingServices".into()));
+ }
+
+ let mut chosen: Option<Opened> = None;
+ // Count Logitech cameras reached on the location-less path. With a
+ // parseable location only an exact match opens; without a hint (an
+ // unparseable unique id) the first Logitech camera is a best effort
+ // that is only safe when it's the *only* one — see the fail-closed
+ // check after the loop.
+ let mut vendor_candidates = 0usize;
+ loop {
+ let service = IOIteratorNext(iter);
+ if service == 0 {
+ break;
+ }
+ match Self::try_open(service, want_vid, want_location) {
+ Some(found) => {
+ let exact =
+ want_location.is_some_and(|l| found.matched_location == Some(l));
+ IOObjectRelease(service);
+ if exact {
+ if let Some(prev) = chosen.take() {
+ prev.into_device().close();
+ }
+ chosen = Some(found);
+ break;
+ } else if want_location.is_none() {
+ vendor_candidates += 1;
+ if chosen.is_none() {
+ chosen = Some(found);
+ } else {
+ found.into_device().close();
+ }
+ } else {
+ found.into_device().close();
+ }
+ }
+ None => {
+ IOObjectRelease(service);
+ }
+ }
+ }
+ IOObjectRelease(iter);
+
+ // A location-less match is only unambiguous with exactly one
+ // Logitech camera attached; with two (and a unique id we couldn't
+ // parse into a USB location) we can't tell them apart, so refuse
+ // rather than write the wrong camera's registers.
+ if want_location.is_none() && vendor_candidates > 1 {
+ if let Some(dev) = chosen.take() {
+ dev.into_device().close();
+ }
+ return Err(ControlError::Ambiguous);
+ }
+
+ chosen
+ .map(Opened::into_device)
+ .ok_or(ControlError::NotFound)
+ }
+ }
+
+ /// The entity id addressed for `unit`, or `Unsupported` when the camera's
+ /// descriptor lists no camera terminal.
+ fn entity(&self, unit: Unit) -> Result<u8, ControlError> {
+ match unit {
+ Unit::Processing => Ok(self.unit_id),
+ Unit::CameraTerminal => self.terminal_id.ok_or(ControlError::Unsupported),
+ }
+ }
+
+ /// Issue a UVC GET request (`req` = GET_MIN/MAX/DEF/CUR), returning the
+ /// control-sized little-endian value, sign-extended per the control.
+ fn get(&self, control: CameraControl, req: u8) -> Result<i32, ControlError> {
+ let entity = self.entity(control.unit())?;
+ let mut buf = [0u8; 4];
+ let len = control.len();
+ self.transfer(RT_GET, req, control.selector(), entity, &mut buf[..len])?;
+ Ok(match (len, control.signed()) {
+ (4, _) => i32::try_from(u32::from_le_bytes(buf)).unwrap_or(i32::MAX),
+ (_, true) => i32::from(i16::from_le_bytes([buf[0], buf[1]])),
+ (_, false) => i32::from(u16::from_le_bytes([buf[0], buf[1]])),
+ })
+ }
+
+ /// Issue a UVC SET_CUR request with `value` truncated to the control's size.
+ fn set(&self, control: CameraControl, value: i32) -> Result<(), ControlError> {
+ let entity = self.entity(control.unit())?;
+ let mut buf = (value as u32).to_le_bytes();
+ let len = control.len();
+ self.transfer(
+ RT_SET,
+ UVC_SET_CUR,
+ control.selector(),
+ entity,
+ &mut buf[..len],
+ )
+ }
+
+ /// Read an auto toggle (`req` = GET_CUR/GET_DEF) as a boolean. For the
+ /// AE-mode bitmap anything but fully-manual counts as auto.
+ fn get_auto(&self, toggle: AutoToggle, req: u8) -> Result<bool, ControlError> {
+ let entity = self.entity(toggle.unit())?;
+ let mut buf = [0u8; 1];
+ self.transfer(RT_GET, req, toggle.selector(), entity, &mut buf)?;
+ Ok(match toggle {
+ AutoToggle::Exposure => buf[0] != AE_MANUAL,
+ _ => buf[0] != 0,
+ })
+ }
+
+ /// Switch an auto toggle. Enabling auto-exposure tries each AE mode the
+ /// camera might support, most-automatic first.
+ fn set_auto(&self, toggle: AutoToggle, on: bool) -> Result<(), ControlError> {
+ let entity = self.entity(toggle.unit())?;
+ let selector = toggle.selector();
+ let candidates: &[u8] = match (toggle, on) {
+ (AutoToggle::Exposure, true) => &AE_AUTO_MODES,
+ (AutoToggle::Exposure, false) => &[AE_MANUAL],
+ (_, true) => &[1],
+ (_, false) => &[0],
+ };
+ let mut last = ControlError::Unsupported;
+ for &mode in candidates {
+ match self.transfer(RT_SET, UVC_SET_CUR, selector, entity, &mut [mode]) {
+ Ok(()) => return Ok(()),
+ Err(e) => last = e,
+ }
+ }
+ Err(last)
+ }
+
+ fn transfer(
+ &self,
+ request_type: u8,
+ request: u8,
+ selector: u16,
+ entity: u8,
+ data: &mut [u8],
+ ) -> Result<(), ControlError> {
+ let mut req = IoUsbDevRequest {
+ bm_request_type: request_type,
+ b_request: request,
+ w_value: selector << 8,
+ w_index: (u16::from(entity) << 8) | u16::from(self.vc_interface),
+ w_length: data.len() as u16,
+ p_data: data.as_mut_ptr().cast::<c_void>(),
+ w_len_done: 0,
+ };
+ // SAFETY: `self.dev` is a live IOUSBDeviceInterface**; DeviceRequest reads
+ // `req` and writes into the `data` buffer it points at.
+ let rc = unsafe { ((**self.dev).device_request)(self.dev.cast::<c_void>(), &raw mut req) };
+ if rc != KIO_RETURN_SUCCESS {
+ return Err(ControlError::Unsupported);
+ }
+ Ok(())
+ }
+
+ fn close(self) {
+ // Drop handles the teardown.
+ drop(self);
+ }
+}
+
+impl Drop for UsbDevice {
+ fn drop(&mut self) {
+ // SAFETY: `self.dev` is a live interface we opened; close then release it.
+ unsafe {
+ let _ = ((**self.dev).usb_device_close)(self.dev.cast::<c_void>());
+ ((**self.dev).release)(self.dev.cast::<c_void>());
+ }
+ }
+}
+
+/// A device that matched on vendor id, carrying the location id it reported so
+/// the caller can prefer an exact-location match.
+struct Opened {
+ device: UsbDevice,
+ matched_location: Option<u32>,
+}
+
+impl Opened {
+ fn into_device(self) -> UsbDevice {
+ self.device
+ }
+}
+
+impl UsbDevice {
+ /// Try to build an [`Opened`] from an `io_service_t`: query the device
+ /// interface, match the vendor id, open it, and resolve its UVC topology.
+ unsafe fn try_open(
+ service: IoService,
+ want_vid: u16,
+ _want_location: Option<u32>,
+ ) -> Option<Opened> {
+ unsafe {
+ let user_client = make_uuid(&KIO_USB_DEVICE_USER_CLIENT_TYPE_ID);
+ let plugin_type = make_uuid(&KIO_CF_PLUGIN_INTERFACE_ID);
+ let mut plugin: *mut *mut PlugInInterface = ptr::null_mut();
+ let mut score: i32 = 0;
+ let rc = IOCreatePlugInInterfaceForService(
+ service,
+ user_client,
+ plugin_type,
+ &raw mut plugin,
+ &raw mut score,
+ );
+ CFRelease(user_client);
+ CFRelease(plugin_type);
+ if rc != KIO_RETURN_SUCCESS || plugin.is_null() {
+ return None;
+ }
+
+ let dev_uuid_ref = make_uuid(&KIO_USB_DEVICE_INTERFACE_ID);
+ let dev_uuid = CFUUIDGetUUIDBytes(dev_uuid_ref);
+ CFRelease(dev_uuid_ref);
+ let mut dev_ptr: *mut c_void = ptr::null_mut();
+ let qrc =
+ ((**plugin).query_interface)(plugin.cast::<c_void>(), dev_uuid, &raw mut dev_ptr);
+ IODestroyPlugInInterface(plugin);
+ if qrc != 0 || dev_ptr.is_null() {
+ return None;
+ }
+ let dev = dev_ptr.cast::<*mut UsbDeviceInterface>();
+
+ let mut vid: u16 = 0;
+ ((**dev).get_device_vendor)(dev.cast::<c_void>(), &raw mut vid);
+ if vid != want_vid {
+ ((**dev).release)(dev.cast::<c_void>());
+ return None;
+ }
+
+ let mut location: u32 = 0;
+ let loc_ok = ((**dev).get_location_id)(dev.cast::<c_void>(), &raw mut location)
+ == KIO_RETURN_SUCCESS;
+
+ // Seize (not plain open) so a control transfer succeeds even while
+ // the camera is streaming in this or another app. Callers batch their
+ // reads/writes into one open to keep this churn low.
+ if ((**dev).usb_device_open_seize)(dev.cast::<c_void>()) != KIO_RETURN_SUCCESS {
+ ((**dev).release)(dev.cast::<c_void>());
+ return None;
+ }
+
+ let Some(topology) = video_control_topology(dev) else {
+ let _ = ((**dev).usb_device_close)(dev.cast::<c_void>());
+ ((**dev).release)(dev.cast::<c_void>());
+ return None;
+ };
+
+ Some(Opened {
+ device: UsbDevice {
+ dev,
+ vc_interface: topology.vc_interface,
+ unit_id: topology.processing_unit,
+ terminal_id: topology.camera_terminal,
+ },
+ matched_location: loc_ok.then_some(location),
+ })
+ }
+ }
+}
+
+/// The VideoControl entities a control request can address, parsed from the
+/// configuration descriptor.
+struct VcTopology {
+ vc_interface: u8,
+ processing_unit: u8,
+ camera_terminal: Option<u8>,
+}
+
+/// Parse the configuration descriptor for the VideoControl interface number,
+/// the Processing-Unit id, and the camera (input) terminal id.
+unsafe fn video_control_topology(dev: *mut *mut UsbDeviceInterface) -> Option<VcTopology> {
+ unsafe {
+ let mut num_configs: u8 = 0;
+ ((**dev).get_number_of_configurations)(dev.cast::<c_void>(), &raw mut num_configs);
+ for cfg in 0..num_configs {
+ let mut desc: *const u8 = ptr::null();
+ if ((**dev).get_configuration_descriptor_ptr)(dev.cast::<c_void>(), cfg, &raw mut desc)
+ != KIO_RETURN_SUCCESS
+ || desc.is_null()
+ {
+ continue;
+ }
+ // wTotalLength at offset 2..4 (little-endian).
+ let total = u16::from(*desc.add(2)) | (u16::from(*desc.add(3)) << 8);
+ if let Some(found) = scan_descriptors(desc, total as usize) {
+ return Some(found);
+ }
+ }
+ None
+ }
+}
+
+/// Walk a configuration descriptor blob, collecting the first VideoControl
+/// interface's Processing-Unit and camera-terminal entity ids.
+unsafe fn scan_descriptors(base: *const u8, total: usize) -> Option<VcTopology> {
+ unsafe {
+ let mut off = 0usize;
+ let mut vc_interface: Option<u8> = None;
+ let mut camera_terminal: Option<u8> = None;
+ while off + 2 <= total {
+ let len = *base.add(off) as usize;
+ if len < 2 || off + len > total {
+ break;
+ }
+ let dtype = *base.add(off + 1);
+ if dtype == DESC_INTERFACE && len >= 9 {
+ let class = *base.add(off + 5);
+ let sub = *base.add(off + 6);
+ vc_interface = (class == CC_VIDEO && sub == SC_VIDEOCONTROL)
+ .then(|| *base.add(off + 2))
+ .or(vc_interface);
+ } else if dtype == DESC_CS_INTERFACE && len >= 4 && vc_interface.is_some() {
+ let subtype = *base.add(off + 2);
+ // bUnitID / bTerminalID sit at offset 3 in both descriptors;
+ // an input terminal's wTerminalType (offset 4..6) must be the
+ // camera sensor — skip composite/other input terminals.
+ if subtype == VC_INPUT_TERMINAL && len >= 8 {
+ let ttype =
+ u16::from(*base.add(off + 4)) | (u16::from(*base.add(off + 5)) << 8);
+ if ttype == ITT_CAMERA && camera_terminal.is_none() {
+ camera_terminal = Some(*base.add(off + 3));
+ }
+ } else if subtype == VC_PROCESSING_UNIT {
+ return vc_interface.map(|vc| VcTopology {
+ vc_interface: vc,
+ processing_unit: *base.add(off + 3),
+ camera_terminal,
+ });
+ }
+ }
+ off += len;
+ }
+ None
+ }
+}
+
+unsafe fn make_uuid(bytes: &[u8; 16]) -> CfUuidRef {
+ unsafe { CFUUIDCreateFromUUIDBytes(ptr::null(), CfUuidBytes { bytes: *bytes }) }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::location_hint;
+
+ /// AVFoundation prints the location id unpadded: a StreamCam on bus
+ /// 0x01123000 yields a 15-digit id whose leading run is only 7 digits.
+ /// Taking a fixed 8 would swallow a vid nibble and shift the location —
+ /// which made every control write fail closed with `NotFound` (the bug
+ /// the exact-match requirement exposed).
+ #[test]
+ fn unpadded_location_parses() {
+ assert_eq!(location_hint("0x1123000046d0893"), Some(0x0112_3000));
+ }
+
+ #[test]
+ fn padded_location_parses() {
+ assert_eq!(location_hint("0x14110000046d082d"), Some(0x1411_0000));
+ }
+
+ #[test]
+ fn too_short_ids_yield_no_hint() {
+ assert_eq!(location_hint("0x46d0893"), None);
+ assert_eq!(location_hint("46d0893"), None);
+ assert_eq!(location_hint(""), None);
+ }
+}
diff --git a/crates/openlogi-camera/src/uvc_linux.rs b/crates/openlogi-camera/src/uvc_linux.rs
new file mode 100644
index 0000000000000000000000000000000000000000..78eeee9a26867eddf7a84a65ad7f8bee247aa075
--- /dev/null
+++ b/crates/openlogi-camera/src/uvc_linux.rs
@@ -0,0 +1,452 @@
+//! UVC controls on Linux, over V4L2.
+//!
+//! The kernel's `uvcvideo` driver already speaks UVC to the camera, so this
+//! backend issues `VIDIOC_G_CTRL` / `VIDIOC_S_CTRL` against standard control
+//! ids rather than the raw Processing Unit / Camera Terminal transfers the
+//! macOS backend has to build by hand.
+//!
+//! Two V4L2 details shape the code:
+//!
+//! * **Auto-exposure is a menu, not a boolean.** `V4L2_CID_EXPOSURE_AUTO`
+//! selects one of four modes; two count as automatic. See [`exposure_mode`].
+//! * **Batched writes can't cross a control class.** `VIDIOC_S_EXT_CTRLS`
+//! requires every control in one call to share a class, and the controls this
+//! crate exposes span the User (`0x0098_0000`) and Camera (`0x009a_0000`)
+//! classes. [`apply_settings`] groups by class instead of issuing one call.
+
+use v4l::Device;
+use v4l::control::{Control, Description, Flags, Value};
+
+use crate::controls::{
+ AutoState, AutoToggle, CameraControl, CameraState, ControlError, ControlRange,
+};
+use crate::linux;
+
+/// `V4L2_CID_BRIGHTNESS` — the User control class base.
+const CID_BRIGHTNESS: u32 = 0x0098_0900;
+const CID_CONTRAST: u32 = 0x0098_0901;
+const CID_SATURATION: u32 = 0x0098_0902;
+const CID_AUTO_WHITE_BALANCE: u32 = 0x0098_090c;
+const CID_WHITE_BALANCE_TEMPERATURE: u32 = 0x0098_091a;
+const CID_SHARPNESS: u32 = 0x0098_091b;
+
+/// `V4L2_CID_EXPOSURE_AUTO` — the Camera control class base.
+const CID_EXPOSURE_AUTO: u32 = 0x009a_0901;
+const CID_EXPOSURE_ABSOLUTE: u32 = 0x009a_0902;
+const CID_FOCUS_ABSOLUTE: u32 = 0x009a_090a;
+const CID_FOCUS_AUTO: u32 = 0x009a_090c;
+const CID_ZOOM_ABSOLUTE: u32 = 0x009a_090d;
+
+/// `V4L2_CID_EXPOSURE_AUTO` menu values, in the kernel's order.
+const EXPOSURE_AUTO: i64 = 0;
+const EXPOSURE_MANUAL: i64 = 1;
+const EXPOSURE_SHUTTER_PRIORITY: i64 = 2;
+const EXPOSURE_APERTURE_PRIORITY: i64 = 3;
+
+/// The V4L2 control id backing each [`CameraControl`].
+///
+/// [`CameraControl::Tint`] has no V4L2 equivalent — UVC exposes white balance
+/// as a single colour temperature, and the component (blue/red balance) form
+/// the macOS backend uses for tint isn't a standard V4L2 control — so it
+/// reports [`ControlError::Unsupported`].
+fn control_id(control: CameraControl) -> Option<u32> {
+ Some(match control {
+ CameraControl::Zoom => CID_ZOOM_ABSOLUTE,
+ CameraControl::Focus => CID_FOCUS_ABSOLUTE,
+ CameraControl::Exposure => CID_EXPOSURE_ABSOLUTE,
+ CameraControl::Brightness => CID_BRIGHTNESS,
+ CameraControl::Contrast => CID_CONTRAST,
+ CameraControl::Saturation => CID_SATURATION,
+ CameraControl::Sharpness => CID_SHARPNESS,
+ CameraControl::WhiteBalance => CID_WHITE_BALANCE_TEMPERATURE,
+ CameraControl::Tint => return None,
+ })
+}
+
+/// The V4L2 control id backing each [`AutoToggle`].
+fn auto_id(toggle: AutoToggle) -> u32 {
+ match toggle {
+ AutoToggle::Focus => CID_FOCUS_AUTO,
+ AutoToggle::Exposure => CID_EXPOSURE_AUTO,
+ AutoToggle::WhiteBalance => CID_AUTO_WHITE_BALANCE,
+ }
+}
+
+/// Open the V4L2 node for `unique_id`.
+fn open(unique_id: &str) -> Result<Device, ControlError> {
+ let path = linux::node_for_unique_id(unique_id).ok_or(ControlError::NotFound)?;
+ Device::with_path(&path).map_err(|error| ControlError::Io(error.to_string()))
+}
+
+/// Read one control's range and current value.
+///
+/// # Errors
+/// [`ControlError::Unsupported`] when the camera doesn't expose the control.
+pub fn control_range(
+ unique_id: &str,
+ control: CameraControl,
+) -> Result<ControlRange, ControlError> {
+ let device = open(unique_id)?;
+ let id = control_id(control).ok_or(ControlError::Unsupported)?;
+ let description = describe(&device, id).ok_or(ControlError::Unsupported)?;
+ range_of(&device, &description).ok_or(ControlError::Unsupported)
+}
+
+/// Read the range of every control this camera supports, skipping the rest.
+///
+/// # Errors
+/// [`ControlError::NotFound`] when no node matches `unique_id`.
+pub fn control_ranges(unique_id: &str) -> Result<Vec<(CameraControl, ControlRange)>, ControlError> {
+ let device = open(unique_id)?;
+ let descriptions = query(&device)?;
+
+ Ok(CameraControl::ALL
+ .into_iter()
+ .filter_map(|control| {
+ let id = control_id(control)?;
+ let description = descriptions.iter().find(|d| d.id == id)?;
+ Some((control, range_of(&device, description)?))
+ })
+ .collect())
+}
+
+/// Read every supported control range and auto-toggle state in one device open.
+///
+/// # Errors
+/// [`ControlError::NotFound`] when no node matches `unique_id`.
+pub fn read_camera_state(unique_id: &str) -> Result<CameraState, ControlError> {
+ let device = open(unique_id)?;
+ let descriptions = query(&device)?;
+
+ let controls = CameraControl::ALL
+ .into_iter()
+ .filter_map(|control| {
+ let id = control_id(control)?;
+ let description = descriptions.iter().find(|d| d.id == id)?;
+ Some((control, range_of(&device, description)?))
+ })
+ .collect();
+
+ let autos = AutoToggle::ALL
+ .into_iter()
+ .filter_map(|toggle| {
+ let id = auto_id(toggle);
+ let description = descriptions.iter().find(|d| d.id == id)?;
+ let current = read_auto(&device, toggle)?;
+ let default = if toggle == AutoToggle::Exposure {
+ is_auto_mode(description.default)
+ } else {
+ description.default != 0
+ };
+ Some((toggle, AutoState { current, default }))
+ })
+ .collect();
+
+ Ok(CameraState { controls, autos })
+}
+
+/// Write one control value.
+///
+/// # Errors
+/// [`ControlError::Unsupported`] when the camera doesn't expose the control, or
+/// rejects the write because an auto mode currently owns it.
+pub fn set_control(
+ unique_id: &str,
+ control: CameraControl,
+ value: i32,
+) -> Result<(), ControlError> {
+ let device = open(unique_id)?;
+ let id = control_id(control).ok_or(ControlError::Unsupported)?;
+ write_value(&device, id, i64::from(value))
+}
+
+/// Turn one auto mode on or off.
+///
+/// # Errors
+/// [`ControlError::Unsupported`] when the camera has no such toggle.
+pub fn set_auto(unique_id: &str, toggle: AutoToggle, on: bool) -> Result<(), ControlError> {
+ let device = open(unique_id)?;
+ write_auto(&device, toggle, on)
+}
+
+/// Apply auto toggles and control values in one device open.
+///
+/// Autos are written first: a manual value is rejected while its auto mode
+/// still owns the control, so dragging an auto-gated slider must clear the
+/// mode before the value lands. Controls are then batched per class, since
+/// `VIDIOC_S_EXT_CTRLS` refuses a mixed-class call.
+///
+/// Unsupported controls are skipped rather than failing the batch — a profile
+/// saved against a Brio shouldn't fail wholesale when applied to a C270.
+///
+/// # Errors
+/// [`ControlError::NotFound`] when no node matches `unique_id`; the first I/O
+/// error otherwise.
+pub fn apply_settings(
+ unique_id: &str,
+ autos: &[(AutoToggle, bool)],
+ values: &[(CameraControl, i32)],
+) -> Result<(), ControlError> {
+ let device = open(unique_id)?;
+ let supported = query(&device)?;
+ let has = |id: u32| supported.iter().any(|d| d.id == id);
+
+ for &(toggle, on) in autos {
+ if has(auto_id(toggle)) {
+ write_auto(&device, toggle, on)?;
+ }
+ }
+
+ let writable: Vec<(u32, i64)> = values
+ .iter()
+ .filter(|&&(control, _)| !gated_by_enabled_auto(control, autos))
+ .filter_map(|&(control, value)| {
+ let id = control_id(control)?;
+ has(id).then_some((id, i64::from(value)))
+ })
+ .collect();
+
+ for class in [CLASS_USER, CLASS_CAMERA] {
+ let in_class = || {
+ writable
+ .iter()
+ .filter(move |(id, _)| id & CLASS_MASK == class)
+ };
+ let batch: Vec<Control> = in_class()
+ .map(|&(id, value)| Control {
+ id,
+ value: Value::Integer(value),
+ })
+ .collect();
+ if batch.is_empty() {
+ continue;
+ }
+ // A rejected batch falls back to per-control writes so one control the
+ // camera dislikes can't discard the whole profile. A control the device
+ // refuses outright is skipped for the same reason — only a genuine I/O
+ // failure aborts.
+ if device.set_controls(batch).is_err() {
+ for &(id, value) in in_class() {
+ match write_value(&device, id, value) {
+ Ok(()) | Err(ControlError::Unsupported) => {}
+ Err(error) => return Err(error),
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
+
+/// Whether this call is handing `control` over to an auto mode.
+///
+/// A control under automatic control rejects manual writes, so a profile that
+/// carries both "auto on" and the value it gates would otherwise fail — and,
+/// because the write aborts the batch, would strand later controls unapplied.
+/// The auto toggle expresses the intent; the stale manual value is redundant.
+fn gated_by_enabled_auto(control: CameraControl, autos: &[(AutoToggle, bool)]) -> bool {
+ control
+ .auto_toggle()
+ .is_some_and(|gate| autos.iter().any(|&(toggle, on)| toggle == gate && on))
+}
+
+/// Mask selecting the class bits of a V4L2 control id.
+const CLASS_MASK: u32 = 0xFFFF_0000;
+const CLASS_USER: u32 = 0x0098_0000;
+const CLASS_CAMERA: u32 = 0x009a_0000;
+
+/// Every control the device advertises.
+fn query(device: &Device) -> Result<Vec<Description>, ControlError> {
+ device
+ .query_controls()
+ .map_err(|error| ControlError::Io(error.to_string()))
+}
+
+/// One control's description, if the device advertises it.
+fn describe(device: &Device, id: u32) -> Option<Description> {
+ device
+ .query_controls()
+ .ok()?
+ .into_iter()
+ .find(|description| description.id == id)
+}
+
+/// Build a [`ControlRange`], reading the live value.
+///
+/// Disabled controls are dropped — the driver refuses to read them, and they
+/// can't be adjusted. An *inactive* control (one an auto mode currently owns,
+/// like `exposure_time_absolute` under aperture priority) is kept: its range
+/// and last value are exactly what the UI needs to show the slider it will
+/// enable the moment auto is switched off.
+fn range_of(device: &Device, description: &Description) -> Option<ControlRange> {
+ if description.flags.contains(Flags::DISABLED) {
+ return None;
+ }
+ let current = read_int(device, description.id).unwrap_or(description.default);
+ Some(ControlRange {
+ min: clamp_i32(description.minimum),
+ max: clamp_i32(description.maximum),
+ default: clamp_i32(description.default),
+ current: clamp_i32(current),
+ })
+}
+
+/// Read an integer/boolean control's current value.
+fn read_int(device: &Device, id: u32) -> Option<i64> {
+ match device.control(id).ok()?.value {
+ Value::Integer(value) => Some(value),
+ Value::Boolean(value) => Some(i64::from(value)),
+ _ => None,
+ }
+}
+
+/// Read whether an auto mode is currently engaged.
+fn read_auto(device: &Device, toggle: AutoToggle) -> Option<bool> {
+ let raw = read_int(device, auto_id(toggle))?;
+ Some(if toggle == AutoToggle::Exposure {
+ is_auto_mode(raw)
+ } else {
+ raw != 0
+ })
+}
+
+/// Whether a `V4L2_CID_EXPOSURE_AUTO` menu value counts as automatic.
+///
+/// `AUTO` and `APERTURE_PRIORITY` both let the camera drive exposure time;
+/// `MANUAL` and `SHUTTER_PRIORITY` leave it under application control.
+fn is_auto_mode(value: i64) -> bool {
+ value == EXPOSURE_AUTO || value == EXPOSURE_APERTURE_PRIORITY
+}
+
+/// Write an auto toggle, translating the exposure menu.
+fn write_auto(device: &Device, toggle: AutoToggle, on: bool) -> Result<(), ControlError> {
+ if toggle == AutoToggle::Exposure {
+ let mode = exposure_mode(device, on).ok_or(ControlError::Unsupported)?;
+ return write_value(device, CID_EXPOSURE_AUTO, mode);
+ }
+ let control = Control {
+ id: auto_id(toggle),
+ value: Value::Boolean(on),
+ };
+ device
+ .set_control(control)
+ .map_err(|error| ControlError::Io(error.to_string()))
+}
+
+/// Pick an exposure menu value for the requested automatic/manual intent.
+///
+/// Cameras implement different subsets — the MX Brio offers only
+/// `APERTURE_PRIORITY` and `MANUAL`, while others offer `AUTO` — so the
+/// preferred value is checked against the advertised menu before falling back
+/// to the alternative with the same meaning.
+fn exposure_mode(device: &Device, on: bool) -> Option<i64> {
+ let description = describe(device, CID_EXPOSURE_AUTO)?;
+ let offered = |value: i64| -> bool {
+ // A menu with no enumerated items (some drivers omit them) still
+ // accepts values inside its advertised min/max.
+ description.items.as_ref().map_or(
+ value >= description.minimum && value <= description.maximum,
+ |items| items.iter().any(|(index, _)| i64::from(*index) == value),
+ )
+ };
+
+ let preferences: [i64; 2] = if on {
+ [EXPOSURE_APERTURE_PRIORITY, EXPOSURE_AUTO]
+ } else {
+ [EXPOSURE_MANUAL, EXPOSURE_SHUTTER_PRIORITY]
+ };
+ preferences.into_iter().find(|&value| offered(value))
+}
+
+/// `errno` values that mean "this camera won't take that write" rather than
+/// "the call went wrong": unknown control, value out of range, or an auto mode
+/// currently owning the control.
+const REJECTED: [i32; 4] = [
+ 22, // EINVAL
+ 34, // ERANGE
+ 13, // EACCES
+ 16, // EBUSY
+];
+
+/// Write an integer control, mapping a driver rejection to `Unsupported`.
+fn write_value(device: &Device, id: u32, value: i64) -> Result<(), ControlError> {
+ let control = Control {
+ id,
+ value: Value::Integer(value),
+ };
+ device.set_control(control).map_err(|error| {
+ if error
+ .raw_os_error()
+ .is_some_and(|no| REJECTED.contains(&no))
+ {
+ ControlError::Unsupported
+ } else {
+ ControlError::Io(error.to_string())
+ }
+ })
+}
+
+/// Narrow a V4L2 `i64` control bound to the `i32` the shared vocabulary uses.
+///
+/// Standard UVC controls fit comfortably; saturating keeps a driver reporting
+/// an absurd bound from wrapping into a negative slider bound.
+fn clamp_i32(value: i64) -> i32 {
+ i32::try_from(value).unwrap_or(if value.is_negative() {
+ i32::MIN
+ } else {
+ i32::MAX
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn exposure_auto_maps_only_two_menu_values_to_automatic() {
+ assert!(is_auto_mode(EXPOSURE_AUTO));
+ assert!(is_auto_mode(EXPOSURE_APERTURE_PRIORITY));
+ assert!(!is_auto_mode(EXPOSURE_MANUAL));
+ assert!(!is_auto_mode(EXPOSURE_SHUTTER_PRIORITY));
+ }
+
+ #[test]
+ fn a_control_handed_to_auto_is_skipped() {
+ let autos = [(AutoToggle::Exposure, true)];
+ // Exposure is gated by the toggle being switched on...
+ assert!(gated_by_enabled_auto(CameraControl::Exposure, &autos));
+ // ...while ungated controls, and controls gated by a *different*
+ // toggle, still apply.
+ assert!(!gated_by_enabled_auto(CameraControl::Zoom, &autos));
+ assert!(!gated_by_enabled_auto(CameraControl::Focus, &autos));
+ }
+
+ #[test]
+ fn a_control_taken_off_auto_still_applies() {
+ // Switching auto *off* is exactly when the manual value must be written.
+ let autos = [(AutoToggle::Focus, false)];
+ assert!(!gated_by_enabled_auto(CameraControl::Focus, &autos));
+ }
+
+ #[test]
+ fn an_unmentioned_toggle_leaves_its_control_writable() {
+ assert!(!gated_by_enabled_auto(CameraControl::WhiteBalance, &[]));
+ }
+
+ #[test]
+ fn every_supported_control_has_a_known_class() {
+ // apply_settings batches per class; a control outside both would be
+ // silently dropped from every batch.
+ for control in CameraControl::ALL {
+ let Some(id) = control_id(control) else {
+ continue; // Tint has no V4L2 equivalent.
+ };
+ let class = id & CLASS_MASK;
+ assert!(
+ class == CLASS_USER || class == CLASS_CAMERA,
+ "{} has class {class:#x}",
+ control.name()
+ );
+ }
+ }
+}
diff --git a/crates/openlogi-camera/src/uvc_windows.rs b/crates/openlogi-camera/src/uvc_windows.rs
new file mode 100644
index 0000000000000000000000000000000000000000..c588aba307f0c745660aef4550618ebe75f3038c
--- /dev/null
+++ b/crates/openlogi-camera/src/uvc_windows.rs
@@ -0,0 +1,446 @@
+//! Device-level UVC controls over DirectShow (Windows).
+//!
+//! Windows exposes the same UVC controls the macOS backend reaches over raw
+//! IOKit USB, but pre-mapped by the OS: `IAMVideoProcAmp` carries the image
+//! controls (brightness/contrast/…) and `IAMCameraControl` the lens controls
+//! (zoom/focus/exposure), each with per-property auto/manual flags. Writes
+//! land in the camera's own registers, so — exactly as on macOS — a change is
+//! seen by every app that opens the camera.
+//!
+//! Enumeration also lives here: the DirectShow video-input category yields
+//! each camera's friendly name and its device path, whose embedded
+//! `vid_xxxx&pid_xxxx` markers give the USB identity. The device path is the
+//! OS capture id ([`Camera::unique_id`]); when it embeds a real USB serial
+//! (not a parent-generated instance id) that serial is preferred for the
+//! port-stable [`Camera::config_key`].
+
+#![expect(
+ unsafe_code,
+ reason = "DirectShow COM (device enumeration + IAMVideoProcAmp / IAMCameraControl)"
+)]
+
+use windows::Win32::Media::DirectShow::{IAMCameraControl, IAMVideoProcAmp, IBaseFilter};
+use windows::Win32::System::Com::StructuredStorage::IPropertyBag;
+use windows::Win32::System::Com::{
+ CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, IEnumMoniker,
+ IMoniker,
+};
+use windows::Win32::System::Variant::{VARIANT, VT_BSTR};
+use windows::core::{GUID, Interface, w};
+
+use crate::Camera;
+use crate::controls::{
+ AutoState, AutoToggle, CameraControl, CameraState, ControlError, ControlRange,
+};
+
+/// CLSID_SystemDeviceEnum — the DirectShow device-category enumerator.
+const CLSID_SYSTEM_DEVICE_ENUM: GUID = GUID::from_u128(0x62be5d10_60eb_11d0_bd3b_00a0c911ce86);
+/// CLSID_VideoInputDeviceCategory — webcams and other video capture sources.
+const CLSID_VIDEO_INPUT_DEVICE_CATEGORY: GUID =
+ GUID::from_u128(0x860bb310_5d01_11d0_bd3b_00a0c911ce86);
+// VideoProcAmp / CameraControl property ids (strmif.h). Raw values rather
+// than the generated enums so the mapping reads like the UVC tables.
+const VPA_BRIGHTNESS: i32 = 0;
+const VPA_CONTRAST: i32 = 1;
+const VPA_HUE: i32 = 2;
+const VPA_SATURATION: i32 = 3;
+const VPA_SHARPNESS: i32 = 4;
+const VPA_WHITE_BALANCE: i32 = 6;
+const CC_ZOOM: i32 = 3;
+const CC_EXPOSURE: i32 = 4;
+const CC_FOCUS: i32 = 6;
+/// `*_Flags_Auto` / `*_Flags_Manual` share values across both interfaces.
+const FLAG_AUTO: i32 = 0x1;
+const FLAG_MANUAL: i32 = 0x2;
+
+/// Which DirectShow interface carries a control, plus its property id.
+#[derive(Clone, Copy)]
+enum Prop {
+ VideoProcAmp(i32),
+ CameraControl(i32),
+}
+
+impl CameraControl {
+ fn prop(self) -> Prop {
+ match self {
+ Self::Zoom => Prop::CameraControl(CC_ZOOM),
+ Self::Focus => Prop::CameraControl(CC_FOCUS),
+ Self::Exposure => Prop::CameraControl(CC_EXPOSURE),
+ Self::Brightness => Prop::VideoProcAmp(VPA_BRIGHTNESS),
+ Self::Contrast => Prop::VideoProcAmp(VPA_CONTRAST),
+ Self::Saturation => Prop::VideoProcAmp(VPA_SATURATION),
+ Self::Sharpness => Prop::VideoProcAmp(VPA_SHARPNESS),
+ Self::WhiteBalance => Prop::VideoProcAmp(VPA_WHITE_BALANCE),
+ Self::Tint => Prop::VideoProcAmp(VPA_HUE),
+ }
+ }
+}
+
+impl AutoToggle {
+ /// The property whose auto/manual flag backs this toggle.
+ fn prop(self) -> Prop {
+ match self {
+ Self::Focus => Prop::CameraControl(CC_FOCUS),
+ Self::Exposure => Prop::CameraControl(CC_EXPOSURE),
+ Self::WhiteBalance => Prop::VideoProcAmp(VPA_WHITE_BALANCE),
+ }
+ }
+}
+
+/// Enumerate every video-input device DirectShow reports, with the USB
+/// vendor/product ids parsed out of the device path. Non-USB sources (virtual
+/// cameras) carry no `vid_`/`pid_` markers and are dropped.
+pub fn enumerate() -> Vec<Camera> {
+ monikers()
+ .map(|monikers| {
+ monikers
+ .into_iter()
+ .filter_map(|m| camera_from_moniker(&m))
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+/// Read a control's min/max/default/current straight from the device.
+///
+/// # Errors
+/// [`ControlError::NotFound`] when no camera matches `unique_id`,
+/// [`ControlError::Unsupported`] when the camera lacks the control.
+pub fn control_range(
+ unique_id: &str,
+ control: CameraControl,
+) -> Result<ControlRange, ControlError> {
+ let dev = Device::open(unique_id)?;
+ dev.range(control.prop()).map(|(range, _)| range)
+}
+
+/// Read every supported control in a single device bind.
+///
+/// # Errors
+/// [`ControlError::NotFound`] when no camera matches `unique_id`.
+pub fn control_ranges(unique_id: &str) -> Result<Vec<(CameraControl, ControlRange)>, ControlError> {
+ Ok(read_camera_state(unique_id)?.controls)
+}
+
+/// Read every supported control range *and* auto-toggle state in a single
+/// device bind — what the GUI controls panel builds itself from.
+///
+/// # Errors
+/// [`ControlError::NotFound`] when no camera matches `unique_id`.
+pub fn read_camera_state(unique_id: &str) -> Result<CameraState, ControlError> {
+ let dev = Device::open(unique_id)?;
+ let mut state = CameraState::default();
+ for control in CameraControl::ALL {
+ if let Ok((range, _)) = dev.range(control.prop()) {
+ state.controls.push((control, range));
+ }
+ }
+ for toggle in AutoToggle::ALL {
+ if let Ok((_, caps)) = dev.range(toggle.prop())
+ && caps & FLAG_AUTO != 0
+ && let Ok(current) = dev.auto_engaged(toggle.prop())
+ {
+ // DirectShow reports which modes exist but not a factory default;
+ // auto-capable properties ship with auto engaged on every Logitech
+ // camera, so that is the reset target.
+ state.autos.push((
+ toggle,
+ AutoState {
+ current,
+ default: true,
+ },
+ ));
+ }
+ }
+ Ok(state)
+}
+
+/// Write a control's current value (switching that property to manual).
+///
+/// # Errors
+/// As [`control_range`].
+pub fn set_control(
+ unique_id: &str,
+ control: CameraControl,
+ value: i32,
+) -> Result<(), ControlError> {
+ let dev = Device::open(unique_id)?;
+ dev.set(control.prop(), value, FLAG_MANUAL)
+}
+
+/// Switch an auto mode (focus / exposure / white balance) on or off.
+///
+/// # Errors
+/// As [`control_range`].
+pub fn set_auto(unique_id: &str, toggle: AutoToggle, on: bool) -> Result<(), ControlError> {
+ let dev = Device::open(unique_id)?;
+ dev.set_auto(toggle.prop(), on)
+}
+
+/// Apply a batch of auto toggles and control values in a single device bind.
+/// Every write is attempted, but any failure surfaces so callers never persist
+/// a batch the hardware didn't take.
+///
+/// # Errors
+/// [`ControlError::NotFound`] when no camera matches `unique_id`; otherwise the
+/// first per-write error after attempting the whole batch.
+pub fn apply_settings(
+ unique_id: &str,
+ autos: &[(AutoToggle, bool)],
+ values: &[(CameraControl, i32)],
+) -> Result<(), ControlError> {
+ let dev = Device::open(unique_id)?;
+ let mut first_err = None;
+ for (toggle, on) in autos {
+ if let Err(e) = dev.set_auto(toggle.prop(), *on) {
+ first_err.get_or_insert(e);
+ }
+ }
+ for (control, value) in values {
+ if let Err(e) = dev.set(control.prop(), *value, FLAG_MANUAL) {
+ first_err.get_or_insert(e);
+ }
+ }
+ first_err.map_or(Ok(()), Err)
+}
+
+/// A camera's bound capture filter, with the two control interfaces it may
+/// implement (a camera without lens motors typically lacks `IAMCameraControl`).
+struct Device {
+ proc_amp: Option<IAMVideoProcAmp>,
+ camera_control: Option<IAMCameraControl>,
+}
+
+impl Device {
+ /// Bind the capture filter whose device path equals `unique_id`. Exact
+ /// match only — guessing another camera could adjust the wrong hardware.
+ fn open(unique_id: &str) -> Result<Self, ControlError> {
+ let monikers = monikers().map_err(|e| ControlError::Io(e.to_string()))?;
+ for moniker in monikers {
+ if read_property(&moniker, w!("DevicePath")).as_deref() != Some(unique_id) {
+ continue;
+ }
+ // SAFETY: documented moniker → filter bind; the returned interface
+ // pointers are reference-counted by the `windows` wrappers.
+ let filter: IBaseFilter = unsafe { moniker.BindToObject(None, None) }
+ .map_err(|e| ControlError::Io(e.to_string()))?;
+ return Ok(Self {
+ proc_amp: filter.cast().ok(),
+ camera_control: filter.cast().ok(),
+ });
+ }
+ Err(ControlError::NotFound)
+ }
+
+ /// GetRange for `prop`: the control's bounds plus its capability flags.
+ fn range(&self, prop: Prop) -> Result<(ControlRange, i32), ControlError> {
+ let (mut min, mut max, mut step, mut default, mut caps) = (0, 0, 0, 0, 0);
+ // SAFETY: documented COM calls writing the five out-params.
+ unsafe {
+ match prop {
+ Prop::VideoProcAmp(id) => self
+ .proc_amp
+ .as_ref()
+ .ok_or(ControlError::Unsupported)?
+ .GetRange(
+ id,
+ &raw mut min,
+ &raw mut max,
+ &raw mut step,
+ &raw mut default,
+ &raw mut caps,
+ ),
+ Prop::CameraControl(id) => self
+ .camera_control
+ .as_ref()
+ .ok_or(ControlError::Unsupported)?
+ .GetRange(
+ id,
+ &raw mut min,
+ &raw mut max,
+ &raw mut step,
+ &raw mut default,
+ &raw mut caps,
+ ),
+ }
+ }
+ .map_err(|_| ControlError::Unsupported)?;
+ let current = self.get(prop).map_or(default, |(value, _)| value);
+ Ok((
+ ControlRange {
+ min,
+ max,
+ default,
+ current,
+ },
+ caps,
+ ))
+ }
+
+ /// Get for `prop`: the current value and its auto/manual flags.
+ fn get(&self, prop: Prop) -> Result<(i32, i32), ControlError> {
+ let (mut value, mut flags) = (0, 0);
+ // SAFETY: documented COM calls writing the two out-params.
+ unsafe {
+ match prop {
+ Prop::VideoProcAmp(id) => self
+ .proc_amp
+ .as_ref()
+ .ok_or(ControlError::Unsupported)?
+ .Get(id, &raw mut value, &raw mut flags),
+ Prop::CameraControl(id) => self
+ .camera_control
+ .as_ref()
+ .ok_or(ControlError::Unsupported)?
+ .Get(id, &raw mut value, &raw mut flags),
+ }
+ }
+ .map_err(|_| ControlError::Unsupported)?;
+ Ok((value, flags))
+ }
+
+ /// Whether `prop` currently runs in auto mode.
+ fn auto_engaged(&self, prop: Prop) -> Result<bool, ControlError> {
+ Ok(self.get(prop)?.1 & FLAG_AUTO != 0)
+ }
+
+ /// Set `prop` to `value` under `flags` (auto or manual).
+ fn set(&self, prop: Prop, value: i32, flags: i32) -> Result<(), ControlError> {
+ // SAFETY: documented COM calls; the device validates the value.
+ unsafe {
+ match prop {
+ Prop::VideoProcAmp(id) => self
+ .proc_amp
+ .as_ref()
+ .ok_or(ControlError::Unsupported)?
+ .Set(id, value, flags),
+ Prop::CameraControl(id) => self
+ .camera_control
+ .as_ref()
+ .ok_or(ControlError::Unsupported)?
+ .Set(id, value, flags),
+ }
+ }
+ .map_err(|_| ControlError::Unsupported)
+ }
+
+ /// Engage or release auto mode, keeping the current value in place.
+ fn set_auto(&self, prop: Prop, on: bool) -> Result<(), ControlError> {
+ let value = self.get(prop).map_or(0, |(v, _)| v);
+ self.set(prop, value, if on { FLAG_AUTO } else { FLAG_MANUAL })
+ }
+}
+
+/// Every video-input moniker DirectShow reports (empty when the category has
+/// no devices, which the enumerator signals with `S_FALSE`).
+fn monikers() -> windows::core::Result<Vec<IMoniker>> {
+ // SAFETY: standard COM setup + documented enumerator calls. Double
+ // initialization (or an existing STA on this thread) is harmless here —
+ // the enumerator works under either apartment model.
+ unsafe {
+ let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
+ let dev_enum: windows::Win32::Media::DirectShow::ICreateDevEnum =
+ CoCreateInstance(&CLSID_SYSTEM_DEVICE_ENUM, None, CLSCTX_INPROC_SERVER)?;
+ let mut enum_moniker: Option<IEnumMoniker> = None;
+ // S_FALSE (an empty category) is Ok with no enumerator — handled below.
+ dev_enum.CreateClassEnumerator(
+ &CLSID_VIDEO_INPUT_DEVICE_CATEGORY,
+ &raw mut enum_moniker,
+ 0,
+ )?;
+ let Some(enum_moniker) = enum_moniker else {
+ return Ok(Vec::new());
+ };
+ let mut all = Vec::new();
+ loop {
+ let mut chunk = [const { None }; 8];
+ let mut fetched = 0;
+ let hr = enum_moniker.Next(&mut chunk, Some(&raw mut fetched));
+ all.extend(chunk.into_iter().take(fetched as usize).flatten());
+ if hr.is_err() || fetched == 0 {
+ break;
+ }
+ }
+ Ok(all)
+ }
+}
+
+/// Build a [`Camera`] from one moniker: friendly name + device path, with the
+/// USB ids parsed from the path's `vid_xxxx&pid_xxxx` markers.
+fn camera_from_moniker(moniker: &IMoniker) -> Option<Camera> {
+ let unique_id = read_property(moniker, w!("DevicePath"))?;
+ let (vendor_id, product_id) = parse_device_path_ids(&unique_id)?;
+ let name = read_property(moniker, w!("FriendlyName")).unwrap_or_else(|| "Camera".into());
+ let serial_number = usb_serial_from_device_path(&unique_id);
+ Some(Camera {
+ name,
+ unique_id,
+ serial_number,
+ vendor_id,
+ product_id,
+ max_resolution: None,
+ max_fps: None,
+ })
+}
+
+/// USB `iSerialNumber` embedded in a device-interface path, when present.
+///
+/// Paths look like `\\?\usb#vid_046d&pid_0893&mi_00#SERIAL#{guid}\global`.
+/// Windows fabricates a parent-relative instance id (always containing `&`)
+/// when the device has no serial — those are port-dependent and rejected.
+pub(crate) fn usb_serial_from_device_path(path: &str) -> Option<String> {
+ // Split on '#': [prefix, hardware-id, instance, {class-guid}…]
+ let instance = path.split('#').nth(2)?.split('\\').next()?.trim();
+ if instance.is_empty() || instance.contains('&') {
+ return None;
+ }
+ Some(instance.to_string())
+}
+
+/// Read one string property (`FriendlyName` / `DevicePath`) from a moniker's
+/// property bag.
+fn read_property(moniker: &IMoniker, name: windows::core::PCWSTR) -> Option<String> {
+ // SAFETY: documented property-bag reads; the VARIANT is only interpreted
+ // as a BSTR when the bag reports that type.
+ unsafe {
+ let bag: IPropertyBag = moniker.BindToStorage(None, None).ok()?;
+ let mut value = VARIANT::default();
+ bag.Read(name, &raw mut value, None).ok()?;
+ if value.Anonymous.Anonymous.vt != VT_BSTR {
+ return None;
+ }
+ Some(value.Anonymous.Anonymous.Anonymous.bstrVal.to_string())
+ }
+}
+
+/// Pull the hex USB vendor/product ids out of a device path such as
+/// `\\?\usb#vid_046d&pid_0893&mi_00#…`.
+fn parse_device_path_ids(path: &str) -> Option<(u16, u16)> {
+ let lower = path.to_ascii_lowercase();
+ let hex_after = |marker: &str| -> Option<u16> {
+ let rest = lower.split(marker).nth(1)?;
+ u16::from_str_radix(rest.get(..4)?, 16).ok()
+ };
+ Some((hex_after("vid_")?, hex_after("pid_")?))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::usb_serial_from_device_path;
+
+ #[test]
+ fn real_usb_serial_is_extracted() {
+ let path = r"\\?\usb#vid_046d&pid_0893&mi_00#6B123456#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global";
+ assert_eq!(
+ usb_serial_from_device_path(path).as_deref(),
+ Some("6B123456")
+ );
+ }
+
+ #[test]
+ fn parent_generated_instance_is_rejected() {
+ // StreamCam without a firmware serial: Windows fabricates `9&56d9c30&0&0000`.
+ let path = r"\\?\usb#vid_046d&pid_0893&mi_00#9&56d9c30&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global";
+ assert_eq!(usb_serial_from_device_path(path), None);
+ }
+}
diff --git a/crates/openlogi-cli/Cargo.toml b/crates/openlogi-cli/Cargo.toml
index c066de93caa2da990c63d7558a763f29ccae76c0..861b01176176009e8f63d6b6980d7acb5f1429d0 100644
--- a/crates/openlogi-cli/Cargo.toml
+++ b/crates/openlogi-cli/Cargo.toml
@@ -11,9 +11,11 @@ keywords = ["logitech", "hidpp", "hid", "mouse", "cli"]
categories = ["command-line-utilities", "hardware-support"]
[dependencies]
-openlogi-core = { path = "../openlogi-core", version = "0.6.23" }
-openlogi-hid = { path = "../openlogi-hid", version = "0.6.23" }
-openlogi-assets = { path = "../openlogi-assets", version = "0.6.23" }
+openlogi-core = { path = "../openlogi-core", version = "0.6.24" }
+openlogi-hid = { path = "../openlogi-hid", version = "0.6.24" }
+openlogi-camera = { path = "../openlogi-camera", version = "0.6.24" }
+openlogi-assets = { path = "../openlogi-assets", version = "0.6.24" }
+png = "0.17"
clap = { workspace = true }
anyhow = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] }
diff --git a/crates/openlogi-cli/src/cmd/backlight.rs b/crates/openlogi-cli/src/cmd/backlight.rs
new file mode 100644
index 0000000000000000000000000000000000000000..584c89d22d866f33a8093a0c1cdf081478f4aab5
--- /dev/null
+++ b/crates/openlogi-cli/src/cmd/backlight.rs
@@ -0,0 +1,131 @@
+//! `openlogi backlight` — read and set the HID++ `0x1982` keyboard backlight.
+//!
+//! Unlike `diag`, this is persistent configuration: `setBacklightConfig` writes
+//! to the keyboard's non-volatile memory, so `backlight off` survives
+//! reconnects, host switches, and power cycles with nothing re-applying it.
+
+use anyhow::{Context, Result};
+use clap::{Args, Subcommand};
+use openlogi_hid::{BacklightMode, BacklightState, BacklightStatus, DeviceRoute};
+
+use crate::cmd::diag::select_device;
+
+/// HID++ `Backlight` — the white, level-adjustable backlight on the MX Keys
+/// line. RGB keyboards use `0x8070` / `0x8080` instead and are driven by
+/// `diag lighting`.
+const BACKLIGHT_FEATURE: u16 = 0x1982;
+
+#[derive(Debug, Args)]
+pub struct BacklightArgs {
+ /// Run against the device whose name contains this string
+ /// (case-insensitive) instead of auto-selecting. Useful when several
+ /// keyboards are paired.
+ #[arg(long, value_name = "NAME", global = true)]
+ pub device: Option<String>,
+
+ #[command(subcommand)]
+ pub action: Option<BacklightAction>,
+}
+
+#[derive(Debug, Subcommand)]
+pub enum BacklightAction {
+ /// Show the current backlight state (the default with no subcommand).
+ Status,
+ /// Turn the backlight off completely and persistently. The LEDs stay dark
+ /// regardless of ambient light or hand proximity.
+ Off,
+ /// Turn the backlight back on, restoring the mode and brightness level the
+ /// keyboard still has stored.
+ On,
+}
+
+pub async fn run(args: BacklightArgs) -> Result<()> {
+ let (route, name) = select_device(args.device.as_deref(), &[BACKLIGHT_FEATURE]).await?;
+ println!("device: {name} ({route})");
+
+ let enable = match args.action.unwrap_or(BacklightAction::Status) {
+ BacklightAction::Status => {
+ let state = read_state(&route).await?;
+ print_state("current", state);
+ return Ok(());
+ }
+ BacklightAction::Off => false,
+ BacklightAction::On => true,
+ };
+
+ let before = read_state(&route).await?;
+ print_state("current", before);
+
+ // The keyboard sets this mode itself from its backlight keys and
+ // setBacklightConfig cannot write it back, so say so rather than let the
+ // mode change look like a side effect of the enable bit.
+ if before.mode == BacklightMode::TemporaryManual {
+ println!(
+ " note: the level came from the keyboard's backlight keys, a mode software cannot write back — it returns to automatic (ambient-light sensor)"
+ );
+ }
+
+ let after = openlogi_hid::set_backlight_enabled(&route, enable)
+ .await
+ .with_context(|| {
+ format!(
+ "set backlight {}",
+ if enable { "enabled" } else { "disabled" }
+ )
+ })?;
+ print_state("read-back", after);
+
+ if after.enabled != enable {
+ anyhow::bail!(
+ "backlight write not applied: requested enabled={enable}, device reports enabled={}",
+ after.enabled
+ );
+ }
+
+ if enable {
+ println!(
+ "✓ backlight enabled (level {}/{})",
+ after.current_level, after.nb_levels
+ );
+ } else {
+ println!("✓ backlight off — persisted to the keyboard, survives reconnect and power cycle");
+ }
+ Ok(())
+}
+
+async fn read_state(route: &DeviceRoute) -> Result<BacklightState> {
+ openlogi_hid::get_backlight(route)
+ .await
+ .context("read backlight state")
+}
+
+fn print_state(label: &str, state: BacklightState) {
+ println!(
+ " {label}: enabled={} mode={} status={} level={}/{}",
+ state.enabled,
+ mode_label(state.mode),
+ status_label(state.status),
+ state.current_level,
+ state.nb_levels,
+ );
+}
+
+fn mode_label(mode: BacklightMode) -> &'static str {
+ match mode {
+ BacklightMode::None => "none",
+ BacklightMode::Automatic => "automatic (ambient-light sensor)",
+ BacklightMode::TemporaryManual => "temporary manual (backlight keys)",
+ BacklightMode::PermanentManual => "permanent manual (software)",
+ }
+}
+
+fn status_label(status: BacklightStatus) -> &'static str {
+ match status {
+ BacklightStatus::DisabledBySoftware => "off (disabled by software)",
+ BacklightStatus::DisabledByCriticalBattery => "off (critical battery)",
+ BacklightStatus::AlsAutomatic => "on (following ambient light)",
+ BacklightStatus::AlsSaturated => "off (ambient light saturated)",
+ BacklightStatus::TemporaryManual => "on (level from backlight keys)",
+ BacklightStatus::PermanentManual => "on (level from software)",
+ }
+}
diff --git a/crates/openlogi-cli/src/cmd/camera.rs b/crates/openlogi-cli/src/cmd/camera.rs
new file mode 100644
index 0000000000000000000000000000000000000000..788ec8011e39945f48bdd0343ee66a366d5b733b
--- /dev/null
+++ b/crates/openlogi-cli/src/cmd/camera.rs
@@ -0,0 +1,101 @@
+//! `openlogi camera` — read and write device-level UVC image controls.
+//!
+//! Exercises the `openlogi-camera` UVC path (the same primitive the GUI controls
+//! panel uses). Changes land in the camera's own registers, so other apps
+//! (Google Meet, Zoom, OBS) see them too.
+
+use anyhow::{Result, anyhow};
+use clap::{Args, Subcommand};
+use openlogi_camera::{AutoToggle, CameraControl};
+
+#[derive(Debug, Args)]
+pub struct CameraArgs {
+ #[command(subcommand)]
+ pub cmd: Option<CameraCmd>,
+ /// Operate on the camera with this unique id (default: first Logitech).
+ #[arg(long, global = true)]
+ pub camera: Option<String>,
+}
+
+#[derive(Debug, Subcommand)]
+pub enum CameraCmd {
+ /// Show each control's min/max/default/current and each auto mode's state
+ /// (the default action).
+ Get,
+ /// Set a control to a value (or an auto toggle to 0/1); persists on the
+ /// device.
+ Set {
+ /// zoom | focus | exposure | brightness | contrast | saturation |
+ /// sharpness | white_balance | tint, or focus_auto | exposure_auto |
+ /// white_balance_auto
+ control: String,
+ value: i32,
+ },
+}
+
+pub fn run(args: CameraArgs) -> Result<()> {
+ let uid = match args.camera {
+ Some(id) => id,
+ None => openlogi_camera::enumerate_cameras()
+ .into_iter()
+ .next()
+ .map(|c| c.unique_id)
+ .ok_or_else(|| anyhow!("no Logitech camera found"))?,
+ };
+
+ match args.cmd.unwrap_or(CameraCmd::Get) {
+ CameraCmd::Get => {
+ println!("controls for {uid}:");
+ match openlogi_camera::read_camera_state(&uid) {
+ Ok(state) if !state.controls.is_empty() => {
+ for (control, r) in &state.controls {
+ println!(
+ " {}: min={} max={} default={} current={}",
+ control.name(),
+ r.min,
+ r.max,
+ r.default,
+ r.current
+ );
+ }
+ for (toggle, st) in &state.autos {
+ println!(
+ " {}: current={} default={}",
+ toggle.name(),
+ st.current,
+ st.default
+ );
+ }
+ }
+ Ok(_) => println!(" (no adjustable controls, or camera not found)"),
+ Err(e) => println!(" {e}"),
+ }
+ }
+ CameraCmd::Set { control, value } => {
+ let raw = control.to_ascii_lowercase();
+ if let Some(toggle) = AutoToggle::ALL.iter().find(|t| t.name() == raw) {
+ openlogi_camera::set_auto(&uid, *toggle, value != 0).map_err(|e| anyhow!("{e}"))?;
+ println!("set {} = {}", toggle.name(), value != 0);
+ } else {
+ let control = parse_control(&raw)?;
+ openlogi_camera::set_control(&uid, control, value).map_err(|e| anyhow!("{e}"))?;
+ println!("set {} = {value}", control.name());
+ }
+ }
+ }
+ Ok(())
+}
+
+fn parse_control(raw: &str) -> Result<CameraControl> {
+ CameraControl::ALL
+ .into_iter()
+ .find(|c| c.name() == raw)
+ .ok_or_else(|| {
+ let names: Vec<&str> = CameraControl::ALL
+ .iter()
+ .map(|c| c.name())
+ .chain(AutoToggle::ALL.iter().map(|t| t.name()))
+ .collect();
+ anyhow!("unknown control {raw:?} ({})", names.join("|"))
+ })
+}
diff --git a/crates/openlogi-cli/src/cmd/diag.rs b/crates/openlogi-cli/src/cmd/diag.rs
index 3a9e0a74ab6a23798ffbdfe9fb1977464bbdee74..deecea0c94b429c49139be7f42bbaaaef3606ab6 100644
--- a/crates/openlogi-cli/src/cmd/diag.rs
+++ b/crates/openlogi-cli/src/cmd/diag.rs
@@ -10,6 +10,7 @@ use anyhow::{Result, anyhow};
use clap::Subcommand;
use openlogi_hid::{DeviceRoute, dump_features};
+pub mod battery;
pub mod controls;
pub mod dpi;
pub mod features;
@@ -23,6 +24,8 @@ pub enum DiagCmd {
Features(features::FeaturesArgs),
/// Dump HID++ 0x1b04 reprogrammable controls and capability flags.
Controls(controls::ControlsArgs),
+ /// Read the raw battery report (0x1004 or 0x1000 fields).
+ Battery(battery::BatteryArgs),
/// Read DPI → write a small delta → read back → restore → report.
Dpi(dpi::DpiArgs),
/// Read SmartShift mode → toggle → read back → toggle back → report.
@@ -38,6 +41,7 @@ impl DiagCmd {
match self {
Self::Features(args) => features::run(args).await,
Self::Controls(args) => controls::run(args).await,
+ Self::Battery(args) => battery::run(args).await,
Self::Dpi(args) => dpi::run(args).await,
Self::Smartshift(args) => smartshift::run(args).await,
Self::Lighting(args) => lighting::run(args).await,
diff --git a/crates/openlogi-cli/src/cmd/diag/battery.rs b/crates/openlogi-cli/src/cmd/diag/battery.rs
new file mode 100644
index 0000000000000000000000000000000000000000..18e93c2fcece954dfc8f04f4c7325fd2301bf1de
--- /dev/null
+++ b/crates/openlogi-cli/src/cmd/diag/battery.rs
@@ -0,0 +1,32 @@
+//! `openlogi diag battery` — dump the device's raw battery report.
+//!
+//! Prints exactly what the firmware returns (unified `0x1004` fields, or legacy
+//! `0x1000` `discharge_level`/`next_level`/`status`). Run it once on battery and
+//! once with the charger plugged in to see how the device reports while charging
+//! — e.g. an MX2S returns `discharge_level=0` mid-charge, which is the device's
+//! own limitation, not a bug in the read path.
+
+use anyhow::{Context, Result};
+use clap::Args;
+
+use crate::cmd::diag::select_device;
+
+#[derive(Debug, Args)]
+pub struct BatteryArgs {
+ /// Run against the device whose name contains this string
+ /// (case-insensitive) instead of auto-selecting.
+ #[arg(long, value_name = "NAME")]
+ pub device: Option<String>,
+}
+
+pub async fn run(args: BatteryArgs) -> Result<()> {
+ // 0x1004 UnifiedBattery / 0x1000 BatteryStatus — pick a device with either.
+ let (route, name) = select_device(args.device.as_deref(), &[0x1000, 0x1004]).await?;
+ println!("device: {name} ({route})");
+
+ let line = openlogi_hid::read_battery_raw(&route)
+ .await
+ .context("read battery")?;
+ println!(" {line}");
+ Ok(())
+}
diff --git a/crates/openlogi-cli/src/cmd/light.rs b/crates/openlogi-cli/src/cmd/light.rs
new file mode 100644
index 0000000000000000000000000000000000000000..fdfde05bcb49b464d14a18accc2a6604cc2d9edf
--- /dev/null
+++ b/crates/openlogi-cli/src/cmd/light.rs
@@ -0,0 +1,251 @@
+//! `openlogi light` — discovery and manual control for standalone lights.
+//!
+//! The CLI intentionally uses the same raw-HID driver as the agent. It is a
+//! small hardware-facing surface for validating discovery and report encoding
+//! before exercising the GPUI panel.
+
+use anyhow::{Context, Result, anyhow};
+use clap::{Args, Subcommand};
+use openlogi_core::device::{LightValueUnit, StandaloneDevice};
+use openlogi_hid::{DeviceRoute, LightCommand, LitraModel};
+
+#[derive(Debug, Subcommand)]
+pub enum LightCmd {
+ /// List recognized standalone lights and their advertised controls.
+ List,
+ /// Turn a light on.
+ On(DeviceArgs),
+ /// Turn a light off.
+ Off(DeviceArgs),
+ /// Set normalized brightness or native lumens.
+ Brightness(BrightnessArgs),
+ /// Set colour temperature in Kelvin.
+ Temperature(TemperatureArgs),
+}
+
+#[derive(Debug, Args)]
+pub struct DeviceArgs {
+ /// Case-insensitive substring of the light name or identity.
+ #[arg(long)]
+ device: Option<String>,
+}
+
+#[derive(Debug, Args)]
+pub struct BrightnessArgs {
+ #[command(flatten)]
+ device: DeviceArgs,
+ /// Normalized brightness from 0 to 100 percent.
+ #[arg(long, conflicts_with = "lumens", value_parser = clap::value_parser!(u8).range(0..=100))]
+ percent: Option<u8>,
+ /// Native brightness in lumens.
+ #[arg(long, conflicts_with = "percent")]
+ lumens: Option<u16>,
+}
+
+#[derive(Debug, Args)]
+pub struct TemperatureArgs {
+ #[command(flatten)]
+ device: DeviceArgs,
+ /// Colour temperature in Kelvin.
+ #[arg(long)]
+ kelvin: u16,
+}
+
+impl LightCmd {
+ pub async fn run(self) -> Result<()> {
+ match self {
+ Self::List => list().await,
+ Self::On(args) => set_power(args.device.as_deref(), true).await,
+ Self::Off(args) => set_power(args.device.as_deref(), false).await,
+ Self::Brightness(args) => set_brightness(args).await,
+ Self::Temperature(args) => set_temperature(args).await,
+ }
+ }
+}
+
+async fn standalone() -> Result<Vec<StandaloneDevice>> {
+ openlogi_hid::enumerate_standalone()
+ .await
+ .context("failed to enumerate standalone HID devices")
+}
+
+async fn list() -> Result<()> {
+ let devices = standalone().await?;
+ if devices.is_empty() {
+ println!("No supported standalone lights found.");
+ return Ok(());
+ }
+ for device in devices {
+ let address = &device.address;
+ println!(
+ "{} — {} ({:04x}:{:04x} usage {:04x}:{:04x})",
+ device.display_name,
+ address.identity,
+ address.vendor_id,
+ address.product_id,
+ address.usage_page,
+ address.usage_id,
+ );
+ if let Some(caps) = device.light_capabilities {
+ if let Some(range) = caps.brightness {
+ println!(
+ " brightness: {}–{} {:?}",
+ range.min(),
+ range.max(),
+ range.unit()
+ );
+ }
+ if let Some(range) = caps.temperature {
+ println!(
+ " temperature: {}–{} K step {}",
+ range.min(),
+ range.max(),
+ range.step()
+ );
+ }
+ println!(" power: {}", if caps.power { "yes" } else { "no" });
+ }
+ }
+ Ok(())
+}
+
+async fn set_power(query: Option<&str>, enabled: bool) -> Result<()> {
+ let devices = standalone().await?;
+ let device = select(&devices, query)?;
+ apply(device, LightCommand::Power(enabled)).await
+}
+
+async fn set_brightness(args: BrightnessArgs) -> Result<()> {
+ let devices = standalone().await?;
+ let device = select(&devices, args.device.device.as_deref())?;
+ let caps = device
+ .light_capabilities
+ .ok_or_else(|| anyhow!("selected light did not advertise capabilities"))?;
+ let range = caps
+ .brightness
+ .ok_or_else(|| anyhow!("selected light does not support brightness"))?;
+ let command = match (args.percent, args.lumens) {
+ (Some(percent), None) => LightCommand::BrightnessPercent(percent),
+ (None, Some(lumens)) => {
+ if range.unit() != LightValueUnit::Lumens || !range.contains(lumens) {
+ return Err(anyhow!(
+ "lumens must be in the supported range {}..={} with step {}",
+ range.min(),
+ range.max(),
+ range.step()
+ ));
+ }
+ LightCommand::BrightnessNative(lumens)
+ }
+ (None, None) => return Err(anyhow!("pass either --percent or --lumens")),
+ (Some(_), Some(_)) => unreachable!("clap enforces the argument conflict"),
+ };
+ apply(device, command).await
+}
+
+async fn set_temperature(args: TemperatureArgs) -> Result<()> {
+ let devices = standalone().await?;
+ let device = select(&devices, args.device.device.as_deref())?;
+ apply(device, LightCommand::TemperatureKelvin(args.kelvin)).await
+}
+
+async fn apply(device: &StandaloneDevice, command: LightCommand) -> Result<()> {
+ let model = LitraModel::from_product_id(device.address.product_id).ok_or_else(|| {
+ anyhow!(
+ "unsupported light product {:04x}",
+ device.address.product_id
+ )
+ })?;
+ let route = DeviceRoute::RawHid {
+ vendor_id: device.address.vendor_id,
+ product_id: device.address.product_id,
+ usage_page: device.address.usage_page,
+ usage_id: device.address.usage_id,
+ identity: device.address.identity.clone(),
+ };
+ openlogi_hid::apply_litra(&route, model, command)
+ .await
+ .context("failed to write the light command")
+}
+
+fn select<'a>(
+ devices: &'a [StandaloneDevice],
+ query: Option<&str>,
+) -> Result<&'a StandaloneDevice> {
+ let Some(query) = query else {
+ return match devices {
+ [] => Err(anyhow!("no supported standalone light found")),
+ [device] => Ok(device),
+ _ => Err(anyhow!(
+ "multiple standalone lights found; select one with --device"
+ )),
+ };
+ };
+ let query = query.to_ascii_lowercase();
+ let mut matches = devices.iter().filter(|device| {
+ device.display_name.to_ascii_lowercase().contains(&query)
+ || device
+ .address
+ .identity
+ .to_ascii_lowercase()
+ .contains(&query)
+ });
+ let Some(device) = matches.next() else {
+ return Err(anyhow!("no standalone light matches --device {query}"));
+ };
+ if matches.next().is_some() {
+ return Err(anyhow!(
+ "multiple standalone lights match --device {query}; use a more specific value"
+ ));
+ }
+ Ok(device)
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "expect is idiomatic in selection tests")]
+
+ use super::select;
+ use openlogi_core::device::{DeviceKind, RawDeviceAddress, StandaloneDevice};
+
+ fn device(name: &str) -> StandaloneDevice {
+ StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:test".into(),
+ },
+ display_name: name.into(),
+ manufacturer: Some("Logi".into()),
+ serial_number: Some("test".into()),
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: None,
+ driver_id: "litra".into(),
+ registry_model_id: None,
+ }
+ }
+
+ #[test]
+ fn selection_requires_disambiguation_and_supports_name_queries() {
+ let devices = vec![device("Litra Glow"), device("Litra Beam")];
+ assert!(select(&devices, None).is_err());
+ assert_eq!(
+ select(&devices, Some("beam"))
+ .expect("matching device")
+ .display_name,
+ "Litra Beam"
+ );
+ assert!(select(&devices, Some("litra")).is_err());
+ assert_eq!(
+ select(&devices[..1], None)
+ .expect("single device")
+ .display_name,
+ "Litra Glow"
+ );
+ }
+}
diff --git a/crates/openlogi-cli/src/cmd/list.rs b/crates/openlogi-cli/src/cmd/list.rs
index 963d7f2982fe0b3666675fc7b4186fd1f3321031..dcc57c822c66bb9733c11a1a3705cb232c8fef20 100644
--- a/crates/openlogi-cli/src/cmd/list.rs
+++ b/crates/openlogi-cli/src/cmd/list.rs
@@ -1,5 +1,6 @@
use anyhow::{Context, Result};
use clap::Args;
+use openlogi_camera::Camera;
use openlogi_core::device::{BatteryInfo, DeviceInventory, DeviceModelInfo, PairedDevice};
#[derive(Debug, Args)]
@@ -9,9 +10,10 @@ pub async fn run(_args: ListArgs) -> Result<()> {
let inventories = openlogi_hid::enumerate()
.await
.context("failed to enumerate HID++ devices")?;
+ let cameras = openlogi_camera::enumerate_cameras();
- if inventories.is_empty() {
- println!("No Logitech HID++ devices found.");
+ if inventories.is_empty() && cameras.is_empty() {
+ println!("No Logitech HID++ devices or webcams found.");
println!();
println!("Notes:");
println!(" - On macOS, quit Logi Options+ first — both apps fight over HID++ access.");
@@ -33,9 +35,33 @@ pub async fn run(_args: ListArgs) -> Result<()> {
print_inventory(inv);
}
+ if !cameras.is_empty() {
+ if !inventories.is_empty() {
+ println!();
+ }
+ print_cameras(&cameras);
+ }
+
Ok(())
}
+fn print_cameras(cameras: &[Camera]) {
+ println!("Cameras ({} Logitech UVC)", cameras.len());
+ let last = cameras.len() - 1;
+ for (i, cam) in cameras.iter().enumerate() {
+ let prefix = if i == last { " └─" } else { " ├─" };
+ let caps = match (cam.max_resolution, cam.max_fps) {
+ (Some((w, h)), Some(fps)) => format!(", up to {w}x{h}@{fps}"),
+ (Some((w, h)), None) => format!(", up to {w}x{h}"),
+ _ => String::new(),
+ };
+ println!(
+ "{prefix} ● {} (camera, vid={:04x} pid={:04x}{caps}, id={})",
+ cam.name, cam.vendor_id, cam.product_id, cam.unique_id
+ );
+ }
+}
+
fn print_inventory(inv: &DeviceInventory) {
let uid = inv.receiver.unique_id.as_deref().unwrap_or("—");
println!(
diff --git a/crates/openlogi-cli/src/cmd/mod.rs b/crates/openlogi-cli/src/cmd/mod.rs
index 91136c28e3c420779c131c0e745465e53850f319..7032408a65d23b19d91b686418376053408c488a 100644
--- a/crates/openlogi-cli/src/cmd/mod.rs
+++ b/crates/openlogi-cli/src/cmd/mod.rs
@@ -2,28 +2,47 @@ use anyhow::Result;
use clap::Subcommand;
pub mod assets;
+pub mod backlight;
+pub mod camera;
pub mod diag;
+pub mod light;
pub mod list;
+pub mod snapshot;
#[derive(Debug, Subcommand)]
pub enum Command {
/// List connected Logitech HID++ devices.
List(list::ListArgs),
+ /// Read or persistently set the keyboard backlight (HID++ 0x1982).
+ Backlight(backlight::BacklightArgs),
+ /// Capture one frame from a Logitech webcam to a PNG.
+ Snapshot(snapshot::SnapshotArgs),
+ /// Read or write device-level UVC image controls on a webcam.
+ Camera(camera::CameraArgs),
/// Manage assets fetched from OpenLogi's asset mirrors.
#[command(subcommand)]
Assets(assets::AssetsCmd),
/// Real-device round-trip smoke tests against the HID++ write path.
#[command(subcommand)]
Diag(diag::DiagCmd),
+ /// Inspect and control standalone Logitech lights.
+ #[command(subcommand)]
+ Light(light::LightCmd),
}
impl Command {
pub async fn run(self) -> Result<()> {
match self {
Self::List(args) => list::run(args).await,
+ Self::Backlight(args) => backlight::run(args).await,
+ // Camera capture is blocking AVFoundation — no need for the async runtime.
+ Self::Snapshot(args) => snapshot::run(args),
+ // UVC control transfers are blocking IOKit — no async runtime needed.
+ Self::Camera(args) => camera::run(args),
// `assets sync` is blocking HTTP — no need for the async runtime.
Self::Assets(cmd) => cmd.run(),
Self::Diag(cmd) => cmd.run().await,
+ Self::Light(cmd) => cmd.run().await,
}
}
}
diff --git a/crates/openlogi-cli/src/cmd/snapshot.rs b/crates/openlogi-cli/src/cmd/snapshot.rs
new file mode 100644
index 0000000000000000000000000000000000000000..889b81c768af4236cfeb2465716ddb72ff46b059
--- /dev/null
+++ b/crates/openlogi-cli/src/cmd/snapshot.rs
@@ -0,0 +1,57 @@
+//! `openlogi snapshot` — grab one frame from a Logitech webcam to a PNG.
+//!
+//! Exercises the `openlogi-camera` capture path (the same primitive the GUI
+//! preview uses). Capturing needs Camera permission; from this unbundled CLI
+//! macOS may deny access (no `NSCameraUsageDescription`), which is reported
+//! rather than fatal.
+
+use std::time::Duration;
+
+use anyhow::{Context, Result, anyhow};
+use clap::Args;
+
+#[derive(Debug, Args)]
+pub struct SnapshotArgs {
+ /// Output PNG path.
+ #[arg(default_value = "snapshot.png")]
+ pub path: String,
+ /// Capture from the camera with this unique id (default: first Logitech).
+ #[arg(long)]
+ pub camera: Option<String>,
+}
+
+pub fn run(args: SnapshotArgs) -> Result<()> {
+ let unique_id = match args.camera {
+ Some(id) => id,
+ None => openlogi_camera::enumerate_cameras()
+ .into_iter()
+ .next()
+ .map(|camera| camera.unique_id)
+ .ok_or_else(|| anyhow!("no Logitech camera found"))?,
+ };
+
+ println!("capturing one frame from {unique_id} …");
+ let frame = openlogi_camera::capture_frame(&unique_id, Duration::from_secs(5))
+ .map_err(|e| anyhow!("{e}"))?;
+ // Frames are stored BGRA (gpui's order); PNG wants RGBA, so swap R/B once.
+ let mut rgba = frame.bgra;
+ for px in rgba.chunks_exact_mut(4) {
+ px.swap(0, 2);
+ }
+ write_png(&args.path, frame.width, frame.height, &rgba)
+ .with_context(|| format!("writing {}", args.path))?;
+ println!("wrote {}x{} → {}", frame.width, frame.height, args.path);
+ Ok(())
+}
+
+fn write_png(path: &str, width: u32, height: u32, rgba: &[u8]) -> Result<()> {
+ let file = std::fs::File::create(path)?;
+ let writer = std::io::BufWriter::new(file);
+ let mut encoder = png::Encoder::new(writer, width, height);
+ encoder.set_color(png::ColorType::Rgba);
+ encoder.set_depth(png::BitDepth::Eight);
+ encoder
+ .write_header()?
+ .write_image_data(rgba)
+ .context("encoding PNG")
+}
diff --git a/crates/openlogi-cli/src/lib.rs b/crates/openlogi-cli/src/lib.rs
index 97af9981fd330d89c9df14a04386483910b7ec70..0d3b0e86959558927a56fadf3b4d52f22be715ef 100644
--- a/crates/openlogi-cli/src/lib.rs
+++ b/crates/openlogi-cli/src/lib.rs
@@ -43,6 +43,7 @@ mod tests {
use super::*;
use cmd::Command;
+ use cmd::backlight::BacklightAction;
use cmd::diag::DiagCmd;
use cmd::diag::lighting::Method;
use cmd::diag::wheel::ResolutionArg;
@@ -63,6 +64,41 @@ mod tests {
assert!(cli.cmd.is_none());
}
+ /// A bare `openlogi backlight` must stay valid — `run` treats a missing
+ /// action as `status`, so it can never write to the device by accident.
+ #[test]
+ fn backlight_defaults_to_status_and_accepts_a_device_filter() {
+ let cli = Cli::try_parse_from(["openlogi", "backlight", "--device", "MX KEYS S"])
+ .expect("bare backlight invocation parses");
+
+ match cli.cmd.expect("subcommand present") {
+ Command::Backlight(args) => {
+ assert_eq!(args.device.as_deref(), Some("MX KEYS S"));
+ assert!(args.action.is_none());
+ }
+ other => panic!("expected Backlight, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn backlight_off_is_parsed_as_its_own_action() {
+ let cli =
+ Cli::try_parse_from(["openlogi", "backlight", "off"]).expect("backlight off parses");
+
+ match cli.cmd.expect("subcommand present") {
+ Command::Backlight(args) => {
+ assert!(matches!(args.action, Some(BacklightAction::Off)));
+ }
+ other => panic!("expected Backlight, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn backlight_rejects_an_unknown_action() {
+ let result = Cli::try_parse_from(["openlogi", "backlight", "dim"]);
+ assert!(result.is_err());
+ }
+
#[test]
fn smartshift_leave_flipped_conflicts_with_sensitivity() {
let result = Cli::try_parse_from([
diff --git a/crates/openlogi-core/Cargo.toml b/crates/openlogi-core/Cargo.toml
index abaee6068bcf53fa5f6a014a8dcd590d9479baf1..49af4e064eeb179e356f6a940c2ee5441aac9444 100644
--- a/crates/openlogi-core/Cargo.toml
+++ b/crates/openlogi-core/Cargo.toml
@@ -18,6 +18,9 @@ tracing = { workspace = true }
etcetera = "0.11.0"
atomic-write-file = "0.3.0"
+[target.'cfg(target_os = "macos")'.dependencies]
+plist = "1.10.0"
+
[dev-dependencies]
tempfile = "3"
tracing-subscriber = { workspace = true }
diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs
index 95fae5e56de064e19249feb231ab5c41aa991cf0..3e5ade69ee65441e7171bf0b2484f500155c6f3b 100644
--- a/crates/openlogi-core/src/binding.rs
+++ b/crates/openlogi-core/src/binding.rs
@@ -5,1105 +5,31 @@
//! When [`Action`] gains new variants, keep the existing variant names stable:
//! the TOML config keys/values use the enum variant identifiers verbatim, so
//! renames are migration events.
-
-use std::collections::BTreeMap;
-use std::fmt;
-
-use serde::{Deserialize, Serialize};
-
+//!
+//! Modules are split by domain so concurrent feature work (new buttons, new
+//! actions, defaults, gesture maps) rarely edits the same file.
+
+mod action;
+mod button;
+mod category;
+mod defaults;
+mod gesture;
+mod key_combo;
mod swipe;
+mod value;
+#[cfg(test)]
+#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
+mod tests;
+
+pub use action::{Action, WorkflowStep};
+pub use button::ButtonId;
+pub use category::Category;
+pub use defaults::{default_binding, default_binding_for, default_gesture_binding};
+pub use gesture::GestureDirection;
+pub use key_combo::KeyCombo;
pub use swipe::{
GESTURE_HOLD_FOR_SWIPE, GESTURE_SWIPE_DEADZONE, GESTURE_SWIPE_THRESHOLD, SwipeAccumulator,
detect_swipe,
};
-
-/// One of the user-rebindable hotspots on a Logi mouse. The order matches the
-/// physical layout from front to side; [`ButtonId::ALL`] is consumed by the
-/// default-binding generator and the popover trigger list.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub enum ButtonId {
- /// The primary button. Rebindable in the config schema, but the OS hook
- /// never suppresses it — see [`ButtonId::is_os_hook_button`].
- LeftClick,
- /// The secondary button. Like [`ButtonId::LeftClick`], it always passes
- /// through the OS hook.
- RightClick,
- /// The wheel click — one of the three buttons the OS hook remaps.
- MiddleClick,
- /// The thumb-side "back" button (mouse button 4), remapped by the OS hook.
- Back,
- /// The thumb-side "forward" button (mouse button 5), remapped by the OS hook.
- Forward,
- /// The "ModeShift" button under the wheel — typically used for SmartShift /
- /// DPI cycle. Named `DpiToggle` for historical reasons.
- DpiToggle,
- /// The horizontal thumb wheel's click. Kept in [`ButtonId::ALL`] so its
- /// default still seeds and dispatches when the wheel is diverted, even
- /// though the mouse model surfaces one paired rotation control instead of
- /// the click (see `mouse_model::geometry`).
- Thumbwheel,
- /// Rotating the thumb wheel "up" (positive rotation). Bound, by default, to
- /// continuous horizontal scroll; see the agent-core `watchers`-side dispatch.
- ThumbwheelScrollUp,
- /// Rotating the thumb wheel "down" (negative rotation).
- ThumbwheelScrollDown,
- /// The HID++ gesture button on MX-line devices. The press itself
- /// fires the bound action; swipe directions are P1.5 territory.
- GestureButton,
-}
-
-impl ButtonId {
- /// Every rebindable button in declaration (physical front-to-side) order —
- /// the iteration source for default-binding seeding and the popover
- /// trigger list.
- pub const ALL: [ButtonId; 10] = [
- ButtonId::LeftClick,
- ButtonId::RightClick,
- ButtonId::MiddleClick,
- ButtonId::Back,
- ButtonId::Forward,
- ButtonId::DpiToggle,
- ButtonId::Thumbwheel,
- ButtonId::ThumbwheelScrollUp,
- ButtonId::ThumbwheelScrollDown,
- ButtonId::GestureButton,
- ];
-
- /// Whether this button is one the OS hook (macOS `CGEventTap` / Linux evdev)
- /// remaps: Middle, Back, or Forward. The primary L/R clicks always pass
- /// through (suppressing them would brick the mouse), and the DPI / thumb /
- /// dedicated gesture controls aren't visible to the OS hook at all (they're
- /// captured over HID++). These are exactly the buttons that can become an
- /// OS-hook gesture button, so the hook's remap gate and the gesture-owner
- /// projection share this one definition.
- #[must_use]
- pub fn is_os_hook_button(self) -> bool {
- matches!(
- self,
- ButtonId::MiddleClick | ButtonId::Back | ButtonId::Forward
- )
- }
-
- /// Human-readable label for popovers and tooltips.
- #[must_use]
- pub fn label(self) -> &'static str {
- match self {
- ButtonId::LeftClick => "Left Click",
- ButtonId::RightClick => "Right Click",
- ButtonId::MiddleClick => "Middle Click",
- ButtonId::Back => "Back",
- ButtonId::Forward => "Forward",
- ButtonId::DpiToggle => "DPI Toggle",
- ButtonId::Thumbwheel => "Thumb Wheel",
- ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up",
- ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down",
- ButtonId::GestureButton => "Gesture Button",
- }
- }
-}
-
-impl fmt::Display for ButtonId {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.write_str(self.label())
- }
-}
-
-/// One of the five sub-bindings on the gesture button: hold + swipe up/down/
-/// left/right or a plain click without movement. Logi ships these as
-/// independent assignments (`SLOT_NAME_GESTURE_*_BUTTON` in the
-/// `device_gesture_buttons_image` metadata block) — OpenLogi mirrors the
-/// same shape.
-///
-/// Variant identifiers are TOML-stable: renames are migration events.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub enum GestureDirection {
- /// Hold + swipe up (negative raw-XY `dy`).
- Up,
- /// Hold + swipe down (positive raw-XY `dy`).
- Down,
- /// Hold + swipe left (negative raw-XY `dx`).
- Left,
- /// Hold + swipe right (positive raw-XY `dx`).
- Right,
- /// A press-and-release that never committed a swipe — the gesture
- /// button's plain-click slot.
- Click,
-}
-
-impl GestureDirection {
- /// All five direction slots, swipes first and [`Click`](Self::Click) last.
- /// Iterated to seed or complete a full gesture map — see
- /// [`Binding::fill_gesture_defaults`] and [`default_binding_for`].
- pub const ALL: [GestureDirection; 5] = [
- GestureDirection::Up,
- GestureDirection::Down,
- GestureDirection::Left,
- GestureDirection::Right,
- GestureDirection::Click,
- ];
-
- /// Human-readable label for popovers and tooltips.
- #[must_use]
- pub fn label(self) -> &'static str {
- match self {
- GestureDirection::Up => "Up",
- GestureDirection::Down => "Down",
- GestureDirection::Left => "Left",
- GestureDirection::Right => "Right",
- GestureDirection::Click => "Click",
- }
- }
-
- /// Arrow glyph for compact list rendering.
- #[must_use]
- pub fn glyph(self) -> &'static str {
- match self {
- GestureDirection::Up => "↑",
- GestureDirection::Down => "↓",
- GestureDirection::Left => "←",
- GestureDirection::Right => "→",
- GestureDirection::Click => "·",
- }
- }
-}
-
-impl fmt::Display for GestureDirection {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.write_str(self.label())
- }
-}
-
-/// Grouping for popover section headers.
-///
-/// Used by [`Action::category`] and rendered as a small muted label above
-/// each group in the action picker.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
-pub enum Category {
- /// Cut, copy, paste, undo, redo, select-all, find, save.
- Editing,
- /// Browser navigation: tabs, page reload, back/forward.
- Browser,
- /// Playback and volume controls.
- Media,
- /// Physical mouse clicks.
- Mouse,
- /// DPI cycle and SmartShift.
- Dpi,
- /// Scroll direction shortcuts.
- Scroll,
- /// Window/app navigation: Mission Control, Launchpad, etc.
- Navigation,
- /// Lock screen, show desktop, system-level actions.
- System,
-}
-
-impl Category {
- /// Short label for popover section headers (already uppercase so callers
- /// don't have to transform it).
- #[must_use]
- pub fn label(self) -> &'static str {
- match self {
- Category::Editing => "EDITING",
- Category::Browser => "BROWSER",
- Category::Media => "MEDIA",
- Category::Mouse => "MOUSE",
- Category::Dpi => "DPI",
- Category::Scroll => "SCROLL",
- Category::Navigation => "NAVIGATION",
- Category::System => "SYSTEM",
- }
- }
-}
-
-/// What pressing a [`ButtonId`] should do.
-///
-/// Serialization uses serde's default external tagging: unit variants
-/// serialize as a bare string (`"BrowserBack"`) and the tuple variant
-/// serializes as a single-key table (`{ CustomShortcut = "my chord" }`).
-///
-/// **Stability contract:** existing variant *names* are frozen — they form the
-/// on-disk `config.toml` schema. New variants may be appended freely; removing
-/// or renaming a variant requires a `schema_version` bump and a migration.
-///
-/// This type is pure config data: OS-level event synthesis for each variant
-/// lives in the `openlogi-inject` crate (`openlogi_inject::execute`), keeping
-/// this crate platform- and IO-free.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
-pub enum Action {
- // ── System ───────────────────────────────────────────────────────────────
- /// Suppress the input entirely — the button or wheel direction is captured
- /// but no OS event is synthesised, so the physical input does nothing.
- None,
-
- // ── Mouse ────────────────────────────────────────────────────────────────
- /// Primary mouse button.
- LeftClick,
- /// Secondary mouse button.
- RightClick,
- /// Middle mouse button (wheel click).
- MiddleClick,
- /// Mouse "back" side button (extra button 4). Synthesizes the real mouse
- /// button event, which browsers and most apps interpret as "navigate back"
- /// natively — unlike [`Action::BrowserBack`], which sends ⌘[ and is ignored
- /// by many apps.
- MouseBack,
- /// Mouse "forward" side button (extra button 5). Native counterpart to
- /// [`Action::MouseBack`]; see [`Action::BrowserForward`] for the ⌘] form.
- MouseForward,
-
- // ── Editing ──────────────────────────────────────────────────────────────
- /// Copy the current selection (⌘C / Ctrl+C).
- Copy,
- /// Paste from the clipboard (⌘V / Ctrl+V).
- Paste,
- /// Cut the current selection (⌘X / Ctrl+X).
- Cut,
- /// Undo the last action (⌘Z / Ctrl+Z).
- Undo,
- /// Redo the last undone action (⌘⇧Z on macOS / Ctrl+Shift+Z on Linux).
- ///
- /// Note: Ctrl+Y is the dominant redo shortcut in LibreOffice and many GTK
- /// apps. Ctrl+Shift+Z is used here because it mirrors the macOS convention
- /// and works in GNOME text fields, browsers, and Electron apps. If Ctrl+Y
- /// coverage is needed, a `CustomShortcut` binding is the escape hatch.
- Redo,
- /// Select all content (⌘A / Ctrl+A).
- SelectAll,
- /// Open the find / search bar (⌘F / Ctrl+F).
- Find,
- /// Save the current document (⌘S / Ctrl+S).
- Save,
-
- // ── Browser / Navigation ──────────────────────────────────────────────────
- /// Navigate backward in browser history.
- BrowserBack,
- /// Navigate forward in browser history.
- BrowserForward,
- /// Open a new tab (⌘T / Ctrl+T).
- NewTab,
- /// Close the current tab (⌘W / Ctrl+W).
- CloseTab,
- /// Reopen the last closed tab (⌘⇧T / Ctrl+Shift+T).
- ReopenTab,
- /// Switch to the next tab (⌃⇥ / Ctrl+Tab).
- NextTab,
- /// Switch to the previous tab (⌃⇧⇥ / Ctrl+Shift+Tab).
- PrevTab,
- /// Reload the current page (⌘R / Ctrl+R).
- ReloadPage,
-
- // ── Navigation / Window ───────────────────────────────────────────────────
- /// macOS Mission Control (⌃↑).
- MissionControl,
- /// macOS App Exposé — all windows for the current app (⌃↓).
- AppExpose,
- /// Switch to the previous desktop / Space.
- PreviousDesktop,
- /// Switch to the next desktop / Space.
- NextDesktop,
- /// Show the desktop (hide all windows).
- ShowDesktop,
- /// Open Launchpad.
- LaunchpadShow,
-
- // ── System ────────────────────────────────────────────────────────────────
- /// Lock the screen (⌘⌃Q on macOS).
- ///
- /// On Linux, calls `org.freedesktop.login1.Manager.LockSession($XDG_SESSION_ID)`
- /// on the system bus (current session only). Falls back to Super+L when
- /// `$XDG_SESSION_ID` is unset or on non-systemd systems.
- LockScreen,
- /// Capture a screenshot.
- Screenshot,
- /// Capture a selected screen region to the clipboard.
- ///
- /// macOS uses Cmd+Shift+Ctrl+4; Windows uses Win+Shift+S. Linux delegates
- /// to the desktop environment's screenshot handler via Print Screen.
- CaptureRegion,
-
- // ── Media ────────────────────────────────────────────────────────────────
- /// Toggle media play/pause.
- PlayPause,
- /// Skip to the next track.
- NextTrack,
- /// Go back to the previous track.
- PrevTrack,
- /// Increase system volume.
- VolumeUp,
- /// Decrease system volume.
- VolumeDown,
- /// Toggle system mute.
- MuteVolume,
-
- // ── DPI ──────────────────────────────────────────────────────────────────
- /// Step through the configured DPI preset list (P1.7).
- CycleDpiPresets,
- /// Jump to a specific zero-based preset in the device's DPI preset list.
- /// Out-of-range indices clamp to the list length at fire time (P1.7).
- SetDpiPreset(u8),
- /// Toggle the HID++ SmartShift ratchet/free-spin wheel mode (P1.1).
- ToggleSmartShift,
-
- // ── Scroll ───────────────────────────────────────────────────────────────
- /// Synthesise a vertical scroll-up tick.
- ScrollUp,
- /// Synthesise a vertical scroll-down tick.
- ScrollDown,
- /// Synthesise a horizontal scroll-left tick.
- HorizontalScrollLeft,
- /// Synthesise a horizontal scroll-right tick.
- HorizontalScrollRight,
-
- // ── Custom ───────────────────────────────────────────────────────────────
- /// Replay an arbitrary recorded key chord (P1.3).
- ///
- /// Holds the structured chord data so `openlogi_inject::execute` can post the
- /// real keystroke (macOS: CGEventPost with the encoded modifier flags).
- /// The `display` field is used by [`Action::label`] so the popover
- /// shows the user-friendly chord name.
- CustomShortcut(KeyCombo),
-}
-
-/// A modifier + virtual-key keystroke captured by the P1.3 recorder UI or
-/// hand-authored in `config.toml`.
-///
-/// `modifiers` is a bitmask of [`KeyCombo::MOD_CMD`] etc. so the wire format
-/// is a compact integer, not a string. `key_code` is the macOS virtual key
-/// (`kVK_*`); on Linux, `openlogi-inject` maps it to an evdev `KeyCode` when it
-/// synthesizes the chord.
-///
-/// `display` is purely for rendering — e.g. `"⌘⇧P"`. Callers regenerate it
-/// from the captured chord; we keep it in the struct so older configs
-/// continue to render the same label without re-deriving on every load.
-#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
-pub struct KeyCombo {
- /// Bitmask of [`Self::MOD_CMD`] etc.
- pub modifiers: u8,
- /// macOS virtual key code (`kVK_*`). 0 means "no key" — useful for
- /// modifier-only placeholders that the recorder UI rejects. On Linux,
- /// `openlogi-inject` translates this to an evdev `KeyCode`.
- pub key_code: u16,
- /// Pre-rendered chord label, e.g. `"⌘⇧P"`. Empty falls through to a
- /// generated label at runtime.
- #[serde(default)]
- pub display: String,
-}
-
-impl KeyCombo {
- /// Bit for the ⌘ Command modifier in [`Self::modifiers`].
- pub const MOD_CMD: u8 = 1 << 0;
- /// Bit for the ⇧ Shift modifier in [`Self::modifiers`].
- pub const MOD_SHIFT: u8 = 1 << 1;
- /// Bit for the ⌃ Control modifier in [`Self::modifiers`].
- pub const MOD_CTRL: u8 = 1 << 2;
- /// Bit for the ⌥ Option/Alt modifier in [`Self::modifiers`].
- pub const MOD_OPTION: u8 = 1 << 3;
-
- /// Build the human-readable label from the modifier bitmask + key code.
- /// Falls back to `"⌘key 0xNN"` when the key code isn't one of the
- /// commonly-recognised letters; the recorder UI usually overrides this
- /// with its own derivation.
- #[must_use]
- pub fn rendered_label(&self) -> String {
- if !self.display.is_empty() {
- return self.display.clone();
- }
- let mut out = String::new();
- if self.modifiers & Self::MOD_CTRL != 0 {
- out.push('⌃');
- }
- if self.modifiers & Self::MOD_OPTION != 0 {
- out.push('⌥');
- }
- if self.modifiers & Self::MOD_SHIFT != 0 {
- out.push('⇧');
- }
- if self.modifiers & Self::MOD_CMD != 0 {
- out.push('⌘');
- }
- match self.key_code {
- 0x00 => out.push('A'),
- 0x01 => out.push('S'),
- 0x02 => out.push('D'),
- 0x03 => out.push('F'),
- 0x06 => out.push('Z'),
- 0x07 => out.push('X'),
- 0x08 => out.push('C'),
- 0x09 => out.push('V'),
- 0x0B => out.push('B'),
- 0x0C => out.push('Q'),
- 0x0D => out.push('W'),
- 0x0E => out.push('E'),
- 0x0F => out.push('R'),
- 0x10 => out.push('Y'),
- 0x11 => out.push('T'),
- 0x20 => out.push('U'),
- 0x22 => out.push('I'),
- 0x1F => out.push('O'),
- 0x23 => out.push('P'),
- _ => {
- use std::fmt::Write as _;
- let _ = write!(out, "key 0x{:02X}", self.key_code);
- }
- }
- out
- }
-}
-
-/// What a single rebindable [`ButtonId`] does: either one [`Action`], or — for a
-/// raw-XY-capable button placed in gesture mode — a per-[`GestureDirection`]
-/// map (hold + swipe up/down/left/right, or a plain click).
-///
-/// There has only ever been one binding map per device; a gesture binding is
-/// just a binding whose payload is a direction map instead of a single action.
-///
-/// # Serialization
-///
-/// `#[serde(untagged)]`: [`Single`](Binding::Single) serializes exactly as the
-/// bare [`Action`] did before (a string `"BrowserBack"`, or a single-key table
-/// for the payload variants), and [`Gesture`](Binding::Gesture) serializes as a
-/// table keyed by [`GestureDirection`] names (`Up`/`Down`/`Left`/`Right`/
-/// `Click`).
-///
-/// The two arms are disambiguated by the **zero overlap** between [`Action`]
-/// variant names and [`GestureDirection`] variant names — untagged tries
-/// `Single(Action)` first, and a table keyed by `Up` etc. cannot parse as an
-/// externally-tagged `Action`, so it falls through to `Gesture`. A payload
-/// action like `{ SetDpiPreset = 2 }` is a valid externally-tagged `Action`, so
-/// it stays `Single` and never reaches the `Gesture` arm. This invariant is the
-/// entire safety basis for untagged routing; the `binding_untagged_*` tests
-/// guard it (a future `Action` named `Up`/`Down`/`Left`/`Right`/`Click` would
-/// silently mis-route, and those tests would fail).
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(untagged)]
-pub enum Binding {
- /// One action, fired on press. The shape every non-gesture button uses.
- Single(Action),
- /// Per-direction sub-bindings for a button in gesture mode. Keyed by the
- /// committed swipe direction, with [`GestureDirection::Click`] holding the
- /// plain-click (no-swipe) action.
- Gesture(BTreeMap<GestureDirection, Action>),
-}
-
-impl Binding {
- /// The plain-click action for this binding: the [`Single`](Binding::Single)
- /// action, or the [`Gesture`](Binding::Gesture) map's
- /// [`Click`](GestureDirection::Click) entry. Falls back to [`Action::None`]
- /// when a gesture binding has no explicit `Click`.
- ///
- /// Lets the click-dispatch path stay binding-shape-agnostic.
- #[must_use]
- pub fn click_action(&self) -> Action {
- match self {
- Binding::Single(action) => action.clone(),
- Binding::Gesture(map) => map
- .get(&GestureDirection::Click)
- .cloned()
- .unwrap_or(Action::None),
- }
- }
-
- /// The action bound to `direction`, if this is a gesture binding.
- /// [`Single`](Binding::Single) has no directions and returns `None`.
- #[must_use]
- pub fn direction_action(&self, direction: GestureDirection) -> Option<&Action> {
- match self {
- Binding::Single(_) => None,
- Binding::Gesture(map) => map.get(&direction),
- }
- }
-
- /// Whether this binding drives raw-XY swipe capture (the
- /// [`Gesture`](Binding::Gesture) arm).
- #[must_use]
- pub fn is_gesture(&self) -> bool {
- matches!(self, Binding::Gesture(_))
- }
-
- /// Promote a [`Single`](Binding::Single) binding in place to a
- /// [`Gesture`](Binding::Gesture), keeping its action as the
- /// [`GestureDirection::Click`] entry and leaving the swipe arms unbound.
- /// A no-op when this is already a [`Gesture`](Binding::Gesture).
- pub fn upgrade_to_gesture(&mut self) {
- if let Binding::Single(action) = self {
- let mut map = BTreeMap::new();
- map.insert(GestureDirection::Click, action.clone());
- *self = Binding::Gesture(map);
- }
- }
-
- /// Fill any unbound directions of a [`Gesture`](Binding::Gesture) binding
- /// with their canonical [`default_gesture_binding`], so a button promoted to
- /// the gesture role always exposes the full five-direction set — rather than
- /// leaving swipe arms the GUI renders as defaults but the runtime never
- /// dispatches. A no-op on [`Single`](Binding::Single) and on directions
- /// already bound (existing user choices are preserved).
- pub fn fill_gesture_defaults(&mut self) {
- if let Binding::Gesture(map) = self {
- for dir in GestureDirection::ALL {
- map.entry(dir)
- .or_insert_with(|| default_gesture_binding(dir));
- }
- }
- }
-}
-
-impl From<Action> for Binding {
- fn from(action: Action) -> Self {
- Binding::Single(action)
- }
-}
-
-impl Action {
- /// Display label for the popover row.
- ///
- /// Returns `String` rather than `&str` so parameterized variants (e.g.
- /// `SetDpiPreset(i)`, `CustomShortcut(s)`) can build a label that
- /// includes their payload.
- #[must_use]
- pub fn label(&self) -> String {
- match self {
- Action::None => "Do Nothing".into(),
- Action::LeftClick => "Left Click".into(),
- Action::RightClick => "Right Click".into(),
- Action::MiddleClick => "Middle Click".into(),
- Action::MouseBack => "Back (Button 4)".into(),
- Action::MouseForward => "Forward (Button 5)".into(),
- Action::Copy => "Copy".into(),
- Action::Paste => "Paste".into(),
- Action::Cut => "Cut".into(),
- Action::Undo => "Undo".into(),
- Action::Redo => "Redo".into(),
- Action::SelectAll => "Select All".into(),
- Action::Find => "Find".into(),
- Action::Save => "Save".into(),
- Action::BrowserBack => "Browser Back".into(),
- Action::BrowserForward => "Browser Forward".into(),
- Action::NewTab => "New Tab".into(),
- Action::CloseTab => "Close Tab".into(),
- Action::ReopenTab => "Reopen Tab".into(),
- Action::NextTab => "Next Tab".into(),
- Action::PrevTab => "Previous Tab".into(),
- Action::ReloadPage => "Reload Page".into(),
- Action::MissionControl => "Mission Control".into(),
- Action::AppExpose => "App Exposé".into(),
- Action::PreviousDesktop => "Previous Desktop".into(),
- Action::NextDesktop => "Next Desktop".into(),
- Action::ShowDesktop => "Show Desktop".into(),
- Action::LaunchpadShow => "Launchpad".into(),
- Action::LockScreen => "Lock Screen".into(),
- Action::Screenshot => "Screenshot".into(),
- Action::CaptureRegion => "Capture Region".into(),
- Action::PlayPause => "Play / Pause".into(),
- Action::NextTrack => "Next Track".into(),
- Action::PrevTrack => "Previous Track".into(),
- Action::VolumeUp => "Volume Up".into(),
- Action::VolumeDown => "Volume Down".into(),
- Action::MuteVolume => "Mute".into(),
- Action::CycleDpiPresets => "Cycle DPI Presets".into(),
- Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
- Action::ToggleSmartShift => "Toggle SmartShift".into(),
- Action::ScrollUp => "Scroll Up".into(),
- Action::ScrollDown => "Scroll Down".into(),
- Action::HorizontalScrollLeft => "Scroll Left".into(),
- Action::HorizontalScrollRight => "Scroll Right".into(),
- Action::CustomShortcut(combo) => combo.rendered_label(),
- }
- }
-
- /// Which [`Category`] this action belongs to, used for popover grouping.
- #[must_use]
- pub fn category(&self) -> Category {
- match self {
- Action::LeftClick
- | Action::RightClick
- | Action::MiddleClick
- | Action::MouseBack
- | Action::MouseForward => Category::Mouse,
- // CustomShortcut is assigned to Editing so it doesn't need a
- // separate arm (it's not in the picker catalog).
- Action::Copy
- | Action::Paste
- | Action::Cut
- | Action::Undo
- | Action::Redo
- | Action::SelectAll
- | Action::Find
- | Action::Save
- | Action::CustomShortcut(_) => Category::Editing,
- Action::BrowserBack
- | Action::BrowserForward
- | Action::NewTab
- | Action::CloseTab
- | Action::ReopenTab
- | Action::NextTab
- | Action::PrevTab
- | Action::ReloadPage => Category::Browser,
- Action::MissionControl
- | Action::AppExpose
- | Action::PreviousDesktop
- | Action::NextDesktop
- | Action::ShowDesktop
- | Action::LaunchpadShow => Category::Navigation,
- Action::None | Action::LockScreen | Action::Screenshot | Action::CaptureRegion => {
- Category::System
- }
- Action::PlayPause
- | Action::NextTrack
- | Action::PrevTrack
- | Action::VolumeUp
- | Action::VolumeDown
- | Action::MuteVolume => Category::Media,
- Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
- Category::Dpi
- }
- Action::ScrollUp
- | Action::ScrollDown
- | Action::HorizontalScrollLeft
- | Action::HorizontalScrollRight => Category::Scroll,
- }
- }
-
- /// All pickable actions in a deterministic order.
- ///
- /// [`Action::CustomShortcut`] is intentionally excluded — it is opened via
- /// "Record shortcut…" (P1.3), not selected from the catalog.
- #[must_use]
- pub fn catalog() -> Vec<Action> {
- vec![
- // Mouse
- Action::LeftClick,
- Action::RightClick,
- Action::MiddleClick,
- Action::MouseBack,
- Action::MouseForward,
- // Editing
- Action::Copy,
- Action::Paste,
- Action::Cut,
- Action::Undo,
- Action::Redo,
- Action::SelectAll,
- Action::Find,
- Action::Save,
- // Browser
- Action::BrowserBack,
- Action::BrowserForward,
- Action::NewTab,
- Action::CloseTab,
- Action::ReopenTab,
- Action::NextTab,
- Action::PrevTab,
- Action::ReloadPage,
- // Navigation
- Action::MissionControl,
- Action::AppExpose,
- Action::PreviousDesktop,
- Action::NextDesktop,
- Action::ShowDesktop,
- Action::LaunchpadShow,
- // System
- Action::None,
- Action::LockScreen,
- Action::Screenshot,
- Action::CaptureRegion,
- // Media
- Action::PlayPause,
- Action::NextTrack,
- Action::PrevTrack,
- Action::VolumeUp,
- Action::VolumeDown,
- Action::MuteVolume,
- // DPI
- Action::CycleDpiPresets,
- Action::ToggleSmartShift,
- // Scroll
- Action::ScrollUp,
- Action::ScrollDown,
- Action::HorizontalScrollLeft,
- Action::HorizontalScrollRight,
- ]
- }
-}
-
-/// Sensible defaults for a fresh device so the panel isn't empty on first run.
-///
-/// Thumbwheel / GestureButton defaults match what Logi Options+ ships for
-/// MX-line devices: thumb wheel click → App Exposé, gesture button →
-/// Mission Control. The thumb wheel isn't captured yet; the dedicated gesture button is
-/// (per-direction, see [`default_gesture_binding`]). The bindings persist
-/// regardless so the user only configures once.
-///
-/// `GestureButton`'s entry here is vestigial: in the merged [`Binding`] model
-/// the gesture button defaults to [`Binding::Gesture`] (see
-/// [`default_binding_for`]), so this single-action value is never the source of
-/// truth for it. It is retained only so the per-button-`Action` callers (the
-/// hook map, scroll defaults, labels) stay total.
-#[must_use]
-pub fn default_binding(button: ButtonId) -> Action {
- match button {
- ButtonId::LeftClick => Action::LeftClick,
- ButtonId::RightClick => Action::RightClick,
- ButtonId::MiddleClick => Action::MiddleClick,
- ButtonId::Back => Action::BrowserBack,
- ButtonId::Forward => Action::BrowserForward,
- ButtonId::DpiToggle => Action::CycleDpiPresets,
- ButtonId::Thumbwheel => Action::AppExpose,
- // The thumb wheel scrolls horizontally by default: rotating it produces
- // continuous horizontal scroll, with "up" → right and "down" → left.
- // The wheel watcher renders these two actions as smooth, sensitivity-
- // scaled scrolling rather than the discrete per-press burst a button
- // would get (see `watchers::gesture`).
- ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
- ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
- ButtonId::GestureButton => Action::MissionControl,
- }
-}
-
-/// Per-direction defaults for the gesture button. These are captured live over
-/// HID++ `0x1b04` (raw-XY diversion) and dispatched like any other binding; the
-/// defaults give the picker something sensible to show on first run.
-#[must_use]
-pub fn default_gesture_binding(direction: GestureDirection) -> Action {
- match direction {
- GestureDirection::Up => Action::MissionControl,
- GestureDirection::Down => Action::ShowDesktop,
- GestureDirection::Left => Action::PrevTab,
- GestureDirection::Right => Action::NextTab,
- GestureDirection::Click => Action::AppExpose,
- }
-}
-
-/// The canonical default [`Binding`] for a fresh button in the merged model.
-///
-/// [`ButtonId::GestureButton`] defaults to [`Binding::Gesture`] populated from
-/// [`default_gesture_binding`] — preserving the existing per-direction swipe
-/// behavior — so the GUI mode toggle and the runtime agree it starts in gesture
-/// mode. Every other button defaults to [`Binding::Single`] of its
-/// [`default_binding`].
-///
-/// This is the seed when a button is first promoted to a gesture binding (see
-/// [`Config::set_gesture_direction`](crate::config::Config::set_gesture_direction)),
-/// so a freshly-customized gesture button always carries a full default
-/// direction map — including a [`GestureDirection::Click`] — rather than a sparse
-/// map whose click would project to a no-op [`Action::None`].
-#[must_use]
-pub fn default_binding_for(button: ButtonId) -> Binding {
- match button {
- ButtonId::GestureButton => Binding::Gesture(
- GestureDirection::ALL
- .into_iter()
- .map(|d| (d, default_gesture_binding(d)))
- .collect(),
- ),
- other => Binding::Single(default_binding(other)),
- }
-}
-
-#[cfg(test)]
-#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
-mod tests {
- use std::assert_matches;
- use std::collections::BTreeMap;
-
- use serde::{Deserialize, Serialize};
-
- use super::*;
-
- // ── Roundtrip wrapper: defined here so it precedes any `let` statements ──
-
- /// Minimal TOML-serializable wrapper used by `roundtrip`.
- /// Defined at module scope to satisfy `clippy::items_after_statements`.
- #[derive(Serialize, Deserialize)]
- struct RoundtripWrapper {
- binding: BTreeMap<ButtonId, Action>,
- }
-
- // ── Catalog tests ─────────────────────────────────────────────────────────
-
- #[test]
- fn catalog_has_at_least_29_entries() {
- let catalog = Action::catalog();
- assert!(
- catalog.len() >= 29,
- "catalog has {} entries, need ≥ 29",
- catalog.len()
- );
- }
-
- #[test]
- fn catalog_excludes_custom_shortcut() {
- let catalog = Action::catalog();
- for action in &catalog {
- assert!(
- !matches!(action, Action::CustomShortcut(_)),
- "catalog must not contain CustomShortcut"
- );
- }
- }
-
- // ── Binding (merged model) serde routing ──────────────────────────────────
-
- /// On-disk shape: a `ButtonId` → [`Binding`] map, as `DeviceConfig.bindings`
- /// serializes it.
- #[derive(Serialize, Deserialize)]
- struct BindingWrapper {
- bindings: BTreeMap<ButtonId, Binding>,
- }
-
- fn binding_roundtrip(bindings: BTreeMap<ButtonId, Binding>) -> BTreeMap<ButtonId, Binding> {
- let toml = toml::to_string_pretty(&BindingWrapper { bindings }).expect("serialize");
- toml::from_str::<BindingWrapper>(&toml)
- .expect("deserialize")
- .bindings
- }
-
- #[test]
- fn binding_single_roundtrips_including_payload_variants() {
- let mut bindings = BTreeMap::new();
- bindings.insert(ButtonId::Back, Binding::Single(Action::BrowserBack));
- bindings.insert(
- ButtonId::DpiToggle,
- Binding::Single(Action::SetDpiPreset(2)),
- );
- bindings.insert(
- ButtonId::Forward,
- Binding::Single(Action::CustomShortcut(KeyCombo {
- modifiers: KeyCombo::MOD_CMD,
- key_code: 0x23,
- display: "⌘P".into(),
- })),
- );
- let back = binding_roundtrip(bindings);
- assert_eq!(back[&ButtonId::Back], Binding::Single(Action::BrowserBack));
- assert_eq!(
- back[&ButtonId::DpiToggle],
- Binding::Single(Action::SetDpiPreset(2))
- );
- assert_matches!(
- back[&ButtonId::Forward],
- Binding::Single(Action::CustomShortcut(_))
- );
- }
-
- #[test]
- fn binding_gesture_roundtrips() {
- let mut map = BTreeMap::new();
- map.insert(GestureDirection::Up, Action::Copy);
- map.insert(GestureDirection::Click, Action::Paste);
- let mut bindings = BTreeMap::new();
- bindings.insert(ButtonId::GestureButton, Binding::Gesture(map.clone()));
- let back = binding_roundtrip(bindings);
- assert_eq!(back[&ButtonId::GestureButton], Binding::Gesture(map));
- }
-
- /// The untagged-routing safety guard. A TOML table keyed by ANY
- /// [`GestureDirection`] name must deserialize as [`Binding::Gesture`], never
- /// [`Binding::Single`]. If a future [`Action`] payload variant is ever named
- /// `Up`/`Down`/`Left`/`Right`/`Click`, the table would parse as `Single`
- /// first and this test fails — catching the silent mis-route at CI time.
- #[test]
- fn binding_direction_keyed_table_routes_to_gesture() {
- for dir in GestureDirection::ALL {
- // `GestureDirection`'s serde key equals its `Display`/variant name.
- let toml = format!("bindings.GestureButton.{dir} = \"None\"");
- let parsed = toml::from_str::<BindingWrapper>(&toml).expect("deserialize");
- assert!(
- matches!(
- parsed.bindings[&ButtonId::GestureButton],
- Binding::Gesture(_)
- ),
- "a {dir}-keyed table must route to Gesture, not Single"
- );
- }
- }
-
- /// The collision case: a payload [`Action`] also serializes as a single-key
- /// table, but untagged must keep it [`Binding::Single`] (it parses as a valid
- /// externally-tagged `Action` before the `Gesture` arm is tried).
- #[test]
- fn binding_payload_action_stays_single() {
- let toml = "bindings.DpiToggle.SetDpiPreset = 2";
- let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
- assert_eq!(
- parsed.bindings[&ButtonId::DpiToggle],
- Binding::Single(Action::SetDpiPreset(2))
- );
- }
-
- #[test]
- fn binding_capture_region_roundtrips_as_single_string() {
- let toml = "bindings.Back = \"CaptureRegion\"";
- let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
- assert_eq!(
- parsed.bindings[&ButtonId::Back],
- Binding::Single(Action::CaptureRegion)
- );
-
- let back = binding_roundtrip(parsed.bindings);
- assert_eq!(
- back[&ButtonId::Back],
- Binding::Single(Action::CaptureRegion)
- );
- assert_eq!(Action::CaptureRegion.label(), "Capture Region");
- assert_eq!(Action::CaptureRegion.category(), Category::System);
- assert!(Action::catalog().contains(&Action::CaptureRegion));
- }
-
- // ── TOML roundtrip ────────────────────────────────────────────────────────
-
- /// Serialize then deserialize `action` through TOML, using a wrapper
- /// struct because TOML requires a top-level table.
- fn roundtrip(action: &Action) -> Action {
- let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
- map.insert(ButtonId::Back, action.clone());
- let w = RoundtripWrapper { binding: map };
- let s = toml::to_string(&w).expect("serialize");
- let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
- back.binding
- .into_values()
- .next()
- .expect("binding present after roundtrip")
- }
-
- #[test]
- fn all_catalog_variants_roundtrip_toml() {
- for action in Action::catalog() {
- let back = roundtrip(&action);
- assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
- }
- }
-
- #[test]
- fn custom_shortcut_roundtrips_toml() {
- let action = Action::CustomShortcut(KeyCombo {
- modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
- key_code: 0x23, // kVK_ANSI_P
- display: "⌘⇧P".into(),
- });
- assert_eq!(roundtrip(&action), action);
- }
-
- #[test]
- fn key_combo_rendered_label_uses_display_when_set() {
- let combo = KeyCombo {
- modifiers: 0,
- key_code: 0,
- display: "preset".into(),
- };
- assert_eq!(combo.rendered_label(), "preset");
- }
-
- #[test]
- fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
- let combo = KeyCombo {
- modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
- key_code: 0x23, // P
- display: String::new(),
- };
- assert_eq!(combo.rendered_label(), "⇧⌘P");
- }
-
- // ── Category tests ────────────────────────────────────────────────────────
-
- #[test]
- fn category_editing_variants() {
- assert_eq!(Action::Copy.category(), Category::Editing);
- assert_eq!(Action::Undo.category(), Category::Editing);
- assert_eq!(Action::SelectAll.category(), Category::Editing);
- assert_eq!(Action::Find.category(), Category::Editing);
- assert_eq!(Action::Save.category(), Category::Editing);
- assert_eq!(Action::Cut.category(), Category::Editing);
- assert_eq!(Action::Redo.category(), Category::Editing);
- assert_eq!(Action::Paste.category(), Category::Editing);
- }
-
- #[test]
- fn category_browser_variants() {
- assert_eq!(Action::BrowserBack.category(), Category::Browser);
- assert_eq!(Action::BrowserForward.category(), Category::Browser);
- assert_eq!(Action::NewTab.category(), Category::Browser);
- assert_eq!(Action::CloseTab.category(), Category::Browser);
- assert_eq!(Action::ReopenTab.category(), Category::Browser);
- assert_eq!(Action::NextTab.category(), Category::Browser);
- assert_eq!(Action::PrevTab.category(), Category::Browser);
- assert_eq!(Action::ReloadPage.category(), Category::Browser);
- }
-
- #[test]
- fn category_media_variants() {
- assert_eq!(Action::PlayPause.category(), Category::Media);
- assert_eq!(Action::NextTrack.category(), Category::Media);
- assert_eq!(Action::PrevTrack.category(), Category::Media);
- assert_eq!(Action::VolumeUp.category(), Category::Media);
- assert_eq!(Action::VolumeDown.category(), Category::Media);
- assert_eq!(Action::MuteVolume.category(), Category::Media);
- }
-
- #[test]
- fn category_mouse_variants() {
- assert_eq!(Action::LeftClick.category(), Category::Mouse);
- assert_eq!(Action::RightClick.category(), Category::Mouse);
- assert_eq!(Action::MiddleClick.category(), Category::Mouse);
- }
-
- #[test]
- fn category_dpi_variants() {
- assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
- assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
- }
-
- #[test]
- fn category_scroll_variants() {
- assert_eq!(Action::ScrollUp.category(), Category::Scroll);
- assert_eq!(Action::ScrollDown.category(), Category::Scroll);
- assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
- assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
- }
-
- #[test]
- fn category_navigation_variants() {
- assert_eq!(Action::MissionControl.category(), Category::Navigation);
- assert_eq!(Action::AppExpose.category(), Category::Navigation);
- assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
- assert_eq!(Action::NextDesktop.category(), Category::Navigation);
- assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
- assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
- }
-
- #[test]
- fn category_system_variants() {
- assert_eq!(Action::LockScreen.category(), Category::System);
- assert_eq!(Action::Screenshot.category(), Category::System);
- }
-
- // ── Category label smoke test ─────────────────────────────────────────────
-
- #[test]
- fn category_labels_are_nonempty() {
- let categories = [
- Category::Editing,
- Category::Browser,
- Category::Media,
- Category::Mouse,
- Category::Dpi,
- Category::Scroll,
- Category::Navigation,
- Category::System,
- ];
- for cat in categories {
- assert!(!cat.label().is_empty(), "label empty for {cat:?}");
- }
- }
-
- // ── Default binding ───────────────────────────────────────────────────────
-
- #[test]
- fn dpi_toggle_default_is_cycle_dpi_presets() {
- assert_eq!(
- default_binding(ButtonId::DpiToggle),
- Action::CycleDpiPresets
- );
- }
-}
+pub use value::Binding;
diff --git a/crates/openlogi-core/src/binding/action.rs b/crates/openlogi-core/src/binding/action.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f1690ee1145d9355fd18bc57b335198dc9359d3f
--- /dev/null
+++ b/crates/openlogi-core/src/binding/action.rs
@@ -0,0 +1,383 @@
+//! The action vocabulary a button can bind to, plus workflow steps.
+
+use serde::{Deserialize, Serialize};
+
+use super::category::Category;
+use super::key_combo::KeyCombo;
+
+/// What pressing a [`ButtonId`] should do.
+///
+/// Serialization uses serde's default external tagging: unit variants
+/// serialize as a bare string (`"BrowserBack"`) and the tuple variant
+/// serializes as a single-key table (`{ CustomShortcut = "my chord" }`).
+///
+/// **Stability contract:** existing variant *names* are frozen — they form the
+/// on-disk `config.toml` schema. New variants may be appended freely; removing
+/// or renaming a variant requires a `schema_version` bump and a migration.
+///
+/// This type is pure config data: OS-level event synthesis for each variant
+/// lives in the `openlogi-inject` crate (`openlogi_inject::execute`), keeping
+/// this crate platform- and IO-free.
+#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum Action {
+ // ── System ───────────────────────────────────────────────────────────────
+ /// Suppress the input entirely — the button or wheel direction is captured
+ /// but no OS event is synthesised, so the physical input does nothing.
+ None,
+
+ // ── Mouse ────────────────────────────────────────────────────────────────
+ /// Primary mouse button.
+ LeftClick,
+ /// Secondary mouse button.
+ RightClick,
+ /// Middle mouse button (wheel click).
+ MiddleClick,
+ /// Mouse "back" side button (extra button 4). Synthesizes the real mouse
+ /// button event, which browsers and most apps interpret as "navigate back"
+ /// natively — unlike [`Action::BrowserBack`], which sends ⌘[ and is ignored
+ /// by many apps.
+ MouseBack,
+ /// Mouse "forward" side button (extra button 5). Native counterpart to
+ /// [`Action::MouseBack`]; see [`Action::BrowserForward`] for the ⌘] form.
+ MouseForward,
+
+ // ── Editing ──────────────────────────────────────────────────────────────
+ /// Copy the current selection (⌘C / Ctrl+C).
+ Copy,
+ /// Paste from the clipboard (⌘V / Ctrl+V).
+ Paste,
+ /// Cut the current selection (⌘X / Ctrl+X).
+ Cut,
+ /// Undo the last action (⌘Z / Ctrl+Z).
+ Undo,
+ /// Redo the last undone action (⌘⇧Z on macOS / Ctrl+Shift+Z on Linux).
+ ///
+ /// Note: Ctrl+Y is the dominant redo shortcut in LibreOffice and many GTK
+ /// apps. Ctrl+Shift+Z is used here because it mirrors the macOS convention
+ /// and works in GNOME text fields, browsers, and Electron apps. If Ctrl+Y
+ /// coverage is needed, a `CustomShortcut` binding is the escape hatch.
+ Redo,
+ /// Select all content (⌘A / Ctrl+A).
+ SelectAll,
+ /// Open the find / search bar (⌘F / Ctrl+F).
+ Find,
+ /// Save the current document (⌘S / Ctrl+S).
+ Save,
+
+ // ── Browser / Navigation ──────────────────────────────────────────────────
+ /// Navigate backward in browser history.
+ BrowserBack,
+ /// Navigate forward in browser history.
+ BrowserForward,
+ /// Open a new tab (⌘T / Ctrl+T).
+ NewTab,
+ /// Close the current tab (⌘W / Ctrl+W).
+ CloseTab,
+ /// Reopen the last closed tab (⌘⇧T / Ctrl+Shift+T).
+ ReopenTab,
+ /// Switch to the next tab (⌃⇥ / Ctrl+Tab).
+ NextTab,
+ /// Switch to the previous tab (⌃⇧⇥ / Ctrl+Shift+Tab).
+ PrevTab,
+ /// Reload the current page (⌘R / Ctrl+R).
+ ReloadPage,
+
+ // ── Navigation / Window ───────────────────────────────────────────────────
+ /// macOS Mission Control (⌃↑).
+ MissionControl,
+ /// macOS App Exposé — all windows for the current app (⌃↓).
+ AppExpose,
+ /// Switch to the previous desktop / Space.
+ PreviousDesktop,
+ /// Switch to the next desktop / Space.
+ NextDesktop,
+ /// Show the desktop (hide all windows).
+ ShowDesktop,
+ /// Open Launchpad.
+ LaunchpadShow,
+
+ // ── System ────────────────────────────────────────────────────────────────
+ /// Lock the screen (⌘⌃Q on macOS).
+ ///
+ /// On Linux, calls `org.freedesktop.login1.Manager.LockSession($XDG_SESSION_ID)`
+ /// on the system bus (current session only). Falls back to Super+L when
+ /// `$XDG_SESSION_ID` is unset or on non-systemd systems.
+ LockScreen,
+ /// Capture a screenshot.
+ Screenshot,
+ /// Capture a selected screen region to the clipboard.
+ ///
+ /// macOS uses Cmd+Shift+Ctrl+4; Windows uses Win+Shift+S. Linux delegates
+ /// to the desktop environment's screenshot handler via Print Screen.
+ CaptureRegion,
+
+ // ── Media ────────────────────────────────────────────────────────────────
+ /// Toggle media play/pause.
+ PlayPause,
+ /// Skip to the next track.
+ NextTrack,
+ /// Go back to the previous track.
+ PrevTrack,
+ /// Increase system volume.
+ VolumeUp,
+ /// Decrease system volume.
+ VolumeDown,
+ /// Toggle system mute.
+ MuteVolume,
+
+ // ── DPI ──────────────────────────────────────────────────────────────────
+ /// Step through the configured DPI preset list (P1.7).
+ CycleDpiPresets,
+ /// Jump to a specific zero-based preset in the device's DPI preset list.
+ /// Out-of-range indices clamp to the list length at fire time (P1.7).
+ SetDpiPreset(u8),
+ /// Toggle the HID++ SmartShift ratchet/free-spin wheel mode (P1.1).
+ ToggleSmartShift,
+
+ // ── Scroll ───────────────────────────────────────────────────────────────
+ /// Synthesise a vertical scroll-up tick.
+ ScrollUp,
+ /// Synthesise a vertical scroll-down tick.
+ ScrollDown,
+ /// Synthesise a horizontal scroll-left tick.
+ HorizontalScrollLeft,
+ /// Synthesise a horizontal scroll-right tick.
+ HorizontalScrollRight,
+
+ // ── Custom ───────────────────────────────────────────────────────────────
+ /// Replay an arbitrary recorded key chord (P1.3).
+ ///
+ /// Holds the structured chord data so `openlogi_inject::execute` can post the
+ /// real keystroke (macOS: CGEventPost with the encoded modifier flags).
+ /// The `display` field is used by [`Action::label`] so the popover
+ /// shows the user-friendly chord name.
+ CustomShortcut(KeyCombo),
+
+ // ── System (appended) ────────────────────────────────────────────────────
+ /// Put the computer to sleep. Appended after `CustomShortcut` because the
+ /// serde variant index is the wire format (see the stability contract
+ /// above) — new variants only ever go at the end.
+ Sleep,
+ /// Type an arbitrary string by emitting unicode characters (macOS
+ /// `CGEventKeyboardSetUnicodeString`). Used for macro text. Power-user
+ /// escape hatch — excluded from the default catalog.
+ TypeText(String),
+ /// Run an AppleScript via `osascript -e <source>`. Power-user escape hatch.
+ RunAppleScript(String),
+ /// Run a shell command via `/bin/sh -c <command>`. Power-user escape hatch.
+ RunShellCommand(String),
+ /// Run a timed, ordered sequence of steps — the native, no-code version of
+ /// "type 'bite me', wait 5s, press Enter, wait 5s, type more, Escape". Each
+ /// step is one of the power-user actions or a `Delay`. The sequencer
+ /// (`openlogi-inject`) runs them in order, awaiting `Delay`s. Power-user
+ /// escape hatch — excluded from the default catalog.
+ Workflow(Vec<WorkflowStep>),
+}
+
+/// One step in a [`Action::Workflow`]. A workflow is a `Vec<WorkflowStep>`
+/// executed in order by the inject layer; `Delay` introduces a pause between
+/// the surrounding steps.
+///
+/// `PressKey` reuses [`KeyCombo`] (the same model as [`Action::CustomShortcut`])
+/// so a step can press a key chord. The other variants mirror their standalone
+/// [`Action`] counterparts.
+#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum WorkflowStep {
+ /// Type a unicode string (see [`Action::TypeText`]).
+ TypeText(String),
+ /// Press a key chord (see [`Action::CustomShortcut`] / [`KeyCombo`]).
+ PressKey(KeyCombo),
+ /// Wait `millis` milliseconds before the next step.
+ Delay {
+ /// Pause length in milliseconds.
+ millis: u64,
+ },
+ /// Run an AppleScript (see [`Action::RunAppleScript`]).
+ RunAppleScript(String),
+ /// Run a shell command (see [`Action::RunShellCommand`]).
+ RunShellCommand(String),
+}
+
+impl Action {
+ /// Display label for the popover row.
+ ///
+ /// Returns `String` rather than `&str` so parameterized variants (e.g.
+ /// `SetDpiPreset(i)`, `CustomShortcut(s)`) can build a label that
+ /// includes their payload.
+ #[must_use]
+ pub fn label(&self) -> String {
+ match self {
+ Action::None => "Do Nothing".into(),
+ Action::LeftClick => "Left Click".into(),
+ Action::RightClick => "Right Click".into(),
+ Action::MiddleClick => "Middle Click".into(),
+ Action::MouseBack => "Back (Button 4)".into(),
+ Action::MouseForward => "Forward (Button 5)".into(),
+ Action::Copy => "Copy".into(),
+ Action::Paste => "Paste".into(),
+ Action::Cut => "Cut".into(),
+ Action::Undo => "Undo".into(),
+ Action::Redo => "Redo".into(),
+ Action::SelectAll => "Select All".into(),
+ Action::Find => "Find".into(),
+ Action::Save => "Save".into(),
+ Action::BrowserBack => "Browser Back".into(),
+ Action::BrowserForward => "Browser Forward".into(),
+ Action::NewTab => "New Tab".into(),
+ Action::CloseTab => "Close Tab".into(),
+ Action::ReopenTab => "Reopen Tab".into(),
+ Action::NextTab => "Next Tab".into(),
+ Action::PrevTab => "Previous Tab".into(),
+ Action::ReloadPage => "Reload Page".into(),
+ Action::MissionControl => "Mission Control".into(),
+ Action::AppExpose => "App Exposé".into(),
+ Action::PreviousDesktop => "Previous Desktop".into(),
+ Action::NextDesktop => "Next Desktop".into(),
+ Action::ShowDesktop => "Show Desktop".into(),
+ Action::LaunchpadShow => "Launchpad".into(),
+ Action::LockScreen => "Lock Screen".into(),
+ Action::Screenshot => "Screenshot".into(),
+ Action::CaptureRegion => "Capture Region".into(),
+ Action::PlayPause => "Play / Pause".into(),
+ Action::NextTrack => "Next Track".into(),
+ Action::PrevTrack => "Previous Track".into(),
+ Action::VolumeUp => "Volume Up".into(),
+ Action::VolumeDown => "Volume Down".into(),
+ Action::MuteVolume => "Mute".into(),
+ Action::CycleDpiPresets => "Cycle DPI Presets".into(),
+ Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
+ Action::ToggleSmartShift => "Toggle SmartShift".into(),
+ Action::ScrollUp => "Scroll Up".into(),
+ Action::ScrollDown => "Scroll Down".into(),
+ Action::HorizontalScrollLeft => "Scroll Left".into(),
+ Action::HorizontalScrollRight => "Scroll Right".into(),
+ Action::CustomShortcut(combo) => combo.rendered_label(),
+ Action::Sleep => "Sleep".into(),
+ Action::TypeText(s) => format!("Type \"{s}\""),
+ Action::RunAppleScript(_) => "Run AppleScript".into(),
+ Action::RunShellCommand(_) => "Run Command".into(),
+ Action::Workflow(steps) => format!("Workflow ({} steps)", steps.len()),
+ }
+ }
+
+ /// Which [`Category`] this action belongs to, used for popover grouping.
+ #[must_use]
+ pub fn category(&self) -> Category {
+ match self {
+ Action::LeftClick
+ | Action::RightClick
+ | Action::MiddleClick
+ | Action::MouseBack
+ | Action::MouseForward => Category::Mouse,
+ // CustomShortcut is assigned to Editing so it doesn't need a
+ // separate arm (it's not in the picker catalog).
+ Action::Copy
+ | Action::Paste
+ | Action::Cut
+ | Action::Undo
+ | Action::Redo
+ | Action::SelectAll
+ | Action::Find
+ | Action::Save
+ | Action::CustomShortcut(_)
+ | Action::TypeText(_)
+ | Action::RunAppleScript(_)
+ | Action::RunShellCommand(_)
+ | Action::Workflow(_) => Category::Editing,
+ Action::BrowserBack
+ | Action::BrowserForward
+ | Action::NewTab
+ | Action::CloseTab
+ | Action::ReopenTab
+ | Action::NextTab
+ | Action::PrevTab
+ | Action::ReloadPage => Category::Browser,
+ Action::MissionControl
+ | Action::AppExpose
+ | Action::PreviousDesktop
+ | Action::NextDesktop
+ | Action::ShowDesktop
+ | Action::LaunchpadShow => Category::Navigation,
+ Action::None
+ | Action::LockScreen
+ | Action::Screenshot
+ | Action::CaptureRegion
+ | Action::Sleep => Category::System,
+ Action::PlayPause
+ | Action::NextTrack
+ | Action::PrevTrack
+ | Action::VolumeUp
+ | Action::VolumeDown
+ | Action::MuteVolume => Category::Media,
+ Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
+ Category::Dpi
+ }
+ Action::ScrollUp
+ | Action::ScrollDown
+ | Action::HorizontalScrollLeft
+ | Action::HorizontalScrollRight => Category::Scroll,
+ }
+ }
+
+ /// All pickable actions in a deterministic order.
+ ///
+ /// [`Action::CustomShortcut`] is intentionally excluded — it is opened via
+ /// "Record shortcut…" (P1.3), not selected from the catalog.
+ #[must_use]
+ pub fn catalog() -> Vec<Action> {
+ vec![
+ // Mouse
+ Action::LeftClick,
+ Action::RightClick,
+ Action::MiddleClick,
+ Action::MouseBack,
+ Action::MouseForward,
+ // Editing
+ Action::Copy,
+ Action::Paste,
+ Action::Cut,
+ Action::Undo,
+ Action::Redo,
+ Action::SelectAll,
+ Action::Find,
+ Action::Save,
+ // Browser
+ Action::BrowserBack,
+ Action::BrowserForward,
+ Action::NewTab,
+ Action::CloseTab,
+ Action::ReopenTab,
+ Action::NextTab,
+ Action::PrevTab,
+ Action::ReloadPage,
+ // Navigation
+ Action::MissionControl,
+ Action::AppExpose,
+ Action::PreviousDesktop,
+ Action::NextDesktop,
+ Action::ShowDesktop,
+ Action::LaunchpadShow,
+ // System
+ Action::None,
+ Action::LockScreen,
+ Action::Screenshot,
+ Action::CaptureRegion,
+ Action::Sleep,
+ // Media
+ Action::PlayPause,
+ Action::NextTrack,
+ Action::PrevTrack,
+ Action::VolumeUp,
+ Action::VolumeDown,
+ Action::MuteVolume,
+ // DPI
+ Action::CycleDpiPresets,
+ Action::ToggleSmartShift,
+ // Scroll
+ Action::ScrollUp,
+ Action::ScrollDown,
+ Action::HorizontalScrollLeft,
+ Action::HorizontalScrollRight,
+ ]
+ }
+}
diff --git a/crates/openlogi-core/src/binding/button.rs b/crates/openlogi-core/src/binding/button.rs
new file mode 100644
index 0000000000000000000000000000000000000000..46253ab3dbee5fd316aa37fe64726df517b8595d
--- /dev/null
+++ b/crates/openlogi-core/src/binding/button.rs
@@ -0,0 +1,144 @@
+//! Rebindable mouse/keyboard button identifiers.
+
+use std::fmt;
+
+use serde::{Deserialize, Serialize};
+
+/// One of the user-rebindable hotspots on a Logi mouse. The order matches the
+/// physical layout from front to side; [`ButtonId::ALL`] is consumed by the
+/// default-binding generator and the popover trigger list.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub enum ButtonId {
+ /// The primary button. Rebindable in the config schema, but the OS hook
+ /// never suppresses it — see [`ButtonId::is_os_hook_button`].
+ LeftClick,
+ /// The secondary button. Like [`ButtonId::LeftClick`], it always passes
+ /// through the OS hook.
+ RightClick,
+ /// The wheel click — one of the three buttons the OS hook remaps.
+ MiddleClick,
+ /// The thumb-side "back" button (mouse button 4), remapped by the OS hook.
+ Back,
+ /// The thumb-side "forward" button (mouse button 5), remapped by the OS hook.
+ Forward,
+ /// The "ModeShift" button under the wheel — typically used for SmartShift /
+ /// DPI cycle. Named `DpiToggle` for historical reasons.
+ DpiToggle,
+ /// The horizontal thumb wheel's click. Kept in [`ButtonId::ALL`] so its
+ /// default still seeds and dispatches when the wheel is diverted, even
+ /// though the mouse model surfaces one paired rotation control instead of
+ /// the click (see `mouse_model::geometry`).
+ Thumbwheel,
+ /// Rotating the thumb wheel "up" (positive rotation). Bound, by default, to
+ /// continuous horizontal scroll; see the agent-core `watchers`-side dispatch.
+ ThumbwheelScrollUp,
+ /// Rotating the thumb wheel "down" (negative rotation).
+ ThumbwheelScrollDown,
+ /// The HID++ gesture button on MX-line devices. The press itself
+ /// fires the bound action; swipe directions are P1.5 territory.
+ GestureButton,
+ /// Keyboard F-row "Search" control (`0x1b04` CID `0x00d4`,
+ /// `MultiPlatform_Search`) — F4 on the Signature series.
+ KeySearch,
+ /// Keyboard "Dictation" control (CID `0x0103`) — F5 on the Signature series.
+ KeyDictation,
+ /// Keyboard "Emoji" control (CID `0x0108`) — F6 on the Signature series.
+ KeyEmoji,
+ /// Keyboard "Screen Capture" control (CID `0x010a`) — F7 on the Signature
+ /// series.
+ KeyScreenCapture,
+ /// Keyboard "Mute Microphone" control (CID `0x011c`) — F8 on the Signature
+ /// series.
+ KeyMicMute,
+ /// Keyboard "Play/Pause" control (CID `0x00e5`) — F9 on the Signature series.
+ KeyPlayPause,
+ /// Keyboard "Mute" control (CID `0x00e7`) — F10 on the Signature series.
+ KeyMute,
+ /// Keyboard "Volume Down" control (CID `0x00e8`) — F11 on the Signature
+ /// series.
+ KeyVolumeDown,
+ /// Keyboard "Volume Up" control (CID `0x00e9`) — F12 on the Signature
+ /// series.
+ KeyVolumeUp,
+}
+
+impl ButtonId {
+ /// Every rebindable button in declaration (physical front-to-side) order —
+ /// the iteration source for default-binding seeding and the popover
+ /// trigger list.
+ pub const ALL: [ButtonId; 10] = [
+ ButtonId::LeftClick,
+ ButtonId::RightClick,
+ ButtonId::MiddleClick,
+ ButtonId::Back,
+ ButtonId::Forward,
+ ButtonId::DpiToggle,
+ ButtonId::Thumbwheel,
+ ButtonId::ThumbwheelScrollUp,
+ ButtonId::ThumbwheelScrollDown,
+ ButtonId::GestureButton,
+ ];
+
+ /// The divertable keyboard F-row controls, in F-row order. Kept out of
+ /// [`ButtonId::ALL`]: that array seeds mouse defaults and the mouse
+ /// popover trigger list, while keyboard keys stay native unless the user
+ /// binds them (an unbound key is never diverted).
+ pub const KEYBOARD_KEYS: [ButtonId; 9] = [
+ ButtonId::KeySearch,
+ ButtonId::KeyDictation,
+ ButtonId::KeyEmoji,
+ ButtonId::KeyScreenCapture,
+ ButtonId::KeyMicMute,
+ ButtonId::KeyPlayPause,
+ ButtonId::KeyMute,
+ ButtonId::KeyVolumeDown,
+ ButtonId::KeyVolumeUp,
+ ];
+
+ /// Whether this button is one the OS hook (macOS `CGEventTap` / Linux evdev)
+ /// remaps: Middle, Back, or Forward. The primary L/R clicks always pass
+ /// through (suppressing them would brick the mouse), and the DPI / thumb /
+ /// dedicated gesture controls aren't visible to the OS hook at all (they're
+ /// captured over HID++). These are exactly the buttons that can become an
+ /// OS-hook gesture button, so the hook's remap gate and the gesture-owner
+ /// projection share this one definition.
+ #[must_use]
+ pub fn is_os_hook_button(self) -> bool {
+ matches!(
+ self,
+ ButtonId::MiddleClick | ButtonId::Back | ButtonId::Forward
+ )
+ }
+
+ /// Human-readable label for popovers and tooltips.
+ #[must_use]
+ pub fn label(self) -> &'static str {
+ match self {
+ ButtonId::LeftClick => "Left Click",
+ ButtonId::RightClick => "Right Click",
+ ButtonId::MiddleClick => "Middle Click",
+ ButtonId::Back => "Back",
+ ButtonId::Forward => "Forward",
+ ButtonId::DpiToggle => "DPI Toggle",
+ ButtonId::Thumbwheel => "Thumb Wheel",
+ ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up",
+ ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down",
+ ButtonId::GestureButton => "Gesture Button",
+ ButtonId::KeySearch => "Search Key",
+ ButtonId::KeyDictation => "Dictation Key",
+ ButtonId::KeyEmoji => "Emoji Key",
+ ButtonId::KeyScreenCapture => "Screen Capture Key",
+ ButtonId::KeyMicMute => "Mic Mute Key",
+ ButtonId::KeyPlayPause => "Play/Pause Key",
+ ButtonId::KeyMute => "Mute Key",
+ ButtonId::KeyVolumeDown => "Volume Down Key",
+ ButtonId::KeyVolumeUp => "Volume Up Key",
+ }
+ }
+}
+
+impl fmt::Display for ButtonId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.label())
+ }
+}
diff --git a/crates/openlogi-core/src/binding/category.rs b/crates/openlogi-core/src/binding/category.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e62e6c0b16883cd1af8046548f726c1f92d379b4
--- /dev/null
+++ b/crates/openlogi-core/src/binding/category.rs
@@ -0,0 +1,43 @@
+//! Popover section categories for the action catalog.
+
+/// Grouping for popover section headers.
+///
+/// Used by [`Action::category`] and rendered as a small muted label above
+/// each group in the action picker.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum Category {
+ /// Cut, copy, paste, undo, redo, select-all, find, save.
+ Editing,
+ /// Browser navigation: tabs, page reload, back/forward.
+ Browser,
+ /// Playback and volume controls.
+ Media,
+ /// Physical mouse clicks.
+ Mouse,
+ /// DPI cycle and SmartShift.
+ Dpi,
+ /// Scroll direction shortcuts.
+ Scroll,
+ /// Window/app navigation: Mission Control, Launchpad, etc.
+ Navigation,
+ /// Lock screen, show desktop, system-level actions.
+ System,
+}
+
+impl Category {
+ /// Short label for popover section headers (already uppercase so callers
+ /// don't have to transform it).
+ #[must_use]
+ pub fn label(self) -> &'static str {
+ match self {
+ Category::Editing => "EDITING",
+ Category::Browser => "BROWSER",
+ Category::Media => "MEDIA",
+ Category::Mouse => "MOUSE",
+ Category::Dpi => "DPI",
+ Category::Scroll => "SCROLL",
+ Category::Navigation => "NAVIGATION",
+ Category::System => "SYSTEM",
+ }
+ }
+}
diff --git a/crates/openlogi-core/src/binding/defaults.rs b/crates/openlogi-core/src/binding/defaults.rs
new file mode 100644
index 0000000000000000000000000000000000000000..4d38df97740446500e4d791e1b8fb0745c0c5ac6
--- /dev/null
+++ b/crates/openlogi-core/src/binding/defaults.rs
@@ -0,0 +1,92 @@
+//! Default bindings for a fresh device / gesture map.
+
+use super::action::Action;
+use super::button::ButtonId;
+use super::gesture::GestureDirection;
+use super::value::Binding;
+
+/// Sensible defaults for a fresh device so the panel isn't empty on first run.
+///
+/// Thumbwheel / GestureButton defaults match what Logi Options+ ships for
+/// MX-line devices: thumb wheel click → App Exposé, gesture button →
+/// Mission Control. The thumb wheel isn't captured yet; the dedicated gesture button is
+/// (per-direction, see [`default_gesture_binding`]). The bindings persist
+/// regardless so the user only configures once.
+///
+/// `GestureButton`'s entry here is vestigial: in the merged [`Binding`] model
+/// the gesture button defaults to [`Binding::Gesture`] (see
+/// [`default_binding_for`]), so this single-action value is never the source of
+/// truth for it. It is retained only so the per-button-`Action` callers (the
+/// hook map, scroll defaults, labels) stay total.
+#[must_use]
+pub fn default_binding(button: ButtonId) -> Action {
+ match button {
+ ButtonId::LeftClick => Action::LeftClick,
+ ButtonId::RightClick => Action::RightClick,
+ ButtonId::MiddleClick => Action::MiddleClick,
+ ButtonId::Back => Action::BrowserBack,
+ ButtonId::Forward => Action::BrowserForward,
+ ButtonId::DpiToggle => Action::CycleDpiPresets,
+ ButtonId::Thumbwheel => Action::AppExpose,
+ // The thumb wheel scrolls horizontally by default: rotating it produces
+ // continuous horizontal scroll, with "up" → right and "down" → left.
+ // The wheel watcher renders these two actions as smooth, sensitivity-
+ // scaled scrolling rather than the discrete per-press burst a button
+ // would get (see `watchers::gesture`).
+ ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
+ ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
+ ButtonId::GestureButton => Action::MissionControl,
+ // Keyboard keys stay on their native firmware function until the user
+ // explicitly binds them; an unbound key is never diverted, so a
+ // `None` default keeps the projection total without capturing anything.
+ ButtonId::KeySearch
+ | ButtonId::KeyDictation
+ | ButtonId::KeyEmoji
+ | ButtonId::KeyScreenCapture
+ | ButtonId::KeyMicMute
+ | ButtonId::KeyPlayPause
+ | ButtonId::KeyMute
+ | ButtonId::KeyVolumeDown
+ | ButtonId::KeyVolumeUp => Action::None,
+ }
+}
+
+/// Per-direction defaults for the gesture button. These are captured live over
+/// HID++ `0x1b04` (raw-XY diversion) and dispatched like any other binding; the
+/// defaults give the picker something sensible to show on first run.
+#[must_use]
+pub fn default_gesture_binding(direction: GestureDirection) -> Action {
+ match direction {
+ GestureDirection::Up => Action::MissionControl,
+ GestureDirection::Down => Action::ShowDesktop,
+ GestureDirection::Left => Action::PrevTab,
+ GestureDirection::Right => Action::NextTab,
+ GestureDirection::Click => Action::AppExpose,
+ }
+}
+
+/// The canonical default [`Binding`] for a fresh button in the merged model.
+///
+/// [`ButtonId::GestureButton`] defaults to [`Binding::Gesture`] populated from
+/// [`default_gesture_binding`] — preserving the existing per-direction swipe
+/// behavior — so the GUI mode toggle and the runtime agree it starts in gesture
+/// mode. Every other button defaults to [`Binding::Single`] of its
+/// [`default_binding`].
+///
+/// This is the seed when a button is first promoted to a gesture binding (see
+/// [`Config::set_gesture_direction`](crate::config::Config::set_gesture_direction)),
+/// so a freshly-customized gesture button always carries a full default
+/// direction map — including a [`GestureDirection::Click`] — rather than a sparse
+/// map whose click would project to a no-op [`Action::None`].
+#[must_use]
+pub fn default_binding_for(button: ButtonId) -> Binding {
+ match button {
+ ButtonId::GestureButton => Binding::Gesture(
+ GestureDirection::ALL
+ .into_iter()
+ .map(|d| (d, default_gesture_binding(d)))
+ .collect(),
+ ),
+ other => Binding::Single(default_binding(other)),
+ }
+}
diff --git a/crates/openlogi-core/src/binding/gesture.rs b/crates/openlogi-core/src/binding/gesture.rs
new file mode 100644
index 0000000000000000000000000000000000000000..9790e23c9238c61d31c36ba9dce6cf54e6176027
--- /dev/null
+++ b/crates/openlogi-core/src/binding/gesture.rs
@@ -0,0 +1,70 @@
+//! Gesture-button direction slots (swipe + click).
+
+use std::fmt;
+
+use serde::{Deserialize, Serialize};
+
+/// One of the five sub-bindings on the gesture button: hold + swipe up/down/
+/// left/right or a plain click without movement. Logi ships these as
+/// independent assignments (`SLOT_NAME_GESTURE_*_BUTTON` in the
+/// `device_gesture_buttons_image` metadata block) — OpenLogi mirrors the
+/// same shape.
+///
+/// Variant identifiers are TOML-stable: renames are migration events.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub enum GestureDirection {
+ /// Hold + swipe up (negative raw-XY `dy`).
+ Up,
+ /// Hold + swipe down (positive raw-XY `dy`).
+ Down,
+ /// Hold + swipe left (negative raw-XY `dx`).
+ Left,
+ /// Hold + swipe right (positive raw-XY `dx`).
+ Right,
+ /// A press-and-release that never committed a swipe — the gesture
+ /// button's plain-click slot.
+ Click,
+}
+
+impl GestureDirection {
+ /// All five direction slots, swipes first and [`Click`](Self::Click) last.
+ /// Iterated to seed or complete a full gesture map — see
+ /// [`Binding::fill_gesture_defaults`] and [`default_binding_for`].
+ pub const ALL: [GestureDirection; 5] = [
+ GestureDirection::Up,
+ GestureDirection::Down,
+ GestureDirection::Left,
+ GestureDirection::Right,
+ GestureDirection::Click,
+ ];
+
+ /// Human-readable label for popovers and tooltips.
+ #[must_use]
+ pub fn label(self) -> &'static str {
+ match self {
+ GestureDirection::Up => "Up",
+ GestureDirection::Down => "Down",
+ GestureDirection::Left => "Left",
+ GestureDirection::Right => "Right",
+ GestureDirection::Click => "Click",
+ }
+ }
+
+ /// Arrow glyph for compact list rendering.
+ #[must_use]
+ pub fn glyph(self) -> &'static str {
+ match self {
+ GestureDirection::Up => "↑",
+ GestureDirection::Down => "↓",
+ GestureDirection::Left => "←",
+ GestureDirection::Right => "→",
+ GestureDirection::Click => "·",
+ }
+ }
+}
+
+impl fmt::Display for GestureDirection {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.label())
+ }
+}
diff --git a/crates/openlogi-core/src/binding/key_combo.rs b/crates/openlogi-core/src/binding/key_combo.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d1023690adb0e9b5cccdf055c0480601dc80d486
--- /dev/null
+++ b/crates/openlogi-core/src/binding/key_combo.rs
@@ -0,0 +1,89 @@
+//! Modifier + virtual-key chords for custom shortcuts and workflows.
+
+use serde::{Deserialize, Serialize};
+
+/// A modifier + virtual-key keystroke captured by the P1.3 recorder UI or
+/// hand-authored in `config.toml`.
+///
+/// `modifiers` is a bitmask of [`KeyCombo::MOD_CMD`] etc. so the wire format
+/// is a compact integer, not a string. `key_code` is the macOS virtual key
+/// (`kVK_*`); on Linux, `openlogi-inject` maps it to an evdev `KeyCode` when it
+/// synthesizes the chord.
+///
+/// `display` is purely for rendering — e.g. `"⌘⇧P"`. Callers regenerate it
+/// from the captured chord; we keep it in the struct so older configs
+/// continue to render the same label without re-deriving on every load.
+#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct KeyCombo {
+ /// Bitmask of [`Self::MOD_CMD`] etc.
+ pub modifiers: u8,
+ /// macOS virtual key code (`kVK_*`). 0 means "no key" — useful for
+ /// modifier-only placeholders that the recorder UI rejects. On Linux,
+ /// `openlogi-inject` translates this to an evdev `KeyCode`.
+ pub key_code: u16,
+ /// Pre-rendered chord label, e.g. `"⌘⇧P"`. Empty falls through to a
+ /// generated label at runtime.
+ #[serde(default)]
+ pub display: String,
+}
+
+impl KeyCombo {
+ /// Bit for the ⌘ Command modifier in [`Self::modifiers`].
+ pub const MOD_CMD: u8 = 1 << 0;
+ /// Bit for the ⇧ Shift modifier in [`Self::modifiers`].
+ pub const MOD_SHIFT: u8 = 1 << 1;
+ /// Bit for the ⌃ Control modifier in [`Self::modifiers`].
+ pub const MOD_CTRL: u8 = 1 << 2;
+ /// Bit for the ⌥ Option/Alt modifier in [`Self::modifiers`].
+ pub const MOD_OPTION: u8 = 1 << 3;
+
+ /// Build the human-readable label from the modifier bitmask + key code.
+ /// Falls back to `"⌘key 0xNN"` when the key code isn't one of the
+ /// commonly-recognised letters; the recorder UI usually overrides this
+ /// with its own derivation.
+ #[must_use]
+ pub fn rendered_label(&self) -> String {
+ if !self.display.is_empty() {
+ return self.display.clone();
+ }
+ let mut out = String::new();
+ if self.modifiers & Self::MOD_CTRL != 0 {
+ out.push('⌃');
+ }
+ if self.modifiers & Self::MOD_OPTION != 0 {
+ out.push('⌥');
+ }
+ if self.modifiers & Self::MOD_SHIFT != 0 {
+ out.push('⇧');
+ }
+ if self.modifiers & Self::MOD_CMD != 0 {
+ out.push('⌘');
+ }
+ match self.key_code {
+ 0x00 => out.push('A'),
+ 0x01 => out.push('S'),
+ 0x02 => out.push('D'),
+ 0x03 => out.push('F'),
+ 0x06 => out.push('Z'),
+ 0x07 => out.push('X'),
+ 0x08 => out.push('C'),
+ 0x09 => out.push('V'),
+ 0x0B => out.push('B'),
+ 0x0C => out.push('Q'),
+ 0x0D => out.push('W'),
+ 0x0E => out.push('E'),
+ 0x0F => out.push('R'),
+ 0x10 => out.push('Y'),
+ 0x11 => out.push('T'),
+ 0x20 => out.push('U'),
+ 0x22 => out.push('I'),
+ 0x1F => out.push('O'),
+ 0x23 => out.push('P'),
+ _ => {
+ use std::fmt::Write as _;
+ let _ = write!(out, "key 0x{:02X}", self.key_code);
+ }
+ }
+ out
+ }
+}
diff --git a/crates/openlogi-core/src/binding/tests.rs b/crates/openlogi-core/src/binding/tests.rs
new file mode 100644
index 0000000000000000000000000000000000000000..1e1be285c3fa29a15440ffd8161758018bfd5f6d
--- /dev/null
+++ b/crates/openlogi-core/src/binding/tests.rs
@@ -0,0 +1,389 @@
+//! Binding catalog / serde roundtrip tests.
+
+use std::assert_matches;
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::*;
+
+// ── Roundtrip wrapper: defined here so it precedes any `let` statements ──
+
+/// Minimal TOML-serializable wrapper used by `roundtrip`.
+/// Defined at module scope to satisfy `clippy::items_after_statements`.
+#[derive(Serialize, Deserialize)]
+struct RoundtripWrapper {
+ binding: BTreeMap<ButtonId, Action>,
+}
+
+// ── Catalog tests ─────────────────────────────────────────────────────────
+
+#[test]
+fn catalog_has_at_least_29_entries() {
+ let catalog = Action::catalog();
+ assert!(
+ catalog.len() >= 29,
+ "catalog has {} entries, need ≥ 29",
+ catalog.len()
+ );
+}
+
+#[test]
+fn catalog_excludes_custom_shortcut() {
+ let catalog = Action::catalog();
+ for action in &catalog {
+ assert!(
+ !matches!(action, Action::CustomShortcut(_)),
+ "catalog must not contain CustomShortcut"
+ );
+ }
+}
+
+#[test]
+fn power_user_action_labels_and_category() {
+ assert_eq!(Action::TypeText("hi".into()).label(), "Type \"hi\"");
+ assert_eq!(
+ Action::RunAppleScript("osascript".into()).label(),
+ "Run AppleScript"
+ );
+ assert_eq!(
+ Action::RunShellCommand("echo hi".into()).label(),
+ "Run Command"
+ );
+ // All three are power-user escape hatches: classed as Editing so a
+ // hand-authored binding has a home group, but never in the default
+ // catalog (asserted below).
+ assert_eq!(Action::TypeText("x".into()).category(), Category::Editing);
+ assert_eq!(
+ Action::RunAppleScript("x".into()).category(),
+ Category::Editing
+ );
+ assert_eq!(
+ Action::RunShellCommand("x".into()).category(),
+ Category::Editing
+ );
+}
+
+#[test]
+fn power_user_actions_excluded_from_catalog() {
+ let cat = Action::catalog();
+ assert!(cat.iter().all(|a| !matches!(
+ a,
+ Action::TypeText(_) | Action::RunAppleScript(_) | Action::RunShellCommand(_)
+ )));
+}
+
+#[test]
+fn power_user_actions_roundtrip_toml() {
+ for action in [
+ Action::TypeText("hello".into()),
+ Action::RunAppleScript("beep".into()),
+ Action::RunShellCommand("date".into()),
+ ] {
+ let toml = toml::to_string(&action).expect("serialize");
+ let back: Action = toml::from_str(&toml).expect("deserialize");
+ assert_eq!(action, back);
+ }
+}
+
+#[test]
+fn workflow_label_category_and_catalog_exclusion() {
+ let wf = Action::Workflow(vec![
+ WorkflowStep::TypeText("bite me".into()),
+ WorkflowStep::Delay { millis: 5000 },
+ WorkflowStep::PressKey(KeyCombo {
+ modifiers: 0,
+ key_code: 0x24, // Return
+ display: String::new(),
+ }),
+ ]);
+ assert_eq!(wf.label(), "Workflow (3 steps)");
+ assert_eq!(wf.category(), Category::Editing);
+ // Excluded from the default catalog like the other power-user actions.
+ assert!(
+ Action::catalog()
+ .iter()
+ .all(|a| !matches!(a, Action::Workflow(_)))
+ );
+}
+
+#[test]
+fn workflow_roundtrips_toml() {
+ let wf = Action::Workflow(vec![
+ WorkflowStep::TypeText("bite me".into()),
+ WorkflowStep::Delay { millis: 5000 },
+ WorkflowStep::PressKey(KeyCombo {
+ modifiers: KeyCombo::MOD_SHIFT,
+ key_code: 0x24,
+ display: "⇧↩".into(),
+ }),
+ WorkflowStep::RunShellCommand("echo done".into()),
+ ]);
+ let toml = toml::to_string(&wf).expect("serialize");
+ let back: Action = toml::from_str(&toml).expect("deserialize");
+ assert_eq!(wf, back);
+}
+
+// ── Binding (merged model) serde routing ──────────────────────────────────
+
+/// On-disk shape: a `ButtonId` → [`Binding`] map, as `DeviceConfig.bindings`
+/// serializes it.
+#[derive(Serialize, Deserialize)]
+struct BindingWrapper {
+ bindings: BTreeMap<ButtonId, Binding>,
+}
+
+fn binding_roundtrip(bindings: BTreeMap<ButtonId, Binding>) -> BTreeMap<ButtonId, Binding> {
+ let toml = toml::to_string_pretty(&BindingWrapper { bindings }).expect("serialize");
+ toml::from_str::<BindingWrapper>(&toml)
+ .expect("deserialize")
+ .bindings
+}
+
+#[test]
+fn binding_single_roundtrips_including_payload_variants() {
+ let mut bindings = BTreeMap::new();
+ bindings.insert(ButtonId::Back, Binding::Single(Action::BrowserBack));
+ bindings.insert(
+ ButtonId::DpiToggle,
+ Binding::Single(Action::SetDpiPreset(2)),
+ );
+ bindings.insert(
+ ButtonId::Forward,
+ Binding::Single(Action::CustomShortcut(KeyCombo {
+ modifiers: KeyCombo::MOD_CMD,
+ key_code: 0x23,
+ display: "⌘P".into(),
+ })),
+ );
+ let back = binding_roundtrip(bindings);
+ assert_eq!(back[&ButtonId::Back], Binding::Single(Action::BrowserBack));
+ assert_eq!(
+ back[&ButtonId::DpiToggle],
+ Binding::Single(Action::SetDpiPreset(2))
+ );
+ assert_matches!(
+ back[&ButtonId::Forward],
+ Binding::Single(Action::CustomShortcut(_))
+ );
+}
+
+#[test]
+fn binding_gesture_roundtrips() {
+ let mut map = BTreeMap::new();
+ map.insert(GestureDirection::Up, Action::Copy);
+ map.insert(GestureDirection::Click, Action::Paste);
+ let mut bindings = BTreeMap::new();
+ bindings.insert(ButtonId::GestureButton, Binding::Gesture(map.clone()));
+ let back = binding_roundtrip(bindings);
+ assert_eq!(back[&ButtonId::GestureButton], Binding::Gesture(map));
+}
+
+/// The untagged-routing safety guard. A TOML table keyed by ANY
+/// [`GestureDirection`] name must deserialize as [`Binding::Gesture`], never
+/// [`Binding::Single`]. If a future [`Action`] payload variant is ever named
+/// `Up`/`Down`/`Left`/`Right`/`Click`, the table would parse as `Single`
+/// first and this test fails — catching the silent mis-route at CI time.
+#[test]
+fn binding_direction_keyed_table_routes_to_gesture() {
+ for dir in GestureDirection::ALL {
+ // `GestureDirection`'s serde key equals its `Display`/variant name.
+ let toml = format!("bindings.GestureButton.{dir} = \"None\"");
+ let parsed = toml::from_str::<BindingWrapper>(&toml).expect("deserialize");
+ assert!(
+ matches!(
+ parsed.bindings[&ButtonId::GestureButton],
+ Binding::Gesture(_)
+ ),
+ "a {dir}-keyed table must route to Gesture, not Single"
+ );
+ }
+}
+
+/// The collision case: a payload [`Action`] also serializes as a single-key
+/// table, but untagged must keep it [`Binding::Single`] (it parses as a valid
+/// externally-tagged `Action` before the `Gesture` arm is tried).
+#[test]
+fn binding_payload_action_stays_single() {
+ let toml = "bindings.DpiToggle.SetDpiPreset = 2";
+ let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
+ assert_eq!(
+ parsed.bindings[&ButtonId::DpiToggle],
+ Binding::Single(Action::SetDpiPreset(2))
+ );
+}
+
+#[test]
+fn binding_capture_region_roundtrips_as_single_string() {
+ let toml = "bindings.Back = \"CaptureRegion\"";
+ let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
+ assert_eq!(
+ parsed.bindings[&ButtonId::Back],
+ Binding::Single(Action::CaptureRegion)
+ );
+
+ let back = binding_roundtrip(parsed.bindings);
+ assert_eq!(
+ back[&ButtonId::Back],
+ Binding::Single(Action::CaptureRegion)
+ );
+ assert_eq!(Action::CaptureRegion.label(), "Capture Region");
+ assert_eq!(Action::CaptureRegion.category(), Category::System);
+ assert!(Action::catalog().contains(&Action::CaptureRegion));
+}
+
+// ── TOML roundtrip ────────────────────────────────────────────────────────
+
+/// Serialize then deserialize `action` through TOML, using a wrapper
+/// struct because TOML requires a top-level table.
+fn roundtrip(action: &Action) -> Action {
+ let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
+ map.insert(ButtonId::Back, action.clone());
+ let w = RoundtripWrapper { binding: map };
+ let s = toml::to_string(&w).expect("serialize");
+ let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
+ back.binding
+ .into_values()
+ .next()
+ .expect("binding present after roundtrip")
+}
+
+#[test]
+fn all_catalog_variants_roundtrip_toml() {
+ for action in Action::catalog() {
+ let back = roundtrip(&action);
+ assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
+ }
+}
+
+#[test]
+fn custom_shortcut_roundtrips_toml() {
+ let action = Action::CustomShortcut(KeyCombo {
+ modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
+ key_code: 0x23, // kVK_ANSI_P
+ display: "⌘⇧P".into(),
+ });
+ assert_eq!(roundtrip(&action), action);
+}
+
+#[test]
+fn key_combo_rendered_label_uses_display_when_set() {
+ let combo = KeyCombo {
+ modifiers: 0,
+ key_code: 0,
+ display: "preset".into(),
+ };
+ assert_eq!(combo.rendered_label(), "preset");
+}
+
+#[test]
+fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
+ let combo = KeyCombo {
+ modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
+ key_code: 0x23, // P
+ display: String::new(),
+ };
+ assert_eq!(combo.rendered_label(), "⇧⌘P");
+}
+
+// ── Category tests ────────────────────────────────────────────────────────
+
+#[test]
+fn category_editing_variants() {
+ assert_eq!(Action::Copy.category(), Category::Editing);
+ assert_eq!(Action::Undo.category(), Category::Editing);
+ assert_eq!(Action::SelectAll.category(), Category::Editing);
+ assert_eq!(Action::Find.category(), Category::Editing);
+ assert_eq!(Action::Save.category(), Category::Editing);
+ assert_eq!(Action::Cut.category(), Category::Editing);
+ assert_eq!(Action::Redo.category(), Category::Editing);
+ assert_eq!(Action::Paste.category(), Category::Editing);
+}
+
+#[test]
+fn category_browser_variants() {
+ assert_eq!(Action::BrowserBack.category(), Category::Browser);
+ assert_eq!(Action::BrowserForward.category(), Category::Browser);
+ assert_eq!(Action::NewTab.category(), Category::Browser);
+ assert_eq!(Action::CloseTab.category(), Category::Browser);
+ assert_eq!(Action::ReopenTab.category(), Category::Browser);
+ assert_eq!(Action::NextTab.category(), Category::Browser);
+ assert_eq!(Action::PrevTab.category(), Category::Browser);
+ assert_eq!(Action::ReloadPage.category(), Category::Browser);
+}
+
+#[test]
+fn category_media_variants() {
+ assert_eq!(Action::PlayPause.category(), Category::Media);
+ assert_eq!(Action::NextTrack.category(), Category::Media);
+ assert_eq!(Action::PrevTrack.category(), Category::Media);
+ assert_eq!(Action::VolumeUp.category(), Category::Media);
+ assert_eq!(Action::VolumeDown.category(), Category::Media);
+ assert_eq!(Action::MuteVolume.category(), Category::Media);
+}
+
+#[test]
+fn category_mouse_variants() {
+ assert_eq!(Action::LeftClick.category(), Category::Mouse);
+ assert_eq!(Action::RightClick.category(), Category::Mouse);
+ assert_eq!(Action::MiddleClick.category(), Category::Mouse);
+}
+
+#[test]
+fn category_dpi_variants() {
+ assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
+ assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
+}
+
+#[test]
+fn category_scroll_variants() {
+ assert_eq!(Action::ScrollUp.category(), Category::Scroll);
+ assert_eq!(Action::ScrollDown.category(), Category::Scroll);
+ assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
+ assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
+}
+
+#[test]
+fn category_navigation_variants() {
+ assert_eq!(Action::MissionControl.category(), Category::Navigation);
+ assert_eq!(Action::AppExpose.category(), Category::Navigation);
+ assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
+ assert_eq!(Action::NextDesktop.category(), Category::Navigation);
+ assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
+ assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
+}
+
+#[test]
+fn category_system_variants() {
+ assert_eq!(Action::LockScreen.category(), Category::System);
+ assert_eq!(Action::Screenshot.category(), Category::System);
+}
+
+// ── Category label smoke test ─────────────────────────────────────────────
+
+#[test]
+fn category_labels_are_nonempty() {
+ let categories = [
+ Category::Editing,
+ Category::Browser,
+ Category::Media,
+ Category::Mouse,
+ Category::Dpi,
+ Category::Scroll,
+ Category::Navigation,
+ Category::System,
+ ];
+ for cat in categories {
+ assert!(!cat.label().is_empty(), "label empty for {cat:?}");
+ }
+}
+
+// ── Default binding ───────────────────────────────────────────────────────
+
+#[test]
+fn dpi_toggle_default_is_cycle_dpi_presets() {
+ assert_eq!(
+ default_binding(ButtonId::DpiToggle),
+ Action::CycleDpiPresets
+ );
+}
diff --git a/crates/openlogi-core/src/binding/value.rs b/crates/openlogi-core/src/binding/value.rs
new file mode 100644
index 0000000000000000000000000000000000000000..84a338714509662364eabe64c7896c75a97304f0
--- /dev/null
+++ b/crates/openlogi-core/src/binding/value.rs
@@ -0,0 +1,113 @@
+//! Single-action vs per-direction gesture bindings.
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use super::action::Action;
+use super::defaults::default_gesture_binding;
+use super::gesture::GestureDirection;
+
+/// What a single rebindable [`ButtonId`] does: either one [`Action`], or — for a
+/// raw-XY-capable button placed in gesture mode — a per-[`GestureDirection`]
+/// map (hold + swipe up/down/left/right, or a plain click).
+///
+/// There has only ever been one binding map per device; a gesture binding is
+/// just a binding whose payload is a direction map instead of a single action.
+///
+/// # Serialization
+///
+/// `#[serde(untagged)]`: [`Single`](Binding::Single) serializes exactly as the
+/// bare [`Action`] did before (a string `"BrowserBack"`, or a single-key table
+/// for the payload variants), and [`Gesture`](Binding::Gesture) serializes as a
+/// table keyed by [`GestureDirection`] names (`Up`/`Down`/`Left`/`Right`/
+/// `Click`).
+///
+/// The two arms are disambiguated by the **zero overlap** between [`Action`]
+/// variant names and [`GestureDirection`] variant names — untagged tries
+/// `Single(Action)` first, and a table keyed by `Up` etc. cannot parse as an
+/// externally-tagged `Action`, so it falls through to `Gesture`. A payload
+/// action like `{ SetDpiPreset = 2 }` is a valid externally-tagged `Action`, so
+/// it stays `Single` and never reaches the `Gesture` arm. This invariant is the
+/// entire safety basis for untagged routing; the `binding_untagged_*` tests
+/// guard it (a future `Action` named `Up`/`Down`/`Left`/`Right`/`Click` would
+/// silently mis-route, and those tests would fail).
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum Binding {
+ /// One action, fired on press. The shape every non-gesture button uses.
+ Single(Action),
+ /// Per-direction sub-bindings for a button in gesture mode. Keyed by the
+ /// committed swipe direction, with [`GestureDirection::Click`] holding the
+ /// plain-click (no-swipe) action.
+ Gesture(BTreeMap<GestureDirection, Action>),
+}
+
+impl Binding {
+ /// The plain-click action for this binding: the [`Single`](Binding::Single)
+ /// action, or the [`Gesture`](Binding::Gesture) map's
+ /// [`Click`](GestureDirection::Click) entry. Falls back to [`Action::None`]
+ /// when a gesture binding has no explicit `Click`.
+ ///
+ /// Lets the click-dispatch path stay binding-shape-agnostic.
+ #[must_use]
+ pub fn click_action(&self) -> Action {
+ match self {
+ Binding::Single(action) => action.clone(),
+ Binding::Gesture(map) => map
+ .get(&GestureDirection::Click)
+ .cloned()
+ .unwrap_or(Action::None),
+ }
+ }
+
+ /// The action bound to `direction`, if this is a gesture binding.
+ /// [`Single`](Binding::Single) has no directions and returns `None`.
+ #[must_use]
+ pub fn direction_action(&self, direction: GestureDirection) -> Option<&Action> {
+ match self {
+ Binding::Single(_) => None,
+ Binding::Gesture(map) => map.get(&direction),
+ }
+ }
+
+ /// Whether this binding drives raw-XY swipe capture (the
+ /// [`Gesture`](Binding::Gesture) arm).
+ #[must_use]
+ pub fn is_gesture(&self) -> bool {
+ matches!(self, Binding::Gesture(_))
+ }
+
+ /// Promote a [`Single`](Binding::Single) binding in place to a
+ /// [`Gesture`](Binding::Gesture), keeping its action as the
+ /// [`GestureDirection::Click`] entry and leaving the swipe arms unbound.
+ /// A no-op when this is already a [`Gesture`](Binding::Gesture).
+ pub fn upgrade_to_gesture(&mut self) {
+ if let Binding::Single(action) = self {
+ let mut map = BTreeMap::new();
+ map.insert(GestureDirection::Click, action.clone());
+ *self = Binding::Gesture(map);
+ }
+ }
+
+ /// Fill any unbound directions of a [`Gesture`](Binding::Gesture) binding
+ /// with their canonical [`default_gesture_binding`], so a button promoted to
+ /// the gesture role always exposes the full five-direction set — rather than
+ /// leaving swipe arms the GUI renders as defaults but the runtime never
+ /// dispatches. A no-op on [`Single`](Binding::Single) and on directions
+ /// already bound (existing user choices are preserved).
+ pub fn fill_gesture_defaults(&mut self) {
+ if let Binding::Gesture(map) = self {
+ for dir in GestureDirection::ALL {
+ map.entry(dir)
+ .or_insert_with(|| default_gesture_binding(dir));
+ }
+ }
+ }
+}
+
+impl From<Action> for Binding {
+ fn from(action: Action) -> Self {
+ Binding::Single(action)
+ }
+}
diff --git a/crates/openlogi-core/src/brand.rs b/crates/openlogi-core/src/brand.rs
index 5835dd89aacdcbd2693ac045d20cce2bcc3380ba..d0d688826e15c21811f42d460a1ddc4e307281dd 100644
--- a/crates/openlogi-core/src/brand.rs
+++ b/crates/openlogi-core/src/brand.rs
@@ -14,6 +14,15 @@ pub const HELP_URL: &str = "https://github.com/AprilNEA/OpenLogi#readme";
/// The "latest release" page.
pub const RELEASES_URL: &str = "https://github.com/AprilNEA/OpenLogi/releases/latest";
+/// The application identifier: the Wayland xdg-toplevel `app_id` (and X11
+/// `WM_CLASS`) the GUI advertises, the root of the macOS bundle-id family
+/// (`org.openlogi.agent`, `org.openlogi.openlogi.dev`), and the value the Linux
+/// `.desktop` file pins as `StartupWMClass`. Defined once here so the window the
+/// compositor sees, the launcher that groups it, and the frontmost backend that
+/// self-identifies OpenLogi can never disagree. The `.desktop` file carries its
+/// own literal copy (it can't reference Rust) — keep the two in sync.
+pub const APP_ID: &str = "org.openlogi.openlogi";
+
/// The release page for a specific version tag (e.g. the running build).
#[must_use]
pub fn release_tag_url(version: &str) -> String {
diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs
index 603fa825d881d85ff2c3ff073e57bbed31cb5873..1b62586e0a78720f3cbce9814c85f36c61cf3eaf 100644
--- a/crates/openlogi-core/src/config.rs
+++ b/crates/openlogi-core/src/config.rs
@@ -17,12 +17,18 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
mod device;
+mod key_trigger;
mod settings;
+#[cfg(test)]
+mod tests;
+
pub use device::{DeviceConfig, DeviceIdentity};
+pub use key_trigger::{KeyModifiers, KeyTrigger, KeyboardConfig, ParseTriggerError};
+pub use settings::LightSettings;
pub use settings::{
- AppSettings, Appearance, AssetSourcePreference, DEFAULT_THUMBWHEEL_SENSITIVITY, GestureOwner,
- Lighting, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY,
+ AppSettings, Appearance, AssetSourcePreference, CameraControls, DEFAULT_THUMBWHEEL_SENSITIVITY,
+ GestureOwner, Lighting, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY,
SMARTSHIFT_AUTO_DISENGAGE_DEFAULT, SMARTSHIFT_MIN_AUTO_DISENGAGE, ScrollResolution, SmartShift,
WheelMode,
};
@@ -60,11 +66,21 @@ pub struct Config {
/// first paired device. `None` means "fall back to the first device".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_device: Option<String>,
+ /// When set (see [`Self::ephemeral`]), [`Self::save_atomic`] is a no-op:
+ /// this config never writes the on-disk file. Never true for a loaded or
+ /// default-constructed config.
+ #[serde(skip)]
+ ephemeral: bool,
/// Per-device state, keyed by the stable physical-device identifier
/// (e.g. `"receiver:abc123:slot:2"`) so two identical models never share
/// an entry.
#[serde(default)]
pub devices: BTreeMap<String, DeviceConfig>,
+ /// Keyboard remappings, independent of device. The function-key remapper
+ /// (M1) reads this; `#[serde(default)]` keeps older configs without a
+ /// `[keyboard]` section loading unchanged.
+ #[serde(default)]
+ pub keyboard: KeyboardConfig,
}
impl Default for Config {
@@ -74,6 +90,8 @@ impl Default for Config {
app_settings: AppSettings::default(),
selected_device: None,
devices: BTreeMap::new(),
+ ephemeral: false,
+ keyboard: KeyboardConfig::default(),
}
}
}
@@ -176,10 +194,25 @@ impl Config {
}
}
+ /// A config that never touches the on-disk file: [`Self::save_atomic`] is
+ /// a no-op. For tests that drive the state layer's persistence paths —
+ /// with a default config those would overwrite the developer's real
+ /// `config.toml` with test fixtures.
+ #[must_use]
+ pub fn ephemeral() -> Self {
+ Self {
+ ephemeral: true,
+ ..Self::default()
+ }
+ }
+
/// Writes the config atomically to the default user path: serialize to a
/// sibling temp file, then rename over the target. On Unix the temp file
- /// is created with mode 0600.
+ /// is created with mode 0600. No-op for an [`Self::ephemeral`] config.
pub fn save_atomic(&self) -> Result<(), ConfigError> {
+ if self.ephemeral {
+ return Ok(());
+ }
self.save_to_path(&paths::config_path()?)
}
@@ -220,6 +253,27 @@ impl Config {
.insert(button, binding);
}
+ /// Records (or, with `action = None`, clears) the F-key `trigger` binding
+ /// in the global `[keyboard]` map. Keyboard bindings are device-agnostic —
+ /// one map applies across all keyboards — so this mirrors [`Self::set_binding`]
+ /// minus the device key.
+ pub fn set_keyboard_binding(&mut self, trigger: KeyTrigger, action: Option<Action>) {
+ match action {
+ Some(a) => {
+ self.keyboard.bindings.insert(trigger, a);
+ }
+ None => {
+ self.keyboard.bindings.remove(&trigger);
+ }
+ }
+ }
+
+ /// The global keyboard F-key bindings (read accessor).
+ #[must_use]
+ pub fn keyboard_bindings(&self) -> &std::collections::HashMap<KeyTrigger, Action> {
+ &self.keyboard.bindings
+ }
+
/// Returns the gesture sub-bindings for `device_key`'s gesture button, or an
/// empty map if it isn't in gesture mode. Derived from the unified
/// [`DeviceConfig::bindings`]; kept as a convenience for the agent-side
@@ -497,6 +551,81 @@ impl Config {
.lighting = Some(lighting);
}
+ /// The saved UVC image controls for `device_key`, or `None` if never set.
+ #[must_use]
+ pub fn camera_controls(&self, device_key: &str) -> Option<CameraControls> {
+ self.devices
+ .get(device_key)
+ .and_then(|d| d.camera_controls.clone())
+ }
+
+ /// Replace the saved UVC image controls for `device_key`.
+ pub fn set_camera_controls(&mut self, device_key: &str, controls: CameraControls) {
+ self.devices
+ .entry(device_key.to_string())
+ .or_default()
+ .camera_controls = Some(controls);
+ }
+
+ /// The saved custom camera profiles for `device_key` (name → snapshot).
+ #[must_use]
+ pub fn camera_profiles(&self, device_key: &str) -> BTreeMap<String, CameraControls> {
+ self.devices
+ .get(device_key)
+ .map(|d| d.camera_profiles.clone())
+ .unwrap_or_default()
+ }
+
+ /// Save (or overwrite) a custom camera profile for `device_key`.
+ pub fn save_camera_profile(&mut self, device_key: &str, name: &str, snap: CameraControls) {
+ self.devices
+ .entry(device_key.to_string())
+ .or_default()
+ .camera_profiles
+ .insert(name.to_string(), snap);
+ }
+
+ /// Delete a custom camera profile, clearing the active selection if it
+ /// named it. Unknown names are a no-op.
+ pub fn delete_camera_profile(&mut self, device_key: &str, name: &str) {
+ if let Some(device) = self.devices.get_mut(device_key) {
+ device.camera_profiles.remove(name);
+ if device.camera_profile.as_deref() == Some(name) {
+ device.camera_profile = None;
+ }
+ }
+ }
+
+ /// The last-applied camera profile name for `device_key`, if any.
+ #[must_use]
+ pub fn camera_active_profile(&self, device_key: &str) -> Option<String> {
+ self.devices
+ .get(device_key)
+ .and_then(|d| d.camera_profile.clone())
+ }
+
+ /// Record which camera profile `device_key` last applied.
+ pub fn set_camera_active_profile(&mut self, device_key: &str, name: Option<String>) {
+ self.devices
+ .entry(device_key.to_string())
+ .or_default()
+ .camera_profile = name;
+ }
+
+ /// The standalone-light config for `device_key`, or `None` if unset.
+ #[must_use]
+ pub fn light(&self, device_key: &str) -> Option<LightSettings> {
+ self.devices.get(device_key).and_then(|d| d.light)
+ }
+
+ /// Replace the standalone-light config for `device_key`.
+ pub fn set_light(&mut self, device_key: &str, light: LightSettings) {
+ self.devices
+ .entry(device_key.to_string())
+ .or_default()
+ .light = Some(light);
+ }
+
/// The committed sensor DPI for `device_key`, or `None` if never set.
#[must_use]
pub fn dpi(&self, device_key: &str) -> Option<u32> {
@@ -515,6 +644,13 @@ impl Config {
self.devices.get(device_key).and_then(|d| d.smartshift)
}
+ /// The persisted keyboard Fn-lock state for `device_key`, or `None` when
+ /// the user never set one (the keyboard keeps its own state).
+ #[must_use]
+ pub fn fn_lock(&self, device_key: &str) -> Option<bool> {
+ self.devices.get(device_key).and_then(|d| d.fn_lock)
+ }
+
/// Record the SmartShift wheel config for `device_key`, so the agent can
/// re-apply it when the device reconnects (#189).
pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
@@ -584,857 +720,3 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
io::Write::write_all(&mut file, bytes)?;
file.commit()
}
-
-#[cfg(test)]
-#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
-mod tests {
- use std::assert_matches;
-
- use super::*;
- use crate::binding::{default_binding, default_gesture_binding};
-
- fn write_and_read(config: &Config) -> Config {
- let dir = tempfile::tempdir().expect("tempdir");
- let path = dir.path().join("config.toml");
- config.save_to_path(&path).expect("save");
- Config::load_from_path(&path).expect("load")
- }
-
- #[test]
- fn missing_file_yields_default() {
- let dir = tempfile::tempdir().expect("tempdir");
- let path = dir.path().join("nonexistent.toml");
- let cfg = Config::load_from_path(&path).expect("load");
- assert_eq!(cfg.schema_version, SCHEMA_VERSION);
- assert!(cfg.devices.is_empty());
- }
-
- #[test]
- fn lighting_roundtrips_per_device() {
- let mut cfg = Config::default();
- cfg.set_lighting(
- "g513",
- Lighting {
- enabled: true,
- color: "00aabb".parse().expect("valid hex"),
- brightness: 75,
- },
- );
- let restored = write_and_read(&cfg);
- assert_eq!(
- restored.lighting("g513"),
- Some(Lighting {
- enabled: true,
- color: "00aabb".parse().expect("valid hex"),
- brightness: 75,
- })
- );
- assert_eq!(restored.lighting("absent"), None);
- }
-
- #[test]
- fn unparseable_lighting_color_falls_back_to_white() {
- let cfg: Config = toml::from_str(
- r#"
- schema_version = 3
- [devices.g513.lighting]
- enabled = true
- color = "red"
- brightness = 50
- "#,
- )
- .expect("config with a bad color still loads");
- assert_eq!(
- cfg.lighting("g513").map(|l| l.color),
- Some(crate::color::Rgb::WHITE)
- );
- }
-
- #[test]
- fn hash_prefixed_lighting_color_migrates_to_canonical_hex() {
- let dir = tempfile::tempdir().expect("tempdir");
- let path = dir.path().join("config.toml");
- fs::write(
- &path,
- r##"
- schema_version = 3
- [devices.g513.lighting]
- enabled = true
- color = "#ff0000"
- brightness = 50
- "##,
- )
- .expect("write config");
-
- let cfg = Config::load_from_path(&path).expect("load hash-prefixed color");
- assert_eq!(
- cfg.lighting("g513").map(|lighting| lighting.color),
- Some(crate::color::Rgb::new(0xff, 0x00, 0x00))
- );
-
- cfg.save_to_path(&path).expect("save canonical color");
- let saved = fs::read_to_string(path).expect("read saved config");
- assert!(saved.contains("color = \"ff0000\""));
- assert!(!saved.contains("color = \"#"));
- }
-
- #[test]
- fn dpi_roundtrips_per_device() {
- let mut cfg = Config::default();
- cfg.set_dpi("2b042", 1600);
- let restored = write_and_read(&cfg);
- assert_eq!(restored.dpi("2b042"), Some(1600));
- assert_eq!(restored.dpi("absent"), None);
- }
-
- #[test]
- fn smartshift_roundtrips_per_device() {
- let mut cfg = Config::default();
- cfg.set_smartshift(
- "2b042",
- SmartShift {
- mode: WheelMode::Ratchet,
- auto_disengage: 16,
- tunable_torque: 30,
- },
- );
- let restored = write_and_read(&cfg);
- assert_eq!(
- restored.smartshift("2b042"),
- Some(SmartShift {
- mode: WheelMode::Ratchet,
- auto_disengage: 16,
- tunable_torque: 30,
- })
- );
- assert_eq!(restored.smartshift("absent"), None);
- }
-
- #[test]
- fn invert_scroll_roundtrips_per_device() {
- let mut cfg = Config::default();
- // Default is the native direction for any device, present or not.
- assert!(!cfg.invert_scroll("2b042"));
- cfg.set_invert_scroll("2b042", true);
- let restored = write_and_read(&cfg);
- assert!(restored.invert_scroll("2b042"));
- assert!(!restored.invert_scroll("absent"));
- }
-
- #[test]
- fn default_invert_scroll_is_omitted_from_toml() {
- // A device block with only the default (false) invert_scroll must not
- // emit the field — `skip_serializing_if` keeps configs clean.
- let mut cfg = Config::default();
- cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
- cfg.set_invert_scroll("2b042", false);
- let body = toml::to_string_pretty(&cfg).expect("serialize");
- assert!(
- !body.contains("invert_scroll"),
- "default invert_scroll should be omitted: {body}"
- );
- }
-
- #[test]
- fn scroll_resolution_roundtrips_all_three_states() {
- let mut cfg = Config::default();
- assert_eq!(cfg.scroll_resolution("mouse"), None);
-
- cfg.set_scroll_resolution("mouse", Some(ScrollResolution::Low));
- let low = write_and_read(&cfg);
- assert_eq!(low.scroll_resolution("mouse"), Some(ScrollResolution::Low));
-
- cfg.set_scroll_resolution("mouse", Some(ScrollResolution::High));
- let high = write_and_read(&cfg);
- assert_eq!(
- high.scroll_resolution("mouse"),
- Some(ScrollResolution::High)
- );
-
- cfg.set_scroll_resolution("mouse", None);
- let unmanaged = write_and_read(&cfg);
- assert_eq!(unmanaged.scroll_resolution("mouse"), None);
- }
-
- #[test]
- fn unset_scroll_resolution_is_omitted_from_toml() {
- let mut cfg = Config::default();
- cfg.set_binding("mouse", ButtonId::Back, Binding::Single(Action::Copy));
- cfg.set_scroll_resolution("mouse", Some(ScrollResolution::Low));
- cfg.set_scroll_resolution("mouse", None);
-
- let body = toml::to_string_pretty(&cfg).expect("serialize");
- assert!(
- !body.contains("scroll_resolution"),
- "unset scroll resolution should be omitted: {body}"
- );
- }
-
- #[test]
- fn config_without_scroll_resolution_loads_as_unmanaged() {
- let dir = tempfile::tempdir().expect("tempdir");
- let path = dir.path().join("config.toml");
- fs::write(
- &path,
- r"
- schema_version = 3
- [devices.mouse]
- invert_scroll = true
- ",
- )
- .expect("write config");
-
- let cfg = Config::load_from_path(&path).expect("load existing config");
- assert_eq!(cfg.scroll_resolution("mouse"), None);
- assert!(cfg.invert_scroll("mouse"));
- }
-
- #[test]
- fn bindings_roundtrip_per_device() {
- let mut cfg = Config::default();
- cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
- cfg.set_binding(
- "2b042",
- ButtonId::DpiToggle,
- Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
- modifiers: crate::binding::KeyCombo::MOD_CMD,
- key_code: 0x23, // kVK_ANSI_P
- display: "⌘P".into(),
- })),
- );
- cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
-
- let parsed = write_and_read(&cfg);
-
- // Per-device isolation.
- let a = parsed.bindings_for("2b042");
- assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
- assert_eq!(
- a.get(&ButtonId::DpiToggle),
- Some(&Binding::Single(Action::CustomShortcut(
- crate::binding::KeyCombo {
- modifiers: crate::binding::KeyCombo::MOD_CMD,
- key_code: 0x23,
- display: "⌘P".into(),
- }
- )))
- );
-
- let b = parsed.bindings_for("4082d");
- assert_eq!(
- b.get(&ButtonId::Back),
- Some(&Binding::Single(Action::Paste))
- );
- assert_eq!(b.len(), 1, "device b should only see its own bindings");
-
- // Unknown device returns empty map without panic.
- assert!(parsed.bindings_for("deadbeef").is_empty());
- }
-
- #[test]
- fn human_readable_toml_layout() {
- let mut cfg = Config::default();
- cfg.set_binding(
- "2b042",
- ButtonId::Back,
- Binding::Single(Action::BrowserBack),
- );
- let body = toml::to_string_pretty(&cfg).expect("serialize");
-
- // The key only contains [A-Za-z0-9_], so TOML emits it as a bare-word
- // table key (no surrounding quotes). The test asserts the observable
- // structure rather than locking in a specific quoting.
- assert!(body.contains("schema_version = 3"), "got: {body}");
- assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
- // A `Single` binding serializes byte-identically to the pre-v2 bare
- // `Action`, so the leaf line is unchanged.
- assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
- }
-
- #[test]
- fn dpi_presets_roundtrip_per_device() {
- let mut cfg = Config::default();
- cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
- cfg.set_dpi_presets("4082d", vec![400, 1600]);
-
- let parsed = write_and_read(&cfg);
-
- assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
- assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
- assert!(parsed.dpi_presets("unknown").is_empty());
- }
-
- #[test]
- fn empty_dpi_presets_skip_serialization() {
- let mut cfg = Config::default();
- // Add a binding so the device block exists.
- cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
- cfg.set_dpi_presets("2b042", vec![800]);
- cfg.set_dpi_presets("2b042", vec![]); // clear
-
- let body = toml::to_string_pretty(&cfg).expect("serialize");
- assert!(
- !body.contains("dpi_presets"),
- "empty dpi_presets should be omitted: {body}"
- );
- }
-
- #[test]
- fn device_identity_roundtrips_and_is_iterable() {
- use crate::device::{Capabilities, DeviceKind};
-
- let mut cfg = Config::default();
- let mouse = DeviceIdentity {
- display_name: "MX Master 3S".to_string(),
- model_info: None,
- codename: None,
- kind: DeviceKind::Mouse,
- capabilities: Capabilities {
- buttons: true,
- pointer: true,
- lighting: false,
- scroll_inversion: false,
- hires_wheel: true,
- thumbwheel: false,
- },
- };
- cfg.set_device_identity("2b034", mouse.clone());
- // Recording an identity must not disturb unrelated per-device state.
- cfg.set_binding(
- "2b034",
- ButtonId::Back,
- Binding::Single(Action::BrowserBack),
- );
-
- let parsed = write_and_read(&cfg);
- assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
- assert_eq!(parsed.device_identity("absent"), None);
- assert_eq!(
- parsed.bindings_for("2b034").get(&ButtonId::Back),
- Some(&Binding::Single(Action::BrowserBack)),
- "identity must coexist with bindings on the same device block"
- );
- assert_eq!(
- parsed.known_identities().collect::<Vec<_>>(),
- vec![("2b034", &mouse)]
- );
- }
-
- #[test]
- fn selected_device_roundtrips() {
- let mut cfg = Config::default();
- assert_eq!(cfg.selected_device(), None);
- cfg.set_selected_device(Some("2b042".into()));
- let parsed = write_and_read(&cfg);
- assert_eq!(parsed.selected_device(), Some("2b042"));
- }
-
- #[test]
- fn per_app_overlay_takes_precedence() {
- let mut cfg = Config::default();
- cfg.set_binding(
- "2b042",
- ButtonId::Back,
- Binding::Single(Action::BrowserBack),
- );
- cfg.set_binding(
- "2b042",
- ButtonId::Forward,
- Binding::Single(Action::BrowserForward),
- );
- cfg.set_per_app_binding(
- "2b042",
- "com.microsoft.VSCode",
- ButtonId::Back,
- Some(Action::Undo),
- );
-
- // Global: both buttons are browser nav.
- let global = cfg.effective_bindings("2b042", None);
- assert_eq!(
- global.get(&ButtonId::Back),
- Some(&Binding::Single(Action::BrowserBack))
- );
- assert_eq!(
- global.get(&ButtonId::Forward),
- Some(&Binding::Single(Action::BrowserForward))
- );
-
- // VSCode: Back overridden (wrapped as Single), Forward inherits.
- let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
- assert_eq!(
- vscode.get(&ButtonId::Back),
- Some(&Binding::Single(Action::Undo))
- );
- assert_eq!(
- vscode.get(&ButtonId::Forward),
- Some(&Binding::Single(Action::BrowserForward))
- );
-
- // Unrelated app falls through.
- let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
- assert_eq!(
- other.get(&ButtonId::Back),
- Some(&Binding::Single(Action::BrowserBack))
- );
- }
-
- #[test]
- fn per_app_binding_removal_prunes_empty_app() {
- let mut cfg = Config::default();
- cfg.set_per_app_binding(
- "2b042",
- "com.example.App",
- ButtonId::Back,
- Some(Action::Copy),
- );
- cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
- assert!(
- cfg.devices["2b042"].per_app_bindings.is_empty(),
- "removing last override should prune the app entry"
- );
- }
-
- #[test]
- fn app_settings_default_omits_block() {
- let cfg = Config::default();
- let body = toml::to_string_pretty(&cfg).expect("serialize");
- assert!(
- !body.contains("app_settings"),
- "default app_settings should be omitted: {body}"
- );
- }
-
- #[test]
- fn app_settings_launch_at_login_roundtrips() {
- let mut cfg = Config::default();
- cfg.app_settings.launch_at_login = true;
- let parsed = write_and_read(&cfg);
- assert!(parsed.app_settings.launch_at_login);
- }
-
- #[test]
- fn asset_source_preference_roundtrips() {
- let mut cfg = Config::default();
- cfg.app_settings.asset_source = AssetSourcePreference::OpenLogi;
-
- let body = toml::to_string_pretty(&cfg).expect("serialize");
- let parsed = write_and_read(&cfg);
-
- assert!(body.contains("asset_source = \"openlogi\""));
- assert_eq!(
- parsed.app_settings.asset_source,
- AssetSourcePreference::OpenLogi
- );
- }
-
- #[test]
- fn config_without_asset_source_keeps_automatic_selection() {
- let parsed: Config = toml::from_str(
- r"
- schema_version = 3
- [app_settings]
- auto_download_assets = false
- ",
- )
- .expect("config predating the asset-source setting loads");
-
- assert_eq!(
- parsed.app_settings.asset_source,
- AssetSourcePreference::Automatic
- );
- }
-
- #[test]
- fn cleared_selected_device_omits_field() {
- let mut cfg = Config::default();
- cfg.set_selected_device(Some("2b042".into()));
- cfg.set_selected_device(None);
- let body = toml::to_string_pretty(&cfg).expect("serialize");
- assert!(
- !body.contains("selected_device"),
- "cleared selection should not appear: {body}"
- );
- }
-
- #[test]
- fn empty_device_block_is_skipped_in_output() {
- // Inserting then clearing should not leave a [devices."x"] header
- // with no bindings under it (skip_serializing_if on bindings).
- let mut cfg = Config::default();
- cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
- cfg.devices
- .get_mut("2b042")
- .expect("entry")
- .bindings
- .clear();
- let body = toml::to_string_pretty(&cfg).expect("serialize");
- assert!(
- !body.contains("Back"),
- "cleared bindings should not appear: {body}"
- );
- }
-
- #[test]
- fn migrates_v1_button_and_gesture_bindings() {
- // A pre-v2 file: split button_bindings + a flat gesture_bindings map.
- let v1 = "\
-schema_version = 1
-
-[devices.2b042.button_bindings]
-Back = \"BrowserBack\"
-
-[devices.2b042.gesture_bindings]
-Up = \"Copy\"
-Click = \"Paste\"
-";
- let dir = tempfile::tempdir().expect("tempdir");
- let path = dir.path().join("config.toml");
- fs::write(&path, v1).expect("write");
-
- // v1 still loads (version <= current) and folds into the merged map.
- let cfg = Config::load_from_path(&path).expect("load v1");
- let bindings = cfg.bindings_for("2b042");
- assert_eq!(
- bindings.get(&ButtonId::Back),
- Some(&Binding::Single(Action::BrowserBack))
- );
- let mut gesture = BTreeMap::new();
- gesture.insert(GestureDirection::Up, Action::Copy);
- gesture.insert(GestureDirection::Click, Action::Paste);
- assert_eq!(
- bindings.get(&ButtonId::GestureButton),
- Some(&Binding::Gesture(gesture))
- );
-
- // Saving self-heals to the current shape: stamped version + merged table,
- // legacy field names gone.
- let body = toml::to_string_pretty(&cfg).expect("serialize");
- assert!(body.contains("schema_version = 3"), "got: {body}");
- assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
- assert!(!body.contains("button_bindings"), "got: {body}");
- assert!(!body.contains("gesture_bindings"), "got: {body}");
- }
-
- #[test]
- fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
- // The data-loss guard: when a legacy single button_bindings[GestureButton]
- // entry coexists with a gesture_bindings map (reachable via hand-edited
- // or very old configs), the gesture map must survive — not be shadowed by
- // the single entry. Mirrors the pre-v2 "gesture entries win" rule.
- let v1 = "\
-schema_version = 1
-
-[devices.2b042.button_bindings]
-GestureButton = \"MissionControl\"
-
-[devices.2b042.gesture_bindings]
-Up = \"Copy\"
-Down = \"Paste\"
-";
- let dir = tempfile::tempdir().expect("tempdir");
- let path = dir.path().join("config.toml");
- fs::write(&path, v1).expect("write");
-
- let cfg = Config::load_from_path(&path).expect("load v1");
- let mut gesture = BTreeMap::new();
- gesture.insert(GestureDirection::Up, Action::Copy);
- gesture.insert(GestureDirection::Down, Action::Paste);
- assert_eq!(
- cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
- Some(&Binding::Gesture(gesture)),
- "gesture map must win over the legacy single GestureButton entry"
- );
- }
-
- #[test]
- fn migration_drops_vestigial_lone_gesture_button_single() {
- // A v1 file with only `button_bindings[GestureButton]` and no
- // `gesture_bindings` (the pre-gesture-picker shape). That entry never
- // dispatched in v1 — the gesture button's plain press routes through the
- // gesture `Click` slot, not the per-button map — so migrating it to a
- // `Binding::Single` would leave an unreachable entry the GUI hides and the
- // runtime ignores. It must be dropped, not shadow the gesture path.
- let v1 = "\
-schema_version = 1
-
-[devices.2b042.button_bindings]
-GestureButton = \"MissionControl\"
-Back = \"BrowserBack\"
-";
- let dir = tempfile::tempdir().expect("tempdir");
- let path = dir.path().join("config.toml");
- fs::write(&path, v1).expect("write");
-
- let bindings = Config::load_from_path(&path)
- .expect("load v1")
- .bindings_for("2b042");
- // An ordinary button still migrates to a `Single`...
- assert_eq!(
- bindings.get(&ButtonId::Back),
- Some(&Binding::Single(Action::BrowserBack))
- );
- // ...but the vestigial gesture-button single is gone, leaving the button
- // to fall back to its canonical default rather than an unreachable entry.
- assert_eq!(bindings.get(&ButtonId::GestureButton), None);
- }
-
- #[test]
- fn rejects_newer_schema_version_but_accepts_v1() {
- // A future version is rejected loudly; the current and older versions
- // load (older ones migrate through the shim).
- let dir = tempfile::tempdir().expect("tempdir");
- let path = dir.path().join("config.toml");
- fs::write(&path, "schema_version = 99\n").expect("write");
- assert_matches!(
- Config::load_from_path(&path).expect_err("v99 should fail"),
- ConfigError::UnsupportedSchemaVersion { found: 99, .. }
- );
-
- fs::write(&path, "schema_version = 1\n").expect("write");
- assert!(
- Config::load_from_path(&path).is_ok(),
- "v1 should still load"
- );
- }
-
- #[test]
- fn set_gesture_direction_upgrades_single_to_gesture() {
- let mut cfg = Config::default();
- // Start from a Single binding, then bind a swipe direction.
- cfg.set_binding(
- "2b042",
- ButtonId::Back,
- Binding::Single(Action::BrowserBack),
- );
- cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
-
- match cfg.bindings_for("2b042").get(&ButtonId::Back) {
- Some(Binding::Gesture(map)) => {
- // The prior single action is preserved as the Click entry.
- assert_eq!(
- map.get(&GestureDirection::Click),
- Some(&Action::BrowserBack)
- );
- assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
- }
- other => panic!("expected Gesture after upgrade, got {other:?}"),
- }
- }
-
- #[test]
- fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
- // Binding one direction on a never-configured gesture button must still
- // persist a `Click`, so the click projection is the canonical default
- // rather than `Action::None` (which reads as a no-op press).
- let mut cfg = Config::default();
- cfg.set_gesture_direction(
- "2b042",
- ButtonId::GestureButton,
- GestureDirection::Up,
- Action::Copy,
- );
-
- match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
- Some(Binding::Gesture(map)) => {
- assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
- assert_eq!(
- map.get(&GestureDirection::Click),
- Some(&crate::binding::default_gesture_binding(
- GestureDirection::Click
- )),
- "a fresh gesture button must seed a Click from its default"
- );
- }
- other => panic!("expected Gesture, got {other:?}"),
- }
- }
-
- #[test]
- fn gesture_owner_defaults_to_hidpp_button_yields_to_oshook_and_can_be_off() {
- let mut cfg = Config::default();
- // Default: the dedicated HID++ gesture button owns the gesture role even with no config.
- assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
-
- // A dedicated HID++ gesture binding keeps it the owner.
- cfg.set_gesture_direction(
- "2b042",
- ButtonId::GestureButton,
- GestureDirection::Up,
- Action::MissionControl,
- );
- assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
-
- // An explicit OS-hook gesture button takes the role over.
- cfg.set_binding(
- "2b042",
- ButtonId::Forward,
- Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
- );
- assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
-
- // Turning gestures off explicitly yields `None` (not the HID++ button default).
- let mut off = Config::default();
- off.disable_gestures("2b042");
- assert_eq!(off.gesture_owner("2b042"), None);
- }
-
- #[test]
- fn set_gesture_owner_records_owner_without_destroying_other_maps() {
- let mut cfg = Config::default();
- // Customize the dedicated HID++ gesture button's Up swipe; it is the (inferred) owner.
- cfg.set_gesture_direction(
- "2b042",
- ButtonId::GestureButton,
- GestureDirection::Up,
- Action::Copy,
- );
- assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
-
- // Promote Back: the owner becomes Back explicitly; the HID++ gesture button keeps
- // its full gesture map (no destructive demotion).
- cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
- cfg.set_gesture_owner("2b042", ButtonId::Back);
- assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
-
- let bindings = cfg.bindings_for("2b042");
- // Back is a full five-direction gesture button: its prior single action
- // stays as Click, and the swipe arms are seeded from defaults.
- match bindings.get(&ButtonId::Back) {
- Some(Binding::Gesture(map)) => {
- assert_eq!(
- map.get(&GestureDirection::Click),
- Some(&Action::BrowserBack)
- );
- assert_eq!(
- map.get(&GestureDirection::Up),
- Some(&default_gesture_binding(GestureDirection::Up)),
- "a promoted button gets full default arms"
- );
- }
- other => panic!("expected Back to be a gesture binding, got {other:?}"),
- }
- // The HID++ gesture button's customized map survived the switch intact.
- match bindings.get(&ButtonId::GestureButton) {
- Some(Binding::Gesture(map)) => {
- assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
- }
- other => panic!("expected the HID++ gesture button map preserved, got {other:?}"),
- }
-
- // Switching back restores the user's customization, not defaults
- // (regression guard: owner-switch used to discard the swipe arms).
- cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
- assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
- match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
- Some(Binding::Gesture(map)) => {
- assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
- }
- other => panic!("expected preserved gesture map, got {other:?}"),
- }
- }
-
- #[test]
- fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
- let mut cfg = Config::default();
- // The dedicated HID++ gesture button gets the full default direction map.
- cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
- match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
- Some(Binding::Gesture(map)) => {
- for dir in GestureDirection::ALL {
- assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
- }
- }
- other => panic!("expected full default gesture map, got {other:?}"),
- }
-
- // A fresh OS-hook button also gets all five directions, not just a Click:
- // its native action stays as Click, and the swipe arms are defaults — so
- // the GUI's shown defaults are exactly what the runtime dispatches.
- cfg.set_gesture_owner("2b042", ButtonId::Forward);
- match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
- Some(Binding::Gesture(map)) => {
- assert_eq!(
- map.get(&GestureDirection::Click),
- Some(&default_binding(ButtonId::Forward))
- );
- for dir in [
- GestureDirection::Up,
- GestureDirection::Down,
- GestureDirection::Left,
- GestureDirection::Right,
- ] {
- assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
- }
- }
- other => panic!("expected full gesture map for Forward, got {other:?}"),
- }
- }
-
- #[test]
- fn disable_gestures_turns_off_without_destroying_maps() {
- let mut cfg = Config::default();
- cfg.set_gesture_direction(
- "2b042",
- ButtonId::GestureButton,
- GestureDirection::Up,
- Action::Copy,
- );
- cfg.disable_gestures("2b042");
- // Off, but the HID++ gesture button's customized map is preserved (re-enabling
- // restores it rather than resurrecting a wiped default).
- assert_eq!(cfg.gesture_owner("2b042"), None);
- match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
- Some(Binding::Gesture(map)) => {
- assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
- }
- other => panic!("expected the gesture map preserved while off, got {other:?}"),
- }
- }
-
- #[test]
- fn gesture_owner_field_roundtrips_as_a_scalar() {
- let mut cfg = Config::default();
- cfg.set_gesture_owner("2b042", ButtonId::Back); // explicit button
- cfg.disable_gestures("4082d"); // explicit off
-
- let parsed = write_and_read(&cfg);
- assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
- assert_eq!(parsed.gesture_owner("4082d"), None);
-
- // The custom codec keeps it a bare TOML string (a nested table would risk
- // a value-after-table serialization error, since `bindings` is a table).
- let body = toml::to_string_pretty(&cfg).expect("serialize");
- assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
- assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
- }
-
- #[test]
- fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
- // A hand-edit typo in gesture_owner must NOT fail the whole-document parse
- // (which would revert every device's settings to defaults). It degrades
- // to "infer" while the rest of the device config survives.
- let toml = "\
-schema_version = 2
-
-[devices.2b042]
-gesture_owner = \"bogus\"
-
-[devices.2b042.bindings]
-Back = \"Copy\"
-";
- let dir = tempfile::tempdir().expect("tempdir");
- let path = dir.path().join("config.toml");
- fs::write(&path, toml).expect("write");
-
- let cfg =
- Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
- // The rest of the device config survived...
- assert_eq!(
- cfg.bindings_for("2b042").get(&ButtonId::Back),
- Some(&Binding::Single(Action::Copy))
- );
- // ...and the bad owner degraded to inference (HID++ button default here).
- assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
- }
-}
diff --git a/crates/openlogi-core/src/config/device.rs b/crates/openlogi-core/src/config/device.rs
index 9d73ae37b0b1453bbf781840da5a6bb759fdd478..d71870af15c450fb0f9eef2469720bd307e1b20a 100644
--- a/crates/openlogi-core/src/config/device.rs
+++ b/crates/openlogi-core/src/config/device.rs
@@ -7,10 +7,11 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::settings::{
- GestureOwner, Lighting, ScrollResolution, SmartShift, deserialize_gesture_owner,
+ CameraControls, GestureOwner, LightSettings, Lighting, ScrollResolution, SmartShift,
+ deserialize_gesture_owner,
};
use crate::binding::{Action, Binding, ButtonId, GestureDirection};
-use crate::device::{Capabilities, DeviceKind, DeviceModelInfo};
+use crate::device::{Capabilities, DeviceKind, DeviceModelInfo, LightCapabilities};
/// Last-known identity of a device, captured while it was online so the UI can
/// render its card and the *correct* config panels before any live HID++ probe
@@ -43,6 +44,18 @@ pub struct DeviceIdentity {
/// Configuration capabilities measured from the device's HID++ feature
/// table. This is the field that keeps a sleeping mouse's panels visible.
pub capabilities: Capabilities,
+ /// Standalone-light controls measured by its protocol driver, if this is
+ /// a non-HID++ light. Old configs omit this field.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub light_capabilities: Option<LightCapabilities>,
+ /// Standalone driver family that produced this identity, when applicable.
+ /// Old configs and HID++ devices omit it.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub driver_id: Option<String>,
+ /// Optional model-level identity in the OpenLogi asset registry. This is
+ /// not a physical-device key and never contains a serial or OS node id.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub registry_model_id: Option<String>,
}
/// Settings scoped to a single physical device.
@@ -97,10 +110,25 @@ pub struct DeviceConfig {
/// until the user changes it, so it stays out of `config.toml` otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lighting: Option<Lighting>,
+ /// Per-device standalone-light settings. Separate from [`Self::lighting`],
+ /// which is the existing HID++ keyboard RGB configuration.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub light: Option<LightSettings>,
/// Per-device SmartShift wheel configuration, re-applied on reconnect for
/// the same reason as [`Self::dpi`]. `None` until the user changes it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smartshift: Option<SmartShift>,
+ /// Per-webcam UVC image controls (brightness/contrast/…). `None` until the
+ /// user adjusts one, so it stays out of `config.toml` otherwise.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub camera_controls: Option<CameraControls>,
+ /// User-saved camera profiles (name → control snapshot). Built-in profiles
+ /// (Default / Streaming / Video call) live in the GUI, not here.
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub camera_profiles: BTreeMap<String, CameraControls>,
+ /// The camera profile last applied from the GUI, highlighted on reopen.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub camera_profile: Option<String>,
/// Invert this device's scroll-wheel direction relative to the OS setting
/// (issue #126): on, a wheel tick scrolls the opposite way, so a user who
/// keeps macOS "natural scrolling" for the trackpad can have a traditional
@@ -113,6 +141,18 @@ pub struct DeviceConfig {
/// current resolution unmanaged and omits the field from `config.toml`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scroll_resolution: Option<ScrollResolution>,
+ /// Physical config keys of pointing devices that follow this keyboard's
+ /// host switch channel. The relationship is keyboard-initiated: pressing
+ /// one of this device's host keys switches every listed target first, then
+ /// lets the keyboard leave the current host.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub host_switch_targets: Vec<String>,
+ /// Keyboard Fn-lock state (HID++ fn inversion, `0x40a2`/`0x40a3`): `true`
+ /// means the F-row sends F1–F12 without holding Fn. The state lives in
+ /// device RAM per host, so the agent re-applies it on reconnect like
+ /// [`Self::dpi`]. `None` means "never set — leave the keyboard alone".
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fn_lock: Option<bool>,
}
/// `skip_serializing_if` helper for plain `bool` fields whose default is
@@ -158,11 +198,23 @@ struct RawDeviceConfig {
#[serde(default)]
lighting: Option<Lighting>,
#[serde(default)]
+ light: Option<LightSettings>,
+ #[serde(default)]
smartshift: Option<SmartShift>,
#[serde(default)]
+ camera_controls: Option<CameraControls>,
+ #[serde(default)]
+ camera_profiles: BTreeMap<String, CameraControls>,
+ #[serde(default)]
+ camera_profile: Option<String>,
+ #[serde(default)]
invert_scroll: bool,
#[serde(default)]
scroll_resolution: Option<ScrollResolution>,
+ #[serde(default)]
+ host_switch_targets: Vec<String>,
+ #[serde(default)]
+ fn_lock: Option<bool>,
}
impl From<RawDeviceConfig> for DeviceConfig {
@@ -204,9 +256,38 @@ impl From<RawDeviceConfig> for DeviceConfig {
dpi_presets: raw.dpi_presets,
dpi: raw.dpi,
lighting: raw.lighting,
+ light: raw.light,
smartshift: raw.smartshift,
+ camera_controls: raw.camera_controls,
+ camera_profiles: raw.camera_profiles,
+ camera_profile: raw.camera_profile,
invert_scroll: raw.invert_scroll,
scroll_resolution: raw.scroll_resolution,
+ host_switch_targets: raw.host_switch_targets,
+ fn_lock: raw.fn_lock,
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::DeviceConfig;
+
+ #[test]
+ fn host_switch_targets_round_trip_as_physical_keys() -> Result<(), Box<dyn std::error::Error>> {
+ let config: DeviceConfig = toml::from_str(
+ r#"host_switch_targets = [
+ "receiver:keyboard:slot:1",
+ "receiver:mouse:slot:2",
+]"#,
+ )?;
+
+ assert_eq!(
+ config.host_switch_targets,
+ ["receiver:keyboard:slot:1", "receiver:mouse:slot:2"]
+ );
+ let serialized = toml::to_string(&config)?;
+ assert!(serialized.contains("host_switch_targets"));
+ Ok(())
+ }
+}
diff --git a/crates/openlogi-core/src/config/key_trigger.rs b/crates/openlogi-core/src/config/key_trigger.rs
new file mode 100644
index 0000000000000000000000000000000000000000..6ef49517db8c8d14817e77f7a19d091e5d3de2ac
--- /dev/null
+++ b/crates/openlogi-core/src/config/key_trigger.rs
@@ -0,0 +1,180 @@
+//! Keyboard key triggers and the global keyboard-bindings section.
+
+use serde::{Deserialize, Serialize};
+
+use crate::binding::Action;
+
+/// Detectable modifier state for a keyboard trigger. A leaf-level duplicate of
+/// `openlogi_hook::KeyModifiers` — core must not depend on hook, so the four
+/// bools are mirrored here and converted at the agent boundary (which depends
+/// on both crates). `Fn` is absent: firmware-internal, unusable as a trigger
+/// (function-key-remapper spec, Appendix A).
+#[derive(
+ Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
+)]
+#[expect(
+ clippy::struct_excessive_bools,
+ reason = "four independent modifier flags mirrored from the OS hook"
+)]
+pub struct KeyModifiers {
+ /// Shift held.
+ pub shift: bool,
+ /// Control held.
+ pub control: bool,
+ /// Option/Alt held.
+ pub option: bool,
+ /// Command held.
+ pub command: bool,
+}
+
+impl KeyModifiers {
+ /// True when no modifiers are held.
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ !self.shift && !self.control && !self.option && !self.command
+ }
+}
+
+/// A keyboard trigger: a keycode plus an optional modifier mask. The parse
+/// format is `[mod+]+key`, e.g. `"f1"`, `"shift+cmd+f5"`. Modifier names:
+/// `shift`, `control` (alias `ctrl`), `option` (alias `alt`), `command`
+/// (alias `cmd`). Key names: `esc`, `f1`..`f19` (macOS virtual keycodes).
+///
+/// Serializes as its string form (via `Display`) so it can be a TOML map key:
+/// `[keyboard.bindings]` keys are `"f1"`, `"shift+f2"`, etc.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct KeyTrigger {
+ /// Platform virtual keycode (macOS `kVK_*`).
+ pub keycode: u16,
+ /// Modifier mask that must also be held.
+ pub modifiers: KeyModifiers,
+}
+
+impl std::fmt::Display for KeyTrigger {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let mut parts: Vec<&str> = Vec::new();
+ let m = &self.modifiers;
+ if m.shift {
+ parts.push("shift");
+ }
+ if m.control {
+ parts.push("control");
+ }
+ if m.option {
+ parts.push("option");
+ }
+ if m.command {
+ parts.push("command");
+ }
+ parts.push(keycode_to_name(self.keycode).ok_or(std::fmt::Error)?);
+ write!(f, "{}", parts.join("+"))
+ }
+}
+
+/// Reverse lookup for the parse table — needed so `Display` can render a
+/// parsed trigger back to its canonical name.
+fn keycode_to_name(code: u16) -> Option<&'static str> {
+ Some(match code {
+ 0x35 => "esc",
+ 0x7A => "f1",
+ 0x78 => "f2",
+ 0x63 => "f3",
+ 0x76 => "f4",
+ 0x60 => "f5",
+ 0x61 => "f6",
+ 0x62 => "f7",
+ 0x64 => "f8",
+ 0x65 => "f9",
+ 0x6D => "f10",
+ 0x67 => "f11",
+ 0x6F => "f12",
+ 0x69 => "f13",
+ 0x6B => "f14",
+ 0x71 => "f15",
+ 0x6A => "f16",
+ 0x40 => "f17",
+ 0x4F => "f18",
+ 0x50 => "f19",
+ _ => return None,
+ })
+}
+
+// String-form serde so KeyTrigger can be a TOML map key.
+impl Serialize for KeyTrigger {
+ fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
+ s.collect_str(self)
+ }
+}
+impl<'de> Deserialize<'de> for KeyTrigger {
+ fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
+ let s = String::deserialize(d)?;
+ s.parse().map_err(serde::de::Error::custom)
+ }
+}
+
+/// Error returned by [`KeyTrigger`]'s `FromStr` impl.
+#[derive(Debug)]
+pub struct ParseTriggerError(pub String);
+impl std::fmt::Display for ParseTriggerError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "invalid key trigger: {}", self.0)
+ }
+}
+impl std::error::Error for ParseTriggerError {}
+
+impl std::str::FromStr for KeyTrigger {
+ type Err = ParseTriggerError;
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let mut mods = KeyModifiers::default();
+ let parts: Vec<&str> = s.split('+').map(str::trim).collect();
+ if parts.is_empty() || parts.iter().any(|p| p.is_empty()) {
+ return Err(ParseTriggerError("empty segment".into()));
+ }
+ // All but the last segment must be modifiers; the last is the key.
+ let (mod_parts, key_part) = parts.split_at(parts.len() - 1);
+ for part in mod_parts {
+ match part.to_ascii_lowercase().as_str() {
+ "shift" => mods.shift = true,
+ "control" | "ctrl" => mods.control = true,
+ "option" | "alt" => mods.option = true,
+ "command" | "cmd" => mods.command = true,
+ other => return Err(ParseTriggerError(format!("unknown modifier '{other}'"))),
+ }
+ }
+ let keycode = match key_part[0].to_ascii_lowercase().as_str() {
+ "esc" => 0x35,
+ "f1" => 0x7A,
+ "f2" => 0x78,
+ "f3" => 0x63,
+ "f4" => 0x76,
+ "f5" => 0x60,
+ "f6" => 0x61,
+ "f7" => 0x62,
+ "f8" => 0x64,
+ "f9" => 0x65,
+ "f10" => 0x6D,
+ "f11" => 0x67,
+ "f12" => 0x6F,
+ "f13" => 0x69,
+ "f14" => 0x6B,
+ "f15" => 0x71,
+ "f16" => 0x6A,
+ "f17" => 0x40,
+ "f18" => 0x4F,
+ "f19" => 0x50,
+ other => return Err(ParseTriggerError(format!("unknown key '{other}'"))),
+ };
+ Ok(KeyTrigger {
+ keycode,
+ modifiers: mods,
+ })
+ }
+}
+
+/// The top-level `[keyboard]` table. Bindings are keyed by [`KeyTrigger`].
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct KeyboardConfig {
+ /// Function-key trigger → action map for the remapper.
+ #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
+ pub bindings: std::collections::HashMap<KeyTrigger, Action>,
+}
diff --git a/crates/openlogi-core/src/config/settings.rs b/crates/openlogi-core/src/config/settings.rs
index cc50d01910d25a1cec213a809db1456d6a7ab946..a2c2f8b14e84037915e4f6c796d13b5eaf36ae62 100644
--- a/crates/openlogi-core/src/config/settings.rs
+++ b/crates/openlogi-core/src/config/settings.rs
@@ -3,6 +3,8 @@
//! [`GestureOwner`], plus
//! their serde `default_*` / `deserialize_*` helpers.
+use std::collections::BTreeMap;
+
use serde::{Deserialize, Serialize};
use crate::binding::ButtonId;
@@ -231,6 +233,73 @@ pub struct Lighting {
pub brightness: u8,
}
+/// Persisted settings for a standalone light such as Logitech Litra.
+///
+/// Brightness is stored as a normalized percentage so the same config shape
+/// works for lumen-based, percentage-based, and stepped light protocols. The
+/// selected driver maps it to its native range when applying the setting.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct LightSettings {
+ /// Whether the light should be on.
+ #[serde(default = "default_true")]
+ pub enabled: bool,
+ /// Link power to aggregate host-camera activity. This is a policy setting:
+ /// brightness, colour temperature, and the persisted manual power choice
+ /// remain independent from the transient effective power state.
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub auto_camera: bool,
+ /// Brightness across the device's advertised range.
+ #[serde(
+ default = "default_light_brightness",
+ deserialize_with = "deserialize_brightness"
+ )]
+ pub brightness_percent: u8,
+ /// Desired colour temperature, when the device supports it.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub temperature_kelvin: Option<u16>,
+ /// Optional colour for a driver that exposes RGB controls.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub color: Option<Rgb>,
+}
+
+const fn default_light_brightness() -> u8 {
+ 100
+}
+
+impl Default for LightSettings {
+ fn default() -> Self {
+ Self {
+ enabled: true,
+ auto_camera: false,
+ brightness_percent: default_light_brightness(),
+ temperature_kelvin: None,
+ color: None,
+ }
+ }
+}
+
+impl LightSettings {
+ /// Create settings with a normalized brightness percentage.
+ #[must_use]
+ pub fn new(enabled: bool, brightness_percent: u8, temperature_kelvin: Option<u16>) -> Self {
+ Self {
+ enabled,
+ auto_camera: false,
+ brightness_percent,
+ temperature_kelvin,
+ color: None,
+ }
+ }
+}
+
+#[allow(
+ clippy::trivially_copy_pass_by_ref,
+ reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
+)]
+const fn is_false(value: &bool) -> bool {
+ !*value
+}
+
impl Default for Lighting {
fn default() -> Self {
Self {
@@ -278,6 +347,17 @@ where
.unwrap_or(Rgb::WHITE))
}
+/// Per-webcam UVC controls, keyed by control name (`brightness`, `focus`,
+/// `focus_auto`, …). Each value is the raw device unit (its scale comes from
+/// the camera's own min/max); auto toggles store 0/1. Persisted so values
+/// survive an unplug or reboot — the GUI re-applies them over USB when the
+/// camera is next viewed, since the hardware only retains them until it loses
+/// power. Serializes to the same TOML table the earlier fixed-field struct
+/// wrote, so existing saved controls load unchanged.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct CameraControls(pub BTreeMap<String, i32>);
+
/// Vertical wheel reporting resolution for HID++ `0x2121 HiResWheel`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
diff --git a/crates/openlogi-core/src/config/tests.rs b/crates/openlogi-core/src/config/tests.rs
new file mode 100644
index 0000000000000000000000000000000000000000..574efff8f32fd782502f349011d31cb675b20980
--- /dev/null
+++ b/crates/openlogi-core/src/config/tests.rs
@@ -0,0 +1,1027 @@
+//! Config load/save and binding-map tests.
+
+#![allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
+
+use std::assert_matches;
+
+use super::*;
+use crate::binding::{default_binding, default_gesture_binding};
+
+fn write_and_read(config: &Config) -> Config {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("config.toml");
+ config.save_to_path(&path).expect("save");
+ Config::load_from_path(&path).expect("load")
+}
+
+#[test]
+fn key_trigger_parses_bare_and_modified() {
+ // Bare function key — F1 is macOS keycode 0x7A.
+ let t: KeyTrigger = "f1".parse().expect("parse key trigger");
+ assert_eq!(t.keycode, 0x7A);
+ assert!(t.modifiers.is_empty());
+
+ // Modifier-qualified, in any order, with aliases.
+ let t: KeyTrigger = "shift+cmd+f5".parse().expect("parse key trigger");
+ assert_eq!(t.keycode, 0x60); // F5
+ assert!(t.modifiers.shift && t.modifiers.command);
+ assert!(!t.modifiers.control && !t.modifiers.option);
+
+ let t: KeyTrigger = "ctrl+alt+f2".parse().expect("parse key trigger");
+ assert!(t.modifiers.control && t.modifiers.option);
+
+ // Esc.
+ assert_eq!(
+ "esc"
+ .parse::<KeyTrigger>()
+ .expect("parse key trigger")
+ .keycode,
+ 0x35
+ );
+}
+
+#[test]
+fn key_trigger_parses_and_displays_extended_function_keys() {
+ let f13: KeyTrigger = "f13".parse().expect("parse key trigger");
+ let f17: KeyTrigger = "command+f17".parse().expect("parse key trigger");
+ let f19: KeyTrigger = "f19".parse().expect("parse key trigger");
+
+ assert_eq!(f13.keycode, 0x69);
+ assert_eq!(f17.keycode, 0x40);
+ assert_eq!(f17.to_string(), "command+f17");
+ assert_eq!(f19.keycode, 0x50);
+ assert_eq!(f19.to_string(), "f19");
+}
+
+#[test]
+fn key_trigger_rejects_unknown() {
+ assert!("f99".parse::<KeyTrigger>().is_err());
+ assert!("shift+".parse::<KeyTrigger>().is_err());
+ assert!("".parse::<KeyTrigger>().is_err());
+}
+
+#[test]
+fn keyboard_section_roundtrips_through_config() {
+ let mut config = Config::default();
+ config.keyboard.bindings.insert(
+ "f1".parse().expect("parse key trigger"),
+ Action::TypeText("hello".into()),
+ );
+ config.keyboard.bindings.insert(
+ "shift+f2".parse().expect("parse key trigger"),
+ Action::VolumeUp,
+ );
+ config.keyboard.bindings.insert(
+ "f17".parse().expect("parse key trigger"),
+ Action::MissionControl,
+ );
+
+ let roundtripped = write_and_read(&config);
+ assert_eq!(roundtripped.keyboard.bindings.len(), 3);
+ assert_eq!(
+ roundtripped
+ .keyboard
+ .bindings
+ .get(&"f1".parse::<KeyTrigger>().expect("parse key trigger")),
+ Some(&Action::TypeText("hello".into()))
+ );
+ assert_eq!(
+ roundtripped
+ .keyboard
+ .bindings
+ .get(&"f17".parse::<KeyTrigger>().expect("parse key trigger")),
+ Some(&Action::MissionControl)
+ );
+}
+
+#[test]
+fn set_keyboard_binding_inserts_and_clears() {
+ let mut config = Config::default();
+ let f1: KeyTrigger = "f1".parse().expect("parse key trigger");
+
+ // Insert.
+ config.set_keyboard_binding(f1.clone(), Some(Action::VolumeUp));
+ assert_eq!(config.keyboard_bindings().get(&f1), Some(&Action::VolumeUp));
+ assert_eq!(config.keyboard_bindings().len(), 1);
+
+ // Overwrite.
+ config.set_keyboard_binding(f1.clone(), Some(Action::MuteVolume));
+ assert_eq!(
+ config.keyboard_bindings().get(&f1),
+ Some(&Action::MuteVolume)
+ );
+ assert_eq!(config.keyboard_bindings().len(), 1);
+
+ // Clear via None.
+ config.set_keyboard_binding(f1.clone(), None);
+ assert!(config.keyboard_bindings().get(&f1).is_none());
+ assert!(config.keyboard_bindings().is_empty());
+}
+
+#[test]
+fn missing_file_yields_default() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("nonexistent.toml");
+ let cfg = Config::load_from_path(&path).expect("load");
+ assert_eq!(cfg.schema_version, SCHEMA_VERSION);
+ assert!(cfg.devices.is_empty());
+}
+
+#[test]
+fn lighting_roundtrips_per_device() {
+ let mut cfg = Config::default();
+ cfg.set_lighting(
+ "g513",
+ Lighting {
+ enabled: true,
+ color: "00aabb".parse().expect("valid hex"),
+ brightness: 75,
+ },
+ );
+ let restored = write_and_read(&cfg);
+ assert_eq!(
+ restored.lighting("g513"),
+ Some(Lighting {
+ enabled: true,
+ color: "00aabb".parse().expect("valid hex"),
+ brightness: 75,
+ })
+ );
+ assert_eq!(restored.lighting("absent"), None);
+}
+
+#[test]
+fn standalone_light_settings_roundtrip_per_device() {
+ let mut cfg = Config::default();
+ cfg.set_light(
+ "raw:046d:c900:ff43:0202:serial:glow",
+ LightSettings {
+ enabled: false,
+ auto_camera: false,
+ brightness_percent: 65,
+ temperature_kelvin: Some(4600),
+ color: None,
+ },
+ );
+ let restored = write_and_read(&cfg);
+ assert_eq!(
+ restored.light("raw:046d:c900:ff43:0202:serial:glow"),
+ Some(LightSettings {
+ enabled: false,
+ auto_camera: false,
+ brightness_percent: 65,
+ temperature_kelvin: Some(4600),
+ color: None,
+ })
+ );
+ assert_eq!(restored.light("absent"), None);
+}
+
+#[test]
+fn standalone_light_brightness_is_clamped_on_load() {
+ let cfg: Config = toml::from_str(
+ r"
+ schema_version = 3
+ [devices.glow.light]
+ enabled = true
+ brightness_percent = 255
+ ",
+ )
+ .expect("light config loads");
+ assert_eq!(
+ cfg.light("glow").map(|light| light.brightness_percent),
+ Some(100)
+ );
+}
+
+#[test]
+fn standalone_light_camera_automation_roundtrips() {
+ let mut cfg = Config::default();
+ cfg.set_light(
+ "raw:046d:c900:ff43:0202:serial:glow",
+ LightSettings {
+ enabled: true,
+ auto_camera: true,
+ brightness_percent: 80,
+ temperature_kelvin: Some(5000),
+ color: None,
+ },
+ );
+
+ let restored = write_and_read(&cfg);
+ assert_eq!(
+ restored
+ .light("raw:046d:c900:ff43:0202:serial:glow")
+ .map(|light| light.auto_camera),
+ Some(true)
+ );
+}
+
+#[test]
+fn unparseable_lighting_color_falls_back_to_white() {
+ let cfg: Config = toml::from_str(
+ r#"
+ schema_version = 3
+ [devices.g513.lighting]
+ enabled = true
+ color = "red"
+ brightness = 50
+ "#,
+ )
+ .expect("config with a bad color still loads");
+ assert_eq!(
+ cfg.lighting("g513").map(|l| l.color),
+ Some(crate::color::Rgb::WHITE)
+ );
+}
+
+#[test]
+fn hash_prefixed_lighting_color_migrates_to_canonical_hex() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("config.toml");
+ fs::write(
+ &path,
+ r##"
+ schema_version = 3
+ [devices.g513.lighting]
+ enabled = true
+ color = "#ff0000"
+ brightness = 50
+ "##,
+ )
+ .expect("write config");
+
+ let cfg = Config::load_from_path(&path).expect("load hash-prefixed color");
+ assert_eq!(
+ cfg.lighting("g513").map(|lighting| lighting.color),
+ Some(crate::color::Rgb::new(0xff, 0x00, 0x00))
+ );
+
+ cfg.save_to_path(&path).expect("save canonical color");
+ let saved = fs::read_to_string(path).expect("read saved config");
+ assert!(saved.contains("color = \"ff0000\""));
+ assert!(!saved.contains("color = \"#"));
+}
+
+#[test]
+fn dpi_roundtrips_per_device() {
+ let mut cfg = Config::default();
+ cfg.set_dpi("2b042", 1600);
+ let restored = write_and_read(&cfg);
+ assert_eq!(restored.dpi("2b042"), Some(1600));
+ assert_eq!(restored.dpi("absent"), None);
+}
+
+#[test]
+fn smartshift_roundtrips_per_device() {
+ let mut cfg = Config::default();
+ cfg.set_smartshift(
+ "2b042",
+ SmartShift {
+ mode: WheelMode::Ratchet,
+ auto_disengage: 16,
+ tunable_torque: 30,
+ },
+ );
+ let restored = write_and_read(&cfg);
+ assert_eq!(
+ restored.smartshift("2b042"),
+ Some(SmartShift {
+ mode: WheelMode::Ratchet,
+ auto_disengage: 16,
+ tunable_torque: 30,
+ })
+ );
+ assert_eq!(restored.smartshift("absent"), None);
+}
+
+#[test]
+fn invert_scroll_roundtrips_per_device() {
+ let mut cfg = Config::default();
+ // Default is the native direction for any device, present or not.
+ assert!(!cfg.invert_scroll("2b042"));
+ cfg.set_invert_scroll("2b042", true);
+ let restored = write_and_read(&cfg);
+ assert!(restored.invert_scroll("2b042"));
+ assert!(!restored.invert_scroll("absent"));
+}
+
+#[test]
+fn default_invert_scroll_is_omitted_from_toml() {
+ // A device block with only the default (false) invert_scroll must not
+ // emit the field — `skip_serializing_if` keeps configs clean.
+ let mut cfg = Config::default();
+ cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
+ cfg.set_invert_scroll("2b042", false);
+ let body = toml::to_string_pretty(&cfg).expect("serialize");
+ assert!(
+ !body.contains("invert_scroll"),
+ "default invert_scroll should be omitted: {body}"
+ );
+}
+
+#[test]
+fn scroll_resolution_roundtrips_all_three_states() {
+ let mut cfg = Config::default();
+ assert_eq!(cfg.scroll_resolution("mouse"), None);
+
+ cfg.set_scroll_resolution("mouse", Some(ScrollResolution::Low));
+ let low = write_and_read(&cfg);
+ assert_eq!(low.scroll_resolution("mouse"), Some(ScrollResolution::Low));
+
+ cfg.set_scroll_resolution("mouse", Some(ScrollResolution::High));
+ let high = write_and_read(&cfg);
+ assert_eq!(
+ high.scroll_resolution("mouse"),
+ Some(ScrollResolution::High)
+ );
+
+ cfg.set_scroll_resolution("mouse", None);
+ let unmanaged = write_and_read(&cfg);
+ assert_eq!(unmanaged.scroll_resolution("mouse"), None);
+}
+
+#[test]
+fn unset_scroll_resolution_is_omitted_from_toml() {
+ let mut cfg = Config::default();
+ cfg.set_binding("mouse", ButtonId::Back, Binding::Single(Action::Copy));
+ cfg.set_scroll_resolution("mouse", Some(ScrollResolution::Low));
+ cfg.set_scroll_resolution("mouse", None);
+
+ let body = toml::to_string_pretty(&cfg).expect("serialize");
+ assert!(
+ !body.contains("scroll_resolution"),
+ "unset scroll resolution should be omitted: {body}"
+ );
+}
+
+#[test]
+fn config_without_scroll_resolution_loads_as_unmanaged() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("config.toml");
+ fs::write(
+ &path,
+ r"
+ schema_version = 3
+ [devices.mouse]
+ invert_scroll = true
+ ",
+ )
+ .expect("write config");
+
+ let cfg = Config::load_from_path(&path).expect("load existing config");
+ assert_eq!(cfg.scroll_resolution("mouse"), None);
+ assert!(cfg.invert_scroll("mouse"));
+}
+
+#[test]
+fn bindings_roundtrip_per_device() {
+ let mut cfg = Config::default();
+ cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
+ cfg.set_binding(
+ "2b042",
+ ButtonId::DpiToggle,
+ Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
+ modifiers: crate::binding::KeyCombo::MOD_CMD,
+ key_code: 0x23, // kVK_ANSI_P
+ display: "⌘P".into(),
+ })),
+ );
+ cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
+
+ let parsed = write_and_read(&cfg);
+
+ // Per-device isolation.
+ let a = parsed.bindings_for("2b042");
+ assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
+ assert_eq!(
+ a.get(&ButtonId::DpiToggle),
+ Some(&Binding::Single(Action::CustomShortcut(
+ crate::binding::KeyCombo {
+ modifiers: crate::binding::KeyCombo::MOD_CMD,
+ key_code: 0x23,
+ display: "⌘P".into(),
+ }
+ )))
+ );
+
+ let b = parsed.bindings_for("4082d");
+ assert_eq!(
+ b.get(&ButtonId::Back),
+ Some(&Binding::Single(Action::Paste))
+ );
+ assert_eq!(b.len(), 1, "device b should only see its own bindings");
+
+ // Unknown device returns empty map without panic.
+ assert!(parsed.bindings_for("deadbeef").is_empty());
+}
+
+#[test]
+fn human_readable_toml_layout() {
+ let mut cfg = Config::default();
+ cfg.set_binding(
+ "2b042",
+ ButtonId::Back,
+ Binding::Single(Action::BrowserBack),
+ );
+ let body = toml::to_string_pretty(&cfg).expect("serialize");
+
+ // The key only contains [A-Za-z0-9_], so TOML emits it as a bare-word
+ // table key (no surrounding quotes). The test asserts the observable
+ // structure rather than locking in a specific quoting.
+ assert!(body.contains("schema_version = 3"), "got: {body}");
+ assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
+ // A `Single` binding serializes byte-identically to the pre-v2 bare
+ // `Action`, so the leaf line is unchanged.
+ assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
+}
+
+#[test]
+fn dpi_presets_roundtrip_per_device() {
+ let mut cfg = Config::default();
+ cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
+ cfg.set_dpi_presets("4082d", vec![400, 1600]);
+
+ let parsed = write_and_read(&cfg);
+
+ assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
+ assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
+ assert!(parsed.dpi_presets("unknown").is_empty());
+}
+
+#[test]
+fn empty_dpi_presets_skip_serialization() {
+ let mut cfg = Config::default();
+ // Add a binding so the device block exists.
+ cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
+ cfg.set_dpi_presets("2b042", vec![800]);
+ cfg.set_dpi_presets("2b042", vec![]); // clear
+
+ let body = toml::to_string_pretty(&cfg).expect("serialize");
+ assert!(
+ !body.contains("dpi_presets"),
+ "empty dpi_presets should be omitted: {body}"
+ );
+}
+
+#[test]
+fn device_identity_roundtrips_and_is_iterable() {
+ use crate::device::{Capabilities, DeviceKind};
+
+ let mut cfg = Config::default();
+ let mouse = DeviceIdentity {
+ display_name: "MX Master 3S".to_string(),
+ model_info: None,
+ codename: None,
+ kind: DeviceKind::Mouse,
+ capabilities: Capabilities {
+ buttons: true,
+ pointer: true,
+ lighting: false,
+ scroll_inversion: false,
+ hires_wheel: true,
+ thumbwheel: false,
+ },
+ light_capabilities: None,
+ driver_id: None,
+ registry_model_id: None,
+ };
+ cfg.set_device_identity("2b034", mouse.clone());
+ // Recording an identity must not disturb unrelated per-device state.
+ cfg.set_binding(
+ "2b034",
+ ButtonId::Back,
+ Binding::Single(Action::BrowserBack),
+ );
+
+ let parsed = write_and_read(&cfg);
+ assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
+ assert_eq!(parsed.device_identity("absent"), None);
+ assert_eq!(
+ parsed.bindings_for("2b034").get(&ButtonId::Back),
+ Some(&Binding::Single(Action::BrowserBack)),
+ "identity must coexist with bindings on the same device block"
+ );
+ assert_eq!(
+ parsed.known_identities().collect::<Vec<_>>(),
+ vec![("2b034", &mouse)]
+ );
+}
+
+#[test]
+fn selected_device_roundtrips() {
+ let mut cfg = Config::default();
+ assert_eq!(cfg.selected_device(), None);
+ cfg.set_selected_device(Some("2b042".into()));
+ let parsed = write_and_read(&cfg);
+ assert_eq!(parsed.selected_device(), Some("2b042"));
+}
+
+#[test]
+fn per_app_overlay_takes_precedence() {
+ let mut cfg = Config::default();
+ cfg.set_binding(
+ "2b042",
+ ButtonId::Back,
+ Binding::Single(Action::BrowserBack),
+ );
+ cfg.set_binding(
+ "2b042",
+ ButtonId::Forward,
+ Binding::Single(Action::BrowserForward),
+ );
+ cfg.set_per_app_binding(
+ "2b042",
+ "com.microsoft.VSCode",
+ ButtonId::Back,
+ Some(Action::Undo),
+ );
+
+ // Global: both buttons are browser nav.
+ let global = cfg.effective_bindings("2b042", None);
+ assert_eq!(
+ global.get(&ButtonId::Back),
+ Some(&Binding::Single(Action::BrowserBack))
+ );
+ assert_eq!(
+ global.get(&ButtonId::Forward),
+ Some(&Binding::Single(Action::BrowserForward))
+ );
+
+ // VSCode: Back overridden (wrapped as Single), Forward inherits.
+ let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
+ assert_eq!(
+ vscode.get(&ButtonId::Back),
+ Some(&Binding::Single(Action::Undo))
+ );
+ assert_eq!(
+ vscode.get(&ButtonId::Forward),
+ Some(&Binding::Single(Action::BrowserForward))
+ );
+
+ // Unrelated app falls through.
+ let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
+ assert_eq!(
+ other.get(&ButtonId::Back),
+ Some(&Binding::Single(Action::BrowserBack))
+ );
+}
+
+#[test]
+fn per_app_binding_removal_prunes_empty_app() {
+ let mut cfg = Config::default();
+ cfg.set_per_app_binding(
+ "2b042",
+ "com.example.App",
+ ButtonId::Back,
+ Some(Action::Copy),
+ );
+ cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
+ assert!(
+ cfg.devices["2b042"].per_app_bindings.is_empty(),
+ "removing last override should prune the app entry"
+ );
+}
+
+#[test]
+fn app_settings_default_omits_block() {
+ let cfg = Config::default();
+ let body = toml::to_string_pretty(&cfg).expect("serialize");
+ assert!(
+ !body.contains("app_settings"),
+ "default app_settings should be omitted: {body}"
+ );
+}
+
+#[test]
+fn app_settings_launch_at_login_roundtrips() {
+ let mut cfg = Config::default();
+ cfg.app_settings.launch_at_login = true;
+ let parsed = write_and_read(&cfg);
+ assert!(parsed.app_settings.launch_at_login);
+}
+
+#[test]
+fn asset_source_preference_roundtrips() {
+ let mut cfg = Config::default();
+ cfg.app_settings.asset_source = AssetSourcePreference::OpenLogi;
+
+ let body = toml::to_string_pretty(&cfg).expect("serialize");
+ let parsed = write_and_read(&cfg);
+
+ assert!(body.contains("asset_source = \"openlogi\""));
+ assert_eq!(
+ parsed.app_settings.asset_source,
+ AssetSourcePreference::OpenLogi
+ );
+}
+
+#[test]
+fn config_without_asset_source_keeps_automatic_selection() {
+ let parsed: Config = toml::from_str(
+ r"
+ schema_version = 3
+ [app_settings]
+ auto_download_assets = false
+ ",
+ )
+ .expect("config predating the asset-source setting loads");
+
+ assert_eq!(
+ parsed.app_settings.asset_source,
+ AssetSourcePreference::Automatic
+ );
+}
+
+#[test]
+fn cleared_selected_device_omits_field() {
+ let mut cfg = Config::default();
+ cfg.set_selected_device(Some("2b042".into()));
+ cfg.set_selected_device(None);
+ let body = toml::to_string_pretty(&cfg).expect("serialize");
+ assert!(
+ !body.contains("selected_device"),
+ "cleared selection should not appear: {body}"
+ );
+}
+
+#[test]
+fn empty_device_block_is_skipped_in_output() {
+ // Inserting then clearing should not leave a [devices."x"] header
+ // with no bindings under it (skip_serializing_if on bindings).
+ let mut cfg = Config::default();
+ cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
+ cfg.devices
+ .get_mut("2b042")
+ .expect("entry")
+ .bindings
+ .clear();
+ let body = toml::to_string_pretty(&cfg).expect("serialize");
+ assert!(
+ !body.contains("Back"),
+ "cleared bindings should not appear: {body}"
+ );
+}
+
+#[test]
+fn migrates_v1_button_and_gesture_bindings() {
+ // A pre-v2 file: split button_bindings + a flat gesture_bindings map.
+ let v1 = "\
+schema_version = 1
+
+[devices.2b042.button_bindings]
+Back = \"BrowserBack\"
+
+[devices.2b042.gesture_bindings]
+Up = \"Copy\"
+Click = \"Paste\"
+";
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("config.toml");
+ fs::write(&path, v1).expect("write");
+
+ // v1 still loads (version <= current) and folds into the merged map.
+ let cfg = Config::load_from_path(&path).expect("load v1");
+ let bindings = cfg.bindings_for("2b042");
+ assert_eq!(
+ bindings.get(&ButtonId::Back),
+ Some(&Binding::Single(Action::BrowserBack))
+ );
+ let mut gesture = BTreeMap::new();
+ gesture.insert(GestureDirection::Up, Action::Copy);
+ gesture.insert(GestureDirection::Click, Action::Paste);
+ assert_eq!(
+ bindings.get(&ButtonId::GestureButton),
+ Some(&Binding::Gesture(gesture))
+ );
+
+ // Saving self-heals to the current shape: stamped version + merged table,
+ // legacy field names gone.
+ let body = toml::to_string_pretty(&cfg).expect("serialize");
+ assert!(body.contains("schema_version = 3"), "got: {body}");
+ assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
+ assert!(!body.contains("button_bindings"), "got: {body}");
+ assert!(!body.contains("gesture_bindings"), "got: {body}");
+}
+
+#[test]
+fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
+ // The data-loss guard: when a legacy single button_bindings[GestureButton]
+ // entry coexists with a gesture_bindings map (reachable via hand-edited
+ // or very old configs), the gesture map must survive — not be shadowed by
+ // the single entry. Mirrors the pre-v2 "gesture entries win" rule.
+ let v1 = "\
+schema_version = 1
+
+[devices.2b042.button_bindings]
+GestureButton = \"MissionControl\"
+
+[devices.2b042.gesture_bindings]
+Up = \"Copy\"
+Down = \"Paste\"
+";
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("config.toml");
+ fs::write(&path, v1).expect("write");
+
+ let cfg = Config::load_from_path(&path).expect("load v1");
+ let mut gesture = BTreeMap::new();
+ gesture.insert(GestureDirection::Up, Action::Copy);
+ gesture.insert(GestureDirection::Down, Action::Paste);
+ assert_eq!(
+ cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
+ Some(&Binding::Gesture(gesture)),
+ "gesture map must win over the legacy single GestureButton entry"
+ );
+}
+
+#[test]
+fn migration_drops_vestigial_lone_gesture_button_single() {
+ // A v1 file with only `button_bindings[GestureButton]` and no
+ // `gesture_bindings` (the pre-gesture-picker shape). That entry never
+ // dispatched in v1 — the gesture button's plain press routes through the
+ // gesture `Click` slot, not the per-button map — so migrating it to a
+ // `Binding::Single` would leave an unreachable entry the GUI hides and the
+ // runtime ignores. It must be dropped, not shadow the gesture path.
+ let v1 = "\
+schema_version = 1
+
+[devices.2b042.button_bindings]
+GestureButton = \"MissionControl\"
+Back = \"BrowserBack\"
+";
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("config.toml");
+ fs::write(&path, v1).expect("write");
+
+ let bindings = Config::load_from_path(&path)
+ .expect("load v1")
+ .bindings_for("2b042");
+ // An ordinary button still migrates to a `Single`...
+ assert_eq!(
+ bindings.get(&ButtonId::Back),
+ Some(&Binding::Single(Action::BrowserBack))
+ );
+ // ...but the vestigial gesture-button single is gone, leaving the button
+ // to fall back to its canonical default rather than an unreachable entry.
+ assert_eq!(bindings.get(&ButtonId::GestureButton), None);
+}
+
+#[test]
+fn rejects_newer_schema_version_but_accepts_v1() {
+ // A future version is rejected loudly; the current and older versions
+ // load (older ones migrate through the shim).
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("config.toml");
+ fs::write(&path, "schema_version = 99\n").expect("write");
+ assert_matches!(
+ Config::load_from_path(&path).expect_err("v99 should fail"),
+ ConfigError::UnsupportedSchemaVersion { found: 99, .. }
+ );
+
+ fs::write(&path, "schema_version = 1\n").expect("write");
+ assert!(
+ Config::load_from_path(&path).is_ok(),
+ "v1 should still load"
+ );
+}
+
+#[test]
+fn set_gesture_direction_upgrades_single_to_gesture() {
+ let mut cfg = Config::default();
+ // Start from a Single binding, then bind a swipe direction.
+ cfg.set_binding(
+ "2b042",
+ ButtonId::Back,
+ Binding::Single(Action::BrowserBack),
+ );
+ cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
+
+ match cfg.bindings_for("2b042").get(&ButtonId::Back) {
+ Some(Binding::Gesture(map)) => {
+ // The prior single action is preserved as the Click entry.
+ assert_eq!(
+ map.get(&GestureDirection::Click),
+ Some(&Action::BrowserBack)
+ );
+ assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
+ }
+ other => panic!("expected Gesture after upgrade, got {other:?}"),
+ }
+}
+
+#[test]
+fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
+ // Binding one direction on a never-configured gesture button must still
+ // persist a `Click`, so the click projection is the canonical default
+ // rather than `Action::None` (which reads as a no-op press).
+ let mut cfg = Config::default();
+ cfg.set_gesture_direction(
+ "2b042",
+ ButtonId::GestureButton,
+ GestureDirection::Up,
+ Action::Copy,
+ );
+
+ match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
+ Some(Binding::Gesture(map)) => {
+ assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
+ assert_eq!(
+ map.get(&GestureDirection::Click),
+ Some(&crate::binding::default_gesture_binding(
+ GestureDirection::Click
+ )),
+ "a fresh gesture button must seed a Click from its default"
+ );
+ }
+ other => panic!("expected Gesture, got {other:?}"),
+ }
+}
+
+#[test]
+fn gesture_owner_defaults_to_hidpp_button_yields_to_oshook_and_can_be_off() {
+ let mut cfg = Config::default();
+ // Default: the dedicated HID++ gesture button owns the gesture role even with no config.
+ assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
+
+ // A dedicated HID++ gesture binding keeps it the owner.
+ cfg.set_gesture_direction(
+ "2b042",
+ ButtonId::GestureButton,
+ GestureDirection::Up,
+ Action::MissionControl,
+ );
+ assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
+
+ // An explicit OS-hook gesture button takes the role over.
+ cfg.set_binding(
+ "2b042",
+ ButtonId::Forward,
+ Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
+ );
+ assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
+
+ // Turning gestures off explicitly yields `None` (not the HID++ button default).
+ let mut off = Config::default();
+ off.disable_gestures("2b042");
+ assert_eq!(off.gesture_owner("2b042"), None);
+}
+
+#[test]
+fn set_gesture_owner_records_owner_without_destroying_other_maps() {
+ let mut cfg = Config::default();
+ // Customize the dedicated HID++ gesture button's Up swipe; it is the (inferred) owner.
+ cfg.set_gesture_direction(
+ "2b042",
+ ButtonId::GestureButton,
+ GestureDirection::Up,
+ Action::Copy,
+ );
+ assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
+
+ // Promote Back: the owner becomes Back explicitly; the HID++ gesture button keeps
+ // its full gesture map (no destructive demotion).
+ cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
+ cfg.set_gesture_owner("2b042", ButtonId::Back);
+ assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
+
+ let bindings = cfg.bindings_for("2b042");
+ // Back is a full five-direction gesture button: its prior single action
+ // stays as Click, and the swipe arms are seeded from defaults.
+ match bindings.get(&ButtonId::Back) {
+ Some(Binding::Gesture(map)) => {
+ assert_eq!(
+ map.get(&GestureDirection::Click),
+ Some(&Action::BrowserBack)
+ );
+ assert_eq!(
+ map.get(&GestureDirection::Up),
+ Some(&default_gesture_binding(GestureDirection::Up)),
+ "a promoted button gets full default arms"
+ );
+ }
+ other => panic!("expected Back to be a gesture binding, got {other:?}"),
+ }
+ // The HID++ gesture button's customized map survived the switch intact.
+ match bindings.get(&ButtonId::GestureButton) {
+ Some(Binding::Gesture(map)) => {
+ assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
+ }
+ other => panic!("expected the HID++ gesture button map preserved, got {other:?}"),
+ }
+
+ // Switching back restores the user's customization, not defaults
+ // (regression guard: owner-switch used to discard the swipe arms).
+ cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
+ assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
+ match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
+ Some(Binding::Gesture(map)) => {
+ assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
+ }
+ other => panic!("expected preserved gesture map, got {other:?}"),
+ }
+}
+
+#[test]
+fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
+ let mut cfg = Config::default();
+ // The dedicated HID++ gesture button gets the full default direction map.
+ cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
+ match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
+ Some(Binding::Gesture(map)) => {
+ for dir in GestureDirection::ALL {
+ assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
+ }
+ }
+ other => panic!("expected full default gesture map, got {other:?}"),
+ }
+
+ // A fresh OS-hook button also gets all five directions, not just a Click:
+ // its native action stays as Click, and the swipe arms are defaults — so
+ // the GUI's shown defaults are exactly what the runtime dispatches.
+ cfg.set_gesture_owner("2b042", ButtonId::Forward);
+ match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
+ Some(Binding::Gesture(map)) => {
+ assert_eq!(
+ map.get(&GestureDirection::Click),
+ Some(&default_binding(ButtonId::Forward))
+ );
+ for dir in [
+ GestureDirection::Up,
+ GestureDirection::Down,
+ GestureDirection::Left,
+ GestureDirection::Right,
+ ] {
+ assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
+ }
+ }
+ other => panic!("expected full gesture map for Forward, got {other:?}"),
+ }
+}
+
+#[test]
+fn disable_gestures_turns_off_without_destroying_maps() {
+ let mut cfg = Config::default();
+ cfg.set_gesture_direction(
+ "2b042",
+ ButtonId::GestureButton,
+ GestureDirection::Up,
+ Action::Copy,
+ );
+ cfg.disable_gestures("2b042");
+ // Off, but the HID++ gesture button's customized map is preserved (re-enabling
+ // restores it rather than resurrecting a wiped default).
+ assert_eq!(cfg.gesture_owner("2b042"), None);
+ match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
+ Some(Binding::Gesture(map)) => {
+ assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
+ }
+ other => panic!("expected the gesture map preserved while off, got {other:?}"),
+ }
+}
+
+#[test]
+fn gesture_owner_field_roundtrips_as_a_scalar() {
+ let mut cfg = Config::default();
+ cfg.set_gesture_owner("2b042", ButtonId::Back); // explicit button
+ cfg.disable_gestures("4082d"); // explicit off
+
+ let parsed = write_and_read(&cfg);
+ assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
+ assert_eq!(parsed.gesture_owner("4082d"), None);
+
+ // The custom codec keeps it a bare TOML string (a nested table would risk
+ // a value-after-table serialization error, since `bindings` is a table).
+ let body = toml::to_string_pretty(&cfg).expect("serialize");
+ assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
+ assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
+}
+
+#[test]
+fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
+ // A hand-edit typo in gesture_owner must NOT fail the whole-document parse
+ // (which would revert every device's settings to defaults). It degrades
+ // to "infer" while the rest of the device config survives.
+ let toml = "\
+schema_version = 2
+
+[devices.2b042]
+gesture_owner = \"bogus\"
+
+[devices.2b042.bindings]
+Back = \"Copy\"
+";
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = dir.path().join("config.toml");
+ fs::write(&path, toml).expect("write");
+
+ let cfg =
+ Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
+ // The rest of the device config survived...
+ assert_eq!(
+ cfg.bindings_for("2b042").get(&ButtonId::Back),
+ Some(&Binding::Single(Action::Copy))
+ );
+ // ...and the bad owner degraded to inference (HID++ button default here).
+ assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
+}
diff --git a/crates/openlogi-core/src/device.rs b/crates/openlogi-core/src/device.rs
index 165af39867d760fd67cd4d0cb13cb2bfe94a0527..67e82264504371d4c0941160e12770019ca421dd 100644
--- a/crates/openlogi-core/src/device.rs
+++ b/crates/openlogi-core/src/device.rs
@@ -6,6 +6,10 @@
use serde::{Deserialize, Serialize};
+mod light;
+
+pub use light::{LightCapabilities, LightValueRange, LightValueRangeError, LightValueUnit};
+
/// What a paired peripheral is. Mirrors `hidpp::receiver::bolt::BoltDeviceKind`
/// but is owned by us so consumers don't depend on `hidpp`.
///
@@ -42,9 +46,16 @@ pub enum DeviceKind {
Joystick,
/// Audio headsets paired through a receiver.
Headset,
+ /// Logitech webcam (UVC), configured through `openlogi-camera`.
+ Camera,
/// Not classified by any source — also the "no asset opinion" value
/// [`DeviceKind::from_registry_type`] returns for unmodelled strings.
Unknown,
+ /// Standalone light or other illumination device controlled outside HID++.
+ ///
+ /// This is an identity hint only. UI controls are gated by the dedicated
+ /// light capability descriptor, never by this variant alone.
+ Light,
}
impl DeviceKind {
@@ -68,6 +79,8 @@ impl DeviceKind {
"gamepad" => Self::Gamepad,
"joystick" => Self::Joystick,
"headset" => Self::Headset,
+ "camera" => Self::Camera,
+ "light" | "lighting" | "illumination_light" => Self::Light,
_ => Self::Unknown,
}
}
@@ -315,6 +328,65 @@ pub struct PairedDevice {
pub capabilities: Option<Capabilities>,
}
+/// Address of a standalone raw-HID interface.
+///
+/// The identity is an opaque transport-generated string. It is deliberately
+/// kept separate from the HID++ receiver/slot address so a raw device cannot
+/// accidentally enter the HID++ `Direct` path.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct RawDeviceAddress {
+ /// HID vendor ID.
+ pub vendor_id: u16,
+ /// HID product ID.
+ pub product_id: u16,
+ /// HID usage page.
+ pub usage_page: u16,
+ /// HID usage ID.
+ pub usage_id: u16,
+ /// Identity chosen by the transport: a serial when available, otherwise
+ /// an explicitly transient OS-node identity. It is never an enumeration
+ /// index and a transient value is not persisted as a physical key.
+ pub identity: String,
+}
+
+/// A standalone device that is not a HID++ receiver pairing slot.
+///
+/// This is the inventory bridge for Litra and future non-HID++ categories.
+/// Receiver-backed devices continue to use [`PairedDevice`] inside
+/// [`DeviceInventory`].
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct StandaloneDevice {
+ /// Raw HID address used to re-find the interface.
+ pub address: RawDeviceAddress,
+ /// Human-readable name supplied by the OS/HID descriptor.
+ pub display_name: String,
+ /// Human-readable manufacturer, when available.
+ pub manufacturer: Option<String>,
+ /// Device serial, when the HID backend exposes one.
+ pub serial_number: Option<String>,
+ /// Stable four-byte identity when the protocol/driver provides one.
+ /// Raw HID drivers may use zeroes when no such field exists.
+ pub unit_id: [u8; 4],
+ /// Identity classification. Capability fields gate controls.
+ pub kind: DeviceKind,
+ /// Whether this interface was present in the latest completed scan.
+ pub online: bool,
+ /// HID++ capabilities are absent for a non-HID++ device.
+ pub capabilities: Option<Capabilities>,
+ /// Standalone capability descriptor, if the selected driver recognizes it.
+ pub light_capabilities: Option<LightCapabilities>,
+ /// Stable identifier of the driver family that owns this raw interface.
+ /// This is deliberately separate from the product ID so a future family
+ /// can share a protocol driver across several product variants.
+ pub driver_id: String,
+ /// Optional model-level identity in the OpenLogi asset registry.
+ ///
+ /// This is deliberately appended: `StandaloneDevice` crosses the
+ /// append-only GUI↔agent bincode wire format.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub registry_model_id: Option<String>,
+}
+
/// One receiver and its paired devices — the unit the agent's inventory
/// snapshot is made of.
///
@@ -335,9 +407,15 @@ pub struct DeviceInventory {
#[cfg(test)]
mod tests {
+ #![allow(
+ clippy::expect_used,
+ reason = "range fixture construction is intentionally asserted in tests"
+ )]
+
use super::{
BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceInventory, DeviceKind,
- DeviceModelInfo, DeviceTransports, PairedDevice, ReceiverInfo,
+ DeviceModelInfo, DeviceTransports, LightValueRange, LightValueUnit, PairedDevice,
+ ReceiverInfo,
};
fn inventory(slot: u8, wpid: Option<u16>, battery_percentage: u8) -> DeviceInventory {
@@ -500,4 +578,30 @@ mod tests {
Capabilities::default()
);
}
+
+ #[test]
+ fn light_ranges_reject_invalid_grids_and_units() {
+ assert!(LightValueRange::new(10, 1, 1, LightValueUnit::Lumens).is_err());
+ assert!(LightValueRange::new(0, 10, 0, LightValueUnit::Lumens).is_err());
+ assert!(LightValueRange::new(0, 10, 3, LightValueUnit::Lumens).is_err());
+ assert!(LightValueRange::new(0, 101, 1, LightValueUnit::Percent).is_err());
+ }
+
+ #[test]
+ fn light_ranges_quantize_without_leaving_the_advertised_grid() {
+ let range = LightValueRange::new(20, 250, 10, LightValueUnit::Lumens).expect("valid range");
+ assert_eq!(range.native_for_percent(0), Some(20));
+ assert_eq!(range.native_for_percent(50), Some(140));
+ assert_eq!(range.native_for_percent(100), Some(250));
+ assert_eq!(range.quantize(249), 250);
+ assert!(range.contains(range.native_for_percent(65).expect("mapped value")));
+ }
+
+ #[test]
+ fn invalid_light_ranges_fail_toml_deserialization() {
+ let result = toml::from_str::<LightValueRange>(
+ "min = 2700\nmax = 6500\nstep = 0\nunit = 'kelvin'\n",
+ );
+ assert!(result.is_err());
+ }
}
diff --git a/crates/openlogi-core/src/device/light.rs b/crates/openlogi-core/src/device/light.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e70e3008233523d087f2ab03912d9356efe93835
--- /dev/null
+++ b/crates/openlogi-core/src/device/light.rs
@@ -0,0 +1,213 @@
+//! Capability types shared by standalone light drivers and their clients.
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+/// The native unit used by a standalone light control range.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum LightValueUnit {
+ /// A percentage of the device's supported range.
+ Percent,
+ /// Absolute luminous output, where the protocol exposes lumens.
+ Lumens,
+ /// Colour temperature in Kelvin.
+ Kelvin,
+}
+
+/// A validated light value range advertised by a device driver.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
+pub struct LightValueRange {
+ /// Inclusive lower bound in [`Self::unit`].
+ min: u16,
+ /// Inclusive upper bound in [`Self::unit`].
+ max: u16,
+ /// Supported increment. Must be non-zero.
+ step: u16,
+ /// The unit represented by `min`, `max`, and `step`.
+ unit: LightValueUnit,
+}
+
+impl LightValueRange {
+ /// Construct a range after validating its bounds and quantization grid.
+ ///
+ /// The upper bound must lie on the same grid as the lower bound. This
+ /// keeps driver quantization total: a valid range can never produce a
+ /// value outside the advertised interval or between device-supported
+ /// stops.
+ pub const fn new(
+ min: u16,
+ max: u16,
+ step: u16,
+ unit: LightValueUnit,
+ ) -> Result<Self, LightValueRangeError> {
+ if min > max {
+ return Err(LightValueRangeError::Reversed { min, max });
+ }
+ if step == 0 {
+ return Err(LightValueRangeError::ZeroStep);
+ }
+ if !(max - min).is_multiple_of(step) {
+ return Err(LightValueRangeError::Unaligned { min, max, step });
+ }
+ if matches!(unit, LightValueUnit::Percent) && max > 100 {
+ return Err(LightValueRangeError::PercentOutOfBounds { min, max });
+ }
+ Ok(Self {
+ min,
+ max,
+ step,
+ unit,
+ })
+ }
+
+ /// Inclusive lower bound in the advertised unit.
+ #[must_use]
+ pub const fn min(self) -> u16 {
+ self.min
+ }
+
+ /// Inclusive upper bound in the advertised unit.
+ #[must_use]
+ pub const fn max(self) -> u16 {
+ self.max
+ }
+
+ /// Supported increment.
+ #[must_use]
+ pub const fn step(self) -> u16 {
+ self.step
+ }
+
+ /// Unit represented by this range.
+ #[must_use]
+ pub const fn unit(self) -> LightValueUnit {
+ self.unit
+ }
+
+ /// Whether `value` is representable without clamping or quantization.
+ #[must_use]
+ pub fn contains(self, value: u16) -> bool {
+ value >= self.min
+ && value <= self.max
+ && self.step != 0
+ && (value - self.min).is_multiple_of(self.step)
+ }
+
+ /// Snap `value` to the nearest supported point inside this range.
+ #[must_use]
+ pub fn quantize(self, value: u16) -> u16 {
+ let clamped = value.clamp(self.min, self.max);
+ let offset = clamped - self.min;
+ let lower = offset / self.step;
+ let remainder = offset % self.step;
+ let index = if remainder.saturating_mul(2) >= self.step {
+ lower.saturating_add(1)
+ } else {
+ lower
+ };
+ self.min
+ .saturating_add(index.saturating_mul(self.step))
+ .min(self.max)
+ }
+
+ /// Map normalized brightness to the nearest native value in this range.
+ #[must_use]
+ pub fn native_for_percent(self, percent: u8) -> Option<u16> {
+ if percent > 100 {
+ return None;
+ }
+ let span = u32::from(self.max) - u32::from(self.min);
+ let raw = u32::from(self.min) + (span * u32::from(percent) + 50) / 100;
+ u16::try_from(raw).ok().map(|value| self.quantize(value))
+ }
+
+ /// Convert a supported native value to normalized brightness.
+ #[must_use]
+ pub fn percent_for_native(self, value: u16) -> Option<u8> {
+ if !self.contains(value) {
+ return None;
+ }
+ let span = u32::from(self.max) - u32::from(self.min);
+ if span == 0 {
+ return Some(0);
+ }
+ u8::try_from(((u32::from(value) - u32::from(self.min)) * 100 + span / 2) / span).ok()
+ }
+}
+
+/// Validation failure for [`LightValueRange`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
+pub enum LightValueRangeError {
+ /// The lower bound is greater than the upper bound.
+ #[error("light range minimum {min} is greater than maximum {max}")]
+ Reversed {
+ /// Rejected lower bound.
+ min: u16,
+ /// Rejected upper bound.
+ max: u16,
+ },
+ /// A range cannot have a zero increment.
+ #[error("light range step must be non-zero")]
+ ZeroStep,
+ /// The upper bound is not reachable from the lower bound using `step`.
+ #[error("light range {min}..={max} is not aligned to step {step}")]
+ Unaligned {
+ /// Lower bound of the invalid range.
+ min: u16,
+ /// Upper bound of the invalid range.
+ max: u16,
+ /// Increment that does not reach the upper bound.
+ step: u16,
+ },
+ /// Percentage ranges must stay within 0–100.
+ #[error("percentage light range {min}..={max} exceeds 0..=100")]
+ PercentOutOfBounds {
+ /// Rejected lower bound.
+ min: u16,
+ /// Rejected upper bound.
+ max: u16,
+ },
+}
+
+#[derive(Deserialize)]
+struct RawLightValueRange {
+ min: u16,
+ max: u16,
+ step: u16,
+ unit: LightValueUnit,
+}
+
+impl<'de> Deserialize<'de> for LightValueRange {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let raw = RawLightValueRange::deserialize(deserializer)?;
+ Self::new(raw.min, raw.max, raw.step, raw.unit).map_err(serde::de::Error::custom)
+ }
+}
+
+/// Controls a standalone light driver can implement.
+///
+/// Optional ranges are the source of truth for UI controls. A driver must not
+/// advertise a control merely because the product is classified as a light.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[allow(
+ clippy::struct_excessive_bools,
+ reason = "independent optional light controls are a serialized capability DTO"
+)]
+pub struct LightCapabilities {
+ /// Whether the driver can switch the light on and off.
+ pub power: bool,
+ /// Supported brightness range, if brightness is controllable.
+ pub brightness: Option<LightValueRange>,
+ /// Supported colour-temperature range, if temperature is controllable.
+ pub temperature: Option<LightValueRange>,
+ /// Whether the driver can set a colour.
+ #[serde(default)]
+ pub color: bool,
+ /// Whether the driver exposes independently-addressable zones.
+ #[serde(default)]
+ pub zones: bool,
+}
diff --git a/crates/openlogi-core/src/diagnostics.rs b/crates/openlogi-core/src/diagnostics.rs
index 65ce7ac6ac88ad056af7f503523f930bd2bf1ef5..6703e9bfcf050a490ce67f3982527018c17b13eb 100644
--- a/crates/openlogi-core/src/diagnostics.rs
+++ b/crates/openlogi-core/src/diagnostics.rs
@@ -411,7 +411,9 @@ fn kind_label(kind: DeviceKind) -> &'static str {
DeviceKind::Gamepad => "gamepad",
DeviceKind::Joystick => "joystick",
DeviceKind::Headset => "headset",
+ DeviceKind::Camera => "camera",
DeviceKind::Unknown => "unknown",
+ DeviceKind::Light => "light",
}
}
diff --git a/crates/openlogi-core/src/paths.rs b/crates/openlogi-core/src/paths.rs
index 05c2b3e0c262300757341e1b0aecf6940f1e6a2b..3213432b8615928975a6a1066cb68f78480c97b1 100644
--- a/crates/openlogi-core/src/paths.rs
+++ b/crates/openlogi-core/src/paths.rs
@@ -18,13 +18,20 @@
//! shipped in Windows artifacts, because moving it afterwards would strand
//! every existing user's `config.toml` and the agent's first-run state.
+//! Local packaged macOS builds stamped with `.dev` bundle identifiers use the
+//! same layout under an `openlogi-dev` app directory.
+
use std::path::PathBuf;
+use std::sync::OnceLock;
use etcetera::{BaseStrategy, base_strategy::Xdg};
use thiserror::Error;
-/// Subdirectory created under each XDG base directory.
+/// Production subdirectory created under each XDG base directory.
const APP_DIR: &str = "openlogi";
+/// Local macOS `.dev` bundles use a separate profile so development agents
+/// cannot take over the installed app's socket, lock, config, or asset cache.
+const DEV_APP_DIR: &str = "openlogi-dev";
/// Failure resolving the per-user base directories.
#[derive(Debug, Error)]
@@ -39,6 +46,63 @@ fn xdg() -> Result<Xdg, PathsError> {
Xdg::new().map_err(|_| PathsError::HomeNotFound)
}
+fn app_dir() -> &'static str {
+ static IS_DEV_PROFILE: OnceLock<bool> = OnceLock::new();
+ if *IS_DEV_PROFILE.get_or_init(is_dev_profile) {
+ DEV_APP_DIR
+ } else {
+ APP_DIR
+ }
+}
+
+fn is_dev_profile() -> bool {
+ match std::env::var("OPENLOGI_PROFILE") {
+ Ok(value) if value == "dev" => return true,
+ Ok(value) if matches!(value.as_str(), "prod" | "production") => return false,
+ _ => {}
+ }
+
+ #[cfg(target_os = "macos")]
+ {
+ if let Some(identifier) = current_bundle_identifier() {
+ // Reverse-DNS suffix (org.openlogi.*.dev), not a filesystem extension.
+ return identifier
+ .rsplit_once('.')
+ .is_some_and(|(_, suffix)| suffix.eq_ignore_ascii_case("dev"));
+ }
+ }
+
+ false
+}
+
+#[cfg(target_os = "macos")]
+fn current_bundle_identifier() -> Option<String> {
+ let exe = std::env::current_exe().ok()?;
+ for ancestor in exe.ancestors() {
+ if !ancestor
+ .extension()
+ .is_some_and(|ext| ext.eq_ignore_ascii_case("app"))
+ {
+ continue;
+ }
+
+ let info = ancestor.join("Contents/Info.plist");
+ let Ok(plist) = plist::Value::from_file(info) else {
+ continue;
+ };
+ let Some(identifier) = plist
+ .as_dictionary()
+ .and_then(|dictionary| dictionary.get("CFBundleIdentifier"))
+ .and_then(plist::Value::as_string)
+ else {
+ continue;
+ };
+ return Some(identifier.to_owned());
+ }
+
+ None
+}
+
/// The current user's home directory.
///
/// The plain home, not an XDG base — for callers placing files under
@@ -59,8 +123,9 @@ pub fn xdg_config_home() -> Result<PathBuf, PathsError> {
/// Directory holding the user's `config.toml`.
///
/// `$XDG_CONFIG_HOME/openlogi`, default `~/.config/openlogi`.
+/// Local macOS `.dev` bundles use `openlogi-dev` instead.
pub fn config_dir() -> Result<PathBuf, PathsError> {
- Ok(xdg_config_home()?.join(APP_DIR))
+ Ok(xdg_config_home()?.join(app_dir()))
}
/// Full path to the user config file.
@@ -72,16 +137,18 @@ pub fn config_path() -> Result<PathBuf, PathsError> {
/// lives under `data_dir()/assets`.
///
/// `$XDG_DATA_HOME/openlogi`, default `~/.local/share/openlogi`.
+/// Local macOS `.dev` bundles use `openlogi-dev` instead.
pub fn data_dir() -> Result<PathBuf, PathsError> {
- Ok(xdg()?.data_dir().join(APP_DIR))
+ Ok(xdg()?.data_dir().join(app_dir()))
}
/// Directory for runtime sockets — the background agent's IPC endpoint.
pub fn runtime_dir() -> Result<PathBuf, PathsError> {
let xdg = xdg()?;
- Ok(xdg
- .runtime_dir()
- .map_or_else(|| xdg.config_dir().join(APP_DIR), |dir| dir.join(APP_DIR)))
+ Ok(xdg.runtime_dir().map_or_else(
+ || xdg.config_dir().join(app_dir()),
+ |dir| dir.join(app_dir()),
+ ))
}
/// Path to the background agent's Unix-domain IPC socket: the GUI connects here
diff --git a/crates/openlogi-gui/Cargo.toml b/crates/openlogi-gui/Cargo.toml
index f1bc3f808985b35444491dcff84dafee20cd9b9c..76aa11ee666f9cb29b9a282d46912181f3e6219e 100644
--- a/crates/openlogi-gui/Cargo.toml
+++ b/crates/openlogi-gui/Cargo.toml
@@ -18,6 +18,7 @@ path = "src/main.rs"
[dependencies]
openlogi-core = { path = "../openlogi-core" }
openlogi-hid = { path = "../openlogi-hid" }
+openlogi-camera = { path = "../openlogi-camera" }
openlogi-hook = { path = "../openlogi-hook" }
openlogi-assets = { path = "../openlogi-assets" }
openlogi-agent-core = { path = "../openlogi-agent-core" }
diff --git a/crates/openlogi-gui/action-icons/moon.svg b/crates/openlogi-gui/action-icons/moon.svg
new file mode 100644
index 0000000000000000000000000000000000000000..17bb18352c8195f20016b9d3f09630af1ae5c249
--- /dev/null
+++ b/crates/openlogi-gui/action-icons/moon.svg
@@ -0,0 +1,13 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+>
+ <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
+</svg>
diff --git a/crates/openlogi-gui/action-icons/terminal.svg b/crates/openlogi-gui/action-icons/terminal.svg
new file mode 100644
index 0000000000000000000000000000000000000000..ac07084d346e02779fa8031011a01b7ad37bb016
--- /dev/null
+++ b/crates/openlogi-gui/action-icons/terminal.svg
@@ -0,0 +1,15 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+>
+ <path d="m7 11 2-2-2-2" />
+ <path d="M11 13h4" />
+ <rect width="20" height="16" x="2" y="4" rx="2" />
+</svg>
diff --git a/crates/openlogi-gui/bundle/gui-dev/Info.plist b/crates/openlogi-gui/bundle/gui-dev/Info.plist
index 91256e34ddb31487d43aa9f79340f80d38b8d0e4..e915e46def0398ee88298abfd7906112fb1b5e90 100644
--- a/crates/openlogi-gui/bundle/gui-dev/Info.plist
+++ b/crates/openlogi-gui/bundle/gui-dev/Info.plist
@@ -2,8 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
- <key>CFBundleName</key><string>OpenLogi</string>
- <key>CFBundleDisplayName</key><string>OpenLogi</string>
+ <key>CFBundleName</key><string>OpenLogi Dev</string>
+ <key>CFBundleDisplayName</key><string>OpenLogi Dev</string>
<key>CFBundleExecutable</key><string>openlogi-gui</string>
<key>CFBundleIdentifier</key><string>org.openlogi.openlogi.dev</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
@@ -14,6 +14,7 @@
<key>CFBundleVersion</key><string>0.0.0</string>
<key>LSMinimumSystemVersion</key><string>13.0</string>
<key>NSHighResolutionCapable</key><true/>
+ <key>NSCameraUsageDescription</key><string>OpenLogi previews your Logitech webcam locally. Video never leaves your Mac.</string>
<!-- openlogi:// deep-link scheme. Mirror of [package.metadata.bundle]
osx_url_schemes in Cargo.toml (which cargo-bundle uses for the release
app) — keep the two in sync. CFBundleURLName matches the name
diff --git a/crates/openlogi-gui/locales/da.yml b/crates/openlogi-gui/locales/da.yml
index 46b2fafa260184fccdfd99253b7399c999c82184..32983daf8aeb63f6ded71ce2277c196a3e35e027 100644
--- a/crates/openlogi-gui/locales/da.yml
+++ b/crates/openlogi-gui/locales/da.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Ingen enhed tilsluttet"
"Devices": "Enheder"
"Buttons": "Knapper"
+"Keys": "Keys"
"Pointer": "Markør"
"Lighting": "Belysning"
"LIGHTING": "BELYSNING"
"BRIGHTNESS": "LYSSTYRKE"
+"COLOUR TEMPERATURE": "FARVETEMPERATUR"
"On": "Til"
"Off": "Fra"
"Open OpenLogi": "Åbn OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Frem"
"DPI Toggle": "DPI-skift"
"Thumb Wheel": "Tommelfingerhjul"
-"Back / Forward": "Tilbage / Frem"
-"Undo / Redo": "Fortryd / Annuller fortryd"
-"Browser Back / Forward": "Browser tilbage / Browser frem"
-"Previous / Next Tab": "Forrige faneblad / Næste faneblad"
-"Previous / Next Desktop": "Forrige skrivebord / Næste skrivebord"
-"Previous / Next Track": "Forrige nummer / Næste nummer"
-"Volume Down / Up": "Skru ned / Skru op"
-"Vertical Scroll": "Rul ned / Rul op"
-"Horizontal Scroll": "Rul til venstre / Rul til højre"
-"Custom": "Custom"
"Gesture Button": "Bevægelsesknap"
"Up": "Op"
"Down": "Ned"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Lås skærm"
"Screenshot": "Skærmbillede"
+"Sleep": "Slumre"
"Capture Region": "Optag område"
"Play / Pause": "Afspil / pause"
"Next Track": "Næste nummer"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Registrerer andre apps, der opfanger musens hændelsesstrøm – en almindelig årsag til markørforsinkelse."
"No other app is intercepting mouse input.": "Ingen anden app opfanger museinput."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "En anden app opfanger museinput, hvilket kan forårsage markørforsinkelse eller dublerede knaphandlinger: %{apps}"
+"Camera": "Kamera"
+"Starting preview…": "Starter forhåndsvisning…"
+"Enable Camera access in Settings to preview.": "Aktivér kameraadgang i Indstillinger for at få vist en forhåndsvisning."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Dit Logitech-webkamera vises på hovedsiden. Giv adgang for at se live-forhåndsvisningen — videoen forlader aldrig din Mac."
+"Brightness": "Lysstyrke"
+"Contrast": "Kontrast"
+"Saturation": "Mætning"
+"Sharpness": "Skarphed"
+"Camera controls": "Kamerakontroller"
+"Reset to defaults": "Nulstil til standard"
+"This camera exposes no adjustable image controls.": "Dette kamera har ingen justerbare billedkontroller."
+"Focus": "Fokus"
+"Exposure": "Eksponering"
+"White balance": "Hvidbalance"
+"Tint": "Farvetone"
+"Auto": "Auto"
+"Lens": "Objektiv"
+"Image": "Billede"
+"Streaming": "Streaming"
+"Video call": "Videoopkald"
+"New": "Ny"
+"Live preview isn't available on this platform yet.": "Live-forhåndsvisning er endnu ikke tilgængelig på denne platform."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/de.yml b/crates/openlogi-gui/locales/de.yml
index 1d269fad1f9b83679b2bfe8ec7a46bf39f56bb91..c6bde70e814838fc6e6b32ec73c877e710615845 100644
--- a/crates/openlogi-gui/locales/de.yml
+++ b/crates/openlogi-gui/locales/de.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Kein Gerät verbunden"
"Devices": "Geräte"
"Buttons": "Tasten"
+"Keys": "Keys"
"Pointer": "Zeiger"
"Lighting": "Beleuchtung"
"LIGHTING": "BELEUCHTUNG"
"BRIGHTNESS": "HELLIGKEIT"
+"COLOUR TEMPERATURE": "FARBTEMPERATUR"
"On": "Ein"
"Off": "Aus"
"Open OpenLogi": "OpenLogi öffnen"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Vor"
"DPI Toggle": "DPI-Umschaltung"
"Thumb Wheel": "Daumenrad"
-"Back / Forward": "Zurück / Vor"
-"Undo / Redo": "Widerrufen / Wiederholen"
-"Browser Back / Forward": "Browser zurück / Browser vor"
-"Previous / Next Tab": "Vorheriger Tab / Nächster Tab"
-"Previous / Next Desktop": "Vorheriger Schreibtisch / Nächster Schreibtisch"
-"Previous / Next Track": "Vorheriger Titel / Nächster Titel"
-"Volume Down / Up": "Leiser / Lauter"
-"Vertical Scroll": "Nach unten scrollen / Nach oben scrollen"
-"Horizontal Scroll": "Nach links scrollen / Nach rechts scrollen"
-"Custom": "Custom"
"Gesture Button": "Gestentaste"
"Up": "Oben"
"Down": "Unten"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Bildschirm sperren"
"Screenshot": "Bildschirmfoto"
+"Sleep": "Ruhezustand"
"Capture Region": "Bereich aufnehmen"
"Play / Pause": "Wiedergabe / Pause"
"Next Track": "Nächster Titel"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Erkennt andere Apps, die den Maus-Ereignisstrom abgreifen – eine häufige Ursache für Zeigerverzögerung."
"No other app is intercepting mouse input.": "Keine andere App fängt Mauseingaben ab."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Eine andere App fängt Mauseingaben ab, was zu Zeigerverzögerung oder doppelten Tastenaktionen führen kann: %{apps}"
+"Camera": "Kamera"
+"Starting preview…": "Vorschau wird gestartet…"
+"Enable Camera access in Settings to preview.": "Aktiviere den Kamerazugriff in den Einstellungen für die Vorschau."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Deine Logitech-Webcam erscheint auf der Hauptseite. Erlaube den Zugriff, um die Live-Vorschau zu sehen — das Video verlässt deinen Mac nie."
+"Brightness": "Helligkeit"
+"Contrast": "Kontrast"
+"Saturation": "Sättigung"
+"Sharpness": "Schärfe"
+"Camera controls": "Kamerasteuerung"
+"Reset to defaults": "Auf Standard zurücksetzen"
+"This camera exposes no adjustable image controls.": "Diese Kamera bietet keine einstellbaren Bildregler."
+"Focus": "Fokus"
+"Exposure": "Belichtung"
+"White balance": "Weißabgleich"
+"Tint": "Farbton"
+"Auto": "Auto"
+"Lens": "Objektiv"
+"Image": "Bild"
+"Streaming": "Streaming"
+"Video call": "Videoanruf"
+"New": "Neu"
+"Live preview isn't available on this platform yet.": "Die Live-Vorschau ist auf dieser Plattform noch nicht verfügbar."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/el.yml b/crates/openlogi-gui/locales/el.yml
index f8936f293d36eba85182e86e0ebcc598097ee3cd..d135a6c134fd53ddead09d609cec2adb974a7432 100644
--- a/crates/openlogi-gui/locales/el.yml
+++ b/crates/openlogi-gui/locales/el.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Καμία συσκευή δεν είναι συνδεδεμένη"
"Devices": "Συσκευές"
"Buttons": "Κουμπιά"
+"Keys": "Keys"
"Pointer": "Δείκτης"
"Lighting": "Φωτισμός"
"LIGHTING": "ΦΩΤΙΣΜΟΣ"
"BRIGHTNESS": "ΦΩΤΕΙΝΟΤΗΤΑ"
+"COLOUR TEMPERATURE": "ΘΕΡΜΟΚΡΑΣΙΑ ΧΡΩΜΑΤΟΣ"
"On": "Ενεργό"
"Off": "Ανενεργό"
"Open OpenLogi": "Άνοιγμα του OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Εμπρός"
"DPI Toggle": "Εναλλαγή DPI"
"Thumb Wheel": "Πλαϊνός τροχός"
-"Back / Forward": "Πίσω / Εμπρός"
-"Undo / Redo": "Αναίρεση / Επανάληψη"
-"Browser Back / Forward": "Πίσω στο πρόγραμμα περιήγησης / Εμπρός στο πρόγραμμα περιήγησης"
-"Previous / Next Tab": "Προηγούμενη καρτέλα / Επόμενη καρτέλα"
-"Previous / Next Desktop": "Προηγούμενο γραφείο εργασίας / Επόμενο γραφείο εργασίας"
-"Previous / Next Track": "Προηγούμενο κομμάτι / Επόμενο κομμάτι"
-"Volume Down / Up": "Μείωση έντασης / Αύξηση έντασης"
-"Vertical Scroll": "Κύλιση προς τα κάτω / Κύλιση προς τα πάνω"
-"Horizontal Scroll": "Κύλιση προς τα αριστερά / Κύλιση προς τα δεξιά"
-"Custom": "Custom"
"Gesture Button": "Κουμπί χειρονομιών"
"Up": "Πάνω"
"Down": "Κάτω"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Κλείδωμα οθόνης"
"Screenshot": "Στιγμιότυπο οθόνης"
+"Sleep": "Ύπνωση"
"Capture Region": "Λήψη περιοχής"
"Play / Pause": "Αναπαραγωγή / Παύση"
"Next Track": "Επόμενο κομμάτι"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Εντοπίζει άλλες εφαρμογές που υποκλέπτουν τη ροή συμβάντων του ποντικιού — μια συνηθισμένη αιτία καθυστέρησης του δείκτη."
"No other app is intercepting mouse input.": "Καμία άλλη εφαρμογή δεν υποκλέπτει την είσοδο του ποντικιού."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Μια άλλη εφαρμογή υποκλέπτει την είσοδο του ποντικιού, κάτι που μπορεί να προκαλέσει καθυστέρηση του δείκτη ή διπλές ενέργειες κουμπιών: %{apps}"
+"Camera": "Κάμερα"
+"Starting preview…": "Έναρξη προεπισκόπησης…"
+"Enable Camera access in Settings to preview.": "Ενεργοποιήστε την πρόσβαση στην κάμερα στις Ρυθμίσεις για προεπισκόπηση."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Η κάμερα Logitech εμφανίζεται στην κύρια σελίδα. Παραχωρήστε πρόσβαση για να δείτε τη ζωντανή προεπισκόπηση — το βίντεο δεν φεύγει ποτέ από το Mac σας."
+"Brightness": "Φωτεινότητα"
+"Contrast": "Αντίθεση"
+"Saturation": "Κορεσμός"
+"Sharpness": "Ευκρίνεια"
+"Camera controls": "Στοιχεία ελέγχου κάμερας"
+"Reset to defaults": "Επαναφορά προεπιλογών"
+"This camera exposes no adjustable image controls.": "Αυτή η κάμερα δεν διαθέτει ρυθμιζόμενα στοιχεία ελέγχου εικόνας."
+"Focus": "Εστίαση"
+"Exposure": "Έκθεση"
+"White balance": "Ισορροπία λευκού"
+"Tint": "Απόχρωση"
+"Auto": "Αυτόματο"
+"Lens": "Φακός"
+"Image": "Εικόνα"
+"Streaming": "Streaming"
+"Video call": "Βιντεοκλήση"
+"New": "Νέο"
+"Live preview isn't available on this platform yet.": "Η ζωντανή προεπισκόπηση δεν είναι ακόμη διαθέσιμη σε αυτήν την πλατφόρμα."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/en.yml b/crates/openlogi-gui/locales/en.yml
index 1652687d8532d91ba55d48c907479e6507dfc66d..7369d793c49cb4769830188f0eef59147ae5d8ac 100644
--- a/crates/openlogi-gui/locales/en.yml
+++ b/crates/openlogi-gui/locales/en.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "No devices connected"
"Devices": "Devices"
"Buttons": "Buttons"
+"Keys": "Keys"
"Pointer": "Pointer"
"Lighting": "Lighting"
"LIGHTING": "LIGHTING"
"BRIGHTNESS": "BRIGHTNESS"
+"COLOUR TEMPERATURE": "COLOUR TEMPERATURE"
"On": "On"
"Off": "Off"
"Open OpenLogi": "Open OpenLogi"
@@ -198,6 +200,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Lock Screen"
"Screenshot": "Screenshot"
+"Sleep": "Sleep"
"Capture Region": "Capture Region"
"Play / Pause": "Play / Pause"
"Next Track": "Next Track"
@@ -329,3 +332,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detects other apps tapping the mouse event stream — a common cause of pointer lag."
"No other app is intercepting mouse input.": "No other app is intercepting mouse input."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}"
+"Camera": "Camera"
+"Starting preview…": "Starting preview…"
+"Enable Camera access in Settings to preview.": "Enable Camera access in Settings to preview."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac."
+"Brightness": "Brightness"
+"Contrast": "Contrast"
+"Saturation": "Saturation"
+"Sharpness": "Sharpness"
+"Camera controls": "Camera controls"
+"Reset to defaults": "Reset to defaults"
+"This camera exposes no adjustable image controls.": "This camera exposes no adjustable image controls."
+"Focus": "Focus"
+"Exposure": "Exposure"
+"White balance": "White balance"
+"Tint": "Tint"
+"Auto": "Auto"
+"Lens": "Lens"
+"Image": "Image"
+"Streaming": "Streaming"
+"Video call": "Video call"
+"New": "New"
+"Live preview isn't available on this platform yet.": "Live preview isn't available on this platform yet."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/es.yml b/crates/openlogi-gui/locales/es.yml
index 696c5687288f5d61de24dcf787fb3309e94a7e69..e4f3e88409288ed9dc7e3f5b96a502171fb955d6 100644
--- a/crates/openlogi-gui/locales/es.yml
+++ b/crates/openlogi-gui/locales/es.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Ningún dispositivo conectado"
"Devices": "Dispositivos"
"Buttons": "Botones"
+"Keys": "Keys"
"Pointer": "Puntero"
"Lighting": "Iluminación"
"LIGHTING": "ILUMINACIÓN"
"BRIGHTNESS": "BRILLO"
+"COLOUR TEMPERATURE": "TEMPERATURA DE COLOR"
"On": "Activado"
"Off": "Desactivado"
"Open OpenLogi": "Abrir OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Adelante"
"DPI Toggle": "Cambiar DPI"
"Thumb Wheel": "Rueda lateral"
-"Back / Forward": "Atrás / Adelante"
-"Undo / Redo": "Deshacer / Rehacer"
-"Browser Back / Forward": "Atrás en el navegador / Adelante en el navegador"
-"Previous / Next Tab": "Pestaña anterior / Pestaña siguiente"
-"Previous / Next Desktop": "Escritorio anterior / Escritorio siguiente"
-"Previous / Next Track": "Pista anterior / Pista siguiente"
-"Volume Down / Up": "Bajar volumen / Subir volumen"
-"Vertical Scroll": "Desplazar hacia abajo / Desplazar hacia arriba"
-"Horizontal Scroll": "Desplazar a la izquierda / Desplazar a la derecha"
-"Custom": "Custom"
"Gesture Button": "Botón de gestos"
"Up": "Arriba"
"Down": "Abajo"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Bloquear pantalla"
"Screenshot": "Captura de pantalla"
+"Sleep": "Reposo"
"Capture Region": "Capturar región"
"Play / Pause": "Reproducir / Pausar"
"Next Track": "Pista siguiente"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detecta otras apps que interceptan el flujo de eventos del ratón, una causa habitual de retraso del puntero."
"No other app is intercepting mouse input.": "Ninguna otra app está interceptando la entrada del ratón."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Otra app está interceptando la entrada del ratón, lo que puede causar retraso del puntero o acciones de botón duplicadas: %{apps}"
+"Camera": "Cámara"
+"Starting preview…": "Iniciando vista previa…"
+"Enable Camera access in Settings to preview.": "Activa el acceso a la cámara en Ajustes para ver la vista previa."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Tu webcam Logitech aparece en la página principal. Concede acceso para ver su vista previa en directo — el vídeo nunca sale de tu Mac."
+"Brightness": "Brillo"
+"Contrast": "Contraste"
+"Saturation": "Saturación"
+"Sharpness": "Nitidez"
+"Camera controls": "Controles de la cámara"
+"Reset to defaults": "Restablecer valores predeterminados"
+"This camera exposes no adjustable image controls.": "Esta cámara no ofrece controles de imagen ajustables."
+"Focus": "Enfoque"
+"Exposure": "Exposición"
+"White balance": "Balance de blancos"
+"Tint": "Matiz"
+"Auto": "Auto"
+"Lens": "Objetivo"
+"Image": "Imagen"
+"Streaming": "Streaming"
+"Video call": "Videollamada"
+"New": "Nuevo"
+"Live preview isn't available on this platform yet.": "La vista previa en vivo aún no está disponible en esta plataforma."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/fi.yml b/crates/openlogi-gui/locales/fi.yml
index 84fc22cad87261058636431b0c4faeb0de2b492a..d47e38a22074e3aa6bf87c03ec09f2fa4912db73 100644
--- a/crates/openlogi-gui/locales/fi.yml
+++ b/crates/openlogi-gui/locales/fi.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Ei yhdistettyä laitetta"
"Devices": "Laitteet"
"Buttons": "Painikkeet"
+"Keys": "Keys"
"Pointer": "Osoitin"
"Lighting": "Valaistus"
"LIGHTING": "VALAISTUS"
"BRIGHTNESS": "KIRKKAUS"
+"COLOUR TEMPERATURE": "VÄRILÄMPÖTILA"
"On": "Päällä"
"Off": "Pois"
"Open OpenLogi": "Avaa OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Eteenpäin"
"DPI Toggle": "DPI-vaihto"
"Thumb Wheel": "Peukalorulla"
-"Back / Forward": "Takaisin / Eteenpäin"
-"Undo / Redo": "Kumoa / Tee uudelleen"
-"Browser Back / Forward": "Selaimessa takaisin / Selaimessa eteenpäin"
-"Previous / Next Tab": "Edellinen välilehti / Seuraava välilehti"
-"Previous / Next Desktop": "Edellinen työpöytä / Seuraava työpöytä"
-"Previous / Next Track": "Edellinen kappale / Seuraava kappale"
-"Volume Down / Up": "Vähennä äänenvoimakkuutta / Lisää äänenvoimakkuutta"
-"Vertical Scroll": "Vieritä alas / Vieritä ylös"
-"Horizontal Scroll": "Vieritä vasemmalle / Vieritä oikealle"
-"Custom": "Custom"
"Gesture Button": "Eletoiminto"
"Up": "Ylös"
"Down": "Alas"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Lukitse näyttö"
"Screenshot": "Kuvakaappaus"
+"Sleep": "Lepotila"
"Capture Region": "Kaappaa alue"
"Play / Pause": "Toista / Keskeytä"
"Next Track": "Seuraava kappale"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Tunnistaa muut sovellukset, jotka sieppaavat hiiren tapahtumavirtaa – yleinen osoittimen viiveen syy."
"No other app is intercepting mouse input.": "Mikään muu sovellus ei sieppaa hiiren syötettä."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Toinen sovellus sieppaa hiiren syötettä, mikä voi aiheuttaa osoittimen viivettä tai painikkeiden kaksoistoimintoja: %{apps}"
+"Camera": "Kamera"
+"Starting preview…": "Käynnistetään esikatselua…"
+"Enable Camera access in Settings to preview.": "Ota kameran käyttöoikeus käyttöön asetuksissa esikatselua varten."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Logitech-verkkokamerasi näkyy pääsivulla. Myönnä käyttöoikeus nähdäksesi reaaliaikaisen esikatselun — video ei koskaan poistu Macistasi."
+"Brightness": "Kirkkaus"
+"Contrast": "Kontrasti"
+"Saturation": "Kylläisyys"
+"Sharpness": "Terävyys"
+"Camera controls": "Kameran säädöt"
+"Reset to defaults": "Palauta oletukset"
+"This camera exposes no adjustable image controls.": "Tässä kamerassa ei ole säädettäviä kuva-asetuksia."
+"Focus": "Tarkennus"
+"Exposure": "Valotus"
+"White balance": "Valkotasapaino"
+"Tint": "Sävy"
+"Auto": "Auto"
+"Lens": "Objektiivi"
+"Image": "Kuva"
+"Streaming": "Suoratoisto"
+"Video call": "Videopuhelu"
+"New": "Uusi"
+"Live preview isn't available on this platform yet.": "Live-esikatselu ei ole vielä saatavilla tällä alustalla."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/fr.yml b/crates/openlogi-gui/locales/fr.yml
index e2c0e2394efd05653447b259cf592d65d2232154..ad10c78f4c6db12449d0b30322aa785cde1e40ca 100644
--- a/crates/openlogi-gui/locales/fr.yml
+++ b/crates/openlogi-gui/locales/fr.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Aucun appareil connecté"
"Devices": "Appareils"
"Buttons": "Boutons"
+"Keys": "Keys"
"Pointer": "Pointeur"
"Lighting": "Éclairage"
"LIGHTING": "ÉCLAIRAGE"
"BRIGHTNESS": "LUMINOSITÉ"
+"COLOUR TEMPERATURE": "TEMPÉRATURE DE COULEUR"
"On": "Activé"
"Off": "Désactivé"
"Open OpenLogi": "Ouvrir OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Suivant"
"DPI Toggle": "Bascule DPI"
"Thumb Wheel": "Molette latérale"
-"Back / Forward": "Précédent / Suivant"
-"Undo / Redo": "Annuler / Rétablir"
-"Browser Back / Forward": "Précédent (navigateur) / Suivant (navigateur)"
-"Previous / Next Tab": "Onglet précédent / Onglet suivant"
-"Previous / Next Desktop": "Bureau précédent / Bureau suivant"
-"Previous / Next Track": "Piste précédente / Piste suivante"
-"Volume Down / Up": "Baisser le volume / Augmenter le volume"
-"Vertical Scroll": "Défiler vers le bas / Défiler vers le haut"
-"Horizontal Scroll": "Défiler vers la gauche / Défiler vers la droite"
-"Custom": "Custom"
"Gesture Button": "Bouton de gestes"
"Up": "Haut"
"Down": "Bas"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Verrouiller l'écran"
"Screenshot": "Capture d'écran"
+"Sleep": "Suspendre l'activité"
"Capture Region": "Capturer une zone"
"Play / Pause": "Lecture / Pause"
"Next Track": "Piste suivante"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Détecte les autres applications qui interceptent le flux d'événements de la souris — une cause fréquente de latence du pointeur."
"No other app is intercepting mouse input.": "Aucune autre application n'intercepte les entrées de la souris."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Une autre application intercepte les entrées de la souris, ce qui peut provoquer une latence du pointeur ou des actions de bouton en double : %{apps}"
+"Camera": "Caméra"
+"Starting preview…": "Démarrage de l'aperçu…"
+"Enable Camera access in Settings to preview.": "Activez l'accès à la caméra dans les Réglages pour voir l'aperçu."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Votre webcam Logitech apparaît sur la page principale. Autorisez l'accès pour voir son aperçu en direct — la vidéo ne quitte jamais votre Mac."
+"Brightness": "Luminosité"
+"Contrast": "Contraste"
+"Saturation": "Saturation"
+"Sharpness": "Netteté"
+"Camera controls": "Réglages de la caméra"
+"Reset to defaults": "Réinitialiser les valeurs par défaut"
+"This camera exposes no adjustable image controls.": "Cette caméra n'expose aucun réglage d'image ajustable."
+"Focus": "Mise au point"
+"Exposure": "Exposition"
+"White balance": "Balance des blancs"
+"Tint": "Teinte"
+"Auto": "Auto"
+"Lens": "Objectif"
+"Image": "Image"
+"Streaming": "Streaming"
+"Video call": "Appel vidéo"
+"New": "Nouveau"
+"Live preview isn't available on this platform yet.": "L'aperçu en direct n'est pas encore disponible sur cette plateforme."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/it.yml b/crates/openlogi-gui/locales/it.yml
index 588f6a5aa04d07e22d78210ffa520d163698b566..a216bc0ab52dd84e6429a7a3c149060fa55f3c9b 100644
--- a/crates/openlogi-gui/locales/it.yml
+++ b/crates/openlogi-gui/locales/it.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Nessun dispositivo connesso"
"Devices": "Dispositivi"
"Buttons": "Pulsanti"
+"Keys": "Keys"
"Pointer": "Puntatore"
"Lighting": "Illuminazione"
"LIGHTING": "ILLUMINAZIONE"
"BRIGHTNESS": "LUMINOSITÀ"
+"COLOUR TEMPERATURE": "TEMPERATURA COLORE"
"On": "On"
"Off": "Off"
"Open OpenLogi": "Apri OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Avanti"
"DPI Toggle": "Cambia DPI"
"Thumb Wheel": "Volante da pollice"
-"Back / Forward": "Indietro / Avanti"
-"Undo / Redo": "Annulla / Ripristina"
-"Browser Back / Forward": "Indietro nel browser / Avanti nel browser"
-"Previous / Next Tab": "Scheda precedente / Scheda successiva"
-"Previous / Next Desktop": "Scrivania precedente / Scrivania successiva"
-"Previous / Next Track": "Traccia precedente / Traccia successiva"
-"Volume Down / Up": "Abbassa il volume / Alza il volume"
-"Vertical Scroll": "Scorri giù / Scorri su"
-"Horizontal Scroll": "Scorri a sinistra / Scorri a destra"
-"Custom": "Custom"
"Gesture Button": "Pulsante gesture"
"Up": "Su"
"Down": "Giù"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Blocca schermo"
"Screenshot": "Istantanea schermo"
+"Sleep": "Stop"
"Capture Region": "Cattura area"
"Play / Pause": "Riproduci / Pausa"
"Next Track": "Traccia successiva"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Rileva altre app che intercettano il flusso di eventi del mouse, una causa comune di lentezza del puntatore."
"No other app is intercepting mouse input.": "Nessun'altra app sta intercettando l'input del mouse."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Un'altra app sta intercettando l'input del mouse, il che può causare lentezza del puntatore o azioni dei pulsanti duplicate: %{apps}"
+"Camera": "Fotocamera"
+"Starting preview…": "Avvio anteprima…"
+"Enable Camera access in Settings to preview.": "Abilita l'accesso alla fotocamera nelle Impostazioni per l'anteprima."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "La tua webcam Logitech compare nella pagina principale. Concedi l'accesso per vederne l'anteprima dal vivo — il video non lascia mai il tuo Mac."
+"Brightness": "Luminosità"
+"Contrast": "Contrasto"
+"Saturation": "Saturazione"
+"Sharpness": "Nitidezza"
+"Camera controls": "Controlli fotocamera"
+"Reset to defaults": "Ripristina i valori predefiniti"
+"This camera exposes no adjustable image controls.": "Questa fotocamera non espone controlli immagine regolabili."
+"Focus": "Messa a fuoco"
+"Exposure": "Esposizione"
+"White balance": "Bilanciamento del bianco"
+"Tint": "Tinta"
+"Auto": "Auto"
+"Lens": "Obiettivo"
+"Image": "Immagine"
+"Streaming": "Streaming"
+"Video call": "Videochiamata"
+"New": "Nuovo"
+"Live preview isn't available on this platform yet.": "L'anteprima dal vivo non è ancora disponibile su questa piattaforma."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Accensione automatica con la fotocamera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Accendi questa luce quando una fotocamera è in uso e spegnila quando tutte si fermano."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/ja.yml b/crates/openlogi-gui/locales/ja.yml
index 95597eac78d69a05d2cb2fedecd4e8fd1f8cf528..48a1650096d6cbd08d16f3b3b731e98717cf12c5 100644
--- a/crates/openlogi-gui/locales/ja.yml
+++ b/crates/openlogi-gui/locales/ja.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "デバイスが接続されていません"
"Devices": "デバイス"
"Buttons": "ボタン"
+"Keys": "Keys"
"Pointer": "ポインター"
"Lighting": "ライト"
"LIGHTING": "ライト"
"BRIGHTNESS": "明るさ"
+"COLOUR TEMPERATURE": "色温度"
"On": "オン"
"Off": "オフ"
"Open OpenLogi": "OpenLogi を開く"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "進む"
"DPI Toggle": "DPI 切り替え"
"Thumb Wheel": "サムホイール"
-"Back / Forward": "戻る / 進む"
-"Undo / Redo": "取り消す / やり直す"
-"Browser Back / Forward": "ブラウザで戻る / ブラウザで進む"
-"Previous / Next Tab": "前のタブ / 次のタブ"
-"Previous / Next Desktop": "前のデスクトップ / 次のデスクトップ"
-"Previous / Next Track": "前の曲 / 次の曲"
-"Volume Down / Up": "音量を下げる / 音量を上げる"
-"Vertical Scroll": "下にスクロール / 上にスクロール"
-"Horizontal Scroll": "左にスクロール / 右にスクロール"
-"Custom": "Custom"
"Gesture Button": "ジェスチャーボタン"
"Up": "上"
"Down": "下"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "画面をロック"
"Screenshot": "スクリーンショット"
+"Sleep": "スリープ"
"Capture Region": "範囲をキャプチャ"
"Play / Pause": "再生 / 一時停止"
"Next Track": "次の曲"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "マウスイベントストリームを監視している他のアプリを検出します。ポインタ遅延のよくある原因です。"
"No other app is intercepting mouse input.": "マウス入力を横取りしている他のアプリはありません。"
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "別のアプリがマウス入力を横取りしています。ポインタの遅延やボタンの二重動作の原因になることがあります:%{apps}"
+"Camera": "カメラ"
+"Starting preview…": "プレビューを開始しています…"
+"Enable Camera access in Settings to preview.": "プレビューするには、設定でカメラへのアクセスを有効にしてください。"
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Logitech のウェブカメラはメインページに表示されます。アクセスを許可するとライブプレビューを確認できます。映像が Mac の外に出ることはありません。"
+"Brightness": "明るさ"
+"Contrast": "コントラスト"
+"Saturation": "彩度"
+"Sharpness": "シャープネス"
+"Camera controls": "カメラコントロール"
+"Reset to defaults": "デフォルトに戻す"
+"This camera exposes no adjustable image controls.": "このカメラには調整可能な画質コントロールがありません。"
+"Focus": "フォーカス"
+"Exposure": "露出"
+"White balance": "ホワイトバランス"
+"Tint": "色合い"
+"Auto": "自動"
+"Lens": "レンズ"
+"Image": "画像"
+"Streaming": "配信"
+"Video call": "ビデオ通話"
+"New": "新規"
+"Live preview isn't available on this platform yet.": "ライブプレビューはこのプラットフォームではまだ利用できません。"
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/ko.yml b/crates/openlogi-gui/locales/ko.yml
index a22c1965ab2e20a49b06c7c039d0e8623e06a11f..1f49fdee872734c3013460f614afea21f595639c 100644
--- a/crates/openlogi-gui/locales/ko.yml
+++ b/crates/openlogi-gui/locales/ko.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "연결된 기기가 없습니다"
"Devices": "기기"
"Buttons": "버튼"
+"Keys": "Keys"
"Pointer": "포인터"
"Lighting": "조명"
"LIGHTING": "조명"
"BRIGHTNESS": "밝기"
+"COLOUR TEMPERATURE": "색온도"
"On": "켜짐"
"Off": "꺼짐"
"Open OpenLogi": "OpenLogi 열기"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "앞으로"
"DPI Toggle": "DPI 전환"
"Thumb Wheel": "썸휠"
-"Back / Forward": "뒤로 / 앞으로"
-"Undo / Redo": "실행 취소 / 다시 실행"
-"Browser Back / Forward": "브라우저 뒤로 / 브라우저 앞으로"
-"Previous / Next Tab": "이전 탭 / 다음 탭"
-"Previous / Next Desktop": "이전 데스크탑 / 다음 데스크탑"
-"Previous / Next Track": "이전 트랙 / 다음 트랙"
-"Volume Down / Up": "볼륨 낮추기 / 볼륨 높이기"
-"Vertical Scroll": "아래로 스크롤 / 위로 스크롤"
-"Horizontal Scroll": "왼쪽으로 스크롤 / 오른쪽으로 스크롤"
-"Custom": "Custom"
"Gesture Button": "제스처 버튼"
"Up": "위"
"Down": "아래"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "런치패드"
"Lock Screen": "화면 잠금"
"Screenshot": "스크린샷"
+"Sleep": "잠자기"
"Capture Region": "영역 캡처"
"Play / Pause": "재생 / 일시정지"
"Next Track": "다음 트랙"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "마우스 이벤트 스트림을 가로채는 다른 앱을 감지합니다. 포인터 지연의 흔한 원인입니다."
"No other app is intercepting mouse input.": "마우스 입력을 가로채는 다른 앱이 없습니다."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "다른 앱이 마우스 입력을 가로채고 있어 포인터 지연이나 버튼 중복 동작이 발생할 수 있습니다: %{apps}"
+"Camera": "카메라"
+"Starting preview…": "미리 보기를 시작하는 중…"
+"Enable Camera access in Settings to preview.": "미리 보려면 설정에서 카메라 액세스를 허용하세요."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Logitech 웹캠이 메인 페이지에 표시됩니다. 액세스를 허용하면 실시간 미리 보기를 볼 수 있습니다 — 영상은 Mac을 벗어나지 않습니다."
+"Brightness": "밝기"
+"Contrast": "대비"
+"Saturation": "채도"
+"Sharpness": "선명도"
+"Camera controls": "카메라 컨트롤"
+"Reset to defaults": "기본값으로 재설정"
+"This camera exposes no adjustable image controls.": "이 카메라에는 조정 가능한 이미지 컨트롤이 없습니다."
+"Focus": "초점"
+"Exposure": "노출"
+"White balance": "화이트 밸런스"
+"Tint": "틴트"
+"Auto": "자동"
+"Lens": "렌즈"
+"Image": "이미지"
+"Streaming": "스트리밍"
+"Video call": "영상 통화"
+"New": "신규"
+"Live preview isn't available on this platform yet.": "라이브 미리보기는 이 플랫폼에서 아직 지원되지 않습니다."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/nb.yml b/crates/openlogi-gui/locales/nb.yml
index 4006254be6e19fac66eab7adf91da576d9f66e19..dd26bfc7b1fdea62d4e3f59804893875a4773be8 100644
--- a/crates/openlogi-gui/locales/nb.yml
+++ b/crates/openlogi-gui/locales/nb.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Ingen enhet tilkoblet"
"Devices": "Enheter"
"Buttons": "Knapper"
+"Keys": "Keys"
"Pointer": "Peker"
"Lighting": "Belysning"
"LIGHTING": "BELYSNING"
"BRIGHTNESS": "LYSSTYRKE"
+"COLOUR TEMPERATURE": "FARGETEMPERATUR"
"On": "På"
"Off": "Av"
"Open OpenLogi": "Åpne OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Frem"
"DPI Toggle": "DPI-veksling"
"Thumb Wheel": "Tommelhjul"
-"Back / Forward": "Tilbake / Frem"
-"Undo / Redo": "Angre / Gjør om"
-"Browser Back / Forward": "Nettleser tilbake / Nettleser frem"
-"Previous / Next Tab": "Forrige fane / Neste fane"
-"Previous / Next Desktop": "Forrige skrivebord / Neste skrivebord"
-"Previous / Next Track": "Forrige spor / Neste spor"
-"Volume Down / Up": "Volum ned / Volum opp"
-"Vertical Scroll": "Rull ned / Rull opp"
-"Horizontal Scroll": "Rull til venstre / Rull til høyre"
-"Custom": "Custom"
"Gesture Button": "Bevegelsesknapp"
"Up": "Opp"
"Down": "Ned"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Lås skjerm"
"Screenshot": "Skjermbilde"
+"Sleep": "Dvale"
"Capture Region": "Ta opp område"
"Play / Pause": "Spill av / pause"
"Next Track": "Neste spor"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Oppdager andre apper som fanger opp musens hendelsesstrøm – en vanlig årsak til pekerforsinkelse."
"No other app is intercepting mouse input.": "Ingen annen app fanger opp museinndata."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "En annen app fanger opp museinndata, noe som kan føre til pekerforsinkelse eller dupliserte knappehandlinger: %{apps}"
+"Camera": "Kamera"
+"Starting preview…": "Starter forhåndsvisning…"
+"Enable Camera access in Settings to preview.": "Aktiver kameratilgang i Innstillinger for å forhåndsvise."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Logitech-webkameraet ditt vises på hovedsiden. Gi tilgang for å se sanntidsforhåndsvisningen — videoen forlater aldri Mac-en din."
+"Brightness": "Lysstyrke"
+"Contrast": "Kontrast"
+"Saturation": "Metning"
+"Sharpness": "Skarphet"
+"Camera controls": "Kamerakontroller"
+"Reset to defaults": "Tilbakestill til standard"
+"This camera exposes no adjustable image controls.": "Dette kameraet har ingen justerbare bildekontroller."
+"Focus": "Fokus"
+"Exposure": "Eksponering"
+"White balance": "Hvitbalanse"
+"Tint": "Fargetone"
+"Auto": "Auto"
+"Lens": "Objektiv"
+"Image": "Bilde"
+"Streaming": "Strømming"
+"Video call": "Videosamtale"
+"New": "Ny"
+"Live preview isn't available on this platform yet.": "Direkte forhåndsvisning er ikke tilgjengelig på denne plattformen ennå."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/nl.yml b/crates/openlogi-gui/locales/nl.yml
index 008f301666465ddf984c4799854f2bb9175e125e..c9569cd9013dd0adce7d20a1372e9ff22ac62710 100644
--- a/crates/openlogi-gui/locales/nl.yml
+++ b/crates/openlogi-gui/locales/nl.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Geen apparaat verbonden"
"Devices": "Apparaten"
"Buttons": "Knoppen"
+"Keys": "Keys"
"Pointer": "Aanwijzer"
"Lighting": "Verlichting"
"LIGHTING": "VERLICHTING"
"BRIGHTNESS": "HELDERHEID"
+"COLOUR TEMPERATURE": "KLEURTEMPERATUUR"
"On": "Aan"
"Off": "Uit"
"Open OpenLogi": "OpenLogi openen"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Volgende"
"DPI Toggle": "DPI wisselen"
"Thumb Wheel": "Duimwiel"
-"Back / Forward": "Vorige / Volgende"
-"Undo / Redo": "Maak ongedaan / Opnieuw"
-"Browser Back / Forward": "Browser vorige / Browser volgende"
-"Previous / Next Tab": "Vorig tabblad / Volgend tabblad"
-"Previous / Next Desktop": "Vorig bureaublad / Volgend bureaublad"
-"Previous / Next Track": "Vorig nummer / Volgend nummer"
-"Volume Down / Up": "Volume omlaag / Volume omhoog"
-"Vertical Scroll": "Omlaag scrollen / Omhoog scrollen"
-"Horizontal Scroll": "Naar links scrollen / Naar rechts scrollen"
-"Custom": "Custom"
"Gesture Button": "Gebarenknop"
"Up": "Omhoog"
"Down": "Omlaag"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Scherm vergrendelen"
"Screenshot": "Schermafbeelding"
+"Sleep": "Sluimer"
"Capture Region": "Gebied vastleggen"
"Play / Pause": "Afspelen / pauzeren"
"Next Track": "Volgend nummer"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detecteert andere apps die de muisgebeurtenissenstroom onderscheppen — een veelvoorkomende oorzaak van vertraging van de aanwijzer."
"No other app is intercepting mouse input.": "Geen andere app onderschept muisinvoer."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Een andere app onderschept muisinvoer, wat kan leiden tot vertraging van de aanwijzer of dubbele knopacties: %{apps}"
+"Camera": "Camera"
+"Starting preview…": "Voorbeeld starten…"
+"Enable Camera access in Settings to preview.": "Schakel cameratoegang in bij Instellingen om een voorbeeld te zien."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Je Logitech-webcam verschijnt op de hoofdpagina. Verleen toegang om het live voorbeeld te zien — video verlaat nooit je Mac."
+"Brightness": "Helderheid"
+"Contrast": "Contrast"
+"Saturation": "Verzadiging"
+"Sharpness": "Scherpte"
+"Camera controls": "Camerabediening"
+"Reset to defaults": "Standaardwaarden herstellen"
+"This camera exposes no adjustable image controls.": "Deze camera heeft geen instelbare beeldregelaars."
+"Focus": "Focus"
+"Exposure": "Belichting"
+"White balance": "Witbalans"
+"Tint": "Tint"
+"Auto": "Auto"
+"Lens": "Lens"
+"Image": "Beeld"
+"Streaming": "Streaming"
+"Video call": "Videogesprek"
+"New": "Nieuw"
+"Live preview isn't available on this platform yet.": "Livevoorbeeld is nog niet beschikbaar op dit platform."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/pl.yml b/crates/openlogi-gui/locales/pl.yml
index dc57ce0a7e7fbdf85b11bf734d272ffea2059d56..bda10f6e8da0cb84a93adc93a8415965ccbb8521 100644
--- a/crates/openlogi-gui/locales/pl.yml
+++ b/crates/openlogi-gui/locales/pl.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Brak podłączonego urządzenia"
"Devices": "Urządzenia"
"Buttons": "Przyciski"
+"Keys": "Keys"
"Pointer": "Wskaźnik"
"Lighting": "Podświetlenie"
"LIGHTING": "PODŚWIETLENIE"
"BRIGHTNESS": "JASNOŚĆ"
+"COLOUR TEMPERATURE": "TEMPERATURA BARWOWA"
"On": "Wł."
"Off": "Wył."
"Open OpenLogi": "Otwórz OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Do przodu"
"DPI Toggle": "Przełącznik DPI"
"Thumb Wheel": "Rolka kciukowa"
-"Back / Forward": "Wstecz / Do przodu"
-"Undo / Redo": "Cofnij / Ponów"
-"Browser Back / Forward": "Wstecz w przeglądarce / Do przodu w przeglądarce"
-"Previous / Next Tab": "Poprzednia karta / Następna karta"
-"Previous / Next Desktop": "Poprzedni pulpit / Następny pulpit"
-"Previous / Next Track": "Poprzedni utwór / Następny utwór"
-"Volume Down / Up": "Ciszej / Głośniej"
-"Vertical Scroll": "Przewiń w dół / Przewiń w górę"
-"Horizontal Scroll": "Przewiń w lewo / Przewiń w prawo"
-"Custom": "Custom"
"Gesture Button": "Przycisk gestów"
"Up": "W górę"
"Down": "W dół"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Zablokuj ekran"
"Screenshot": "Zrzut ekranu"
+"Sleep": "Uśpij"
"Capture Region": "Przechwyć obszar"
"Play / Pause": "Odtwarzaj / Wstrzymaj"
"Next Track": "Następny utwór"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Wykrywa inne aplikacje przechwytujące strumień zdarzeń myszy — częstą przyczynę opóźnień wskaźnika."
"No other app is intercepting mouse input.": "Żadna inna aplikacja nie przechwytuje danych wejściowych myszy."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Inna aplikacja przechwytuje dane wejściowe myszy, co może powodować opóźnienia wskaźnika lub zdublowane działania przycisków: %{apps}"
+"Camera": "Kamera"
+"Starting preview…": "Uruchamianie podglądu…"
+"Enable Camera access in Settings to preview.": "Włącz dostęp do kamery w Ustawieniach, aby zobaczyć podgląd."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Twoja kamera Logitech pojawia się na stronie głównej. Przyznaj dostęp, aby zobaczyć podgląd na żywo — wideo nigdy nie opuszcza Twojego Maca."
+"Brightness": "Jasność"
+"Contrast": "Kontrast"
+"Saturation": "Nasycenie"
+"Sharpness": "Ostrość"
+"Camera controls": "Sterowanie kamerą"
+"Reset to defaults": "Przywróć domyślne"
+"This camera exposes no adjustable image controls.": "Ta kamera nie udostępnia regulowanych ustawień obrazu."
+"Focus": "Ostrość"
+"Exposure": "Ekspozycja"
+"White balance": "Balans bieli"
+"Tint": "Odcień"
+"Auto": "Auto"
+"Lens": "Obiektyw"
+"Image": "Obraz"
+"Streaming": "Streaming"
+"Video call": "Rozmowa wideo"
+"New": "Nowy"
+"Live preview isn't available on this platform yet.": "Podgląd na żywo nie jest jeszcze dostępny na tej platformie."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/pt-BR.yml b/crates/openlogi-gui/locales/pt-BR.yml
index 3b91a0b4d635a09e199cac328a0ab422c31910c8..f1f1ca9cde78dd9bb3b7737ac89fc89bda45da7e 100644
--- a/crates/openlogi-gui/locales/pt-BR.yml
+++ b/crates/openlogi-gui/locales/pt-BR.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Nenhum dispositivo conectado"
"Devices": "Dispositivos"
"Buttons": "Botões"
+"Keys": "Keys"
"Pointer": "Ponteiro"
"Lighting": "Iluminação"
"LIGHTING": "ILUMINAÇÃO"
"BRIGHTNESS": "BRILHO"
+"COLOUR TEMPERATURE": "TEMPERATURA DA COR"
"On": "Ligado"
"Off": "Desligado"
"Open OpenLogi": "Abrir o OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Avançar"
"DPI Toggle": "Alternar DPI"
"Thumb Wheel": "Roda do Polegar"
-"Back / Forward": "Voltar / Avançar"
-"Undo / Redo": "Desfazer / Refazer"
-"Browser Back / Forward": "Voltar no Navegador / Avançar no Navegador"
-"Previous / Next Tab": "Aba Anterior / Próxima Aba"
-"Previous / Next Desktop": "Mesa Anterior / Próxima Mesa"
-"Previous / Next Track": "Faixa Anterior / Próxima Faixa"
-"Volume Down / Up": "Diminuir Volume / Aumentar Volume"
-"Vertical Scroll": "Rolar para Baixo / Rolar para Cima"
-"Horizontal Scroll": "Rolar para a Esquerda / Rolar para a Direita"
-"Custom": "Custom"
"Gesture Button": "Botão de Gesto"
"Up": "Cima"
"Down": "Baixo"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Bloquear Tela"
"Screenshot": "Captura de Tela"
+"Sleep": "Repouso"
"Capture Region": "Capturar Região"
"Play / Pause": "Reproduzir / Pausar"
"Next Track": "Próxima Faixa"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detecta outros apps que interceptam o fluxo de eventos do mouse — uma causa comum de lentidão do ponteiro."
"No other app is intercepting mouse input.": "Nenhum outro app está interceptando a entrada do mouse."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Outro app está interceptando a entrada do mouse, o que pode causar lentidão do ponteiro ou ações de botão duplicadas: %{apps}"
+"Camera": "Câmera"
+"Starting preview…": "Iniciando visualização…"
+"Enable Camera access in Settings to preview.": "Ative o acesso à câmera nas Configurações para visualizar."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Sua webcam Logitech aparece na página principal. Conceda acesso para ver a visualização ao vivo — o vídeo nunca sai do seu Mac."
+"Brightness": "Brilho"
+"Contrast": "Contraste"
+"Saturation": "Saturação"
+"Sharpness": "Nitidez"
+"Camera controls": "Controles da câmera"
+"Reset to defaults": "Restaurar padrões"
+"This camera exposes no adjustable image controls.": "Esta câmera não oferece controles de imagem ajustáveis."
+"Focus": "Foco"
+"Exposure": "Exposição"
+"White balance": "Balanço de branco"
+"Tint": "Matiz"
+"Auto": "Auto"
+"Lens": "Lente"
+"Image": "Imagem"
+"Streaming": "Streaming"
+"Video call": "Videochamada"
+"New": "Novo"
+"Live preview isn't available on this platform yet.": "A pré-visualização ao vivo ainda não está disponível nesta plataforma."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/pt-PT.yml b/crates/openlogi-gui/locales/pt-PT.yml
index 24b8821ebd66c51f1dd7bfe91292d7b011a31a1d..8ed4c5e03c517865883922025369de75191d4301 100644
--- a/crates/openlogi-gui/locales/pt-PT.yml
+++ b/crates/openlogi-gui/locales/pt-PT.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Nenhum dispositivo ligado"
"Devices": "Dispositivos"
"Buttons": "Botões"
+"Keys": "Keys"
"Pointer": "Ponteiro"
"Lighting": "Iluminação"
"LIGHTING": "ILUMINAÇÃO"
"BRIGHTNESS": "BRILHO"
+"COLOUR TEMPERATURE": "TEMPERATURA DA COR"
"On": "Ativado"
"Off": "Desativado"
"Open OpenLogi": "Abrir o OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Avançar"
"DPI Toggle": "Alternar DPI"
"Thumb Wheel": "Roda de polegar"
-"Back / Forward": "Retroceder / Avançar"
-"Undo / Redo": "Anular / Refazer"
-"Browser Back / Forward": "Retroceder no navegador / Avançar no navegador"
-"Previous / Next Tab": "Separador anterior / Separador seguinte"
-"Previous / Next Desktop": "Secretária anterior / Secretária seguinte"
-"Previous / Next Track": "Faixa anterior / Faixa seguinte"
-"Volume Down / Up": "Diminuir volume / Aumentar volume"
-"Vertical Scroll": "Deslocar para baixo / Deslocar para cima"
-"Horizontal Scroll": "Deslocar para a esquerda / Deslocar para a direita"
-"Custom": "Custom"
"Gesture Button": "Botão de gesto"
"Up": "Cima"
"Down": "Baixo"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Bloquear ecrã"
"Screenshot": "Captura de ecrã"
+"Sleep": "Pausa"
"Capture Region": "Capturar região"
"Play / Pause": "Reproduzir / Pausa"
"Next Track": "Faixa seguinte"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Deteta outras apps que intercetam o fluxo de eventos do rato — uma causa comum de lentidão do ponteiro."
"No other app is intercepting mouse input.": "Nenhuma outra app está a intercetar a entrada do rato."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Outra app está a intercetar a entrada do rato, o que pode causar lentidão do ponteiro ou ações de botão duplicadas: %{apps}"
+"Camera": "Câmara"
+"Starting preview…": "A iniciar a pré-visualização…"
+"Enable Camera access in Settings to preview.": "Ative o acesso à câmara nas Definições para pré-visualizar."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "A sua webcam Logitech aparece na página principal. Conceda acesso para ver a pré-visualização em direto — o vídeo nunca sai do seu Mac."
+"Brightness": "Brilho"
+"Contrast": "Contraste"
+"Saturation": "Saturação"
+"Sharpness": "Nitidez"
+"Camera controls": "Controlos da câmara"
+"Reset to defaults": "Repor predefinições"
+"This camera exposes no adjustable image controls.": "Esta câmara não oferece controlos de imagem ajustáveis."
+"Focus": "Focagem"
+"Exposure": "Exposição"
+"White balance": "Equilíbrio de brancos"
+"Tint": "Tonalidade"
+"Auto": "Auto"
+"Lens": "Objetiva"
+"Image": "Imagem"
+"Streaming": "Streaming"
+"Video call": "Videochamada"
+"New": "Novo"
+"Live preview isn't available on this platform yet.": "A pré-visualização em direto ainda não está disponível nesta plataforma."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/ru.yml b/crates/openlogi-gui/locales/ru.yml
index 5d1958348b6d399e76f6962e30b4193385ac6195..25889c7e61e84fa1d034f7fb5cf43aacaa239445 100644
--- a/crates/openlogi-gui/locales/ru.yml
+++ b/crates/openlogi-gui/locales/ru.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Устройство не подключено"
"Devices": "Устройства"
"Buttons": "Кнопки"
+"Keys": "Keys"
"Pointer": "Указатель"
"Lighting": "Подсветка"
"LIGHTING": "ПОДСВЕТКА"
"BRIGHTNESS": "ЯРКОСТЬ"
+"COLOUR TEMPERATURE": "ЦВЕТОВАЯ ТЕМПЕРАТУРА"
"On": "Вкл"
"Off": "Выкл"
"Open OpenLogi": "Открыть OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Вперёд"
"DPI Toggle": "Переключение DPI"
"Thumb Wheel": "Колесо под большой палец"
-"Back / Forward": "Назад / Вперёд"
-"Undo / Redo": "Отменить / Повторить"
-"Browser Back / Forward": "Назад в браузере / Вперёд в браузере"
-"Previous / Next Tab": "Предыдущая вкладка / Следующая вкладка"
-"Previous / Next Desktop": "Предыдущий рабочий стол / Следующий рабочий стол"
-"Previous / Next Track": "Предыдущий трек / Следующий трек"
-"Volume Down / Up": "Уменьшить громкость / Увеличить громкость"
-"Vertical Scroll": "Прокрутить вниз / Прокрутить вверх"
-"Horizontal Scroll": "Прокрутить влево / Прокрутить вправо"
-"Custom": "Custom"
"Gesture Button": "Кнопка жестов"
"Up": "Вверх"
"Down": "Вниз"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Заблокировать экран"
"Screenshot": "Снимок экрана"
+"Sleep": "Режим сна"
"Capture Region": "Снимок области"
"Play / Pause": "Воспроизведение / пауза"
"Next Track": "Следующий трек"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Обнаруживает другие приложения, перехватывающие поток событий мыши, — частая причина задержки указателя."
"No other app is intercepting mouse input.": "Никакое другое приложение не перехватывает ввод мыши."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Другое приложение перехватывает ввод мыши, что может вызывать задержку указателя или дублирование действий кнопок: %{apps}"
+"Camera": "Камера"
+"Starting preview…": "Запуск предпросмотра…"
+"Enable Camera access in Settings to preview.": "Чтобы увидеть предпросмотр, включите доступ к камере в настройках."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Ваша веб-камера Logitech отображается на главной странице. Предоставьте доступ, чтобы увидеть прямой предпросмотр — видео никогда не покидает ваш Mac."
+"Brightness": "Яркость"
+"Contrast": "Контраст"
+"Saturation": "Насыщенность"
+"Sharpness": "Резкость"
+"Camera controls": "Элементы управления камерой"
+"Reset to defaults": "Сбросить настройки"
+"This camera exposes no adjustable image controls.": "Эта камера не имеет регулируемых параметров изображения."
+"Focus": "Фокус"
+"Exposure": "Экспозиция"
+"White balance": "Баланс белого"
+"Tint": "Оттенок"
+"Auto": "Авто"
+"Lens": "Объектив"
+"Image": "Изображение"
+"Streaming": "Стриминг"
+"Video call": "Видеозвонок"
+"New": "Новый"
+"Live preview isn't available on this platform yet.": "Предпросмотр в реальном времени пока недоступен на этой платформе."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/sv.yml b/crates/openlogi-gui/locales/sv.yml
index 37b9b7cd29828829cfc4a51d879c48726accfb55..2b0fc5558c1bb418a4bc33579a178ec206d99ddc 100644
--- a/crates/openlogi-gui/locales/sv.yml
+++ b/crates/openlogi-gui/locales/sv.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "Ingen enhet ansluten"
"Devices": "Enheter"
"Buttons": "Knappar"
+"Keys": "Keys"
"Pointer": "Pekare"
"Lighting": "Belysning"
"LIGHTING": "BELYSNING"
"BRIGHTNESS": "LJUSSTYRKA"
+"COLOUR TEMPERATURE": "FÄRGTEMPERATUR"
"On": "På"
"Off": "Av"
"Open OpenLogi": "Öppna OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "Framåt"
"DPI Toggle": "DPI-växling"
"Thumb Wheel": "Tumhjul"
-"Back / Forward": "Bakåt / Framåt"
-"Undo / Redo": "Ångra / Gör om"
-"Browser Back / Forward": "Webbläsare bakåt / Webbläsare framåt"
-"Previous / Next Tab": "Föregående flik / Nästa flik"
-"Previous / Next Desktop": "Föregående skrivbord / Nästa skrivbord"
-"Previous / Next Track": "Föregående spår / Nästa spår"
-"Volume Down / Up": "Sänk volymen / Höj volymen"
-"Vertical Scroll": "Rulla ned / Rulla upp"
-"Horizontal Scroll": "Rulla vänster / Rulla höger"
-"Custom": "Custom"
"Gesture Button": "Gestknapp"
"Up": "Upp"
"Down": "Ned"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "Launchpad"
"Lock Screen": "Lås skärmen"
"Screenshot": "Skärmavbild"
+"Sleep": "Vila"
"Capture Region": "Fånga område"
"Play / Pause": "Spela upp / Pausa"
"Next Track": "Nästa spår"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Upptäcker andra appar som fångar upp musens händelseström – en vanlig orsak till pekarfördröjning."
"No other app is intercepting mouse input.": "Ingen annan app fångar upp musinmatning."
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "En annan app fångar upp musinmatning, vilket kan orsaka pekarfördröjning eller dubblerade knappåtgärder: %{apps}"
+"Camera": "Kamera"
+"Starting preview…": "Startar förhandsvisning…"
+"Enable Camera access in Settings to preview.": "Aktivera kameraåtkomst i Inställningar för att förhandsgranska."
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "Din Logitech-webbkamera visas på huvudsidan. Ge åtkomst för att se live-förhandsvisningen — videon lämnar aldrig din Mac."
+"Brightness": "Ljusstyrka"
+"Contrast": "Kontrast"
+"Saturation": "Mättnad"
+"Sharpness": "Skärpa"
+"Camera controls": "Kamerakontroller"
+"Reset to defaults": "Återställ till standard"
+"This camera exposes no adjustable image controls.": "Den här kameran har inga justerbara bildkontroller."
+"Focus": "Fokus"
+"Exposure": "Exponering"
+"White balance": "Vitbalans"
+"Tint": "Färgton"
+"Auto": "Auto"
+"Lens": "Objektiv"
+"Image": "Bild"
+"Streaming": "Strömning"
+"Video call": "Videosamtal"
+"New": "Ny"
+"Live preview isn't available on this platform yet.": "Direktförhandsvisning är inte tillgänglig på den här plattformen ännu."
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/zh-CN.yml b/crates/openlogi-gui/locales/zh-CN.yml
index 602caff010a3240945366c9d91e0982681b6cce8..fe84055386d4e44a0fc70f2acb630d6acf39c27b 100644
--- a/crates/openlogi-gui/locales/zh-CN.yml
+++ b/crates/openlogi-gui/locales/zh-CN.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "未连接设备"
"Devices": "设备"
"Buttons": "按键"
+"Keys": "Keys"
"Pointer": "指针"
"Lighting": "灯光"
"LIGHTING": "灯光"
"BRIGHTNESS": "亮度"
+"COLOUR TEMPERATURE": "色温"
"On": "开"
"Off": "关"
"Open OpenLogi": "打开 OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "前进"
"DPI Toggle": "灵敏度切换"
"Thumb Wheel": "拇指滚轮"
-"Back / Forward": "后退 / 前进"
-"Undo / Redo": "撤销 / 重做"
-"Browser Back / Forward": "浏览器后退 / 浏览器前进"
-"Previous / Next Tab": "上一个标签页 / 下一个标签页"
-"Previous / Next Desktop": "上一个桌面 / 下一个桌面"
-"Previous / Next Track": "上一首 / 下一首"
-"Volume Down / Up": "减小音量 / 增大音量"
-"Vertical Scroll": "向下滚动 / 向上滚动"
-"Horizontal Scroll": "向左滚动 / 向右滚动"
-"Custom": "Custom"
"Gesture Button": "手势按钮"
"Up": "上"
"Down": "下"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "启动台"
"Lock Screen": "锁定屏幕"
"Screenshot": "截屏"
+"Sleep": "睡眠"
"Capture Region": "截取区域"
"Play / Pause": "播放 / 暂停"
"Next Track": "下一首"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "检测其它正在监听鼠标事件流的应用——这是指针卡顿的常见原因。"
"No other app is intercepting mouse input.": "没有其它应用在拦截鼠标输入。"
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "另一个应用正在拦截鼠标输入,可能导致指针卡顿或按键重复触发:%{apps}"
+"Camera": "摄像头"
+"Starting preview…": "正在启动预览…"
+"Enable Camera access in Settings to preview.": "在设置中启用摄像头访问权限以进行预览。"
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "您的 Logitech 网络摄像头会显示在主页面上。授予访问权限即可查看实时预览——视频绝不会离开您的 Mac。"
+"Brightness": "亮度"
+"Contrast": "对比度"
+"Saturation": "饱和度"
+"Sharpness": "锐度"
+"Camera controls": "摄像头控制"
+"Reset to defaults": "恢复默认值"
+"This camera exposes no adjustable image controls.": "此摄像头没有可调节的图像控制项。"
+"Focus": "对焦"
+"Exposure": "曝光"
+"White balance": "白平衡"
+"Tint": "色调"
+"Auto": "自动"
+"Lens": "镜头"
+"Image": "图像"
+"Streaming": "直播"
+"Video call": "视频通话"
+"New": "新建"
+"Live preview isn't available on this platform yet.": "实时预览暂不支持此平台。"
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/zh-HK.yml b/crates/openlogi-gui/locales/zh-HK.yml
index bbb4d9965cd8f4922bddfaa28ddd939cb34204ee..22ddda3388d2e72a4e23add05efdd757691d24d6 100644
--- a/crates/openlogi-gui/locales/zh-HK.yml
+++ b/crates/openlogi-gui/locales/zh-HK.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "未連接裝置"
"Devices": "裝置"
"Buttons": "按鍵"
+"Keys": "Keys"
"Pointer": "指標"
"Lighting": "燈光"
"LIGHTING": "燈光"
"BRIGHTNESS": "亮度"
+"COLOUR TEMPERATURE": "色溫"
"On": "開"
"Off": "關"
"Open OpenLogi": "開啟 OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "前進"
"DPI Toggle": "靈敏度切換"
"Thumb Wheel": "拇指滾輪"
-"Back / Forward": "後退 / 前進"
-"Undo / Redo": "復原 / 重做"
-"Browser Back / Forward": "瀏覽器後退 / 瀏覽器前進"
-"Previous / Next Tab": "上一個分頁 / 下一個分頁"
-"Previous / Next Desktop": "上一個桌面 / 下一個桌面"
-"Previous / Next Track": "上一首 / 下一首"
-"Volume Down / Up": "調低音量 / 調高音量"
-"Vertical Scroll": "向下捲動 / 向上捲動"
-"Horizontal Scroll": "向左捲動 / 向右捲動"
-"Custom": "Custom"
"Gesture Button": "手勢按鈕"
"Up": "上"
"Down": "下"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "啟動台"
"Lock Screen": "鎖定螢幕"
"Screenshot": "截圖"
+"Sleep": "睡眠"
"Capture Region": "擷取區域"
"Play / Pause": "播放 / 暫停"
"Next Track": "下一首"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "偵測其他正在監聽滑鼠事件流的應用程式——這是指標延遲的常見原因。"
"No other app is intercepting mouse input.": "沒有其他應用程式正在攔截滑鼠輸入。"
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "另一個應用程式正在攔截滑鼠輸入,可能導致指標延遲或按鍵重複觸發:%{apps}"
+"Camera": "攝影機"
+"Starting preview…": "正在啟動預覽…"
+"Enable Camera access in Settings to preview.": "在設定中啟用攝影機存取權限即可預覽。"
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "您的 Logitech 網絡攝影機會顯示在主頁面上。授予存取權限即可查看即時預覽——影像絕不會離開您的 Mac。"
+"Brightness": "亮度"
+"Contrast": "對比度"
+"Saturation": "飽和度"
+"Sharpness": "銳利度"
+"Camera controls": "攝影機控制項"
+"Reset to defaults": "重設為預設值"
+"This camera exposes no adjustable image controls.": "此攝影機沒有可調整的影像控制項。"
+"Focus": "對焦"
+"Exposure": "曝光"
+"White balance": "白平衡"
+"Tint": "色調"
+"Auto": "自動"
+"Lens": "鏡頭"
+"Image": "影像"
+"Streaming": "直播"
+"Video call": "視像通話"
+"New": "新增"
+"Live preview isn't available on this platform yet.": "此平台暫不支援即時預覽。"
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/locales/zh-TW.yml b/crates/openlogi-gui/locales/zh-TW.yml
index 593ad830ffbe919b76ea721848b5c7d1eb778295..6a638c973f04f4595ccc9e5ac20e8ac1bcd1a749 100644
--- a/crates/openlogi-gui/locales/zh-TW.yml
+++ b/crates/openlogi-gui/locales/zh-TW.yml
@@ -3,10 +3,12 @@ _version: 1
"No devices connected": "沒有已連線的裝置"
"Devices": "裝置"
"Buttons": "按鍵"
+"Keys": "Keys"
"Pointer": "指標"
"Lighting": "燈光"
"LIGHTING": "燈光"
"BRIGHTNESS": "亮度"
+"COLOUR TEMPERATURE": "色溫"
"On": "開"
"Off": "關"
"Open OpenLogi": "開啟 OpenLogi"
@@ -156,16 +158,6 @@ _version: 1
"Forward": "前進"
"DPI Toggle": "靈敏度切換"
"Thumb Wheel": "拇指滾輪"
-"Back / Forward": "後退 / 前進"
-"Undo / Redo": "復原 / 重做"
-"Browser Back / Forward": "瀏覽器上一頁 / 瀏覽器下一頁"
-"Previous / Next Tab": "上一個分頁 / 下一個分頁"
-"Previous / Next Desktop": "上一個桌面 / 下一個桌面"
-"Previous / Next Track": "上一首 / 下一首"
-"Volume Down / Up": "調低音量 / 調高音量"
-"Vertical Scroll": "向下捲動 / 向上捲動"
-"Horizontal Scroll": "向左捲動 / 向右捲動"
-"Custom": "Custom"
"Gesture Button": "手勢按鈕"
"Up": "上"
"Down": "下"
@@ -198,6 +190,7 @@ _version: 1
"Launchpad": "啟動台"
"Lock Screen": "鎖定螢幕"
"Screenshot": "截圖"
+"Sleep": "睡眠"
"Capture Region": "擷取區域"
"Play / Pause": "播放 / 暫停"
"Next Track": "下一首"
@@ -329,3 +322,35 @@ _version: 1
"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "偵測其他正在監聽滑鼠事件流的應用程式——這是指標延遲的常見原因。"
"No other app is intercepting mouse input.": "沒有其他應用程式在攔截滑鼠輸入。"
"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "另一個應用程式正在攔截滑鼠輸入,可能導致指標延遲或按鍵重複觸發:%{apps}"
+"Camera": "攝影機"
+"Starting preview…": "正在啟動預覽…"
+"Enable Camera access in Settings to preview.": "在設定中啟用攝影機存取權限即可預覽。"
+"Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac.": "您的 Logitech 網路攝影機會顯示在主頁面上。授予存取權限即可查看即時預覽——影像絕不會離開您的 Mac。"
+"Brightness": "亮度"
+"Contrast": "對比度"
+"Saturation": "飽和度"
+"Sharpness": "銳利度"
+"Camera controls": "攝影機控制項"
+"Reset to defaults": "重設為預設值"
+"This camera exposes no adjustable image controls.": "此攝影機沒有可調整的影像控制項。"
+"Focus": "對焦"
+"Exposure": "曝光"
+"White balance": "白平衡"
+"Tint": "色調"
+"Auto": "自動"
+"Lens": "鏡頭"
+"Image": "影像"
+"Streaming": "串流"
+"Video call": "視訊通話"
+"New": "新增"
+"Live preview isn't available on this platform yet.": "此平台尚不支援即時預覽。"
+"Applying light setting…": "Applying light setting…"
+"Auto-on with camera": "Auto-on with camera"
+"Turn this light on while any camera is in use and off when cameras stop.": "Turn this light on while any camera is in use and off when cameras stop."
+"Power User": "Power User"
+"Type Text…": "Type Text…"
+"Run AppleScript…": "Run AppleScript…"
+"Run Shell Command…": "Run Shell Command…"
+"Workflow…": "Workflow…"
+"Save Workflow": "Save Workflow"
+"+ Add Step": "+ Add Step"
diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs
index ce7a006f69d3bdbc6e812fb346163a48868c6dd9..59555d2b9806c004816ca0b62afba2893ae0fd17 100644
--- a/crates/openlogi-gui/src/app.rs
+++ b/crates/openlogi-gui/src/app.rs
@@ -15,9 +15,13 @@ use openlogi_agent_core::ipc::InventoryHealth;
use crate::app_menu::{CloseWindow, Minimize, Zoom};
use crate::asset::AssetResolver;
+use crate::components::camera_controls::CameraControlsPanel;
+use crate::components::camera_preview::CameraPreview;
use crate::components::dpi_panel::DpiPanel;
+use crate::components::light_panel::LightPanel;
use crate::components::lighting_panel::LightingPanel;
use crate::components::smartshift_panel::SmartShiftPanel;
+use crate::keyboard_model::function_row::FunctionRowView;
use crate::mouse_model::view::MouseModelView;
use crate::state::{AgentLink, AppState, DeviceRecord};
use crate::theme::{self, Palette, Typography as _};
@@ -31,6 +35,8 @@ mod widgets;
// gallery card, so it reaches these through the crate-stable `crate::app::…`
// path rather than the internal `app::home` submodule.
pub(crate) use home::{glow_canvas, keyboard_glow};
+// Tray menu and other crate-level callers need the cold-start charging quirk.
+pub(crate) use widgets::battery_charging_no_reading;
/// Which screen the root view is showing.
///
@@ -63,10 +69,16 @@ enum Route {
enum DetailTab {
/// The mouse model with clickable button hotspots.
Buttons,
+ /// The keyboard function-row remapper with clickable F-key bubbles.
+ Keys,
/// Pointer tuning — DPI and presets.
Pointer,
/// RGB lighting — color, brightness, on/off.
Lighting,
+ /// Live webcam preview (UVC cameras only).
+ Camera,
+ /// Standalone light controls driven by a raw-HID device driver.
+ Light,
/// Device info and configuration.
Device,
}
@@ -82,28 +94,38 @@ impl DetailTab {
/// measured capabilities; we presume a set from their kind so a sleeping
/// mouse still shows its (host-side) button bindings.
///
- /// The Buttons panel renders a *mouse-model* silhouette with hotspots. It is
- /// only useful for pointer-type devices (Mouse / Trackball) or when the device
- /// has a resolved asset that provides its own correct layout. A keyboard that
- /// exposes ReprogControls via HID++ but has no asset would get the generic
- /// mouse fallback hotspots — confusing and wrong. Suppress the Buttons tab for
- /// such devices until a proper keyboard-layout UI is available.
+ /// The Buttons panel renders a mouse-model silhouette with hotspots. It is
+ /// only useful for pointer-type devices; keyboards get the Keys panel
+ /// instead, even when they expose ReprogControls over HID++.
fn tabs_for(record: &DeviceRecord) -> Vec<Self> {
let caps = record
.capabilities
.unwrap_or_else(|| Capabilities::presumed_from_kind(record.kind));
- let can_show_mouse_model = record.asset.is_some()
- || matches!(record.kind, DeviceKind::Mouse | DeviceKind::Trackball);
+ // Buttons panel is a mouse-model silhouette — only for pointer devices.
+ // Keyboards get the Keys panel instead, even when they expose ReprogControls.
+ let can_show_mouse_model = matches!(record.kind, DeviceKind::Mouse | DeviceKind::Trackball);
let mut tabs = Vec::new();
+ // A webcam is a UVC device with no HID++ capabilities; its detail screen
+ // leads with the live preview, then the generic info tab.
+ if matches!(record.kind, DeviceKind::Camera) {
+ tabs.push(Self::Camera);
+ }
if caps.buttons && can_show_mouse_model {
tabs.push(Self::Buttons);
}
+ // Function-row remapper when the keyboard reports remappable buttons.
+ if matches!(record.kind, DeviceKind::Keyboard) && caps.buttons {
+ tabs.push(Self::Keys);
+ }
if caps.pointer {
tabs.push(Self::Pointer);
}
if caps.lighting {
tabs.push(Self::Lighting);
}
+ if record.light_capabilities.is_some() {
+ tabs.push(Self::Light);
+ }
tabs.push(Self::Device);
tabs
}
@@ -119,8 +141,10 @@ impl DetailTab {
fn label(self) -> gpui::SharedString {
match self {
Self::Buttons => tr!("Buttons"),
+ Self::Keys => tr!("Keys"),
Self::Pointer => tr!("Pointer"),
- Self::Lighting => tr!("Lighting"),
+ Self::Lighting | Self::Light => tr!("Lighting"),
+ Self::Camera => tr!("Camera"),
Self::Device => tr!("Device"),
}
}
@@ -131,9 +155,13 @@ pub struct AppView {
focus_handle: FocusHandle,
route: Route,
mouse_model: Entity<MouseModelView>,
+ keyboard_model: Entity<FunctionRowView>,
dpi_panel: Entity<DpiPanel>,
smartshift_panel: Entity<SmartShiftPanel>,
lighting_panel: Entity<LightingPanel>,
+ camera_preview: Entity<CameraPreview>,
+ camera_controls: Entity<CameraControlsPanel>,
+ light_panel: Entity<LightPanel>,
#[allow(dead_code, reason = "held to keep the appearance observer alive")]
appearance_obs: Option<Subscription>,
/// Re-renders the root when the device list changes so the empty state
@@ -175,17 +203,25 @@ impl AppView {
}
let mouse_model = cx.new(MouseModelView::new);
+ let keyboard_model = cx.new(FunctionRowView::new);
let dpi_panel = cx.new(DpiPanel::new);
let smartshift_panel = cx.new(SmartShiftPanel::new);
let lighting_panel = cx.new(LightingPanel::new);
+ let camera_preview = cx.new(CameraPreview::new);
+ let camera_controls = cx.new(CameraControlsPanel::new);
+ let light_panel = cx.new(LightPanel::new);
let state_obs = cx.observe_global::<AppState>(|_, cx| cx.notify());
Self {
focus_handle,
route: Route::Home,
mouse_model,
+ keyboard_model,
dpi_panel,
smartshift_panel,
lighting_panel,
+ camera_preview,
+ camera_controls,
+ light_panel,
appearance_obs: None,
state_obs,
accessibility_dismissed: false,
@@ -331,6 +367,10 @@ fn app_title_bar(pal: Palette) -> impl IntoElement {
}
impl Render for AppView {
+ #[allow(
+ clippy::too_many_lines,
+ reason = "root view assembles every screen branch inline"
+ )]
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let pal = theme::palette(cx);
@@ -431,13 +471,33 @@ impl Render for AppView {
} else {
tabs.first().copied().unwrap_or(DetailTab::Device)
};
+ // Run the camera only while its live-preview tab is the one on screen;
+ // any other tab, device, or Home tears the session down (LED off).
+ // Use capture_id (OS open id), not config_key — the latter prefers
+ // the port-stable USB serial and is not a valid AVFoundation id.
+ let camera_target = if active == DetailTab::Camera {
+ record
+ .as_ref()
+ .filter(|r| matches!(r.kind, DeviceKind::Camera))
+ .and_then(|r| r.capture_id.clone())
+ } else {
+ None
+ };
+ self.camera_preview
+ .update(cx, |preview, cx| preview.set_target(camera_target, cx));
(
detail::detail_header(record.as_ref(), &tabs, active, pal, cx).into_any_element(),
detail::detail_content(
- &self.mouse_model,
- &self.dpi_panel,
- &self.smartshift_panel,
- &self.lighting_panel,
+ &detail::DetailPanels {
+ mouse_model: &self.mouse_model,
+ keyboard_model: &self.keyboard_model,
+ dpi_panel: &self.dpi_panel,
+ smartshift_panel: &self.smartshift_panel,
+ lighting_panel: &self.lighting_panel,
+ camera_preview: &self.camera_preview,
+ camera_controls: &self.camera_controls,
+ light_panel: &self.light_panel,
+ },
active,
pal,
cx,
@@ -445,6 +505,8 @@ impl Render for AppView {
.into_any_element(),
)
} else {
+ self.camera_preview
+ .update(cx, |preview, cx| preview.set_target(None, cx));
(
home::home_header(pal).into_any_element(),
if has_device {
@@ -468,11 +530,44 @@ impl Render for AppView {
#[cfg(test)]
mod tests {
+ #![allow(
+ clippy::expect_used,
+ reason = "capability fixture construction is intentionally asserted in tests"
+ )]
+
use super::home::connection_icon_path;
- use super::{Capabilities, DetailTab, DeviceKind, DeviceRecord};
- use openlogi_core::device::DeviceTransports;
+ use super::{Capabilities, DetailTab, DeviceKind, DeviceRecord, battery_charging_no_reading};
+ use openlogi_core::device::{
+ BatteryInfo, BatteryLevel, BatteryStatus, DeviceTransports, LightCapabilities,
+ LightValueRange, LightValueUnit,
+ };
use openlogi_hid::DeviceRoute;
+ /// "Charging" replaces the bogus percentage only when charging *and* the
+ /// reading is still 0% (cold start, no cached pre-charge value). A non-zero
+ /// charge or a real 0% while discharging keeps the number.
+ #[test]
+ fn charging_without_reading_suppresses_percentage() {
+ let b = |percentage, status| BatteryInfo {
+ percentage,
+ level: BatteryLevel::Good,
+ status,
+ };
+ assert!(battery_charging_no_reading(&b(0, BatteryStatus::Charging)));
+ assert!(battery_charging_no_reading(&b(
+ 0,
+ BatteryStatus::ChargingSlow
+ )));
+ assert!(!battery_charging_no_reading(&b(
+ 40,
+ BatteryStatus::Charging
+ )));
+ assert!(!battery_charging_no_reading(&b(
+ 0,
+ BatteryStatus::Discharging
+ )));
+ }
+
#[test]
fn connection_icon_matches_route() {
let bolt = DeviceRoute::Bolt {
@@ -562,9 +657,13 @@ mod tests {
codename: None,
serial_number: None,
unit_id: [0; 4],
+ driver_id: None,
+ registry_model_id: None,
route: None,
+ capture_id: None,
kind,
capabilities,
+ light_capabilities: None,
slot: 1,
online: true,
battery: None,
@@ -612,6 +711,21 @@ mod tests {
assert!(tabs.contains(&DetailTab::Lighting));
}
+ #[test]
+ fn keyboard_with_buttons_shows_keys_tab() {
+ let caps = Some(Capabilities {
+ buttons: true,
+ pointer: false,
+ lighting: true,
+ scroll_inversion: false,
+ hires_wheel: false,
+ thumbwheel: false,
+ });
+ let tabs = DetailTab::tabs_for(&record(DeviceKind::Keyboard, caps));
+ assert!(tabs.contains(&DetailTab::Keys));
+ assert!(!tabs.contains(&DetailTab::Buttons));
+ }
+
/// Each panel is independent: a lighting-only device (e.g. a keyboard with
/// RGB but no remappable keys yet) shows only Lighting + Device.
#[test]
@@ -624,6 +738,23 @@ mod tests {
assert_eq!(tabs, vec![DetailTab::Lighting, DetailTab::Device]);
}
+ #[test]
+ fn light_tab_follows_light_capabilities() {
+ let mut device = record(DeviceKind::Light, None);
+ device.light_capabilities = Some(LightCapabilities {
+ power: true,
+ brightness: Some(
+ LightValueRange::new(20, 250, 1, LightValueUnit::Lumens)
+ .expect("demo light range is valid"),
+ ),
+ ..LightCapabilities::default()
+ });
+ assert_eq!(
+ DetailTab::tabs_for(&device),
+ vec![DetailTab::Light, DetailTab::Device]
+ );
+ }
+
/// An unprobed (offline) device has no measured capabilities and falls back
/// to a kind presumption, so a sleeping mouse keeps its button/pointer tabs.
#[test]
diff --git a/crates/openlogi-gui/src/app/detail.rs b/crates/openlogi-gui/src/app/detail.rs
index 0608334f5b85eb8c126aebc846f382e268fcfede..2b64645e8f406155f2a189131ac98da4710c3e70 100644
--- a/crates/openlogi-gui/src/app/detail.rs
+++ b/crates/openlogi-gui/src/app/detail.rs
@@ -1,5 +1,5 @@
//! The device-detail screen: the header (back + name + section tabs), and the
-//! four section bodies (Buttons, Pointer, Lighting, Device).
+//! section bodies (Buttons, Keys, Pointer, Lighting, Camera, Device).
use gpui::{
AnyElement, BorrowAppContext as _, Context, IntoElement, ParentElement, SharedString, Styled,
@@ -23,9 +23,14 @@ use super::widgets::{
};
use super::{AppView, DetailTab};
use crate::app_menu::file_url;
+use crate::components::camera_controls::CameraControlsPanel;
+use crate::components::camera_preview::CameraPreview;
use crate::components::dpi_panel::DpiPanel;
+use crate::components::light_panel::LightPanel;
+use crate::components::light_visual;
use crate::components::lighting_panel::LightingPanel;
use crate::components::smartshift_panel::SmartShiftPanel;
+use crate::keyboard_model::function_row::FunctionRowView;
use crate::mouse_model::view::MouseModelView;
use crate::state::{AppState, DeviceRecord};
use crate::theme::{HEADER_H, Palette, SCREEN_PAD, Typography as _};
@@ -86,11 +91,19 @@ pub(super) fn detail_header(
/// switches them — is the header's job (see [`detail_header`] and
/// [`DetailTab::tabs_for`]); `active` arrives pre-resolved against this device's
/// tab set, so this only has to render the chosen section.
+pub(super) struct DetailPanels<'a> {
+ pub mouse_model: &'a gpui::Entity<MouseModelView>,
+ pub keyboard_model: &'a gpui::Entity<FunctionRowView>,
+ pub dpi_panel: &'a gpui::Entity<DpiPanel>,
+ pub smartshift_panel: &'a gpui::Entity<SmartShiftPanel>,
+ pub lighting_panel: &'a gpui::Entity<LightingPanel>,
+ pub camera_preview: &'a gpui::Entity<CameraPreview>,
+ pub camera_controls: &'a gpui::Entity<CameraControlsPanel>,
+ pub light_panel: &'a gpui::Entity<LightPanel>,
+}
+
pub(super) fn detail_content(
- mouse_model: &gpui::Entity<MouseModelView>,
- dpi_panel: &gpui::Entity<DpiPanel>,
- smartshift_panel: &gpui::Entity<SmartShiftPanel>,
- lighting_panel: &gpui::Entity<LightingPanel>,
+ panels: &DetailPanels<'_>,
active: DetailTab,
pal: Palette,
cx: &mut Context<AppView>,
@@ -100,9 +113,16 @@ pub(super) fn detail_content(
.and_then(AppState::current_record)
.is_some_and(|record| record.online);
let content = match active {
- DetailTab::Buttons => buttons_tab(mouse_model).into_any_element(),
- DetailTab::Pointer => pointer_tab(dpi_panel, smartshift_panel, pal, cx).into_any_element(),
- DetailTab::Lighting => lighting_tab(lighting_panel, pal).into_any_element(),
+ DetailTab::Buttons => buttons_tab(panels.mouse_model).into_any_element(),
+ DetailTab::Keys => keys_tab(panels.keyboard_model).into_any_element(),
+ DetailTab::Pointer => {
+ pointer_tab(panels.dpi_panel, panels.smartshift_panel, pal, cx).into_any_element()
+ }
+ DetailTab::Lighting => lighting_tab(panels.lighting_panel, pal).into_any_element(),
+ DetailTab::Camera => {
+ camera_tab(panels.camera_preview, panels.camera_controls, pal).into_any_element()
+ }
+ DetailTab::Light => light_tab(panels.light_panel, pal, cx).into_any_element(),
DetailTab::Device => device_tab(pal, cx).into_any_element(),
};
v_flex()
@@ -173,6 +193,23 @@ fn buttons_tab(mouse_model: &gpui::Entity<MouseModelView>) -> impl IntoElement {
.child(div().w_full().max_w(px(760.)).child(mouse_model.clone()))
}
+/// Keys tab: the function-row remapper for a keyboard.
+fn keys_tab(keyboard_model: &gpui::Entity<FunctionRowView>) -> impl IntoElement {
+ v_flex()
+ .flex_1()
+ .w_full()
+ .min_h_0()
+ .items_center()
+ .justify_center()
+ .p(px(SCREEN_PAD))
+ .child(
+ div()
+ .w_full()
+ .max_w(px(1040.))
+ .child(keyboard_model.clone()),
+ )
+}
+
/// Pointer tab: the DPI panel, the SmartShift wheel controls, and the
/// scroll-wheel preferences, each in a titled card. Use a responsive two-column
/// grid that still fits the window's 720 px minimum width, so these short
@@ -397,6 +434,93 @@ fn lighting_tab(lighting_panel: &gpui::Entity<LightingPanel>, pal: Palette) -> i
)))
}
+/// Camera tab: the live webcam preview beside the device-level image controls,
+/// each in a titled card. Side by side at the default window width so every
+/// control is visible without scrolling; the cards wrap to a stacked column
+/// when the window is too narrow. The preview drives the capture session via
+/// [`CameraPreview::set_target`] (called from [`AppView::render`]); the controls
+/// panel reads/writes UVC settings directly on the device.
+fn camera_tab(
+ camera_preview: &gpui::Entity<CameraPreview>,
+ camera_controls: &gpui::Entity<CameraControlsPanel>,
+ pal: Palette,
+) -> impl IntoElement {
+ v_flex()
+ .flex_1()
+ .w_full()
+ .min_h_0()
+ .items_center()
+ .overflow_y_scrollbar()
+ .p_6()
+ .child(
+ h_flex()
+ .w_full()
+ .flex_wrap()
+ .justify_center()
+ .items_start()
+ .gap_3()
+ .child(div().w(px(514.)).flex_shrink_0().child(panel_card(
+ tr!("Camera"),
+ Icon::new(IconName::Eye),
+ pal,
+ camera_preview.clone().into_any_element(),
+ )))
+ .child(div().w(px(500.)).flex_shrink_0().child(panel_card(
+ tr!("Camera controls"),
+ Icon::new(IconName::Settings),
+ pal,
+ camera_controls.clone().into_any_element(),
+ ))),
+ )
+}
+
+/// Standalone-light controls in a separate panel from HID++ keyboard RGB.
+fn light_tab(
+ light_panel: &gpui::Entity<LightPanel>,
+ pal: Palette,
+ cx: &mut Context<AppView>,
+) -> impl IntoElement {
+ let (asset, online, enabled, settings) = cx.try_global::<AppState>().map_or(
+ (
+ None,
+ false,
+ false,
+ openlogi_core::config::LightSettings::default(),
+ ),
+ |state| {
+ let record = state.current_record();
+ (
+ record.and_then(|record| record.asset.as_ref()),
+ record.is_some_and(|record| record.online),
+ state.light_enabled(),
+ state.light(),
+ )
+ },
+ );
+ v_flex()
+ .flex_1()
+ .w_full()
+ .min_h_0()
+ .items_center()
+ .overflow_y_scrollbar()
+ .p(px(SCREEN_PAD))
+ .child(
+ h_flex()
+ .w_full()
+ .max_w(px(980.))
+ .gap_4()
+ .flex_wrap()
+ .items_start()
+ .child(light_visual::detail(asset, online, enabled, settings, pal))
+ .child(div().w(px(400.)).min_w(px(360.)).child(panel_card(
+ tr!("Lighting"),
+ Icon::new(IconName::Sun),
+ pal,
+ light_panel.clone().into_any_element(),
+ ))),
+ )
+}
+
/// Device tab: device details and configuration cards stacked.
fn device_tab(pal: Palette, cx: &mut Context<AppView>) -> impl IntoElement {
v_flex()
@@ -538,11 +662,19 @@ fn device_summary(name: &str, kind: DeviceKind, online: bool, pal: Palette) -> i
}
fn device_description_list(record: DeviceRecord) -> impl IntoElement {
- let mut items = vec![
- DescriptionItem::new(tr!("Connection")).value(route_label(record.route.as_ref())),
- DescriptionItem::new(tr!("Slot")).value(record.slot.to_string()),
- DescriptionItem::new(tr!("Device key")).value(record.config_key),
- ];
+ // Cameras are plain UVC over the cable — no HID++ route, and their slot is
+ // a synthetic 0 that would only mislead next to real receiver slots.
+ let is_camera = matches!(record.kind, DeviceKind::Camera);
+ let connection = if is_camera {
+ tr!("USB").to_string()
+ } else {
+ route_label(record.route.as_ref())
+ };
+ let mut items = vec![DescriptionItem::new(tr!("Connection")).value(connection)];
+ if !is_camera {
+ items.push(DescriptionItem::new(tr!("Slot")).value(record.slot.to_string()));
+ }
+ items.push(DescriptionItem::new(tr!("Device key")).value(elided_key(&record.config_key)));
if let Some(serial) = record.serial_number {
items.push(DescriptionItem::new(tr!("Serial")).value(serial));
}
@@ -553,3 +685,45 @@ fn device_description_list(record: DeviceRecord) -> impl IntoElement {
.bordered(false)
.children(items)
}
+
+/// Show long machine keys (a camera's config key embeds the OS device path)
+/// as head…tail instead of wrapping the details card; short HID++ keys pass
+/// through whole. The full key stays in the config file for copying.
+fn elided_key(key: &str) -> String {
+ const HEAD: usize = 40;
+ const TAIL: usize = 8;
+ let chars: Vec<char> = key.chars().collect();
+ if chars.len() <= HEAD + TAIL + 1 {
+ return key.to_string();
+ }
+ let head: String = chars[..HEAD].iter().collect();
+ let tail: String = chars[chars.len() - TAIL..].iter().collect();
+ format!("{head}…{tail}")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::elided_key;
+
+ #[test]
+ fn short_hid_keys_pass_through_whole() {
+ let key = "direct:046d:b023:unit:a393cae0";
+ assert_eq!(elided_key(key), key);
+ }
+
+ #[test]
+ fn long_camera_keys_show_head_and_tail() {
+ let key = r"camera-\?\usb#vid_046d&pid_0893&mi_00#9&56d9c30&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global";
+ let shown = elided_key(key);
+ assert!(shown.contains('…'));
+ assert!(shown.starts_with(r"camera-\?\usb#vid_046d&pid_0893"));
+ assert!(shown.ends_with(r"}\global"));
+ assert!(shown.chars().count() < 55);
+ }
+
+ #[test]
+ fn exactly_at_the_threshold_is_not_elided() {
+ let key = "k".repeat(49);
+ assert_eq!(elided_key(&key), key);
+ }
+}
diff --git a/crates/openlogi-gui/src/app/home.rs b/crates/openlogi-gui/src/app/home.rs
index 966bbda07e021e260c8a5d07191528c0bf05bcf6..3bb80d21258307c718ac74e12c2b123a50edce59 100644
--- a/crates/openlogi-gui/src/app/home.rs
+++ b/crates/openlogi-gui/src/app/home.rs
@@ -14,6 +14,7 @@ use gpui_component::{
button::{Button, ButtonVariants as _},
h_flex, v_flex,
};
+use openlogi_core::config::LightSettings;
use openlogi_core::device::{
BatteryInfo, BatteryLevel, BatteryStatus, DeviceKind, DeviceTransports,
};
@@ -24,6 +25,7 @@ use super::status::{loading_body, notice_body};
use super::widgets::{add_device_button, kind_label, settings_button};
use crate::asset::GlowGeometry;
use crate::components::carousel::Carousel;
+use crate::components::light_visual;
use crate::state::{AppState, DeviceRecord};
use crate::theme::{self, HEADER_H, Palette, SelectableStyle as _, Typography as _};
@@ -81,11 +83,19 @@ pub(super) fn device_gallery(cx: &mut Context<AppView>) -> impl IntoElement {
return div().into_any_element();
};
let key = record.config_key.clone();
+ let light_enabled = cx.try_global::<AppState>().is_some_and(|state| {
+ record.kind == DeviceKind::Light && state.light_enabled_for(&record.config_key)
+ });
+ let light_settings = cx
+ .try_global::<AppState>()
+ .map_or_else(LightSettings::default, |state| {
+ state.light_for(&record.config_key)
+ });
let glow = cx
.try_global::<AppState>()
.and_then(|s| keyboard_glow(s, &record));
let view = view.clone();
- device_card(&record, focused, glow, pal)
+ device_card(&record, focused, glow, light_enabled, light_settings, pal)
.id(("device-card", idx))
.active(gpui::Styled::shadow_2xs)
.role(Role::Button)
@@ -203,6 +213,8 @@ fn device_card(
record: &DeviceRecord,
active: bool,
glow: Option<(Arc<GlowGeometry>, Hsla)>,
+ light_enabled: bool,
+ light_settings: LightSettings,
pal: Palette,
) -> Div {
v_flex()
@@ -228,11 +240,12 @@ fn device_card(
.flex()
.items_center()
.justify_center()
+ .overflow_hidden()
.opacity(if record.online { 1. } else { 0.55 })
.when_some(glow, |this, (geom, color)| {
this.child(glow_canvas(geom, color))
})
- .child(device_image(record, pal)),
+ .child(device_image(record, light_enabled, light_settings, pal)),
)
.child(
v_flex()
@@ -265,11 +278,13 @@ fn device_card(
.truncate()
.text_caption()
.text_color(pal.text_muted)
- .child(format!(
- "{} · slot {}",
- kind_label(record.kind),
- record.slot
- )),
+ .child(if matches!(record.kind, DeviceKind::Camera) {
+ // A camera's synthetic slot 0 means nothing
+ // next to real receiver slots.
+ kind_label(record.kind)
+ } else {
+ format!("{} · slot {}", kind_label(record.kind), record.slot)
+ }),
)
.child(
h_flex()
@@ -278,10 +293,15 @@ fn device_card(
.gap_1p5()
.child(
svg()
- .path(connection_icon_path(
- record.route.as_ref(),
- record.model_info.as_ref().map(|m| &m.transports),
- ))
+ .path(if matches!(record.kind, DeviceKind::Camera) {
+ // UVC cameras are always on the cable.
+ "action-icons/usb.svg"
+ } else {
+ connection_icon_path(
+ record.route.as_ref(),
+ record.model_info.as_ref().map(|m| &m.transports),
+ )
+ })
.size_3()
.flex_none()
.text_color(pal.text_muted),
@@ -302,21 +322,42 @@ fn device_card(
/// fall back to the raw pixel dimensions when the box can't fully constrain it,
/// which (with an `overflow_hidden` parent) cropped the device into a zoomed
/// close-up. `object_fit` defaults to `Contain`, so the whole device shows.
-fn device_image(record: &DeviceRecord, pal: Palette) -> AnyElement {
- match record
+fn device_image(
+ record: &DeviceRecord,
+ light_enabled: bool,
+ light_settings: LightSettings,
+ pal: Palette,
+) -> AnyElement {
+ if record.kind == DeviceKind::Light {
+ return light_visual::gallery(
+ record.asset.as_ref(),
+ record.online,
+ light_enabled,
+ light_settings,
+ pal,
+ );
+ }
+ if let Some(path) = record
.asset
.as_ref()
.and_then(|a| a.hero_image_path.clone())
{
- Some(path) => img(path).max_w_full().max_h_full().into_any_element(),
- None => div()
- .size_full()
- .flex()
- .items_center()
- .justify_center()
- .child(Icon::new(IconName::Cpu).size_8().text_color(pal.text_muted))
- .into_any_element(),
+ return img(path).max_w_full().max_h_full().into_any_element();
}
+ // Cameras carry no depot asset, so give them a recognisable glyph on their
+ // gallery card instead of the generic chip fallback.
+ let icon = if matches!(record.kind, DeviceKind::Camera) {
+ IconName::Eye
+ } else {
+ IconName::Cpu
+ };
+ div()
+ .size_full()
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(Icon::new(icon).size_8().text_color(pal.text_muted))
+ .into_any_element()
}
/// Connectivity dot for a gallery card: a steady grey when offline, a green dot
@@ -347,14 +388,17 @@ fn status_dot(online: bool) -> AnyElement {
/// Battery readout for a gallery card: a charge/level glyph plus the
/// percentage, in the muted metadata style.
fn battery_view(b: &BatteryInfo, pal: Palette) -> AnyElement {
- h_flex()
+ let row = h_flex()
.gap_1()
.items_center()
.text_caption()
.text_color(pal.text_muted)
- .child(Icon::new(battery_icon(b)).size_3())
- .child(format!("{}%", b.percentage))
- .into_any_element()
+ .child(Icon::new(battery_icon(b)).size_3());
+ if super::widgets::battery_charging_no_reading(b) {
+ row.child(tr!("Charging")).into_any_element()
+ } else {
+ row.child(format!("{}%", b.percentage)).into_any_element()
+ }
}
/// Pick the battery glyph from charge state first (charging / full / error),
@@ -403,6 +447,7 @@ pub(super) fn connection_icon_path(
// keep the old default.
_ => "action-icons/bluetooth.svg",
},
+ Some(DeviceRoute::RawHid { .. }) => "action-icons/usb.svg",
}
}
diff --git a/crates/openlogi-gui/src/app/widgets.rs b/crates/openlogi-gui/src/app/widgets.rs
index f36c71119d1905072a84c25db3cff63d93827e79..7a7d917613f6f9fe997805e1c8273efd48df8bb7 100644
--- a/crates/openlogi-gui/src/app/widgets.rs
+++ b/crates/openlogi-gui/src/app/widgets.rs
@@ -18,6 +18,16 @@ use super::AppView;
use crate::state::AppState;
use crate::theme::{self, Palette, Typography as _};
+/// True when the device is charging but still reports 0% — the MX2S `0x1000`
+/// firmware can't gauge charge under load, and on a cold start there's no
+/// pre-charge % cached to carry forward. Show "Charging" without the bogus 0%.
+pub(crate) fn battery_charging_no_reading(b: &BatteryInfo) -> bool {
+ matches!(
+ b.status,
+ BatteryStatus::Charging | BatteryStatus::ChargingSlow
+ ) && b.percentage == 0
+}
+
/// "← Back" affordance on the detail screen; returns to the gallery without
/// changing the active-device selection.
pub(super) fn back_button(cx: &mut Context<AppView>) -> impl IntoElement {
@@ -147,22 +157,32 @@ pub(super) fn battery_summary(battery: &BatteryInfo, pal: Palette) -> impl IntoE
.text_caption()
.text_color(pal.text_muted)
.child(status)
- .child(format!("{}%", battery.percentage)),
+ .child(if battery_charging_no_reading(battery) {
+ String::new()
+ } else {
+ format!("{}%", battery.percentage)
+ }),
)
- .child(
- div()
+ .child({
+ let track = div()
.h(px(6.))
.w_full()
.rounded_full()
- .bg(pal.surface_hover)
- .child(
+ .bg(pal.surface_hover);
+ // Charging with no reliable %: leave the track empty rather than
+ // drawing the 1%-wide red critical sliver that percentage==0 yields.
+ if battery_charging_no_reading(battery) {
+ track
+ } else {
+ track.child(
div()
.h_full()
.w(relative(f32::from(battery.percentage.clamp(1, 100)) / 100.))
.rounded_full()
.bg(rgb(battery_color(battery.percentage))),
- ),
- )
+ )
+ }
+ })
}
fn battery_color(percentage: u8) -> u32 {
@@ -192,7 +212,9 @@ pub(super) fn route_label(route: Option<&DeviceRoute>) -> String {
match route {
Some(DeviceRoute::Bolt { .. }) => tr!("Bolt receiver").to_string(),
Some(DeviceRoute::Unifying { .. }) => tr!("Unifying receiver").to_string(),
- Some(DeviceRoute::Direct { .. }) => tr!("Direct connection").to_string(),
+ Some(DeviceRoute::Direct { .. } | DeviceRoute::RawHid { .. }) => {
+ tr!("Direct connection").to_string()
+ }
None => tr!("Unavailable").to_string(),
}
}
@@ -210,6 +232,8 @@ pub(super) fn kind_label(kind: DeviceKind) -> String {
DeviceKind::Gamepad => tr!("Gamepad").to_string(),
DeviceKind::Joystick => tr!("Joystick").to_string(),
DeviceKind::Headset => tr!("Headset").to_string(),
+ DeviceKind::Camera => tr!("Camera").to_string(),
DeviceKind::Unknown => tr!("Device").to_string(),
+ DeviceKind::Light => tr!("Lighting").to_string(),
}
}
diff --git a/crates/openlogi-gui/src/app_assets.rs b/crates/openlogi-gui/src/app_assets.rs
index 28d730c271b80a29514d3ebb394b39090e37873f..acd182a8e79f084a53813b8dd88f0599dcb13672 100644
--- a/crates/openlogi-gui/src/app_assets.rs
+++ b/crates/openlogi-gui/src/app_assets.rs
@@ -1,10 +1,9 @@
//! The app's GPUI [`AssetSource`].
//!
-//! Serves the embedded OpenLogi logo and delegates every other path to
+//! Serves source-owned embedded artwork and delegates every other path to
//! gpui-component's icon assets (the lucide SVGs behind `IconName`). Embedding
-//! the logo via `include_bytes!` means `img("openlogi.png")` resolves the same
-//! inside a packaged `.app` as it does from a dev build — a filesystem path
-//! would not.
+//! these files via `include_bytes!` means they resolve the same inside a
+//! packaged `.app` as they do from a dev build — a filesystem path would not.
use std::borrow::Cow;
@@ -71,6 +70,7 @@ const ACTION_ICONS: &[(&str, &[u8])] = &[
("action-icons/square-arrow-right.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/square-arrow-right.svg"))),
("action-icons/square-plus.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/square-plus.svg"))),
("action-icons/square-x.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/square-x.svg"))),
+ ("action-icons/terminal.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/terminal.svg"))),
("action-icons/undo-2.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/undo-2.svg"))),
("action-icons/unifying.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/unifying.svg"))),
("action-icons/usb.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/usb.svg"))),
diff --git a/crates/openlogi-gui/src/app_menu.rs b/crates/openlogi-gui/src/app_menu.rs
index d718c3f608b6657570c5a56b50fa21f5e7bc5c41..996295835edbd1460891d917226cc7c43dc81d12 100644
--- a/crates/openlogi-gui/src/app_menu.rs
+++ b/crates/openlogi-gui/src/app_menu.rs
@@ -220,6 +220,9 @@ fn device_menu_items(cx: &App) -> Vec<MenuItem> {
Some(state) if !state.device_list.is_empty() => {
for record in &state.device_list {
let title = match &record.battery {
+ Some(battery) if crate::app::battery_charging_no_reading(battery) => {
+ format!("{} · {}", record.display_name, tr!("Charging"))
+ }
Some(battery) => format!("{} · {}%", record.display_name, battery.percentage),
None => record.display_name.clone(),
};
diff --git a/crates/openlogi-gui/src/asset.rs b/crates/openlogi-gui/src/asset.rs
index a2c6988d35305d1822cbc6a1ade01e091fec9c18..9a8ae97e404ca00bbfa38b0b319100e3f2cfc5e6 100644
--- a/crates/openlogi-gui/src/asset.rs
+++ b/crates/openlogi-gui/src/asset.rs
@@ -23,6 +23,7 @@ pub(crate) use self::glow::GlowGeometry;
use std::path::{Path, PathBuf};
use std::sync::Arc;
+use openlogi_assets::http::safe_component_path;
use openlogi_assets::{
BUTTONS_RENDER_FILES, DeviceEntry, FRONT_RENDER_FILES, Index, METADATA_FILES, Metadata,
};
@@ -200,6 +201,19 @@ impl AssetResolver {
self.load_files(depot, entry, model)
}
+ /// Resolve a standalone device directly by its registry model id.
+ ///
+ /// Standalone raw-HID devices do not expose a HID++ `DeviceModelInfo`, so
+ /// constructing one just to reuse [`Self::resolve`] would conflate a
+ /// physical protocol identity with a model-level asset identity. The
+ /// registry lookup remains exact and case-insensitive, while all local
+ /// filenames still pass through the same safe component checks.
+ pub fn resolve_registry_model(&self, registry_model_id: &str) -> Option<ResolvedAsset> {
+ let index = self.index.as_ref()?;
+ let (depot, entry) = index.find_by_model_id(registry_model_id)?;
+ self.load_standalone_files(depot, entry, registry_model_id)
+ }
+
fn load_files(
&self,
depot: &str,
@@ -207,7 +221,13 @@ impl AssetResolver {
model: &DeviceModelInfo,
) -> Option<ResolvedAsset> {
for root in &self.read_roots {
- let dir = root.join(depot);
+ let Ok(dir) = safe_component_path(root, depot, "asset depot") else {
+ warn!(
+ depot,
+ "unsafe asset depot component — ignoring registry entry"
+ );
+ continue;
+ };
// Hotspot metadata in whichever schema this depot cached:
// `core_metadata.json` (newer) or `metadata.json` (older).
let Some(&meta_name) = METADATA_FILES.iter().find(|n| dir.join(n).exists()) else {
@@ -246,7 +266,7 @@ impl AssetResolver {
.clone()
.into_iter()
.chain(FRONT_RENDER_FILES.map(str::to_string))
- .map(|n| dir.join(n))
+ .filter_map(|n| safe_component_path(&dir, &n, "asset file").ok())
.find(|p| p.exists());
let image_name = buttons_name
.clone()
@@ -261,7 +281,10 @@ impl AssetResolver {
candidates.extend(BUTTONS_RENDER_FILES.map(str::to_string));
candidates.extend(variant_front_name);
candidates.extend(FRONT_RENDER_FILES.map(str::to_string));
- let Some(image_path) = candidates.iter().map(|n| dir.join(n)).find(|p| p.exists())
+ let Some(image_path) = candidates
+ .iter()
+ .filter_map(|n| safe_component_path(&dir, n, "asset file").ok())
+ .find(|p| p.exists())
else {
continue;
};
@@ -297,13 +320,20 @@ impl AssetResolver {
png_height,
"asset hit"
);
+ let kind = DeviceKind::from_registry_type(&entry.kind);
+ // Only keyboards paint the inter-key glow, and the runtime
+ // fallback decodes the full render — don't pay that for mice.
+ let glow = (kind == DeviceKind::Keyboard)
+ .then(|| self::glow::resolve_glow_geometry(&dir, &image_path))
+ .flatten()
+ .map(Arc::new);
return Some(ResolvedAsset {
depot: depot.to_string(),
display_name: entry.display_name.clone(),
- kind: DeviceKind::from_registry_type(&entry.kind),
+ kind,
image_path,
hero_image_path,
- glow: self::glow::load_glow_geometry(&dir).map(Arc::new),
+ glow,
metadata,
png_width,
png_height,
@@ -312,6 +342,58 @@ impl AssetResolver {
debug!(depot, "asset cache miss across all roots");
None
}
+
+ fn load_standalone_files(
+ &self,
+ depot: &str,
+ entry: &DeviceEntry,
+ registry_model_id: &str,
+ ) -> Option<ResolvedAsset> {
+ for root in &self.read_roots {
+ let Ok(dir) = safe_component_path(root, depot, "asset depot") else {
+ continue;
+ };
+ let manifest = load_manifest(&dir);
+ let Some(image_name) = manifest
+ .as_ref()
+ .and_then(|manifest| manifest.device_image_for(registry_model_id))
+ .or_else(|| entry.preferred_file(&FRONT_RENDER_FILES))
+ else {
+ continue;
+ };
+ let Ok(image_path) = safe_component_path(&dir, image_name, "asset file") else {
+ continue;
+ };
+ if !image_path.is_file() {
+ continue;
+ }
+ let Ok((png_width, png_height)) = read_png_dimensions(&image_path) else {
+ continue;
+ };
+ debug!(
+ depot,
+ root = %root.display(),
+ image = image_name,
+ "standalone asset hit"
+ );
+ return Some(ResolvedAsset {
+ depot: depot.to_owned(),
+ display_name: entry.display_name.clone(),
+ kind: DeviceKind::from_registry_type(&entry.kind),
+ image_path: image_path.clone(),
+ hero_image_path: Some(image_path),
+ glow: None,
+ // Standalone-light rendering intentionally consumes only the
+ // verified front image; shared metadata remains for HID++
+ // button hotspots in `load_files`.
+ metadata: Metadata::default(),
+ png_width,
+ png_height,
+ });
+ }
+ debug!(depot, "standalone asset cache miss across all roots");
+ None
+ }
}
impl Default for AssetResolver {
@@ -546,6 +628,180 @@ mod tests {
assert_eq!(asset.metadata.assignments().count(), 1);
}
+ #[test]
+ fn resolves_standalone_registry_model_without_synthetic_hidpp_info() {
+ let root = tempfile::tempdir().expect("create temp dir");
+ let depot = root.path().join("litra_glow");
+ std::fs::create_dir_all(&depot).expect("create depot dir");
+ std::fs::write(
+ depot.join("manifest.json"),
+ r#"{"devices":[{"modelId":"8c900","resources":[{"key":"device_image","src":"front.png"}]}],"resources":[]}"#,
+ )
+ .expect("write manifest");
+ std::fs::write(depot.join("front.png"), png_header(396, 396)).expect("write front");
+
+ let index = index_of(
+ "litra_glow",
+ DeviceEntry {
+ model_id: "8c900".into(),
+ model_ids: vec![],
+ display_name: "Litra Glow".into(),
+ kind: "ILLUMINATION_LIGHT".into(),
+ asset_path: "v1/devices/litra_glow/".into(),
+ files: vec![],
+ },
+ );
+ let resolver = AssetResolver {
+ read_roots: vec![root.path().to_path_buf()],
+ write_root: root.path().to_path_buf(),
+ has_bundle: false,
+ index: Some(index),
+ };
+
+ let asset = resolver
+ .resolve_registry_model("8c900")
+ .expect("standalone registry model should resolve");
+ assert_eq!(asset.display_name, "Litra Glow");
+ assert_eq!(asset.kind, DeviceKind::Light);
+ assert_eq!(asset.image_path, depot.join("front.png"));
+ assert_eq!((asset.png_width, asset.png_height), (396, 396));
+ }
+
+ #[test]
+ fn standalone_registry_lookup_does_not_cross_model_depots() {
+ let root = tempfile::tempdir().expect("create temp dir");
+ let depot = root.path().join("litra_beam");
+ std::fs::create_dir_all(&depot).expect("create depot dir");
+ std::fs::write(
+ depot.join("manifest.json"),
+ r#"{"devices":[{"modelId":"8c901","resources":[{"key":"device_image","src":"front.png"}]}],"resources":[]}"#,
+ )
+ .expect("write manifest");
+ std::fs::write(depot.join("front.png"), png_header(120, 240)).expect("write front");
+ let index = Index {
+ schema_version: 1,
+ devices: HashMap::from([
+ (
+ "litra_glow".into(),
+ DeviceEntry {
+ model_id: "8c900".into(),
+ model_ids: vec![],
+ display_name: "Litra Glow".into(),
+ kind: "ILLUMINATION_LIGHT".into(),
+ asset_path: "v1/devices/litra_glow/".into(),
+ files: vec![],
+ },
+ ),
+ (
+ "litra_beam".into(),
+ DeviceEntry {
+ model_id: "8c901".into(),
+ model_ids: vec![],
+ display_name: "Litra Beam".into(),
+ kind: "ILLUMINATION_LIGHT".into(),
+ asset_path: "v1/devices/litra_beam/".into(),
+ files: vec![],
+ },
+ ),
+ ]),
+ };
+ let resolver = AssetResolver {
+ read_roots: vec![root.path().to_path_buf()],
+ write_root: root.path().to_path_buf(),
+ has_bundle: false,
+ index: Some(index),
+ };
+
+ assert!(resolver.resolve_registry_model("8c900").is_none());
+ assert_eq!(
+ resolver
+ .resolve_registry_model("8c901")
+ .expect("beam should resolve")
+ .display_name,
+ "Litra Beam"
+ );
+ }
+
+ #[test]
+ fn unsafe_standalone_manifest_filename_is_rejected() {
+ let root = tempfile::tempdir().expect("create temp dir");
+ let depot = root.path().join("litra_glow");
+ std::fs::create_dir_all(&depot).expect("create depot dir");
+ std::fs::write(
+ depot.join("manifest.json"),
+ r#"{"devices":[{"modelId":"8c900","resources":[{"key":"device_image","src":"../front.png"}]}],"resources":[]}"#,
+ )
+ .expect("write manifest");
+ std::fs::write(root.path().join("front.png"), png_header(1, 1)).expect("write escape");
+ let resolver = AssetResolver {
+ read_roots: vec![root.path().to_path_buf()],
+ write_root: root.path().to_path_buf(),
+ has_bundle: false,
+ index: Some(index_of(
+ "litra_glow",
+ DeviceEntry {
+ model_id: "8c900".into(),
+ model_ids: vec![],
+ display_name: "Litra Glow".into(),
+ kind: "ILLUMINATION_LIGHT".into(),
+ asset_path: "v1/devices/litra_glow/".into(),
+ files: vec![openlogi_assets::FileEntry {
+ name: "front.png".into(),
+ sha256: String::new(),
+ bytes: 0,
+ }],
+ },
+ )),
+ };
+ assert!(resolver.resolve_registry_model("8c900").is_none());
+ }
+
+ #[test]
+ fn standalone_resolution_prefers_the_first_read_root() {
+ let roots = [
+ tempfile::tempdir().expect("create bundle root"),
+ tempfile::tempdir().expect("create cache root"),
+ ];
+ for (root, dimensions) in roots.iter().zip([(10, 10), (20, 20)]) {
+ let depot = root.path().join("litra_glow");
+ std::fs::create_dir_all(&depot).expect("create depot dir");
+ std::fs::write(
+ depot.join("front.png"),
+ png_header(dimensions.0, dimensions.1),
+ )
+ .expect("write front");
+ }
+ let resolver = AssetResolver {
+ read_roots: roots.iter().map(|root| root.path().to_path_buf()).collect(),
+ write_root: roots[1].path().to_path_buf(),
+ has_bundle: true,
+ index: Some(index_of(
+ "litra_glow",
+ DeviceEntry {
+ model_id: "8c900".into(),
+ model_ids: vec![],
+ display_name: "Litra Glow".into(),
+ kind: "ILLUMINATION_LIGHT".into(),
+ asset_path: "v1/devices/litra_glow/".into(),
+ files: vec![openlogi_assets::FileEntry {
+ name: "front.png".into(),
+ sha256: String::new(),
+ bytes: 0,
+ }],
+ },
+ )),
+ };
+
+ let asset = resolver
+ .resolve_registry_model("8c900")
+ .expect("bundle asset should resolve");
+ assert_eq!((asset.png_width, asset.png_height), (10, 10));
+ assert_eq!(
+ asset.image_path,
+ roots[0].path().join("litra_glow/front.png")
+ );
+ }
+
#[test]
fn cleanup_removes_only_legacy_glow_pngs() {
let root = tempfile::tempdir().expect("create temp dir");
diff --git a/crates/openlogi-gui/src/asset/glow.rs b/crates/openlogi-gui/src/asset/glow.rs
index 08d3d75a4328ba9210465ebad6ef2db6aaaecb19..081219a9fb9cd354411c655587ad75b17a225749 100644
--- a/crates/openlogi-gui/src/asset/glow.rs
+++ b/crates/openlogi-gui/src/asset/glow.rs
@@ -19,7 +19,13 @@ use serde::Deserialize;
use tracing::warn;
/// Metadata files to read the precomputed mask from, newest schema first.
-const META_FILES: [&str; 2] = ["core_metadata.json", "metadata.json"];
+const META_FILES: [&str; 3] = ["core_metadata.json", "metadata_full.json", "metadata.json"];
+
+/// Ceiling on a runtime-derived mask's width — the same ~1k scale as the
+/// pipeline-baked masks, and what keeps the flood fill cheap.
+const COMPUTED_MASK_MAX_W: u32 = 1024;
+/// Alpha below this counts as see-through when deriving holes from a render.
+const HOLE_ALPHA: u8 = 96;
/// Sanity bound on a baked mask's stored dimensions. The masks are ~1k px wide;
/// anything far larger is a corrupt or hostile `metadata.json`. The cap also
@@ -62,9 +68,17 @@ pub(crate) struct GlowGeometry {
pub segments: Vec<GlowSeg>,
}
+/// The inter-key hole geometry for a depot: the pipeline-baked mask when the
+/// metadata ships one, otherwise derived at resolve time from the render's own
+/// alpha channel — so any keyboard with a transparent render glows, not just
+/// the depots the asset pipeline has processed.
+pub(crate) fn resolve_glow_geometry(dir: &Path, image_path: &Path) -> Option<GlowGeometry> {
+ load_glow_geometry(dir).or_else(|| compute_glow_geometry(image_path))
+}
+
/// Load and decode the precomputed glow mask from a depot directory's metadata.
-/// `None` when the depot ships no mask (the feature gate) or it's malformed.
-pub(crate) fn load_glow_geometry(dir: &Path) -> Option<GlowGeometry> {
+/// `None` when the depot ships no mask or it's malformed.
+fn load_glow_geometry(dir: &Path) -> Option<GlowGeometry> {
let mask = META_FILES.iter().find_map(|name| {
let text = std::fs::read_to_string(dir.join(name)).ok()?;
serde_json::from_str::<MetaGlow>(&text).ok()?.glow
@@ -72,6 +86,93 @@ pub(crate) fn load_glow_geometry(dir: &Path) -> Option<GlowGeometry> {
GlowGeometry::from_mask(&mask)
}
+/// Derive the inter-key holes straight from the render's alpha channel:
+/// flood-fill the see-through field inward from the image border, and whatever
+/// see-through cells remain unreached are enclosed by the silhouette — the
+/// holes. The image is binned to ≤[`COMPUTED_MASK_MAX_W`] cells wide; a cell
+/// is see-through when *any* source pixel in its bin is, so the few-pixel
+/// slits between floating keycaps survive the binning. Border-connected
+/// transparency (including background reachable through open seams) is
+/// "outside" and never glows, which is what keeps the colour inside the
+/// silhouette.
+fn compute_glow_geometry(image_path: &Path) -> Option<GlowGeometry> {
+ let img = image::open(image_path).ok()?.into_rgba8();
+ let (src_w, src_h) = img.dimensions();
+ if src_w == 0 || src_h == 0 || src_w > MAX_MASK_DIM || src_h > MAX_MASK_DIM {
+ return None;
+ }
+ let scale = src_w.div_ceil(COMPUTED_MASK_MAX_W).max(1);
+ let (w, h) = (src_w.div_ceil(scale), src_h.div_ceil(scale));
+
+ // 0 = opaque, 1 = see-through, 2 = see-through and border-connected.
+ let mut cells = vec![0u8; (w as usize) * (h as usize)];
+ for (x, y, pixel) in img.enumerate_pixels() {
+ if pixel.0[3] < HOLE_ALPHA {
+ cells[((y / scale) * w + (x / scale)) as usize] = 1;
+ }
+ }
+
+ let mut queue: std::collections::VecDeque<(u32, u32)> = (0..w)
+ .flat_map(|x| [(x, 0), (x, h - 1)])
+ .chain((0..h).flat_map(|y| [(0, y), (w - 1, y)]))
+ .filter(|&(x, y)| cells[(y * w + x) as usize] == 1)
+ .collect();
+ for &(x, y) in &queue {
+ cells[(y * w + x) as usize] = 2;
+ }
+ while let Some((x, y)) = queue.pop_front() {
+ let neighbors = [
+ (x.wrapping_sub(1), y),
+ (x + 1, y),
+ (x, y.wrapping_sub(1)),
+ (x, y + 1),
+ ];
+ for (nx, ny) in neighbors {
+ if nx < w && ny < h && cells[(ny * w + nx) as usize] == 1 {
+ cells[(ny * w + nx) as usize] = 2;
+ queue.push_back((nx, ny));
+ }
+ }
+ }
+
+ #[allow(
+ clippy::cast_precision_loss,
+ reason = "mask coords are < 8192 px — well within f32 mantissa"
+ )]
+ let (wf, hf) = (w as f32, h as f32);
+ let mut segments = Vec::new();
+ for y in 0..h {
+ let mut x = 0;
+ while x < w {
+ if cells[(y * w + x) as usize] == 1 {
+ let start = x;
+ while x < w && cells[(y * w + x) as usize] == 1 {
+ x += 1;
+ }
+ #[allow(
+ clippy::cast_precision_loss,
+ reason = "mask coords are < 8192 px — well within f32 mantissa"
+ )]
+ segments.push(GlowSeg {
+ x: start as f32 / wf,
+ y: y as f32 / hf,
+ w: (x - start) as f32 / wf,
+ h: 1.0 / hf,
+ });
+ } else {
+ x += 1;
+ }
+ }
+ }
+ if segments.is_empty() {
+ return None;
+ }
+ Some(GlowGeometry {
+ aspect: wf / hf,
+ segments,
+ })
+}
+
impl GlowGeometry {
/// Decode the RLE mask into normalized per-row hole segments. A run that
/// crosses a row boundary is split so every segment stays on one row.
@@ -170,4 +271,68 @@ mod tests {
};
assert!(GlowGeometry::from_mask(&mask).is_none());
}
+
+ /// Write an RGBA png where `1` cells are solid and the rest fully
+ /// transparent.
+ fn write_png(dir: &std::path::Path, name: &str, rows: &[&[u8]]) -> std::path::PathBuf {
+ let h = u32::try_from(rows.len()).expect("test image height fits u32");
+ let w = u32::try_from(rows[0].len()).expect("test image width fits u32");
+ let mut img = image::RgbaImage::new(w, h);
+ for (y, row) in rows.iter().enumerate() {
+ for (x, &cell) in row.iter().enumerate() {
+ let alpha = if cell == 1 { 255 } else { 0 };
+ let (x, y) = (
+ u32::try_from(x).expect("test x fits u32"),
+ u32::try_from(y).expect("test y fits u32"),
+ );
+ img.put_pixel(x, y, image::Rgba([40, 40, 40, alpha]));
+ }
+ }
+ let path = dir.join(name);
+ img.save(&path).expect("write png");
+ path
+ }
+
+ #[test]
+ fn computed_geometry_finds_only_enclosed_holes() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ // A ring of opaque pixels around one transparent pixel (a hole), with
+ // border-connected transparency everywhere else.
+ let path = write_png(
+ dir.path(),
+ "ring.png",
+ &[
+ &[0, 0, 0, 0, 0],
+ &[0, 1, 1, 1, 0],
+ &[0, 1, 0, 1, 0],
+ &[0, 1, 1, 1, 0],
+ &[0, 0, 0, 0, 0],
+ ],
+ );
+ let geom = compute_glow_geometry(&path).expect("hole found");
+ assert_eq!(geom.segments.len(), 1);
+ let seg = geom.segments[0];
+ assert!((seg.x - 0.4).abs() < 1e-6, "hole at col 2 of 5");
+ assert!((seg.y - 0.4).abs() < 1e-6, "hole at row 2 of 5");
+ assert!((geom.aspect - 1.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn computed_geometry_ignores_border_connected_transparency() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ // A C-shape: the notch opens to the border, so nothing is enclosed.
+ let path = write_png(
+ dir.path(),
+ "open.png",
+ &[&[1, 1, 1], &[1, 0, 0], &[1, 1, 1]],
+ );
+ assert!(compute_glow_geometry(&path).is_none());
+ }
+
+ #[test]
+ fn computed_geometry_skips_fully_opaque_renders() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let path = write_png(dir.path(), "solid.png", &[&[1, 1], &[1, 1]]);
+ assert!(compute_glow_geometry(&path).is_none());
+ }
}
diff --git a/crates/openlogi-gui/src/asset/images.rs b/crates/openlogi-gui/src/asset/images.rs
index fb0b64220a06484035070cd95802cddd23406820..f1774cfe42b27d921e5b7c6ba688b3002ec4de46 100644
--- a/crates/openlogi-gui/src/asset/images.rs
+++ b/crates/openlogi-gui/src/asset/images.rs
@@ -39,9 +39,10 @@ pub(super) fn read_png_dimensions(path: &Path) -> std::io::Result<(u32, u32)> {
}
/// Look up the colour variant matching `ext` in an already-loaded depot
-/// manifest. Returns the `device_image` src filename or `None` when the
-/// manifest lacks that variant. Pure — the caller loads the manifest once
-/// (see [`load_manifest`]) and reuses it across candidate bases.
+/// manifest. Returns the `device_image` src filename — falling back to
+/// `device_camera_image`, the hero-render key webcam depots use instead — or
+/// `None` when the manifest lacks that variant. Pure — the caller loads the
+/// manifest once (see [`load_manifest`]) and reuses it across candidate bases.
pub(super) fn variant_image_for(
manifest: &DepotManifest,
base_model_id: &str,
@@ -49,6 +50,7 @@ pub(super) fn variant_image_for(
) -> Option<String> {
manifest
.resource_for_variant(base_model_id, ext, "device_image")
+ .or_else(|| manifest.resource_for_variant(base_model_id, ext, "device_camera_image"))
.map(str::to_string)
}
diff --git a/crates/openlogi-gui/src/asset/sync.rs b/crates/openlogi-gui/src/asset/sync.rs
index 989bbbde28d272579424b393bddc3b7f3032bce7..f156a95453d7fbf85721d7c2818aea58fd72573c 100644
--- a/crates/openlogi-gui/src/asset/sync.rs
+++ b/crates/openlogi-gui/src/asset/sync.rs
@@ -51,10 +51,42 @@ pub fn should_run(has_bundle: bool) -> bool {
/// Each entry pairs a device's HID++ model info with its firmware `codename`,
/// so the depot match can fall back to the registry `displayName` for devices
/// whose live PID isn't in the registry (e.g. an MX Master 3S over BTLE).
-pub fn sync(
- source: Option<AssetSource>,
- models: &[(DeviceModelInfo, Option<String>)],
-) -> Result<()> {
+/// One model-level asset lookup requested by the GUI.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum AssetTarget {
+ /// HID++ lookup, retaining the existing codename/variant behavior.
+ Hidpp {
+ /// HID++ model information used for registry matching.
+ model: DeviceModelInfo,
+ /// Firmware codename used as the final matching fallback.
+ codename: Option<String>,
+ },
+ /// Standalone raw-HID lookup by the driver-provided registry identity.
+ Standalone {
+ /// Exact model-level registry id, never a physical-device key.
+ registry_model_id: String,
+ },
+}
+
+/// Stable session key for one model-level asset target.
+#[must_use]
+pub(crate) fn model_key(target: &AssetTarget) -> String {
+ match target {
+ AssetTarget::Hidpp { model, codename } => format!(
+ "hidpp:{:02x}:{:04x}:{:04x}:{:04x}:{}",
+ model.extended_model_id,
+ model.model_ids[0],
+ model.model_ids[1],
+ model.model_ids[2],
+ codename.as_deref().unwrap_or_default()
+ ),
+ AssetTarget::Standalone { registry_model_id } => {
+ format!("standalone:model:{registry_model_id}")
+ }
+ }
+}
+
+pub fn sync(source: Option<AssetSource>, targets: &[AssetTarget]) -> Result<()> {
let cache_root = super::paths::user_cache_root();
fs::create_dir_all(&cache_root)
.with_context(|| format!("create cache root {}", cache_root.display()))?;
@@ -71,31 +103,45 @@ pub fn sync(
// depot sync can fetch the right colour variant. `OPENLOGI_FORCE_DEPOT`
// doesn't correspond to a physical device, so we pass `ext = 0`
// and end up with the base PNG.
- let mut targets: Vec<(String, DeviceEntry, u8)> = Vec::new();
+ let mut depot_targets: Vec<(String, DeviceEntry, u8)> = Vec::new();
if let Ok(forced) = std::env::var("OPENLOGI_FORCE_DEPOT")
&& let Some(entry) = index.devices.get(&forced)
{
- targets.push((forced, entry.clone(), 0));
+ depot_targets.push((forced, entry.clone(), 0));
}
- for (model, codename) in models {
- if let Some((depot, entry)) = super::resolve_in_index(index, model, codename.as_deref()) {
- targets.push((depot.to_string(), entry.clone(), model.extended_model_id));
+ for target in targets {
+ let match_result = match target {
+ AssetTarget::Hidpp { model, codename } => {
+ super::resolve_in_index(index, model, codename.as_deref())
+ .map(|(depot, entry)| (depot, entry, model.extended_model_id))
+ }
+ AssetTarget::Standalone { registry_model_id } => index
+ .find_by_model_id(registry_model_id)
+ .map(|(depot, entry)| (depot, entry, 0)),
+ };
+ if let Some((depot, entry, ext)) = match_result {
+ depot_targets.push((depot.to_string(), entry.clone(), ext));
+ } else if let AssetTarget::Standalone { registry_model_id } = target {
+ info!(
+ registry_model_id,
+ "standalone model is not registered — using fallback art"
+ );
}
}
- targets.sort_by(|a, b| a.0.cmp(&b.0));
- targets.dedup_by(|a, b| a.0 == b.0);
+ depot_targets.sort_by(|a, b| a.0.cmp(&b.0));
+ depot_targets.dedup_by(|a, b| a.0 == b.0);
- if targets.is_empty() {
+ if depot_targets.is_empty() {
debug!("sync: no matching depots for known devices");
return Ok(());
}
- for (depot, entry, ext) in &targets {
+ for (depot, entry, ext) in &depot_targets {
if let Err(e) = sync_depot(client, &cache_root, depot, entry, *ext) {
warn!(depot, error = %e, "depot sync failed");
}
}
- info!(devices = targets.len(), "asset sync complete");
+ info!(devices = depot_targets.len(), "asset sync complete");
Ok(())
}
@@ -126,13 +172,18 @@ fn sync_depot(
warn!(depot, error = %e, "buttons render fetch failed");
}
- // Optional second pass: download the colour variant PNGs matching
- // the connected device's `extended_model_id`, for both the front
- // (carousel) and the side / buttons (mouse-model) views. Failure is
- // non-fatal — `AssetResolver.load_files` falls back to the bare hero
- // render that came in with the baseline fetch above.
+ // Optional second pass: download the manifest-mapped render PNGs — the
+ // colour variant matching the device's `extended_model_id` for the front
+ // (carousel) and side / buttons (mouse-model) views, plus the camera hero
+ // (`device_camera_image` — camera depots ship no bare `front*.png`, so the
+ // baseline fetch above brings no render for them at all). Failure is
+ // non-fatal — `AssetResolver.load_files` falls back to whatever landed.
let manifest_path = dir.join("manifest.json");
- for resource_key in ["device_image", "device_buttons_image"] {
+ for resource_key in [
+ "device_image",
+ "device_buttons_image",
+ "device_camera_image",
+ ] {
let Some(variant) =
pick_variant_filename(&manifest_path, &entry.model_id, ext, resource_key)
else {
@@ -178,15 +229,17 @@ fn fetch_to_cache(
/// Parse a freshly-downloaded `manifest.json` and resolve the colour
/// variant filename for `resource_key` (e.g. `"device_image"` or
-/// `"device_buttons_image"`). `None` when the manifest is missing,
-/// malformed, or doesn't list the device's `ext` byte.
+/// `"device_camera_image"`). `ext == 0` resolves the base-model entry —
+/// needed for depots whose base render isn't a baseline `front*.png` (the
+/// caller's skip list keeps already-fetched baseline names from re-fetching).
+/// `None` when the manifest is missing, malformed, or lacks the variant.
fn pick_variant_filename(
manifest_path: &Path,
base_model_id: &str,
ext: u8,
resource_key: &str,
) -> Option<String> {
- if ext == 0 || !manifest_path.exists() {
+ if !manifest_path.exists() {
return None;
}
let manifest = DepotManifest::load_from(manifest_path)
@@ -210,17 +263,6 @@ pub(crate) struct SyncOutcome {
/// extended-model byte (the colour-variant selector) and the codename the
/// depot match falls back on. Models that collapse to one key would resolve
/// to the same depot files anyway.
-pub(crate) fn model_key((model, codename): &(DeviceModelInfo, Option<String>)) -> String {
- format!(
- "{:02x}:{:04x}:{:04x}:{:04x}:{}",
- model.extended_model_id,
- model.model_ids[0],
- model.model_ids[1],
- model.model_ids[2],
- codename.as_deref().unwrap_or_default()
- )
-}
-
/// A manual asset action requested from the Settings → Assets tab, pushed to
/// the main event loop via [`AssetControl`].
pub enum AssetCommand {
@@ -258,13 +300,10 @@ pub(crate) fn sync_retry_delay(attempts: u32) -> Duration {
/// startup plus the auto-download setting; the Settings → Assets manual
/// actions always fetch, even in a release build that would otherwise serve
/// only bundled art.)
-pub(crate) fn run_asset_sync(
- preference: AssetSourcePreference,
- models: &[(DeviceModelInfo, Option<String>)],
-) -> bool {
+pub(crate) fn run_asset_sync(preference: AssetSourcePreference, targets: &[AssetTarget]) -> bool {
let server = std::env::var("OPENLOGI_ASSETS").ok();
let source = source_for_sync(preference, server.as_deref());
- match sync(source, models) {
+ match sync(source, targets) {
Ok(()) => true,
Err(e) => {
warn!(error = ?e, "asset sync failed — will retry with backoff");
@@ -290,7 +329,7 @@ fn source_for_sync(
#[cfg(test)]
mod tests {
- use super::{source_for_sync, sync_retry_delay};
+ use super::{AssetTarget, model_key, source_for_sync, sync_retry_delay};
use openlogi_assets::AssetSource;
use openlogi_core::config::AssetSourcePreference;
use std::time::Duration;
@@ -342,4 +381,14 @@ mod tests {
))
);
}
+
+ #[test]
+ fn standalone_target_key_is_model_scoped_not_physical() {
+ assert_eq!(
+ model_key(&AssetTarget::Standalone {
+ registry_model_id: "8c900".into(),
+ }),
+ "standalone:model:8c900"
+ );
+ }
}
diff --git a/crates/openlogi-gui/src/components/camera_controls.rs b/crates/openlogi-gui/src/components/camera_controls.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e480b2eafc8d31fab6daf68c8bd4ed22b4370075
--- /dev/null
+++ b/crates/openlogi-gui/src/components/camera_controls.rs
@@ -0,0 +1,998 @@
+//! Camera controls for the Camera tab: lens (zoom/focus/exposure) and image
+//! (brightness/contrast/…) sliders, auto toggles, and profiles.
+//!
+//! Each slider drives a UVC control straight on the device, so a change is
+//! seen by every app that opens the camera — Google Meet, Zoom, OBS — not just
+//! our preview. Values are persisted per-camera and re-applied over USB when
+//! the camera is next viewed, since the hardware only holds them until it
+//! loses power. Focus/exposure/white-balance carry an Auto chip mirroring the
+//! device's auto modes; their sliders disable while auto owns the value.
+//!
+//! Profiles are one-click control snapshots: three built-ins (Default /
+//! Streaming / Video call) plus user-saved customs, applied to the hardware in
+//! a single batched device-open.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_precision_loss,
+ clippy::cast_sign_loss,
+ reason = "UVC control values are small integers; slider math goes through f32"
+)]
+
+use gpui::{
+ AnyElement, AppContext as _, BorrowAppContext as _, ClickEvent, Context, Entity,
+ InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Render,
+ SharedString, StatefulInteractiveElement as _, Styled, Subscription, Window, div,
+ prelude::FluentBuilder as _, px, rgb,
+};
+use gpui_component::{
+ h_flex,
+ slider::{Slider, SliderEvent, SliderState},
+ v_flex,
+};
+use openlogi_camera::{AutoToggle, CameraControl, CameraState, ControlRange};
+use openlogi_core::config::CameraControls;
+use tracing::debug;
+
+use crate::state::AppState;
+use crate::theme::{self, ACCENT_BLUE, Palette};
+
+/// Built-in profiles: `values` are fractions of each control's own range, so
+/// they scale to whatever the camera reports. Auto modes all engage — the
+/// point of a preset is a good picture without babysitting.
+const BUILTIN_PROFILES: [BuiltinProfile; 3] = [
+ BuiltinProfile {
+ id: "default",
+ values: &[],
+ },
+ BuiltinProfile {
+ id: "streaming",
+ values: &[
+ (CameraControl::Brightness, 0.50),
+ (CameraControl::Contrast, 0.58),
+ (CameraControl::Saturation, 0.62),
+ (CameraControl::Sharpness, 0.60),
+ ],
+ },
+ BuiltinProfile {
+ id: "video_call",
+ values: &[
+ (CameraControl::Brightness, 0.55),
+ (CameraControl::Contrast, 0.52),
+ (CameraControl::Saturation, 0.55),
+ (CameraControl::Sharpness, 0.48),
+ ],
+ },
+];
+
+/// One built-in profile: an id for persistence plus range-relative targets
+/// (an empty list means "device defaults for everything").
+struct BuiltinProfile {
+ id: &'static str,
+ values: &'static [(CameraControl, f32)],
+}
+
+pub struct CameraControlsPanel {
+ /// Persistence key (`camera:vid:pid:serial:…` or legacy `camera-<uid>`).
+ key: Option<String>,
+ /// OS capture id used for UVC open/read/write (may change with USB port).
+ uid: Option<String>,
+ sliders: Vec<ControlSlider>,
+ autos: Vec<AutoRow>,
+ #[allow(dead_code, reason = "held to keep the AppState observer alive")]
+ state_obs: Subscription,
+}
+
+struct ControlSlider {
+ control: CameraControl,
+ label: SharedString,
+ range: ControlRange,
+ state: Entity<SliderState>,
+ #[allow(dead_code, reason = "held to keep the slider subscription alive")]
+ sub: Subscription,
+}
+
+/// Live UI state for one device-supported auto mode.
+struct AutoRow {
+ toggle: AutoToggle,
+ on: bool,
+ default: bool,
+}
+
+/// What [`CameraControlsPanel::ensure_built`] should build the panel from after
+/// re-asserting saved settings on the hardware.
+enum Reapplied {
+ /// Nothing needed writing, or the batch stuck — build from the desired
+ /// (saved-over-snapshot) state.
+ Clean,
+ /// The batch failed; build rows from this freshly-read live state.
+ Live(CameraState),
+ /// The batch failed and the confirming re-read failed too — the true
+ /// hardware state is unknown, so the caller must not cache a build.
+ Unknown,
+}
+
+impl CameraControlsPanel {
+ pub fn new(cx: &mut Context<Self>) -> Self {
+ let state_obs = cx.observe_global::<AppState>(|_panel, cx| cx.notify());
+ Self {
+ key: None,
+ uid: None,
+ sliders: Vec::new(),
+ autos: Vec::new(),
+ state_obs,
+ }
+ }
+
+ /// The active camera's `(config_key, capture_id)`, if a webcam is selected.
+ fn active_camera(cx: &Context<Self>) -> Option<(String, String)> {
+ let record = cx.try_global::<AppState>()?.current_record()?;
+ if !matches!(record.kind, openlogi_core::device::DeviceKind::Camera) {
+ return None;
+ }
+ Some((record.config_key.clone(), record.capture_id.clone()?))
+ }
+
+ /// Re-assert the saved auto/value differences on the hardware in one
+ /// device-open, reporting what the caller should build the panel from.
+ ///
+ /// `apply_settings` isn't atomic — it writes the auto mode, then the value —
+ /// so a rejected batch can leave the hardware between states, making the
+ /// pre-write snapshot untrustworthy. On failure we clear the active profile
+ /// (so a later edit's [`Self::sync_active_custom`] can't overwrite the saved
+ /// profile with fallback values) and re-read the device: [`Reapplied::Live`]
+ /// carries that truth for the caller to cache, while a re-read that also
+ /// fails yields [`Reapplied::Unknown`] — never the stale pre-write state.
+ fn reapply_saved(
+ key: &str,
+ uid: &str,
+ apply_autos: &[(AutoToggle, bool)],
+ apply_values: &[(CameraControl, i32)],
+ cx: &mut Context<Self>,
+ ) -> Reapplied {
+ if apply_autos.is_empty() && apply_values.is_empty() {
+ return Reapplied::Clean;
+ }
+ let Err(e) = openlogi_camera::apply_settings(uid, apply_autos, apply_values) else {
+ return Reapplied::Clean;
+ };
+ debug!(error = %e, "saved camera state reapply failed");
+ cx.update_global::<AppState, _>(|state, _| {
+ state.set_camera_active_profile(key, None);
+ });
+ match openlogi_camera::read_camera_state(uid) {
+ Ok(live) => Reapplied::Live(live),
+ Err(e) => {
+ debug!(error = %e, "post-failure camera re-read failed");
+ Reapplied::Unknown
+ }
+ }
+ }
+
+ /// Build the sliders and auto rows for `key` from the device's reported
+ /// state, re-applying any saved values in one batched device write. Cheap
+ /// no-op when already built for this camera. `uid` is the OS capture id.
+ fn ensure_built(&mut self, key: &str, uid: &str, cx: &mut Context<Self>) {
+ if self.key.as_deref() == Some(key) && self.uid.as_deref() == Some(uid) {
+ return;
+ }
+ self.sliders.clear();
+ self.autos.clear();
+ // Port-bound keys from older builds → stable serial key, once per open.
+ cx.update_global::<AppState, _>(|state, _| {
+ state.migrate_legacy_camera_key(key, uid);
+ });
+
+ // One device-open reads every control and auto state. A failed read
+ // means the camera is unreachable (unplugged or seized by another app):
+ // leave `self.key` unset so the next render retries, instead of caching
+ // an empty panel that never rebuilds once the device returns.
+ let Ok(snap) = openlogi_camera::read_camera_state(uid) else {
+ debug!("camera state read failed; retrying next render");
+ self.key = None;
+ self.uid = None;
+ return;
+ };
+ self.key = Some(key.to_string());
+ self.uid = Some(uid.to_string());
+
+ // Saved auto states win over the device's, then saved values win for
+ // controls whose auto is off; the differences push back in one open.
+ let mut desired_autos = Vec::new();
+ let mut apply_autos = Vec::new();
+ for (toggle, st) in &snap.autos {
+ let saved = cx
+ .try_global::<AppState>()
+ .and_then(|s| s.camera_auto(key, *toggle));
+ let on = saved.unwrap_or(st.current);
+ if on != st.current {
+ apply_autos.push((*toggle, on));
+ }
+ desired_autos.push((*toggle, on, *st));
+ }
+ let auto_desired = |control: CameraControl| {
+ let toggle = control.auto_toggle()?;
+ desired_autos
+ .iter()
+ .find(|(t, ..)| *t == toggle)
+ .map(|(_, on, _)| *on)
+ };
+ let mut desired_values = Vec::new();
+ let mut apply_values = Vec::new();
+ for (control, range) in &snap.controls {
+ let saved = cx
+ .try_global::<AppState>()
+ .and_then(|s| s.camera_control(key, *control));
+ let initial = saved.unwrap_or(range.current).clamp(range.min, range.max);
+ if saved.is_some()
+ && saved != Some(range.current)
+ && !auto_desired(*control).is_some_and(|on| on)
+ {
+ apply_values.push((*control, initial));
+ }
+ desired_values.push((*control, *range, initial));
+ }
+
+ // Saved state only sticks when the hardware takes it. On a rejected
+ // (non-atomic) batch, rebuild rows from the device's live state; if even
+ // that read fails, the hardware state is unknown — drop the key and let
+ // the next render retry rather than caching the stale pre-write values.
+ let live = match Self::reapply_saved(key, uid, &apply_autos, &apply_values, cx) {
+ Reapplied::Clean => None,
+ Reapplied::Live(state) => Some(state),
+ Reapplied::Unknown => {
+ self.key = None;
+ self.uid = None;
+ return;
+ }
+ };
+
+ for (toggle, on, st) in desired_autos {
+ let shown_on = match &live {
+ None => on,
+ Some(state) => state
+ .autos
+ .iter()
+ .find(|(t, _)| *t == toggle)
+ .map_or(st.current, |(_, s)| s.current),
+ };
+ self.autos.push(AutoRow {
+ toggle,
+ on: shown_on,
+ default: st.default,
+ });
+ }
+ for (control, range, initial) in desired_values {
+ let shown = match &live {
+ None => initial,
+ Some(state) => state
+ .controls
+ .iter()
+ .find(|(c, _)| *c == control)
+ .map_or(range.current, |(_, r)| r.current),
+ };
+ self.push_control_slider(control, range, shown, uid, key, cx);
+ }
+ }
+
+ /// Build one control's slider (seeded to `shown`), wire its release-writes
+ /// to the device, and push it onto the panel.
+ fn push_control_slider(
+ &mut self,
+ control: CameraControl,
+ range: ControlRange,
+ shown: i32,
+ uid: &str,
+ key: &str,
+ cx: &mut Context<Self>,
+ ) {
+ let state = cx.new(|_| {
+ let (lo, hi) = (range.min as f32, range.max as f32);
+ // `SliderState` defaults to [0, 100] and re-clamps its value on every
+ // builder call, panicking if min > max even transiently. A fully
+ // negative range (UVC exposure reports e.g. -11..-2) would make
+ // `.max(-2)` clamp against the default min of 0 — so set the min
+ // first for negative ranges, and the max first otherwise.
+ let bounded = if lo < 0.0 {
+ SliderState::new().min(lo).max(hi)
+ } else {
+ SliderState::new().max(hi).min(lo)
+ };
+ bounded.step(1.0).default_value(shown as f32)
+ });
+ let uid_for_event = uid.to_string();
+ let key_for_event = key.to_string();
+ let sub = cx.subscribe(&state, move |panel, _slider, event: &SliderEvent, cx| {
+ match event {
+ // Drag updates the label; the USB write lands once on release
+ // so we don't flood the camera with intermediate values.
+ SliderEvent::Change(_) => cx.notify(),
+ SliderEvent::Release(value) => {
+ let v = value.start().round() as i32;
+ panel.commit_release(control, &uid_for_event, &key_for_event, v, cx);
+ }
+ }
+ });
+ self.sliders.push(ControlSlider {
+ control,
+ label: control_label(control),
+ range,
+ state,
+ sub,
+ });
+ }
+
+ /// One slider release: write the value — taking the control over to manual
+ /// first when its auto mode owns it (the camera rejects gated values, and
+ /// grabbing the slider *is* the take-over gesture, as in G HUB) — then
+ /// persist exactly what the device took.
+ fn commit_release(
+ &mut self,
+ control: CameraControl,
+ uid: &str,
+ key: &str,
+ v: i32,
+ cx: &mut Context<Self>,
+ ) {
+ let takeover = control.auto_toggle().and_then(|toggle| {
+ let ix = self.autos.iter().position(|a| a.toggle == toggle && a.on)?;
+ Some((toggle, ix))
+ });
+ let written = match takeover {
+ Some((toggle, _)) => {
+ openlogi_camera::apply_settings(uid, &[(toggle, false)], &[(control, v)])
+ }
+ None => openlogi_camera::set_control(uid, control, v),
+ };
+ if let Err(e) = written {
+ debug!(?control, value = v, error = %e, "camera control write failed");
+ // The slider already moved to `v` on release, but the camera kept its
+ // old register (a plain write is atomic; a takeover can land auto-off
+ // before the value fails). Rebuild from live hardware so the panel
+ // never shows a value the device didn't take.
+ self.resync_after_failed_write(cx);
+ return;
+ }
+ if let Some((toggle, ix)) = takeover {
+ self.autos[ix].on = false;
+ cx.update_global::<AppState, _>(|state, _| {
+ state.commit_camera_auto(key, toggle, false);
+ });
+ }
+ cx.update_global::<AppState, _>(|state, _| {
+ state.commit_camera_control(key, control, v);
+ });
+ self.sync_active_custom(cx);
+ cx.notify();
+ }
+
+ /// The current auto state gating `control`, if the device has that toggle.
+ fn auto_state_for(&self, control: CameraControl) -> Option<bool> {
+ let toggle = control.auto_toggle()?;
+ self.autos.iter().find(|a| a.toggle == toggle).map(|a| a.on)
+ }
+
+ /// Flip one auto mode. Turning auto off re-asserts the slider's value so
+ /// the hardware ends where the UI shows, in the same device-open.
+ fn toggle_auto(&mut self, ix: usize, cx: &mut Context<Self>) {
+ let (Some(key), Some(uid)) = (self.key.clone(), self.uid.clone()) else {
+ return;
+ };
+ let Some(row) = self.autos.get(ix) else {
+ return;
+ };
+ let toggle = row.toggle;
+ let on = !row.on;
+ let mut values = Vec::new();
+ if !on
+ && let Some(slider) = self
+ .sliders
+ .iter()
+ .find(|s| s.control.auto_toggle() == Some(toggle))
+ {
+ values.push((
+ slider.control,
+ slider.state.read(cx).value().start().round() as i32,
+ ));
+ }
+ if let Err(e) = openlogi_camera::apply_settings(&uid, &[(toggle, on)], &values) {
+ debug!(?toggle, on, error = %e, "camera auto write failed");
+ // Turning auto off batches the slider value, so a partial write can
+ // land the mode but not the value; resync from live hardware.
+ self.resync_after_failed_write(cx);
+ return;
+ }
+ self.autos[ix].on = on;
+ cx.update_global::<AppState, _>(|state, _| {
+ state.commit_camera_auto(&key, toggle, on);
+ });
+ self.sync_active_custom(cx);
+ cx.notify();
+ }
+
+ /// Reset every control and auto mode to the device defaults, in one
+ /// batched device-open. All rows persist together or not at all — a
+ /// per-row loop would silently skip the remaining rows once a failure
+ /// invalidated the panel, leaving a mix of reset and stale saved values.
+ fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let (Some(key), Some(uid)) = (self.key.clone(), self.uid.clone()) else {
+ return;
+ };
+ let autos: Vec<(AutoToggle, bool)> = self
+ .autos
+ .iter()
+ .map(|row| (row.toggle, row.default))
+ .collect();
+ let values: Vec<(CameraControl, i32)> = self
+ .sliders
+ .iter()
+ .map(|s| (s.control, s.range.default))
+ .collect();
+ if let Err(e) = openlogi_camera::apply_settings(&uid, &autos, &values) {
+ debug!(error = %e, "camera reset failed");
+ // Partial writes may have landed; rebuild from live hardware
+ // rather than persisting a mixed reset.
+ self.resync_after_failed_write(cx);
+ return;
+ }
+ self.commit_batch(&key, &autos, &values, window, cx);
+ self.sync_active_custom(cx);
+ cx.notify();
+ }
+
+ /// After a successful batched write: mirror `autos` + `values` onto the
+ /// rows, re-seat the sliders, and persist everything to the config.
+ fn commit_batch(
+ &mut self,
+ key: &str,
+ autos: &[(AutoToggle, bool)],
+ values: &[(CameraControl, i32)],
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ for (toggle, on) in autos {
+ if let Some(row) = self.autos.iter_mut().find(|a| a.toggle == *toggle) {
+ row.on = *on;
+ }
+ }
+ for (control, value) in values {
+ if let Some(slider) = self.sliders.iter().find(|s| s.control == *control) {
+ slider.state.clone().update(cx, |s, cx| {
+ s.set_value(*value as f32, window, cx);
+ });
+ }
+ }
+ cx.update_global::<AppState, _>(|state, _| {
+ for (toggle, on) in autos {
+ state.commit_camera_auto(key, *toggle, *on);
+ }
+ for (control, value) in values {
+ state.commit_camera_control(key, *control, *value);
+ }
+ });
+ }
+
+ /// Reset one control to its device default — auto mode back to the
+ /// device's default state, the value re-seated and persisted.
+ fn reset_control(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
+ let (Some(key), Some(uid)) = (self.key.clone(), self.uid.clone()) else {
+ return;
+ };
+ let Some((control, default, state)) = self
+ .sliders
+ .get(ix)
+ .map(|s| (s.control, s.range.default, s.state.clone()))
+ else {
+ return;
+ };
+ let mut autos = Vec::new();
+ let auto_pos = control.auto_toggle().and_then(|toggle| {
+ let pos = self.autos.iter().position(|a| a.toggle == toggle)?;
+ autos.push((toggle, self.autos[pos].default));
+ Some(pos)
+ });
+ if let Err(e) = openlogi_camera::apply_settings(&uid, &autos, &[(control, default)]) {
+ debug!(?control, value = default, error = %e, "camera control reset failed");
+ // Auto default + value default aren't atomic; resync from live
+ // hardware so a partial reset can't desync the row.
+ self.resync_after_failed_write(cx);
+ return;
+ }
+ if let Some(pos) = auto_pos {
+ let (toggle, auto_default) = autos[0];
+ self.autos[pos].on = auto_default;
+ cx.update_global::<AppState, _>(|state, _| {
+ state.commit_camera_auto(&key, toggle, auto_default);
+ });
+ }
+ state.update(cx, |slider, cx| {
+ slider.set_value(default as f32, window, cx);
+ });
+ cx.update_global::<AppState, _>(|state, _| {
+ state.commit_camera_control(&key, control, default);
+ });
+ self.sync_active_custom(cx);
+ cx.notify();
+ }
+
+ /// Apply a built-in or saved profile: compute each control's target, push
+ /// everything to the hardware in one batched open, re-seat the sliders,
+ /// persist the values, and remember the selection.
+ fn apply_profile(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
+ let (Some(key), Some(uid)) = (self.key.clone(), self.uid.clone()) else {
+ return;
+ };
+ let custom = cx
+ .try_global::<AppState>()
+ .map(|s| s.camera_profiles(&key))
+ .unwrap_or_default();
+
+ // Auto targets: built-ins engage every auto mode except Default, which
+ // restores the device's own default states; customs use their snapshot
+ // (falling back to the current state for toggles they don't record).
+ let mut autos: Vec<(AutoToggle, bool)> = Vec::new();
+ let mut values: Vec<(CameraControl, i32)> = Vec::new();
+ if let Some(builtin) = BUILTIN_PROFILES.iter().find(|p| p.id == id) {
+ for row in &self.autos {
+ autos.push((
+ row.toggle,
+ if builtin.id == "default" {
+ row.default
+ } else {
+ true
+ },
+ ));
+ }
+ for slider in &self.sliders {
+ let target = builtin
+ .values
+ .iter()
+ .find(|(c, _)| *c == slider.control)
+ .map_or(slider.range.default, |(_, pct)| {
+ let span = (slider.range.max - slider.range.min) as f32;
+ slider.range.min + (span * pct).round() as i32
+ });
+ values.push((
+ slider.control,
+ target.clamp(slider.range.min, slider.range.max),
+ ));
+ }
+ } else if let Some(snap) = custom.get(id) {
+ for row in &self.autos {
+ let on = snap.0.get(row.toggle.name()).map_or(row.on, |v| *v != 0);
+ autos.push((row.toggle, on));
+ }
+ for slider in &self.sliders {
+ if let Some(v) = snap.0.get(slider.control.name()) {
+ values.push((
+ slider.control,
+ (*v).clamp(slider.range.min, slider.range.max),
+ ));
+ }
+ }
+ } else {
+ return;
+ }
+
+ if let Err(e) = openlogi_camera::apply_settings(&uid, &autos, &values) {
+ debug!(profile = id, error = %e, "camera profile apply failed");
+ // Some writes may have landed; resync from live state and drop the
+ // active profile so a later edit can't persist a half-applied one.
+ self.resync_after_failed_write(cx);
+ return;
+ }
+ self.commit_batch(&key, &autos, &values, window, cx);
+ cx.update_global::<AppState, _>(|state, _| {
+ state.set_camera_active_profile(&key, Some(id.to_string()));
+ });
+ cx.notify();
+ }
+
+ /// The current control values + auto states as a profile snapshot.
+ fn snapshot(&self, cx: &Context<Self>) -> CameraControls {
+ let mut snap = CameraControls::default();
+ for slider in &self.sliders {
+ snap.0.insert(
+ slider.control.name().to_string(),
+ slider.state.read(cx).value().start().round() as i32,
+ );
+ }
+ for row in &self.autos {
+ snap.0
+ .insert(row.toggle.name().to_string(), i32::from(row.on));
+ }
+ snap
+ }
+
+ /// Keep the active *custom* profile tracking live edits: any slider or
+ /// auto change writes back into its snapshot, so a profile is always what
+ /// you last saw while it was selected. Built-ins are never edited.
+ fn sync_active_custom(&self, cx: &mut Context<Self>) {
+ let Some(key) = self.key.clone() else {
+ return;
+ };
+ let snap = self.snapshot(cx);
+ cx.update_global::<AppState, _>(|state, _| {
+ let Some(active) = state.camera_active_profile(&key) else {
+ return;
+ };
+ if state.camera_profiles(&key).contains_key(&active) {
+ state.save_camera_profile(&key, &active, snap);
+ }
+ });
+ }
+
+ /// Recover after a batched device write failed partway through.
+ /// `apply_settings` is not atomic (it writes the auto mode, then the value,
+ /// in one open), so a partial failure can leave the hardware between the old
+ /// and new state. Drop the cached rows so the panel rebuilds from the
+ /// device's live state on the next render, and clear any active profile so a
+ /// later edit's [`Self::sync_active_custom`] can't overwrite a saved profile
+ /// with those rebuilt values.
+ fn resync_after_failed_write(&mut self, cx: &mut Context<Self>) {
+ self.uid = None;
+ if let Some(key) = self.key.take() {
+ cx.update_global::<AppState, _>(|state, _| {
+ state.set_camera_active_profile(&key, None);
+ });
+ }
+ cx.notify();
+ }
+
+ /// Save the current control values + auto states as a new custom profile
+ /// (auto-named `Custom N`) and mark it active.
+ fn save_profile(&mut self, cx: &mut Context<Self>) {
+ let Some(key) = self.key.clone() else {
+ return;
+ };
+ let snap = self.snapshot(cx);
+ cx.update_global::<AppState, _>(|state, _| {
+ let existing = state.camera_profiles(&key);
+ let mut n = existing.len() + 1;
+ let mut name = format!("Custom {n}");
+ while existing.contains_key(&name) {
+ n += 1;
+ name = format!("Custom {n}");
+ }
+ state.save_camera_profile(&key, &name, snap);
+ state.set_camera_active_profile(&key, Some(name));
+ });
+ cx.notify();
+ }
+
+ /// Delete a saved custom profile. The hardware keeps whatever it's set to —
+ /// only the snapshot (and, if it named this profile, the selection) goes.
+ fn delete_profile(&mut self, name: &str, cx: &mut Context<Self>) {
+ let Some(key) = self.key.clone() else {
+ return;
+ };
+ cx.update_global::<AppState, _>(|state, _| {
+ state.delete_camera_profile(&key, name);
+ });
+ cx.notify();
+ }
+}
+
+impl Render for CameraControlsPanel {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let pal = theme::palette(cx);
+ let Some((key, uid)) = Self::active_camera(cx) else {
+ self.key = None;
+ self.uid = None;
+ self.sliders.clear();
+ self.autos.clear();
+ return div().into_any_element();
+ };
+ self.ensure_built(&key, &uid, cx);
+
+ if self.sliders.is_empty() {
+ return div()
+ .text_sm()
+ .text_color(pal.text_muted)
+ .child(tr!("This camera exposes no adjustable image controls."))
+ .into_any_element();
+ }
+
+ let lens: Vec<usize> = section_indices(&self.sliders, true);
+ let image: Vec<usize> = section_indices(&self.sliders, false);
+
+ let mut panel = v_flex().gap_2().w_full().child(profiles_row(&key, pal, cx));
+ if !lens.is_empty() && !image.is_empty() {
+ panel = panel.child(section_label(tr!("Lens"), pal));
+ }
+ for ix in lens {
+ panel = panel.child(control_row(self, ix, cx, pal));
+ }
+ if !image.is_empty() && self.sliders.len() != image.len() {
+ panel = panel.child(section_label(tr!("Image"), pal));
+ }
+ for ix in image {
+ panel = panel.child(control_row(self, ix, cx, pal));
+ }
+ panel.child(reset_button(pal, cx)).into_any_element()
+ }
+}
+
+/// Indices of the lens (camera-terminal) or image (processing-unit) sliders,
+/// preserving [`CameraControl::ALL`] order.
+fn section_indices(sliders: &[ControlSlider], lens: bool) -> Vec<usize> {
+ sliders
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| {
+ matches!(
+ s.control,
+ CameraControl::Zoom | CameraControl::Focus | CameraControl::Exposure
+ ) == lens
+ })
+ .map(|(ix, _)| ix)
+ .collect()
+}
+
+/// The one-click profile chips: built-ins, saved customs, then Save.
+fn profiles_row(key: &str, pal: Palette, cx: &mut Context<CameraControlsPanel>) -> AnyElement {
+ let state = cx.try_global::<AppState>();
+ let active = state.and_then(|s| s.camera_active_profile(key));
+ let customs: Vec<String> = state
+ .map(|s| s.camera_profiles(key).keys().cloned().collect())
+ .unwrap_or_default();
+
+ let mut row = h_flex().flex_wrap().gap_1p5().items_center();
+ for (ix, builtin) in BUILTIN_PROFILES.iter().enumerate() {
+ let id = builtin.id;
+ row = row.child(profile_chip(
+ ("camera-profile-builtin", ix),
+ builtin_label(id),
+ active.as_deref() == Some(id),
+ pal,
+ cx.listener(move |panel, _: &ClickEvent, window, cx| {
+ panel.apply_profile(id, window, cx);
+ }),
+ ));
+ }
+ for (ix, name) in customs.into_iter().enumerate() {
+ let is_active = active.as_deref() == Some(name.as_str());
+ row = row.child(custom_profile_chip(ix, name, is_active, pal, cx));
+ }
+ row = row.child(
+ div()
+ .id("camera-profile-save")
+ .px_2()
+ .py_0p5()
+ .rounded_full()
+ .border_1()
+ .border_color(pal.border)
+ .text_xs()
+ .text_color(pal.text_muted)
+ .hover(|s| s.bg(pal.surface_hover))
+ .child(format!("+ {}", tr!("New")))
+ .on_click(cx.listener(|panel, _: &ClickEvent, _window, cx| {
+ panel.save_profile(cx);
+ })),
+ );
+ row.into_any_element()
+}
+
+fn profile_chip(
+ id: (&'static str, usize),
+ label: SharedString,
+ active: bool,
+ pal: Palette,
+ on_click: impl Fn(&ClickEvent, &mut Window, &mut gpui::App) + 'static,
+) -> AnyElement {
+ let accent = rgb(ACCENT_BLUE);
+ div()
+ .id(id)
+ .px_2()
+ .py_0p5()
+ .rounded_full()
+ .border_1()
+ .border_color(if active { accent.into() } else { pal.border })
+ .text_xs()
+ .text_color(if active {
+ accent.into()
+ } else {
+ pal.text_muted
+ })
+ .when(active, |s| s.bg(pal.surface))
+ .hover(move |s| s.bg(pal.surface_hover))
+ .child(label)
+ .on_click(on_click)
+ .into_any_element()
+}
+
+/// A saved custom profile's chip: click applies it, the trailing `×` deletes
+/// it (stopping propagation so a delete never also applies the profile).
+fn custom_profile_chip(
+ ix: usize,
+ name: String,
+ active: bool,
+ pal: Palette,
+ cx: &mut Context<CameraControlsPanel>,
+) -> AnyElement {
+ let accent = rgb(ACCENT_BLUE);
+ let apply_name = name.clone();
+ let delete_name = name.clone();
+ h_flex()
+ .id(("camera-profile-custom", ix))
+ .pl_2()
+ .pr_1()
+ .py_0p5()
+ .gap_1()
+ .items_center()
+ .rounded_full()
+ .border_1()
+ .border_color(if active { accent.into() } else { pal.border })
+ .text_xs()
+ .text_color(if active {
+ accent.into()
+ } else {
+ pal.text_muted
+ })
+ .when(active, |s| s.bg(pal.surface))
+ .hover(move |s| s.bg(pal.surface_hover))
+ .child(SharedString::from(name))
+ .on_click(cx.listener(move |panel, _: &ClickEvent, window, cx| {
+ panel.apply_profile(&apply_name, window, cx);
+ }))
+ .child(
+ div()
+ .id(("camera-profile-del", ix))
+ .px_0p5()
+ .rounded_full()
+ .text_color(pal.text_muted)
+ .hover(|s| s.text_color(gpui::white()))
+ .child("×")
+ .on_click(cx.listener(move |panel, _: &ClickEvent, _window, cx| {
+ cx.stop_propagation();
+ panel.delete_profile(&delete_name, cx);
+ })),
+ )
+ .into_any_element()
+}
+
+fn section_label(text: SharedString, pal: Palette) -> AnyElement {
+ div()
+ .mt_1()
+ .text_xs()
+ .text_color(pal.text_muted)
+ .child(text)
+ .into_any_element()
+}
+
+/// One compact control line: label · slider · live value (· Auto chip when the
+/// device pairs one). Double-click anywhere on the line resets that control.
+fn control_row(
+ panel: &CameraControlsPanel,
+ ix: usize,
+ cx: &Context<CameraControlsPanel>,
+ pal: Palette,
+) -> AnyElement {
+ let slider = &panel.sliders[ix];
+ let value = slider.state.read(cx).value().start().round() as i32;
+ let auto_on = panel.auto_state_for(slider.control);
+ let dimmed = auto_on == Some(true);
+
+ let mut row = h_flex()
+ .id(("camera-control-row", ix))
+ .w_full()
+ .gap_3()
+ .items_center()
+ // Capture phase, so the double-click wins over the slider's own
+ // handlers: the thumb's mouse-down stops propagation (a bubbled click
+ // never fires), and a track click would jump the value and then
+ // re-commit it from its deferred Release event after the reset ran.
+ .capture_any_mouse_down(cx.listener(
+ move |panel, event: &MouseDownEvent, window, cx| {
+ if event.button == MouseButton::Left && event.click_count == 2 {
+ cx.stop_propagation();
+ panel.reset_control(ix, window, cx);
+ }
+ },
+ ))
+ .child(
+ div()
+ .w(px(96.))
+ .flex_shrink_0()
+ .truncate()
+ .text_sm()
+ .text_color(pal.text_muted)
+ .child(slider.label.clone()),
+ )
+ .child(
+ div()
+ .flex_1()
+ // Dimmed while auto owns the value, but still draggable —
+ // grabbing the slider takes the control over to manual.
+ .when(dimmed, |s| s.opacity(0.55))
+ .child(Slider::new(&slider.state).horizontal()),
+ )
+ .child(
+ div()
+ .w(px(36.))
+ .flex_shrink_0()
+ .text_right()
+ .text_sm()
+ .text_color(if dimmed {
+ pal.text_muted
+ } else {
+ rgb(ACCENT_BLUE).into()
+ })
+ .child(format!("{value}")),
+ );
+
+ // Every row carries the trailing Auto column — empty for controls without
+ // an auto mode — so the sliders and values align across the whole panel.
+ let mut auto_cell = div().w(px(46.)).flex_shrink_0().flex().justify_end();
+ if let Some(on) = auto_on
+ && let Some(toggle) = slider.control.auto_toggle()
+ && let Some(auto_ix) = panel.autos.iter().position(|a| a.toggle == toggle)
+ {
+ let accent = rgb(ACCENT_BLUE);
+ auto_cell = auto_cell.child(
+ div()
+ .id(("camera-control-auto", ix))
+ .px_1p5()
+ .py_0p5()
+ .rounded_full()
+ .border_1()
+ .border_color(if on { accent.into() } else { pal.border })
+ .text_xs()
+ .text_color(if on { accent.into() } else { pal.text_muted })
+ .hover(|s| s.bg(pal.surface_hover))
+ .child(tr!("Auto"))
+ .on_click(cx.listener(move |panel, _: &ClickEvent, _window, cx| {
+ panel.toggle_auto(auto_ix, cx);
+ })),
+ );
+ }
+ row = row.child(auto_cell);
+
+ row.into_any_element()
+}
+
+fn reset_button(pal: Palette, cx: &mut Context<CameraControlsPanel>) -> AnyElement {
+ h_flex()
+ .w_full()
+ .justify_end()
+ .child(
+ div()
+ .id("camera-controls-reset")
+ .px_2p5()
+ .py_0p5()
+ .rounded_md()
+ .border_1()
+ .border_color(pal.border)
+ .bg(pal.surface)
+ .hover(|s| s.bg(pal.surface_hover))
+ .text_xs()
+ .text_color(pal.text_muted)
+ .child(tr!("Reset to defaults"))
+ .on_click(cx.listener(|panel, _: &ClickEvent, window, cx| {
+ panel.reset(window, cx);
+ })),
+ )
+ .into_any_element()
+}
+
+fn builtin_label(id: &str) -> SharedString {
+ match id {
+ "streaming" => tr!("Streaming"),
+ "video_call" => tr!("Video call"),
+ _ => tr!("Default"),
+ }
+}
+
+fn control_label(control: CameraControl) -> SharedString {
+ match control {
+ CameraControl::Zoom => tr!("Zoom"),
+ CameraControl::Focus => tr!("Focus"),
+ CameraControl::Exposure => tr!("Exposure"),
+ CameraControl::Brightness => tr!("Brightness"),
+ CameraControl::Contrast => tr!("Contrast"),
+ CameraControl::Saturation => tr!("Saturation"),
+ CameraControl::Sharpness => tr!("Sharpness"),
+ CameraControl::WhiteBalance => tr!("White balance"),
+ CameraControl::Tint => tr!("Tint"),
+ }
+}
diff --git a/crates/openlogi-gui/src/components/camera_preview.rs b/crates/openlogi-gui/src/components/camera_preview.rs
new file mode 100644
index 0000000000000000000000000000000000000000..623a1869a9026b4d2dd8fb0a63d9aeb7f81edd85
--- /dev/null
+++ b/crates/openlogi-gui/src/components/camera_preview.rs
@@ -0,0 +1,172 @@
+//! Live webcam preview, driven by the parent view's tab visibility.
+//!
+//! [`CameraPreview::set_target`] is the single lifecycle switch: the parent
+//! ([`crate::app::AppView`]) calls it each render with the active camera's id
+//! while the live-preview tab is showing, or `None` otherwise. Passing `None`
+//! — leaving the tab, going home, or selecting another device — drops the
+//! `AVCaptureSession`, so the LED goes off and the camera leaves zero CPU,
+//! memory, and GPU texture behind. The camera is therefore active *only* while
+//! you are looking at it.
+//!
+//! While streaming it captures at 720p (Retina-sharp for the 480pt box),
+//! rebuilds the GPU texture only when a new frame arrives, and repaints at the
+//! camera's ~30 fps delivery rate.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use gpui::{
+ AnyElement, Context, IntoElement, ParentElement, Render, RenderImage, SharedString, Styled,
+ Task, Window, div, img, px,
+};
+use gpui_component::v_flex;
+use image::{Frame as ImageFrame, RgbaImage};
+use openlogi_camera::{CameraStream, Frame};
+
+use crate::theme::{self, Palette};
+
+const PREVIEW_W: f32 = 480.;
+const PREVIEW_H: f32 = 270.; // 16:9
+
+/// Live preview view. Holds the capture stream + its texture only while the
+/// parent points it at a camera via [`Self::set_target`].
+pub struct CameraPreview {
+ stream: Option<CameraStream>,
+ streaming_uid: Option<String>,
+ current_image: Option<Arc<RenderImage>>,
+ last_generation: u64,
+ /// Frame-rate repaint pump; exists only while streaming (dropping it cancels it).
+ repaint_task: Option<Task<()>>,
+}
+
+impl CameraPreview {
+ pub fn new(_cx: &mut Context<Self>) -> Self {
+ Self {
+ stream: None,
+ streaming_uid: None,
+ current_image: None,
+ last_generation: 0,
+ repaint_task: None,
+ }
+ }
+
+ /// Point the preview at `target` (a camera's unique id) or `None` to stop.
+ /// The parent calls this every render from the active detail tab, so the
+ /// camera runs only while its preview is on screen. Idempotent when the
+ /// target is unchanged.
+ pub fn set_target(&mut self, target: Option<String>, cx: &mut Context<Self>) {
+ if target == self.streaming_uid {
+ return;
+ }
+ // Stop the old stream first: drop the session (LED off), cancel the
+ // repaint pump, and free the GPU texture immediately — not in `render`,
+ // which stops running the moment the preview leaves the screen.
+ self.stream = None;
+ self.repaint_task = None;
+ self.last_generation = 0;
+ if let Some(old) = self.current_image.take() {
+ cx.drop_image(old, None);
+ }
+ self.streaming_uid.clone_from(&target);
+
+ let Some(uid) = target else {
+ cx.notify();
+ return;
+ };
+ // Only open the camera when access is already granted, so selecting it
+ // never blocks the UI thread on the permission dialog.
+ if openlogi_camera::camera_access_granted() {
+ self.stream = openlogi_camera::start_stream(&uid).ok();
+ }
+ if self.stream.is_some() {
+ self.repaint_task = Some(cx.spawn(async move |this, cx| {
+ loop {
+ cx.background_executor()
+ .timer(Duration::from_millis(16))
+ .await;
+ // Repaint only when a *new* frame has arrived, so gpui isn't
+ // re-rendering the window on idle ticks.
+ let result = this.update(cx, |view, cx| {
+ let has_new = view
+ .stream
+ .as_ref()
+ .is_some_and(|s| s.frame_generation() != view.last_generation);
+ if has_new {
+ cx.notify();
+ }
+ });
+ if result.is_err() {
+ break;
+ }
+ }
+ }));
+ }
+ cx.notify();
+ }
+}
+
+impl Render for CameraPreview {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let pal = theme::palette(cx);
+ let granted = openlogi_camera::camera_access_granted();
+
+ // Rebuild the texture only when a new frame arrived; free the old one.
+ if let Some(stream) = self.stream.as_ref() {
+ let generation = stream.frame_generation();
+ if generation != self.last_generation
+ && let Some(image) = stream
+ .take_frame()
+ .and_then(|f| build_image(Arc::unwrap_or_clone(f)))
+ {
+ if let Some(old) = self.current_image.take() {
+ let _ = window.drop_image(old);
+ }
+ self.current_image = Some(image);
+ self.last_generation = generation;
+ }
+ }
+
+ let surface: AnyElement = if let Some(image) = self.current_image.as_ref() {
+ img(image.clone())
+ .w(px(PREVIEW_W))
+ .h(px(PREVIEW_H))
+ .rounded_md()
+ .into_any_element()
+ } else if !openlogi_camera::capture_supported() {
+ note(
+ tr!("Live preview isn't available on this platform yet."),
+ pal,
+ )
+ } else if granted {
+ note(tr!("Starting preview…"), pal)
+ } else {
+ note(tr!("Enable Camera access in Settings to preview."), pal)
+ };
+
+ v_flex()
+ .w(px(PREVIEW_W))
+ .h(px(PREVIEW_H))
+ .items_center()
+ .justify_center()
+ .rounded_md()
+ .border_1()
+ .border_color(pal.border)
+ .bg(pal.surface)
+ .child(surface)
+ }
+}
+
+/// Wrap a BGRA camera frame as a gpui texture. The frame is already in gpui's
+/// BGRA order and is consumed whole, so no pixel buffer is copied or swapped.
+fn build_image(frame: Frame) -> Option<Arc<RenderImage>> {
+ let buffer = RgbaImage::from_raw(frame.width, frame.height, frame.bgra)?;
+ Some(Arc::new(RenderImage::new(vec![ImageFrame::new(buffer)])))
+}
+
+fn note(text: impl Into<SharedString>, pal: Palette) -> AnyElement {
+ div()
+ .text_sm()
+ .text_color(pal.text_muted)
+ .child(text.into())
+ .into_any_element()
+}
diff --git a/crates/openlogi-gui/src/components/light_panel.rs b/crates/openlogi-gui/src/components/light_panel.rs
new file mode 100644
index 0000000000000000000000000000000000000000..8d2f6857db4336b289e9fc06914d716022013737
--- /dev/null
+++ b/crates/openlogi-gui/src/components/light_panel.rs
@@ -0,0 +1,537 @@
+//! Capability-driven controls for standalone lights.
+
+use crate::state::{AppState, LightCommandStatus};
+use crate::theme::{self, ACCENT_BLUE, Palette, SelectableStyle as _, Typography as _};
+use gpui::{
+ AppContext as _, BorrowAppContext as _, BoxShadow, Context, Entity, Hsla, InteractiveElement,
+ IntoElement, ParentElement, Render, StatefulInteractiveElement as _, Styled, Subscription,
+ Window, div, hsla, point, prelude::FluentBuilder as _, px, rgb,
+};
+use gpui_component::{
+ Icon, IconName, h_flex,
+ slider::{Slider, SliderEvent, SliderState},
+ v_flex,
+};
+use openlogi_core::{
+ config::LightSettings,
+ device::{LightCapabilities, LightValueRange, LightValueUnit},
+};
+
+/// Standalone-light panel. The UI is driven by the active device's advertised
+/// capabilities; the panel is not Litra-specific even though Litra is the
+/// first driver.
+pub struct LightPanel {
+ brightness: Option<Entity<SliderState>>,
+ temperature: Option<Entity<SliderState>>,
+ brightness_range: Option<LightValueRange>,
+ temperature_range: Option<LightValueRange>,
+ device_key: Option<String>,
+ last_brightness: u8,
+ last_temperature: Option<u16>,
+ brightness_sub: Option<Subscription>,
+ temperature_sub: Option<Subscription>,
+ _state_obs: Subscription,
+}
+
+impl LightPanel {
+ /// Construct the panel. Capability-shaped sliders are created lazily when
+ /// the selected device is known.
+ pub fn new(cx: &mut Context<Self>) -> Self {
+ let settings = cx
+ .try_global::<AppState>()
+ .map(AppState::light)
+ .unwrap_or_default();
+ let state_obs = cx.observe_global::<AppState>(|_, cx| cx.notify());
+ Self {
+ brightness: None,
+ temperature: None,
+ brightness_range: None,
+ temperature_range: None,
+ device_key: None,
+ last_brightness: settings.brightness_percent,
+ last_temperature: settings.temperature_kelvin,
+ brightness_sub: None,
+ temperature_sub: None,
+ _state_obs: state_obs,
+ }
+ }
+
+ fn ensure_sliders(
+ &mut self,
+ key: Option<&str>,
+ capabilities: Option<LightCapabilities>,
+ settings: LightSettings,
+ cx: &mut Context<Self>,
+ ) {
+ let brightness_range = capabilities.and_then(|caps| caps.brightness);
+ let temperature_range = capabilities.and_then(|caps| caps.temperature);
+ if self.device_key.as_deref() == key
+ && self.brightness_range == brightness_range
+ && self.temperature_range == temperature_range
+ {
+ return;
+ }
+
+ self.brightness = None;
+ self.temperature = None;
+ self.brightness_sub = None;
+ self.temperature_sub = None;
+ self.device_key = key.map(str::to_string);
+ self.brightness_range = brightness_range;
+ self.temperature_range = temperature_range;
+
+ if let Some(range) = brightness_range {
+ let value = range
+ .native_for_percent(settings.brightness_percent)
+ .unwrap_or(range.min());
+ let slider = cx.new(|_| {
+ SliderState::new()
+ .max(f32::from(range.max()))
+ .min(f32::from(range.min()))
+ .step(f32::from(range.step()))
+ .default_value(f32::from(value))
+ });
+ let subscription =
+ cx.subscribe(&slider, move |_panel, _slider, event: &SliderEvent, cx| {
+ if let SliderEvent::Release(value) = event {
+ let native = round_u16(value.start());
+ let Some(percent) = range.percent_for_native(native) else {
+ return;
+ };
+ cx.update_global::<AppState, _>(|state, _| {
+ let mut light = state.light();
+ if !state.camera_automation_active() {
+ light.enabled = true;
+ }
+ light.brightness_percent = percent;
+ state.commit_light(light);
+ });
+ cx.notify();
+ }
+ });
+ self.brightness = Some(slider);
+ self.brightness_sub = Some(subscription);
+ }
+
+ if let Some(range) = temperature_range {
+ let value = settings
+ .temperature_kelvin
+ .map_or_else(|| midpoint(range), |value| range.quantize(value));
+ let slider = cx.new(|_| {
+ SliderState::new()
+ .max(f32::from(range.max()))
+ .min(f32::from(range.min()))
+ .step(f32::from(range.step()))
+ .default_value(f32::from(value))
+ });
+ let subscription =
+ cx.subscribe(&slider, move |_panel, _slider, event: &SliderEvent, cx| {
+ if let SliderEvent::Release(value) = event {
+ let kelvin = range.quantize(round_u16(value.start()));
+ cx.update_global::<AppState, _>(|state, _| {
+ let mut light = state.light();
+ if !state.camera_automation_active() {
+ light.enabled = true;
+ }
+ light.temperature_kelvin = Some(kelvin);
+ state.commit_light(light);
+ });
+ cx.notify();
+ }
+ });
+ self.temperature = Some(slider);
+ self.temperature_sub = Some(subscription);
+ }
+ }
+}
+
+impl Render for LightPanel {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let pal = theme::palette(cx);
+ let settings = cx
+ .try_global::<AppState>()
+ .map(AppState::light)
+ .unwrap_or_default();
+ let record = cx
+ .try_global::<AppState>()
+ .and_then(AppState::current_record)
+ .cloned();
+ let capabilities = record.as_ref().and_then(|record| record.light_capabilities);
+
+ self.ensure_sliders(
+ record.as_ref().map(|record| record.config_key.as_str()),
+ capabilities,
+ settings,
+ cx,
+ );
+
+ if settings.brightness_percent != self.last_brightness {
+ self.last_brightness = settings.brightness_percent;
+ if let (Some(range), Some(slider)) = (self.brightness_range, &self.brightness) {
+ let value = range
+ .native_for_percent(settings.brightness_percent)
+ .unwrap_or(range.min());
+ slider.update(cx, |slider, cx| {
+ slider.set_value(f32::from(value), window, cx);
+ });
+ }
+ }
+ if settings.temperature_kelvin != self.last_temperature {
+ self.last_temperature = settings.temperature_kelvin;
+ if let (Some(range), Some(slider)) = (self.temperature_range, &self.temperature) {
+ let value = settings
+ .temperature_kelvin
+ .map_or_else(|| midpoint(range), |kelvin| range.quantize(kelvin));
+ slider.update(cx, |slider, cx| {
+ slider.set_value(f32::from(value), window, cx);
+ });
+ }
+ }
+
+ let device_name = record.as_ref().map_or_else(
+ || tr!("Lighting").to_string(),
+ |record| record.display_name.clone(),
+ );
+ let online = record.as_ref().is_some_and(|record| record.online);
+ let effective_enabled = cx
+ .try_global::<AppState>()
+ .is_some_and(AppState::light_enabled);
+ let power = capabilities.is_some_and(|caps| caps.power);
+
+ let mut panel = v_flex().gap_4().w_full();
+ if power {
+ panel = panel.child(light_hero(&device_name, online, effective_enabled, pal));
+ #[cfg(target_os = "macos")]
+ {
+ panel = panel.child(camera_automation(settings, pal));
+ }
+ panel = panel.child(div().h(px(1.)).w_full().bg(pal.border.opacity(0.55)));
+ }
+ if let (Some(range), Some(slider)) = (self.brightness_range, &self.brightness) {
+ let value = range
+ .native_for_percent(settings.brightness_percent)
+ .unwrap_or(range.min());
+ panel = panel.child(control_well(
+ tr!("BRIGHTNESS"),
+ format_light_value(value, range.unit()),
+ format_range_endpoints(range),
+ Slider::new(slider).horizontal(),
+ pal,
+ ));
+ }
+ if let (Some(range), Some(slider)) = (self.temperature_range, &self.temperature) {
+ let value = settings
+ .temperature_kelvin
+ .map_or_else(|| midpoint(range), |kelvin| range.quantize(kelvin));
+ panel = panel.child(control_well(
+ tr!("COLOUR TEMPERATURE"),
+ format_light_value(value, range.unit()),
+ format_range_endpoints(range),
+ Slider::new(slider).horizontal(),
+ pal,
+ ));
+ }
+ if let Some(status) = cx
+ .try_global::<AppState>()
+ .and_then(AppState::light_command_status)
+ {
+ panel = panel.child(light_command_status(status, pal));
+ }
+ panel
+ }
+}
+
+fn light_hero(
+ device_name: &str,
+ online: bool,
+ effective_enabled: bool,
+ pal: Palette,
+) -> impl IntoElement {
+ h_flex()
+ .gap_3()
+ .items_center()
+ .child(light_emblem(effective_enabled, pal))
+ .child(
+ v_flex()
+ .gap_1()
+ .flex_1()
+ .min_w_0()
+ .child(div().text_heading().child(device_name.to_owned()))
+ .child(light_status(online, effective_enabled, pal)),
+ )
+ .child(toggle(effective_enabled, pal))
+}
+
+fn light_emblem(enabled: bool, pal: Palette) -> impl IntoElement {
+ let halo = if enabled {
+ hsla(0.105, 0.9, 0.66, 0.22)
+ } else {
+ pal.surface_hover
+ };
+ let icon_color: Hsla = if enabled {
+ hsla(0.105, 0.9, 0.66, 1.)
+ } else {
+ pal.text_muted
+ };
+ let icon = if enabled {
+ IconName::Sun
+ } else {
+ IconName::Moon
+ };
+
+ div()
+ .relative()
+ .size(px(64.))
+ .flex_none()
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded(pal.card_radius)
+ .bg(halo)
+ .border_1()
+ .border_color(if enabled {
+ hsla(0.105, 0.9, 0.66, 0.35)
+ } else {
+ pal.border
+ })
+ .when(enabled, |this| {
+ this.shadow(vec![BoxShadow {
+ color: hsla(0.105, 0.9, 0.66, 0.25),
+ offset: point(px(0.), px(0.)),
+ blur_radius: px(18.),
+ spread_radius: px(1.),
+ inset: false,
+ }])
+ })
+ .child(Icon::new(icon).size_7().text_color(icon_color))
+}
+
+#[cfg(target_os = "macos")]
+fn camera_automation(current: LightSettings, pal: Palette) -> impl IntoElement {
+ h_flex()
+ .w_full()
+ .justify_between()
+ .items_center()
+ .gap_3()
+ .rounded(pal.control_radius)
+ .border_1()
+ .border_color(if current.auto_camera {
+ hsla(0.105, 0.9, 0.66, 0.4)
+ } else {
+ pal.border
+ })
+ .bg(if current.auto_camera {
+ hsla(0.105, 0.9, 0.66, 0.08)
+ } else {
+ pal.surface_hover
+ })
+ .p_3()
+ .child(
+ v_flex()
+ .gap_1()
+ .flex_1()
+ .min_w_0()
+ .child(
+ div()
+ .text_body()
+ .text_color(pal.text_primary)
+ .child(tr!("Auto-on with camera")),
+ )
+ .child(div().text_caption().text_color(pal.text_muted).child(tr!(
+ "Turn this light on while any camera is in use and off when cameras stop."
+ ))),
+ )
+ .child(
+ h_flex()
+ .id("standalone-light-camera-automation")
+ .min_w(px(72.))
+ .justify_center()
+ .items_center()
+ .gap_2()
+ .px_3()
+ .py_2()
+ .rounded(pal.control_radius)
+ .selected_border(current.auto_camera, pal)
+ .selected_fill(current.auto_camera)
+ .text_caption()
+ .text_color(if current.auto_camera {
+ pal.text_primary
+ } else {
+ pal.text_muted
+ })
+ .cursor_pointer()
+ .hover(|style| style.bg(pal.surface_hover))
+ .child(if current.auto_camera {
+ tr!("On")
+ } else {
+ tr!("Off")
+ })
+ .on_click(|_event, _window, cx| {
+ cx.update_global::<AppState, _>(|state, _| {
+ let mut light = state.light();
+ light.auto_camera = !light.auto_camera;
+ state.commit_light(light);
+ });
+ cx.refresh_windows();
+ }),
+ )
+}
+
+fn light_status(online: bool, enabled: bool, pal: Palette) -> impl IntoElement {
+ let (label, color) = if !online {
+ (tr!("Offline"), theme::STATUS_OFFLINE)
+ } else if enabled {
+ (tr!("On"), theme::STATUS_CONNECTED)
+ } else {
+ (tr!("Off"), theme::STATUS_OFFLINE)
+ };
+ h_flex()
+ .gap_1p5()
+ .items_center()
+ .text_caption()
+ .text_color(pal.text_muted)
+ .child(div().size_1p5().rounded_full().bg(rgb(color)))
+ .child(label)
+}
+
+fn control_well(
+ title: gpui::SharedString,
+ value: String,
+ endpoints: (String, String),
+ slider: impl IntoElement,
+ pal: Palette,
+) -> impl IntoElement {
+ v_flex()
+ .gap_3()
+ .rounded(pal.control_radius)
+ .border_1()
+ .border_color(pal.border)
+ .bg(pal.surface_hover)
+ .p_3()
+ .child(
+ h_flex()
+ .justify_between()
+ .items_baseline()
+ .child(div().text_caption().text_color(pal.text_muted).child(title))
+ .child(
+ div()
+ .text_body()
+ .text_color(rgb(ACCENT_BLUE))
+ .font_weight(gpui::FontWeight::MEDIUM)
+ .child(value),
+ ),
+ )
+ .child(slider)
+ .child(
+ h_flex()
+ .justify_between()
+ .text_caption()
+ .text_color(pal.text_muted)
+ .child(endpoints.0)
+ .child(endpoints.1),
+ )
+}
+
+fn toggle(effective_enabled: bool, pal: Palette) -> impl IntoElement {
+ let on = effective_enabled;
+ let icon = if on { IconName::Sun } else { IconName::Moon };
+ h_flex()
+ .id("standalone-light-toggle")
+ .min_w(px(72.))
+ .justify_center()
+ .items_center()
+ .gap_2()
+ .px_3()
+ .py_2()
+ .rounded(pal.control_radius)
+ .selected_border(on, pal)
+ .selected_fill(on)
+ .text_caption()
+ .text_color(if on { pal.text_primary } else { pal.text_muted })
+ .cursor_pointer()
+ .hover(|style| style.bg(pal.surface_hover))
+ .child(Icon::new(icon).size_3())
+ .child(if on { tr!("On") } else { tr!("Off") })
+ .on_click(move |_event, _window, cx| {
+ cx.update_global::<AppState, _>(|state, _| {
+ state.commit_manual_light_power(!state.light_enabled());
+ });
+ cx.refresh_windows();
+ })
+}
+
+fn format_range_endpoints(range: LightValueRange) -> (String, String) {
+ (
+ format_light_value(range.min(), range.unit()),
+ format_light_value(range.max(), range.unit()),
+ )
+}
+
+fn format_light_value(value: u16, unit: LightValueUnit) -> String {
+ match unit {
+ LightValueUnit::Lumens => format!("{value} lm"),
+ LightValueUnit::Kelvin => format!("{value} K"),
+ LightValueUnit::Percent => format!("{value}%"),
+ }
+}
+
+fn midpoint(range: LightValueRange) -> u16 {
+ range.quantize(range.min() + (range.max() - range.min()) / 2)
+}
+
+#[allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_sign_loss,
+ reason = "the slider value is clamped to the u16 range before conversion"
+)]
+fn round_u16(raw: f32) -> u16 {
+ raw.clamp(0., f32::from(u16::MAX)).round() as u16
+}
+
+fn light_command_status(status: LightCommandStatus, pal: Palette) -> impl IntoElement {
+ let (label, color) = match status {
+ LightCommandStatus::Pending => (tr!("Applying light setting…").to_string(), pal.text_muted),
+ LightCommandStatus::Failed(error) => (
+ format!("{}: {error}", tr!("Unavailable")),
+ Hsla::from(rgb(theme::STATUS_OFFLINE)),
+ ),
+ LightCommandStatus::Offline => (
+ tr!("Offline").to_string(),
+ Hsla::from(rgb(theme::STATUS_OFFLINE)),
+ ),
+ };
+ h_flex()
+ .gap_1p5()
+ .items_center()
+ .text_caption()
+ .text_color(pal.text_muted)
+ .child(div().size_1p5().rounded_full().bg(color))
+ .child(label)
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(
+ clippy::expect_used,
+ reason = "range fixture construction is intentionally asserted in tests"
+ )]
+
+ use super::{format_light_value, midpoint};
+ use openlogi_core::device::{LightValueRange, LightValueUnit};
+
+ #[test]
+ fn sliders_use_the_advertised_range_and_grid() {
+ let range = LightValueRange::new(3000, 5000, 250, LightValueUnit::Kelvin)
+ .expect("valid test range");
+ assert_eq!(range.quantize(3120), 3000);
+ assert_eq!(range.quantize(3370), 3250);
+ assert_eq!(midpoint(range), 4000);
+ }
+
+ #[test]
+ fn range_values_use_capability_units() {
+ assert_eq!(format_light_value(20, LightValueUnit::Lumens), "20 lm");
+ assert_eq!(format_light_value(2700, LightValueUnit::Kelvin), "2700 K");
+ assert_eq!(format_light_value(100, LightValueUnit::Percent), "100%");
+ }
+}
diff --git a/crates/openlogi-gui/src/components/light_visual.rs b/crates/openlogi-gui/src/components/light_visual.rs
new file mode 100644
index 0000000000000000000000000000000000000000..cb48ae37d1b9cc3acfdcd75500d36b64af7cc374
--- /dev/null
+++ b/crates/openlogi-gui/src/components/light_visual.rs
@@ -0,0 +1,159 @@
+//! Shared standalone-light visuals used by the gallery and detail screen.
+//!
+//! Known models can opt into source-owned product artwork. Unknown models keep
+//! the protocol-neutral generated visual and never borrow another model's
+//! image or diffuser geometry.
+
+use gpui::{AnyElement, BoxShadow, IntoElement, ParentElement, Styled, div, hsla, img, point, px};
+use gpui_component::{Icon, IconName};
+use openlogi_core::config::LightSettings;
+
+use crate::asset::ResolvedAsset;
+use crate::theme::Palette;
+
+/// Render a standalone light inside a home-gallery image slot.
+pub(crate) fn gallery(
+ asset: Option<&ResolvedAsset>,
+ online: bool,
+ enabled: bool,
+ settings: LightSettings,
+ pal: Palette,
+) -> AnyElement {
+ if let Some(asset) = asset {
+ visual_container()
+ .child(product_image(asset, 210., 180., online))
+ .into_any_element()
+ } else {
+ generated_visual(210., 180., online, enabled, settings, pal).into_any_element()
+ }
+}
+
+/// Render a standalone light as the large hero in its detail view.
+pub(crate) fn detail(
+ asset: Option<&ResolvedAsset>,
+ online: bool,
+ enabled: bool,
+ settings: LightSettings,
+ pal: Palette,
+) -> gpui::Div {
+ let content = if let Some(asset) = asset {
+ product_image(asset, 536., 460., online)
+ } else {
+ generated_visual(536., 460., online, enabled, settings, pal)
+ };
+ visual_container()
+ .flex_1()
+ .min_w(px(440.))
+ .h(px(520.))
+ .rounded(pal.card_radius)
+ .border_1()
+ .border_color(pal.border)
+ .bg(pal.surface)
+ .overflow_hidden()
+ .child(content)
+}
+
+fn product_image(asset: &ResolvedAsset, width: f32, height: f32, online: bool) -> gpui::Div {
+ let image_opacity = if online { 1. } else { 0.5 };
+ div()
+ .relative()
+ .flex()
+ .items_center()
+ .justify_center()
+ .w(px(width))
+ .h(px(height))
+ .overflow_hidden()
+ .opacity(image_opacity)
+ // `size_full` lets transparent product artwork escape this slot when
+ // its intrinsic aspect ratio differs from the slot. Keep the source
+ // aspect ratio while bounding both dimensions to the slot.
+ .child(img(asset.image_path.clone()).max_w_full().max_h_full())
+}
+
+fn visual_container() -> gpui::Div {
+ div()
+ .relative()
+ .w_full()
+ .flex()
+ .items_center()
+ .justify_center()
+}
+
+fn generated_visual(
+ width: f32,
+ height: f32,
+ online: bool,
+ enabled: bool,
+ settings: LightSettings,
+ pal: Palette,
+) -> gpui::Div {
+ let powered = online && enabled;
+ let glow = light_color(settings.temperature_kelvin.unwrap_or(4600));
+ let brightness = f32::from(settings.brightness_percent.min(100)) / 100.;
+ let halo_size = width.min(height) * 0.56;
+ let face_size = halo_size * 0.58;
+
+ let halo = div()
+ .size(px(halo_size))
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded_full()
+ .bg(if powered {
+ glow.opacity(0.08 + brightness * 0.18)
+ } else {
+ pal.surface_hover
+ });
+ let halo = if powered {
+ halo.shadow(vec![BoxShadow {
+ color: glow.opacity(0.12 + brightness * 0.2),
+ offset: point(px(0.), px(0.)),
+ blur_radius: px(34.),
+ spread_radius: px(3.),
+ inset: false,
+ }])
+ } else {
+ halo
+ };
+
+ div()
+ .w(px(width))
+ .h(px(height))
+ .flex()
+ .items_center()
+ .justify_center()
+ .opacity(if online { 1. } else { 0.5 })
+ .child(
+ halo.child(
+ div()
+ .size(px(face_size))
+ .flex()
+ .items_center()
+ .justify_center()
+ .rounded_full()
+ .border_1()
+ .border_color(if powered {
+ glow.opacity(0.55)
+ } else {
+ pal.border
+ })
+ .bg(if powered {
+ glow.opacity(0.2)
+ } else {
+ pal.surface
+ })
+ .child(Icon::new(IconName::Sun).size_12().text_color(if powered {
+ glow
+ } else {
+ pal.text_muted
+ })),
+ ),
+ )
+}
+
+fn light_color(kelvin: u16) -> gpui::Hsla {
+ let normalized = (f32::from(kelvin.clamp(2700, 6500)) - 2700.) / 3800.;
+ let hue = 0.09 + normalized * 0.05;
+ let saturation = 0.9 - normalized * 0.48;
+ hsla(hue, saturation, 0.68, 1.)
+}
diff --git a/crates/openlogi-gui/src/components/mod.rs b/crates/openlogi-gui/src/components/mod.rs
index 51b5587db858f39222651cbddd8fd0d0bd295e9e..d1a6482015b51cb7eb3c2eef0dbaf9eb112a1254 100644
--- a/crates/openlogi-gui/src/components/mod.rs
+++ b/crates/openlogi-gui/src/components/mod.rs
@@ -4,9 +4,13 @@
//! widget owns its local state; cross-widget coordination happens through
//! [`crate::state::AppState`].
+pub mod camera_controls;
+pub mod camera_preview;
pub mod carousel;
pub mod device_read;
pub mod dpi_panel;
+pub mod light_panel;
+pub mod light_visual;
pub mod lighting_panel;
pub mod smartshift_panel;
pub mod status;
diff --git a/crates/openlogi-gui/src/data/mouse_buttons.rs b/crates/openlogi-gui/src/data/mouse_buttons.rs
index 90b3123d5d4e1a3e43d873ac2d4dbb9dc4df9bcd..816266703b98ebac94c23d3e22732a3f9e6b4674 100644
--- a/crates/openlogi-gui/src/data/mouse_buttons.rs
+++ b/crates/openlogi-gui/src/data/mouse_buttons.rs
@@ -13,7 +13,7 @@
)]
pub use openlogi_core::binding::{
- Action, ButtonId, Category, GestureDirection, default_binding, default_gesture_binding,
+ Action, Binding, ButtonId, Category, GestureDirection, default_binding, default_gesture_binding,
};
/// One visual target in the mouse diagram.
@@ -157,9 +157,11 @@ mod tests {
#[test]
fn fallback_thumbwheel_is_capability_gated() {
- assert!(!default_hotspots(false).iter().any(|hotspot| {
- hotspot.id == MouseControlId::ThumbwheelRotation
- }));
+ assert!(
+ !default_hotspots(false)
+ .iter()
+ .any(|hotspot| { hotspot.id == MouseControlId::ThumbwheelRotation })
+ );
assert_eq!(
default_hotspots(true)
.iter()
@@ -173,9 +175,9 @@ mod tests {
fn default_hotspots_expose_the_gesture_button() {
let hotspots = default_hotspots(false);
assert!(
- hotspots.iter().any(|h| {
- h.id == MouseControlId::Button(ButtonId::GestureButton)
- }),
+ hotspots
+ .iter()
+ .any(|h| { h.id == MouseControlId::Button(ButtonId::GestureButton) }),
"the gesture button must be a mappable hotspot in the synthetic model"
);
}
diff --git a/crates/openlogi-gui/src/diagnostics.rs b/crates/openlogi-gui/src/diagnostics.rs
index 5044859a75869654e971066d19fdd4e594250ecc..2f67e324b4a06f2c7fff6d126909a191465b904e 100644
--- a/crates/openlogi-gui/src/diagnostics.rs
+++ b/crates/openlogi-gui/src/diagnostics.rs
@@ -125,7 +125,9 @@ fn collect_devices(state: &AppState) -> Vec<DeviceDiag> {
battery: record.battery.clone(),
capabilities: record.capabilities,
dpi: dpi_summary(state.dpi_status_for(&record.config_key)),
- config_key: record.config_key.clone(),
+ // Diagnostics are model-level by contract. The runtime config
+ // key may contain a receiver UID or raw-device serial.
+ config_key: record.model_key.clone(),
wpid: paired.and_then(|p| p.wpid),
model_ids: model.map(|m| m.model_ids),
extended_model_id: model.map(|m| m.extended_model_id),
@@ -164,6 +166,7 @@ fn connection_for(
Some(t) if t.usb => ConnectionKind::Wired,
_ => ConnectionKind::Unknown,
},
+ Some(DeviceRoute::RawHid { .. }) => ConnectionKind::Wired,
None => ConnectionKind::Unknown,
}
}
diff --git a/crates/openlogi-gui/src/i18n.rs b/crates/openlogi-gui/src/i18n.rs
index 94944fc2ac0780ed0f7f7ab507268f9849b5139c..1a6e49c4e7ba80e9326fe0a5c19ba4f4b2eb1a7b 100644
--- a/crates/openlogi-gui/src/i18n.rs
+++ b/crates/openlogi-gui/src/i18n.rs
@@ -1,11 +1,12 @@
//! UI localization plumbing.
//!
//! Translations live in `crates/openlogi-gui/locales/*.yml` and are loaded at
-//! compile time by the `rust_i18n::i18n!` macro in `main.rs`. Crowdin manages one
-//! file per locale: `en.yml` is the source file, and every other locale in
-//! [`SUPPORTED`] is downloaded as a translated YAML file. Call sites use the
-//! [`tr!`](crate::tr) helper (or `rust_i18n::t!`) with the **English string as
-//! the key**.
+//! compile time by the `rust_i18n::i18n!` macro in `main.rs` (fallback `"en"`).
+//! **`en.yml` is the only source of truth** for UI strings — edit that file when
+//! adding or changing copy. Crowdin owns non-English catalogs (`SUPPORTED`
+//! minus `en`); the Crowdin workflow downloads them. Call sites use
+//! [`tr!`](crate::tr) / `rust_i18n::t!` with the **English string as the key**.
+//! Missing keys in a non-English file fall back to English at runtime.
//!
//! The current locale is a process-global atomic inside `rust_i18n`. Setting it
//! re-localizes both our own call sites *and* gpui-component's built-in widget
@@ -295,9 +296,20 @@ mod tests {
assert_eq!(rust_i18n::t!(BLURB), BLURB);
}
+ /// Non-English catalogs may lag `en.yml` until Crowdin syncs (runtime falls
+ /// back to English). They must never introduce keys that are not in `en.yml`.
#[test]
- fn locale_files_have_the_same_keys() {
- let source = locale_keys(include_str!("../locales/en.yml"));
+ fn locale_files_keys_are_subset_of_en() {
+ use std::collections::BTreeSet;
+
+ let source: BTreeSet<&str> = locale_keys(include_str!("../locales/en.yml"))
+ .into_iter()
+ .collect();
+ assert!(
+ !source.is_empty(),
+ "en.yml is the string source of truth and must define keys"
+ );
+
for (locale, file) in [
("ja", include_str!("../locales/ja.yml")),
("ru", include_str!("../locales/ru.yml")),
@@ -319,8 +331,12 @@ mod tests {
("pt-PT", include_str!("../locales/pt-PT.yml")),
("sv", include_str!("../locales/sv.yml")),
] {
- let keys = locale_keys(file);
- assert_eq!(keys, source, "{locale}.yml keys drifted from en.yml");
+ let keys: BTreeSet<&str> = locale_keys(file).into_iter().collect();
+ let extras: Vec<&str> = keys.difference(&source).copied().collect();
+ assert!(
+ extras.is_empty(),
+ "{locale}.yml has keys not in en.yml (edit en.yml only; Crowdin owns other locales): {extras:?}"
+ );
}
}
diff --git a/crates/openlogi-gui/src/ipc_client.rs b/crates/openlogi-gui/src/ipc_client.rs
index 73040715f4f650245bc124f9f670c6f4d0853578..80660b90cfaca975999828f0d48c4d1b7d6f75f7 100644
--- a/crates/openlogi-gui/src/ipc_client.rs
+++ b/crates/openlogi-gui/src/ipc_client.rs
@@ -24,9 +24,10 @@ use openlogi_agent_core::ipc::{
PairingFailure, PairingUpdate,
};
use openlogi_core::config::Lighting;
-use openlogi_core::device::DeviceInventory;
+use openlogi_core::device::{DeviceInventory, StandaloneDevice};
use openlogi_hid::{
- DeviceRoute, DpiInfo, ReceiverSelector, SmartShiftMode, SmartShiftStatus, WriteError,
+ DeviceRoute, DpiInfo, LightCommand, ReceiverSelector, SmartShiftMode, SmartShiftStatus,
+ WriteError,
};
use tarpc::client;
use tarpc::context;
@@ -65,20 +66,36 @@ pub enum GuiUpdate {
/// updated on disk while this GUI kept running, and only a relaunch
/// helps. Sent once per episode.
OutdatedGui,
+ /// Result of an agent-owned standalone-light command. The typed failure
+ /// reaches the GPUI state model instead of being reduced to a log line.
+ LightCommandResult {
+ /// Runtime/config key of the light that issued the command.
+ key: String,
+ /// Monotonic request id used to ignore stale results.
+ request_id: u64,
+ /// The control whose write produced this result.
+ command: LightCommand,
+ /// Agent acceptance or typed device failure.
+ result: Result<(), WriteError>,
+ },
}
/// A poll snapshot pushed to the GPUI loop on every successful poll round.
pub struct PollUpdate {
pub inventory: Vec<DeviceInventory>,
+ pub standalone: Vec<StandaloneDevice>,
pub status: AgentStatus,
+ pub camera_active: bool,
}
/// A device command sent from the GPUI thread to the client thread. Reads carry
-/// a `oneshot` for the reply; "apply now" writes are fire-and-forget (the GUI
-/// updates its display optimistically and the client logs any device failure).
+/// a `oneshot` for the reply; standalone-light writes return a result event so
+/// the GUI can surface device failures after an optimistic update.
pub enum Command {
SetDpi(DeviceRoute, u32),
SetLighting(DeviceRoute, Lighting),
+ SetLight(DeviceRoute, LightCommand, String, u64),
+ SetLightManualPower(DeviceRoute, bool, String, u64),
SetSmartShift(DeviceRoute, SmartShiftMode, u8, u8),
ReadDpi(DeviceRoute, oneshot::Sender<Result<DpiInfo, WriteError>>),
ReadSmartShift(
@@ -224,7 +241,7 @@ async fn poll_loop(
}
cmd = cmd_rx.recv() => {
let Some(cmd) = cmd else { break }; // GUI dropped the sender → shut down
- if handle(&mut client, pairing_tx, cmd).await.is_err() {
+ if handle(&mut client, update_tx, pairing_tx, cmd).await.is_err() {
// Same as a poll-detected drop: back to the fast cadence
// so the reconnect (agent self-exec, crash) re-converges
// just as quickly as at startup.
@@ -527,8 +544,15 @@ async fn poll(
let snapshot = client.snapshot(context::current()).await.map_err(|_| ())?;
let status = snapshot.status;
let inventory = snapshot.inventory;
+ let standalone = snapshot.standalone;
+ let camera_active = snapshot.camera_active;
let ready = status.inventory == InventoryHealth::Ready;
- let _ = update_tx.send(GuiUpdate::Snapshot(PollUpdate { inventory, status }));
+ let _ = update_tx.send(GuiUpdate::Snapshot(PollUpdate {
+ inventory,
+ standalone,
+ status,
+ camera_active,
+ }));
Ok(PollOutcome::Delivered { ready })
}
@@ -536,12 +560,13 @@ async fn poll(
/// reconnects; the command's own failure is reported back over its oneshot.
async fn handle(
client: &mut Option<AgentClient>,
+ update_tx: &mpsc::UnboundedSender<GuiUpdate>,
pairing_tx: &mpsc::UnboundedSender<PairingUpdate>,
cmd: Command,
) -> Result<(), ()> {
// keep `client` None on connect failure; that's not a dropped live connection
let Ok(client) = ensure(client).await else {
- reply_disconnected(pairing_tx, cmd);
+ reply_disconnected(update_tx, pairing_tx, cmd);
return Ok(());
};
let ctx = context::current();
@@ -550,6 +575,24 @@ async fn handle(
Command::SetLighting(route, lighting) => {
log_apply(client.set_lighting(ctx, route, lighting).await)?;
}
+ Command::SetLight(route, command, key, request_id) => {
+ send_light_result(
+ update_tx,
+ key,
+ request_id,
+ command,
+ client.set_light(ctx, route, command).await,
+ )?;
+ }
+ Command::SetLightManualPower(route, enabled, key, request_id) => {
+ send_light_result(
+ update_tx,
+ key,
+ request_id,
+ LightCommand::Power(enabled),
+ client.set_light_manual_power(ctx, route, enabled).await,
+ )?;
+ }
Command::SetSmartShift(route, mode, auto, torque) => {
log_apply(client.set_smartshift(ctx, route, mode, auto, torque).await)?;
}
@@ -607,6 +650,32 @@ fn log_apply(r: Result<Result<(), WriteError>, tarpc::client::RpcError>) -> Resu
}
}
+fn send_light_result(
+ update_tx: &mpsc::UnboundedSender<GuiUpdate>,
+ key: String,
+ request_id: u64,
+ command: LightCommand,
+ result: Result<Result<(), WriteError>, tarpc::client::RpcError>,
+) -> Result<(), ()> {
+ if let Ok(result) = result {
+ let _ = update_tx.send(GuiUpdate::LightCommandResult {
+ key,
+ request_id,
+ command,
+ result,
+ });
+ Ok(())
+ } else {
+ let _ = update_tx.send(GuiUpdate::LightCommandResult {
+ key,
+ request_id,
+ command,
+ result: Err(WriteError::AgentUnavailable),
+ });
+ Err(())
+ }
+}
+
/// Unwrap a tarpc transport result: `Err(())` (connection dropped) propagates so
/// the caller reconnects; the inner application `Result` is returned for the reply.
fn rpc_result<T>(r: Result<T, tarpc::client::RpcError>) -> Result<T, ()> {
@@ -619,7 +688,11 @@ fn rpc_result<T>(r: Result<T, tarpc::client::RpcError>) -> Result<T, ()> {
clippy::match_same_arms,
reason = "the two read arms send the same disconnect error to differently-typed reply channels, so they can't be merged"
)]
-fn reply_disconnected(pairing_tx: &mpsc::UnboundedSender<PairingUpdate>, cmd: Command) {
+fn reply_disconnected(
+ update_tx: &mpsc::UnboundedSender<GuiUpdate>,
+ pairing_tx: &mpsc::UnboundedSender<PairingUpdate>,
+ cmd: Command,
+) {
// Transient, not a permanent feature error: the agent is just restarting,
// so the panel should keep retrying, not latch "unsupported".
match cmd {
@@ -629,6 +702,22 @@ fn reply_disconnected(pairing_tx: &mpsc::UnboundedSender<PairingUpdate>, cmd: Co
Command::ReadSmartShift(_, reply) => {
let _ = reply.send(Err(WriteError::AgentUnavailable));
}
+ Command::SetLight(_, command, key, request_id) => {
+ let _ = update_tx.send(GuiUpdate::LightCommandResult {
+ key,
+ request_id,
+ command,
+ result: Err(WriteError::AgentUnavailable),
+ });
+ }
+ Command::SetLightManualPower(_, enabled, key, request_id) => {
+ let _ = update_tx.send(GuiUpdate::LightCommandResult {
+ key,
+ request_id,
+ command: LightCommand::Power(enabled),
+ result: Err(WriteError::AgentUnavailable),
+ });
+ }
Command::StartPairing(_) | Command::PairDevice(_) => {
let _ = pairing_tx.send(PairingUpdate::Failed(PairingFailure::AgentRestarted));
}
diff --git a/crates/openlogi-gui/src/keyboard_model/editors.rs b/crates/openlogi-gui/src/keyboard_model/editors.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2c5142a1c6ee99685b64d90627fcd57165a7fbab
--- /dev/null
+++ b/crates/openlogi-gui/src/keyboard_model/editors.rs
@@ -0,0 +1,377 @@
+//! Inline editors for the parameterised power-user actions, shown inside the
+//! config panel (the side inspector) once one is selected from the list.
+//!
+//! Each editor reuses the shared [`menu_card`] surface. Draft state lives on
+//! the [`FunctionRowView`] so it survives re-rendering. Closing the editor
+//! returns to the action list; the panel itself closes when the key is
+//! deselected.
+//!
+//! [`menu_card`]: crate::mouse_model::picker::menu_card
+
+#![allow(
+ clippy::needless_pass_by_value,
+ clippy::redundant_closure,
+ clippy::redundant_closure_for_method_calls,
+ reason = "GPUI builders take owned Copy palette values; entity.update wants closures"
+)]
+
+use std::rc::Rc;
+
+use gpui::{
+ AnyElement, BorrowAppContext as _, Context, Entity, FontWeight, IntoElement, ParentElement,
+ StatefulInteractiveElement as _, Styled, div, px, svg,
+};
+use gpui_component::{
+ Icon, IconName, Sizable as _,
+ button::{Button, ButtonVariants},
+ h_flex,
+ input::Input,
+ input::InputState,
+ v_flex,
+};
+use openlogi_core::binding::{Action, KeyCombo, WorkflowStep};
+use openlogi_core::config::KeyTrigger;
+
+use crate::keyboard_model::function_row::FunctionRowView;
+use crate::mouse_model::picker::{PickFn, divider, menu_card, menu_row, scroll_list, title};
+use crate::state::AppState;
+use crate::theme::Palette;
+
+/// Which power-user editor is showing for the selected key.
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum PowerUserKind {
+ TypeText,
+ RunAppleScript,
+ RunShellCommand,
+ Workflow,
+}
+
+impl PowerUserKind {
+ fn heading(self) -> &'static str {
+ match self {
+ Self::TypeText => "Type Text",
+ Self::RunAppleScript => "Run AppleScript",
+ Self::RunShellCommand => "Run Shell Command",
+ Self::Workflow => "Workflow",
+ }
+ }
+}
+
+pub(crate) fn text_editor_placeholder(kind: PowerUserKind) -> &'static str {
+ match kind {
+ PowerUserKind::TypeText => "Text to type…",
+ PowerUserKind::RunAppleScript => "display dialog \"Hello\"",
+ PowerUserKind::RunShellCommand => "echo hello",
+ PowerUserKind::Workflow => "",
+ }
+}
+
+pub(crate) fn text_editor_seed(action: Option<&Action>, kind: PowerUserKind) -> String {
+ match (action, kind) {
+ (Some(Action::TypeText(text)), PowerUserKind::TypeText)
+ | (Some(Action::RunAppleScript(text)), PowerUserKind::RunAppleScript)
+ | (Some(Action::RunShellCommand(text)), PowerUserKind::RunShellCommand) => text.clone(),
+ _ => String::new(),
+ }
+}
+
+pub(crate) fn workflow_editor_seed(action: Option<&Action>) -> Vec<WorkflowStep> {
+ match action {
+ Some(Action::Workflow(steps)) => steps.clone(),
+ _ => Vec::new(),
+ }
+}
+
+/// Render the editor card for `kind`, replacing the panel's action list.
+pub fn editor_card(
+ trigger: KeyTrigger,
+ kind: PowerUserKind,
+ text_state: Option<Entity<InputState>>,
+ workflow_draft: Vec<WorkflowStep>,
+ view: &Entity<FunctionRowView>,
+ pal: Palette,
+ cx: &mut Context<FunctionRowView>,
+) -> AnyElement {
+ match kind {
+ PowerUserKind::Workflow => workflow_editor_card(trigger, workflow_draft, view, pal, cx),
+ _ => match text_state {
+ Some(state) => text_editor_card(trigger, kind, state, view, pal, cx),
+ None => menu_card(pal)
+ .w(px(300.))
+ .child(title(tr!("Editor unavailable"), pal))
+ .into_any_element(),
+ },
+ }
+}
+
+/// The TypeText / RunAppleScript / RunShellCommand editors share a single text
+/// field; only the commit wrapping differs.
+fn text_editor_card(
+ trigger: KeyTrigger,
+ kind: PowerUserKind,
+ text_state: Entity<InputState>,
+ view: &Entity<FunctionRowView>,
+ pal: Palette,
+ cx: &mut Context<FunctionRowView>,
+) -> AnyElement {
+ let heading = kind.heading();
+ let key_name = trigger.to_string();
+
+ menu_card(pal)
+ .w(px(300.))
+ .child(title(
+ tr!("%{action} · %{key}", action => heading, key => key_name),
+ pal,
+ ))
+ .child(divider(pal))
+ .child(
+ v_flex()
+ .p_2()
+ .gap_2()
+ .child(div().child(Input::new(&text_state).cleanable(true)))
+ .child(editor_action_row(trigger, kind, view, pal, cx)),
+ )
+ .into_any_element()
+}
+
+/// Cancel (back to list) + Save (commit the drafted text).
+fn editor_action_row(
+ trigger: KeyTrigger,
+ kind: PowerUserKind,
+ view: &Entity<FunctionRowView>,
+ _pal: Palette,
+ _cx: &mut Context<FunctionRowView>,
+) -> AnyElement {
+ let view_save = view.clone();
+ let trigger_save = trigger.clone();
+ let view_cancel = view.clone();
+
+ h_flex()
+ .gap_2()
+ .justify_end()
+ .child(
+ Button::new("editor-cancel")
+ .ghost()
+ .label(tr!("Cancel"))
+ .on_click(move |_e, _window, cx| {
+ view_cancel.update(cx, |v, vcx| v.close_editor(vcx));
+ }),
+ )
+ .child(
+ Button::new("editor-save")
+ .primary()
+ .label(tr!("Save"))
+ .on_click(move |_e, _window, cx| {
+ let text = view_save
+ .read(cx)
+ .text_state()
+ .map(|s| s.read(cx).value().to_string())
+ .unwrap_or_default();
+ let action = match kind {
+ PowerUserKind::TypeText => Action::TypeText(text),
+ PowerUserKind::RunAppleScript => Action::RunAppleScript(text),
+ PowerUserKind::RunShellCommand => Action::RunShellCommand(text),
+ PowerUserKind::Workflow => return,
+ };
+ cx.update_global::<AppState, _>(|state, _| {
+ state.commit_keyboard_binding(trigger_save.clone(), Some(action));
+ });
+ view_save.update(cx, |v, vcx| v.close_editor(vcx));
+ }),
+ )
+ .into_any_element()
+}
+
+/// The Workflow editor: a list of steps with add/remove.
+fn workflow_editor_card(
+ trigger: KeyTrigger,
+ steps: Vec<WorkflowStep>,
+ view: &Entity<FunctionRowView>,
+ pal: Palette,
+ cx: &mut Context<FunctionRowView>,
+) -> AnyElement {
+ let key_name = trigger.to_string();
+
+ let mut rows: Vec<AnyElement> = Vec::new();
+ for (idx, step) in steps.iter().enumerate() {
+ rows.push(workflow_step_row(idx, step.clone(), view, pal, cx));
+ }
+
+ menu_card(pal)
+ .w(px(320.))
+ .child(title(tr!("Workflow · %{key}", key => key_name), pal))
+ .child(divider(pal))
+ .child(scroll_list("workflow-steps", rows))
+ .child(
+ h_flex()
+ .p_2()
+ .gap_2()
+ .justify_between()
+ .child(
+ Button::new("wf-add-step")
+ .ghost()
+ .small()
+ .label(tr!("+ Add Step"))
+ .on_click({
+ let v = view.clone();
+ move |_e, _w, cx| {
+ v.update(cx, |v, vcx| {
+ v.push_workflow_step(
+ WorkflowStep::TypeText(String::new()),
+ vcx,
+ );
+ });
+ }
+ }),
+ )
+ .child(
+ Button::new("wf-save")
+ .primary()
+ .label(tr!("Save Workflow"))
+ .on_click({
+ let v = view.clone();
+ let trigger = trigger.clone();
+ move |_e, _window, cx| {
+ let steps = v.read(cx).workflow_draft().to_vec();
+ let action = Action::Workflow(steps);
+ cx.update_global::<AppState, _>(|state, _| {
+ state.commit_keyboard_binding(trigger.clone(), Some(action));
+ });
+ v.update(cx, |v, vcx| v.close_editor(vcx));
+ }
+ }),
+ ),
+ )
+ .into_any_element()
+}
+
+/// One Workflow step row: type chip + payload preview + remove button.
+fn workflow_step_row(
+ idx: usize,
+ step: WorkflowStep,
+ view: &Entity<FunctionRowView>,
+ pal: Palette,
+ _cx: &mut Context<FunctionRowView>,
+) -> AnyElement {
+ let (type_label, glyph): (&'static str, &'static str) = match &step {
+ WorkflowStep::TypeText(_) => ("Type Text", "action-icons/keyboard.svg"),
+ WorkflowStep::PressKey(_) => ("Press Key", "action-icons/keyboard.svg"),
+ WorkflowStep::Delay { .. } => ("Delay", "action-icons/chevrons-right.svg"),
+ WorkflowStep::RunAppleScript(_) => ("AppleScript", "action-icons/terminal.svg"),
+ WorkflowStep::RunShellCommand(_) => ("Shell", "action-icons/terminal.svg"),
+ };
+ let view_remove = view.clone();
+
+ menu_row(("wf-step", idx), pal, false)
+ .child(
+ h_flex()
+ .w_full()
+ .items_center()
+ .gap_2()
+ .child(
+ svg()
+ .path(glyph)
+ .size_4()
+ .flex_none()
+ .text_color(pal.text_muted),
+ )
+ .child(
+ div()
+ .text_xs()
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(pal.text_muted)
+ .child(type_label),
+ )
+ .child(div().flex_1().child(step_preview(&step, pal))),
+ )
+ .child(
+ Icon::new(IconName::Close)
+ .size_3()
+ .text_color(pal.text_muted),
+ )
+ .on_click(move |_e, _w, cx| {
+ view_remove.update(cx, |v, vcx| v.remove_workflow_step(idx, vcx));
+ })
+ .into_any_element()
+}
+
+fn step_preview(step: &WorkflowStep, pal: Palette) -> AnyElement {
+ let text: String = match step {
+ WorkflowStep::TypeText(s) => {
+ if s.is_empty() {
+ "…".to_string()
+ } else {
+ format!("“{s}”")
+ }
+ }
+ WorkflowStep::PressKey(k) => key_combo_preview(k),
+ WorkflowStep::Delay { millis } => format!("{millis} ms"),
+ WorkflowStep::RunAppleScript(s) | WorkflowStep::RunShellCommand(s) => {
+ if s.is_empty() {
+ "…".to_string()
+ } else {
+ s.clone()
+ }
+ }
+ };
+ div()
+ .text_xs()
+ .text_color(pal.text_primary)
+ .child(text)
+ .into_any_element()
+}
+
+fn key_combo_preview(combo: &KeyCombo) -> String {
+ if !combo.display.is_empty() {
+ combo.display.clone()
+ } else if combo.key_code == 0 {
+ "—".to_string()
+ } else {
+ format!("key 0x{:02X}", combo.key_code)
+ }
+}
+
+#[allow(dead_code, reason = "kept for parity with the mouse picker")]
+fn _silence_pickfn() -> PickFn {
+ Rc::new(|_a: Action, _w: &mut gpui::Window, _cx: &mut gpui::App| {})
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn text_editor_seed_only_uses_matching_power_user_action() {
+ assert_eq!(
+ text_editor_seed(
+ Some(&Action::RunAppleScript(
+ "tell app \"Finder\" to activate".into()
+ )),
+ PowerUserKind::RunAppleScript,
+ ),
+ "tell app \"Finder\" to activate"
+ );
+ assert_eq!(
+ text_editor_seed(
+ Some(&Action::RunShellCommand("echo nope".into())),
+ PowerUserKind::RunAppleScript,
+ ),
+ ""
+ );
+ }
+
+ #[test]
+ fn workflow_editor_seed_only_uses_workflow_action() {
+ let steps = vec![WorkflowStep::TypeText("hello".into())];
+ assert_eq!(
+ workflow_editor_seed(Some(&Action::Workflow(steps.clone()))),
+ steps
+ );
+ assert!(
+ workflow_editor_seed(Some(&Action::RunAppleScript(
+ "display dialog \"Hello\"".into()
+ )))
+ .is_empty()
+ );
+ }
+}
diff --git a/crates/openlogi-gui/src/keyboard_model/function_row.rs b/crates/openlogi-gui/src/keyboard_model/function_row.rs
new file mode 100644
index 0000000000000000000000000000000000000000..07c95336d742ce3707c31315393323a6b10f7c5b
--- /dev/null
+++ b/crates/openlogi-gui/src/keyboard_model/function_row.rs
@@ -0,0 +1,1423 @@
+//! The keyboard function-row remapper view — the Keys tab body.
+//!
+//! A two-pane inspector model (the "pro-tool" layout): the keyboard photo sits
+//! beside a row of mouse-style callout bubbles, and clicking a function key
+//! **selects** it (no popover). A tall, scrollable config panel slides in on the
+//! right while the keyboard physically makes room. Only one key is selected at a
+//! time.
+//!
+//! F-key bindings are global (`AppState`'s keyboard map), committed via
+//! [`AppState::commit_keyboard_binding`]. The panel lists the same action
+//! catalog the mouse picker uses, plus a Power User section.
+
+#![allow(
+ clippy::cast_precision_loss,
+ clippy::float_cmp,
+ clippy::needless_pass_by_value,
+ clippy::too_many_arguments,
+ reason = "GPUI builders take owned Copy palette/slots; layout math uses small f32 counts"
+)]
+
+use std::rc::Rc;
+use std::sync::Arc;
+
+use gpui::{
+ AnyElement, AppContext as _, BorrowAppContext as _, Bounds, Context, Entity, FontWeight, Hsla,
+ InteractiveElement, IntoElement, ParentElement, PathBuilder, Render,
+ StatefulInteractiveElement as _, Styled, Subscription, Window, canvas, div, hsla, point,
+ prelude::FluentBuilder as _, px, rgb, svg,
+};
+use gpui_component::{h_flex, input::InputState, v_flex};
+use openlogi_core::binding::WorkflowStep;
+use openlogi_core::config::{KeyModifiers, KeyTrigger};
+
+use crate::app::{glow_canvas, keyboard_glow};
+use crate::asset::{GlowGeometry, ResolvedAsset};
+use crate::data::mouse_buttons::Action;
+use crate::keyboard_model::editors::{
+ PowerUserKind, text_editor_placeholder, text_editor_seed, workflow_editor_seed,
+};
+use crate::mouse_model::geometry::asset_dimensions_for_png;
+use crate::mouse_model::picker::{
+ PickFn, action_icon_path, action_rows, divider, menu_card, menu_row, scroll_list,
+ section_header,
+};
+use crate::state::AppState;
+use crate::theme::{self, ACCENT_BLUE, Palette};
+use gpui::ease_in_out;
+use gpui::{Animation, AnimationExt, img};
+
+/// The full programmable top row: Esc, then F1-F19. Each entry is the display
+/// label (on the key) + the [`KeyTrigger`] keycode it binds. MX Keys-class
+/// boards expose all 20; boards with a shorter F-row (a G513 has F1-F12)
+/// surface a prefix of this list, sized by the asset's key markers — see
+/// [`key_points`].
+const FUNCTION_KEYS: [(&str, u16); 20] = [
+ ("Esc", 0x35),
+ ("F1", 0x7A),
+ ("F2", 0x78),
+ ("F3", 0x63),
+ ("F4", 0x76),
+ ("F5", 0x60),
+ ("F6", 0x61),
+ ("F7", 0x62),
+ ("F8", 0x64),
+ ("F9", 0x65),
+ ("F10", 0x6D),
+ ("F11", 0x67),
+ ("F12", 0x6F),
+ ("F13", 0x69),
+ ("F14", 0x6B),
+ ("F15", 0x71),
+ ("F16", 0x6A),
+ ("F17", 0x40),
+ ("F18", 0x4F),
+ ("F19", 0x50),
+];
+
+/// Width of the config panel (CSS px) when a key is selected.
+const PANEL_W: f32 = 320.;
+/// Duration of the keyboard slide + panel slide animation.
+const SLIDE_MS: u64 = 180;
+/// Maximum keyboard render width in the Keys inspector.
+const KEYBOARD_W: f32 = 700.;
+/// Render size when no asset resolved: the placeholder box.
+const FALLBACK_KEYBOARD_SIZE: (f32, f32) = (KEYBOARD_W, 220.);
+/// Space above the keyboard reserved for function-key callouts.
+const CALLOUT_BAND_H: f32 = 118.;
+/// Vertical chrome around the keyboard pane (header, tab strip, screen
+/// padding, footer) — the viewport height minus this and the callout band is
+/// what the render may occupy before it scales down to fit.
+const KEYS_VERTICAL_RESERVE: f32 = 224.;
+/// Floor on the render height so a tiny window still shows a usable model.
+const KEYBOARD_MIN_IMG_H: f32 = 160.;
+const KEY_CALLOUT_W: f32 = 60.;
+const KEY_CALLOUT_H: f32 = 48.;
+const KEY_CALLOUT_TOP_UPPER: f32 = 4.;
+const KEY_CALLOUT_TOP_LOWER: f32 = 50.;
+const KEY_TARGET_W: f32 = 30.;
+const KEY_TARGET_H: f32 = 30.;
+const KEY_HOTSPOT_DOT: f32 = 12.;
+const FALLBACK_KEY_Y_FRAC: f32 = 0.153;
+/// Legacy pixel-marker depots (G513 family) mark F1-F12 but not Esc. Esc sits
+/// this many key pitches left of F1 on that chassis (measured on the render).
+const ESC_LEFT_OF_F1_PITCHES: f32 = 1.55;
+/// Logitech key markers are authored against a tighter internal keyboard
+/// image. The rendered `front.png` includes a little more top/left padding, so
+/// the raw marker lands high-left of the visible keycap center.
+const FRONT_MARKER_X_OFFSET_FRAC: f32 = 0.02;
+const FRONT_MARKER_Y_OFFSET_FRAC: f32 = 0.023;
+/// Even-spacing fallback band (fractions of image width) when no metadata.
+const EVEN_SPACING_START: f32 = 0.04;
+const EVEN_SPACING_END: f32 = 0.96;
+
+/// The function-row remapper view.
+pub struct FunctionRowView {
+ /// The single selected key index (0 = Esc), or `None` when nothing is
+ /// selected (no panel shown).
+ selected_key: Option<usize>,
+ /// The hovered function-row key index, shared by callout bubbles, key hit
+ /// zones, and leader lines.
+ hovered_key: Option<usize>,
+ /// Which power-user editor is showing in the panel, if any.
+ active_editor: Option<PowerUserKind>,
+ /// Lazily-created [`InputState`] for the text editors.
+ text_state: Option<Entity<InputState>>,
+ /// Draft copy of the Workflow steps under edit.
+ workflow_draft: Vec<WorkflowStep>,
+ _state_obs: Subscription,
+}
+
+impl FunctionRowView {
+ /// Create the view.
+ pub fn new(cx: &mut Context<Self>) -> Self {
+ let state_obs = cx.observe_global::<AppState>(|_view, cx| cx.notify());
+ Self {
+ selected_key: None,
+ hovered_key: None,
+ active_editor: None,
+ text_state: None,
+ workflow_draft: Vec::new(),
+ _state_obs: state_obs,
+ }
+ }
+
+ /// Select a key (or deselect with `None`), opening/closing the panel.
+ pub(crate) fn select_key(&mut self, idx: Option<usize>, cx: &mut Context<Self>) {
+ // Changing selection also drops any open editor + its drafts.
+ if self.selected_key != idx {
+ self.active_editor = None;
+ self.text_state = None;
+ self.workflow_draft.clear();
+ }
+ self.selected_key = idx;
+ cx.notify();
+ }
+
+ /// Toggle a key selection from a click on either its callout or key hit
+ /// target.
+ pub(crate) fn click_key(&mut self, idx: usize, cx: &mut Context<Self>) {
+ self.select_key(next_selection_after_click(self.selected_key, idx), cx);
+ }
+
+ #[allow(dead_code, reason = "public accessor for the selection state")]
+ pub(crate) fn selected_key(&self) -> Option<usize> {
+ self.selected_key
+ }
+
+ pub(crate) fn set_hovered_key(&mut self, idx: Option<usize>, cx: &mut Context<Self>) {
+ if self.hovered_key != idx {
+ self.hovered_key = idx;
+ cx.notify();
+ }
+ }
+
+ pub(crate) fn open_editor(&mut self, kind: PowerUserKind, cx: &mut Context<Self>) {
+ self.active_editor = Some(kind);
+ self.text_state = None;
+ self.workflow_draft.clear();
+ cx.notify();
+ }
+
+ pub(crate) fn close_editor(&mut self, cx: &mut Context<Self>) {
+ self.active_editor = None;
+ self.text_state = None;
+ self.workflow_draft.clear();
+ cx.notify();
+ }
+
+ pub(crate) fn text_state(&self) -> Option<Entity<InputState>> {
+ self.text_state.clone()
+ }
+
+ pub(crate) fn new_text_state(
+ &mut self,
+ seed: String,
+ placeholder: &str,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Entity<InputState> {
+ let state = cx.new(|cx| {
+ let mut s = InputState::new(window, cx).placeholder(tr!(placeholder));
+ if !seed.is_empty() {
+ s.set_value(seed, window, cx);
+ }
+ s
+ });
+ self.text_state = Some(state.clone());
+ state
+ }
+
+ pub(crate) fn workflow_draft(&self) -> &[WorkflowStep] {
+ &self.workflow_draft
+ }
+
+ pub(crate) fn push_workflow_step(&mut self, step: WorkflowStep, cx: &mut Context<Self>) {
+ self.workflow_draft.push(step);
+ cx.notify();
+ }
+
+ pub(crate) fn remove_workflow_step(&mut self, idx: usize, cx: &mut Context<Self>) {
+ if idx < self.workflow_draft.len() {
+ self.workflow_draft.remove(idx);
+ cx.notify();
+ }
+ }
+}
+
+/// The app-state slice the view renders from: the device's asset, the global
+/// keyboard bindings, and the lighting glow (geometry + tinted colour).
+type StateSnapshot = (
+ Option<ResolvedAsset>,
+ Vec<(KeyTrigger, Action)>,
+ Option<(Arc<GlowGeometry>, Hsla)>,
+);
+
+impl Render for FunctionRowView {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let pal = theme::palette(cx);
+ let (asset, bindings, glow): StateSnapshot = cx
+ .try_global::<AppState>()
+ .map(|s| {
+ (
+ s.current_record().and_then(|r| r.asset.clone()),
+ s.keyboard_bindings
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ s.current_record().and_then(|r| keyboard_glow(s, r)),
+ )
+ })
+ .unwrap_or_default();
+
+ let viewport_h = f32::from(window.viewport_size().height);
+ let render_size = keyboard_render_size(asset.as_ref(), viewport_h);
+ let points = key_points(asset.as_ref());
+ let slots: Vec<KeySlot> = FUNCTION_KEYS
+ .iter()
+ .zip(points.iter())
+ .enumerate()
+ .map(|(idx, ((label, keycode), point))| {
+ let trigger = KeyTrigger {
+ keycode: *keycode,
+ modifiers: KeyModifiers::default(),
+ };
+ let bound = bindings
+ .iter()
+ .find(|(k, _)| *k == trigger)
+ .map(|(_, a)| a.clone());
+ KeySlot {
+ idx,
+ label,
+ trigger,
+ x_frac: point.x_frac,
+ y_frac: point.y_frac,
+ bound,
+ }
+ })
+ .collect();
+
+ // A stale selection can outlive a device switch to a shorter F-row;
+ // drop it instead of indexing past the new slot list.
+ if self.selected_key.is_some_and(|idx| idx >= slots.len()) {
+ self.selected_key = None;
+ self.active_editor = None;
+ self.text_state = None;
+ self.workflow_draft.clear();
+ }
+ let selected = self.selected_key;
+ let hovered = self.hovered_key;
+ let active_editor = self.active_editor;
+ if let (Some(selected_idx), Some(kind)) = (selected, active_editor)
+ && let Some(slot) = slots.get(selected_idx)
+ {
+ let current_action = bindings
+ .iter()
+ .find(|(trigger, _)| trigger == &slot.trigger)
+ .map(|(_, action)| action);
+ match kind {
+ PowerUserKind::Workflow => {
+ if self.workflow_draft.is_empty() {
+ self.workflow_draft = workflow_editor_seed(current_action);
+ }
+ }
+ _ => {
+ if self.text_state.is_none() {
+ self.new_text_state(
+ text_editor_seed(current_action, kind),
+ text_editor_placeholder(kind),
+ window,
+ cx,
+ );
+ }
+ }
+ }
+ }
+ let text_state = self.text_state.clone();
+ let workflow_draft = self.workflow_draft.clone();
+ let view = cx.entity();
+
+ // The whole row animates as one: when a key is selected the right-side
+ // panel grows in and the keyboard nudges left to make room.
+ v_flex().w_full().items_center().child(inspector_row(
+ slots,
+ asset,
+ glow,
+ render_size,
+ selected,
+ hovered,
+ active_editor,
+ text_state,
+ workflow_draft,
+ &view,
+ &pal,
+ window,
+ cx,
+ ))
+ }
+}
+
+/// The keyboard render size: the actual PNG aspect at up to [`KEYBOARD_W`]
+/// wide, shrunk to fit the viewport height. Sizing off the real aspect keeps
+/// `ObjectFit::Contain` from letterboxing and keeps the marker overlays
+/// registered with the rendered keys — the G513 render (with wrist rest) is
+/// nearly twice as tall as an MX Keys render at the same width.
+fn keyboard_render_size(asset: Option<&ResolvedAsset>, viewport_h: f32) -> (f32, f32) {
+ let Some(asset) = asset.filter(|a| a.png_height > 0) else {
+ return FALLBACK_KEYBOARD_SIZE;
+ };
+ let target_h = (viewport_h - KEYS_VERTICAL_RESERVE - CALLOUT_BAND_H).max(KEYBOARD_MIN_IMG_H);
+ asset_dimensions_for_png(asset, target_h, KEYBOARD_W)
+}
+
+/// One function-row key with its resolved layout + binding.
+#[derive(Clone)]
+struct KeySlot {
+ idx: usize,
+ label: &'static str,
+ trigger: KeyTrigger,
+ x_frac: f32,
+ y_frac: f32,
+ bound: Option<Action>,
+}
+
+/// The two-pane row: keyboard photo + (when a key is selected) the side panel.
+fn inspector_row(
+ slots: Vec<KeySlot>,
+ asset: Option<ResolvedAsset>,
+ glow: Option<(Arc<GlowGeometry>, Hsla)>,
+ render_size: (f32, f32),
+ selected: Option<usize>,
+ hovered: Option<usize>,
+ active_editor: Option<PowerUserKind>,
+ text_state: Option<Entity<InputState>>,
+ workflow_draft: Vec<WorkflowStep>,
+ view: &Entity<FunctionRowView>,
+ pal: &Palette,
+ window: &mut Window,
+ cx: &mut Context<FunctionRowView>,
+) -> impl IntoElement {
+ let keyboard = keyboard_pane(
+ slots.clone(),
+ asset.as_ref(),
+ glow,
+ render_size,
+ selected,
+ hovered,
+ view,
+ pal,
+ );
+
+ // When nothing is selected, just the keyboard, full width.
+ let Some(selected) = selected else {
+ return h_flex()
+ .w_full()
+ .justify_center()
+ .child(keyboard)
+ .into_any_element();
+ };
+
+ let panel = config_panel(
+ selected,
+ &slots,
+ active_editor,
+ text_state,
+ workflow_draft,
+ view,
+ pal,
+ window,
+ cx,
+ );
+
+ // The panel grows in from width 0 → PANEL_W over SLIDE_MS, easing in/out,
+ // always on the right as a stable inspector.
+ let animated_panel = div().overflow_hidden().child(panel).with_animation(
+ "panel-slide",
+ Animation::new(std::time::Duration::from_millis(SLIDE_MS)).with_easing(ease_in_out),
+ |el, delta| el.w(px(PANEL_W * delta)),
+ );
+
+ h_flex()
+ .w_full()
+ .gap_5()
+ .items_center()
+ .justify_center()
+ .child(keyboard)
+ .child(animated_panel)
+ .into_any_element()
+}
+
+/// The keyboard photo with callout bubbles above each function key, leader
+/// lines, and invisible click-targets over the real keys.
+fn keyboard_pane(
+ slots: Vec<KeySlot>,
+ asset: Option<&ResolvedAsset>,
+ glow: Option<(Arc<GlowGeometry>, Hsla)>,
+ (img_w, img_h): (f32, f32),
+ selected: Option<usize>,
+ hovered: Option<usize>,
+ view: &Entity<FunctionRowView>,
+ pal: &Palette,
+) -> impl IntoElement {
+ let img_path = asset.map(|a| a.image_path.clone());
+ let view_clone = view.clone();
+
+ div()
+ .relative()
+ .w(px(img_w))
+ .h(px(CALLOUT_BAND_H + img_h))
+ .child(
+ div()
+ .absolute()
+ .top(px(CALLOUT_BAND_H))
+ .left(px(0.))
+ .w(px(img_w))
+ .h(px(img_h))
+ // The keyboard's RGB paints *behind* the render, so the opaque
+ // keys occlude it and the colour only reads through the
+ // inter-key gaps — same treatment as the home gallery and the
+ // mouse model.
+ .when_some(glow, |this, (geom, color)| {
+ this.child(glow_canvas(geom, color))
+ })
+ .child(image_or_fallback(img_path, img_w, img_h, pal)),
+ )
+ .child(keyboard_leader_canvas(
+ slots.clone(),
+ selected,
+ hovered,
+ (img_w, img_h),
+ ))
+ .children({
+ let count = slots.len();
+ let view_for_callouts = view_clone.clone();
+ slots.iter().cloned().map(move |s| {
+ let highlighted = key_is_highlighted(s.idx, selected, hovered);
+ key_callout(s, count, highlighted, img_w, &view_for_callouts, pal)
+ })
+ })
+ // Click-targets overlay, centered on each key's marker point.
+ .child(
+ div()
+ .absolute()
+ .top(px(CALLOUT_BAND_H))
+ .left(px(0.))
+ .w(px(img_w))
+ .h(px(img_h))
+ .children(slots.into_iter().map(|s| {
+ let highlighted = key_is_highlighted(s.idx, selected, hovered);
+ key_click_target(s, highlighted, (img_w, img_h), &view_clone, pal)
+ })),
+ )
+}
+
+/// One callout bubble in the band above the keyboard.
+fn key_callout(
+ slot: KeySlot,
+ count: usize,
+ highlighted: bool,
+ img_w: f32,
+ view: &Entity<FunctionRowView>,
+ pal: &Palette,
+) -> AnyElement {
+ let idx = slot.idx;
+ let left = callout_left_px(idx, count, img_w, KEY_CALLOUT_W);
+ let top = callout_top_px(idx);
+ let view_hover = view.clone();
+ let view_click = view.clone();
+ let binding = binding_label(slot.bound.as_ref());
+ let binding_icon = slot.bound.as_ref().map(action_icon_path);
+
+ v_flex()
+ .id(("key-callout", idx))
+ .absolute()
+ .top(px(top))
+ .left(px(left))
+ .w(px(KEY_CALLOUT_W))
+ .h(px(KEY_CALLOUT_H))
+ .px_1()
+ .justify_center()
+ .items_center()
+ .gap(px(1.))
+ .rounded_md()
+ .border_1()
+ .border_color(if highlighted {
+ rgb(ACCENT_BLUE).into()
+ } else {
+ pal.border
+ })
+ .bg(if highlighted {
+ theme::accent_tint()
+ } else {
+ pal.surface_hover
+ })
+ .cursor_pointer()
+ .hover(move |s| {
+ s.bg(if highlighted {
+ theme::accent_tint_hover()
+ } else {
+ pal.surface
+ })
+ })
+ .child(
+ div()
+ .text_xs()
+ .font_weight(FontWeight::SEMIBOLD)
+ .text_color(if highlighted {
+ rgb(ACCENT_BLUE).into()
+ } else {
+ pal.text_primary
+ })
+ .child(slot.label),
+ )
+ .child(
+ h_flex()
+ .items_center()
+ .justify_center()
+ .gap(px(2.))
+ .max_w(px(KEY_CALLOUT_W - 8.))
+ .when_some(binding_icon, |row, icon| {
+ row.child(svg().path(icon).size(px(9.)).flex_none().text_color(
+ if highlighted {
+ rgb(ACCENT_BLUE).into()
+ } else {
+ pal.text_muted
+ },
+ ))
+ })
+ .child(
+ div()
+ .min_w_0()
+ .overflow_hidden()
+ .text_ellipsis()
+ .whitespace_nowrap()
+ .text_xs()
+ .text_color(if highlighted {
+ rgb(ACCENT_BLUE).into()
+ } else {
+ pal.text_muted
+ })
+ .child(binding),
+ ),
+ )
+ .on_hover(move |hovered, _window, cx| {
+ let next = (*hovered).then_some(idx);
+ view_hover.update(cx, |v, vcx| v.set_hovered_key(next, vcx));
+ })
+ .on_click(move |_ev, _window, cx| {
+ view_click.update(cx, |v, vcx| v.click_key(idx, vcx));
+ })
+ .into_any_element()
+}
+
+/// One invisible click-target over a function key. Selecting it opens the
+/// panel; hover/selection draws only a subtle keycap ring on the photo.
+fn key_click_target(
+ slot: KeySlot,
+ highlighted: bool,
+ (img_w, img_h): (f32, f32),
+ view: &Entity<FunctionRowView>,
+ _pal: &Palette,
+) -> AnyElement {
+ let idx = slot.idx;
+ let x_frac = slot.x_frac;
+ let y_frac = slot.y_frac;
+ let view_hover = view.clone();
+ let view_click = view.clone();
+ let left = key_target_left_px(x_frac, img_w, KEY_TARGET_W);
+ let top = key_target_top_px(y_frac, img_h, KEY_TARGET_H);
+
+ div()
+ .id(("key-target", idx))
+ .absolute()
+ .top(px(top))
+ .left(px(left))
+ .w(px(KEY_TARGET_W))
+ .h(px(KEY_TARGET_H))
+ .flex()
+ .items_center()
+ .justify_center()
+ .cursor_pointer()
+ .when(highlighted, |el| {
+ el.child(
+ div()
+ .w_full()
+ .h_full()
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(
+ div()
+ .w(px(KEY_HOTSPOT_DOT))
+ .h(px(KEY_HOTSPOT_DOT))
+ .rounded_full()
+ .border_1()
+ .border_color(gpui::Hsla::from(rgb(ACCENT_BLUE)))
+ .bg(gpui::Hsla::from(rgb(ACCENT_BLUE))),
+ )
+ .rounded_full()
+ .border_1()
+ .border_color(theme::accent_tint_hover())
+ .bg(theme::accent_tint()),
+ )
+ })
+ .on_hover(move |hovered, _window, cx| {
+ let next = (*hovered).then_some(idx);
+ view_hover.update(cx, |v, vcx| v.set_hovered_key(next, vcx));
+ })
+ .on_click(move |_ev, _window, cx| {
+ view_click.update(cx, |v, vcx| v.click_key(idx, vcx));
+ })
+ .into_any_element()
+}
+
+fn binding_label(action: Option<&Action>) -> gpui::SharedString {
+ match action {
+ Some(Action::CustomShortcut(combo)) => combo.rendered_label().into(),
+ Some(a) => tr!(a.label()),
+ None => tr!("Off"),
+ }
+}
+
+fn keyboard_leader_canvas(
+ slots: Vec<KeySlot>,
+ selected: Option<usize>,
+ hovered: Option<usize>,
+ (img_w, img_h): (f32, f32),
+) -> impl IntoElement {
+ let guides: Vec<(usize, f32, f32)> =
+ slots.iter().map(|s| (s.idx, s.x_frac, s.y_frac)).collect();
+ canvas(
+ move |_bounds, _, _| (guides, selected, hovered),
+ move |bounds, payload, window, _app| {
+ let (guides, selected, hovered) = payload;
+ paint_keyboard_leaders(bounds, guides, selected, hovered, (img_w, img_h), window);
+ },
+ )
+ .absolute()
+ .inset_0()
+ .w(px(img_w))
+ .h(px(CALLOUT_BAND_H + img_h))
+}
+
+fn paint_keyboard_leaders(
+ bounds: Bounds<gpui::Pixels>,
+ guides: Vec<(usize, f32, f32)>,
+ selected: Option<usize>,
+ hovered: Option<usize>,
+ (img_w, img_h): (f32, f32),
+ window: &mut Window,
+) {
+ let count = guides.len();
+ for (idx, x_frac, y_frac) in guides {
+ let highlighted = key_is_highlighted(idx, selected, hovered);
+ let key_x = x_frac * img_w;
+ let key_y = CALLOUT_BAND_H + (y_frac * img_h);
+ let callout_x = callout_center_x(idx, count, img_w);
+ let callout_bottom = callout_top_px(idx) + KEY_CALLOUT_H;
+ let start = bounds.origin + point(px(callout_x), px(callout_bottom));
+ let elbow = bounds.origin + point(px(callout_x), px(CALLOUT_BAND_H - 14.));
+ let end = bounds.origin + point(px(key_x), px(key_y));
+
+ let mut path = PathBuilder::stroke(if highlighted { px(2.) } else { px(1.) });
+ path.move_to(start);
+ path.line_to(elbow);
+ path.line_to(end);
+ if let Ok(path) = path.build() {
+ if highlighted {
+ window.paint_path(path, rgb(ACCENT_BLUE));
+ } else {
+ window.paint_path(path, hsla(0., 0., 0.55, 0.35));
+ }
+ }
+ }
+}
+
+fn next_selection_after_click(current: Option<usize>, clicked: usize) -> Option<usize> {
+ (current != Some(clicked)).then_some(clicked)
+}
+
+fn key_is_highlighted(idx: usize, selected: Option<usize>, hovered: Option<usize>) -> bool {
+ selected == Some(idx) || hovered == Some(idx)
+}
+
+/// Callout bubbles lay out *evenly* across the pane instead of over their
+/// keys: a dense F-row (a G513 packs Esc-F12 into half the render width)
+/// would otherwise stack the bubbles into an overlapping wall. The leader
+/// lines fan from each bubble down to its true key position.
+fn callout_center_x(idx: usize, count: usize, image_w: f32) -> f32 {
+ let margin = KEY_CALLOUT_W / 2.0 + 4.0;
+ if count <= 1 {
+ return image_w / 2.0;
+ }
+ margin + (idx as f32) * (image_w - 2.0 * margin) / ((count - 1) as f32)
+}
+
+fn callout_left_px(idx: usize, count: usize, image_w: f32, callout_w: f32) -> f32 {
+ (callout_center_x(idx, count, image_w) - callout_w / 2.0).clamp(0.0, image_w - callout_w)
+}
+
+fn key_target_left_px(x_frac: f32, img_w: f32, target_w: f32) -> f32 {
+ (x_frac * img_w - target_w / 2.0).clamp(0.0, img_w - target_w)
+}
+
+fn key_target_top_px(y_frac: f32, img_h: f32, target_h: f32) -> f32 {
+ (y_frac * img_h - target_h / 2.0).clamp(0.0, img_h - target_h)
+}
+
+fn callout_top_px(idx: usize) -> f32 {
+ if callout_lane_is_lower(idx) {
+ KEY_CALLOUT_TOP_LOWER
+ } else {
+ KEY_CALLOUT_TOP_UPPER
+ }
+}
+
+fn callout_lane_is_lower(idx: usize) -> bool {
+ idx.is_multiple_of(2)
+}
+
+/// The scrollable config panel for the selected key. Lists the same action
+/// catalog the mouse picker uses, plus a Power User section. Renders the rows
+/// directly (no popover) in a tall card.
+fn config_panel(
+ selected_idx: usize,
+ slots: &[KeySlot],
+ active_editor: Option<PowerUserKind>,
+ text_state: Option<Entity<InputState>>,
+ workflow_draft: Vec<WorkflowStep>,
+ view: &Entity<FunctionRowView>,
+ pal: &Palette,
+ _window: &mut Window,
+ cx: &mut Context<FunctionRowView>,
+) -> impl IntoElement {
+ let slot = &slots[selected_idx];
+ let trigger = slot.trigger.clone();
+ let key_name = trigger.to_string();
+
+ // If an editor is active, render it instead of the list.
+ if let Some(kind) = active_editor {
+ return crate::keyboard_model::editors::editor_card(
+ trigger,
+ kind,
+ text_state,
+ workflow_draft,
+ view,
+ *pal,
+ cx,
+ );
+ }
+
+ let current = cx
+ .try_global::<AppState>()
+ .and_then(|s| s.keyboard_bindings.get(&trigger).cloned());
+
+ let view_for_pick = view.clone();
+ let trigger_for_pick = trigger.clone();
+ let on_pick: PickFn = Rc::new(move |action, _window, cx| {
+ cx.update_global::<AppState, _>(|state, _| {
+ state.commit_keyboard_binding(trigger_for_pick.clone(), Some(action));
+ });
+ view_for_pick.update(cx, |_, vcx| vcx.notify());
+ });
+
+ let rows = panel_action_rows(current.as_ref(), &on_pick, view, pal);
+
+ menu_card(*pal)
+ .w(px(PANEL_W))
+ .max_h(px(500.))
+ .child(title_header(&key_name, pal))
+ .child(divider(*pal))
+ .child(scroll_list("key-panel-scroll", rows))
+ .into_any_element()
+}
+
+/// The panel's title — shows which key is selected, e.g. "F1".
+fn title_header(key_name: &str, pal: &Palette) -> impl IntoElement {
+ h_flex()
+ .items_center()
+ .justify_between()
+ .px_2()
+ .pb_1()
+ .child(
+ div()
+ .text_xs()
+ .font_weight(FontWeight::SEMIBOLD)
+ .text_color(pal.text_muted)
+ .child(tr!("Bind %{name}", name => key_name)),
+ )
+}
+
+/// The action rows + a Power User section, mirroring the picker's list but
+/// adapted for the panel context (no popover dismissal).
+fn panel_action_rows(
+ current: Option<&Action>,
+ on_pick: &PickFn,
+ view: &Entity<FunctionRowView>,
+ pal: &Palette,
+) -> Vec<AnyElement> {
+ let mut children = action_rows("panel-action", current, on_pick, *pal);
+ children.push(section_header(&tr!("Power User"), *pal));
+
+ let power_user_actions: &[(PowerUserKind, &str, &'static str)] = &[
+ (
+ PowerUserKind::TypeText,
+ "Type Text…",
+ "action-icons/keyboard.svg",
+ ),
+ (
+ PowerUserKind::RunAppleScript,
+ "Run AppleScript…",
+ "action-icons/terminal.svg",
+ ),
+ (
+ PowerUserKind::RunShellCommand,
+ "Run Shell Command…",
+ "action-icons/terminal.svg",
+ ),
+ (
+ PowerUserKind::Workflow,
+ "Workflow…",
+ "action-icons/list-checks.svg",
+ ),
+ ];
+
+ for (idx, (kind, label, icon_path)) in power_user_actions.iter().enumerate() {
+ let kind = *kind;
+ let view = view.clone();
+ let selected = matches!(
+ (current, kind),
+ (Some(Action::TypeText(_)), PowerUserKind::TypeText)
+ | (
+ Some(Action::RunAppleScript(_)),
+ PowerUserKind::RunAppleScript
+ )
+ | (
+ Some(Action::RunShellCommand(_)),
+ PowerUserKind::RunShellCommand
+ )
+ | (Some(Action::Workflow(_)), PowerUserKind::Workflow)
+ );
+ children.push(
+ menu_row(format!("panel-power-{idx}"), *pal, selected)
+ .child(
+ h_flex()
+ .items_center()
+ .gap_2()
+ .child(
+ svg()
+ .path(*icon_path)
+ .size_4()
+ .flex_none()
+ .text_color(pal.text_muted),
+ )
+ .child(div().child((*label).to_string())),
+ )
+ .when(selected, |s| {
+ s.child(
+ gpui_component::Icon::new(gpui_component::IconName::Check)
+ .size_3()
+ .text_color(rgb(ACCENT_BLUE)),
+ )
+ })
+ .on_click(move |_ev, _window, cx| {
+ view.update(cx, |v, vcx| v.open_editor(kind, vcx));
+ })
+ .into_any_element(),
+ );
+ }
+ children
+}
+
+#[derive(Clone, Copy, Debug)]
+struct KeyPoint {
+ x_frac: f32,
+ y_frac: f32,
+}
+
+/// Resolve key marker points as fractions [0..1] of the rendered image, along
+/// with how many top-row keys the board exposes (`points.len()` — the visible
+/// prefix of [`FUNCTION_KEYS`]). Prefer asset metadata's top-row markers —
+/// percent-based on MX Keys-class depots, pixel-based on legacy keyboard
+/// depots (G513) — and fall back to even spacing on the same row.
+fn key_points(asset: Option<&ResolvedAsset>) -> Vec<KeyPoint> {
+ if let Some(a) = asset {
+ if let Some(points) = legacy_pixel_key_points(a) {
+ return points;
+ }
+ let key_markers = sorted_marker_points(a, &["device_keys_image", "device_buttons_image"]);
+ let easy_switch_markers = sorted_marker_points(a, &["device_easyswitch_image"]);
+
+ if key_markers.len() >= 16 && easy_switch_markers.len() >= 3 {
+ let mut out = Vec::with_capacity(FUNCTION_KEYS.len());
+ out.push(synthesized_esc_point(key_markers[0]));
+ out.extend(
+ key_markers[..12]
+ .iter()
+ .copied()
+ .map(calibrated_marker_point),
+ );
+ out.extend(
+ easy_switch_markers[..3]
+ .iter()
+ .copied()
+ .map(calibrated_marker_point),
+ );
+ out.extend(
+ key_markers[key_markers.len() - 4..]
+ .iter()
+ .copied()
+ .map(calibrated_marker_point),
+ );
+ if out.len() == FUNCTION_KEYS.len() {
+ return out;
+ }
+ }
+
+ if key_markers.len() >= FUNCTION_KEYS.len() - 1 {
+ let f1_to_f19 = &key_markers[..FUNCTION_KEYS.len() - 1];
+ let mut out = Vec::with_capacity(FUNCTION_KEYS.len());
+ out.push(synthesized_esc_point(f1_to_f19[0]));
+ out.extend(f1_to_f19.iter().copied().map(calibrated_marker_point));
+ return out;
+ }
+ }
+ fallback_key_points()
+}
+
+#[cfg(test)]
+fn key_x_fractions(asset: Option<&ResolvedAsset>) -> Vec<f32> {
+ key_points(asset)
+ .into_iter()
+ .map(|point| point.x_frac)
+ .collect()
+}
+
+/// Key points from a legacy pixel-marker depot (the G513 family), or `None`
+/// when the asset isn't one.
+///
+/// Legacy `metadata*.json` files mark each F-key's cap-face centre in
+/// *absolute pixels* of the authored canvas (`origin`), not percentages. The
+/// markers only apply when that canvas is the render we actually cached —
+/// the same depot also ships marker sets authored against other variants'
+/// renders (the G513's `metadata.json` belongs to the G512 banner render) —
+/// so a depot whose `origin` doesn't match the PNG is rejected rather than
+/// misplacing every callout.
+fn legacy_pixel_key_points(asset: &ResolvedAsset) -> Option<Vec<KeyPoint>> {
+ let img = asset
+ .metadata
+ .images
+ .iter()
+ .find(|img| img.key == "device_image" && !img.assignments.is_empty())?;
+ if img.origin.width != asset.png_width || img.origin.height != asset.png_height {
+ return None;
+ }
+ let (w, h) = (img.origin.width as f32, img.origin.height as f32);
+
+ let mut markers: Vec<KeyPoint> = img
+ .assignments
+ .iter()
+ .map(|asg| asg.marker)
+ // Percent-schema depots never exceed 100 on either axis; anything
+ // beyond is a pixel coordinate. Mixed files don't exist in the wild,
+ // but a percent marker slipping through would land off by 27x.
+ .filter(|m| m.x > 100. || m.y > 100.)
+ .map(|m| KeyPoint {
+ x_frac: (m.x / w).clamp(0.0, 1.0),
+ y_frac: (m.y / h).clamp(0.0, 1.0),
+ })
+ .collect();
+ if markers.len() < 2 || markers.len() > FUNCTION_KEYS.len() - 1 {
+ return None;
+ }
+ markers.sort_by(|a, b| {
+ a.x_frac
+ .partial_cmp(&b.x_frac)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ });
+
+ // The depots mark F1..Fn but never Esc; place it left of F1 by the F-row's
+ // own key pitch so it stays registered at any render size.
+ let pitch = median_pitch(&markers)?;
+ let first = markers[0];
+ let esc = KeyPoint {
+ x_frac: (first.x_frac - ESC_LEFT_OF_F1_PITCHES * pitch).max(0.0),
+ y_frac: first.y_frac,
+ };
+
+ let mut out = Vec::with_capacity(markers.len() + 1);
+ out.push(esc);
+ out.extend(markers);
+ Some(out)
+}
+
+/// Median gap between adjacent marker x positions — the F-row's key pitch.
+/// The median rides out the wider inter-cluster gaps (F4→F5, F8→F9).
+fn median_pitch(sorted_markers: &[KeyPoint]) -> Option<f32> {
+ let mut gaps: Vec<f32> = sorted_markers
+ .windows(2)
+ .map(|pair| pair[1].x_frac - pair[0].x_frac)
+ .filter(|gap| *gap > 0.)
+ .collect();
+ if gaps.is_empty() {
+ return None;
+ }
+ gaps.sort_by(f32::total_cmp);
+ Some(gaps[gaps.len() / 2])
+}
+
+fn sorted_marker_points(asset: &ResolvedAsset, image_keys: &[&str]) -> Vec<KeyPoint> {
+ let mut markers: Vec<KeyPoint> = asset
+ .metadata
+ .images
+ .iter()
+ .filter(|img| image_keys.contains(&img.key.as_str()))
+ .flat_map(|img| img.assignments.iter())
+ .map(|asg| KeyPoint {
+ x_frac: asg.marker.x / 100.0,
+ y_frac: asg.marker.y / 100.0,
+ })
+ .collect();
+ markers.sort_by(|a, b| {
+ a.x_frac
+ .partial_cmp(&b.x_frac)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ });
+ markers
+}
+
+fn synthesized_esc_point(first_function_key: KeyPoint) -> KeyPoint {
+ KeyPoint {
+ x_frac: synthesized_esc_x(first_function_key.x_frac),
+ y_frac: calibrated_marker_point(first_function_key).y_frac,
+ }
+}
+
+fn calibrated_marker_point(raw: KeyPoint) -> KeyPoint {
+ KeyPoint {
+ x_frac: (raw.x_frac + FRONT_MARKER_X_OFFSET_FRAC).clamp(0.0, 1.0),
+ y_frac: (raw.y_frac + FRONT_MARKER_Y_OFFSET_FRAC).clamp(0.0, 1.0),
+ }
+}
+
+fn synthesized_esc_x(first_function_key_x: f32) -> f32 {
+ (first_function_key_x - 0.045).max(0.02)
+}
+
+fn fallback_key_x_fractions() -> Vec<f32> {
+ let step = (EVEN_SPACING_END - EVEN_SPACING_START) / (FUNCTION_KEYS.len() - 1) as f32;
+ (0..FUNCTION_KEYS.len())
+ .map(|i| EVEN_SPACING_START + (i as f32) * step)
+ .collect()
+}
+
+fn fallback_key_points() -> Vec<KeyPoint> {
+ fallback_key_x_fractions()
+ .into_iter()
+ .map(|x_frac| KeyPoint {
+ x_frac,
+ y_frac: FALLBACK_KEY_Y_FRAC,
+ })
+ .collect()
+}
+
+/// The keyboard image, or a labeled placeholder when no asset resolved. The
+/// element is sized to the PNG's own aspect (see [`keyboard_render_size`]), so
+/// the contain-fit paints edge to edge and the marker overlays stay registered.
+fn image_or_fallback(
+ img_path: Option<std::path::PathBuf>,
+ img_w: f32,
+ img_h: f32,
+ pal: &Palette,
+) -> AnyElement {
+ match img_path {
+ Some(path) if path.exists() => img(path).w(px(img_w)).h(px(img_h)).into_any_element(),
+ Some(_) | None => div()
+ .w(px(img_w))
+ .h(px(160.))
+ .rounded_md()
+ .border_1()
+ .border_color(pal.border)
+ .bg(pal.surface)
+ .flex()
+ .items_center()
+ .justify_center()
+ .text_color(pal.text_muted)
+ .child(tr!("No keyboard image available"))
+ .into_any_element(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use openlogi_assets::{Assignment, Direction, ImageEntry, Metadata, Origin, Point};
+ use openlogi_core::device::DeviceKind;
+ use std::path::PathBuf;
+
+ #[test]
+ fn clicking_the_selected_key_closes_the_panel() {
+ assert_eq!(next_selection_after_click(None, 3), Some(3));
+ assert_eq!(next_selection_after_click(Some(3), 3), None);
+ assert_eq!(next_selection_after_click(Some(3), 4), Some(4));
+ }
+
+ #[test]
+ fn hover_or_selection_highlights_a_key() {
+ assert!(key_is_highlighted(2, Some(2), None));
+ assert!(key_is_highlighted(2, None, Some(2)));
+ assert!(key_is_highlighted(2, Some(2), Some(7)));
+ assert!(!key_is_highlighted(2, Some(1), Some(7)));
+ }
+
+ #[test]
+ fn function_row_covers_esc_through_f19() {
+ let labels: Vec<&str> = FUNCTION_KEYS.iter().map(|(label, _)| *label).collect();
+
+ assert_eq!(FUNCTION_KEYS.len(), 20);
+ assert_eq!(labels.first(), Some(&"Esc"));
+ assert_eq!(labels.last(), Some(&"F19"));
+ assert!(labels.contains(&"F13"));
+ assert!(labels.contains(&"F19"));
+ }
+
+ #[test]
+ fn fallback_key_positions_cover_the_full_top_row() {
+ let positions = key_x_fractions(None);
+
+ assert_eq!(positions.len(), 20);
+ assert_eq!(positions.first().copied(), Some(EVEN_SPACING_START));
+ assert_eq!(positions.last().copied(), Some(EVEN_SPACING_END));
+ }
+
+ #[test]
+ fn mx_keys_markers_merge_function_and_easy_switch_groups() {
+ let key_markers = vec![
+ 9.0, 13.4, 17.8, 22.3, 26.7, 31.15, 35.55, 40.05, 44.55, 49.1, 53.5, 57.9, 62.35, 81.5,
+ 85.9, 90.3, 94.7,
+ ];
+ let easy_switch_markers = vec![67.5, 71.92, 76.3];
+ let asset = asset_with_markers(&key_markers, &easy_switch_markers);
+
+ let positions = key_x_fractions(Some(&asset));
+
+ assert_eq!(positions.len(), 20);
+ assert_approx_eq(positions[0], 0.045);
+ assert_approx_eq(positions[1], 0.11);
+ assert_approx_eq(positions[12], 0.599);
+ assert_approx_eq(positions[13], 0.695);
+ assert_approx_eq(positions[15], 0.783);
+ assert_approx_eq(positions[16], 0.835);
+ assert_approx_eq(positions[19], 0.967);
+ assert!(
+ positions.windows(2).all(|pair| pair[0] < pair[1]),
+ "positions should stay in physical left-to-right order"
+ );
+ }
+
+ #[test]
+ fn mx_keys_markers_preserve_key_center_points() {
+ let key_markers = vec![
+ 9.0, 13.4, 17.8, 22.3, 26.7, 31.15, 35.55, 40.05, 44.55, 49.1, 53.5, 57.9, 62.35, 81.5,
+ 85.9, 90.3, 94.7,
+ ];
+ let easy_switch_markers = vec![67.5, 71.92, 76.3];
+ let asset = asset_with_markers(&key_markers, &easy_switch_markers);
+
+ let points = key_points(Some(&asset));
+
+ assert_eq!(points.len(), 20);
+ assert_approx_eq(points[19].x_frac, 0.967);
+ assert_approx_eq(points[19].y_frac, 0.153);
+ assert_approx_eq(key_target_top_px(points[19].y_frac, 220.0, 30.0), 18.66);
+ }
+
+ /// The G513 family's `metadata_full.json`: `device_image` markers in
+ /// absolute pixels of the authored canvas, which matches the cached
+ /// render. F1-F12 come from the markers; Esc is synthesized one chassis
+ /// offset left of F1.
+ #[test]
+ fn g513_pixel_markers_resolve_esc_plus_f1_to_f12() {
+ let marker_xs = [
+ 285., 405., 525., 645., 840., 960., 1080., 1200., 1395., 1515., 1635., 1755.,
+ ];
+ let asset = legacy_asset(&marker_xs, 290., (2760, 1600), (2760, 1600));
+
+ let points = key_points(Some(&asset));
+
+ assert_eq!(points.len(), 13, "Esc + F1-F12, no phantom F13-F19");
+ assert_approx_eq(points[1].x_frac, 285. / 2760.);
+ assert_approx_eq(points[12].x_frac, 1755. / 2760.);
+ // Esc: 1.55 key pitches (median gap 120px) left of F1.
+ assert_approx_eq(points[0].x_frac, (285. - 1.55 * 120.) / 2760.);
+ for point in &points {
+ assert_approx_eq(point.y_frac, 290. / 1600.);
+ }
+ assert!(
+ points
+ .windows(2)
+ .all(|pair| pair[0].x_frac < pair[1].x_frac),
+ "points stay in physical left-to-right order"
+ );
+ }
+
+ /// The same depot's `metadata.json` is authored against a *different*
+ /// render (the G512 banner). Its origin doesn't match the cached PNG, so
+ /// the markers must be rejected in favour of the even-spacing fallback
+ /// rather than misplacing every callout.
+ #[test]
+ fn pixel_markers_for_a_different_render_fall_back_to_even_spacing() {
+ let marker_xs = [370., 525., 680., 835., 1090., 1250., 1400., 1555.];
+ let asset = legacy_asset(&marker_xs, 300., (3598, 1315), (2760, 1600));
+
+ let points = key_points(Some(&asset));
+
+ assert_eq!(points.len(), FUNCTION_KEYS.len());
+ assert_approx_eq(points[0].x_frac, EVEN_SPACING_START);
+ assert_approx_eq(points[19].x_frac, EVEN_SPACING_END);
+ }
+
+ #[test]
+ fn render_size_follows_the_png_aspect_up_to_the_width_cap() {
+ // MX Keys-class render (1872x728): width-bound at a roomy viewport.
+ let mx = legacy_asset(&[], 0., (1872, 728), (1872, 728));
+ let (w, h) = keyboard_render_size(Some(&mx), 900.);
+ assert_approx_eq(w, 700.);
+ assert!((h - 700. * 728. / 1872.).abs() < 0.01);
+
+ // G513 render (2760x1600) is far taller at the same width.
+ let g513 = legacy_asset(&[], 0., (2760, 1600), (2760, 1600));
+ let (w, h) = keyboard_render_size(Some(&g513), 900.);
+ assert_approx_eq(w, 700.);
+ assert!((h - 700. * 1600. / 2760.).abs() < 0.01);
+
+ // A short viewport shrinks the render instead of overflowing it.
+ let (w, h) = keyboard_render_size(Some(&g513), 500.);
+ assert_approx_eq(h, KEYBOARD_MIN_IMG_H);
+ assert!((w - KEYBOARD_MIN_IMG_H * 2760. / 1600.).abs() < 0.01);
+
+ assert_eq!(keyboard_render_size(None, 900.), FALLBACK_KEYBOARD_SIZE);
+ }
+
+ #[test]
+ fn callouts_spread_evenly_from_margin_to_margin() {
+ let margin = KEY_CALLOUT_W / 2.0 + 4.0;
+ assert_approx_eq(callout_center_x(0, 13, 700.0), margin);
+ assert_approx_eq(callout_center_x(12, 13, 700.0), 700.0 - margin);
+ assert_approx_eq(callout_center_x(0, 1, 700.0), 350.0);
+ assert!(callout_left_px(0, 13, 700.0, KEY_CALLOUT_W) >= 0.0);
+ assert!(callout_left_px(12, 13, 700.0, KEY_CALLOUT_W) <= 700.0 - KEY_CALLOUT_W);
+ }
+
+ /// Bubbles share a stagger lane with every second key; same-lane
+ /// neighbours must never overlap for any board size the row can show.
+ #[test]
+ fn same_lane_callouts_never_overlap() {
+ for count in [13usize, 20] {
+ for idx in 0..count.saturating_sub(2) {
+ let gap = callout_center_x(idx + 2, count, KEYBOARD_W)
+ - callout_center_x(idx, count, KEYBOARD_W);
+ assert!(
+ gap >= KEY_CALLOUT_W,
+ "lane neighbours {idx}/{} overlap at count {count}: gap {gap}",
+ idx + 2
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn function_key_callouts_stagger_even_lower_odd_upper() {
+ assert!(callout_top_px(0) > callout_top_px(1));
+ assert_eq!(callout_top_px(0), callout_top_px(2));
+ assert_eq!(callout_top_px(1), callout_top_px(3));
+ }
+
+ #[test]
+ fn staggered_function_key_callout_rows_fit_the_keyboard_width() {
+ let lower_count = FUNCTION_KEYS
+ .iter()
+ .enumerate()
+ .filter(|(idx, _)| callout_lane_is_lower(*idx))
+ .count();
+ let upper_count = FUNCTION_KEYS.len() - lower_count;
+ assert!(
+ KEY_CALLOUT_W * lower_count as f32 <= KEYBOARD_W,
+ "lower callout lane overlaps before spacing is considered"
+ );
+ assert!(
+ KEY_CALLOUT_W * upper_count as f32 <= KEYBOARD_W,
+ "upper callout lane overlaps before spacing is considered"
+ );
+ }
+
+ /// A legacy pixel-marker asset: `device_image` assignments in absolute
+ /// pixels of an `origin` canvas, over a render of `png` dimensions.
+ fn legacy_asset(
+ marker_xs: &[f32],
+ marker_y: f32,
+ origin: (u32, u32),
+ png: (u32, u32),
+ ) -> ResolvedAsset {
+ let assignments = marker_xs
+ .iter()
+ .map(|x| Assignment {
+ slot_name: String::new(),
+ marker: Point { x: *x, y: marker_y },
+ label: Direction { x: -1, y: -1 },
+ })
+ .collect();
+ ResolvedAsset {
+ depot: "g513".to_string(),
+ display_name: "G513".to_string(),
+ kind: DeviceKind::Keyboard,
+ image_path: PathBuf::from("/tmp/g513.png"),
+ hero_image_path: None,
+ glow: None,
+ metadata: Metadata {
+ images: vec![ImageEntry {
+ key: "device_image".to_string(),
+ origin: Origin {
+ width: origin.0,
+ height: origin.1,
+ },
+ assignments,
+ }],
+ },
+ png_width: png.0,
+ png_height: png.1,
+ }
+ }
+
+ fn asset_with_markers(key_markers: &[f32], easy_switch_markers: &[f32]) -> ResolvedAsset {
+ ResolvedAsset {
+ depot: "mx_keys_s_for_mac".to_string(),
+ display_name: "MX Keys S for Mac".to_string(),
+ kind: DeviceKind::Keyboard,
+ image_path: PathBuf::from("/tmp/mx-keys.png"),
+ hero_image_path: None,
+ glow: None,
+ metadata: Metadata {
+ images: vec![
+ ImageEntry {
+ key: "device_keys_image".to_string(),
+ origin: Origin {
+ width: 1872,
+ height: 728,
+ },
+ assignments: assignments_from_markers(key_markers),
+ },
+ ImageEntry {
+ key: "device_easyswitch_image".to_string(),
+ origin: Origin {
+ width: 1872,
+ height: 728,
+ },
+ assignments: assignments_from_markers(easy_switch_markers),
+ },
+ ],
+ },
+ png_width: 1872,
+ png_height: 728,
+ }
+ }
+
+ fn assignments_from_markers(markers: &[f32]) -> Vec<Assignment> {
+ markers
+ .iter()
+ .enumerate()
+ .map(|(idx, x)| Assignment {
+ slot_name: format!("slot-{idx}"),
+ marker: Point { x: *x, y: 13.0 },
+ label: Direction { x: -1, y: -1 },
+ })
+ .collect()
+ }
+
+ fn assert_approx_eq(actual: f32, expected: f32) {
+ assert!(
+ (actual - expected).abs() < 0.0001,
+ "expected {expected}, got {actual}"
+ );
+ }
+}
diff --git a/crates/openlogi-gui/src/keyboard_model/mod.rs b/crates/openlogi-gui/src/keyboard_model/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..8ab6e36045c43da896a6a0d31728b1e54c2f2e06
--- /dev/null
+++ b/crates/openlogi-gui/src/keyboard_model/mod.rs
@@ -0,0 +1,15 @@
+//! Keyboard remapper UI — the global function-key binding surface.
+//!
+//! Mirrors [`crate::mouse_model`]: a hardware-style diagram whose clickable
+//! hotspots (here, function-row key-caps) each open the same action picker the
+//! mouse buttons use. The key difference is scope — mouse bindings are
+//! per-device under `config.devices[key].bindings`, while keyboard F-key
+//! bindings are global (`config.keyboard.bindings`) and apply across all
+//! keyboards, so the picker commits via [`AppState::commit_keyboard_binding`]
+//! rather than [`AppState::commit_binding`].
+//!
+//! [`AppState::commit_binding`]: crate::state::AppState::commit_binding
+//! [`AppState::commit_keyboard_binding`]: crate::state::AppState::commit_keyboard_binding
+
+pub mod editors;
+pub mod function_row;
diff --git a/crates/openlogi-gui/src/main.rs b/crates/openlogi-gui/src/main.rs
index ab4380aba031100d9bbd384beaec067e62927050..11295befff483a9bb7a2693af1ce55ff62eac61e 100644
--- a/crates/openlogi-gui/src/main.rs
+++ b/crates/openlogi-gui/src/main.rs
@@ -38,6 +38,7 @@ mod data;
mod diagnostics;
mod i18n;
mod ipc_client;
+mod keyboard_model;
mod mouse_model;
mod platform;
mod state;
@@ -58,9 +59,9 @@ use gpui::{
AppContext, BorrowAppContext as _, Bounds, Size, Styled, WindowBounds, WindowOptions, px,
};
use gpui_component::{ActiveTheme, Root};
-use openlogi_core::brand::DeeplinkCommand;
+use openlogi_core::brand::{APP_ID, DeeplinkCommand};
use openlogi_core::config::Config;
-use openlogi_core::device::DeviceInventory;
+use openlogi_core::device::{DeviceInventory, StandaloneDevice};
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
@@ -68,7 +69,7 @@ use crate::app::AppView;
use crate::asset::sync::{
AssetCommand, AssetControl, SyncOutcome, model_key, run_asset_sync, sync_retry_delay,
};
-use crate::state::AppState;
+use crate::state::{AppState, ConfigPersistence};
fn dispatch_gui_command(command: DeeplinkCommand, cx: &mut gpui::App) {
use DeeplinkCommand as Cmd;
@@ -139,6 +140,7 @@ fn main() -> Result<()> {
// bindings, and the hook live; asset sync is kicked off in the background
// when the first devices appear (see the `inventory_rx` arm).
let inventories: Vec<DeviceInventory> = Vec::new();
+ let standalone = Vec::new();
let initial_config = Config::load_or_default().unwrap_or_else(|e| {
warn!(error = %e, "could not load config.toml; using defaults");
@@ -221,6 +223,14 @@ fn main() -> Result<()> {
.detach();
cx.spawn(async move |cx| {
+ // Enumerate webcams off the UI thread: AVFoundation discovery can
+ // stall for hundreds of ms on first touch, which must never block
+ // the first paint (or, below, a snapshot merge mid-render).
+ let mut latest_cams = cx
+ .background_executor()
+ .spawn(async { openlogi_camera::enumerate_cameras() })
+ .await;
+
// Install the hook-shared AppState up front, then open the window at
// launch; closing it leaves the app live in the menu bar.
cx.update(|cx| {
@@ -229,7 +239,10 @@ fn main() -> Result<()> {
cx.set_global(AppState::with_runtime(
initial_config,
&inventories,
+ &standalone,
&cache,
+ &latest_cams,
+ ConfigPersistence::UserFile,
ipc_commands,
));
}
@@ -278,11 +291,15 @@ fn main() -> Result<()> {
// Clear (the AssetControl arm below) can sync the current devices
// without waiting for the next snapshot.
let mut latest_inv: Vec<DeviceInventory> = Vec::new();
+ let mut latest_standalone: Vec<StandaloneDevice> = Vec::new();
// A manual Refresh / Clear that arrived while a sync was in
// flight: stashed here and run by the sync-outcome arm the moment
// that sync finishes, so a Clear's cache wipe never races the
// in-flight fetch's writes and the manual fetch is never dropped.
let mut deferred_manual: Option<AssetCommand> = None;
+ // Consecutive empty camera scans while cameras were showing — see
+ // the grace logic in the snapshot arm.
+ let mut camera_misses: u8 = 0;
// Cleared when the IPC update channel closes (the client thread
// died), so the select stops polling a closed receiver.
let mut ipc_open = true;
@@ -290,6 +307,24 @@ fn main() -> Result<()> {
tokio::select! {
update = ipc_updates.recv(), if ipc_open => match update {
Some(ipc_client::GuiUpdate::Snapshot(update)) => {
+ // Refresh the camera set off the UI thread (AVFoundation
+ // discovery is far too slow for the render path) so the
+ // merge below sees hot-plugs without ever stalling paint.
+ // An empty scan gets a two-snapshot grace before it
+ // evicts anything: a USB control seize (e.g. another
+ // process's CLI) blinks the camera out of discovery for
+ // a moment, and one blink must not tear down the card —
+ // or the detail page — the user is looking at.
+ let scanned = cx
+ .background_executor()
+ .spawn(async { openlogi_camera::enumerate_cameras() })
+ .await;
+ if scanned.is_empty() && !latest_cams.is_empty() && camera_misses < 2 {
+ camera_misses += 1;
+ } else {
+ camera_misses = 0;
+ latest_cams = scanned;
+ }
// Keep the latest completed enumeration for the manual
// Refresh / Clear arm — a not-yet-ready agent's empty
// pre-enumeration list must not shrink it.
@@ -297,6 +332,7 @@ fn main() -> Result<()> {
== openlogi_agent_core::ipc::InventoryHealth::Ready;
if inventory_ready {
latest_inv.clone_from(&update.inventory);
+ latest_standalone.clone_from(&update.standalone);
}
// A completed sync may have put real photos where
// silhouettes were resolved: the resolver was rebuilt
@@ -313,24 +349,25 @@ fn main() -> Result<()> {
let force_refresh = inventory_ready && std::mem::take(&mut assets_dirty);
let (auto_download, asset_source, models) = cx.update(|cx| {
let (changed, merged, auto_download, asset_source, models) = cx.update_global::<AppState, _>(|state, _| {
- // Merge only *completed* enumerations. A not-yet-ready
- // agent can only serve an empty pre-enumeration list, and
- // counting those as misses would wipe the device list (and
- // pop an open detail page) on every agent restart: at the
- // 250 ms reconnect cadence the miss grace burns in ~750 ms
- // while a fresh enumeration takes 1.5–5 s. The diagnostics
- // snapshot shares the gate so a report copied during that
- // window keeps the receivers the UI is still showing; the
- // manual-sync arm reuses `inventory_ready` above.
+ // Merge only completed enumerations. A scanning agent serves
+ // an empty pre-enumeration list, which must not burn the GUI's
+ // miss grace or replace the last known device set.
let merged = inventory_ready
- && state.refresh_inventories(&update.inventory, &cache, force_refresh);
+ && state.refresh_inventories(
+ &update.inventory,
+ &update.standalone,
+ &cache,
+ force_refresh,
+ &latest_cams,
+ );
if inventory_ready {
state.store_inventory_snapshot(&update.inventory);
}
// Bitwise `|`: the link must be set even when the
// merge already reported a change.
let changed = merged
- | state.set_agent_link(state::AgentLink::Ready(update.status));
+ | state.set_agent_link(state::AgentLink::Ready(update.status))
+ | state.set_camera_active(update.camera_active);
let settings = state.app_settings();
(
changed,
@@ -360,6 +397,17 @@ fn main() -> Result<()> {
// works via the AssetControl arm below.
let backoff_passed = last_sync_at
.is_none_or(|t| t.elapsed() >= sync_retry_delay(sync_attempts));
+ // Cameras are enumerated on the UI side (UVC, not HID++),
+ // so `asset_models` — built from the HID++ device list —
+ // can't see them. Fold their synthesized models in so a
+ // webcam's product art downloads like any other device's.
+ let mut models = models;
+ models.extend(latest_cams.iter().map(|c| {
+ crate::asset::sync::AssetTarget::Hidpp {
+ model: state::camera_model_info(c),
+ codename: Some(c.name.clone()),
+ }
+ }));
let pending: Vec<_> = models
.into_iter()
.filter(|m| !synced_keys.contains(&model_key(m)))
@@ -387,6 +435,19 @@ fn main() -> Result<()> {
Some(ipc_client::GuiUpdate::OutdatedGui) => {
cx.update(|cx| set_agent_link(state::AgentLink::OutdatedGui, cx));
}
+ Some(ipc_client::GuiUpdate::LightCommandResult {
+ key,
+ request_id,
+ command,
+ result,
+ }) => {
+ let changed = cx.update_global::<AppState, _>(|state, _| {
+ state.apply_light_command_result(key, request_id, command, result)
+ });
+ if changed {
+ cx.update(gpui::App::refresh_windows);
+ }
+ }
// The IPC client thread is gone (runtime / thread spawn
// failure) — without this the window would show its
// connecting spinner forever.
@@ -433,7 +494,13 @@ fn main() -> Result<()> {
cache = asset::AssetResolver::new();
cx.update(|cx| {
let changed = cx.update_global::<AppState, _>(|state, _| {
- state.refresh_inventories(&latest_inv, &cache, true)
+ state.refresh_inventories(
+ &latest_inv,
+ &latest_standalone,
+ &cache,
+ true,
+ &latest_cams,
+ )
});
if changed {
cx.refresh_windows();
@@ -447,6 +514,15 @@ fn main() -> Result<()> {
let state = cx.global::<AppState>();
(state.asset_models(), state.app_settings().asset_source)
});
+ // Include the UI-side webcam models (see the snapshot
+ // arm) so a manual Refresh fetches camera art too.
+ let mut models = models;
+ models.extend(latest_cams.iter().map(|c| {
+ crate::asset::sync::AssetTarget::Hidpp {
+ model: state::camera_model_info(c),
+ codename: Some(c.name.clone()),
+ }
+ }));
let tx = sync_tx.clone();
std::thread::spawn(move || {
let keys = models.iter().map(model_key).collect();
@@ -503,11 +579,17 @@ fn main_window_options(cx: &mut gpui::App) -> WindowOptions {
let bounds = Bounds::centered(None, Size::new(px(1100.), px(750.)), cx);
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
+ // Advertise a Wayland xdg-toplevel app_id (and X11 WM_CLASS). Without it
+ // the window ships no app_id, so GNOME's `get_wm_class()` returns empty
+ // and our own `gnome_shell` frontmost backend reports OpenLogi as `None`
+ // (and the dash can't group the window under its launcher icon). The id
+ // is the shared `brand::APP_ID`, matching the desktop file's
+ // `StartupWMClass` and the macOS bundle-id family.
+ app_id: Some(APP_ID.into()),
// Min height keeps the buttons tab's mouse model above its scale floor
// (`MODEL_MIN_H` + the chrome/padding reserve) so its side labels never
// overlap; below this the model can't shrink further without crowding.
window_min_size: Some(Size::new(px(720.), px(680.))),
- app_id: Some("openlogi".to_string()),
// Linux: transparent chrome so `AppView::render` can draw a client-side
// `TitleBar` (the compositor declines server-side decorations and gpui's
// fallback is unpainted). macOS/Windows keep their native titlebar.
diff --git a/crates/openlogi-gui/src/mouse_model/geometry.rs b/crates/openlogi-gui/src/mouse_model/geometry.rs
index 316c64da8fdcaace7ce1d84b381405f4ee2034e9..f7559fa8eb33e80b3a0ca9f2e46b712ece888c66 100644
--- a/crates/openlogi-gui/src/mouse_model/geometry.rs
+++ b/crates/openlogi-gui/src/mouse_model/geometry.rs
@@ -195,9 +195,11 @@ mod tests {
#[test]
fn default_labels_include_capability_gated_thumbwheel() {
- assert!(!default_labels(false)
- .iter()
- .any(|label| label.id == MouseControlId::ThumbwheelRotation));
+ assert!(
+ !default_labels(false)
+ .iter()
+ .any(|label| label.id == MouseControlId::ThumbwheelRotation)
+ );
assert_eq!(
default_labels(true)
.iter()
diff --git a/crates/openlogi-gui/src/mouse_model/mod.rs b/crates/openlogi-gui/src/mouse_model/mod.rs
index feafa2eb2c8365ef6ab29359253d62c6b3a8459b..e703f75313d45e4401611d36c63b792b5ae16496 100644
--- a/crates/openlogi-gui/src/mouse_model/mod.rs
+++ b/crates/openlogi-gui/src/mouse_model/mod.rs
@@ -3,7 +3,7 @@
//!
//! Per UI.md phases 6 (this view), 7 (leader lines), and 8 (ambient motion).
-mod geometry;
+pub mod geometry;
pub mod leader_lines;
pub mod picker;
pub(crate) mod thumbwheel;
diff --git a/crates/openlogi-gui/src/mouse_model/picker.rs b/crates/openlogi-gui/src/mouse_model/picker.rs
index 010454bf3f39291352ce36c942148bd7a3b0195a..6375bc323955db1ef3cafd5a402fc9d0f82f78df 100644
--- a/crates/openlogi-gui/src/mouse_model/picker.rs
+++ b/crates/openlogi-gui/src/mouse_model/picker.rs
@@ -39,11 +39,11 @@ use crate::theme::{self, ACCENT_BLUE, Palette, SelectableStyle, Typography as _}
/// Floor width for the [`action_picker`] popover. The action labels drive the
/// actual width; this only stops the list from collapsing too narrow. Matches
/// gpui-component's own `PopupMenu` floor (`min_w(rems(8.))`).
-const POPOVER_W: f32 = 128.;
+pub(crate) const POPOVER_W: f32 = 128.;
/// Cap the scrollable action list height. The catalog has 29+ entries across
/// half a dozen categories; without a cap the list overflows the window.
-const POPOVER_LIST_MAX_H: f32 = 360.;
+pub(crate) const POPOVER_LIST_MAX_H: f32 = 360.;
/// Build the popover body that re-binds a single `btn`.
///
@@ -199,7 +199,7 @@ pub fn gesture_overview(
/// card uses `rounded_lg` (8px). The shadow is gpui's soft `shadow_md`, not a
/// hard drop. Not stateful (no interaction → no element id, so two sibling cards
/// can't collide on one).
-fn menu_card(pal: Palette) -> gpui::Div {
+pub(crate) fn menu_card(pal: Palette) -> gpui::Div {
v_flex()
.bg(pal.surface)
.border_1()
@@ -356,11 +356,11 @@ fn flyout_card(
/// Commit callback invoked when a row is clicked. Boxed so the row builder can
/// be shared between the button picker and any future custom picker, which
/// differ only in what they do after committing.
-type PickFn = Rc<dyn Fn(Action, &mut Window, &mut App)>;
+pub(crate) type PickFn = Rc<dyn Fn(Action, &mut Window, &mut App)>;
/// The action catalog grouped by [`Category`], preserving catalog order within
/// each group and first-seen order across groups.
-fn grouped_catalog() -> Vec<(Category, Vec<Action>)> {
+pub(crate) fn grouped_catalog() -> Vec<(Category, Vec<Action>)> {
let mut sections: Vec<(Category, Vec<Action>)> = Vec::new();
for action in Action::catalog() {
let cat = action.category();
@@ -396,7 +396,7 @@ pub(crate) fn action_icon_path(action: &Action) -> &'static str {
Action::Cut => "action-icons/scissors.svg",
Action::Undo => "action-icons/undo-2.svg",
Action::Redo => "action-icons/redo-2.svg",
- Action::SelectAll => "action-icons/list-checks.svg",
+ Action::SelectAll | Action::Workflow(_) => "action-icons/list-checks.svg",
Action::Find => "action-icons/search.svg",
Action::Save => "action-icons/save.svg",
Action::BrowserBack => "action-icons/arrow-left.svg",
@@ -415,6 +415,7 @@ pub(crate) fn action_icon_path(action: &Action) -> &'static str {
Action::LaunchpadShow => "action-icons/grid-3x3.svg",
Action::LockScreen => "action-icons/lock.svg",
Action::Screenshot | Action::CaptureRegion => "action-icons/camera.svg",
+ Action::Sleep => "action-icons/moon.svg",
Action::PlayPause => "action-icons/play.svg",
Action::NextTrack => "action-icons/skip-forward.svg",
Action::PrevTrack => "action-icons/skip-back.svg",
@@ -427,7 +428,10 @@ pub(crate) fn action_icon_path(action: &Action) -> &'static str {
Action::ScrollDown => "action-icons/chevrons-down.svg",
Action::HorizontalScrollLeft => "action-icons/chevrons-left.svg",
Action::HorizontalScrollRight => "action-icons/chevrons-right.svg",
- Action::CustomShortcut(_) => "action-icons/keyboard.svg",
+ // Power-user actions (M1 function-key remapper). TypeText shares the
+ // keyboard glyph with CustomShortcut; shell/script arms share terminal.
+ Action::CustomShortcut(_) | Action::TypeText(_) => "action-icons/keyboard.svg",
+ Action::RunAppleScript(_) | Action::RunShellCommand(_) => "action-icons/terminal.svg",
}
}
@@ -435,7 +439,7 @@ pub(crate) fn action_icon_path(action: &Action) -> &'static str {
/// icon, then its label; `current` adds a trailing accent check. Clicking any
/// row invokes `on_pick`. `id_prefix` disambiguates element IDs between pickers
/// that share this builder.
-fn action_rows(
+pub(crate) fn action_rows(
id_prefix: &'static str,
current: Option<&Action>,
on_pick: &PickFn,
@@ -493,7 +497,7 @@ fn action_rows(
/// fill deepens on hover. Unselected rows are transparent at rest, neutral on
/// hover. One accent, one signal per state — no blue label text (which fails AA
/// contrast on the near-white surface).
-fn menu_row(
+pub(crate) fn menu_row(
id: impl Into<gpui::ElementId>,
pal: Palette,
selected: bool,
@@ -520,7 +524,7 @@ fn menu_row(
}
/// Small uppercase muted group header.
-fn section_header(label: &str, pal: Palette) -> AnyElement {
+pub(crate) fn section_header(label: &str, pal: Palette) -> AnyElement {
div()
.w_full()
.px_2()
@@ -533,7 +537,7 @@ fn section_header(label: &str, pal: Palette) -> AnyElement {
}
/// Popover title — the binding context, e.g. "Bind Back".
-fn title(text: impl Into<gpui::SharedString>, pal: Palette) -> impl IntoElement {
+pub(crate) fn title(text: impl Into<gpui::SharedString>, pal: Palette) -> impl IntoElement {
div()
.px_2()
.pb_1()
@@ -543,12 +547,12 @@ fn title(text: impl Into<gpui::SharedString>, pal: Palette) -> impl IntoElement
}
/// 1px hairline separating the title from the list.
-fn divider(pal: Palette) -> impl IntoElement {
+pub(crate) fn divider(pal: Palette) -> impl IntoElement {
div().mb_1().h(px(1.)).w_full().bg(pal.border)
}
/// Wrap `rows` in the height-capped, vertically scrollable list region.
-fn scroll_list(id: &'static str, rows: Vec<AnyElement>) -> impl IntoElement {
+pub(crate) fn scroll_list(id: &'static str, rows: Vec<AnyElement>) -> impl IntoElement {
div()
.id(id)
.max_h(px(POPOVER_LIST_MAX_H))
diff --git a/crates/openlogi-gui/src/mouse_model/thumbwheel.rs b/crates/openlogi-gui/src/mouse_model/thumbwheel.rs
index 1ef52bb157bc132c3618fc82431bd5c89c575d5f..b96d4eb437699237d33005db38acfcdbcfdb43ed 100644
--- a/crates/openlogi-gui/src/mouse_model/thumbwheel.rs
+++ b/crates/openlogi-gui/src/mouse_model/thumbwheel.rs
@@ -54,10 +54,7 @@ impl ThumbwheelPreset {
Self::Volume => (Action::VolumeDown, Action::VolumeUp),
Self::CycleDpi => (Action::CycleDpiPresets, Action::CycleDpiPresets),
Self::VerticalScroll => (Action::ScrollDown, Action::ScrollUp),
- Self::HorizontalScroll => (
- Action::HorizontalScrollLeft,
- Action::HorizontalScrollRight,
- ),
+ Self::HorizontalScroll => (Action::HorizontalScrollLeft, Action::HorizontalScrollRight),
};
ThumbwheelPair { backward, forward }
}
@@ -121,10 +118,7 @@ mod tests {
(Action::VolumeDown, Action::VolumeUp),
(Action::CycleDpiPresets, Action::CycleDpiPresets),
(Action::ScrollDown, Action::ScrollUp),
- (
- Action::HorizontalScrollLeft,
- Action::HorizontalScrollRight,
- ),
+ (Action::HorizontalScrollLeft, Action::HorizontalScrollRight),
];
for (preset, (backward, forward)) in ThumbwheelPreset::ALL.into_iter().zip(expected) {
diff --git a/crates/openlogi-gui/src/mouse_model/view.rs b/crates/openlogi-gui/src/mouse_model/view.rs
index b53fb560606e8821d60bd402ec3591719bc4f10d..248a7df12ea95c8bad9d83f0f38002532b510ab6 100644
--- a/crates/openlogi-gui/src/mouse_model/view.rs
+++ b/crates/openlogi-gui/src/mouse_model/view.rs
@@ -11,8 +11,8 @@ use gpui_component::{Icon, IconName, Selectable, h_flex, popover::Popover, v_fle
use crate::app::{glow_canvas, keyboard_glow};
use crate::asset::{GlowGeometry, ResolvedAsset};
use crate::data::mouse_buttons::{
- Action, ButtonId, GestureDirection, Hotspot, MOUSE_MODEL_SIZE, MouseControlId,
- default_binding, default_hotspots,
+ Action, ButtonId, GestureDirection, Hotspot, MOUSE_MODEL_SIZE, MouseControlId, default_binding,
+ default_hotspots,
};
use crate::mouse_model::geometry::{
asset_dimensions_for_png, asset_has_button_labels, asset_hotspots_for_png, default_labels,
@@ -273,11 +273,7 @@ fn gesture_capable_buttons(labels: &[Label]) -> Vec<ButtonId> {
];
ORDER
.into_iter()
- .filter(|id| {
- labels
- .iter()
- .any(|label| label.id.button() == Some(*id))
- })
+ .filter(|id| labels.iter().any(|label| label.id.button() == Some(*id)))
.collect()
}
@@ -527,6 +523,7 @@ fn label_popover(
binding,
highlighted: highlighted || hovered == Some(label.id) || active == Some(label.id),
selected: false,
+ binding_popover,
view: view.clone(),
};
let popover: AnyElement = if label
@@ -547,8 +544,8 @@ fn label_popover(
let view_state = view.clone();
let view_content = view.clone();
Popover::new(("label-popover", idx))
- // The picker draws its own `menu_card` surface, matching the gesture
- // menu — so suppress the framework popover surface.
+ // `action_picker` draws its own `menu_card` surface, matching the
+ // gesture menu — so suppress the framework popover surface.
.appearance(false)
.anchor(Anchor::TopLeft)
.mouse_button(MouseButton::Left)
@@ -591,6 +588,7 @@ struct LabelTrigger {
binding: BindingLabel,
highlighted: bool,
selected: bool,
+ binding_popover: BindingPopover,
view: Entity<MouseModelView>,
}
@@ -611,6 +609,8 @@ impl RenderOnce for LabelTrigger {
let selected = self.selected;
let btn = self.label.id;
let view = self.view;
+ let view_click = view.clone();
+ let binding_popover = self.binding_popover;
let pal = theme::palette(cx);
let binding_color = if highlighted {
rgb(ACCENT_BLUE).into()
@@ -697,8 +697,12 @@ impl RenderOnce for LabelTrigger {
.text_color(pal.text_muted),
),
)
- // Popover itself owns trigger toggling on mouse-down. Adding an
- // on_click toggle here closes it again on mouse-up.
+ .on_click(move |_event, _window, cx| {
+ view_click.update(cx, |this, vcx| {
+ this.set_binding_popover_open(binding_popover, !selected);
+ vcx.notify();
+ });
+ })
.on_hover(move |hovered, _window, cx| {
let is_hovered = *hovered;
view.update(cx, |this, cx| {
@@ -718,7 +722,10 @@ fn binding_label_for_control(
bindings: &std::collections::BTreeMap<ButtonId, Action>,
gesture_owner: Option<ButtonId>,
) -> BindingLabel {
- if control.button().is_some_and(|button| gesture_owner == Some(button)) {
+ if control
+ .button()
+ .is_some_and(|button| gesture_owner == Some(button))
+ {
return BindingLabel {
text: tr!("5 directions"),
is_default: false,
@@ -835,6 +842,7 @@ fn hotspot_popover(
id: ("hotspot-trigger", idx).into(),
hotspot,
hovered: hovered == Some(hotspot.id) || active == Some(hotspot.id),
+ binding_popover,
view: view.clone(),
selected: false,
};
@@ -894,6 +902,7 @@ struct HotspotTrigger {
id: ElementId,
hotspot: Hotspot,
hovered: bool,
+ binding_popover: BindingPopover,
view: Entity<MouseModelView>,
selected: bool,
}
@@ -914,6 +923,8 @@ impl RenderOnce for HotspotTrigger {
let highlighted = self.hovered || self.selected;
let selected = self.selected;
let view = self.view;
+ let view_click = view.clone();
+ let binding_popover = self.binding_popover;
let hotspot = self.hotspot;
let btn = hotspot.id;
@@ -944,8 +955,12 @@ impl RenderOnce for HotspotTrigger {
hsla(0., 0., 0.18, 0.85)
}),
)
- // Popover itself owns trigger toggling on mouse-down. Adding an
- // on_click toggle here closes it again on mouse-up.
+ .on_click(move |_event, _window, cx| {
+ view_click.update(cx, |this, vcx| {
+ this.set_binding_popover_open(binding_popover, !selected);
+ vcx.notify();
+ });
+ })
.on_hover(move |hovered, _window, cx| {
let is_hovered = *hovered;
view.update(cx, |this, cx| {
diff --git a/crates/openlogi-gui/src/platform/permissions.rs b/crates/openlogi-gui/src/platform/permissions.rs
index 182db5c0766a62996c9dbfe0b219ad31826a0f73..2c6dd94327d5803d2cd8acaf861719a7094f0aab 100644
--- a/crates/openlogi-gui/src/platform/permissions.rs
+++ b/crates/openlogi-gui/src/platform/permissions.rs
@@ -47,6 +47,9 @@ pub enum Permission {
/// macOS: CoreBluetooth authorization.
#[cfg(target_os = "macos")]
Bluetooth,
+ /// macOS: Camera (AVFoundation) authorization for the webcam preview.
+ #[cfg(target_os = "macos")]
+ Camera,
}
/// Current Input Monitoring ("listen event") status.
@@ -63,6 +66,19 @@ pub fn bluetooth() -> PermissionStatus {
macos::bluetooth()
}
+/// Current Camera (AVFoundation) authorization status. Delegates to
+/// `openlogi-camera`, which owns all the camera FFI, so the GUI doesn't
+/// duplicate the AVFoundation calls.
+#[cfg(target_os = "macos")]
+#[must_use]
+pub fn camera() -> PermissionStatus {
+ match openlogi_camera::camera_authorization() {
+ openlogi_camera::CameraAuthorization::Granted => PermissionStatus::Granted,
+ openlogi_camera::CameraAuthorization::Denied => PermissionStatus::Denied,
+ openlogi_camera::CameraAuthorization::Undetermined => PermissionStatus::Unknown,
+ }
+}
+
/// Probe Linux input-device access: `/dev/uinput` (write) and at least one
/// Logitech `/dev/hidraw*` (read/write).
///
@@ -110,6 +126,7 @@ pub fn open_pane(permission: Permission) {
Permission::Accessibility => "Privacy_Accessibility",
Permission::InputMonitoring => "Privacy_ListenEvent",
Permission::Bluetooth => "Privacy_Bluetooth",
+ Permission::Camera => "Privacy_Camera",
};
let url = format!("x-apple.systempreferences:com.apple.preference.security?{anchor}");
if let Err(e) = opener::open(&url) {
diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs
index cec17081519156a379bfde0536f876541033e22b..3f62f0da76fb9dc4fead72c3777aadb10ca14643 100644
--- a/crates/openlogi-gui/src/state.rs
+++ b/crates/openlogi-gui/src/state.rs
@@ -11,22 +11,18 @@
use std::collections::BTreeMap;
-use gpui::{App, Global};
-use openlogi_core::config::{
- AppSettings, Appearance, AssetSourcePreference, Config, DeviceIdentity, Lighting,
-};
-use openlogi_core::device::{DeviceInventory, DeviceModelInfo};
-use openlogi_hid::{
- DeviceRoute, DpiCapabilities, DpiInfo, SmartShiftMode, SmartShiftStatus, WriteError,
-};
+use gpui::Global;
+use openlogi_core::config::{Config, KeyTrigger, LightSettings};
+use openlogi_core::device::{DeviceInventory, StandaloneDevice};
+use openlogi_hid::{DpiInfo, SmartShiftStatus};
use tokio::sync::mpsc;
-use tracing::{debug, warn};
-
-mod devices;
-mod load;
+use tracing::warn;
pub use devices::DeviceRecord;
-pub use load::{DpiStatus, Load, SmartShiftLoad};
+pub use light::LightCommandStatus;
+#[cfg(test)]
+pub use load::Load;
+pub use load::{DpiStatus, SmartShiftLoad};
/// Result of confirming a SmartShift write by reading the value back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -44,18 +40,29 @@ pub enum SmartShiftWriteStatus {
Failed,
}
+pub(crate) use devices::camera_model_info;
+use light::PendingLightCommand;
use load::LazyDeviceData;
use crate::asset::AssetResolver;
use crate::data::mouse_buttons::{Action, ButtonId, GestureDirection};
-use openlogi_core::binding::Binding;
-use crate::mouse_model::thumbwheel::{ThumbwheelPair, ThumbwheelPreset};
-use crate::state::devices::{
- adopt_transient_record, build_device_list, direct_key_prefix, pick_initial_device,
- sort_device_list,
-};
-use openlogi_agent_core::bindings::{bindings_for, gesture_bindings_for};
-use openlogi_agent_core::device_order::PhysicalDeviceKey;
+use crate::state::devices::{build_device_list, pick_initial_device};
+
+mod agent;
+mod bindings;
+mod camera;
+mod devices;
+mod dpi;
+mod inventory;
+mod light;
+mod lighting;
+mod load;
+mod scroll;
+mod settings;
+mod smartshift;
+
+#[cfg(test)]
+mod tests;
/// Default DPI value applied to a fresh AppState. Matches a common Logitech
/// mid-range mouse and keeps the dot-preview visually obvious from frame one.
@@ -85,6 +92,19 @@ pub enum AgentLink {
Ready(openlogi_agent_core::ipc::AgentStatus),
}
+/// Where [`AppState`] may persist configuration mutations.
+///
+/// Runtime state uses [`Self::UserFile`]. Tests opt into
+/// [`Self::MemoryOnly`] so realistic device fixtures can never modify the
+/// developer's actual `config.toml`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ConfigPersistence {
+ /// Persist to OpenLogi's default per-user configuration file.
+ UserFile,
+ /// Keep changes in the in-memory [`Config`] only.
+ MemoryOnly,
+}
+
/// Inventory snapshots can briefly miss a real device while another HID++
/// request is in flight. Keep the previous record through this many
/// consecutive misses so a transient probe timeout does not make the carousel
@@ -100,6 +120,17 @@ pub struct AppState {
/// non-macOS / no frontmost app. Used to overlay per-app bindings on
/// top of the per-device global map.
pub current_app_bundle: Option<String>,
+ /// Aggregate host-camera activity reported by the agent. Runtime only.
+ camera_active: bool,
+ /// Transient manual power choices for camera-linked lights. Cleared on
+ /// the next camera-state transition and never persisted as an override.
+ manual_light_overrides: BTreeMap<String, bool>,
+ /// Session-only settings for raw devices whose OS-node identity is not
+ /// stable enough to persist in `config.toml`.
+ volatile_light_settings: BTreeMap<String, LightSettings>,
+ light_commands: BTreeMap<String, PendingLightCommand>,
+ light_command_status: Option<(String, u64, LightCommandStatus)>,
+ next_light_request_id: u64,
/// The hotspot the user most recently armed by clicking. Drives the
/// "selected button" outline on the mouse model and the popover content.
pub active_button: Option<ButtonId>,
@@ -119,6 +150,12 @@ pub struct AppState {
///
/// [`DeviceConfig::bindings`]: openlogi_core::config::DeviceConfig::bindings
pub gesture_bindings: BTreeMap<GestureDirection, Action>,
+ /// Global keyboard F-key bindings (Esc + F1-F19). Device-agnostic — one
+ /// map applies across all keyboards — so, unlike [`Self::button_bindings`],
+ /// this is *not* reloaded on device switch. Seeded once from
+ /// [`Config::keyboard`] and kept in sync via [`Self::commit_keyboard_binding`].
+ /// Sorted (`BTreeMap`) for stable render order in the function-row view.
+ pub keyboard_bindings: BTreeMap<KeyTrigger, Action>,
pub dpi: u32,
/// DPI capability load state keyed by [`DeviceRecord::config_key`]. Loaded
/// lazily because HID++ reads must not block device switching or rendering.
@@ -155,6 +192,8 @@ pub struct AppState {
/// rebuild, and "apply now" device changes (DPI / SmartShift / lighting)
/// go out as their own commands. The GUI never opens a device itself.
ipc_commands: mpsc::UnboundedSender<crate::ipc_client::Command>,
+ /// Explicit persistence boundary; tests use an in-memory-only state.
+ config_persistence: ConfigPersistence,
/// Raw inventory from the last *completed* enumeration, kept for the
/// diagnostics report (receivers + transports). The poll path only stores
/// [`InventoryHealth::Ready`](openlogi_agent_core::ipc::InventoryHealth)
@@ -184,16 +223,25 @@ impl AppState {
pub fn with_runtime(
mut config: Config,
inventories: &[DeviceInventory],
+ standalone: &[StandaloneDevice],
cache: &AssetResolver,
+ cameras: &[openlogi_camera::Camera],
+ config_persistence: ConfigPersistence,
ipc_commands: mpsc::UnboundedSender<crate::ipc_client::Command>,
) -> Self {
- let device_list = build_device_list(inventories, cache, &config);
+ let device_list = build_device_list(inventories, standalone, cache, &config, cameras);
// Record any device probed at launch so it survives the next cold start.
- persist_identities(&mut config, &device_list);
+ let identities_changed = inventory::persist_identities(&mut config, &device_list);
let current_device = pick_initial_device(&device_list, config.selected_device());
let mut state = Self {
current_device,
current_app_bundle: None,
+ camera_active: false,
+ manual_light_overrides: BTreeMap::new(),
+ volatile_light_settings: BTreeMap::new(),
+ light_commands: BTreeMap::new(),
+ light_command_status: None,
+ next_light_request_id: 0,
active_button: None,
// Updated from the agent's IPC poll; the GUI no longer runs the
// hook, so it can't meaningfully query Accessibility (or devices)
@@ -201,6 +249,7 @@ impl AppState {
agent_link: AgentLink::Connecting,
button_bindings: BTreeMap::new(),
gesture_bindings: BTreeMap::new(),
+ keyboard_bindings: BTreeMap::new(),
dpi: DEFAULT_DPI,
dpi_data: LazyDeviceData::default(),
inventory_misses: BTreeMap::new(),
@@ -211,25 +260,38 @@ impl AppState {
device_list,
config,
ipc_commands,
+ config_persistence,
last_inventory: Vec::new(),
#[cfg(all(target_os = "macos", debug_assertions))]
monitor_events: std::collections::VecDeque::new(),
#[cfg(all(target_os = "macos", debug_assertions))]
event_taps: Vec::new(),
};
+ if identities_changed {
+ state.persist_config("device identity");
+ }
state.button_bindings = state.bindings_for_current();
state.gesture_bindings = state.gesture_bindings_for_current();
+ // Keyboard bindings are global, so they seed straight from the config
+ // map — no per-device resolution like mouse bindings above.
+ state.keyboard_bindings = state
+ .config
+ .keyboard
+ .bindings
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
state
}
-
/// Send a device command to the agent over IPC, logging a dropped channel
/// (the client thread is gone) rather than surfacing it.
- fn send_ipc(&self, command: crate::ipc_client::Command) {
+ fn send_ipc(&self, command: crate::ipc_client::Command) -> bool {
if self.ipc_commands.send(command).is_err() {
warn!("IPC client thread is gone — device command dropped");
+ return false;
}
+ true
}
-
/// Persist the in-memory config and — only if the write actually landed —
/// have the agent reload it. `what` names the setting for the failure log.
///
@@ -239,1735 +301,48 @@ impl AppState {
/// next reconnect or wake. Skipping the reload keeps the agent on whatever
/// it already runs; the GUI keeps the new value in memory either way.
fn persist_and_reload(&self, what: &str) {
+ if self.persist_config(what) {
+ self.send_ipc(crate::ipc_client::Command::ReloadConfig);
+ }
+ }
+ fn persist_config(&self, what: &str) -> bool {
+ if self.config_persistence == ConfigPersistence::MemoryOnly {
+ return true;
+ }
if let Err(e) = self.config.save_atomic() {
- warn!(error = %e, what, "could not persist to config.toml — agent reload skipped");
- return;
+ warn!(error = %e, what, "could not persist to config.toml");
+ return false;
}
- self.send_ipc(crate::ipc_client::Command::ReloadConfig);
+ true
}
-
/// A clone of the IPC command sender, so views (the DPI / SmartShift panels)
/// can issue device reads and writes through the agent themselves.
#[must_use]
pub fn ipc_sender(&self) -> mpsc::UnboundedSender<crate::ipc_client::Command> {
self.ipc_commands.clone()
}
-
/// Cache a *completed* inventory snapshot for the diagnostics report.
/// Callers gate on [`InventoryHealth::Ready`](openlogi_agent_core::ipc::InventoryHealth) —
/// see [`Self::last_inventory`].
pub fn store_inventory_snapshot(&mut self, inventory: &[DeviceInventory]) {
self.last_inventory = inventory.to_vec();
}
-
/// The last completed inventory snapshot, used by diagnostics for transports and receivers.
#[must_use]
pub fn last_inventory(&self) -> &[DeviceInventory] {
&self.last_inventory
}
-
- /// Append a batch of live-monitor events, capping the retained history so the
- /// buffer can't grow without bound while the monitor is open.
- #[cfg(all(target_os = "macos", debug_assertions))]
- pub fn push_monitor_events(&mut self, events: Vec<openlogi_agent_core::ipc::MonitorEvent>) {
- const MAX: usize = 200;
- self.monitor_events.extend(events);
- let overflow = self.monitor_events.len().saturating_sub(MAX);
- self.monitor_events.drain(..overflow);
- }
-
- /// Recent live-monitor events, oldest first.
- #[cfg(all(target_os = "macos", debug_assertions))]
- #[must_use]
- pub fn monitor_events(
- &self,
- ) -> &std::collections::VecDeque<openlogi_agent_core::ipc::MonitorEvent> {
- &self.monitor_events
- }
-
- /// Replace the cached event-tap snapshot the Diagnostics page renders.
- /// Refreshed on the live-monitor poll tick; see [`Self::event_taps`].
- #[cfg(all(target_os = "macos", debug_assertions))]
- pub fn set_event_taps(&mut self, taps: Vec<openlogi_hook::EventTapInfo>) {
- self.event_taps = taps;
- }
-
- /// The cached event-tap snapshot for the Diagnostics page.
- #[cfg(all(target_os = "macos", debug_assertions))]
- #[must_use]
- pub fn event_taps(&self) -> &[openlogi_hook::EventTapInfo] {
- &self.event_taps
- }
-
/// Config schema version and the number of devices with saved configuration.
#[must_use]
pub fn config_summary(&self) -> (u32, usize) {
(self.config.schema_version, self.config.devices.len())
}
-
- /// The cached DPI-discovery status for `key`, for the diagnostics report.
- #[must_use]
- pub fn dpi_status_for(&self, key: &str) -> Option<DpiStatus> {
- self.dpi_data.get(key).cloned()
- }
-
- /// Ask the agent to fire the macOS Accessibility prompt. The agent owns the
- /// CGEventTap, so the system dialog must name and authorize the *agent*
- /// binary; prompting in the GUI process (as the pre-split build did) would
- /// grant the wrong binary and the hook would never install.
- pub fn request_accessibility_prompt(&self) {
- self.send_ipc(crate::ipc_client::Command::RequestAccessibilityPrompt);
- }
-
/// The active device, or `None` when [`Self::device_list`] is empty or
/// `current_device` is past the end.
#[must_use]
pub fn current_record(&self) -> Option<&DeviceRecord> {
self.device_list.get(self.current_device)
}
-
- /// Every known device model that can be resolved to an asset depot.
- ///
- /// This reads the UI's merged device list rather than only the latest live
- /// inventory, so a temporarily incomplete probe can still download art for
- /// a device restored from its persisted identity.
- pub(crate) fn asset_models(&self) -> Vec<(DeviceModelInfo, Option<String>)> {
- self.device_list
- .iter()
- .filter_map(|record| {
- record
- .model_info
- .clone()
- .map(|model| (model, record.codename.clone()))
- })
- .collect()
- }
-
- /// The agent connection state the render path branches on.
- #[must_use]
- pub fn agent_link(&self) -> &AgentLink {
- &self.agent_link
- }
-
- /// The latest agent status snapshot — `None` while not connected (any
- /// non-[`AgentLink::Ready`] state), which readers like the Settings
- /// permission rows surface as "unknown", not "denied".
- #[must_use]
- pub fn agent_status(&self) -> Option<&openlogi_agent_core::ipc::AgentStatus> {
- match &self.agent_link {
- AgentLink::Ready(status) => Some(status),
- _ => None,
- }
- }
-
- /// Replace the link, reporting whether it actually changed — the steady
- /// IPC poll mostly delivers identical snapshots, and the caller skips the
- /// window refresh for those.
- pub fn set_agent_link(&mut self, link: AgentLink) -> bool {
- if self.agent_link == link {
- return false;
- }
- self.agent_link = link;
- true
- }
-
- /// Replace [`Self::device_list`] from a fresh inventory snapshot,
- /// preserving the carousel selection by `config_key` when possible. If
- /// the previously-selected device disappeared, the selection falls back
- /// to index 0. Returns whether anything actually changed.
- ///
- /// No-op (returning `false`) when the new list has the same `config_key`
- /// sequence as the current one — the caller skips the window refresh, and
- /// quiet polling cycles cause no spurious re-renders (P1.6). `force`
- /// pushes through that early-return: the records embed resolved asset
- /// paths, so a completed asset sync needs one rebuild even though the
- /// device *set* is unchanged.
- pub fn refresh_inventories(
- &mut self,
- inventories: &[DeviceInventory],
- cache: &AssetResolver,
- force: bool,
- ) -> bool {
- let new_list = build_device_list(inventories, cache, &self.config);
- let merged_list = self.merge_inventory_snapshot(new_list);
- // Capture any newly-probed identity before the unchanged-check can early
- // out: a device whose capabilities just resolved keeps the same
- // config_key + route, so that guard would otherwise skip the write.
- persist_identities(&mut self.config, &merged_list);
- // Compare more than config_key: a device can reconnect on a new HID++
- // index while keeping its physical config key, and the fresh route must
- // replace the stale one so reads/writes don't target a dead index.
- // `online` and `capabilities` are compared too, so a device waking up or
- // a probe that resolves its feature table on a stable route still
- // refreshes the carousel (and its config panels) instead of being
- // swallowed by this guard.
- let unchanged = merged_list.len() == self.device_list.len()
- && merged_list
- .iter()
- .zip(self.device_list.iter())
- .all(|(a, b)| {
- a.config_key == b.config_key
- && a.route == b.route
- && a.online == b.online
- && a.capabilities == b.capabilities
- });
- if unchanged && !force {
- return false;
- }
-
- let previous_key = self.current_record().map(|r| r.config_key.clone());
- let new_index = previous_key
- .as_deref()
- .and_then(|k| merged_list.iter().position(|r| r.config_key == k))
- .unwrap_or(0);
- let connected_keys = merged_list
- .iter()
- .map(|r| r.config_key.as_str())
- .collect::<Vec<_>>();
- debug!(
- count = merged_list.len(),
- ?connected_keys,
- "inventory refreshed"
- );
-
- // A device that came back on a different route must re-discover DPI —
- // its cached status/attempts were keyed to the now-dead route.
- let rerouted: Vec<String> = merged_list
- .iter()
- .filter(|new| {
- self.device_list
- .iter()
- .any(|old| old.config_key == new.config_key && old.route != new.route)
- })
- .map(|new| new.config_key.clone())
- .collect();
-
- self.device_list = merged_list;
- for key in &rerouted {
- self.dpi_data.remove(key);
- self.smartshift_data.remove(key);
- self.smartshift_pending_confirm.remove(key);
- self.smartshift_write_status.remove(key);
- }
- let present = |key: &str| {
- self.device_list
- .iter()
- .any(|r| r.config_key.as_str() == key)
- };
- self.dpi_data.retain_present(present);
- self.smartshift_data.retain_present(present);
- self.smartshift_pending_confirm
- .retain(|key, _| present(key));
- self.smartshift_write_status.retain(|key, _| present(key));
- self.current_device = new_index;
- // The active device may have changed (selection fell back to index 0
- // when the previous one vanished); re-seed the displayed DPI so it
- // tracks the now-current device rather than the old one.
- self.dpi = self.dpi_for_current();
- self.button_bindings = self.bindings_for_current();
- self.gesture_bindings = self.gesture_bindings_for_current();
- // Display state only — the agent runs its own inventory watcher and
- // rebuilds the live binding/DPI maps itself.
- true
- }
-
- fn merge_inventory_snapshot(&mut self, new_list: Vec<DeviceRecord>) -> Vec<DeviceRecord> {
- let mut by_key = new_list
- .into_iter()
- .map(|record| (record.config_key.clone(), record))
- .collect::<BTreeMap<_, _>>();
- let mut adopted = self.adopt_transient_records(&mut by_key);
- let mut merged = Vec::with_capacity(by_key.len().max(self.device_list.len()));
-
- for previous in &self.device_list {
- if let Some(record) = by_key.remove(&previous.config_key) {
- self.inventory_misses.remove(&previous.config_key);
- merged.push(record);
- continue;
- }
-
- if let Some(record) = adopted.remove(&previous.config_key) {
- self.inventory_misses.remove(&previous.config_key);
- merged.push(record);
- continue;
- }
-
- // An all-zero direct unit id is only a transient probe result. If
- // the next snapshot resolves a physical serial/unit key, retaining
- // this record through the normal miss grace would show both cards.
- if !previous.is_persistent() {
- self.inventory_misses.remove(&previous.config_key);
- continue;
- }
-
- let misses = self
- .inventory_misses
- .entry(previous.config_key.clone())
- .or_insert(0);
- *misses = misses.saturating_add(1);
- if *misses <= INVENTORY_MISS_GRACE {
- debug!(
- key = %previous.config_key,
- misses = *misses,
- "keeping device through transient inventory miss"
- );
- merged.push(previous.clone());
- }
- }
-
- for (key, record) in by_key {
- self.inventory_misses.remove(&key);
- merged.push(record);
- }
- // Adopted records whose known card was never in the previous list
- // (identity known only from config) still belong in the carousel.
- merged.extend(adopted.into_values());
- self.inventory_misses
- .retain(|key, _| merged.iter().any(|record| record.config_key == *key));
- // `merged` is `previous-order + newly-appeared`, so re-apply the
- // canonical route order or a new device would be stuck at the end of
- // the carousel permanently.
- sort_device_list(&mut merged);
- merged
- }
-
- /// Pair each transient direct record in the snapshot with the device it
- /// physically is. A transient key (`…:unit:00000000`) is a half-read probe
- /// of some existing device, not a new one (#482): when exactly one known
- /// card sharing its `direct:<vid>:<pid>` wire identity is not live online —
- /// so the half-read probe can only be that device — the transient record is
- /// folded into that card instead of surfacing beside it (or evicting it).
- /// With no such card the transient is dropped as probe noise when its wire
- /// product is already live online, and an ambiguous one (two known
- /// same-model cards absent) is left alone.
- fn adopt_transient_records(
- &self,
- by_key: &mut BTreeMap<String, DeviceRecord>,
- ) -> BTreeMap<String, DeviceRecord> {
- let transient_keys: Vec<String> = by_key
- .values()
- .filter(|record| !record.is_persistent())
- .map(|record| record.config_key.clone())
- .collect();
- let mut adopted = BTreeMap::new();
- for key in transient_keys {
- let Some(prefix) = direct_key_prefix(&key) else {
- continue;
- };
- let same_wire = |key: &str, record: &DeviceRecord| {
- record.is_persistent() && direct_key_prefix(key) == Some(prefix)
- };
- // A live online sibling is accounted for and never a candidate,
- // but it must not discard the transient — the half-read probe may
- // be the *other* same-model device.
- let mut candidates: Vec<String> = by_key
- .iter()
- .filter(|(k, record)| same_wire(k, record) && !record.online)
- .map(|(k, _)| k.clone())
- .collect();
- for previous in &self.device_list {
- if same_wire(&previous.config_key, previous)
- && !by_key.contains_key(&previous.config_key)
- && !candidates.contains(&previous.config_key)
- {
- candidates.push(previous.config_key.clone());
- }
- }
- let [known_key] = candidates.as_slice() else {
- if candidates.is_empty()
- && by_key
- .iter()
- .any(|(k, record)| same_wire(k, record) && record.online)
- {
- by_key.remove(&key);
- }
- continue;
- };
- // Last tick's record carries the freshest identity; the offline
- // placeholder built from config is the fallback.
- let known = self
- .device_list
- .iter()
- .find(|record| record.config_key == *known_key)
- .cloned()
- .or_else(|| by_key.get(known_key).cloned());
- let Some(known) = known else {
- continue;
- };
- let known_key = known_key.clone();
- by_key.remove(&known_key);
- if let Some(live) = by_key.remove(&key) {
- adopted.insert(known_key, adopt_transient_record(&known, live));
- }
- }
- adopted
- }
-
- /// Switch the carousel to `idx`. Out-of-range indices are silently
- /// ignored so callers can pass them straight through from UI events.
- /// Persists the new selection (by config key, not index — index isn't
- /// stable across restarts), reloads bindings for the new device, and
- /// pushes the new map into the hook-shared `Arc`.
- pub fn set_current_device(&mut self, idx: usize) {
- if idx >= self.device_list.len() || idx == self.current_device {
- return;
- }
- self.current_device = idx;
- // A device left in `Failed` (transient read errors exhausted its retry
- // budget) gets one fresh attempt each time it is re-selected.
- if let Some(key) = self.current_record().map(|r| r.config_key.clone()) {
- if matches!(self.dpi_data.get(&key), Some(Load::Failed(_))) {
- self.dpi_data.retry(&key);
- }
- if matches!(self.smartshift_data.get(&key), Some(Load::Failed(_))) {
- self.smartshift_data.retry(&key);
- self.smartshift_write_status.remove(&key);
- }
- }
- // `self.dpi` is the active device's value; adopt the newly-selected
- // device's known DPI so the panel doesn't keep showing the previous
- // device's number until a fresh read lands.
- self.dpi = self.dpi_for_current();
- self.button_bindings = self.bindings_for_current();
- self.gesture_bindings = self.gesture_bindings_for_current();
- let Some(key) = self
- .current_record()
- .and_then(DeviceRecord::persistent_config_key)
- .map(str::to_string)
- else {
- debug!("transient device selection not persisted");
- return;
- };
- self.config.set_selected_device(Some(key));
- // The agent owns the hook + device I/O; have it switch devices too.
- self.persist_and_reload("selected device");
- }
-
- /// Replace the DPI preset list for the currently selected device. The
- /// new list is persisted to `config.toml` and pushed into the shared
- /// hook map so the next `CycleDpiPresets` press sees it. The cycle
- /// `index` is reset to 0 — the user just rebuilt the list, the old
- /// index is meaningless.
- ///
- /// No-op when no device is selected (binding panel won't expose the
- /// editor in that state).
- pub fn commit_dpi_presets(&mut self, presets: Vec<u32>) {
- let Some(key) = self
- .current_record()
- .and_then(DeviceRecord::persistent_config_key)
- .map(str::to_string)
- else {
- debug!("no persistent device key — DPI presets kept in memory only");
- return;
- };
- self.config.set_dpi_presets(&key, presets);
- self.persist_and_reload("DPI presets");
- }
-
- /// Read the DPI preset list for the active device, or an empty `Vec`
- /// when no device is selected. UI helper.
- #[must_use]
- pub fn dpi_presets(&self) -> Vec<u32> {
- self.current_record()
- .and_then(DeviceRecord::persistent_config_key)
- .map(|key| self.config.dpi_presets(key))
- .unwrap_or_default()
- }
-
- /// DPI capability status for the active device.
- #[must_use]
- pub fn current_dpi_status(&self) -> DpiStatus {
- self.current_record().map_or(DpiStatus::Unknown, |record| {
- self.dpi_data.status(&record.config_key)
- })
- }
-
- /// Whether the active device still needs a DPI read (no status recorded —
- /// i.e. `Unknown`). Cheaper than `current_dpi_status() == Unknown`: it
- /// avoids cloning the `DpiInfo`, which matters on the per-frame render path.
- #[must_use]
- pub fn current_dpi_unqueried(&self) -> bool {
- self.current_record()
- .is_some_and(|record| self.dpi_data.unqueried(&record.config_key))
- }
-
- /// The active device's known DPI, falling back to [`DEFAULT_DPI`] until its
- /// capability read completes. Used to seed `self.dpi` on a device switch.
- #[must_use]
- fn dpi_for_current(&self) -> u32 {
- self.current_record()
- .and_then(|record| self.dpi_data.get(&record.config_key))
- .and_then(|status| match status {
- DpiStatus::Ready(info) => Some(u32::from(info.current)),
- _ => None,
- })
- .unwrap_or(DEFAULT_DPI)
- }
-
- /// Mark DPI capability discovery as in flight for `key`.
- pub fn mark_dpi_loading(&mut self, key: &str) {
- self.dpi_data.mark_loading(key);
- }
-
- /// Reset a stuck `Loading` for `key` back to `Unknown`. Called when the
- /// discovery worker vanished without delivering a result (e.g. it panicked),
- /// so the device isn't wedged on "Reading…" with no path to retry.
- pub fn clear_dpi_loading(&mut self, key: &str) {
- self.dpi_data.clear_loading(key);
- }
-
- /// Drop the active device's recorded DPI status so the next render
- /// re-runs discovery. Backs the "click to retry" affordance on a
- /// [`DpiStatus::Failed`] device, which is the only recovery path when the
- /// carousel has a single device (re-selecting it is a no-op).
- pub fn retry_active_dpi(&mut self) {
- if let Some(key) = self.current_record().map(|r| r.config_key.clone()) {
- self.dpi_data.retry(&key);
- }
- }
-
- /// Store a DPI capability discovery result if it still matches the known
- /// device route. This guards against async reads completing after the
- /// carousel or inventory changed.
- pub fn store_dpi_info(
- &mut self,
- key: String,
- route: &DeviceRoute,
- result: Result<DpiInfo, WriteError>,
- ) {
- let is_active = self.current_record().map(|r| r.config_key.as_str()) == Some(key.as_str());
- let matches_route = self
- .device_list
- .iter()
- .any(|record| record.config_key == key && record.route.as_ref() == Some(route));
- let still_present = self
- .device_list
- .iter()
- .any(|record| record.config_key == key);
- // Only the active device owns the shared `self.dpi`; a result landing for
- // a background device after a carousel switch must not clobber the
- // visible value.
- if let Some(info) = self.dpi_data.store(
- key,
- result,
- dpi_error_is_permanent,
- matches_route,
- still_present,
- "DPI",
- ) && is_active
- {
- self.dpi = u32::from(info.current);
- }
- }
-
- /// DPI capabilities for the active device, if discovery succeeded.
- #[must_use]
- pub fn active_dpi_capabilities(&self) -> Option<&DpiCapabilities> {
- self.current_record()
- .and_then(|record| self.dpi_data.get(&record.config_key))
- .and_then(|status| match status {
- DpiStatus::Ready(info) => Some(&info.capabilities),
- DpiStatus::Unknown
- | DpiStatus::Loading
- | DpiStatus::Failed(_)
- | DpiStatus::Unsupported(_) => None,
- })
- }
-
- /// Snap `dpi` to the active device's supported list when known.
- #[must_use]
- pub fn normalize_active_dpi(&self, dpi: u32) -> u32 {
- self.active_dpi_capabilities()
- .map_or(dpi, |caps| caps.snap(dpi))
- }
-
- /// SmartShift configuration status for the active device.
- #[must_use]
- pub fn current_smartshift_status(&self) -> SmartShiftLoad {
- self.current_record()
- .map_or(SmartShiftLoad::Unknown, |record| {
- self.smartshift_data.status(&record.config_key)
- })
- }
-
- /// Whether the active device still needs a SmartShift read (no status
- /// recorded). Cheaper than comparing a cloned [`SmartShiftLoad`] on the
- /// per-frame render path.
- #[must_use]
- pub fn current_smartshift_unqueried(&self) -> bool {
- self.current_record()
- .is_some_and(|record| self.smartshift_data.unqueried(&record.config_key))
- }
-
- /// The active device's resolved SmartShift config, if the read succeeded.
- /// Callers use it to preserve fields they don't mean to change (e.g.
- /// tunable torque) when writing back.
- #[must_use]
- pub fn current_smartshift_ready(&self) -> Option<SmartShiftStatus> {
- self.current_record()
- .and_then(|record| self.smartshift_data.get(&record.config_key))
- .and_then(|status| match status {
- SmartShiftLoad::Ready(s) => Some(*s),
- SmartShiftLoad::Unknown
- | SmartShiftLoad::Loading
- | SmartShiftLoad::Failed(_)
- | SmartShiftLoad::Unsupported(_) => None,
- })
- }
-
- /// Post-write confirmation status for the active device.
- #[must_use]
- pub fn current_smartshift_write_status(&self) -> Option<SmartShiftWriteStatus> {
- self.current_record().and_then(|record| {
- self.smartshift_write_status
- .get(&record.config_key)
- .copied()
- })
- }
-
- /// Mark SmartShift discovery as in flight for `key`.
- pub fn mark_smartshift_loading(&mut self, key: &str) {
- self.smartshift_data.mark_loading(key);
- }
-
- /// Reset a stuck `Loading` for `key` back to `Unknown` — called when the
- /// read worker vanished without delivering a result.
- pub fn clear_smartshift_loading(&mut self, key: &str) {
- self.smartshift_data.clear_loading(key);
- }
-
- /// Drop the active device's recorded SmartShift status so the next render
- /// re-runs discovery. Backs the "click to retry" affordance on a
- /// [`SmartShiftLoad::Failed`] device.
- pub fn retry_active_smartshift(&mut self) {
- if let Some(key) = self.current_record().map(|r| r.config_key.clone()) {
- self.smartshift_data.retry(&key);
- self.smartshift_write_status.remove(&key);
- }
- }
-
- /// Store a SmartShift read result if it still matches the known device
- /// route and write identity, with the same transient-retry /
- /// permanent-unsupported handling as [`Self::store_dpi_info`].
- pub fn store_smartshift_status(
- &mut self,
- key: String,
- route: &DeviceRoute,
- write_id: Option<u64>,
- result: Result<SmartShiftStatus, WriteError>,
- ) {
- if !smartshift_read_is_current(write_id, self.smartshift_write_status.get(&key)) {
- debug!(key, ?write_id, "stale SmartShift read result ignored");
- return;
- }
- let matches_route = self
- .device_list
- .iter()
- .any(|record| record.config_key == key && record.route.as_ref() == Some(route));
- let still_present = self
- .device_list
- .iter()
- .any(|record| record.config_key == key);
- let status_key = key.clone();
- self.smartshift_data.store(
- key,
- result,
- smartshift_error_is_permanent,
- matches_route,
- still_present,
- "SmartShift",
- );
- let expected = match self.smartshift_write_status.get(&status_key) {
- Some(SmartShiftWriteStatus::Applying { expected, .. }) => Some(*expected),
- Some(SmartShiftWriteStatus::Confirmed | SmartShiftWriteStatus::Failed) | None => None,
- };
- if let Some(status) = expected.and_then(|expected| {
- smartshift_write_outcome(expected, self.smartshift_data.get(&status_key))
- }) {
- self.smartshift_write_status.insert(status_key, status);
- }
- }
-
- /// Write a full SmartShift configuration to the active device (best-effort,
- /// on a background thread), optimistically cache it, and persist it to
- /// `config.toml` — the values live in device RAM and reset on a power
- /// cycle (#189), so the agent re-applies them when the device reconnects.
- /// No-op when no device is selected.
- pub fn commit_smartshift(
- &mut self,
- mode: SmartShiftMode,
- auto_disengage: u8,
- tunable_torque: u8,
- ) {
- let Some(record) = self.current_record() else {
- debug!("no active device — SmartShift change ignored");
- return;
- };
- let key = record.config_key.clone();
- let persistent_key = record.persistent_config_key().map(str::to_string);
- let route = record.route.clone();
- let can_confirm = route.is_some();
- if let Some(route) = route {
- self.send_ipc(crate::ipc_client::Command::SetSmartShift(
- route,
- mode,
- auto_disengage,
- tunable_torque,
- ));
- }
- if let Some(persistent_key) = persistent_key {
- self.config.set_smartshift(
- &persistent_key,
- openlogi_core::config::SmartShift {
- mode: mode.into(),
- auto_disengage,
- tunable_torque,
- },
- );
- self.persist_and_reload("SmartShift");
- }
- // Reflect the write immediately so the panel doesn't flicker back to
- // the previous value before a re-read lands, but queue a confirming
- // re-read: the write is fire-and-forget, so a sleeping device that
- // rejected or timed it out would otherwise leave this optimistic value
- // showing as "applied" forever (Ready blocks any further read).
- let expected = SmartShiftStatus {
- mode,
- auto_disengage,
- tunable_torque,
- };
- self.smartshift_data.set_ready(key.clone(), expected);
- let write_id = can_confirm.then(|| {
- let write_id = self.next_smartshift_write_id;
- self.next_smartshift_write_id = self.next_smartshift_write_id.saturating_add(1);
- self.smartshift_pending_confirm
- .insert(key.clone(), write_id);
- write_id
- });
- self.smartshift_write_status.insert(
- key,
- match write_id {
- Some(write_id) => SmartShiftWriteStatus::Applying { expected, write_id },
- None => SmartShiftWriteStatus::Failed,
- },
- );
- }
-
- /// Whether the active device's scroll wheel is inverted (issue #126).
- /// `false` when no device is selected or the device hasn't opted in.
- #[must_use]
- pub fn current_invert_scroll(&self) -> bool {
- self.current_record()
- .and_then(DeviceRecord::persistent_config_key)
- .is_some_and(|key| self.config.invert_scroll(key))
- }
-
- /// Whether the active device reports native HID++ wheel inversion support.
- #[must_use]
- pub fn current_scroll_inversion_supported(&self) -> bool {
- self.current_record()
- .and_then(|record| record.capabilities)
- .is_some_and(|capabilities| capabilities.scroll_inversion)
- }
-
- /// Set the active device's scroll-wheel inversion, persist it, and reload
- /// the agent so it writes the device's native HID++ wheel inversion. No-op
- /// when no device is selected or the active device does not report support.
- pub fn commit_invert_scroll(&mut self, invert: bool) {
- if !self.current_scroll_inversion_supported() {
- debug!("active device does not support native scroll inversion");
- return;
- }
- let Some(key) = self
- .current_record()
- .and_then(DeviceRecord::persistent_config_key)
- .map(str::to_string)
- else {
- debug!("no persistent device key — invert-scroll change ignored");
- return;
- };
- self.config.set_invert_scroll(&key, invert);
- self.persist_and_reload("invert scroll");
- }
-
- /// The active device's persisted wheel resolution, or `None` when OpenLogi
- /// leaves the device default untouched.
- #[must_use]
- pub fn current_scroll_resolution(&self) -> Option<openlogi_core::config::ScrollResolution> {
- self.current_record()
- .and_then(DeviceRecord::persistent_config_key)
- .and_then(|key| self.config.scroll_resolution(key))
- }
-
- /// Whether the active device exposes HID++ `0x2121 HiResWheel`.
- #[must_use]
- pub fn current_hires_wheel_supported(&self) -> bool {
- self.current_record()
- .and_then(|record| record.capabilities)
- .is_some_and(|capabilities| capabilities.hires_wheel)
- }
-
- /// Persist the active device's wheel resolution and ask the agent to reload
- /// it. `None` removes OpenLogi's override. No-op without a selected,
- /// HiResWheel-capable device.
- pub fn commit_scroll_resolution(
- &mut self,
- resolution: Option<openlogi_core::config::ScrollResolution>,
- ) {
- let Some((key, supported)) = self.current_record().and_then(|record| {
- let key = record.persistent_config_key()?.to_string();
- Some((
- key,
- record
- .capabilities
- .is_some_and(|capabilities| capabilities.hires_wheel),
- ))
- }) else {
- debug!("no persistent device key — wheel-resolution change ignored");
- return;
- };
- if !set_scroll_resolution_if_supported(&mut self.config, &key, supported, resolution) {
- debug!("active device does not support HiResWheel");
- return;
- }
- self.persist_and_reload("wheel resolution");
- }
-
- /// Take the active device's pending SmartShift confirm, if any. Returns the
- /// `(config_key, route, write_id)` for a one-shot re-read that replaces the
- /// optimistic value with the device's real state; consumed once so it
- /// doesn't re-fire.
- pub fn take_active_smartshift_confirm(&mut self) -> Option<(String, DeviceRoute, u64)> {
- let record = self.current_record()?;
- let key = record.config_key.clone();
- let route = record.route.clone()?;
- self.smartshift_pending_confirm
- .remove(&key)
- .map(|write_id| (key, route, write_id))
- }
-
- /// Mark a post-write confirmation as failed when its reply channel closes.
- pub fn fail_smartshift_confirm(&mut self, key: &str, write_id: u64) {
- if matches!(
- self.smartshift_write_status.get(key),
- Some(SmartShiftWriteStatus::Applying {
- write_id: current,
- ..
- }) if *current == write_id
- ) {
- self.smartshift_write_status
- .insert(key.to_string(), SmartShiftWriteStatus::Failed);
- }
- }
-
- /// The lighting config for the active device, or the default when none is
- /// stored / no device is selected.
- #[must_use]
- pub fn lighting(&self) -> Lighting {
- self.current_record()
- .and_then(DeviceRecord::persistent_config_key)
- .and_then(|key| self.config.lighting(key))
- .unwrap_or_default()
- }
-
- /// The stored lighting config for `key`, or `None` when unset.
- #[must_use]
- pub fn lighting_for(&self, key: &str) -> Option<Lighting> {
- if PhysicalDeviceKey::is_transient(key)
- || self
- .device_list
- .iter()
- .any(|record| record.config_key == key && !record.is_persistent())
- {
- return None;
- }
- self.config.lighting(key)
- }
-
- /// Persist a new lighting config for the active device and push it to the
- /// hardware (best-effort). No-op when no device is selected.
- pub fn commit_lighting(&mut self, lighting: Lighting) {
- let Some(record) = self.current_record() else {
- debug!("no active device — lighting change ignored");
- return;
- };
- let key = record.persistent_config_key().map(str::to_string);
- let target = record.route.clone();
- if let Some(route) = target {
- self.send_ipc(crate::ipc_client::Command::SetLighting(
- route,
- lighting.clone(),
- ));
- }
- let Some(key) = key else {
- debug!("transient device lighting applied without persistence");
- return;
- };
- self.config.set_lighting(&key, lighting);
- // Keep the agent's config copy fresh: it re-applies the saved colour
- // when the keyboard reconnects, and without the reload it would
- // replay whatever was saved the last time something *else* reloaded.
- self.persist_and_reload("lighting");
- }
-
- /// Apply `dpi` to the active device (best-effort, via the agent) and
- /// persist it per device — the sensor value lives in device RAM and resets
- /// on a power cycle (#189), so the agent re-applies it on reconnect.
- /// Updates the displayed value even with no device selected.
- pub fn commit_dpi(&mut self, dpi: u32) {
- self.dpi = dpi;
- let Some(record) = self.current_record() else {
- debug!("no active device — DPI change kept in memory only");
- return;
- };
- let key = record.config_key.clone();
- let persistent_key = record.persistent_config_key().map(str::to_string);
- let route = record.route.clone();
- if let Some(route) = route {
- self.send_ipc(crate::ipc_client::Command::SetDpi(route, dpi));
- }
- if let Some(persistent_key) = persistent_key {
- self.config.set_dpi(&persistent_key, dpi);
- self.persist_and_reload("DPI");
- } else {
- debug!(key, "transient device DPI applied without persistence");
- }
- }
-
- /// App-wide settings backing the Settings window (launch-at-login,
- /// update check). Read-only view; mutate via the setters below so the
- /// change is persisted.
- #[must_use]
- pub fn app_settings(&self) -> &AppSettings {
- &self.config.app_settings
- }
-
- /// Toggle launch-at-login, persist to `config.toml`, and reconcile the
- /// macOS `LaunchAgent` plist so the change takes effect without a
- /// restart. No-op when the value is unchanged. Disk failures are logged,
- /// not propagated — the Settings UI shouldn't crash on a full volume.
- pub fn set_launch_at_login(&mut self, enabled: bool) {
- if self.config.app_settings.launch_at_login == enabled {
- return;
- }
- self.config.app_settings.launch_at_login = enabled;
- // The agent owns autostart now; it reconciles its LaunchAgent (which
- // points at the agent, not the GUI) when it reloads the config.
- self.persist_and_reload("launch-at-login setting");
- }
-
- /// Toggle the menu-bar (status item) icon preference and persist it. The
- /// icon is hosted by the always-on agent, which reads this on startup and
- /// installs the status item only when enabled — so the change takes effect
- /// the next time the agent launches (a no-restart live toggle would need a
- /// main-thread hop from the agent's IPC reload). `ReloadConfig` keeps the
- /// agent's other config in sync meanwhile. No-op when unchanged.
- ///
- /// The callers are the menu-bar / notification-area toggle in Settings,
- /// shown only where there's a tray (macOS + Windows), so the setter is
- /// gated the same way to stay dead-code-clean on Linux.
- #[cfg(any(target_os = "macos", target_os = "windows"))]
- pub fn set_show_in_menu_bar(&mut self, enabled: bool) {
- if self.config.app_settings.show_in_menu_bar == enabled {
- return;
- }
- self.config.app_settings.show_in_menu_bar = enabled;
- self.persist_and_reload("show-in-menu-bar setting");
- }
-
- /// Toggle the opt-in update check and persist it. No immediate side
- /// effect beyond the next launch reading the new value. No-op when
- /// unchanged.
- pub fn set_check_for_updates(&mut self, enabled: bool) {
- if self.config.app_settings.check_for_updates == enabled {
- return;
- }
- self.config.app_settings.check_for_updates = enabled;
- if let Err(e) = self.config.save_atomic() {
- warn!(error = %e, "could not persist update-check setting");
- }
- }
-
- /// Toggle opt-in automatic install and persist it. The launch-time updater
- /// observer reads this live, so a newer version found after this is enabled
- /// downloads and stages on its own; no immediate side effect here. No-op
- /// when unchanged.
- pub fn set_auto_install_updates(&mut self, enabled: bool) {
- if self.config.app_settings.auto_install_updates == enabled {
- return;
- }
- self.config.app_settings.auto_install_updates = enabled;
- if let Err(e) = self.config.save_atomic() {
- warn!(error = %e, "could not persist auto-install setting");
- }
- }
-
- /// Persist the light/dark appearance preference. The caller re-applies the
- /// live theme via [`crate::theme::apply_from_settings`]; this only writes the
- /// choice. No-op when unchanged.
- pub fn set_appearance(&mut self, appearance: Appearance) {
- if self.config.app_settings.appearance == appearance {
- return;
- }
- self.config.app_settings.appearance = appearance;
- if let Err(e) = self.config.save_atomic() {
- warn!(error = %e, "could not persist appearance setting");
- }
- }
-
- /// Persist the chosen theme name for one mode (`None` = the OpenLogi brand
- /// theme). No-op when unchanged.
- pub fn set_theme(&mut self, dark: bool, name: Option<String>) {
- let slot = if dark {
- &mut self.config.app_settings.theme_dark
- } else {
- &mut self.config.app_settings.theme_light
- };
- if *slot == name {
- return;
- }
- *slot = name;
- if let Err(e) = self.config.save_atomic() {
- warn!(error = %e, "could not persist theme setting");
- }
- }
-
- /// Persist the UI corner-radius override (`None` = each theme's own radius).
- /// No-op when unchanged.
- pub fn set_ui_radius(&mut self, radius: Option<u8>) {
- if self.config.app_settings.ui_radius == radius {
- return;
- }
- self.config.app_settings.ui_radius = radius;
- if let Err(e) = self.config.save_atomic() {
- warn!(error = %e, "could not persist UI radius setting");
- }
- }
-
- /// Set the thumb-wheel sensitivity (clamped to the valid range), publish it
- /// to the gesture watcher via the shared atomic, and persist it. No-op when
- /// unchanged. Disk failures are logged, not propagated.
- pub fn set_thumbwheel_sensitivity(&mut self, sensitivity: i32) {
- let sensitivity = sensitivity.clamp(
- openlogi_core::config::MIN_THUMBWHEEL_SENSITIVITY,
- openlogi_core::config::MAX_THUMBWHEEL_SENSITIVITY,
- );
- if self.config.app_settings.thumbwheel_sensitivity == sensitivity {
- return;
- }
- self.config.app_settings.thumbwheel_sensitivity = sensitivity;
- self.persist_and_reload("thumbwheel sensitivity");
- }
-
- pub fn set_auto_download_assets(&mut self, enabled: bool) {
- if self.config.app_settings.auto_download_assets == enabled {
- return;
- }
- self.config.app_settings.auto_download_assets = enabled;
- if let Err(e) = self.config.save_atomic() {
- warn!(error = %e, "could not persist auto-download-assets setting");
- }
- }
-
- /// Persist the preferred device-asset source. The Settings view requests a
- /// refresh separately when automatic downloads are enabled, so this setter
- /// remains side-effect-free beyond configuration I/O.
- pub fn set_asset_source(&mut self, source: AssetSourcePreference) {
- if self.config.app_settings.asset_source == source {
- return;
- }
- self.config.app_settings.asset_source = source;
- if let Err(e) = self.config.save_atomic() {
- warn!(error = %e, "could not persist asset-source setting");
- }
- }
-
- /// Record the answer to the first-run update-check prompt: enable (or leave
- /// disabled) the check, and mark the prompt as seen so it never reappears.
- /// Persists once.
- pub fn record_update_consent(&mut self, enabled: bool) {
- self.config.app_settings.check_for_updates = enabled;
- self.config.app_settings.update_prompt_seen = true;
- if let Err(e) = self.config.save_atomic() {
- warn!(error = %e, "could not persist update-check consent");
- }
- }
-
- /// The stored UI-language preference: `Some(code)` for an explicit choice,
- /// `None` for "follow system". Distinct from the *active* locale that
- /// `None` resolves to at startup, so the Settings picker can show "Follow
- /// system" as the selected option.
- #[must_use]
- pub fn language(&self) -> Option<&str> {
- self.config.app_settings.language.as_deref()
- }
-
- /// Set the UI language (`None` = follow system), persist it, switch the
- /// process-global locale live via [`crate::i18n`], and repaint open UI.
- /// No-op when unchanged.
- pub fn set_language(&mut self, language: Option<String>, cx: &mut App) {
- if self.config.app_settings.language == language {
- return;
- }
- self.config.app_settings.language = language;
- if let Err(e) = self.config.save_atomic() {
- warn!(error = %e, "could not persist language setting");
- }
- crate::i18n::activate(self.config.app_settings.language.as_deref());
- cx.refresh_windows();
- crate::app_menu::rebuild(cx);
- }
-
- /// Update a single binding in memory, on disk, and in the shared hook
- /// map for the currently selected device.
- ///
- /// Disk failures and poisoned hook locks are logged at `warn` instead
- /// of bubbling up: the UI thread shouldn't crash because the user's
- /// home volume is full or because the hook thread panicked.
- pub fn commit_binding(&mut self, button: ButtonId, action: Action) {
- self.button_bindings.insert(button, action.clone());
-
- let Some(key) = self
- .current_record()
- .and_then(DeviceRecord::persistent_config_key)
- .map(str::to_string)
- else {
- debug!(
- ?button,
- "no persistent device key — binding kept in memory only"
- );
- return;
- };
- self.config
- .set_binding(&key, button, Binding::Single(action));
- // The agent owns the hook; have it rebuild its live map from config.
- self.persist_and_reload("binding");
- }
-
- /// Apply one paired thumb-wheel preset to both persisted direction bindings.
- /// The two config mutations are followed by exactly one save and one agent
- /// reload, so the runtime never observes a half-updated pair.
- pub(crate) fn commit_thumbwheel_preset(&mut self, preset: ThumbwheelPreset) {
- let pair = preset.pair();
- let persistent_key = self
- .current_record()
- .and_then(DeviceRecord::persistent_config_key)
- .map(str::to_string);
-
- if !apply_thumbwheel_pair(
- &mut self.button_bindings,
- &mut self.config,
- persistent_key.as_deref(),
- pair,
- ) {
- debug!("no persistent device key — thumb-wheel pair kept in memory only");
- return;
- }
-
- self.persist_and_reload("thumb-wheel binding");
- }
-
- fn bindings_for_current(&self) -> BTreeMap<ButtonId, Action> {
- bindings_for(
- &self.config,
- self.current_record()
- .and_then(DeviceRecord::persistent_config_key),
- self.current_app_bundle.as_deref(),
- )
- }
-
- fn gesture_bindings_for_current(&self) -> BTreeMap<GestureDirection, Action> {
- let Some(key) = self
- .current_record()
- .and_then(DeviceRecord::persistent_config_key)
- else {
- return BTreeMap::new();
- };
- match self.config.gesture_owner(key) {
- // The HID++ gesture button seeds every direction from the defaults.
- Some(ButtonId::GestureButton) => gesture_bindings_for(&self.config, Some(key)),
- // A promoted OS-hook button is shown from its raw stored map (which
- // `set_gesture_owner` seeds with full defaults), so the menu matches
- // exactly what `oshook_gestures_for` dispatches — no seeding here.
- Some(owner) => match self.config.bindings_for(key).get(&owner) {
- Some(Binding::Gesture(map)) => map.clone(),
- _ => BTreeMap::new(),
- },
- None => BTreeMap::new(),
- }
- }
-
- /// The current device's gesture button — the [`Binding::Gesture`] owner — or
- /// `None` when no button is in gesture mode. Drives which button's card opens
- /// the gesture menu rather than the single-action picker.
- #[must_use]
- pub fn current_gesture_owner(&self) -> Option<ButtonId> {
- let key = self.current_record()?.persistent_config_key()?;
- self.config.gesture_owner(key)
- }
-
- /// Make `button` the current device's gesture button (or clear it with
- /// `None`), enforcing the one-gesture-button-per-device lock. Persists, tells
- /// the agent to rebuild, and refreshes the projected maps the UI reads.
- pub fn commit_gesture_owner(&mut self, button: Option<ButtonId>) {
- let Some(key) = self
- .current_record()
- .and_then(DeviceRecord::persistent_config_key)
- .map(str::to_string)
- else {
- return;
- };
- match button {
- Some(b) => {
- self.config.set_gesture_owner(&key, b);
- }
- None => {
- self.config.disable_gestures(&key);
- }
- }
- // The owner change shuffles bindings between the single + gesture maps.
- self.button_bindings = self.bindings_for_current();
- self.gesture_bindings = self.gesture_bindings_for_current();
- self.persist_and_reload("gesture-button change");
- }
-
- /// Update a single gesture-button sub-binding in memory, on disk, and in the
- /// shared gesture map the watcher thread reads.
- pub fn commit_gesture_binding(&mut self, direction: GestureDirection, action: Action) {
- let Some(key) = self
- .current_record()
- .and_then(DeviceRecord::persistent_config_key)
- .map(str::to_string)
- else {
- debug!(
- ?direction,
- "no persistent device key — gesture binding edit ignored"
- );
- return;
- };
- // Edit whichever button owns gestures — not always the HID++ gesture button. When
- // gestures are off, a stray edit must NOT silently re-enable them on the
- // default owner (the gesture editor shouldn't be reachable in that state):
- // no-op instead.
- let Some(owner) = self.config.gesture_owner(&key) else {
- debug!(
- ?direction,
- "gestures are off — ignoring gesture binding edit"
- );
- return;
- };
- self.gesture_bindings.insert(direction, action.clone());
- self.config
- .set_gesture_direction(&key, owner, direction, action);
- // The agent owns the gesture watcher; have it rebuild from config.
- self.persist_and_reload("gesture binding");
- }
-}
-
-/// Update both projected bindings and, when a stable device key exists, both
-/// persisted single-action entries. Returns whether the caller should persist
-/// and reload the agent.
-fn apply_thumbwheel_pair(
- button_bindings: &mut BTreeMap<ButtonId, Action>,
- config: &mut Config,
- persistent_key: Option<&str>,
- pair: ThumbwheelPair,
-) -> bool {
- button_bindings.insert(ButtonId::ThumbwheelScrollDown, pair.backward.clone());
- button_bindings.insert(ButtonId::ThumbwheelScrollUp, pair.forward.clone());
-
- let Some(key) = persistent_key else {
- return false;
- };
- config.set_binding(
- key,
- ButtonId::ThumbwheelScrollDown,
- Binding::Single(pair.backward),
- );
- config.set_binding(
- key,
- ButtonId::ThumbwheelScrollUp,
- Binding::Single(pair.forward),
- );
- true
-}
-
-/// Record the identity (name / kind / capabilities) of every currently online,
-/// fully-probed device into `config`, persisting to disk only when something
-/// actually changed.
-///
-/// This is the write half of the identity-driven device list: it is what lets
-/// [`build_device_list`] resurrect a sleeping device on the next launch. Only
-/// online devices with *measured* capabilities are recorded — never a presumed
-/// or carried-forward `None` — so a placeholder never persists empty panels.
-/// The change-guard keeps quiet inventory ticks off the disk; the agent does
-/// not consume identities, so no `ReloadConfig` is sent.
-fn persist_identities(config: &mut Config, list: &[DeviceRecord]) {
- let mut changed = false;
- for record in list {
- if !record.online {
- continue;
- }
- let Some(config_key) = record.persistent_config_key() else {
- continue;
- };
- let Some(capabilities) = record.capabilities else {
- continue;
- };
- let identity = DeviceIdentity {
- display_name: record.display_name.clone(),
- kind: record.kind,
- capabilities,
- model_info: record.model_info.clone().map(|mut model| {
- model.serial_number = None;
- model.unit_id = [0; 4];
- model
- }),
- codename: record.codename.clone(),
- };
- if config.device_identity(config_key) != Some(&identity) {
- config.set_device_identity(config_key, identity);
- changed = true;
- }
- }
- if changed && let Err(e) = config.save_atomic() {
- warn!(error = %e, "could not persist device identities to config.toml");
- }
-}
-
-/// Whether a DPI discovery error is permanent (the device genuinely lacks the
-/// feature or reports nothing usable) versus transient (a timeout or busy
-/// device worth retrying).
-fn dpi_error_is_permanent(error: &WriteError) -> bool {
- matches!(
- error,
- WriteError::FeatureUnsupported { .. } | WriteError::EmptyDpiList
- )
-}
-
-/// Whether a SmartShift read error is permanent: a genuine "feature not
-/// supported" reply (the device lacks `0x2111`) never changes, so stop
-/// probing. Everything else (timeouts, busy device) is transient.
-fn smartshift_error_is_permanent(error: &WriteError) -> bool {
- matches!(error, WriteError::FeatureUnsupported { .. })
-}
-
-fn smartshift_write_outcome(
- expected: SmartShiftStatus,
- load: Option<&SmartShiftLoad>,
-) -> Option<SmartShiftWriteStatus> {
- match load {
- Some(SmartShiftLoad::Ready(actual)) if *actual == expected => {
- Some(SmartShiftWriteStatus::Confirmed)
- }
- Some(SmartShiftLoad::Ready(_)) => Some(SmartShiftWriteStatus::Failed),
- Some(SmartShiftLoad::Failed(_) | SmartShiftLoad::Unsupported(_)) => {
- Some(SmartShiftWriteStatus::Failed)
- }
- None | Some(SmartShiftLoad::Unknown | SmartShiftLoad::Loading) => None,
- }
-}
-
-fn smartshift_read_is_current(
- read_id: Option<u64>,
- write_status: Option<&SmartShiftWriteStatus>,
-) -> bool {
- match (read_id, write_status) {
- (
- Some(read_id),
- Some(SmartShiftWriteStatus::Applying {
- write_id: current, ..
- }),
- ) => read_id == *current,
- (None, Some(SmartShiftWriteStatus::Applying { .. })) | (Some(_), _) => false,
- (None, _) => true,
- }
-}
-
-fn set_scroll_resolution_if_supported(
- config: &mut Config,
- key: &str,
- supported: bool,
- resolution: Option<openlogi_core::config::ScrollResolution>,
-) -> bool {
- if !supported {
- return false;
- }
- config.set_scroll_resolution(key, resolution);
- true
}
impl Global for AppState {}
-
-#[cfg(test)]
-mod tests {
- use openlogi_core::binding::Binding;
- use openlogi_core::config::{Config, DeviceIdentity, Lighting, ScrollResolution};
- use openlogi_core::device::{
- Capabilities, DeviceInventory, DeviceKind, DeviceModelInfo, DeviceTransports, PairedDevice,
- ReceiverInfo,
- };
- use openlogi_hid::{SmartShiftMode, SmartShiftStatus};
-
- use crate::asset::AssetResolver;
- use crate::data::mouse_buttons::{Action, ButtonId};
- use crate::mouse_model::thumbwheel::ThumbwheelPreset;
-
- use super::{
- AppState, Load, SmartShiftWriteStatus, apply_thumbwheel_pair, build_device_list,
- set_scroll_resolution_if_supported, smartshift_read_is_current, smartshift_write_outcome,
- };
-
- fn direct_inventory(unit_id: [u8; 4]) -> DeviceInventory {
- DeviceInventory {
- receiver: ReceiverInfo {
- name: "MX Master 3S".to_string(),
- vendor_id: 0x046d,
- product_id: 0xb023,
- unique_id: None,
- },
- paired: vec![PairedDevice {
- slot: openlogi_hid::DIRECT_DEVICE_INDEX,
- codename: Some("MX Master 3S".to_string()),
- wpid: None,
- kind: DeviceKind::Mouse,
- online: true,
- battery: None,
- model_info: Some(DeviceModelInfo {
- entity_count: 1,
- serial_number: None,
- unit_id,
- transports: DeviceTransports::default(),
- model_ids: [0xb034, 0, 0],
- extended_model_id: 2,
- }),
- capabilities: Some(Capabilities::presumed_from_kind(DeviceKind::Mouse)),
- }],
- }
- }
-
- #[test]
- fn thumbwheel_pair_updates_both_memory_and_config_entries() {
- let mut bindings = std::collections::BTreeMap::new();
- let mut config = Config::default();
- let key = "2b034";
-
- assert!(apply_thumbwheel_pair(
- &mut bindings,
- &mut config,
- Some(key),
- ThumbwheelPreset::Volume.pair(),
- ));
- assert_eq!(
- bindings.get(&ButtonId::ThumbwheelScrollDown),
- Some(&Action::VolumeDown)
- );
- assert_eq!(
- bindings.get(&ButtonId::ThumbwheelScrollUp),
- Some(&Action::VolumeUp)
- );
- let persisted = config.bindings_for(key);
- assert_eq!(
- persisted.get(&ButtonId::ThumbwheelScrollDown),
- Some(&Binding::Single(Action::VolumeDown))
- );
- assert_eq!(
- persisted.get(&ButtonId::ThumbwheelScrollUp),
- Some(&Binding::Single(Action::VolumeUp))
- );
- }
-
- #[test]
- fn transient_thumbwheel_pair_stays_in_memory_without_persistence() {
- let mut bindings = std::collections::BTreeMap::new();
- let mut config = Config::default();
-
- assert!(!apply_thumbwheel_pair(
- &mut bindings,
- &mut config,
- None,
- ThumbwheelPreset::CycleDpi.pair(),
- ));
- assert_eq!(bindings.len(), 2);
- assert!(config.bindings_for("missing").is_empty());
- }
-
- #[test]
- fn transient_identity_is_not_persisted_or_retained_after_resolution() {
- let cache = AssetResolver::new();
- let transient_inventory = direct_inventory([0; 4]);
- let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
- let mut state =
- AppState::with_runtime(Config::default(), &[transient_inventory], &cache, commands);
- let transient_key = "direct:046d:b023:unit:00000000";
-
- assert_eq!(state.device_list.len(), 1);
- assert!(state.config.device_identity(transient_key).is_none());
- state.commit_dpi(2400);
- assert!(state.config.dpi(transient_key).is_none());
-
- let stable_list = build_device_list(
- &[direct_inventory([0xa3, 0x93, 0xca, 0xe0])],
- &cache,
- &state.config,
- );
- let merged = state.merge_inventory_snapshot(stable_list);
-
- assert_eq!(merged.len(), 1);
- assert_eq!(merged[0].config_key, "direct:046d:b023:unit:a393cae0");
- assert!(merged[0].is_persistent());
- }
-
- #[test]
- fn transient_probe_folds_into_its_known_card() {
- // #482: a half-read probe (all-zero unit id) of the only known device
- // with that vid/pid must not evict the known card or appear beside it —
- // the card keeps its identity and takes the live volatile state.
- let cache = AssetResolver::new();
- let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
- let mut state = AppState::with_runtime(
- Config::default(),
- &[direct_inventory([0xa3, 0x93, 0xca, 0xe0])],
- &cache,
- commands,
- );
- let stable_key = "direct:046d:b023:unit:a393cae0";
- assert_eq!(state.device_list[0].config_key, stable_key);
-
- let transient_list = build_device_list(&[direct_inventory([0; 4])], &cache, &state.config);
- let merged = state.merge_inventory_snapshot(transient_list);
-
- assert_eq!(merged.len(), 1, "no second card for the half-read probe");
- assert_eq!(merged[0].config_key, stable_key);
- assert!(merged[0].is_persistent());
- assert!(merged[0].online, "the live probe supplies volatile state");
- assert!(merged[0].route.is_some(), "the live route is kept usable");
- }
-
- #[test]
- fn transient_record_beside_its_live_device_is_dropped() {
- // Both a full and a half-read probe of the same wire product in one
- // snapshot: the transient record is probe noise, not a second device.
- let cache = AssetResolver::new();
- let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
- let mut state = AppState::with_runtime(
- Config::default(),
- &[direct_inventory([0xa3, 0x93, 0xca, 0xe0])],
- &cache,
- commands,
- );
-
- let both = build_device_list(
- &[
- direct_inventory([0xa3, 0x93, 0xca, 0xe0]),
- direct_inventory([0; 4]),
- ],
- &cache,
- &state.config,
- );
- assert_eq!(both.len(), 2);
- let merged = state.merge_inventory_snapshot(both);
-
- assert_eq!(merged.len(), 1);
- assert_eq!(merged[0].config_key, "direct:046d:b023:unit:a393cae0");
- assert!(merged[0].online);
- }
-
- #[test]
- fn transient_probe_adopts_the_absent_sibling_of_a_live_twin() {
- // Two same-model devices; one probes complete, the other half-reads.
- // The live twin must not get the transient discarded as its own noise:
- // the half-read probe can only be the sibling, which keeps its card
- // online and routed.
- let cache = AssetResolver::new();
- let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
- let mut state = AppState::with_runtime(
- Config::default(),
- &[
- direct_inventory([1, 1, 1, 1]),
- direct_inventory([2, 2, 2, 2]),
- ],
- &cache,
- commands,
- );
-
- let snapshot = build_device_list(
- &[direct_inventory([1, 1, 1, 1]), direct_inventory([0; 4])],
- &cache,
- &state.config,
- );
- let merged = state.merge_inventory_snapshot(snapshot);
-
- assert_eq!(merged.len(), 2, "no third card for the half-read probe");
- let Some(sibling) = merged
- .iter()
- .find(|r| r.config_key == "direct:046d:b023:unit:02020202")
- else {
- panic!("the sibling card must survive under its physical key");
- };
- assert!(
- sibling.online,
- "the half-read probe keeps the sibling online"
- );
- assert!(sibling.route.is_some(), "the live route stays usable");
- }
-
- #[test]
- fn ambiguous_transient_probe_is_not_adopted() {
- // Two same-model devices are known; a half-read probe could be either,
- // so neither card may steal it.
- let cache = AssetResolver::new();
- let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
- let mut state = AppState::with_runtime(
- Config::default(),
- &[
- direct_inventory([1, 1, 1, 1]),
- direct_inventory([2, 2, 2, 2]),
- ],
- &cache,
- commands,
- );
- assert_eq!(state.device_list.len(), 2);
-
- let transient_list = build_device_list(&[direct_inventory([0; 4])], &cache, &state.config);
- let merged = state.merge_inventory_snapshot(transient_list);
-
- assert_eq!(merged.len(), 3, "both known cards survive on grace");
- assert_eq!(
- merged.iter().filter(|r| !r.is_persistent()).count(),
- 1,
- "the transient card stays its own record"
- );
- }
-
- #[test]
- fn historical_transient_lighting_is_not_exposed_without_a_live_record() {
- let transient_key = "direct:046d:b023:unit:00000000";
- let mut config = Config::default();
- config.set_lighting(transient_key, Lighting::default());
- assert!(config.lighting(transient_key).is_some());
- let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
- let state = AppState::with_runtime(config, &[], &AssetResolver::new(), commands);
-
- assert!(state.device_list.is_empty());
- assert!(state.lighting_for(transient_key).is_none());
- }
-
- #[test]
- fn smartshift_write_feedback_requires_the_written_value() {
- let expected = SmartShiftStatus {
- mode: SmartShiftMode::Ratchet,
- auto_disengage: 12,
- tunable_torque: 0,
- };
- assert_eq!(smartshift_write_outcome(expected, None), None);
- assert_eq!(
- smartshift_write_outcome(expected, Some(&Load::Ready(expected))),
- Some(SmartShiftWriteStatus::Confirmed)
- );
- assert_eq!(
- smartshift_write_outcome(
- expected,
- Some(&Load::Ready(SmartShiftStatus {
- auto_disengage: 13,
- ..expected
- })),
- ),
- Some(SmartShiftWriteStatus::Failed)
- );
- assert_eq!(
- smartshift_write_outcome(
- expected,
- Some(&Load::<SmartShiftStatus>::Failed("timeout".to_string(),))
- ),
- Some(SmartShiftWriteStatus::Failed)
- );
- }
-
- #[test]
- fn stale_smartshift_reads_do_not_resolve_newer_writes() {
- let expected = SmartShiftStatus {
- mode: SmartShiftMode::Ratchet,
- auto_disengage: 12,
- tunable_torque: 0,
- };
- let applying = SmartShiftWriteStatus::Applying {
- expected,
- write_id: 2,
- };
-
- assert!(smartshift_read_is_current(Some(2), Some(&applying)));
- assert!(!smartshift_read_is_current(Some(1), Some(&applying)));
- assert!(!smartshift_read_is_current(None, Some(&applying)));
- assert!(!smartshift_read_is_current(
- Some(2),
- Some(&SmartShiftWriteStatus::Confirmed)
- ));
- assert!(smartshift_read_is_current(None, None));
- }
-
- #[test]
- fn known_offline_device_is_an_asset_sync_target() {
- let model = DeviceModelInfo {
- entity_count: 0,
- serial_number: None,
- unit_id: [0; 4],
- transports: DeviceTransports::default(),
- model_ids: [0xb034, 0, 0],
- extended_model_id: 2,
- };
- let mut config = Config::default();
- config.set_device_identity(
- "2b034",
- DeviceIdentity {
- display_name: "MX Anywhere 3S".to_string(),
- kind: DeviceKind::Mouse,
- capabilities: Capabilities::presumed_from_kind(DeviceKind::Mouse),
- model_info: Some(model.clone()),
- codename: Some("MX Anywhere 3S".to_string()),
- },
- );
- let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
- let state = AppState::with_runtime(config, &[], &AssetResolver::new(), commands);
-
- assert_eq!(
- state.asset_models(),
- vec![(model, Some("MX Anywhere 3S".to_string()))]
- );
- }
-
- #[test]
- fn gui_state_saves_and_clears_supported_wheel_resolution() {
- let mut config = Config::default();
- assert!(set_scroll_resolution_if_supported(
- &mut config,
- "mouse",
- true,
- Some(ScrollResolution::Low),
- ));
- assert_eq!(
- config.scroll_resolution("mouse"),
- Some(ScrollResolution::Low)
- );
-
- assert!(set_scroll_resolution_if_supported(
- &mut config,
- "mouse",
- true,
- None,
- ));
- assert_eq!(config.scroll_resolution("mouse"), None);
- }
-
- #[test]
- fn gui_state_ignores_unsupported_wheel_resolution() {
- let mut config = Config::default();
- assert!(!set_scroll_resolution_if_supported(
- &mut config,
- "mouse",
- false,
- Some(ScrollResolution::High),
- ));
- assert_eq!(config.scroll_resolution("mouse"), None);
- }
-}
diff --git a/crates/openlogi-gui/src/state/agent.rs b/crates/openlogi-gui/src/state/agent.rs
new file mode 100644
index 0000000000000000000000000000000000000000..9972af433d73aa776da8f4daf442a930e8cc76f8
--- /dev/null
+++ b/crates/openlogi-gui/src/state/agent.rs
@@ -0,0 +1,67 @@
+//! Agent connection status and debug monitor state.
+
+use super::{AgentLink, AppState};
+
+impl AppState {
+ /// Append a batch of live-monitor events, capping the retained history so the
+ /// buffer can't grow without bound while the monitor is open.
+ #[cfg(all(target_os = "macos", debug_assertions))]
+ pub fn push_monitor_events(&mut self, events: Vec<openlogi_agent_core::ipc::MonitorEvent>) {
+ const MAX: usize = 200;
+ self.monitor_events.extend(events);
+ let overflow = self.monitor_events.len().saturating_sub(MAX);
+ self.monitor_events.drain(..overflow);
+ }
+ /// Recent live-monitor events, oldest first.
+ #[cfg(all(target_os = "macos", debug_assertions))]
+ #[must_use]
+ pub fn monitor_events(
+ &self,
+ ) -> &std::collections::VecDeque<openlogi_agent_core::ipc::MonitorEvent> {
+ &self.monitor_events
+ }
+ /// Replace the cached event-tap snapshot the Diagnostics page renders.
+ /// Refreshed on the live-monitor poll tick; see [`Self::event_taps`].
+ #[cfg(all(target_os = "macos", debug_assertions))]
+ pub fn set_event_taps(&mut self, taps: Vec<openlogi_hook::EventTapInfo>) {
+ self.event_taps = taps;
+ }
+ /// The cached event-tap snapshot for the Diagnostics page.
+ #[cfg(all(target_os = "macos", debug_assertions))]
+ #[must_use]
+ pub fn event_taps(&self) -> &[openlogi_hook::EventTapInfo] {
+ &self.event_taps
+ }
+ /// Ask the agent to fire the macOS Accessibility prompt. The agent owns the
+ /// CGEventTap, so the system dialog must name and authorize the *agent*
+ /// binary; prompting in the GUI process (as the pre-split build did) would
+ /// grant the wrong binary and the hook would never install.
+ pub fn request_accessibility_prompt(&self) {
+ self.send_ipc(crate::ipc_client::Command::RequestAccessibilityPrompt);
+ }
+ /// The agent connection state the render path branches on.
+ #[must_use]
+ pub fn agent_link(&self) -> &AgentLink {
+ &self.agent_link
+ }
+ /// The latest agent status snapshot — `None` while not connected (any
+ /// non-[`AgentLink::Ready`] state), which readers like the Settings
+ /// permission rows surface as "unknown", not "denied".
+ #[must_use]
+ pub fn agent_status(&self) -> Option<&openlogi_agent_core::ipc::AgentStatus> {
+ match &self.agent_link {
+ AgentLink::Ready(status) => Some(status),
+ _ => None,
+ }
+ }
+ /// Replace the link, reporting whether it actually changed — the steady
+ /// IPC poll mostly delivers identical snapshots, and the caller skips the
+ /// window refresh for those.
+ pub fn set_agent_link(&mut self, link: AgentLink) -> bool {
+ if self.agent_link == link {
+ return false;
+ }
+ self.agent_link = link;
+ true
+ }
+}
diff --git a/crates/openlogi-gui/src/state/bindings.rs b/crates/openlogi-gui/src/state/bindings.rs
new file mode 100644
index 0000000000000000000000000000000000000000..306ce136c0452605203bb3ca3e48bb5b972b5038
--- /dev/null
+++ b/crates/openlogi-gui/src/state/bindings.rs
@@ -0,0 +1,194 @@
+//! Mouse, gesture, and keyboard binding commits.
+
+use std::collections::BTreeMap;
+
+use openlogi_agent_core::bindings::{bindings_for, gesture_bindings_for};
+use openlogi_core::config::KeyTrigger;
+use tracing::debug;
+
+use crate::data::mouse_buttons::{Action, Binding, ButtonId, GestureDirection};
+use crate::mouse_model::thumbwheel::{ThumbwheelPair, ThumbwheelPreset};
+use crate::state::devices::DeviceRecord;
+
+use super::AppState;
+
+pub(super) fn apply_thumbwheel_pair(
+ button_bindings: &mut BTreeMap<ButtonId, Action>,
+ config: &mut openlogi_core::config::Config,
+ persistent_key: Option<&str>,
+ pair: ThumbwheelPair,
+) -> bool {
+ button_bindings.insert(ButtonId::ThumbwheelScrollDown, pair.backward.clone());
+ button_bindings.insert(ButtonId::ThumbwheelScrollUp, pair.forward.clone());
+
+ let Some(key) = persistent_key else {
+ return false;
+ };
+ config.set_binding(
+ key,
+ ButtonId::ThumbwheelScrollDown,
+ Binding::Single(pair.backward),
+ );
+ config.set_binding(
+ key,
+ ButtonId::ThumbwheelScrollUp,
+ Binding::Single(pair.forward),
+ );
+ true
+}
+
+impl AppState {
+ /// Update a single binding in memory, on disk, and in the shared hook
+ /// map for the currently selected device.
+ ///
+ /// Disk failures and poisoned hook locks are logged at `warn` instead
+ /// of bubbling up: the UI thread shouldn't crash because the user's
+ /// home volume is full or because the hook thread panicked.
+ pub fn commit_binding(&mut self, button: ButtonId, action: Action) {
+ self.button_bindings.insert(button, action.clone());
+
+ let Some(key) = self
+ .current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ .map(str::to_string)
+ else {
+ debug!(
+ ?button,
+ "no persistent device key — binding kept in memory only"
+ );
+ return;
+ };
+ self.config
+ .set_binding(&key, button, Binding::Single(action));
+ // The agent owns the hook; have it rebuild its live map from config.
+ self.persist_and_reload("binding");
+ }
+
+ /// Apply one paired thumb-wheel preset atomically. Both directional
+ /// bindings are updated before the single config persistence/reload.
+ pub fn commit_thumbwheel_preset(&mut self, preset: ThumbwheelPreset) {
+ let pair = preset.pair();
+ let key = self
+ .current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ .map(str::to_string);
+ if !apply_thumbwheel_pair(
+ &mut self.button_bindings,
+ &mut self.config,
+ key.as_deref(),
+ pair,
+ ) {
+ debug!("no persistent device key — thumb-wheel pair kept in memory only");
+ return;
+ }
+ self.persist_and_reload("thumb-wheel binding");
+ }
+ /// Records (or, with `action = None`, clears) the F-key `trigger` binding
+ /// in the global `[keyboard]` map. Mirrors [`Self::commit_binding`] minus
+ /// the device key — keyboard bindings are device-agnostic, so there's no
+ /// `current_record()` dependency. The agent's `rebuild()` republishes its
+ /// shared keyboard map on `reload_config`, so this lands live.
+ pub fn commit_keyboard_binding(&mut self, trigger: KeyTrigger, action: Option<Action>) {
+ match action {
+ Some(ref a) => {
+ self.keyboard_bindings.insert(trigger.clone(), a.clone());
+ }
+ None => {
+ self.keyboard_bindings.remove(&trigger);
+ }
+ }
+ self.config.set_keyboard_binding(trigger, action);
+ self.persist_and_reload("keyboard binding");
+ }
+ pub(crate) fn bindings_for_current(&self) -> BTreeMap<ButtonId, Action> {
+ bindings_for(
+ &self.config,
+ self.current_record()
+ .and_then(DeviceRecord::persistent_config_key),
+ self.current_app_bundle.as_deref(),
+ )
+ }
+ pub(crate) fn gesture_bindings_for_current(&self) -> BTreeMap<GestureDirection, Action> {
+ let Some(key) = self
+ .current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ else {
+ return BTreeMap::new();
+ };
+ match self.config.gesture_owner(key) {
+ // The HID++ gesture button seeds every direction from the defaults.
+ Some(ButtonId::GestureButton) => gesture_bindings_for(&self.config, Some(key)),
+ // A promoted OS-hook button is shown from its raw stored map (which
+ // `set_gesture_owner` seeds with full defaults), so the menu matches
+ // exactly what `oshook_gestures_for` dispatches — no seeding here.
+ Some(owner) => match self.config.bindings_for(key).get(&owner) {
+ Some(Binding::Gesture(map)) => map.clone(),
+ _ => BTreeMap::new(),
+ },
+ None => BTreeMap::new(),
+ }
+ }
+ /// The current device's gesture button — the [`Binding::Gesture`] owner — or
+ /// `None` when no button is in gesture mode. Drives which button's card opens
+ /// the gesture menu rather than the single-action picker.
+ #[must_use]
+ pub fn current_gesture_owner(&self) -> Option<ButtonId> {
+ let key = self.current_record()?.persistent_config_key()?;
+ self.config.gesture_owner(key)
+ }
+ /// Make `button` the current device's gesture button (or clear it with
+ /// `None`), enforcing the one-gesture-button-per-device lock. Persists, tells
+ /// the agent to rebuild, and refreshes the projected maps the UI reads.
+ pub fn commit_gesture_owner(&mut self, button: Option<ButtonId>) {
+ let Some(key) = self
+ .current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ .map(str::to_string)
+ else {
+ return;
+ };
+ match button {
+ Some(b) => {
+ self.config.set_gesture_owner(&key, b);
+ }
+ None => {
+ self.config.disable_gestures(&key);
+ }
+ }
+ // The owner change shuffles bindings between the single + gesture maps.
+ self.button_bindings = self.bindings_for_current();
+ self.gesture_bindings = self.gesture_bindings_for_current();
+ self.persist_and_reload("gesture-button change");
+ }
+ /// Update a single gesture-button sub-binding in memory, on disk, and in the
+ /// shared gesture map the watcher thread reads.
+ pub fn commit_gesture_binding(&mut self, direction: GestureDirection, action: Action) {
+ let Some(key) = self
+ .current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ .map(str::to_string)
+ else {
+ debug!(
+ ?direction,
+ "no persistent device key — gesture binding edit ignored"
+ );
+ return;
+ };
+ // Edit whichever button owns gestures — not always the HID++ gesture button. When
+ // gestures are off, a stray edit must NOT silently re-enable them on the
+ // default owner (the gesture editor shouldn't be reachable in that state):
+ // no-op instead.
+ let Some(owner) = self.config.gesture_owner(&key) else {
+ debug!(
+ ?direction,
+ "gestures are off — ignoring gesture binding edit"
+ );
+ return;
+ };
+ self.gesture_bindings.insert(direction, action.clone());
+ self.config
+ .set_gesture_direction(&key, owner, direction, action);
+ // The agent owns the gesture watcher; have it rebuild from config.
+ self.persist_and_reload("gesture binding");
+ }
+}
diff --git a/crates/openlogi-gui/src/state/camera.rs b/crates/openlogi-gui/src/state/camera.rs
new file mode 100644
index 0000000000000000000000000000000000000000..3923abe54a03b9ffc93939fbd7015c13e9d4362a
--- /dev/null
+++ b/crates/openlogi-gui/src/state/camera.rs
@@ -0,0 +1,137 @@
+//! Webcam control state and camera profiles.
+
+use tracing::warn;
+
+use openlogi_camera::CameraControl;
+
+use super::AppState;
+
+impl AppState {
+ /// Whether any connected device is a webcam. Gates the camera-permission UI
+ /// so it only appears when there is actually a camera to grant access to.
+ /// Only the platforms that register the permission page (macOS/Linux) call
+ /// this; Windows has no such page, so the method is scoped to match.
+ #[cfg(any(target_os = "macos", target_os = "linux"))]
+ #[must_use]
+ pub fn has_camera(&self) -> bool {
+ self.device_list
+ .iter()
+ .any(|r| matches!(r.kind, openlogi_core::device::DeviceKind::Camera))
+ }
+ /// The saved value of a UVC control for `config_key`, if any.
+ #[must_use]
+ pub fn camera_control(&self, config_key: &str, control: CameraControl) -> Option<i32> {
+ self.config
+ .camera_controls(config_key)?
+ .0
+ .get(control.name())
+ .copied()
+ }
+ /// The saved state of a camera auto toggle for `config_key`, if any.
+ #[must_use]
+ pub fn camera_auto(
+ &self,
+ config_key: &str,
+ toggle: openlogi_camera::AutoToggle,
+ ) -> Option<bool> {
+ self.config
+ .camera_controls(config_key)?
+ .0
+ .get(toggle.name())
+ .map(|v| *v != 0)
+ }
+ /// Persist a UVC control for `config_key`. No agent IPC — webcams are
+ /// driven straight from the GUI over USB, so the agent never sees this.
+ pub fn commit_camera_control(&mut self, config_key: &str, control: CameraControl, value: i32) {
+ self.commit_camera_entry(config_key, control.name(), value);
+ }
+ /// Persist a camera auto toggle for `config_key` (stored as 0/1).
+ pub fn commit_camera_auto(
+ &mut self,
+ config_key: &str,
+ toggle: openlogi_camera::AutoToggle,
+ on: bool,
+ ) {
+ self.commit_camera_entry(config_key, toggle.name(), i32::from(on));
+ }
+ fn commit_camera_entry(&mut self, config_key: &str, name: &str, value: i32) {
+ let mut controls = self.config.camera_controls(config_key).unwrap_or_default();
+ controls.0.insert(name.to_string(), value);
+ self.config.set_camera_controls(config_key, controls);
+ if let Err(e) = self.config.save_atomic() {
+ warn!(error = %e, "could not persist camera controls");
+ }
+ }
+ /// Lift settings from the legacy port-bound `camera-<unique_id>` key onto
+ /// the stable serial/model key when the latter has none. Inventory identity
+ /// for cameras is separate ([`DeviceRecord::inventory_key`]); settings never
+ /// use capture-id suffixes, so two serial-less same-model units honestly
+ /// share one settings bag rather than risk cross-assigning on port moves.
+ pub fn migrate_legacy_camera_key(&mut self, config_key: &str, capture_id: &str) {
+ if self.camera_key_has_settings(config_key) {
+ return;
+ }
+ let port_key = format!("camera-{capture_id}");
+ if port_key == config_key || !self.camera_key_has_settings(&port_key) {
+ return;
+ }
+ if let Some(controls) = self.config.camera_controls(&port_key) {
+ self.config.set_camera_controls(config_key, controls);
+ }
+ for (name, snap) in self.config.camera_profiles(&port_key) {
+ self.config.save_camera_profile(config_key, &name, snap);
+ }
+ if let Some(active) = self.config.camera_active_profile(&port_key) {
+ self.config
+ .set_camera_active_profile(config_key, Some(active));
+ }
+ self.config.devices.remove(&port_key);
+ if let Err(e) = self.config.save_atomic() {
+ warn!(error = %e, "could not persist camera key migration");
+ }
+ }
+ fn camera_key_has_settings(&self, key: &str) -> bool {
+ self.config.camera_controls(key).is_some()
+ || !self.config.camera_profiles(key).is_empty()
+ || self.config.camera_active_profile(key).is_some()
+ }
+ /// User-saved camera profiles for `config_key` (name → snapshot).
+ #[must_use]
+ pub fn camera_profiles(
+ &self,
+ config_key: &str,
+ ) -> std::collections::BTreeMap<String, openlogi_core::config::CameraControls> {
+ self.config.camera_profiles(config_key)
+ }
+ /// Save a custom camera profile and persist it.
+ pub fn save_camera_profile(
+ &mut self,
+ config_key: &str,
+ name: &str,
+ snap: openlogi_core::config::CameraControls,
+ ) {
+ self.config.save_camera_profile(config_key, name, snap);
+ if let Err(e) = self.config.save_atomic() {
+ warn!(error = %e, "could not persist camera profile");
+ }
+ }
+ /// Delete a custom camera profile and persist the removal.
+ pub fn delete_camera_profile(&mut self, config_key: &str, name: &str) {
+ self.config.delete_camera_profile(config_key, name);
+ if let Err(e) = self.config.save_atomic() {
+ warn!(error = %e, "could not persist camera profile removal");
+ }
+ }
+ /// The camera profile last applied for `config_key`, if any.
+ #[must_use]
+ pub fn camera_active_profile(&self, config_key: &str) -> Option<String> {
+ self.config.camera_active_profile(config_key)
+ }
+ /// Record (and persist) which camera profile `config_key` last applied.
+ pub fn set_camera_active_profile(&mut self, config_key: &str, name: Option<String>) {
+ self.config.set_camera_active_profile(config_key, name);
+ if let Err(e) = self.config.save_atomic() {
+ warn!(error = %e, "could not persist camera profile selection");
+ }
+ }
+}
diff --git a/crates/openlogi-gui/src/state/devices.rs b/crates/openlogi-gui/src/state/devices.rs
index 9fd5db390feff22b0ea2227bd4b39cf82cd2ab92..6903b840fe698989b021f65aee1f64a7e4427296 100644
--- a/crates/openlogi-gui/src/state/devices.rs
+++ b/crates/openlogi-gui/src/state/devices.rs
@@ -3,9 +3,11 @@
use std::collections::HashSet;
use openlogi_agent_core::device_order::{DeviceStableId, PhysicalDeviceKey};
+use openlogi_camera::Camera;
use openlogi_core::config::{Config, DeviceIdentity};
use openlogi_core::device::{
BatteryInfo, Capabilities, DeviceInventory, DeviceKind, DeviceModelInfo, DeviceTransports,
+ LightCapabilities, StandaloneDevice,
};
use openlogi_hid::DeviceRoute;
use tracing::debug;
@@ -38,7 +40,15 @@ pub struct DeviceRecord {
pub codename: Option<String>,
pub serial_number: Option<String>,
pub unit_id: [u8; 4],
+ /// Standalone driver family, if this is a non-HID++ record.
+ pub driver_id: Option<String>,
+ /// Model-level asset registry identity for standalone devices.
+ pub registry_model_id: Option<String>,
pub route: Option<DeviceRoute>,
+ /// OS capture id for cameras (AVFoundation uniqueID / DirectShow path).
+ /// Distinct from [`Self::config_key`], which prefers the port-stable USB
+ /// serial. `None` for HID++ devices (those open via [`Self::route`]).
+ pub capture_id: Option<String>,
pub kind: DeviceKind,
/// Configuration capabilities from the device's HID++ feature table.
/// Continuity across sleep lives in the hid layer: its probe cache keeps
@@ -46,6 +56,8 @@ pub struct DeviceRecord {
/// this is `None` only for a device never probed since the agent started —
/// and the UI then falls back to [`Capabilities::presumed_from_kind`].
pub capabilities: Option<Capabilities>,
+ /// Capabilities for standalone non-HID++ controls such as Litra lights.
+ pub light_capabilities: Option<LightCapabilities>,
pub slot: u8,
pub online: bool,
pub battery: Option<BatteryInfo>,
@@ -62,6 +74,20 @@ impl DeviceRecord {
pub(super) fn is_persistent(&self) -> bool {
self.persistent
}
+
+ /// Key used to reconcile this record across inventory snapshots.
+ ///
+ /// HID++ devices use [`Self::config_key`]. Cameras may share a model-scoped
+ /// config key (two serial-less units of the same model), so they reconcile
+ /// on the OS capture id instead — settings still persist under `config_key`.
+ pub(super) fn inventory_key(&self) -> String {
+ if self.kind == DeviceKind::Camera
+ && let Some(id) = self.capture_id.as_deref().filter(|s| !s.is_empty())
+ {
+ return format!("cam-live:{id}");
+ }
+ self.config_key.clone()
+ }
}
/// Build the carousel's device list as the **union** of the live inventory and
@@ -80,8 +106,10 @@ impl DeviceRecord {
/// (#271/#280/#387).
pub(super) fn build_device_list(
inventories: &[DeviceInventory],
+ standalone: &[StandaloneDevice],
cache: &AssetResolver,
config: &Config,
+ cameras: &[Camera],
) -> Vec<DeviceRecord> {
let mut list = Vec::new();
for inv in inventories {
@@ -136,15 +164,20 @@ pub(super) fn build_device_list(
codename,
serial_number,
unit_id,
+ driver_id: None,
+ registry_model_id: None,
route,
+ capture_id: None,
kind,
capabilities: paired.capabilities,
+ light_capabilities: None,
slot: paired.slot,
online: paired.online,
battery: paired.battery.clone(),
});
}
}
+ append_standalone(&mut list, standalone, cache);
#[cfg(debug_assertions)]
if std::env::var_os("OPENLOGI_DEMO_KEYBOARD").is_some() {
list.push(demo_keyboard());
@@ -160,10 +193,130 @@ pub(super) fn build_device_list(
cache,
&present_receivers,
);
+ // Cameras are UVC, not HID++, so they come from a parallel discovery path
+ // (AVFoundation on macOS) rather than the receiver inventory. The caller
+ // enumerates them off the UI thread — discovery is too slow for the render
+ // path — so this assembly stays pure; the merge in
+ // `super::AppState::refresh_inventories` reconciles them by config_key.
+ for camera in cameras {
+ list.push(camera_record(camera, cache));
+ }
sort_device_list(&mut list);
list
}
+/// A [`DeviceRecord`] for a Logitech UVC webcam.
+///
+/// [`Camera::config_key`] prefers the USB serial so saved controls survive a
+/// port change; [`DeviceRecord::capture_id`] keeps the OS open id the preview
+/// and UVC layer need. `route: None` / `capabilities: None` keep it out of
+/// every HID++ path — its only detail surface is the live preview tab.
+///
+/// The asset registry keys cameras by their 4-hex USB product id (e.g. the
+/// StreamCam's `0893`), so a webcam's product render resolves through the same
+/// [`AssetResolver`] as HID++ devices once we synthesize a minimal
+/// [`DeviceModelInfo`] from the USB pid.
+fn camera_record(camera: &Camera, cache: &AssetResolver) -> DeviceRecord {
+ let config_key = camera.config_key();
+ let model_info = camera_model_info(camera);
+ let asset = cache.resolve(&model_info, Some(&camera.name));
+ DeviceRecord {
+ model_key: format!("{:04x}", camera.product_id),
+ config_key,
+ persistent: true,
+ display_name: camera.name.clone(),
+ asset,
+ model_info: None,
+ codename: None,
+ serial_number: camera.serial_number.clone(),
+ unit_id: [0; 4],
+ driver_id: None,
+ registry_model_id: None,
+ route: None,
+ capture_id: Some(camera.unique_id.clone()),
+ kind: DeviceKind::Camera,
+ capabilities: None,
+ light_capabilities: None,
+ slot: 0,
+ online: true,
+ battery: None,
+ }
+}
+
+/// A minimal [`DeviceModelInfo`] standing in for a UVC camera, carrying just the
+/// USB product id in `model_ids[0]` so [`AssetResolver::resolve`] can match the
+/// registry's camera depots (which key on the 4-hex pid).
+pub(crate) fn camera_model_info(camera: &Camera) -> DeviceModelInfo {
+ DeviceModelInfo {
+ entity_count: 0,
+ serial_number: None,
+ unit_id: [0; 4],
+ transports: DeviceTransports::default(),
+ model_ids: [camera.product_id, 0, 0],
+ extended_model_id: 0,
+ }
+}
+
+fn append_standalone(
+ list: &mut Vec<DeviceRecord>,
+ devices: &[StandaloneDevice],
+ cache: &AssetResolver,
+) {
+ for device in devices {
+ let route = Some(DeviceRoute::RawHid {
+ vendor_id: device.address.vendor_id,
+ product_id: device.address.product_id,
+ usage_page: device.address.usage_page,
+ usage_id: device.address.usage_id,
+ identity: device.address.identity.clone(),
+ });
+ let stable_id = DeviceStableId::from_parts(
+ route.as_ref(),
+ openlogi_hid::DIRECT_DEVICE_INDEX,
+ device.serial_number.as_deref(),
+ device.unit_id,
+ );
+ let (config_key, persistent) = stable_id.physical_key().map_or_else(
+ || (stable_id.runtime_key(), false),
+ |key| (key.into_string(), true),
+ );
+ let asset = device
+ .registry_model_id
+ .as_deref()
+ .and_then(|model_id| cache.resolve_registry_model(model_id));
+ let display_name = asset
+ .as_ref()
+ .filter(|asset| !asset.display_name.trim().is_empty())
+ .map_or_else(
+ || device.display_name.clone(),
+ |asset| asset.display_name.clone(),
+ );
+ list.push(DeviceRecord {
+ config_key,
+ persistent,
+ // The registry id is presentation metadata, not a replacement for
+ // the raw-device model identity used before registry integration.
+ model_key: format!("raw:{:04x}", device.address.product_id),
+ display_name,
+ asset,
+ model_info: None,
+ codename: None,
+ serial_number: device.serial_number.clone(),
+ unit_id: device.unit_id,
+ driver_id: Some(device.driver_id.clone()),
+ registry_model_id: device.registry_model_id.clone(),
+ route,
+ capture_id: None,
+ kind: device.kind,
+ capabilities: device.capabilities,
+ light_capabilities: device.light_capabilities,
+ slot: openlogi_hid::DIRECT_DEVICE_INDEX,
+ online: device.online,
+ battery: None,
+ });
+ }
+}
+
/// Append an offline placeholder for every known device not already present in
/// `list`, skipping unreachable devices and invalid transient identities.
///
@@ -271,25 +424,44 @@ fn offline_record(
.model_info
.clone()
.or_else(|| model_info_from_legacy_model_key(config_key));
- let asset = model_info
- .as_ref()
- .and_then(|model| cache.resolve(model, identity.codename.as_deref()));
+ let asset = identity
+ .registry_model_id
+ .as_deref()
+ .and_then(|model_id| cache.resolve_registry_model(model_id))
+ .or_else(|| {
+ model_info
+ .as_ref()
+ .and_then(|model| cache.resolve(model, identity.codename.as_deref()))
+ });
+ // Keep offline standalone records keyed exactly as before. The registry id
+ // only selects artwork and must not alter configuration or deduplication.
let model_key = model_info
.as_ref()
.map_or_else(|| config_key.to_string(), DeviceModelInfo::config_key);
+ let display_name = asset
+ .as_ref()
+ .filter(|asset| !asset.display_name.trim().is_empty())
+ .map_or_else(
+ || identity.display_name.clone(),
+ |asset| asset.display_name.clone(),
+ );
DeviceRecord {
config_key: config_key.to_string(),
persistent: true,
model_key,
- display_name: identity.display_name.clone(),
+ display_name,
asset,
model_info,
codename: identity.codename.clone(),
serial_number: None,
unit_id: [0; 4],
+ driver_id: identity.driver_id.clone(),
+ registry_model_id: identity.registry_model_id.clone(),
route: None,
+ capture_id: None,
kind: identity.kind,
capabilities: Some(identity.capabilities),
+ light_capabilities: identity.light_capabilities,
slot: 0,
online: false,
battery: None,
@@ -333,15 +505,21 @@ pub(super) fn adopt_transient_record(known: &DeviceRecord, live: DeviceRecord) -
asset: known.asset.clone().or(live.asset),
model_info: known.model_info.clone().or(live.model_info),
codename: known.codename.clone().or(live.codename),
- serial_number: known.serial_number.clone(),
+ serial_number: known.serial_number.clone().or(live.serial_number),
unit_id: known.unit_id,
+ driver_id: live.driver_id.or_else(|| known.driver_id.clone()),
+ registry_model_id: live
+ .registry_model_id
+ .or_else(|| known.registry_model_id.clone()),
route: live.route,
+ capture_id: live.capture_id.or(known.capture_id.clone()),
kind: if known.kind == DeviceKind::Unknown {
live.kind
} else {
known.kind
},
capabilities: live.capabilities.or(known.capabilities),
+ light_capabilities: live.light_capabilities.or(known.light_capabilities),
slot: live.slot,
online: live.online,
battery: live.battery.or_else(|| known.battery.clone()),
@@ -386,12 +564,16 @@ fn demo_keyboard() -> DeviceRecord {
codename: None,
serial_number: None,
unit_id: [0; 4],
+ driver_id: None,
+ registry_model_id: None,
route: None,
+ capture_id: None,
kind: DeviceKind::Keyboard,
capabilities: Some(Capabilities {
lighting: true,
..Capabilities::default()
}),
+ light_capabilities: None,
slot: 0,
online: true,
battery: None,
@@ -469,16 +651,18 @@ fn prettify_codename(raw: &str) -> String {
#[cfg(test)]
mod tests {
use openlogi_core::config::Config;
- use openlogi_core::device::{DeviceInventory, PairedDevice, ReceiverInfo};
+ use openlogi_core::device::{
+ DeviceInventory, PairedDevice, RawDeviceAddress, ReceiverInfo, StandaloneDevice,
+ };
use crate::asset::AssetResolver;
use std::collections::HashSet;
use super::{
- Capabilities, DeviceIdentity, DeviceKind, DeviceModelInfo, DeviceRecord, DeviceTransports,
- append_offline_known, build_device_list, direct_key_prefix, effective_kind, offline_record,
- pick_initial_device,
+ Camera, Capabilities, DeviceIdentity, DeviceKind, DeviceModelInfo, DeviceRecord,
+ DeviceTransports, append_offline_known, build_device_list, direct_key_prefix,
+ effective_kind, offline_record, pick_initial_device,
};
fn paired_device_no_model_info(slot: u8, wpid: Option<u16>) -> PairedDevice {
@@ -538,9 +722,13 @@ mod tests {
codename: None,
serial_number: None,
unit_id: [1; 4],
+ driver_id: None,
+ registry_model_id: None,
route: None,
+ capture_id: None,
kind: DeviceKind::Mouse,
capabilities: Some(Capabilities::presumed_from_kind(DeviceKind::Mouse)),
+ light_capabilities: None,
slot: 1,
online: true,
battery: None,
@@ -559,16 +747,56 @@ mod tests {
hires_wheel: false,
thumbwheel: false,
},
+ light_capabilities: None,
model_info: None,
codename: None,
+ driver_id: None,
+ registry_model_id: None,
}
}
+ #[test]
+ fn standalone_registry_identity_is_preserved_without_hidpp_model_info() {
+ let device = StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: 0x046d,
+ product_id: 0xc901,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:beam-1".into(),
+ },
+ display_name: "Future Litra model".into(),
+ manufacturer: Some("Logi".into()),
+ serial_number: Some("beam-1".into()),
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: None,
+ driver_id: "litra".into(),
+ registry_model_id: Some("8c901".into()),
+ };
+ let list = build_device_list(
+ &[],
+ std::slice::from_ref(&device),
+ &AssetResolver::new(),
+ &Config::default(),
+ &[],
+ );
+
+ assert_eq!(list.len(), 1);
+ assert_eq!(list[0].driver_id.as_deref(), Some("litra"));
+ assert_eq!(list[0].registry_model_id.as_deref(), Some("8c901"));
+ assert_eq!(list[0].model_key, "raw:c901");
+ assert_eq!(list[0].config_key, "raw:046d:c901:ff43:0202:serial:beam-1");
+ assert!(list[0].asset.is_none());
+ }
+
#[test]
fn no_model_info_uses_receiver_slot_as_config_key() {
let inv = inventory_with(vec![paired_device_no_model_info(1, Some(0x4076))]);
let cache = AssetResolver::new();
- let list = build_device_list(&[inv], &cache, &Config::default());
+ let list = build_device_list(&[inv], &[], &cache, &Config::default(), &[]);
assert_eq!(list.len(), 1);
assert_eq!(list[0].config_key, "receiver:da2699e1:slot:1");
assert_eq!(list[0].model_key, "wpid4076");
@@ -580,7 +808,7 @@ mod tests {
fn no_model_info_falls_back_to_slot_when_no_wpid() {
let inv = inventory_with(vec![paired_device_no_model_info(3, None)]);
let cache = AssetResolver::new();
- let list = build_device_list(&[inv], &cache, &Config::default());
+ let list = build_device_list(&[inv], &[], &cache, &Config::default(), &[]);
assert_eq!(list.len(), 1);
assert_eq!(list[0].config_key, "receiver:da2699e1:slot:3");
assert_eq!(list[0].model_key, "slot3");
@@ -590,7 +818,7 @@ mod tests {
fn no_model_info_display_name_falls_back_to_slot() {
let inv = inventory_with(vec![paired_device_no_model_info(2, Some(0x4051))]);
let cache = AssetResolver::new();
- let list = build_device_list(&[inv], &cache, &Config::default());
+ let list = build_device_list(&[inv], &[], &cache, &Config::default(), &[]);
assert_eq!(list[0].display_name, "Slot 2");
}
@@ -609,6 +837,36 @@ mod tests {
assert_eq!(rec.capabilities, Some(id.capabilities));
}
+ #[test]
+ fn offline_standalone_record_keeps_registry_and_physical_keys() {
+ let id = DeviceIdentity {
+ display_name: "Litra Glow".into(),
+ kind: DeviceKind::Light,
+ capabilities: Capabilities::default(),
+ light_capabilities: None,
+ model_info: None,
+ codename: None,
+ driver_id: Some("litra".into()),
+ registry_model_id: Some("8c900".into()),
+ };
+ let record = offline_record(
+ "raw:046d:c900:ff43:0202:serial:known-light",
+ &id,
+ &AssetResolver::new(),
+ );
+
+ assert_eq!(record.registry_model_id.as_deref(), Some("8c900"));
+ assert_eq!(
+ record.model_key,
+ "raw:046d:c900:ff43:0202:serial:known-light"
+ );
+ assert_eq!(
+ record.config_key,
+ "raw:046d:c900:ff43:0202:serial:known-light"
+ );
+ assert!(record.model_info.is_none());
+ }
+
#[test]
fn known_devices_are_appended_only_when_absent_from_live() {
// "A" is live; "B" is known-but-asleep. The union keeps the live "A"
@@ -652,8 +910,10 @@ mod tests {
let cache = AssetResolver::new();
let list = build_device_list(
&[direct_inventory(model_info(2, 0xb034))],
+ &[],
&cache,
&Config::default(),
+ &[],
);
assert_eq!(list.len(), 1);
@@ -818,4 +1078,99 @@ mod tests {
DeviceKind::Mouse
);
}
+
+ #[test]
+ fn webcams_are_appended_as_camera_records() {
+ // A discovered UVC webcam joins the list as a routeless Camera record.
+ // With a USB serial the config key is port-stable; capture_id keeps the
+ // OS open id the preview needs.
+ let camera = Camera {
+ name: "Logitech StreamCam".to_string(),
+ unique_id: "0x1123000046d0893".to_string(),
+ serial_number: Some("ABC123".to_string()),
+ vendor_id: 0x046d,
+ product_id: 0x0893,
+ max_resolution: Some((1920, 1080)),
+ max_fps: Some(60),
+ };
+ let cache = AssetResolver::new();
+ let list = build_device_list(&[], &[], &cache, &Config::default(), &[camera]);
+
+ assert_eq!(list.len(), 1);
+ assert_eq!(list[0].kind, DeviceKind::Camera);
+ assert_eq!(list[0].config_key, "camera:046d:0893:serial:abc123");
+ assert_eq!(list[0].capture_id.as_deref(), Some("0x1123000046d0893"));
+ assert_eq!(list[0].serial_number.as_deref(), Some("ABC123"));
+ assert_eq!(list[0].display_name, "Logitech StreamCam");
+ assert!(list[0].route.is_none());
+ assert!(list[0].capabilities.is_none());
+ assert!(list[0].online);
+ }
+
+ #[test]
+ fn webcam_without_serial_uses_model_scoped_key() {
+ let camera = Camera {
+ name: "Logitech C920".to_string(),
+ unique_id: "0x14110000046d082d".to_string(),
+ serial_number: None,
+ vendor_id: 0x046d,
+ product_id: 0x082d,
+ max_resolution: None,
+ max_fps: None,
+ };
+ let cache = AssetResolver::new();
+ let list = build_device_list(&[], &[], &cache, &Config::default(), &[camera]);
+ // Port-stable even without a serial: settings follow the model, not the
+ // OS capture id (which embeds the USB location on macOS/Windows).
+ assert_eq!(list[0].config_key, "camera:046d:082d");
+ assert_eq!(list[0].capture_id.as_deref(), Some("0x14110000046d082d"));
+ }
+
+ #[test]
+ fn webcam_config_key_survives_a_usb_port_change() {
+ let port_a = Camera {
+ name: "Logitech StreamCam".to_string(),
+ unique_id: "0x1123000046d0893".to_string(),
+ serial_number: Some("SN42".to_string()),
+ vendor_id: 0x046d,
+ product_id: 0x0893,
+ max_resolution: None,
+ max_fps: None,
+ };
+ let port_b = Camera {
+ unique_id: "0x14110000046d0893".to_string(),
+ ..port_a.clone()
+ };
+ let cache = AssetResolver::new();
+ let a = build_device_list(&[], &[], &cache, &Config::default(), &[port_a]);
+ let b = build_device_list(&[], &[], &cache, &Config::default(), &[port_b]);
+ assert_eq!(a[0].config_key, b[0].config_key);
+ assert_ne!(a[0].capture_id, b[0].capture_id);
+ }
+
+ #[test]
+ fn two_serial_less_same_model_cameras_stay_distinct() {
+ // Settings share the model key (no USB serial to go on); inventory
+ // identity uses capture_id so both still appear in the list.
+ let a = Camera {
+ name: "Logitech StreamCam".to_string(),
+ unique_id: "0x1123000046d0893".to_string(),
+ serial_number: None,
+ vendor_id: 0x046d,
+ product_id: 0x0893,
+ max_resolution: None,
+ max_fps: None,
+ };
+ let b = Camera {
+ unique_id: "0x14110000046d0893".to_string(),
+ ..a.clone()
+ };
+ let cache = AssetResolver::new();
+ let list = build_device_list(&[], &[], &cache, &Config::default(), &[a, b]);
+ assert_eq!(list.len(), 2);
+ assert_eq!(list[0].config_key, list[1].config_key);
+ assert_eq!(list[0].config_key, "camera:046d:0893");
+ assert_ne!(list[0].inventory_key(), list[1].inventory_key());
+ assert_ne!(list[0].capture_id, list[1].capture_id);
+ }
}
diff --git a/crates/openlogi-gui/src/state/dpi.rs b/crates/openlogi-gui/src/state/dpi.rs
new file mode 100644
index 0000000000000000000000000000000000000000..390bba287427883c1deb0e8935466c2980a5ef23
--- /dev/null
+++ b/crates/openlogi-gui/src/state/dpi.rs
@@ -0,0 +1,174 @@
+//! DPI load state, presets, and live writes.
+
+use openlogi_hid::{DeviceRoute, DpiCapabilities, DpiInfo, WriteError};
+use tracing::debug;
+
+use crate::state::devices::DeviceRecord;
+
+use super::load::DpiStatus;
+use super::{AppState, DEFAULT_DPI};
+
+impl AppState {
+ /// The cached DPI-discovery status for `key`, for the diagnostics report.
+ #[must_use]
+ pub fn dpi_status_for(&self, key: &str) -> Option<DpiStatus> {
+ self.dpi_data.get(key).cloned()
+ }
+ /// Replace the DPI preset list for the currently selected device. The
+ /// new list is persisted to `config.toml` and pushed into the shared
+ /// hook map so the next `CycleDpiPresets` press sees it. The cycle
+ /// `index` is reset to 0 — the user just rebuilt the list, the old
+ /// index is meaningless.
+ ///
+ /// No-op when no device is selected (binding panel won't expose the
+ /// editor in that state).
+ pub fn commit_dpi_presets(&mut self, presets: Vec<u32>) {
+ let Some(key) = self
+ .current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ .map(str::to_string)
+ else {
+ debug!("no persistent device key — DPI presets kept in memory only");
+ return;
+ };
+ self.config.set_dpi_presets(&key, presets);
+ self.persist_and_reload("DPI presets");
+ }
+ /// Read the DPI preset list for the active device, or an empty `Vec`
+ /// when no device is selected. UI helper.
+ #[must_use]
+ pub fn dpi_presets(&self) -> Vec<u32> {
+ self.current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ .map(|key| self.config.dpi_presets(key))
+ .unwrap_or_default()
+ }
+ /// DPI capability status for the active device.
+ #[must_use]
+ pub fn current_dpi_status(&self) -> DpiStatus {
+ self.current_record().map_or(DpiStatus::Unknown, |record| {
+ self.dpi_data.status(&record.config_key)
+ })
+ }
+ /// Whether the active device still needs a DPI read (no status recorded —
+ /// i.e. `Unknown`). Cheaper than `current_dpi_status() == Unknown`: it
+ /// avoids cloning the `DpiInfo`, which matters on the per-frame render path.
+ #[must_use]
+ pub fn current_dpi_unqueried(&self) -> bool {
+ self.current_record()
+ .is_some_and(|record| self.dpi_data.unqueried(&record.config_key))
+ }
+ /// The active device's known DPI, falling back to [`DEFAULT_DPI`] until its
+ /// capability read completes. Used to seed `self.dpi` on a device switch.
+ #[must_use]
+ pub(crate) fn dpi_for_current(&self) -> u32 {
+ self.current_record()
+ .and_then(|record| self.dpi_data.get(&record.config_key))
+ .and_then(|status| match status {
+ DpiStatus::Ready(info) => Some(u32::from(info.current)),
+ _ => None,
+ })
+ .unwrap_or(DEFAULT_DPI)
+ }
+ /// Mark DPI capability discovery as in flight for `key`.
+ pub fn mark_dpi_loading(&mut self, key: &str) {
+ self.dpi_data.mark_loading(key);
+ }
+ /// Reset a stuck `Loading` for `key` back to `Unknown`. Called when the
+ /// discovery worker vanished without delivering a result (e.g. it panicked),
+ /// so the device isn't wedged on "Reading…" with no path to retry.
+ pub fn clear_dpi_loading(&mut self, key: &str) {
+ self.dpi_data.clear_loading(key);
+ }
+ /// Drop the active device's recorded DPI status so the next render
+ /// re-runs discovery. Backs the "click to retry" affordance on a
+ /// [`DpiStatus::Failed`] device, which is the only recovery path when the
+ /// carousel has a single device (re-selecting it is a no-op).
+ pub fn retry_active_dpi(&mut self) {
+ if let Some(key) = self.current_record().map(|r| r.config_key.clone()) {
+ self.dpi_data.retry(&key);
+ }
+ }
+ /// Store a DPI capability discovery result if it still matches the known
+ /// device route. This guards against async reads completing after the
+ /// carousel or inventory changed.
+ pub fn store_dpi_info(
+ &mut self,
+ key: String,
+ route: &DeviceRoute,
+ result: Result<DpiInfo, WriteError>,
+ ) {
+ let is_active = self.current_record().map(|r| r.config_key.as_str()) == Some(key.as_str());
+ let matches_route = self
+ .device_list
+ .iter()
+ .any(|record| record.config_key == key && record.route.as_ref() == Some(route));
+ let still_present = self
+ .device_list
+ .iter()
+ .any(|record| record.config_key == key);
+ // Only the active device owns the shared `self.dpi`; a result landing for
+ // a background device after a carousel switch must not clobber the
+ // visible value.
+ if let Some(info) = self.dpi_data.store(
+ key,
+ result,
+ dpi_error_is_permanent,
+ matches_route,
+ still_present,
+ "DPI",
+ ) && is_active
+ {
+ self.dpi = u32::from(info.current);
+ }
+ }
+ /// DPI capabilities for the active device, if discovery succeeded.
+ #[must_use]
+ pub fn active_dpi_capabilities(&self) -> Option<&DpiCapabilities> {
+ self.current_record()
+ .and_then(|record| self.dpi_data.get(&record.config_key))
+ .and_then(|status| match status {
+ DpiStatus::Ready(info) => Some(&info.capabilities),
+ DpiStatus::Unknown
+ | DpiStatus::Loading
+ | DpiStatus::Failed(_)
+ | DpiStatus::Unsupported(_) => None,
+ })
+ }
+ /// Snap `dpi` to the active device's supported list when known.
+ #[must_use]
+ pub fn normalize_active_dpi(&self, dpi: u32) -> u32 {
+ self.active_dpi_capabilities()
+ .map_or(dpi, |caps| caps.snap(dpi))
+ }
+ /// Apply `dpi` to the active device (best-effort, via the agent) and
+ /// persist it per device — the sensor value lives in device RAM and resets
+ /// on a power cycle (#189), so the agent re-applies it on reconnect.
+ /// Updates the displayed value even with no device selected.
+ pub fn commit_dpi(&mut self, dpi: u32) {
+ self.dpi = dpi;
+ let Some(record) = self.current_record() else {
+ debug!("no active device — DPI change kept in memory only");
+ return;
+ };
+ let key = record.config_key.clone();
+ let persistent_key = record.persistent_config_key().map(str::to_string);
+ let route = record.route.clone();
+ if let Some(route) = route {
+ self.send_ipc(crate::ipc_client::Command::SetDpi(route, dpi));
+ }
+ if let Some(persistent_key) = persistent_key {
+ self.config.set_dpi(&persistent_key, dpi);
+ self.persist_and_reload("DPI");
+ } else {
+ debug!(key, "transient device DPI applied without persistence");
+ }
+ }
+}
+
+pub(crate) fn dpi_error_is_permanent(error: &WriteError) -> bool {
+ matches!(
+ error,
+ WriteError::FeatureUnsupported { .. } | WriteError::EmptyDpiList
+ )
+}
diff --git a/crates/openlogi-gui/src/state/inventory.rs b/crates/openlogi-gui/src/state/inventory.rs
new file mode 100644
index 0000000000000000000000000000000000000000..26a8cda8812ab2ebe568946d4466aa3beea57e3e
--- /dev/null
+++ b/crates/openlogi-gui/src/state/inventory.rs
@@ -0,0 +1,363 @@
+//! Device list refresh, transient adoption, and selection.
+
+use std::collections::{BTreeMap, HashSet};
+
+use openlogi_core::config::{Config, DeviceIdentity};
+use openlogi_core::device::{DeviceInventory, StandaloneDevice};
+use tracing::debug;
+
+use crate::asset::AssetResolver;
+use crate::asset::sync::{AssetTarget, model_key};
+use crate::state::devices::{
+ DeviceRecord, adopt_transient_record, build_device_list, direct_key_prefix, sort_device_list,
+};
+
+use super::load::Load;
+use super::{AppState, INVENTORY_MISS_GRACE};
+
+impl AppState {
+ /// Every known device model that can be resolved to an asset depot.
+ ///
+ /// This reads the UI's merged device list rather than only the latest live
+ /// inventory, so a temporarily incomplete probe can still download art for
+ /// a device restored from its persisted identity.
+ pub(crate) fn asset_models(&self) -> Vec<AssetTarget> {
+ let mut seen = HashSet::new();
+ self.device_list
+ .iter()
+ .filter_map(|record| {
+ let target = record
+ .registry_model_id
+ .clone()
+ .map(|registry_model_id| AssetTarget::Standalone { registry_model_id })
+ .or_else(|| {
+ record.model_info.clone().map(|model| AssetTarget::Hidpp {
+ model,
+ codename: record.codename.clone(),
+ })
+ })?;
+ seen.insert(model_key(&target)).then_some(target)
+ })
+ .collect()
+ }
+ /// Replace [`Self::device_list`] from a fresh inventory snapshot,
+ /// preserving the carousel selection by `config_key` when possible. If
+ /// the previously-selected device disappeared, the selection falls back
+ /// to index 0. Returns whether anything actually changed.
+ ///
+ /// No-op (returning `false`) when the new list has the same `config_key`
+ /// sequence as the current one — the caller skips the window refresh, and
+ /// quiet polling cycles cause no spurious re-renders (P1.6). `force`
+ /// pushes through that early-return: the records embed resolved asset
+ /// paths, so a completed asset sync needs one rebuild even though the
+ /// device *set* is unchanged.
+ pub fn refresh_inventories(
+ &mut self,
+ inventories: &[DeviceInventory],
+ standalone: &[StandaloneDevice],
+ cache: &AssetResolver,
+ force: bool,
+ cameras: &[openlogi_camera::Camera],
+ ) -> bool {
+ let new_list = build_device_list(inventories, standalone, cache, &self.config, cameras);
+ let merged_list = self.merge_inventory_snapshot(new_list);
+ // Capture any newly-probed identity before the unchanged-check can early
+ // out: a device whose capabilities just resolved keeps the same
+ // config_key + route, so that guard would otherwise skip the write.
+ if persist_identities(&mut self.config, &merged_list) {
+ self.persist_config("device identity");
+ }
+ // Compare more than config_key: a device can reconnect on a new HID++
+ // index while keeping its physical config key, and the fresh route must
+ // replace the stale one so reads/writes don't target a dead index.
+ // `online` and `capabilities` are compared too, so a device waking up or
+ // a probe that resolves its feature table on a stable route still
+ // refreshes the carousel (and its config panels) instead of being
+ // swallowed by this guard.
+ let unchanged = merged_list.len() == self.device_list.len()
+ && merged_list
+ .iter()
+ .zip(self.device_list.iter())
+ .all(|(a, b)| {
+ a.config_key == b.config_key
+ && a.capture_id == b.capture_id
+ && a.route == b.route
+ && a.online == b.online
+ && a.capabilities == b.capabilities
+ && a.light_capabilities == b.light_capabilities
+ && a.driver_id == b.driver_id
+ && a.registry_model_id == b.registry_model_id
+ && a.kind == b.kind
+ });
+ if unchanged && !force {
+ return false;
+ }
+
+ let previous_key = self.current_record().map(DeviceRecord::inventory_key);
+ let new_index = previous_key
+ .as_deref()
+ .and_then(|k| merged_list.iter().position(|r| r.inventory_key() == k))
+ .unwrap_or(0);
+ let connected_keys = merged_list
+ .iter()
+ .map(|r| r.config_key.as_str())
+ .collect::<Vec<_>>();
+ debug!(
+ count = merged_list.len(),
+ ?connected_keys,
+ "inventory refreshed"
+ );
+
+ // A device that came back on a different route must re-discover DPI —
+ // its cached status/attempts were keyed to the now-dead route.
+ let rerouted: Vec<String> = merged_list
+ .iter()
+ .filter(|new| {
+ self.device_list
+ .iter()
+ .any(|old| old.config_key == new.config_key && old.route != new.route)
+ })
+ .map(|new| new.config_key.clone())
+ .collect();
+
+ self.device_list = merged_list;
+ for key in &rerouted {
+ self.dpi_data.remove(key);
+ self.smartshift_data.remove(key);
+ self.smartshift_pending_confirm.remove(key);
+ self.smartshift_write_status.remove(key);
+ }
+ let present = |key: &str| {
+ self.device_list
+ .iter()
+ .any(|r| r.config_key.as_str() == key)
+ };
+ self.dpi_data.retain_present(present);
+ self.smartshift_data.retain_present(present);
+ self.smartshift_pending_confirm
+ .retain(|key, _| present(key));
+ self.smartshift_write_status.retain(|key, _| present(key));
+ self.current_device = new_index;
+ // The active device may have changed (selection fell back to index 0
+ // when the previous one vanished); re-seed the displayed DPI so it
+ // tracks the now-current device rather than the old one.
+ self.dpi = self.dpi_for_current();
+ self.button_bindings = self.bindings_for_current();
+ self.gesture_bindings = self.gesture_bindings_for_current();
+ // Display state only — the agent runs its own inventory watcher and
+ // rebuilds the live binding/DPI maps itself.
+ true
+ }
+ pub(crate) fn merge_inventory_snapshot(
+ &mut self,
+ new_list: Vec<DeviceRecord>,
+ ) -> Vec<DeviceRecord> {
+ let mut by_key = new_list
+ .into_iter()
+ .map(|record| (record.inventory_key(), record))
+ .collect::<BTreeMap<_, _>>();
+ let mut adopted = self.adopt_transient_records(&mut by_key);
+ let mut merged = Vec::with_capacity(by_key.len().max(self.device_list.len()));
+
+ for previous in &self.device_list {
+ let inv = previous.inventory_key();
+ if let Some(record) = by_key.remove(&inv) {
+ self.inventory_misses.remove(&inv);
+ merged.push(record);
+ continue;
+ }
+
+ if let Some(record) = adopted.remove(&inv) {
+ self.inventory_misses.remove(&inv);
+ merged.push(record);
+ continue;
+ }
+
+ // An all-zero direct unit id is only a transient probe result. If
+ // the next snapshot resolves a physical serial/unit key, retaining
+ // this record through the normal miss grace would show both cards.
+ if !previous.is_persistent() {
+ self.inventory_misses.remove(&inv);
+ continue;
+ }
+
+ // Cameras reappear under a new capture id after a port change —
+ // do not grace-keep a stale cam-live entry beside the new one.
+ if previous.kind == openlogi_core::device::DeviceKind::Camera {
+ self.inventory_misses.remove(&inv);
+ continue;
+ }
+
+ let misses = self.inventory_misses.entry(inv.clone()).or_insert(0);
+ *misses = misses.saturating_add(1);
+ if *misses <= INVENTORY_MISS_GRACE {
+ debug!(
+ key = %inv,
+ misses = *misses,
+ "keeping device through transient inventory miss"
+ );
+ merged.push(previous.clone());
+ }
+ }
+
+ for (key, record) in by_key {
+ self.inventory_misses.remove(&key);
+ merged.push(record);
+ }
+ // Adopted records whose known card was never in the previous list
+ // (identity known only from config) still belong in the carousel.
+ merged.extend(adopted.into_values());
+ self.inventory_misses
+ .retain(|key, _| merged.iter().any(|record| record.inventory_key() == *key));
+ // `merged` is `previous-order + newly-appeared`, so re-apply the
+ // canonical route order or a new device would be stuck at the end of
+ // the carousel permanently.
+ sort_device_list(&mut merged);
+ merged
+ }
+ /// Pair each transient direct record in the snapshot with the device it
+ /// physically is. A transient key (`…:unit:00000000`) is a half-read probe
+ /// of some existing device, not a new one (#482): when exactly one known
+ /// card sharing its `direct:<vid>:<pid>` wire identity is not live online —
+ /// so the half-read probe can only be that device — the transient record is
+ /// folded into that card instead of surfacing beside it (or evicting it).
+ /// With no such card the transient is dropped as probe noise when its wire
+ /// product is already live online, and an ambiguous one (two known
+ /// same-model cards absent) is left alone.
+ pub(crate) fn adopt_transient_records(
+ &self,
+ by_key: &mut BTreeMap<String, DeviceRecord>,
+ ) -> BTreeMap<String, DeviceRecord> {
+ let transient_keys: Vec<String> = by_key
+ .values()
+ .filter(|record| !record.is_persistent())
+ .map(|record| record.config_key.clone())
+ .collect();
+ let mut adopted = BTreeMap::new();
+ for key in transient_keys {
+ let Some(prefix) = direct_key_prefix(&key) else {
+ continue;
+ };
+ let same_wire = |key: &str, record: &DeviceRecord| {
+ record.is_persistent() && direct_key_prefix(key) == Some(prefix)
+ };
+ // A live online sibling is accounted for and never a candidate,
+ // but it must not discard the transient — the half-read probe may
+ // be the *other* same-model device.
+ let mut candidates: Vec<String> = by_key
+ .iter()
+ .filter(|(k, record)| same_wire(k, record) && !record.online)
+ .map(|(k, _)| k.clone())
+ .collect();
+ for previous in &self.device_list {
+ if same_wire(&previous.config_key, previous)
+ && !by_key.contains_key(&previous.config_key)
+ && !candidates.contains(&previous.config_key)
+ {
+ candidates.push(previous.config_key.clone());
+ }
+ }
+ let [known_key] = candidates.as_slice() else {
+ if candidates.is_empty()
+ && by_key
+ .iter()
+ .any(|(k, record)| same_wire(k, record) && record.online)
+ {
+ by_key.remove(&key);
+ }
+ continue;
+ };
+ // Last tick's record carries the freshest identity; the offline
+ // placeholder built from config is the fallback.
+ let known = self
+ .device_list
+ .iter()
+ .find(|record| record.config_key == *known_key)
+ .cloned()
+ .or_else(|| by_key.get(known_key).cloned());
+ let Some(known) = known else {
+ continue;
+ };
+ let known_key = known_key.clone();
+ by_key.remove(&known_key);
+ if let Some(live) = by_key.remove(&key) {
+ adopted.insert(known_key, adopt_transient_record(&known, live));
+ }
+ }
+ adopted
+ }
+ /// Switch the carousel to `idx`. Out-of-range indices are silently
+ /// ignored so callers can pass them straight through from UI events.
+ /// Persists the new selection (by config key, not index — index isn't
+ /// stable across restarts), reloads bindings for the new device, and
+ /// pushes the new map into the hook-shared `Arc`.
+ pub fn set_current_device(&mut self, idx: usize) {
+ if idx >= self.device_list.len() || idx == self.current_device {
+ return;
+ }
+ self.current_device = idx;
+ // A device left in `Failed` (transient read errors exhausted its retry
+ // budget) gets one fresh attempt each time it is re-selected.
+ if let Some(key) = self.current_record().map(|r| r.config_key.clone()) {
+ if matches!(self.dpi_data.get(&key), Some(Load::Failed(_))) {
+ self.dpi_data.retry(&key);
+ }
+ if matches!(self.smartshift_data.get(&key), Some(Load::Failed(_))) {
+ self.smartshift_data.retry(&key);
+ self.smartshift_write_status.remove(&key);
+ }
+ }
+ // `self.dpi` is the active device's value; adopt the newly-selected
+ // device's known DPI so the panel doesn't keep showing the previous
+ // device's number until a fresh read lands.
+ self.dpi = self.dpi_for_current();
+ self.button_bindings = self.bindings_for_current();
+ self.gesture_bindings = self.gesture_bindings_for_current();
+ let Some(key) = self
+ .current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ .map(str::to_string)
+ else {
+ debug!("transient device selection not persisted");
+ return;
+ };
+ self.config.set_selected_device(Some(key));
+ // The agent owns the hook + device I/O; have it switch devices too.
+ self.persist_and_reload("selected device");
+ }
+}
+
+pub(super) fn persist_identities(config: &mut Config, list: &[DeviceRecord]) -> bool {
+ let mut changed = false;
+ for record in list {
+ if !record.online {
+ continue;
+ }
+ let Some(config_key) = record.persistent_config_key() else {
+ continue;
+ };
+ let capabilities = record.capabilities.unwrap_or_default();
+ if record.light_capabilities.is_none() && record.capabilities.is_none() {
+ continue;
+ }
+ let identity = DeviceIdentity {
+ display_name: record.display_name.clone(),
+ kind: record.kind,
+ capabilities,
+ light_capabilities: record.light_capabilities,
+ model_info: record.model_info.clone().map(|mut model| {
+ model.serial_number = None;
+ model.unit_id = [0; 4];
+ model
+ }),
+ codename: record.codename.clone(),
+ driver_id: record.driver_id.clone(),
+ registry_model_id: record.registry_model_id.clone(),
+ };
+ if config.device_identity(config_key) != Some(&identity) {
+ config.set_device_identity(config_key, identity);
+ changed = true;
+ }
+ }
+ changed
+}
diff --git a/crates/openlogi-gui/src/state/light.rs b/crates/openlogi-gui/src/state/light.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ce28509c5166c6576fbc0e3d0ab27203fd6ab35f
--- /dev/null
+++ b/crates/openlogi-gui/src/state/light.rs
@@ -0,0 +1,507 @@
+//! Optimistic standalone-light state and IPC result handling.
+
+use std::collections::BTreeMap;
+
+use openlogi_core::config::LightSettings;
+use openlogi_hid::{DeviceRoute, LightCommand, WriteError};
+use tracing::debug;
+
+use super::AppState;
+
+const fn camera_policy_applies(light: LightSettings) -> bool {
+ cfg!(target_os = "macos") && light.auto_camera
+}
+
+/// Result state of the latest standalone-light command for one device.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum LightCommandStatus {
+ /// The command has been queued for the agent.
+ Pending,
+ /// The device or agent rejected the command.
+ Failed(String),
+ /// No route is available because the device is offline.
+ Offline,
+}
+
+pub(super) struct PendingLightCommand {
+ request_id: u64,
+ pending: u16,
+ settings: Option<LightSettings>,
+ persistent_key: Option<String>,
+ rollback_settings: LightSettings,
+ previous_volatile: Option<LightSettings>,
+ manual_override_rollback: Option<ManualOverrideRollback>,
+ successful_commands: Vec<LightCommand>,
+ failure: Option<String>,
+ superseded: Vec<SupersededLightCommand>,
+}
+
+struct SupersededLightCommand {
+ request_id: u64,
+ pending: u16,
+ successful_commands: Vec<LightCommand>,
+ failure: Option<String>,
+}
+
+#[derive(Clone, Copy)]
+struct ManualOverrideRollback {
+ previous: Option<bool>,
+}
+
+impl AppState {
+ /// Effective power state for the selected standalone light.
+ #[must_use]
+ pub fn light_enabled(&self) -> bool {
+ self.current_record()
+ .is_some_and(|record| self.light_enabled_for(&record.config_key))
+ }
+
+ /// Effective power state for any standalone-light key.
+ #[must_use]
+ pub fn light_enabled_for(&self, key: &str) -> bool {
+ let light = self.light_for(key);
+ if camera_policy_applies(light) {
+ self.manual_light_overrides
+ .get(key)
+ .copied()
+ .unwrap_or(self.camera_active)
+ } else {
+ light.enabled
+ }
+ }
+
+ /// Update the runtime camera state used by camera-linked light rendering.
+ /// A real transition clears every transient manual override.
+ pub fn set_camera_active(&mut self, active: bool) -> bool {
+ let changed = self.camera_active != active;
+ if changed {
+ self.manual_light_overrides.clear();
+ }
+ self.camera_active = active;
+ changed
+ }
+
+ /// Whether the selected light is currently governed by a supported camera
+ /// automation provider. The persisted setting remains portable, while
+ /// platforms without a provider retain normal manual power behaviour.
+ #[must_use]
+ pub fn camera_automation_active(&self) -> bool {
+ camera_policy_applies(self.light())
+ }
+
+ /// Latest light-command status for the selected device, if one exists.
+ #[must_use]
+ pub fn light_command_status(&self) -> Option<LightCommandStatus> {
+ let key = self.current_record()?.config_key.as_str();
+ self.light_command_status
+ .as_ref()
+ .filter(|(status_key, _, _)| status_key == key)
+ .map(|(_, _, status)| status.clone())
+ }
+
+ fn begin_light_command(&mut self, key: &str, online: bool) -> u64 {
+ self.next_light_request_id = self.next_light_request_id.wrapping_add(1);
+ let request_id = self.next_light_request_id;
+ if online {
+ self.light_commands.insert(
+ key.to_string(),
+ PendingLightCommand {
+ request_id,
+ pending: 0,
+ settings: None,
+ persistent_key: None,
+ rollback_settings: LightSettings::default(),
+ previous_volatile: None,
+ manual_override_rollback: None,
+ successful_commands: Vec::new(),
+ failure: None,
+ superseded: Vec::new(),
+ },
+ );
+ self.light_command_status =
+ Some((key.to_string(), request_id, LightCommandStatus::Pending));
+ } else {
+ self.light_command_status =
+ Some((key.to_string(), request_id, LightCommandStatus::Offline));
+ }
+ request_id
+ }
+
+ fn queue_light_command(
+ &mut self,
+ key: &str,
+ request_id: u64,
+ route: DeviceRoute,
+ command: LightCommand,
+ ) {
+ if let Some(pending) = self.light_commands.get_mut(key)
+ && pending.request_id == request_id
+ {
+ pending.pending = pending.pending.saturating_add(1);
+ }
+ if !self.send_ipc(crate::ipc_client::Command::SetLight(
+ route,
+ command,
+ key.to_string(),
+ request_id,
+ )) {
+ self.apply_light_command_result(
+ key.to_string(),
+ request_id,
+ command,
+ Err(WriteError::AgentUnavailable),
+ );
+ }
+ }
+
+ fn supersede_light_command(&mut self, key: &str) -> Vec<SupersededLightCommand> {
+ let Some(pending) = self.light_commands.remove(key) else {
+ return Vec::new();
+ };
+ let mut superseded = pending.superseded;
+ superseded.push(SupersededLightCommand {
+ request_id: pending.request_id,
+ pending: pending.pending,
+ successful_commands: pending.successful_commands,
+ failure: pending.failure,
+ });
+ superseded
+ }
+
+ /// Consume an asynchronous light-write result from the IPC client.
+ /// Results from an older request are ignored so a slow failed write cannot
+ /// overwrite the status of a newer slider release.
+ pub fn apply_light_command_result(
+ &mut self,
+ key: String,
+ request_id: u64,
+ command: LightCommand,
+ result: Result<(), WriteError>,
+ ) -> bool {
+ let Some(pending) = self.light_commands.get_mut(&key) else {
+ return false;
+ };
+ if pending.request_id == request_id {
+ pending.pending = pending.pending.saturating_sub(1);
+ match result {
+ Ok(()) => pending.successful_commands.push(command),
+ Err(error) => {
+ pending.failure.get_or_insert_with(|| error.to_string());
+ }
+ }
+ } else if let Some(superseded) = pending
+ .superseded
+ .iter_mut()
+ .find(|superseded| superseded.request_id == request_id)
+ {
+ superseded.pending = superseded.pending.saturating_sub(1);
+ match result {
+ Ok(()) => superseded.successful_commands.push(command),
+ Err(error) => {
+ superseded.failure.get_or_insert_with(|| error.to_string());
+ }
+ }
+ } else {
+ return false;
+ }
+ if pending.pending != 0 || pending.superseded.iter().any(|batch| batch.pending != 0) {
+ // A sibling may still be queued after the first failure. Keep the
+ // request alive so later successes are reflected in the reconciled
+ // GUI/config state instead of being discarded as stale.
+ return true;
+ }
+
+ let Some(pending) = self.light_commands.remove(&key) else {
+ return false;
+ };
+ let failure = pending.failure.clone().or_else(|| {
+ pending
+ .superseded
+ .iter()
+ .find_map(|batch| batch.failure.clone())
+ });
+ let successful_commands = pending
+ .superseded
+ .iter()
+ .flat_map(|batch| batch.successful_commands.iter().copied())
+ .chain(pending.successful_commands.iter().copied())
+ .collect::<Vec<_>>();
+ let manual_override_rollback = pending.manual_override_rollback;
+ if let Some(error) = failure {
+ if successful_commands.is_empty() {
+ if let Some(previous) = pending.previous_volatile {
+ self.volatile_light_settings.insert(key.clone(), previous);
+ } else {
+ self.volatile_light_settings.remove(&key);
+ }
+ restore_manual_override(
+ &mut self.manual_light_overrides,
+ &key,
+ manual_override_rollback,
+ );
+ } else {
+ let mut accepted = pending.rollback_settings;
+ for &command in &successful_commands {
+ apply_light_command(&mut accepted, command);
+ }
+ if let Some(persistent_key) = pending.persistent_key {
+ self.volatile_light_settings.remove(&key);
+ self.config.set_light(&persistent_key, accepted);
+ self.persist_and_reload("partial light");
+ } else {
+ self.volatile_light_settings.insert(key.clone(), accepted);
+ }
+ if !successful_commands
+ .iter()
+ .any(|command| matches!(command, LightCommand::Power(_)))
+ {
+ restore_manual_override(
+ &mut self.manual_light_overrides,
+ &key,
+ manual_override_rollback,
+ );
+ }
+ }
+ self.light_command_status = Some((key, request_id, LightCommandStatus::Failed(error)));
+ } else {
+ if let (Some(settings), Some(persistent_key)) =
+ (pending.settings, pending.persistent_key)
+ {
+ self.config.set_light(&persistent_key, settings);
+ self.volatile_light_settings.remove(&key);
+ self.persist_and_reload("light");
+ }
+ // Successful writes are reflected by the controls themselves; do
+ // not leave a persistent success banner in the panel.
+ self.light_command_status = None;
+ }
+ true
+ }
+
+ /// The standalone-light settings for the active device, or defaults when
+ /// no light config has been stored yet.
+ #[must_use]
+ pub fn light(&self) -> LightSettings {
+ self.current_record()
+ .map_or_else(LightSettings::default, |record| {
+ self.light_for(&record.config_key)
+ })
+ }
+
+ /// The standalone-light settings for any persistent or runtime device key.
+ #[must_use]
+ pub fn light_for(&self, key: &str) -> LightSettings {
+ self.volatile_light_settings
+ .get(key)
+ .copied()
+ .or_else(|| self.config.light(key))
+ .unwrap_or_default()
+ }
+
+ /// Persist and apply standalone-light settings through the agent-owned
+ /// raw-HID path. Online persistent changes are committed only after every
+ /// advertised device command succeeds; failures roll optimistic state back.
+ pub fn commit_light(&mut self, light: LightSettings) {
+ let Some((runtime_key, key, route, online, capabilities)) =
+ self.current_record().map(|record| {
+ (
+ record.config_key.clone(),
+ record.persistent_config_key().map(str::to_string),
+ record.route.clone(),
+ record.online,
+ record.light_capabilities,
+ )
+ })
+ else {
+ debug!("no active device — light change ignored");
+ return;
+ };
+ let previous = self.light_for(&runtime_key);
+ let camera_mode_changed =
+ cfg!(target_os = "macos") && previous.auto_camera != light.auto_camera;
+ let effective_enabled = if camera_policy_applies(light) {
+ if camera_mode_changed {
+ self.camera_active
+ } else {
+ self.light_enabled_for(&runtime_key)
+ }
+ } else {
+ light.enabled
+ };
+ let inherited_override_rollback = self
+ .light_commands
+ .get(&runtime_key)
+ .and_then(|pending| pending.manual_override_rollback);
+ let manual_override_rollback = inherited_override_rollback.or_else(|| {
+ camera_mode_changed.then(|| ManualOverrideRollback {
+ previous: self.manual_light_overrides.get(&runtime_key).copied(),
+ })
+ });
+ if camera_mode_changed {
+ self.manual_light_overrides.remove(&runtime_key);
+ }
+ let mut effective = light;
+ effective.enabled = effective_enabled;
+ let commands = capabilities.map_or_else(Vec::new, |capabilities| {
+ openlogi_hid::commands_for_light_settings(effective, capabilities)
+ });
+ // If this request supersedes another optimistic write, both must roll
+ // back to the last accepted value—not to the superseded pending value.
+ let (rollback_settings, previous_volatile) =
+ self.light_commands.get(&runtime_key).map_or_else(
+ || {
+ (
+ previous,
+ self.volatile_light_settings.get(&runtime_key).copied(),
+ )
+ },
+ |pending| (pending.rollback_settings, pending.previous_volatile),
+ );
+ self.volatile_light_settings
+ .insert(runtime_key.clone(), light);
+ if !commands.is_empty() {
+ let can_apply = online && route.is_some();
+ let superseded = if can_apply {
+ self.supersede_light_command(&runtime_key)
+ } else {
+ Vec::new()
+ };
+ let request_id = self.begin_light_command(&runtime_key, can_apply);
+ if can_apply && let Some(route) = route {
+ if let Some(pending) = self.light_commands.get_mut(&runtime_key) {
+ pending.settings = Some(light);
+ pending.persistent_key.clone_from(&key);
+ pending.rollback_settings = rollback_settings;
+ pending.previous_volatile = previous_volatile;
+ pending.manual_override_rollback = manual_override_rollback;
+ pending.superseded = superseded;
+ }
+ for command in commands {
+ self.queue_light_command(&runtime_key, request_id, route.clone(), command);
+ }
+ return;
+ }
+ }
+ if let Some(key) = key {
+ self.volatile_light_settings.remove(&runtime_key);
+ self.config.set_light(&key, light);
+ self.persist_and_reload("light");
+ } else {
+ self.volatile_light_settings.insert(runtime_key, light);
+ }
+ }
+
+ /// Apply a transient manual power choice while camera automation remains
+ /// enabled. The persisted `enabled` field is updated as the manual fallback,
+ /// but the runtime override lasts only until the next camera transition.
+ pub fn commit_manual_light_power(&mut self, enabled: bool) {
+ let Some((runtime_key, key, route, online)) = self.current_record().map(|record| {
+ (
+ record.config_key.clone(),
+ record.persistent_config_key().map(str::to_string),
+ record.route.clone(),
+ record.online,
+ )
+ }) else {
+ debug!("no active device — manual light power ignored");
+ return;
+ };
+ let mut light = self.light_for(&runtime_key);
+ if !camera_policy_applies(light) {
+ light.enabled = enabled;
+ self.commit_light(light);
+ return;
+ }
+
+ let (rollback_settings, previous_volatile) =
+ self.light_commands.get(&runtime_key).map_or_else(
+ || {
+ (
+ light,
+ self.volatile_light_settings.get(&runtime_key).copied(),
+ )
+ },
+ |pending| (pending.rollback_settings, pending.previous_volatile),
+ );
+ let manual_override_rollback = self
+ .light_commands
+ .get(&runtime_key)
+ .and_then(|pending| pending.manual_override_rollback)
+ .or_else(|| {
+ Some(ManualOverrideRollback {
+ previous: self.manual_light_overrides.get(&runtime_key).copied(),
+ })
+ });
+ light.enabled = enabled;
+ self.manual_light_overrides
+ .insert(runtime_key.clone(), enabled);
+ self.volatile_light_settings
+ .insert(runtime_key.clone(), light);
+
+ let can_apply = online && route.is_some();
+ let superseded = if can_apply {
+ self.supersede_light_command(&runtime_key)
+ } else {
+ Vec::new()
+ };
+ let request_id = self.begin_light_command(&runtime_key, can_apply);
+ if can_apply && let Some(route) = route {
+ if let Some(pending) = self.light_commands.get_mut(&runtime_key) {
+ pending.pending = 1;
+ pending.settings = Some(light);
+ pending.persistent_key.clone_from(&key);
+ pending.rollback_settings = rollback_settings;
+ pending.previous_volatile = previous_volatile;
+ pending.manual_override_rollback = manual_override_rollback;
+ pending.superseded = superseded;
+ }
+ if !self.send_ipc(crate::ipc_client::Command::SetLightManualPower(
+ route,
+ enabled,
+ runtime_key.clone(),
+ request_id,
+ )) {
+ self.apply_light_command_result(
+ runtime_key,
+ request_id,
+ LightCommand::Power(enabled),
+ Err(WriteError::AgentUnavailable),
+ );
+ }
+ return;
+ }
+
+ if let Some(key) = key {
+ self.volatile_light_settings.remove(&runtime_key);
+ self.config.set_light(&key, light);
+ self.persist_and_reload("manual light power");
+ }
+ }
+}
+
+fn apply_light_command(settings: &mut LightSettings, command: LightCommand) {
+ match command {
+ LightCommand::Power(enabled) => settings.enabled = enabled,
+ LightCommand::BrightnessPercent(brightness_percent) => {
+ settings.brightness_percent = brightness_percent;
+ }
+ LightCommand::TemperatureKelvin(temperature_kelvin) => {
+ settings.temperature_kelvin = Some(temperature_kelvin);
+ }
+ LightCommand::BrightnessNative(_) => {}
+ }
+}
+
+fn restore_manual_override(
+ overrides: &mut BTreeMap<String, bool>,
+ key: &str,
+ rollback: Option<ManualOverrideRollback>,
+) {
+ if let Some(rollback) = rollback {
+ if let Some(previous) = rollback.previous {
+ overrides.insert(key.to_string(), previous);
+ } else {
+ overrides.remove(key);
+ }
+ }
+}
diff --git a/crates/openlogi-gui/src/state/lighting.rs b/crates/openlogi-gui/src/state/lighting.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ee0dfeaf9bc4d7fdf91ad3239cdf2a44509f8101
--- /dev/null
+++ b/crates/openlogi-gui/src/state/lighting.rs
@@ -0,0 +1,59 @@
+//! Per-device RGB keyboard lighting settings.
+
+use openlogi_agent_core::device_order::PhysicalDeviceKey;
+use openlogi_core::config::Lighting;
+use tracing::debug;
+
+use crate::state::devices::DeviceRecord;
+
+use super::AppState;
+
+impl AppState {
+ /// The lighting config for the active device, or the default when none is
+ /// stored / no device is selected.
+ #[must_use]
+ pub fn lighting(&self) -> Lighting {
+ self.current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ .and_then(|key| self.config.lighting(key))
+ .unwrap_or_default()
+ }
+ /// The stored lighting config for `key`, or `None` when unset.
+ #[must_use]
+ pub fn lighting_for(&self, key: &str) -> Option<Lighting> {
+ if PhysicalDeviceKey::is_transient(key)
+ || self
+ .device_list
+ .iter()
+ .any(|record| record.config_key == key && !record.is_persistent())
+ {
+ return None;
+ }
+ self.config.lighting(key)
+ }
+ /// Persist a new lighting config for the active device and push it to the
+ /// hardware (best-effort). No-op when no device is selected.
+ pub fn commit_lighting(&mut self, lighting: Lighting) {
+ let Some(record) = self.current_record() else {
+ debug!("no active device — lighting change ignored");
+ return;
+ };
+ let key = record.persistent_config_key().map(str::to_string);
+ let target = record.route.clone();
+ if let Some(route) = target {
+ self.send_ipc(crate::ipc_client::Command::SetLighting(
+ route,
+ lighting.clone(),
+ ));
+ }
+ let Some(key) = key else {
+ debug!("transient device lighting applied without persistence");
+ return;
+ };
+ self.config.set_lighting(&key, lighting);
+ // Keep the agent's config copy fresh: it re-applies the saved colour
+ // when the keyboard reconnects, and without the reload it would
+ // replay whatever was saved the last time something *else* reloaded.
+ self.persist_and_reload("lighting");
+ }
+}
diff --git a/crates/openlogi-gui/src/state/scroll.rs b/crates/openlogi-gui/src/state/scroll.rs
new file mode 100644
index 0000000000000000000000000000000000000000..070e281ecdd71dbb9fbff1296f3c200c91558fd4
--- /dev/null
+++ b/crates/openlogi-gui/src/state/scroll.rs
@@ -0,0 +1,99 @@
+//! Per-device scroll inversion and wheel resolution.
+
+use tracing::debug;
+
+use openlogi_core::config::Config;
+
+use crate::state::devices::DeviceRecord;
+
+use super::AppState;
+
+impl AppState {
+ /// Whether the active device's scroll wheel is inverted (issue #126).
+ /// `false` when no device is selected or the device hasn't opted in.
+ #[must_use]
+ pub fn current_invert_scroll(&self) -> bool {
+ self.current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ .is_some_and(|key| self.config.invert_scroll(key))
+ }
+ /// Whether the active device reports native HID++ wheel inversion support.
+ #[must_use]
+ pub fn current_scroll_inversion_supported(&self) -> bool {
+ self.current_record()
+ .and_then(|record| record.capabilities)
+ .is_some_and(|capabilities| capabilities.scroll_inversion)
+ }
+ /// Set the active device's scroll-wheel inversion, persist it, and reload
+ /// the agent so it writes the device's native HID++ wheel inversion. No-op
+ /// when no device is selected or the active device does not report support.
+ pub fn commit_invert_scroll(&mut self, invert: bool) {
+ if !self.current_scroll_inversion_supported() {
+ debug!("active device does not support native scroll inversion");
+ return;
+ }
+ let Some(key) = self
+ .current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ .map(str::to_string)
+ else {
+ debug!("no persistent device key — invert-scroll change ignored");
+ return;
+ };
+ self.config.set_invert_scroll(&key, invert);
+ self.persist_and_reload("invert scroll");
+ }
+ /// The active device's persisted wheel resolution, or `None` when OpenLogi
+ /// leaves the device default untouched.
+ #[must_use]
+ pub fn current_scroll_resolution(&self) -> Option<openlogi_core::config::ScrollResolution> {
+ self.current_record()
+ .and_then(DeviceRecord::persistent_config_key)
+ .and_then(|key| self.config.scroll_resolution(key))
+ }
+ /// Whether the active device exposes HID++ `0x2121 HiResWheel`.
+ #[must_use]
+ pub fn current_hires_wheel_supported(&self) -> bool {
+ self.current_record()
+ .and_then(|record| record.capabilities)
+ .is_some_and(|capabilities| capabilities.hires_wheel)
+ }
+ /// Persist the active device's wheel resolution and ask the agent to reload
+ /// it. `None` removes OpenLogi's override. No-op without a selected,
+ /// HiResWheel-capable device.
+ pub fn commit_scroll_resolution(
+ &mut self,
+ resolution: Option<openlogi_core::config::ScrollResolution>,
+ ) {
+ let Some((key, supported)) = self.current_record().and_then(|record| {
+ let key = record.persistent_config_key()?.to_string();
+ Some((
+ key,
+ record
+ .capabilities
+ .is_some_and(|capabilities| capabilities.hires_wheel),
+ ))
+ }) else {
+ debug!("no persistent device key — wheel-resolution change ignored");
+ return;
+ };
+ if !set_scroll_resolution_if_supported(&mut self.config, &key, supported, resolution) {
+ debug!("active device does not support HiResWheel");
+ return;
+ }
+ self.persist_and_reload("wheel resolution");
+ }
+}
+
+pub(crate) fn set_scroll_resolution_if_supported(
+ config: &mut Config,
+ key: &str,
+ supported: bool,
+ resolution: Option<openlogi_core::config::ScrollResolution>,
+) -> bool {
+ if !supported {
+ return false;
+ }
+ config.set_scroll_resolution(key, resolution);
+ true
+}
diff --git a/crates/openlogi-gui/src/state/settings.rs b/crates/openlogi-gui/src/state/settings.rs
new file mode 100644
index 0000000000000000000000000000000000000000..63e212c7a786ce905044f1e3854a4b6e5a13aa50
--- /dev/null
+++ b/crates/openlogi-gui/src/state/settings.rs
@@ -0,0 +1,160 @@
+//! App-level settings (launch-at-login, theme, assets, language).
+
+use super::AppState;
+use gpui::App;
+use openlogi_core::config::{AppSettings, Appearance, AssetSourcePreference};
+
+impl AppState {
+ /// App-wide settings backing the Settings window (launch-at-login,
+ /// update check). Read-only view; mutate via the setters below so the
+ /// change is persisted.
+ #[must_use]
+ pub fn app_settings(&self) -> &AppSettings {
+ &self.config.app_settings
+ }
+ /// Toggle launch-at-login, persist to `config.toml`, and reconcile the
+ /// macOS `LaunchAgent` plist so the change takes effect without a
+ /// restart. No-op when the value is unchanged. Disk failures are logged,
+ /// not propagated — the Settings UI shouldn't crash on a full volume.
+ pub fn set_launch_at_login(&mut self, enabled: bool) {
+ if self.config.app_settings.launch_at_login == enabled {
+ return;
+ }
+ self.config.app_settings.launch_at_login = enabled;
+ // The agent owns autostart now; it reconciles its LaunchAgent (which
+ // points at the agent, not the GUI) when it reloads the config.
+ self.persist_and_reload("launch-at-login setting");
+ }
+ /// Toggle the menu-bar (status item) icon preference and persist it. The
+ /// icon is hosted by the always-on agent, which reads this on startup and
+ /// installs the status item only when enabled — so the change takes effect
+ /// the next time the agent launches (a no-restart live toggle would need a
+ /// main-thread hop from the agent's IPC reload). `ReloadConfig` keeps the
+ /// agent's other config in sync meanwhile. No-op when unchanged.
+ ///
+ /// The callers are the menu-bar / notification-area toggle in Settings,
+ /// shown only where there's a tray (macOS + Windows), so the setter is
+ /// gated the same way to stay dead-code-clean on Linux.
+ #[cfg(any(target_os = "macos", target_os = "windows"))]
+ pub fn set_show_in_menu_bar(&mut self, enabled: bool) {
+ if self.config.app_settings.show_in_menu_bar == enabled {
+ return;
+ }
+ self.config.app_settings.show_in_menu_bar = enabled;
+ self.persist_and_reload("show-in-menu-bar setting");
+ }
+ /// Toggle the opt-in update check and persist it. No immediate side
+ /// effect beyond the next launch reading the new value. No-op when
+ /// unchanged.
+ pub fn set_check_for_updates(&mut self, enabled: bool) {
+ if self.config.app_settings.check_for_updates == enabled {
+ return;
+ }
+ self.config.app_settings.check_for_updates = enabled;
+ self.persist_config("update-check setting");
+ }
+ /// Toggle opt-in automatic install and persist it. The launch-time updater
+ /// observer reads this live, so a newer version found after this is enabled
+ /// downloads and stages on its own; no immediate side effect here. No-op
+ /// when unchanged.
+ pub fn set_auto_install_updates(&mut self, enabled: bool) {
+ if self.config.app_settings.auto_install_updates == enabled {
+ return;
+ }
+ self.config.app_settings.auto_install_updates = enabled;
+ self.persist_config("auto-install setting");
+ }
+ /// Persist the light/dark appearance preference. The caller re-applies the
+ /// live theme via [`crate::theme::apply_from_settings`]; this only writes the
+ /// choice. No-op when unchanged.
+ pub fn set_appearance(&mut self, appearance: Appearance) {
+ if self.config.app_settings.appearance == appearance {
+ return;
+ }
+ self.config.app_settings.appearance = appearance;
+ self.persist_config("appearance setting");
+ }
+ /// Persist the chosen theme name for one mode (`None` = the OpenLogi brand
+ /// theme). No-op when unchanged.
+ pub fn set_theme(&mut self, dark: bool, name: Option<String>) {
+ let slot = if dark {
+ &mut self.config.app_settings.theme_dark
+ } else {
+ &mut self.config.app_settings.theme_light
+ };
+ if *slot == name {
+ return;
+ }
+ *slot = name;
+ self.persist_config("theme setting");
+ }
+ /// Persist the UI corner-radius override (`None` = each theme's own radius).
+ /// No-op when unchanged.
+ pub fn set_ui_radius(&mut self, radius: Option<u8>) {
+ if self.config.app_settings.ui_radius == radius {
+ return;
+ }
+ self.config.app_settings.ui_radius = radius;
+ self.persist_config("UI radius setting");
+ }
+ /// Set the thumb-wheel sensitivity (clamped to the valid range), publish it
+ /// to the gesture watcher via the shared atomic, and persist it. No-op when
+ /// unchanged. Disk failures are logged, not propagated.
+ pub fn set_thumbwheel_sensitivity(&mut self, sensitivity: i32) {
+ let sensitivity = sensitivity.clamp(
+ openlogi_core::config::MIN_THUMBWHEEL_SENSITIVITY,
+ openlogi_core::config::MAX_THUMBWHEEL_SENSITIVITY,
+ );
+ if self.config.app_settings.thumbwheel_sensitivity == sensitivity {
+ return;
+ }
+ self.config.app_settings.thumbwheel_sensitivity = sensitivity;
+ self.persist_and_reload("thumbwheel sensitivity");
+ }
+ pub fn set_auto_download_assets(&mut self, enabled: bool) {
+ if self.config.app_settings.auto_download_assets == enabled {
+ return;
+ }
+ self.config.app_settings.auto_download_assets = enabled;
+ self.persist_config("auto-download-assets setting");
+ }
+ /// Persist the preferred device-asset source. The Settings view requests a
+ /// refresh separately when automatic downloads are enabled, so this setter
+ /// remains side-effect-free beyond configuration I/O.
+ pub fn set_asset_source(&mut self, source: AssetSourcePreference) {
+ if self.config.app_settings.asset_source == source {
+ return;
+ }
+ self.config.app_settings.asset_source = source;
+ self.persist_config("asset-source setting");
+ }
+ /// Record the answer to the first-run update-check prompt: enable (or leave
+ /// disabled) the check, and mark the prompt as seen so it never reappears.
+ /// Persists once.
+ pub fn record_update_consent(&mut self, enabled: bool) {
+ self.config.app_settings.check_for_updates = enabled;
+ self.config.app_settings.update_prompt_seen = true;
+ self.persist_config("update-check consent");
+ }
+ /// The stored UI-language preference: `Some(code)` for an explicit choice,
+ /// `None` for "follow system". Distinct from the *active* locale that
+ /// `None` resolves to at startup, so the Settings picker can show "Follow
+ /// system" as the selected option.
+ #[must_use]
+ pub fn language(&self) -> Option<&str> {
+ self.config.app_settings.language.as_deref()
+ }
+ /// Set the UI language (`None` = follow system), persist it, switch the
+ /// process-global locale live via [`crate::i18n`], and repaint open UI.
+ /// No-op when unchanged.
+ pub fn set_language(&mut self, language: Option<String>, cx: &mut App) {
+ if self.config.app_settings.language == language {
+ return;
+ }
+ self.config.app_settings.language = language;
+ self.persist_config("language setting");
+ crate::i18n::activate(self.config.app_settings.language.as_deref());
+ cx.refresh_windows();
+ crate::app_menu::rebuild(cx);
+ }
+}
diff --git a/crates/openlogi-gui/src/state/smartshift.rs b/crates/openlogi-gui/src/state/smartshift.rs
new file mode 100644
index 0000000000000000000000000000000000000000..183bd5d573606fe8f970eb323aebc74ffe4439ac
--- /dev/null
+++ b/crates/openlogi-gui/src/state/smartshift.rs
@@ -0,0 +1,234 @@
+//! SmartShift load state, optimistic writes, and confirmation.
+
+use openlogi_hid::{DeviceRoute, SmartShiftMode, SmartShiftStatus, WriteError};
+use tracing::debug;
+
+use super::load::SmartShiftLoad;
+use super::{AppState, SmartShiftWriteStatus};
+
+impl AppState {
+ /// SmartShift configuration status for the active device.
+ #[must_use]
+ pub fn current_smartshift_status(&self) -> SmartShiftLoad {
+ self.current_record()
+ .map_or(SmartShiftLoad::Unknown, |record| {
+ self.smartshift_data.status(&record.config_key)
+ })
+ }
+ /// Whether the active device still needs a SmartShift read (no status
+ /// recorded). Cheaper than comparing a cloned [`SmartShiftLoad`] on the
+ /// per-frame render path.
+ #[must_use]
+ pub fn current_smartshift_unqueried(&self) -> bool {
+ self.current_record()
+ .is_some_and(|record| self.smartshift_data.unqueried(&record.config_key))
+ }
+ /// The active device's resolved SmartShift config, if the read succeeded.
+ /// Callers use it to preserve fields they don't mean to change (e.g.
+ /// tunable torque) when writing back.
+ #[must_use]
+ pub fn current_smartshift_ready(&self) -> Option<SmartShiftStatus> {
+ self.current_record()
+ .and_then(|record| self.smartshift_data.get(&record.config_key))
+ .and_then(|status| match status {
+ SmartShiftLoad::Ready(s) => Some(*s),
+ SmartShiftLoad::Unknown
+ | SmartShiftLoad::Loading
+ | SmartShiftLoad::Failed(_)
+ | SmartShiftLoad::Unsupported(_) => None,
+ })
+ }
+ /// Post-write confirmation status for the active device.
+ #[must_use]
+ pub fn current_smartshift_write_status(&self) -> Option<SmartShiftWriteStatus> {
+ self.current_record().and_then(|record| {
+ self.smartshift_write_status
+ .get(&record.config_key)
+ .copied()
+ })
+ }
+ /// Mark SmartShift discovery as in flight for `key`.
+ pub fn mark_smartshift_loading(&mut self, key: &str) {
+ self.smartshift_data.mark_loading(key);
+ }
+ /// Reset a stuck `Loading` for `key` back to `Unknown` — called when the
+ /// read worker vanished without delivering a result.
+ pub fn clear_smartshift_loading(&mut self, key: &str) {
+ self.smartshift_data.clear_loading(key);
+ }
+ /// Drop the active device's recorded SmartShift status so the next render
+ /// re-runs discovery. Backs the "click to retry" affordance on a
+ /// [`SmartShiftLoad::Failed`] device.
+ pub fn retry_active_smartshift(&mut self) {
+ if let Some(key) = self.current_record().map(|r| r.config_key.clone()) {
+ self.smartshift_data.retry(&key);
+ self.smartshift_write_status.remove(&key);
+ }
+ }
+ /// Store a SmartShift read result if it still matches the known device
+ /// route and write identity, with the same transient-retry /
+ /// permanent-unsupported handling as [`Self::store_dpi_info`].
+ pub fn store_smartshift_status(
+ &mut self,
+ key: String,
+ route: &DeviceRoute,
+ write_id: Option<u64>,
+ result: Result<SmartShiftStatus, WriteError>,
+ ) {
+ if !smartshift_read_is_current(write_id, self.smartshift_write_status.get(&key)) {
+ debug!(key, ?write_id, "stale SmartShift read result ignored");
+ return;
+ }
+ let matches_route = self
+ .device_list
+ .iter()
+ .any(|record| record.config_key == key && record.route.as_ref() == Some(route));
+ let still_present = self
+ .device_list
+ .iter()
+ .any(|record| record.config_key == key);
+ let status_key = key.clone();
+ self.smartshift_data.store(
+ key,
+ result,
+ smartshift_error_is_permanent,
+ matches_route,
+ still_present,
+ "SmartShift",
+ );
+ let expected = match self.smartshift_write_status.get(&status_key) {
+ Some(SmartShiftWriteStatus::Applying { expected, .. }) => Some(*expected),
+ Some(SmartShiftWriteStatus::Confirmed | SmartShiftWriteStatus::Failed) | None => None,
+ };
+ if let Some(status) = expected.and_then(|expected| {
+ smartshift_write_outcome(expected, self.smartshift_data.get(&status_key))
+ }) {
+ self.smartshift_write_status.insert(status_key, status);
+ }
+ }
+ /// Write a full SmartShift configuration to the active device (best-effort,
+ /// on a background thread), optimistically cache it, and persist it to
+ /// `config.toml` — the values live in device RAM and reset on a power
+ /// cycle (#189), so the agent re-applies them when the device reconnects.
+ /// No-op when no device is selected.
+ pub fn commit_smartshift(
+ &mut self,
+ mode: SmartShiftMode,
+ auto_disengage: u8,
+ tunable_torque: u8,
+ ) {
+ let Some(record) = self.current_record() else {
+ debug!("no active device — SmartShift change ignored");
+ return;
+ };
+ let key = record.config_key.clone();
+ let persistent_key = record.persistent_config_key().map(str::to_string);
+ let route = record.route.clone();
+ let can_confirm = route.is_some();
+ if let Some(route) = route {
+ self.send_ipc(crate::ipc_client::Command::SetSmartShift(
+ route,
+ mode,
+ auto_disengage,
+ tunable_torque,
+ ));
+ }
+ if let Some(persistent_key) = persistent_key {
+ self.config.set_smartshift(
+ &persistent_key,
+ openlogi_core::config::SmartShift {
+ mode: mode.into(),
+ auto_disengage,
+ tunable_torque,
+ },
+ );
+ self.persist_and_reload("SmartShift");
+ }
+ // Reflect the write immediately so the panel doesn't flicker back to
+ // the previous value before a re-read lands, but queue a confirming
+ // re-read: the write is fire-and-forget, so a sleeping device that
+ // rejected or timed it out would otherwise leave this optimistic value
+ // showing as "applied" forever (Ready blocks any further read).
+ let expected = SmartShiftStatus {
+ mode,
+ auto_disengage,
+ tunable_torque,
+ };
+ self.smartshift_data.set_ready(key.clone(), expected);
+ let write_id = can_confirm.then(|| {
+ let write_id = self.next_smartshift_write_id;
+ self.next_smartshift_write_id = self.next_smartshift_write_id.saturating_add(1);
+ self.smartshift_pending_confirm
+ .insert(key.clone(), write_id);
+ write_id
+ });
+ self.smartshift_write_status.insert(
+ key,
+ match write_id {
+ Some(write_id) => SmartShiftWriteStatus::Applying { expected, write_id },
+ None => SmartShiftWriteStatus::Failed,
+ },
+ );
+ }
+ /// Take the active device's pending SmartShift confirm, if any. Returns the
+ /// `(config_key, route, write_id)` for a one-shot re-read that replaces the
+ /// optimistic value with the device's real state; consumed once so it
+ /// doesn't re-fire.
+ pub fn take_active_smartshift_confirm(&mut self) -> Option<(String, DeviceRoute, u64)> {
+ let record = self.current_record()?;
+ let key = record.config_key.clone();
+ let route = record.route.clone()?;
+ self.smartshift_pending_confirm
+ .remove(&key)
+ .map(|write_id| (key, route, write_id))
+ }
+ /// Mark a post-write confirmation as failed when its reply channel closes.
+ pub fn fail_smartshift_confirm(&mut self, key: &str, write_id: u64) {
+ if matches!(
+ self.smartshift_write_status.get(key),
+ Some(SmartShiftWriteStatus::Applying {
+ write_id: current,
+ ..
+ }) if *current == write_id
+ ) {
+ self.smartshift_write_status
+ .insert(key.to_string(), SmartShiftWriteStatus::Failed);
+ }
+ }
+}
+
+pub(crate) fn smartshift_error_is_permanent(error: &WriteError) -> bool {
+ matches!(error, WriteError::FeatureUnsupported { .. })
+}
+
+pub(crate) fn smartshift_write_outcome(
+ expected: SmartShiftStatus,
+ load: Option<&SmartShiftLoad>,
+) -> Option<SmartShiftWriteStatus> {
+ match load {
+ Some(SmartShiftLoad::Ready(actual)) if *actual == expected => {
+ Some(SmartShiftWriteStatus::Confirmed)
+ }
+ Some(SmartShiftLoad::Ready(_)) => Some(SmartShiftWriteStatus::Failed),
+ Some(SmartShiftLoad::Failed(_) | SmartShiftLoad::Unsupported(_)) => {
+ Some(SmartShiftWriteStatus::Failed)
+ }
+ None | Some(SmartShiftLoad::Unknown | SmartShiftLoad::Loading) => None,
+ }
+}
+
+pub(crate) fn smartshift_read_is_current(
+ read_id: Option<u64>,
+ write_status: Option<&SmartShiftWriteStatus>,
+) -> bool {
+ match (read_id, write_status) {
+ (
+ Some(read_id),
+ Some(SmartShiftWriteStatus::Applying {
+ write_id: current, ..
+ }),
+ ) => read_id == *current,
+ (None, Some(SmartShiftWriteStatus::Applying { .. })) | (Some(_), _) => false,
+ (None, _) => true,
+ }
+}
diff --git a/crates/openlogi-gui/src/state/tests.rs b/crates/openlogi-gui/src/state/tests.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b7ee2d64283ae9f6c6a228094f5612d29a0cd0b9
--- /dev/null
+++ b/crates/openlogi-gui/src/state/tests.rs
@@ -0,0 +1,914 @@
+//! AppState unit tests.
+
+#![allow(
+ clippy::expect_used,
+ reason = "state fixture construction is intentionally asserted in tests"
+)]
+
+use openlogi_core::config::{Config, DeviceIdentity, LightSettings, Lighting, ScrollResolution};
+use openlogi_core::device::{
+ Capabilities, DeviceInventory, DeviceKind, DeviceModelInfo, DeviceTransports,
+ LightCapabilities, LightValueRange, LightValueUnit, PairedDevice, RawDeviceAddress,
+ ReceiverInfo, StandaloneDevice,
+};
+use openlogi_hid::WriteError;
+
+use crate::asset::AssetResolver;
+use crate::data::mouse_buttons::{Action, Binding, ButtonId};
+use crate::mouse_model::thumbwheel::ThumbwheelPreset;
+
+use openlogi_hid::{SmartShiftMode, SmartShiftStatus};
+
+use super::bindings::apply_thumbwheel_pair;
+use super::devices::build_device_list;
+use super::scroll::set_scroll_resolution_if_supported;
+use super::smartshift::{smartshift_read_is_current, smartshift_write_outcome};
+use super::{AppState, ConfigPersistence, LightCommandStatus, Load, SmartShiftWriteStatus};
+
+fn direct_inventory(unit_id: [u8; 4]) -> DeviceInventory {
+ DeviceInventory {
+ receiver: ReceiverInfo {
+ name: "MX Master 3S".to_string(),
+ vendor_id: 0x046d,
+ product_id: 0xb023,
+ unique_id: None,
+ },
+ paired: vec![PairedDevice {
+ slot: openlogi_hid::DIRECT_DEVICE_INDEX,
+ codename: Some("MX Master 3S".to_string()),
+ wpid: None,
+ kind: DeviceKind::Mouse,
+ online: true,
+ battery: None,
+ model_info: Some(DeviceModelInfo {
+ entity_count: 1,
+ serial_number: None,
+ unit_id,
+ transports: DeviceTransports::default(),
+ model_ids: [0xb034, 0, 0],
+ extended_model_id: 2,
+ }),
+ capabilities: Some(Capabilities::presumed_from_kind(DeviceKind::Mouse)),
+ }],
+ }
+}
+
+fn superseded_litra_light() -> StandaloneDevice {
+ StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:glow-superseded".into(),
+ },
+ display_name: "Litra Glow".into(),
+ manufacturer: Some("Logi".into()),
+ serial_number: Some("glow-superseded".into()),
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: Some(LightCapabilities {
+ power: true,
+ brightness: Some(
+ LightValueRange::new(20, 250, 1, LightValueUnit::Lumens).expect("valid range"),
+ ),
+ ..LightCapabilities::default()
+ }),
+ driver_id: "litra".into(),
+ registry_model_id: Some("8c900".into()),
+ }
+}
+
+fn next_light_command(
+ receiver: &mut tokio::sync::mpsc::UnboundedReceiver<crate::ipc_client::Command>,
+) -> (openlogi_hid::LightCommand, u64) {
+ let Ok(crate::ipc_client::Command::SetLight(_, command, _, request_id)) = receiver.try_recv()
+ else {
+ panic!("expected a light command");
+ };
+ (command, request_id)
+}
+
+#[test]
+fn thumbwheel_pair_updates_both_memory_and_config_entries() {
+ let mut bindings = std::collections::BTreeMap::new();
+ let mut config = Config::ephemeral();
+ let key = "2b034";
+
+ assert!(apply_thumbwheel_pair(
+ &mut bindings,
+ &mut config,
+ Some(key),
+ ThumbwheelPreset::Volume.pair(),
+ ));
+ assert_eq!(
+ bindings.get(&ButtonId::ThumbwheelScrollDown),
+ Some(&Action::VolumeDown)
+ );
+ assert_eq!(
+ bindings.get(&ButtonId::ThumbwheelScrollUp),
+ Some(&Action::VolumeUp)
+ );
+ let persisted = config.bindings_for(key);
+ assert_eq!(
+ persisted.get(&ButtonId::ThumbwheelScrollDown),
+ Some(&Binding::Single(Action::VolumeDown))
+ );
+ assert_eq!(
+ persisted.get(&ButtonId::ThumbwheelScrollUp),
+ Some(&Binding::Single(Action::VolumeUp))
+ );
+}
+
+#[test]
+fn transient_thumbwheel_pair_stays_in_memory_without_persistence() {
+ let mut bindings = std::collections::BTreeMap::new();
+ let mut config = Config::ephemeral();
+
+ assert!(!apply_thumbwheel_pair(
+ &mut bindings,
+ &mut config,
+ None,
+ ThumbwheelPreset::CycleDpi.pair(),
+ ));
+ assert_eq!(bindings.len(), 2);
+ assert!(config.bindings_for("missing").is_empty());
+}
+
+#[test]
+fn transient_identity_is_not_persisted_or_retained_after_resolution() {
+ let cache = AssetResolver::new();
+ let transient_inventory = direct_inventory([0; 4]);
+ let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
+ let mut state = AppState::with_runtime(
+ Config::ephemeral(),
+ &[transient_inventory],
+ &[],
+ &cache,
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+ let transient_key = "direct:046d:b023:unit:00000000";
+
+ assert_eq!(state.device_list.len(), 1);
+ assert!(state.config.device_identity(transient_key).is_none());
+ state.commit_dpi(2400);
+ assert!(state.config.dpi(transient_key).is_none());
+
+ let stable_list = build_device_list(
+ &[direct_inventory([0xa3, 0x93, 0xca, 0xe0])],
+ &[],
+ &cache,
+ &state.config,
+ &[],
+ );
+ let merged = state.merge_inventory_snapshot(stable_list);
+
+ assert_eq!(merged.len(), 1);
+ assert_eq!(merged[0].config_key, "direct:046d:b023:unit:a393cae0");
+ assert!(merged[0].is_persistent());
+}
+
+#[test]
+fn transient_probe_folds_into_its_known_card() {
+ // #482: a half-read probe (all-zero unit id) of the only known device
+ // with that vid/pid must not evict the known card or appear beside it —
+ // the card keeps its identity and takes the live volatile state.
+ let cache = AssetResolver::new();
+ let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
+ let mut state = AppState::with_runtime(
+ Config::ephemeral(),
+ &[direct_inventory([0xa3, 0x93, 0xca, 0xe0])],
+ &[],
+ &cache,
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+ let stable_key = "direct:046d:b023:unit:a393cae0";
+ assert_eq!(state.device_list[0].config_key, stable_key);
+
+ let transient_list =
+ build_device_list(&[direct_inventory([0; 4])], &[], &cache, &state.config, &[]);
+ let merged = state.merge_inventory_snapshot(transient_list);
+
+ assert_eq!(merged.len(), 1, "no second card for the half-read probe");
+ assert_eq!(merged[0].config_key, stable_key);
+ assert!(merged[0].is_persistent());
+ assert!(merged[0].online, "the live probe supplies volatile state");
+ assert!(merged[0].route.is_some(), "the live route is kept usable");
+}
+
+#[test]
+fn transient_record_beside_its_live_device_is_dropped() {
+ // Both a full and a half-read probe of the same wire product in one
+ // snapshot: the transient record is probe noise, not a second device.
+ let cache = AssetResolver::new();
+ let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
+ let mut state = AppState::with_runtime(
+ Config::ephemeral(),
+ &[direct_inventory([0xa3, 0x93, 0xca, 0xe0])],
+ &[],
+ &cache,
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+
+ let both = build_device_list(
+ &[
+ direct_inventory([0xa3, 0x93, 0xca, 0xe0]),
+ direct_inventory([0; 4]),
+ ],
+ &[],
+ &cache,
+ &state.config,
+ &[],
+ );
+ assert_eq!(both.len(), 2);
+ let merged = state.merge_inventory_snapshot(both);
+
+ assert_eq!(merged.len(), 1);
+ assert_eq!(merged[0].config_key, "direct:046d:b023:unit:a393cae0");
+ assert!(merged[0].online);
+}
+
+#[test]
+fn transient_probe_adopts_the_absent_sibling_of_a_live_twin() {
+ // Two same-model devices; one probes complete, the other half-reads.
+ // The live twin must not get the transient discarded as its own noise:
+ // the half-read probe can only be the sibling, which keeps its card
+ // online and routed.
+ let cache = AssetResolver::new();
+ let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
+ let mut state = AppState::with_runtime(
+ Config::ephemeral(),
+ &[
+ direct_inventory([1, 1, 1, 1]),
+ direct_inventory([2, 2, 2, 2]),
+ ],
+ &[],
+ &cache,
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+
+ let snapshot = build_device_list(
+ &[direct_inventory([1, 1, 1, 1]), direct_inventory([0; 4])],
+ &[],
+ &cache,
+ &state.config,
+ &[],
+ );
+ let merged = state.merge_inventory_snapshot(snapshot);
+
+ assert_eq!(merged.len(), 2, "no third card for the half-read probe");
+ let Some(sibling) = merged
+ .iter()
+ .find(|r| r.config_key == "direct:046d:b023:unit:02020202")
+ else {
+ panic!("the sibling card must survive under its physical key");
+ };
+ assert!(
+ sibling.online,
+ "the half-read probe keeps the sibling online"
+ );
+ assert!(sibling.route.is_some(), "the live route stays usable");
+}
+
+#[test]
+fn ambiguous_transient_probe_is_not_adopted() {
+ // Two same-model devices are known; a half-read probe could be either,
+ // so neither card may steal it.
+ let cache = AssetResolver::new();
+ let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
+ let mut state = AppState::with_runtime(
+ Config::ephemeral(),
+ &[
+ direct_inventory([1, 1, 1, 1]),
+ direct_inventory([2, 2, 2, 2]),
+ ],
+ &[],
+ &cache,
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+ assert_eq!(state.device_list.len(), 2);
+
+ let transient_list =
+ build_device_list(&[direct_inventory([0; 4])], &[], &cache, &state.config, &[]);
+ let merged = state.merge_inventory_snapshot(transient_list);
+
+ assert_eq!(merged.len(), 3, "both known cards survive on grace");
+ assert_eq!(
+ merged.iter().filter(|r| !r.is_persistent()).count(),
+ 1,
+ "the transient card stays its own record"
+ );
+}
+
+#[test]
+fn historical_transient_lighting_is_not_exposed_without_a_live_record() {
+ let transient_key = "direct:046d:b023:unit:00000000";
+ let mut config = Config::ephemeral();
+ config.set_lighting(transient_key, Lighting::default());
+ assert!(config.lighting(transient_key).is_some());
+ let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
+ let state = AppState::with_runtime(
+ config,
+ &[],
+ &[],
+ &AssetResolver::new(),
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+
+ assert!(state.device_list.is_empty());
+ assert!(state.lighting_for(transient_key).is_none());
+}
+
+#[test]
+fn smartshift_write_feedback_requires_the_written_value() {
+ let expected = SmartShiftStatus {
+ mode: SmartShiftMode::Ratchet,
+ auto_disengage: 12,
+ tunable_torque: 0,
+ };
+ assert_eq!(smartshift_write_outcome(expected, None), None);
+ assert_eq!(
+ smartshift_write_outcome(expected, Some(&Load::Ready(expected))),
+ Some(SmartShiftWriteStatus::Confirmed)
+ );
+ assert_eq!(
+ smartshift_write_outcome(
+ expected,
+ Some(&Load::Ready(SmartShiftStatus {
+ auto_disengage: 13,
+ ..expected
+ })),
+ ),
+ Some(SmartShiftWriteStatus::Failed)
+ );
+ assert_eq!(
+ smartshift_write_outcome(
+ expected,
+ Some(&Load::<SmartShiftStatus>::Failed("timeout".to_string(),))
+ ),
+ Some(SmartShiftWriteStatus::Failed)
+ );
+}
+
+#[test]
+fn stale_smartshift_reads_do_not_resolve_newer_writes() {
+ let expected = SmartShiftStatus {
+ mode: SmartShiftMode::Ratchet,
+ auto_disengage: 12,
+ tunable_torque: 0,
+ };
+ let applying = SmartShiftWriteStatus::Applying {
+ expected,
+ write_id: 2,
+ };
+
+ assert!(smartshift_read_is_current(Some(2), Some(&applying)));
+ assert!(!smartshift_read_is_current(Some(1), Some(&applying)));
+ assert!(!smartshift_read_is_current(None, Some(&applying)));
+ assert!(!smartshift_read_is_current(
+ Some(2),
+ Some(&SmartShiftWriteStatus::Confirmed)
+ ));
+ assert!(smartshift_read_is_current(None, None));
+}
+
+#[test]
+fn known_offline_device_is_an_asset_sync_target() {
+ let model = DeviceModelInfo {
+ entity_count: 0,
+ serial_number: None,
+ unit_id: [0; 4],
+ transports: DeviceTransports::default(),
+ model_ids: [0xb034, 0, 0],
+ extended_model_id: 2,
+ };
+ let mut config = Config::ephemeral();
+ config.set_device_identity(
+ "2b034",
+ DeviceIdentity {
+ display_name: "MX Anywhere 3S".to_string(),
+ kind: DeviceKind::Mouse,
+ capabilities: Capabilities::presumed_from_kind(DeviceKind::Mouse),
+ light_capabilities: None,
+ model_info: Some(model.clone()),
+ codename: Some("MX Anywhere 3S".to_string()),
+ driver_id: None,
+ registry_model_id: None,
+ },
+ );
+ let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
+ let state = AppState::with_runtime(
+ config,
+ &[],
+ &[],
+ &AssetResolver::new(),
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+
+ assert_eq!(
+ state.asset_models(),
+ vec![crate::asset::sync::AssetTarget::Hidpp {
+ model,
+ codename: Some("MX Anywhere 3S".to_string()),
+ }]
+ );
+}
+
+#[test]
+fn identical_standalone_units_share_one_model_asset_target() {
+ let first = superseded_litra_light();
+ let mut second = first.clone();
+ second.address.identity = "serial:glow-second".into();
+ second.serial_number = Some("glow-second".into());
+ let (commands, _receiver) = tokio::sync::mpsc::unbounded_channel();
+ let state = AppState::with_runtime(
+ Config::default(),
+ &[],
+ &[first, second],
+ &AssetResolver::new(),
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+
+ assert_eq!(
+ state.asset_models(),
+ vec![crate::asset::sync::AssetTarget::Standalone {
+ registry_model_id: "8c900".into(),
+ }]
+ );
+}
+
+#[test]
+fn gui_state_saves_and_clears_supported_wheel_resolution() {
+ let mut config = Config::ephemeral();
+ assert!(set_scroll_resolution_if_supported(
+ &mut config,
+ "mouse",
+ true,
+ Some(ScrollResolution::Low),
+ ));
+ assert_eq!(
+ config.scroll_resolution("mouse"),
+ Some(ScrollResolution::Low)
+ );
+
+ assert!(set_scroll_resolution_if_supported(
+ &mut config,
+ "mouse",
+ true,
+ None,
+ ));
+ assert_eq!(config.scroll_resolution("mouse"), None);
+}
+
+#[test]
+fn gui_state_ignores_unsupported_wheel_resolution() {
+ let mut config = Config::ephemeral();
+ assert!(!set_scroll_resolution_if_supported(
+ &mut config,
+ "mouse",
+ false,
+ Some(ScrollResolution::High),
+ ));
+ assert_eq!(config.scroll_resolution("mouse"), None);
+}
+
+fn camera_controls(brightness: i32) -> openlogi_core::config::CameraControls {
+ openlogi_core::config::CameraControls(std::collections::BTreeMap::from([(
+ "brightness".into(),
+ brightness,
+ )]))
+}
+
+fn camera_state(config: Config) -> AppState {
+ let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
+ AppState::with_runtime(
+ config,
+ &[],
+ &[],
+ &AssetResolver::new(),
+ &[],
+ ConfigPersistence::MemoryOnly,
+ tx,
+ )
+}
+
+#[test]
+fn migrate_lifts_legacy_port_bound_camera_key() {
+ let mut config = Config::ephemeral();
+ let model = "camera:046d:0893";
+ let legacy = "camera-0x1123000046d0893";
+ config.set_camera_controls(legacy, camera_controls(42));
+ let mut state = camera_state(config);
+
+ state.migrate_legacy_camera_key(model, "0x1123000046d0893");
+
+ assert_eq!(
+ state
+ .config
+ .camera_controls(model)
+ .map(|c| c.0["brightness"]),
+ Some(42)
+ );
+ assert!(state.config.camera_controls(legacy).is_none());
+}
+
+#[test]
+fn migrate_does_not_overwrite_existing_model_settings() {
+ let mut config = Config::ephemeral();
+ let model = "camera:046d:0893";
+ let legacy = "camera-0x1123000046d0893";
+ config.set_camera_controls(model, camera_controls(1));
+ config.set_camera_controls(legacy, camera_controls(99));
+ let mut state = camera_state(config);
+
+ state.migrate_legacy_camera_key(model, "0x1123000046d0893");
+
+ assert_eq!(
+ state
+ .config
+ .camera_controls(model)
+ .map(|c| c.0["brightness"]),
+ Some(1)
+ );
+ assert_eq!(
+ state
+ .config
+ .camera_controls(legacy)
+ .map(|c| c.0["brightness"]),
+ Some(99)
+ );
+}
+
+#[test]
+fn light_write_failure_reaches_the_gui_state() {
+ let light = StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:glow-1".into(),
+ },
+ display_name: "Litra Glow".into(),
+ manufacturer: Some("Logi".into()),
+ serial_number: Some("glow-1".into()),
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: Some(LightCapabilities {
+ power: true,
+ brightness: Some(
+ LightValueRange::new(20, 250, 1, LightValueUnit::Lumens).expect("valid range"),
+ ),
+ ..LightCapabilities::default()
+ }),
+ driver_id: "litra".into(),
+ registry_model_id: Some("8c900".into()),
+ };
+ let (commands, mut receiver) = tokio::sync::mpsc::unbounded_channel();
+ let mut state = AppState::with_runtime(
+ Config::default(),
+ &[],
+ &[light],
+ &AssetResolver::new(),
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+ let key = state
+ .current_record()
+ .expect("light record")
+ .config_key
+ .clone();
+ let requested = LightSettings::new(false, 50, None);
+ state.commit_light(requested);
+ let Ok(crate::ipc_client::Command::SetLight(
+ _,
+ openlogi_hid::LightCommand::Power(false),
+ _,
+ request_id,
+ )) = receiver.try_recv()
+ else {
+ panic!("expected the power command");
+ };
+ let Ok(crate::ipc_client::Command::SetLight(
+ _,
+ openlogi_hid::LightCommand::BrightnessPercent(50),
+ _,
+ brightness_request_id,
+ )) = receiver.try_recv()
+ else {
+ panic!("expected the brightness command");
+ };
+ assert_eq!(brightness_request_id, request_id);
+ assert_eq!(state.light(), requested);
+ assert_eq!(state.config.light(&key), None);
+ assert!(matches!(
+ state.light_command_status(),
+ Some(LightCommandStatus::Pending)
+ ));
+ assert!(state.apply_light_command_result(
+ key.clone(),
+ request_id,
+ openlogi_hid::LightCommand::Power(false),
+ Ok(()),
+ ));
+ assert_eq!(state.light(), requested);
+ assert!(state.apply_light_command_result(
+ key.clone(),
+ request_id,
+ openlogi_hid::LightCommand::BrightnessPercent(50),
+ Err(WriteError::AmbiguousRawDevice),
+ ));
+ assert!(matches!(
+ state.light_command_status(),
+ Some(LightCommandStatus::Failed(message)) if message.contains("multiple raw HID")
+ ));
+ assert_eq!(
+ state.light(),
+ LightSettings::new(false, LightSettings::default().brightness_percent, None)
+ );
+ assert_eq!(state.config.light(&key), Some(state.light()));
+}
+
+#[test]
+fn superseded_light_write_keeps_prior_successes_for_reconciliation() {
+ let light = superseded_litra_light();
+ let (commands, mut receiver) = tokio::sync::mpsc::unbounded_channel();
+ let mut state = AppState::with_runtime(
+ Config::default(),
+ &[],
+ &[light],
+ &AssetResolver::new(),
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+ let key = state
+ .current_record()
+ .expect("light record")
+ .config_key
+ .clone();
+
+ state.commit_light(LightSettings::new(false, 40, None));
+ let (first_power, first_request_id) = next_light_command(&mut receiver);
+ let (first_brightness, first_brightness_request_id) = next_light_command(&mut receiver);
+ assert_eq!(first_power, openlogi_hid::LightCommand::Power(false));
+ assert_eq!(
+ first_brightness,
+ openlogi_hid::LightCommand::BrightnessPercent(40)
+ );
+ assert_eq!(first_brightness_request_id, first_request_id);
+
+ state.commit_light(LightSettings::new(true, 60, None));
+ let (second_power, second_request_id) = next_light_command(&mut receiver);
+ let (second_brightness, second_brightness_request_id) = next_light_command(&mut receiver);
+ assert_eq!(second_power, openlogi_hid::LightCommand::Power(true));
+ assert_eq!(
+ second_brightness,
+ openlogi_hid::LightCommand::BrightnessPercent(60)
+ );
+ assert_ne!(second_request_id, first_request_id);
+ assert_eq!(second_brightness_request_id, second_request_id);
+
+ assert!(state.apply_light_command_result(
+ key.clone(),
+ second_request_id,
+ openlogi_hid::LightCommand::Power(true),
+ Ok(()),
+ ));
+ assert!(state.apply_light_command_result(
+ key.clone(),
+ second_request_id,
+ openlogi_hid::LightCommand::BrightnessPercent(60),
+ Err(WriteError::AmbiguousRawDevice),
+ ));
+ assert_eq!(state.light(), LightSettings::new(true, 60, None));
+ assert!(matches!(
+ state.light_command_status(),
+ Some(LightCommandStatus::Pending)
+ ));
+
+ assert!(state.apply_light_command_result(
+ key.clone(),
+ first_request_id,
+ openlogi_hid::LightCommand::Power(false),
+ Ok(()),
+ ));
+ assert!(state.apply_light_command_result(
+ key.clone(),
+ first_request_id,
+ openlogi_hid::LightCommand::BrightnessPercent(40),
+ Ok(()),
+ ));
+
+ assert!(matches!(
+ state.light_command_status(),
+ Some(LightCommandStatus::Failed(message)) if message.contains("multiple raw HID")
+ ));
+ assert_eq!(state.light(), LightSettings::new(true, 40, None));
+ assert_eq!(state.config.light(&key), Some(state.light()));
+}
+
+#[test]
+fn transient_light_state_is_kept_in_memory_and_only_supported_commands_are_sent() {
+ let light = StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "id:session-node".into(),
+ },
+ display_name: "Brightness-only light".into(),
+ manufacturer: Some("Test".into()),
+ serial_number: None,
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: Some(LightCapabilities {
+ power: false,
+ brightness: Some(
+ LightValueRange::new(0, 100, 1, LightValueUnit::Percent).expect("valid range"),
+ ),
+ ..LightCapabilities::default()
+ }),
+ driver_id: "test-light".into(),
+ registry_model_id: None,
+ };
+ let (commands, mut receiver) = tokio::sync::mpsc::unbounded_channel();
+ let mut state = AppState::with_runtime(
+ Config::default(),
+ &[],
+ &[light],
+ &AssetResolver::new(),
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+ let settings = LightSettings::new(false, 37, None);
+
+ state.commit_light(settings);
+
+ assert_eq!(state.light(), settings);
+ assert!(!state.light_enabled());
+ assert!(matches!(
+ receiver.try_recv(),
+ Ok(crate::ipc_client::Command::SetLight(
+ _,
+ openlogi_hid::LightCommand::BrightnessPercent(37),
+ _,
+ _
+ ))
+ ));
+ assert!(receiver.try_recv().is_err());
+}
+
+#[cfg(target_os = "macos")]
+#[test]
+fn camera_automation_preserves_manual_power_and_clears_transient_override() {
+ let light = StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:glow-camera".into(),
+ },
+ display_name: "Litra Glow".into(),
+ manufacturer: Some("Logi".into()),
+ serial_number: Some("glow-camera".into()),
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: Some(LightCapabilities {
+ power: true,
+ ..LightCapabilities::default()
+ }),
+ driver_id: "litra".into(),
+ registry_model_id: Some("8c900".into()),
+ };
+ let (commands, mut receiver) = tokio::sync::mpsc::unbounded_channel();
+ let mut state = AppState::with_runtime(
+ Config::default(),
+ &[],
+ &[light],
+ &AssetResolver::new(),
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+ let key = state
+ .current_record()
+ .expect("light record")
+ .config_key
+ .clone();
+ state.config.set_light(
+ &key,
+ LightSettings {
+ enabled: false,
+ auto_camera: true,
+ brightness_percent: 70,
+ temperature_kelvin: None,
+ color: None,
+ },
+ );
+
+ assert!(!state.light_enabled());
+ assert!(state.set_camera_active(true));
+ assert!(state.light_enabled());
+ assert!(!state.light().enabled);
+
+ state.commit_manual_light_power(false);
+ assert!(!state.light_enabled());
+ assert!(matches!(
+ receiver.try_recv(),
+ Ok(crate::ipc_client::Command::SetLightManualPower(
+ _,
+ false,
+ _,
+ _
+ ))
+ ));
+
+ assert!(state.set_camera_active(false));
+ assert!(state.set_camera_active(true));
+ assert!(state.light_enabled());
+ assert!(!state.light().enabled);
+}
+
+#[cfg(target_os = "macos")]
+#[test]
+fn enabling_camera_automation_queues_effective_camera_power() {
+ let light = StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:glow-effective".into(),
+ },
+ display_name: "Litra Glow".into(),
+ manufacturer: Some("Logi".into()),
+ serial_number: Some("glow-effective".into()),
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: Some(LightCapabilities {
+ power: true,
+ ..LightCapabilities::default()
+ }),
+ driver_id: "litra".into(),
+ registry_model_id: Some("8c900".into()),
+ };
+ let (commands, mut receiver) = tokio::sync::mpsc::unbounded_channel();
+ let mut state = AppState::with_runtime(
+ Config::default(),
+ &[],
+ &[light],
+ &AssetResolver::new(),
+ &[],
+ ConfigPersistence::MemoryOnly,
+ commands,
+ );
+ state.set_camera_active(true);
+ let mut settings = state.light();
+ settings.enabled = false;
+ settings.auto_camera = true;
+
+ state.commit_light(settings);
+
+ assert!(matches!(
+ receiver.try_recv(),
+ Ok(crate::ipc_client::Command::SetLight(
+ _,
+ openlogi_hid::LightCommand::Power(true),
+ _,
+ _
+ ))
+ ));
+ assert!(!state.light().enabled);
+ assert!(state.light_enabled());
+}
diff --git a/crates/openlogi-gui/src/windows/settings.rs b/crates/openlogi-gui/src/windows/settings.rs
index f9557f711cfd1886f10a116159dbd984da663a25..584a709572962ac29b6e9b8971581e49b6bed941 100644
--- a/crates/openlogi-gui/src/windows/settings.rs
+++ b/crates/openlogi-gui/src/windows/settings.rs
@@ -355,6 +355,14 @@ impl Render for SettingsView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let pal = theme::palette(cx);
let view = cx.entity();
+ // Only surface the Camera permission when a webcam is actually present,
+ // so people without a Logitech camera are never asked for camera access.
+ // Gated to the platforms that register the permission page below (macOS
+ // consent is the AVFoundation gate; Windows has no such page).
+ #[cfg(any(target_os = "macos", target_os = "linux"))]
+ let has_camera = cx
+ .try_global::<AppState>()
+ .is_some_and(AppState::has_camera);
// Filled group boxes use the theme's content-surface token, keeping
// settings groups distinct from the page without borrowing a control
@@ -371,7 +379,7 @@ impl Render for SettingsView {
// Registered only where grants exist to manage — see the `mod
// permissions` cfg for why Windows skips it.
#[cfg(any(target_os = "macos", target_os = "linux"))]
- let settings = settings.page(permissions::permissions_page(pal));
+ let settings = settings.page(permissions::permissions_page(pal, has_camera));
let settings = settings
.page(appearance::appearance_page(
view.clone(),
diff --git a/crates/openlogi-gui/src/windows/settings/permissions.rs b/crates/openlogi-gui/src/windows/settings/permissions.rs
index e8c2974126e9a1b6d0f081e403dee21412800d0a..2eb9cfb0f106403893a4de2ec276f09001075bfe 100644
--- a/crates/openlogi-gui/src/windows/settings/permissions.rs
+++ b/crates/openlogi-gui/src/windows/settings/permissions.rs
@@ -18,14 +18,14 @@ use crate::theme::Typography as _;
not(any(target_os = "macos", target_os = "linux")),
allow(unused_variables)
)]
-pub(super) fn permissions_page(pal: Palette) -> SettingPage {
+pub(super) fn permissions_page(pal: Palette, has_camera: bool) -> SettingPage {
let page = SettingPage::new(tr!("Permissions"))
.icon(IconName::Info)
.resettable(false);
#[cfg(target_os = "macos")]
- let page = page.group(
- SettingGroup::new()
+ let page = {
+ let mut group = SettingGroup::new()
.item(permission_item(
"perm-accessibility",
tr!("Accessibility"),
@@ -58,8 +58,27 @@ pub(super) fn permissions_page(pal: Palette) -> SettingPage {
Permission::Bluetooth,
|_| permissions::bluetooth(),
pal,
- )),
- );
+ ));
+ // Camera access is only worth asking for once a Logitech webcam is
+ // actually connected — it then appears on the main page, and granting
+ // access turns on its live preview.
+ if has_camera {
+ group = group.item(permission_item(
+ "perm-camera",
+ tr!("Camera"),
+ tr!(
+ "Your Logitech webcam shows up on the main page. Grant access to see its live preview — video never leaves your Mac."
+ ),
+ Permission::Camera,
+ |_| permissions::camera(),
+ pal,
+ ));
+ }
+ page.group(group)
+ };
+
+ #[cfg(not(target_os = "macos"))]
+ let _ = has_camera;
#[cfg(target_os = "linux")]
let page = page.group(SettingGroup::new().item({
diff --git a/crates/openlogi-hid/Cargo.toml b/crates/openlogi-hid/Cargo.toml
index 9f94c1669829f164da52018af836b384586cbb8d..ab87c20f52af77ede926472a0d92a6812a1d41b0 100644
--- a/crates/openlogi-hid/Cargo.toml
+++ b/crates/openlogi-hid/Cargo.toml
@@ -12,7 +12,7 @@ categories = ["hardware-support", "asynchronous"]
readme = "README.md"
[dependencies]
-openlogi-core = { path = "../openlogi-core", version = "0.6.23" }
+openlogi-core = { path = "../openlogi-core", version = "0.6.24" }
hidpp = { workspace = true }
async-hid = { workspace = true }
tokio = { workspace = true }
@@ -39,5 +39,9 @@ windows-sys = { workspace = true, features = [
"Win32_System_IO",
] }
+[target.'cfg(target_os = "macos")'.dependencies]
+objc2 = "0.6.4"
+objc2-app-kit = { version = "0.3.2", features = ["NSWorkspace", "NSRunningApplication"] }
+
[lints]
workspace = true
diff --git a/crates/openlogi-hid/src/backlight.rs b/crates/openlogi-hid/src/backlight.rs
new file mode 100644
index 0000000000000000000000000000000000000000..dc4e477494f4119c8c2ee3cbf696a2188d670a1b
--- /dev/null
+++ b/crates/openlogi-hid/src/backlight.rs
@@ -0,0 +1,146 @@
+//! HID++ `Backlight` (feature `0x1982`) — keyboard backlight control.
+//!
+//! The protocol-level `0x1982` wrapper lives in `openlogi-hidpp`; this module
+//! keeps OpenLogi's IPC/config-facing mode, status, and snapshot types.
+//!
+//! This is the backlight family used by the MX Keys line: a white,
+//! level-adjustable backlight driven by an ambient-light sensor and a hand
+//! proximity sensor. It is distinct from the RGB families (`0x8070`
+//! ColorLedEffects, `0x8080` PerKeyLighting) that [`crate::set_keyboard_color`]
+//! drives — a device exposes one or the other, never both.
+//!
+//! `setBacklightConfig` writes to the device's non-volatile memory, so a
+//! disabled backlight stays disabled across reconnects, host switches, and
+//! power cycles without a daemon re-applying it.
+
+use serde::{Deserialize, Serialize};
+
+/// How the firmware decides the backlight brightness level.
+///
+/// Crosses the agent↔GUI IPC, where serde encodes the variant *index*, so
+/// variant order is wire format — changes require a `PROTOCOL_VERSION` bump
+/// (guarded by `openlogi-agent-core/tests/wire_format.rs`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum BacklightMode {
+ /// No mode selected.
+ None,
+ /// Level follows the ambient-light sensor.
+ Automatic,
+ /// Level adjusted with the keyboard's own backlight keys. The firmware
+ /// enters this mode on its own; software cannot write it.
+ TemporaryManual,
+ /// Level set by software and held until changed.
+ PermanentManual,
+}
+
+/// Why the backlight is in its current state, as reported by
+/// `getBacklightInfo`.
+///
+/// Crosses the agent↔GUI IPC, where serde encodes the variant *index*, so
+/// variant order is wire format — changes require a `PROTOCOL_VERSION` bump
+/// (guarded by `openlogi-agent-core/tests/wire_format.rs`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum BacklightStatus {
+ /// Turned off by software — the LEDs stay dark regardless of ambient
+ /// light or hand proximity. This is what [`crate::set_backlight_enabled`]
+ /// with `false` produces.
+ DisabledBySoftware,
+ /// Turned off because the battery is critically low.
+ DisabledByCriticalBattery,
+ /// Following the ambient-light sensor.
+ AlsAutomatic,
+ /// Following the ambient-light sensor, which reads bright enough that the
+ /// LEDs are off.
+ AlsSaturated,
+ /// Holding a level the user picked with the backlight keys.
+ TemporaryManual,
+ /// Holding a level written by software.
+ PermanentManual,
+}
+
+/// Snapshot of a keyboard's backlight, merged from the `0x1982`
+/// `getBacklightConfig` and `getBacklightInfo` responses.
+///
+/// Crosses the agent↔GUI IPC, so field order is wire format — changes require
+/// a `PROTOCOL_VERSION` bump (guarded by
+/// `openlogi-agent-core/tests/wire_format.rs`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BacklightState {
+ /// Whether the backlight system is enabled at all. When `false` the
+ /// firmware keeps the LEDs dark no matter what the sensors report, and
+ /// [`Self::status`] reads [`BacklightStatus::DisabledBySoftware`].
+ pub enabled: bool,
+ /// How the level is chosen while the backlight is enabled.
+ pub mode: BacklightMode,
+ /// Why the backlight is in its current state.
+ pub status: BacklightStatus,
+ /// Current brightness level, `0` (off) up to [`Self::nb_levels`] minus one.
+ pub current_level: u8,
+ /// Number of user-selectable brightness levels the device reports.
+ pub nb_levels: u8,
+}
+
+impl BacklightState {
+ /// Whether the LEDs are dark right now, for whatever reason — software
+ /// disable, critical battery, a saturated ambient-light sensor, or a zero
+ /// manual level.
+ #[must_use]
+ pub fn is_dark(self) -> bool {
+ !self.enabled
+ || self.current_level == 0
+ || matches!(
+ self.status,
+ BacklightStatus::DisabledBySoftware
+ | BacklightStatus::DisabledByCriticalBattery
+ | BacklightStatus::AlsSaturated
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn lit() -> BacklightState {
+ BacklightState {
+ enabled: true,
+ mode: BacklightMode::Automatic,
+ status: BacklightStatus::AlsAutomatic,
+ current_level: 4,
+ nb_levels: 8,
+ }
+ }
+
+ #[test]
+ fn a_lit_backlight_is_not_dark() {
+ assert!(!lit().is_dark());
+ }
+
+ #[test]
+ fn software_disable_reads_as_dark() {
+ let state = BacklightState {
+ enabled: false,
+ status: BacklightStatus::DisabledBySoftware,
+ ..lit()
+ };
+ assert!(state.is_dark());
+ }
+
+ #[test]
+ fn a_zero_level_reads_as_dark_even_while_enabled() {
+ let state = BacklightState {
+ current_level: 0,
+ ..lit()
+ };
+ assert!(state.is_dark());
+ }
+
+ #[test]
+ fn a_saturated_ambient_sensor_reads_as_dark() {
+ let state = BacklightState {
+ status: BacklightStatus::AlsSaturated,
+ ..lit()
+ };
+ assert!(state.is_dark());
+ }
+}
diff --git a/crates/openlogi-hid/src/channel_pool.rs b/crates/openlogi-hid/src/channel_pool.rs
new file mode 100644
index 0000000000000000000000000000000000000000..5bce4453f839b738fa69e60dcb9c89946d4a10f8
--- /dev/null
+++ b/crates/openlogi-hid/src/channel_pool.rs
@@ -0,0 +1,47 @@
+//! Shared HID++ channels for long-running agent sessions.
+
+use std::sync::{Arc, Weak};
+
+use hidpp::channel::HidppChannel;
+use tokio::sync::Mutex;
+
+use crate::route::{DeviceRoute, open_route_channel};
+
+/// Reuses one open HID++ channel for routes on the same receiver.
+#[derive(Clone, Default)]
+pub struct ChannelPool {
+ entries: Arc<Mutex<Vec<PoolEntry>>>,
+}
+
+struct PoolEntry {
+ route: DeviceRoute,
+ channel: Weak<HidppChannel>,
+}
+
+impl ChannelPool {
+ /// Return a shared channel reaching `route`, opening it when necessary.
+ pub async fn open(
+ &self,
+ route: &DeviceRoute,
+ ) -> Result<Option<Arc<HidppChannel>>, async_hid::HidError> {
+ let mut entries = self.entries.lock().await;
+ entries.retain(|entry| entry.channel.strong_count() > 0);
+ if let Some(channel) = entries.iter().find_map(|entry| {
+ entry
+ .route
+ .shares_transport(route)
+ .then(|| entry.channel.upgrade())
+ .flatten()
+ }) {
+ return Ok(Some(channel));
+ }
+ let Some(channel) = open_route_channel(route).await? else {
+ return Ok(None);
+ };
+ entries.push(PoolEntry {
+ route: route.clone(),
+ channel: Arc::downgrade(&channel),
+ });
+ Ok(Some(channel))
+ }
+}
diff --git a/crates/openlogi-hid/src/channel_registry.rs b/crates/openlogi-hid/src/channel_registry.rs
new file mode 100644
index 0000000000000000000000000000000000000000..7c2f71c9c88bf511ef6c038bc8ebc25ebebb58b0
--- /dev/null
+++ b/crates/openlogi-hid/src/channel_registry.rs
@@ -0,0 +1,363 @@
+//! Registry of HID++ channels owned by the persistent inventory enumerator.
+
+use std::collections::HashSet;
+use std::hash::Hash;
+use std::sync::{Arc, PoisonError, RwLock};
+
+use async_hid::DeviceId;
+use hidpp::channel::HidppChannel;
+
+use crate::{DeviceRoute, SharedChannel};
+
+struct Publication<Node, Channel> {
+ node: Node,
+ sequence: u64,
+ routes: Vec<DeviceRoute>,
+ channel: Channel,
+}
+
+struct NodeRegistry<Node, Channel> {
+ publications: Vec<Publication<Node, Channel>>,
+ next_sequence: u64,
+}
+
+impl<Node, Channel> Default for NodeRegistry<Node, Channel> {
+ fn default() -> Self {
+ Self {
+ publications: Vec::new(),
+ next_sequence: 0,
+ }
+ }
+}
+
+impl<Node: Eq, Channel> NodeRegistry<Node, Channel> {
+ fn replace_node(
+ &mut self,
+ node: Node,
+ routes: impl IntoIterator<Item = DeviceRoute>,
+ channel: Channel,
+ ) {
+ let routes = routes.into_iter().collect();
+ if let Some(publication) = self
+ .publications
+ .iter_mut()
+ .find(|publication| publication.node == node)
+ {
+ publication.routes = routes;
+ publication.channel = channel;
+ return;
+ }
+
+ let sequence = self.next_sequence;
+ self.next_sequence = self.next_sequence.wrapping_add(1);
+ self.publications.push(Publication {
+ node,
+ sequence,
+ routes,
+ channel,
+ });
+ }
+
+ fn remove_node(&mut self, node: &Node) {
+ self.publications
+ .retain(|publication| publication.node != *node);
+ }
+
+ fn lookup(&self, route: &DeviceRoute) -> Option<&Channel> {
+ self.publications
+ .iter()
+ .filter(|publication| publication.routes.contains(route))
+ .min_by_key(|publication| publication.sequence)
+ .map(|publication| &publication.channel)
+ }
+
+ fn any_current(&self, mut predicate: impl FnMut(&DeviceRoute, &Channel) -> bool) -> bool {
+ self.publications.iter().any(|publication| {
+ publication.routes.iter().any(|route| {
+ predicate(route, &publication.channel)
+ && self
+ .lookup(route)
+ .is_some_and(|winner| std::ptr::eq(winner, &raw const publication.channel))
+ })
+ })
+ }
+}
+
+impl<Node: Eq + Hash, Channel> NodeRegistry<Node, Channel> {
+ fn retain_nodes(&mut self, nodes: &HashSet<Node>) {
+ self.publications
+ .retain(|publication| nodes.contains(&publication.node));
+ }
+}
+
+struct Registry<Node, Channel> {
+ state: Arc<RwLock<NodeRegistry<Node, Channel>>>,
+}
+
+impl<Node, Channel> Clone for Registry<Node, Channel> {
+ fn clone(&self) -> Self {
+ Self {
+ state: Arc::clone(&self.state),
+ }
+ }
+}
+
+impl<Node, Channel> Default for Registry<Node, Channel> {
+ fn default() -> Self {
+ Self {
+ state: Arc::new(RwLock::new(NodeRegistry::default())),
+ }
+ }
+}
+
+impl<Node: Eq, Channel> Registry<Node, Channel> {
+ fn replace_node(
+ &self,
+ node: Node,
+ routes: impl IntoIterator<Item = DeviceRoute>,
+ channel: Channel,
+ ) {
+ self.state
+ .write()
+ .unwrap_or_else(PoisonError::into_inner)
+ .replace_node(node, routes, channel);
+ }
+
+ fn remove_node(&self, node: &Node) {
+ self.state
+ .write()
+ .unwrap_or_else(PoisonError::into_inner)
+ .remove_node(node);
+ }
+}
+
+impl<Node: Eq + Hash, Channel> Registry<Node, Channel> {
+ fn retain_nodes(&self, nodes: &HashSet<Node>) {
+ self.state
+ .write()
+ .unwrap_or_else(PoisonError::into_inner)
+ .retain_nodes(nodes);
+ }
+}
+
+impl<Node: Eq, Channel: Clone> Registry<Node, Channel> {
+ fn lookup(&self, route: &DeviceRoute) -> Option<Channel> {
+ self.state.read().ok()?.lookup(route).cloned()
+ }
+}
+
+impl<Node: Eq, Channel> Registry<Node, Channel> {
+ fn any_current(&self, predicate: impl FnMut(&DeviceRoute, &Channel) -> bool) -> bool {
+ self.state
+ .read()
+ .ok()
+ .is_some_and(|state| state.any_current(predicate))
+ }
+}
+
+/// Channels already opened and owned by the persistent inventory enumerator.
+///
+/// Publications are keyed by OS HID node internally and selected by exact
+/// [`DeviceRoute`]. When identical direct devices publish the same route, the
+/// oldest live node wins until it is removed.
+#[derive(Clone, Default)]
+pub struct ChannelRegistry {
+ inner: Registry<DeviceId, Arc<HidppChannel>>,
+}
+
+impl ChannelRegistry {
+ /// Replace every route published by `node`, preserving that node's original
+ /// collision priority when it was already present.
+ pub(crate) fn replace_node(
+ &self,
+ node: DeviceId,
+ routes: impl IntoIterator<Item = DeviceRoute>,
+ channel: Arc<HidppChannel>,
+ ) {
+ self.inner.replace_node(node, routes, channel);
+ }
+
+ /// Remove every route and channel reference owned by `node`.
+ pub(crate) fn remove_node(&self, node: &DeviceId) {
+ self.inner.remove_node(node);
+ }
+
+ /// Remove publications for nodes absent from the current OS enumeration.
+ pub(crate) fn retain_nodes(&self, nodes: &HashSet<DeviceId>) {
+ self.inner.retain_nodes(nodes);
+ }
+
+ /// Clone the current exact-route winner.
+ #[must_use]
+ pub fn lookup(&self, route: &DeviceRoute) -> Option<SharedChannel> {
+ self.inner
+ .lookup(route)
+ .map(|channel| SharedChannel::new(channel, route.clone()))
+ }
+
+ /// Whether `shared` is still the winning publication for its exact route
+ /// and points to the same underlying connection.
+ #[must_use]
+ pub fn is_current(&self, shared: &SharedChannel) -> bool {
+ self.inner.any_current(|route, channel| {
+ shared.matches(route) && Arc::ptr_eq(channel, shared.channel())
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::HashSet;
+ use std::panic::{AssertUnwindSafe, catch_unwind};
+ use std::sync::Arc;
+
+ use crate::DeviceRoute;
+
+ use super::{PoisonError, Registry};
+
+ impl<Node, Channel> Registry<Node, Channel> {
+ fn poison_for_test(&self) {
+ let _ = catch_unwind(AssertUnwindSafe(|| {
+ let _guard = self.state.write().unwrap_or_else(PoisonError::into_inner);
+ panic!("poison registry for test");
+ }));
+ }
+ }
+
+ impl<Node: Eq, Channel: Clone> Registry<Node, Channel> {
+ fn publisher_lookup_for_test(&self, route: &DeviceRoute) -> Option<Channel> {
+ self.state
+ .read()
+ .unwrap_or_else(PoisonError::into_inner)
+ .lookup(route)
+ .cloned()
+ }
+ }
+
+ fn direct(product_id: u16) -> DeviceRoute {
+ DeviceRoute::Direct {
+ vendor_id: 0x046d,
+ product_id,
+ }
+ }
+
+ fn bolt(uid: &str, slot: u8) -> DeviceRoute {
+ DeviceRoute::Bolt {
+ receiver_uid: uid.into(),
+ slot,
+ }
+ }
+
+ #[test]
+ fn lookup_rejects_every_non_exact_route_field() {
+ let registry = Registry::<u8, &'static str>::default();
+ registry.replace_node(1, [bolt("AABB", 2)], "channel-a");
+
+ assert_eq!(registry.lookup(&bolt("AABB", 2)), Some("channel-a"));
+ assert_eq!(registry.lookup(&bolt("AABB", 3)), None);
+ assert_eq!(registry.lookup(&bolt("CCDD", 2)), None);
+ assert_eq!(registry.lookup(&direct(0xb35b)), None);
+ }
+
+ #[test]
+ fn one_node_can_publish_multiple_receiver_slots() {
+ let registry = Registry::<u8, &'static str>::default();
+ registry.replace_node(1, [bolt("AABB", 1), bolt("AABB", 4)], "receiver-channel");
+
+ assert_eq!(registry.lookup(&bolt("AABB", 1)), Some("receiver-channel"));
+ assert_eq!(registry.lookup(&bolt("AABB", 4)), Some("receiver-channel"));
+ }
+
+ #[test]
+ fn current_check_uses_only_the_exact_winning_publication() {
+ let route = direct(0xb35b);
+ let registry = Registry::<u8, &'static str>::default();
+ registry.replace_node(1, [route.clone()], "a");
+ registry.replace_node(2, [route.clone()], "b");
+
+ assert!(
+ registry.any_current(|candidate, channel| { candidate == &route && *channel == "a" })
+ );
+ assert!(
+ !registry.any_current(|candidate, channel| { candidate == &route && *channel == "b" })
+ );
+ }
+
+ #[test]
+ fn same_route_with_a_different_arc_is_not_current() {
+ let route = direct(0xb35b);
+ let published = Arc::new(());
+ let stale = Arc::new(());
+ let registry = Registry::<u8, Arc<()>>::default();
+ registry.replace_node(1, [route.clone()], Arc::clone(&published));
+
+ assert!(registry.any_current(|candidate, channel| {
+ candidate == &route && Arc::ptr_eq(channel, &published)
+ }));
+ assert!(!registry.any_current(|candidate, channel| {
+ candidate == &route && Arc::ptr_eq(channel, &stale)
+ }));
+ }
+
+ #[test]
+ fn replacing_winner_preserves_priority_then_removal_promotes_next_owner() {
+ let route = direct(0xb35b);
+ let registry = Registry::<u8, &'static str>::default();
+ registry.replace_node(1, [route.clone()], "a-v1");
+ registry.replace_node(2, [route.clone()], "b");
+
+ assert_eq!(registry.lookup(&route), Some("a-v1"));
+
+ registry.replace_node(1, [route.clone()], "a-v2");
+ assert_eq!(registry.lookup(&route), Some("a-v2"));
+
+ registry.remove_node(&1);
+ assert_eq!(registry.lookup(&route), Some("b"));
+ }
+
+ #[test]
+ fn replacing_one_node_is_atomic_and_does_not_touch_another() {
+ let registry = Registry::<u8, &'static str>::default();
+ registry.replace_node(1, [bolt("A", 1), bolt("A", 2)], "a");
+ registry.replace_node(2, [bolt("B", 1)], "b");
+
+ registry.replace_node(1, [bolt("A", 3)], "a-new");
+
+ assert_eq!(registry.lookup(&bolt("A", 1)), None);
+ assert_eq!(registry.lookup(&bolt("A", 2)), None);
+ assert_eq!(registry.lookup(&bolt("A", 3)), Some("a-new"));
+ assert_eq!(registry.lookup(&bolt("B", 1)), Some("b"));
+ }
+
+ #[test]
+ fn retaining_nodes_removes_only_absent_owners() {
+ let registry = Registry::<u8, &'static str>::default();
+ registry.replace_node(1, [direct(0xb35b)], "a");
+ registry.replace_node(2, [direct(0xb36b)], "b");
+
+ registry.retain_nodes(&HashSet::from([2]));
+
+ assert_eq!(registry.lookup(&direct(0xb35b)), None);
+ assert_eq!(registry.lookup(&direct(0xb36b)), Some("b"));
+ }
+
+ #[test]
+ fn poisoned_read_fails_closed_but_publishers_can_clean_up() {
+ let registry = Registry::<u8, &'static str>::default();
+ registry.replace_node(1, [direct(0xb35b)], "a");
+ registry.poison_for_test();
+
+ assert_eq!(registry.lookup(&direct(0xb35b)), None);
+ assert!(!registry.any_current(|_, _| true));
+
+ registry.remove_node(&1);
+ registry.replace_node(2, [direct(0xb36b)], "b");
+ registry.retain_nodes(&HashSet::from([2]));
+
+ assert_eq!(registry.publisher_lookup_for_test(&direct(0xb35b)), None);
+ assert_eq!(
+ registry.publisher_lookup_for_test(&direct(0xb36b)),
+ Some("b")
+ );
+ }
+}
diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs
index 2699aa079c9eac0df155b3e415cb4edce01f98a7..1bc2b69aa977b87d2bbbc125a6eba5c685261cbb 100644
--- a/crates/openlogi-hid/src/gesture.rs
+++ b/crates/openlogi-hid/src/gesture.rs
@@ -4,7 +4,9 @@
//!
//! [`run_capture_session`] holds a single HID++ channel open for one device,
//! enables diversion on whichever of those controls it exposes, registers one
-//! message listener, and restores every control's default mapping on shutdown.
+//! message listener, and restores every control's default mapping on graceful
+//! shutdown. Registry revocation instead releases the stale channel without
+//! sending cleanup traffic through it.
//! Using one channel matters: a second channel to the same device would split
//! its input-report stream, so all captured controls share this session.
//!
@@ -17,6 +19,7 @@
//! defaults (click bound, rotation rebound, or sensitivity changed).
use std::sync::{Arc, Mutex, PoisonError, RwLock};
+use std::time::{Duration, Instant};
use hidpp::{
channel::HidppChannel,
@@ -30,16 +33,50 @@ use thiserror::Error;
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
+use crate::channel_registry::ChannelRegistry;
use crate::reprog_controls::{self, RawControlEvent, ReprogControlsV4};
use crate::route::{DeviceRoute, open_route_channel};
use crate::thumbwheel::{self, Thumbwheel};
use crate::write::SharedChannel;
+/// Return the PID of the frontmost application at this instant.
+/// Called on the HID++ listener thread — before any async dispatch delay —
+/// so the PID reflects the app that was active when the button was pressed.
+/// Returns `None` on non-macOS platforms or if no frontmost app exists.
+fn frontmost_pid() -> Option<i32> {
+ #[cfg(target_os = "macos")]
+ {
+ use objc2::rc::autoreleasepool;
+ use objc2_app_kit::NSWorkspace;
+ autoreleasepool(|_| {
+ NSWorkspace::sharedWorkspace()
+ .frontmostApplication()
+ .map(|a| a.processIdentifier())
+ })
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ None
+ }
+}
+
/// Shared slot holding the active capture session's open channel, so DPI /
/// SmartShift writes can reuse it instead of opening a fresh one. `None`
/// whenever no session is connected.
pub type CaptureChannel = Arc<RwLock<Option<SharedChannel>>>;
+/// Why an active capture session is stopping.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CaptureStop {
+ /// The target/configuration changed while the channel is still current, so
+ /// diverted controls must be restored before the session exits.
+ Graceful,
+ /// Inventory revoked or replaced the underlying connection, or the
+ /// transport went down. Clear local ownership without restore writes —
+ /// the device has already discarded volatile diversion state.
+ Revoked,
+}
+
/// One input captured from the active device.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CapturedInput {
@@ -47,8 +84,12 @@ pub enum CapturedInput {
Gesture(GestureDirection),
/// A diverted button was pressed — the DPI/ModeShift button
/// ([`ButtonId::DpiToggle`]) or the thumb-wheel single tap
- /// ([`ButtonId::Thumbwheel`]).
- ButtonPressed(ButtonId),
+ /// ([`ButtonId::Thumbwheel`]). The optional `frontmost_pid` is the
+ /// PID of the frontmost application at the instant the button was pressed
+ /// (captured on the listener thread to avoid timing races on dispatch).
+ /// The PID is skipped in serialization — it is a dispatch hint, not part
+ /// of the stable wire format.
+ ButtonPressed(ButtonId, #[serde(skip)] Option<i32>),
/// Thumb-wheel rotation to re-synthesise as horizontal scroll, in the
/// wheel's `diverted_res` increments. Emitted while the wheel is diverted
/// (click bound, rotation rebound, or sensitivity changed).
@@ -70,6 +111,40 @@ pub enum GestureError {
/// A HID++ feature call returned an error; inner string carries context.
#[error("HID++ protocol error: {0}")]
Hidpp(String),
+ /// An established HID channel disconnected while capture was active.
+ #[error("HID channel disconnected")]
+ ChannelDisconnected,
+}
+
+const CAPTURE_HEALTH_POLL: Duration = Duration::from_secs(1);
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum CaptureExit {
+ Stopped(CaptureStop),
+ Disconnected,
+}
+
+async fn wait_for_capture_exit<Shutdown>(
+ chan: &HidppChannel,
+ shutdown: Shutdown,
+ poll_period: Duration,
+) -> CaptureExit
+where
+ Shutdown: std::future::Future<Output = CaptureStop>,
+{
+ tokio::pin!(shutdown);
+ loop {
+ tokio::select! {
+ stop = &mut shutdown => {
+ return CaptureExit::Stopped(stop);
+ }
+ () = tokio::time::sleep(poll_period) => {
+ if !chan.is_connected() {
+ return CaptureExit::Disconnected;
+ }
+ }
+ }
+ }
}
/// Movement + button state accumulated across messages. Lives behind a `Mutex`
@@ -81,8 +156,31 @@ struct CaptureAccum {
/// Whether any DPI/ModeShift control was held in the last event — for
/// rising-edge press detection.
dpi_down: bool,
+ /// Whether any Back control was held in the last event.
+ back_down: bool,
+ /// Whether any Forward control was held in the last event.
+ forward_down: bool,
+ /// Timestamp of the last Back press dispatch — for debounce.
+ last_back: Option<Instant>,
+ /// Timestamp of the last Forward press dispatch — for debounce.
+ last_forward: Option<Instant>,
}
+/// Minimum time between two Back or Forward dispatches from the same HID++
+/// CID. The MX Vertical sends multiple DivertedButtons frames per physical
+/// click as the CID flag bounces in/out within a single press (~50-100ms).
+/// 150ms suppresses intra-press bounce while allowing intentional rapid
+/// double-clicks (typically ≥200ms apart).
+///
+/// Note: on devices that expose buttons through both HID++ diversion and the
+/// OS CGEventTap path (e.g. MX Vertical), a single press can fire both the
+/// gesture watcher and the hook. The gesture watcher uses AXPress (Safari-safe);
+/// the hook path uses Cmd+[/] (Chrome-safe, no-op in Safari). This is harmless
+/// in practice — Safari only responds to AXPress, Chrome only responds to
+/// keyboard shortcuts, and the two actions don't double-navigate. A shared
+/// cross-path debounce (`TODO`) would be cleaner but is not required.
+const BACK_FORWARD_DEBOUNCE: Duration = Duration::from_millis(150);
+
/// Capture the gesture button, DPI/ModeShift button, and (when
/// `capture_thumbwheel`) the thumb wheel on `route` until `shutdown` resolves,
/// forwarding each event to `sink`.
@@ -96,8 +194,11 @@ struct CaptureAccum {
///
/// Opens and holds one HID++ channel, diverts whichever of those controls the
/// device exposes, and listens. Returns once `shutdown` fires (or its sender is
-/// dropped), after restoring every diverted control. Setup errors are returned;
-/// failures to restore on the way out are logged, not propagated.
+/// dropped), or when the established channel disconnects. A normal shutdown
+/// restores every diverted control; [`CaptureStop::Revoked`] and a disconnect
+/// skip restoration because the device has already reset its volatile mappings.
+/// Setup errors are returned; failures to restore on the way out are logged,
+/// not propagated.
pub async fn run_capture_session(
route: DeviceRoute,
capture_thumbwheel: bool,
@@ -109,28 +210,127 @@ pub async fn run_capture_session(
let chan = open_route_channel(&route)
.await?
.ok_or(GestureError::DeviceNotFound)?;
+ let shared = SharedChannel::new(chan, route.clone());
+ run_capture_session_on(
+ route,
+ shared,
+ capture_thumbwheel,
+ divert_gesture_button,
+ sink,
+ graceful_shutdown(shutdown),
+ channel_slot,
+ )
+ .await
+}
+
+/// Run standalone capture with an explicit graceful-or-revoked stop reason.
+///
+/// This is the reason-aware counterpart to [`run_capture_session`]. A
+/// [`CaptureStop::Graceful`] shutdown restores diverted controls;
+/// [`CaptureStop::Revoked`] releases local ownership without writing through
+/// the stale connection. Dropping the shutdown sender is treated as graceful.
+pub async fn run_capture_session_with_stop_reason(
+ route: DeviceRoute,
+ capture_thumbwheel: bool,
+ divert_gesture_button: bool,
+ sink: mpsc::UnboundedSender<CapturedInput>,
+ shutdown: oneshot::Receiver<CaptureStop>,
+ channel_slot: CaptureChannel,
+) -> Result<(), GestureError> {
+ let chan = open_route_channel(&route)
+ .await?
+ .ok_or(GestureError::DeviceNotFound)?;
+ let shared = SharedChannel::new(chan, route.clone());
+ run_capture_session_on(
+ route,
+ shared,
+ capture_thumbwheel,
+ divert_gesture_button,
+ sink,
+ explicit_shutdown(shutdown),
+ channel_slot,
+ )
+ .await
+}
+
+/// Run capture on the exact channel currently published by `registry`.
+///
+/// A registry miss returns [`GestureError::DeviceNotFound`] without falling
+/// back to route enumeration/opening; the Agent watcher retries after a later
+/// inventory publication.
+pub async fn run_capture_session_with_registry(
+ route: DeviceRoute,
+ capture_thumbwheel: bool,
+ divert_gesture_button: bool,
+ sink: mpsc::UnboundedSender<CapturedInput>,
+ shutdown: oneshot::Receiver<CaptureStop>,
+ channel_slot: CaptureChannel,
+ registry: &ChannelRegistry,
+) -> Result<(), GestureError> {
+ let shared = registry
+ .lookup(&route)
+ .ok_or(GestureError::DeviceNotFound)?;
+ run_capture_session_on(
+ route,
+ shared,
+ capture_thumbwheel,
+ divert_gesture_button,
+ sink,
+ explicit_shutdown(shutdown),
+ channel_slot,
+ )
+ .await
+}
+
+async fn graceful_shutdown(shutdown: oneshot::Receiver<()>) -> CaptureStop {
+ let _ = shutdown.await;
+ CaptureStop::Graceful
+}
+
+async fn explicit_shutdown(shutdown: oneshot::Receiver<CaptureStop>) -> CaptureStop {
+ shutdown.await.unwrap_or(CaptureStop::Graceful)
+}
+
+async fn run_capture_session_on<Shutdown>(
+ route: DeviceRoute,
+ shared: SharedChannel,
+ capture_thumbwheel: bool,
+ divert_gesture_button: bool,
+ sink: mpsc::UnboundedSender<CapturedInput>,
+ shutdown: Shutdown,
+ channel_slot: CaptureChannel,
+) -> Result<(), GestureError>
+where
+ Shutdown: std::future::Future<Output = CaptureStop>,
+{
+ let chan = Arc::clone(shared.channel());
let device_index = route.device_index();
- let trace_legacy_thumbwheel = std::env::var_os("OPENLOGI_TRACE_GESTURE2_THUMBWHEEL").is_some();
+ // Publish before the first setup await so the watcher can validate exact
+ // channel identity on its next poll, even while arming is still in flight.
+ replace_capture_slot(&channel_slot, Some(shared));
let armed = arm_controls(
&chan,
device_index,
capture_thumbwheel,
divert_gesture_button,
- trace_legacy_thumbwheel,
+ legacy_thumbwheel_trace_requested(),
)
- .await?;
-
- // Publish this device's open channel so DPI/SmartShift writes reuse it
- // instead of opening their own. Cleared on the way out.
- if let Ok(mut slot) = channel_slot.write() {
- *slot = Some(SharedChannel::new(Arc::clone(&chan), route.clone()));
- }
+ .await;
+ let armed = match armed {
+ Ok(armed) => armed,
+ Err(error) => {
+ replace_capture_slot(&channel_slot, None);
+ return Err(error);
+ }
+ };
let accum = Arc::new(Mutex::new(CaptureAccum::default()));
let reprog_index = armed.reprog.as_ref().map(|(_, idx)| *idx);
let thumb_index = armed.thumb.as_ref().map(|(_, idx)| *idx);
let legacy_thumb_index = armed.legacy_thumb.as_ref().map(|legacy| legacy.index);
let dpi_set = armed.dpi_cids.clone();
+ let back_set = armed.back_cids.clone();
+ let forward_set = armed.forward_cids.clone();
let listener = chan.add_msg_listener_guarded({
let accum = Arc::clone(&accum);
let sink = sink.clone();
@@ -139,19 +339,7 @@ pub async fn run_capture_session(
return;
}
let msg = v20::Message::from(raw);
- if trace_legacy_thumbwheel
- && let Some(idx) = legacy_thumb_index
- && msg.header().device_index == device_index
- && msg.header().feature_index == idx
- {
- let header = msg.header();
- info!(
- feature_index = header.feature_index,
- function_id = header.function_id.to_lo(),
- software_id = header.software_id.to_lo(),
- payload = ?msg.extend_payload(),
- "legacy Gestures2 thumbwheel raw event"
- );
+ if log_legacy_thumbwheel_packet(&msg, device_index, legacy_thumb_index) {
return;
}
if let Some(idx) = reprog_index
@@ -160,14 +348,14 @@ pub async fn run_capture_session(
// Recover the guard even if a prior holder panicked — the
// critical section is panic-free, so the data is consistent.
let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner);
- handle_reprog(&mut acc, event, &dpi_set, &sink);
+ handle_reprog(&mut acc, event, &dpi_set, &back_set, &forward_set, &sink);
return;
}
if let Some(idx) = thumb_index
&& let Some(event) = thumbwheel::decode_event(&msg, device_index, idx)
{
if event.single_tap {
- let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::Thumbwheel));
+ let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::Thumbwheel, None));
}
if event.rotation != 0 {
let _ = sink.send(CapturedInput::Scroll(event.rotation));
@@ -180,19 +368,84 @@ pub async fn run_capture_session(
index = device_index,
gesture = armed.gesture_diverted,
dpi_buttons = armed.dpi_cids.len(),
+ back_buttons = armed.back_cids.len(),
+ forward_buttons = armed.forward_cids.len(),
thumbwheel = armed.thumb.is_some(),
- legacy_thumbwheel_trace = armed.legacy_thumb.is_some(),
"control capture active"
);
- let _ = shutdown.await;
+ let exit = wait_for_capture_exit(&chan, shutdown, CAPTURE_HEALTH_POLL).await;
+
+ drop(listener);
+ replace_capture_slot(&channel_slot, None);
+ match exit {
+ CaptureExit::Stopped(CaptureStop::Graceful) => {
+ armed.disarm().await;
+ debug!(index = device_index, "control capture stopped");
+ Ok(())
+ }
+ CaptureExit::Stopped(CaptureStop::Revoked) => {
+ debug!(
+ index = device_index,
+ "control capture abandoned after reconnect"
+ );
+ Ok(())
+ }
+ CaptureExit::Disconnected => {
+ debug!(index = device_index, "control capture channel disconnected");
+ Err(GestureError::ChannelDisconnected)
+ }
+ }
+}
+
+fn replace_capture_slot(slot: &CaptureChannel, value: Option<SharedChannel>) {
+ *slot.write().unwrap_or_else(PoisonError::into_inner) = value;
+}
+#[cfg_attr(
+ not(test),
+ allow(dead_code, reason = "used by gesture session unit tests")
+)]
+async fn teardown_capture<Listener, Clear, Disarm, DisarmFuture>(
+ clear: Clear,
+ listener: Listener,
+ stop: CaptureStop,
+ disarm: Disarm,
+) where
+ Clear: FnOnce(),
+ Disarm: FnOnce() -> DisarmFuture,
+ DisarmFuture: std::future::Future<Output = ()>,
+{
+ clear();
drop(listener);
- if let Ok(mut slot) = channel_slot.write() {
- *slot = None;
+ if stop == CaptureStop::Graceful {
+ disarm().await;
}
- armed.disarm().await;
- debug!(index = device_index, "control capture stopped");
- Ok(())
+}
+
+fn legacy_thumbwheel_trace_requested() -> bool {
+ std::env::var_os("OPENLOGI_TRACE_GESTURE2_THUMBWHEEL").is_some()
+}
+
+fn log_legacy_thumbwheel_packet(
+ msg: &v20::Message,
+ device_index: u8,
+ feature_index: Option<u8>,
+) -> bool {
+ let Some(feature_index) = feature_index else {
+ return false;
+ };
+ if msg.header().device_index != device_index || msg.header().feature_index != feature_index {
+ return false;
+ }
+ let header = msg.header();
+ info!(
+ feature_index = header.feature_index,
+ function_id = header.function_id.to_lo(),
+ software_id = header.software_id.to_lo(),
+ payload = ?msg.extend_payload(),
+ "legacy Gestures2 thumbwheel raw event"
+ );
+ true
}
/// The set of controls a session has diverted, kept so they can be handed back
@@ -204,20 +457,23 @@ struct ArmedControls {
gesture_diverted: bool,
/// DPI/ModeShift CIDs diverted as plain buttons.
dpi_cids: Vec<u16>,
+ /// Back button CIDs diverted as plain buttons.
+ back_cids: Vec<u16>,
+ /// Forward button CIDs diverted as plain buttons.
+ forward_cids: Vec<u16>,
/// `0x2150` accessor + feature index, present when the thumb wheel is
/// diverted.
thumb: Option<(Thumbwheel, u8)>,
/// Diagnostic-only `0x6501` diversion for old MX thumb wheels. The packet
/// format is not decoded yet; this exists only while collecting real-device
- /// traces needed to replace the unsafe source-less OS-hook fallback.
+ /// traces for a native HID++ decoder.
legacy_thumb: Option<LegacyThumbwheelTrace>,
}
struct LegacyThumbwheelTrace {
feature: Gestures2Feature,
index: u8,
- /// We only restore when this process changed the state. If another tool had
- /// already diverted the gesture, leave that ownership untouched.
+ /// Restore only when this process changed the state.
restore_native: bool,
}
@@ -234,6 +490,15 @@ impl ArmedControls {
for &cid in &self.dpi_cids {
restore(rc.set_cid_reporting(cid, false, false).await, "DPI button");
}
+ for &cid in &self.back_cids {
+ restore(rc.set_cid_reporting(cid, false, false).await, "Back button");
+ }
+ for &cid in &self.forward_cids {
+ restore(
+ rc.set_cid_reporting(cid, false, false).await,
+ "Forward button",
+ );
+ }
}
if let Some((tw, _)) = self.thumb.as_ref() {
restore(tw.set_reporting(false, false).await, "thumb wheel");
@@ -253,6 +518,54 @@ impl ArmedControls {
}
}
+/// Arm the dedicated HID++ `0x2150` thumbwheel when available.
+async fn arm_modern_thumbwheel(
+ device: &Device,
+ chan: &Arc<HidppChannel>,
+ slot: u8,
+ capture_thumbwheel: bool,
+) -> Result<Option<(Thumbwheel, u8)>, GestureError> {
+ let mut thumb = None;
+ if capture_thumbwheel
+ && let Some(info) = device
+ .root()
+ .get_feature(thumbwheel::FEATURE_ID)
+ .await
+ .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
+ {
+ let tw = Thumbwheel::new(Arc::clone(chan), slot, info.index);
+ // Consume the getInfo error here, before the next await: Hidpp20Error
+ // isn't Send, so holding it across an await would make this future
+ // (spawned on tokio) non-Send.
+ let supports_single_tap = match tw.get_info().await {
+ Ok(twinfo) => twinfo.supports_single_tap,
+ Err(e) => {
+ warn!(error = ?e, "thumb wheel getInfo failed");
+ false
+ }
+ };
+ // Divert whenever capture was requested: rotation rebinds and the
+ // sensitivity multiplier need the diverted event stream even on wheels
+ // that report no single-tap capability (e.g. MX Master 4) — lacking the
+ // tap only means a bound click can never fire.
+ if !supports_single_tap {
+ debug!("thumb wheel reports no single tap — click not capturable");
+ }
+ // Use warn+continue rather than ? so a thumbwheel setup failure
+ // doesn't abort the whole session and leave already-diverted
+ // Back/Forward/DPI controls stuck with no capture session to
+ // restore them.
+ match tw.set_reporting(true, false).await {
+ Ok(()) => thumb = Some((tw, info.index)),
+ Err(e) => {
+ warn!(error = ?e, "thumb wheel set_reporting failed — skipping click capture");
+ }
+ }
+ }
+
+ Ok(thumb)
+}
+
/// Arm diagnostic-only diversion for the legacy `Gestures2` thumb wheel.
async fn arm_legacy_thumbwheel_trace(
device: &Device,
@@ -323,6 +636,8 @@ async fn arm_controls(
let mut reprog: Option<(ReprogControlsV4, u8)> = None;
let mut gesture_diverted = false;
let mut dpi_cids: Vec<u16> = Vec::new();
+ let mut back_cids: Vec<u16> = Vec::new();
+ let mut forward_cids: Vec<u16> = Vec::new();
if let Some(info) = device
.root()
.get_feature(reprog_controls::FEATURE_ID)
@@ -339,58 +654,51 @@ async fn arm_controls(
.iter()
.any(|c| c.cid == reprog_controls::GESTURE_BUTTON_CID && c.supports_raw_xy())
{
+ // No prior diversions to roll back at this point — gesture is first.
rc.set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, true, true)
.await
.map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
gesture_diverted = true;
}
- for &cid in &reprog_controls::DPI_MODE_SHIFT_CIDS {
- if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
- rc.set_cid_reporting(cid, true, false)
- .await
- .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
- dpi_cids.push(cid);
- }
- }
+ // Track every CID diverted so far (across DPI/Back/Forward groups) so a
+ // later group's failure can roll back everything already diverted —
+ // including this group's own partial progress — leaving no button stuck.
+ let mut diverted_cids: Vec<u16> = Vec::new();
+ dpi_cids = divert_candidate_cids(
+ &rc,
+ &controls,
+ &reprog_controls::DPI_MODE_SHIFT_CIDS,
+ &mut diverted_cids,
+ gesture_diverted,
+ )
+ .await?;
+ // Back/Forward buttons on MX Vertical and similar devices report via
+ // HID++ rather than as standard OS mouse buttons. Divert them so the
+ // capture session can synthesize the correct OS events.
+ back_cids = divert_candidate_cids(
+ &rc,
+ &controls,
+ &reprog_controls::BACK_CIDS,
+ &mut diverted_cids,
+ gesture_diverted,
+ )
+ .await?;
+ forward_cids = divert_candidate_cids(
+ &rc,
+ &controls,
+ &reprog_controls::FORWARD_CIDS,
+ &mut diverted_cids,
+ gesture_diverted,
+ )
+ .await?;
reprog = Some((rc, info.index));
}
- let mut thumb: Option<(Thumbwheel, u8)> = None;
- if capture_thumbwheel
- && let Some(info) = device
- .root()
- .get_feature(thumbwheel::FEATURE_ID)
- .await
- .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
- {
- let tw = Thumbwheel::new(Arc::clone(chan), slot, info.index);
- // Consume the getInfo error here, before the next await: Hidpp20Error
- // isn't Send, so holding it across an await would make this future
- // (spawned on tokio) non-Send.
- let supports_single_tap = match tw.get_info().await {
- Ok(twinfo) => twinfo.supports_single_tap,
- Err(e) => {
- warn!(error = ?e, "thumb wheel getInfo failed");
- false
- }
- };
- // Divert whenever capture was requested: rotation rebinds and the
- // sensitivity multiplier need the diverted event stream even on wheels
- // that report no single-tap capability (e.g. MX Master 4) — lacking the
- // tap only means a bound click can never fire.
- if !supports_single_tap {
- debug!("thumb wheel reports no single tap — click not capturable");
- }
- tw.set_reporting(true, false)
- .await
- .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
- thumb = Some((tw, info.index));
- }
+ let thumb = arm_modern_thumbwheel(&device, chan, slot, capture_thumbwheel).await?;
- // The MX Master 2S does not expose 0x2150. Its wheel is Gestures2 gesture
- // 46, whose diverted notification layout is not publicly documented. Do
- // not guess at that wire format: trace mode diverts only that gesture and
- // records raw notifications so a decoder can be written from real packets.
+ // MX Master 2S does not expose 0x2150. Its thumb wheel is Gestures2
+ // gesture 46. Trace mode diverts only that gesture and logs raw packets;
+ // normal builds continue using the native horizontal-wheel fallback.
let legacy_thumb = arm_legacy_thumbwheel_trace(
&device,
chan,
@@ -399,28 +707,80 @@ async fn arm_controls(
)
.await?;
- if !gesture_diverted && dpi_cids.is_empty() && thumb.is_none() && legacy_thumb.is_none() {
+ if !gesture_diverted
+ && dpi_cids.is_empty()
+ && back_cids.is_empty()
+ && forward_cids.is_empty()
+ && thumb.is_none()
+ && legacy_thumb.is_none()
+ {
debug!(slot, "no capturable controls — idle session");
}
Ok(ArmedControls {
reprog,
gesture_diverted,
dpi_cids,
+ back_cids,
+ forward_cids,
thumb,
legacy_thumb,
})
}
+/// Divert every CID in `candidates` that `controls` reports as divertable,
+/// appending each success to `diverted` (the running rollback list shared
+/// across all candidate groups in one [`arm_controls`] call) and returning
+/// the subset actually diverted from this group.
+///
+/// On failure, rolls back `gesture_diverted` (if set) plus everything already
+/// in `diverted` — including this group's own partial progress, since the
+/// failing CID's own diversion never got recorded — then returns the error.
+/// No calling group is left with a stuck-diverted button.
+async fn divert_candidate_cids(
+ rc: &ReprogControlsV4,
+ controls: &[reprog_controls::CtrlIdInfo],
+ candidates: &[u16],
+ diverted: &mut Vec<u16>,
+ gesture_diverted: bool,
+) -> Result<Vec<u16>, GestureError> {
+ let mut group = Vec::new();
+ for &cid in candidates {
+ if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
+ if let Err(e) = rc.set_cid_reporting(cid, true, false).await {
+ if gesture_diverted {
+ let _ = rc
+ .set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, false, false)
+ .await;
+ }
+ for &d in diverted.iter() {
+ let _ = rc.set_cid_reporting(d, false, false).await;
+ }
+ // The failed enable may still have applied in firmware if only
+ // the acknowledgement was lost — best-effort revert this CID
+ // too, so a flaky response doesn't leave it silently diverted
+ // with no ArmedControls session ever created to restore it.
+ let _ = rc.set_cid_reporting(cid, false, false).await;
+ return Err(GestureError::Hidpp(format!("{e:?}")));
+ }
+ group.push(cid);
+ diverted.push(cid);
+ }
+ }
+ Ok(group)
+}
+
/// Log (don't propagate) a failure to hand a control back to the firmware.
-fn restore<E: std::fmt::Display>(result: Result<(), E>, what: &str) {
+/// Shared with the keyboard capture session (`crate::keyboard`).
+pub(crate) fn restore<E: std::fmt::Display>(result: Result<(), E>, what: &str) {
if let Err(e) = result {
warn!(error = %e, control = what, "failed to restore control mapping on shutdown");
}
}
/// Read the device's full reprogrammable-control table in one pass, so we can
-/// test several CIDs without rescanning per control.
-async fn enumerate_controls(
+/// test several CIDs without rescanning per control. Shared with the keyboard
+/// capture session (`crate::keyboard`).
+pub(crate) async fn enumerate_controls(
rc: &ReprogControlsV4,
) -> Result<Vec<reprog_controls::CtrlIdInfo>, GestureError> {
let count = rc
@@ -446,6 +806,8 @@ fn handle_reprog(
acc: &mut CaptureAccum,
event: RawControlEvent,
dpi_cids: &[u16],
+ back_cids: &[u16],
+ forward_cids: &[u16],
sink: &mpsc::UnboundedSender<CapturedInput>,
) {
match event {
@@ -463,9 +825,52 @@ fn handle_reprog(
let dpi_down = dpi_cids.iter().any(|cid| cids.contains(cid));
if dpi_down && !acc.dpi_down {
- let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::DpiToggle));
+ let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::DpiToggle, None));
}
acc.dpi_down = dpi_down;
+
+ // Back/Forward: emit on the rising edge (first frame where the CID
+ // appears), matching the DPI button convention above.
+ let back_down = back_cids.iter().any(|cid| cids.contains(cid));
+ if back_down && !acc.back_down {
+ let now = Instant::now();
+ let elapsed = acc.last_back.map_or(BACK_FORWARD_DEBOUNCE, |t| now - t);
+ if elapsed >= BACK_FORWARD_DEBOUNCE {
+ acc.last_back = Some(now);
+ // Capture frontmost PID NOW on this listener thread — before
+ // any async dispatch delay can shift focus away from the
+ // target browser window.
+ let _ = sink.send(CapturedInput::ButtonPressed(
+ ButtonId::Back,
+ frontmost_pid(),
+ ));
+ } else {
+ debug!(
+ elapsed_ms = elapsed.as_millis(),
+ "Back debounced — too soon after last dispatch"
+ );
+ }
+ }
+ acc.back_down = back_down;
+
+ let forward_down = forward_cids.iter().any(|cid| cids.contains(cid));
+ if forward_down && !acc.forward_down {
+ let now = Instant::now();
+ let elapsed = acc.last_forward.map_or(BACK_FORWARD_DEBOUNCE, |t| now - t);
+ if elapsed >= BACK_FORWARD_DEBOUNCE {
+ acc.last_forward = Some(now);
+ let _ = sink.send(CapturedInput::ButtonPressed(
+ ButtonId::Forward,
+ frontmost_pid(),
+ ));
+ } else {
+ debug!(
+ elapsed_ms = elapsed.as_millis(),
+ "Forward debounced — too soon after last dispatch"
+ );
+ }
+ }
+ acc.forward_down = forward_down;
}
RawControlEvent::RawXy { dx, dy } => {
// Commit the instant a clean direction emerges (mid-swipe, once per
diff --git a/crates/openlogi-hid/src/gesture/tests.rs b/crates/openlogi-hid/src/gesture/tests.rs
index 035f2cdac6b7f0735836acf2423ef1fe63122b9b..5324772d37f5532b91c631e5bd2aeb01cbd3bd3e 100644
--- a/crates/openlogi-hid/src/gesture/tests.rs
+++ b/crates/openlogi-hid/src/gesture/tests.rs
@@ -1,5 +1,97 @@
use super::*;
+use std::cell::RefCell;
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use std::rc::Rc;
+
+struct DropRecorder(Rc<RefCell<Vec<&'static str>>>);
+
+impl Drop for DropRecorder {
+ fn drop(&mut self) {
+ self.0.borrow_mut().push("listener");
+ }
+}
+
+#[tokio::test]
+async fn graceful_teardown_clears_then_drops_listener_then_disarms() {
+ let events = Rc::new(RefCell::new(Vec::new()));
+ teardown_capture(
+ {
+ let events = Rc::clone(&events);
+ move || events.borrow_mut().push("clear")
+ },
+ DropRecorder(Rc::clone(&events)),
+ CaptureStop::Graceful,
+ {
+ let events = Rc::clone(&events);
+ move || async move { events.borrow_mut().push("disarm") }
+ },
+ )
+ .await;
+
+ assert_eq!(*events.borrow(), ["clear", "listener", "disarm"]);
+}
+
+#[tokio::test]
+async fn revoked_teardown_clears_then_drops_listener_without_disarm() {
+ let events = Rc::new(RefCell::new(Vec::new()));
+ teardown_capture(
+ {
+ let events = Rc::clone(&events);
+ move || events.borrow_mut().push("clear")
+ },
+ DropRecorder(Rc::clone(&events)),
+ CaptureStop::Revoked,
+ {
+ let events = Rc::clone(&events);
+ move || async move { events.borrow_mut().push("disarm") }
+ },
+ )
+ .await;
+
+ assert_eq!(*events.borrow(), ["clear", "listener"]);
+}
+
+#[test]
+fn capture_slot_cleanup_recovers_a_poisoned_writer() {
+ let slot: CaptureChannel = Arc::new(RwLock::new(None));
+ let poison = Arc::clone(&slot);
+ let _ = catch_unwind(AssertUnwindSafe(move || {
+ let _guard = poison.write().unwrap_or_else(PoisonError::into_inner);
+ panic!("poison capture slot");
+ }));
+
+ replace_capture_slot(&slot, None);
+
+ assert!(
+ slot.read()
+ .unwrap_or_else(PoisonError::into_inner)
+ .is_none()
+ );
+}
+
+#[tokio::test]
+async fn registry_miss_does_not_fall_back_to_opening_the_route() {
+ let route = DeviceRoute::Direct {
+ vendor_id: 0x046d,
+ product_id: 0xb35b,
+ };
+ let registry = ChannelRegistry::default();
+ let (sink, _events) = mpsc::unbounded_channel();
+ let (_stop, shutdown) = oneshot::channel();
+ let slot: CaptureChannel = Arc::new(RwLock::new(None));
+
+ let result =
+ run_capture_session_with_registry(route, false, false, sink, shutdown, slot, ®istry)
+ .await;
+
+ let Err(error) = result else {
+ panic!("an empty Agent registry must fail before any route open");
+ };
+
+ assert!(matches!(error, GestureError::DeviceNotFound));
+}
+
fn press() -> RawControlEvent {
RawControlEvent::DivertedButtons([reprog_controls::GESTURE_BUTTON_CID, 0, 0, 0])
}
@@ -13,14 +105,16 @@ fn quick_tap_is_a_click_even_while_the_cursor_moves() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
- handle_reprog(&mut acc, press(), &[], &tx);
+ handle_reprog(&mut acc, press(), &[], &[], &[], &tx);
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 120, dy: 5 },
&[],
+ &[],
+ &[],
&tx,
);
- handle_reprog(&mut acc, release(), &[], &tx);
+ handle_reprog(&mut acc, release(), &[], &[], &[], &tx);
assert_eq!(
rx.try_recv(),
@@ -37,13 +131,15 @@ fn a_held_gesture_commits_a_swipe_and_does_not_also_click() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut acc = CaptureAccum::default();
- handle_reprog(&mut acc, press(), &[], &tx);
+ handle_reprog(&mut acc, press(), &[], &[], &[], &tx);
// Pretend the button has been held well past the swipe gate.
acc.swipe.backdate_hold_for_test();
handle_reprog(
&mut acc,
RawControlEvent::RawXy { dx: 120, dy: 5 },
&[],
+ &[],
+ &[],
&tx,
);
@@ -52,7 +148,7 @@ fn a_held_gesture_commits_a_swipe_and_does_not_also_click() {
Ok(CapturedInput::Gesture(GestureDirection::Right))
);
- handle_reprog(&mut acc, release(), &[], &tx);
+ handle_reprog(&mut acc, release(), &[], &[], &[], &tx);
assert!(
rx.try_recv().is_err(),
"a committed swipe must not also click on release"
@@ -66,12 +162,12 @@ fn a_held_dpi_button_presses_once_on_the_rising_edge() {
let dpi = reprog_controls::DPI_MODE_SHIFT_CIDS[0];
let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]);
- handle_reprog(&mut acc, down, &[dpi], &tx);
- handle_reprog(&mut acc, down, &[dpi], &tx);
+ handle_reprog(&mut acc, down, &[dpi], &[], &[], &tx);
+ handle_reprog(&mut acc, down, &[dpi], &[], &[], &tx);
assert_eq!(
rx.try_recv(),
- Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle))
+ Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle, None))
);
assert!(rx.try_recv().is_err(), "a held DPI button presses once");
}
@@ -87,18 +183,98 @@ fn a_dpi_button_re_presses_after_a_release() {
let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]);
let up = RawControlEvent::DivertedButtons([0, 0, 0, 0]);
- handle_reprog(&mut acc, down, &[dpi], &tx);
- handle_reprog(&mut acc, up, &[dpi], &tx);
- handle_reprog(&mut acc, down, &[dpi], &tx);
+ handle_reprog(&mut acc, down, &[dpi], &[], &[], &tx);
+ handle_reprog(&mut acc, up, &[dpi], &[], &[], &tx);
+ handle_reprog(&mut acc, down, &[dpi], &[], &[], &tx);
assert_eq!(
rx.try_recv(),
- Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle))
+ Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle, None))
);
assert_eq!(
rx.try_recv(),
- Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle)),
+ Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle, None)),
"a release re-arms the rising edge"
);
assert!(rx.try_recv().is_err());
}
+
+// Back/Forward emit `ButtonPressed` with a real `frontmost_pid()` reading
+// (`Some` on macOS, `None` elsewhere — see `frontmost_pid`), unlike DPI's
+// hardcoded `None`, so these assertions match on the button only.
+
+#[test]
+fn a_held_back_button_presses_once_on_the_rising_edge() {
+ let (tx, mut rx) = mpsc::unbounded_channel();
+ let mut acc = CaptureAccum::default();
+ let back = reprog_controls::BACK_CIDS[0];
+ let down = RawControlEvent::DivertedButtons([back, 0, 0, 0]);
+
+ handle_reprog(&mut acc, down, &[], &[back], &[], &tx);
+ handle_reprog(&mut acc, down, &[], &[back], &[], &tx);
+
+ assert!(matches!(
+ rx.try_recv(),
+ Ok(CapturedInput::ButtonPressed(ButtonId::Back, _))
+ ));
+ assert!(rx.try_recv().is_err(), "a held Back button presses once");
+}
+
+#[test]
+fn a_forward_button_re_press_within_the_debounce_window_is_suppressed() {
+ // Unlike the DPI button, Back/Forward re-arm on release is gated by
+ // BACK_FORWARD_DEBOUNCE: the MX Vertical's firmware can report a single
+ // physical click as press/release/press within a few ms, and without the
+ // debounce that would fire twice. A press → release → press happening in
+ // a tight test loop is well within the 150ms window, so the second press
+ // must be suppressed.
+ let (tx, mut rx) = mpsc::unbounded_channel();
+ let mut acc = CaptureAccum::default();
+ let fwd = reprog_controls::FORWARD_CIDS[0];
+ let down = RawControlEvent::DivertedButtons([fwd, 0, 0, 0]);
+ let up = RawControlEvent::DivertedButtons([0, 0, 0, 0]);
+
+ handle_reprog(&mut acc, down, &[], &[], &[fwd], &tx);
+ handle_reprog(&mut acc, up, &[], &[], &[fwd], &tx);
+ handle_reprog(&mut acc, down, &[], &[], &[fwd], &tx);
+
+ assert!(matches!(
+ rx.try_recv(),
+ Ok(CapturedInput::ButtonPressed(ButtonId::Forward, _))
+ ));
+ assert!(
+ rx.try_recv().is_err(),
+ "a re-press inside the debounce window must not re-fire"
+ );
+}
+
+#[test]
+fn a_back_button_re_press_after_the_debounce_window_fires_again() {
+ let (tx, mut rx) = mpsc::unbounded_channel();
+ let mut acc = CaptureAccum::default();
+ let back = reprog_controls::BACK_CIDS[0];
+ let down = RawControlEvent::DivertedButtons([back, 0, 0, 0]);
+ let up = RawControlEvent::DivertedButtons([0, 0, 0, 0]);
+
+ handle_reprog(&mut acc, down, &[], &[back], &[], &tx);
+ handle_reprog(&mut acc, up, &[], &[back], &[], &tx);
+ // Backdate the last dispatch past the debounce window instead of
+ // sleeping in the test, mirroring `SwipeAccumulator::backdate_hold_for_test`.
+ acc.last_back = acc
+ .last_back
+ .and_then(|t| t.checked_sub(BACK_FORWARD_DEBOUNCE + Duration::from_millis(1)));
+ handle_reprog(&mut acc, down, &[], &[back], &[], &tx);
+
+ assert!(matches!(
+ rx.try_recv(),
+ Ok(CapturedInput::ButtonPressed(ButtonId::Back, _))
+ ));
+ assert!(
+ matches!(
+ rx.try_recv(),
+ Ok(CapturedInput::ButtonPressed(ButtonId::Back, _))
+ ),
+ "a re-press past the debounce window re-arms and fires"
+ );
+ assert!(rx.try_recv().is_err());
+}
diff --git a/crates/openlogi-hid/src/host_switch.rs b/crates/openlogi-hid/src/host_switch.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f1e560de9d52b062f46a7b6e6a1dbfa7ebbf9d3b
--- /dev/null
+++ b/crates/openlogi-hid/src/host_switch.rs
@@ -0,0 +1,577 @@
+//! Keyboard-initiated host-switch synchronization.
+//!
+//! A session temporarily diverts the keyboard's three host controls, observes
+//! which channel was pressed, switches the linked pointing devices, and then
+//! switches the keyboard itself. Ordering matters: once the keyboard leaves
+//! this host its HID++ channel can no longer command a mouse sharing the same
+//! receiver.
+
+use std::{future::Future, sync::Arc, time::Duration};
+
+use hidpp::{
+ channel::HidppChannel,
+ device::Device,
+ feature::{CreatableFeature, change_host::ChangeHostFeature},
+ protocol::v20,
+};
+use thiserror::Error;
+use tokio::{
+ sync::{mpsc, oneshot},
+ time::timeout,
+};
+use tracing::{debug, info};
+
+use crate::{
+ ChannelPool,
+ reprog_controls::{self, ReprogControlsV4},
+ route::DeviceRoute,
+};
+
+/// Why an armed host-switch session is being stopped externally.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HostSwitchStopReason {
+ /// The keyboard remains reachable, so its controls must be restored.
+ Graceful,
+ /// The keyboard disappeared, so only local resources can be released.
+ DeviceLost,
+}
+
+const HOST_CONTROL_IDS: [(reprog_controls::ControlId, u8); 3] = [
+ (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_1, 0),
+ (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_2, 1),
+ (reprog_controls::control_ids::HOST_SWITCH_CHANNEL_3, 2),
+];
+const HOST_TASK_IDS: [(reprog_controls::TaskId, u8); 3] = [
+ (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_1, 0),
+ (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_2, 1),
+ (reprog_controls::task_ids::HOST_SWITCH_CHANNEL_3, 2),
+];
+const HIDPP_OPERATION_TIMEOUT: Duration = Duration::from_secs(5);
+
+#[derive(Clone, Copy)]
+enum ReportingMode {
+ Diverted,
+ Analytics,
+}
+
+#[derive(Clone, Copy)]
+struct ArmedControl {
+ cid: u16,
+ host: u8,
+ mode: ReportingMode,
+ original: reprog_controls::CidReporting,
+}
+
+/// Failure while arming or running a host-switch link.
+#[derive(Debug, Error)]
+pub enum HostSwitchError {
+ /// HID transport-level failure.
+ #[error("HID transport error")]
+ Hid(#[from] async_hid::HidError),
+ /// The configured keyboard is not currently reachable.
+ #[error("configured keyboard is not connected")]
+ KeyboardNotFound,
+ /// A configured target is not currently reachable.
+ #[error("configured linked device is not connected")]
+ TargetNotFound,
+ /// A required HID++ operation failed.
+ #[error("HID++ protocol error: {0}")]
+ Hidpp(String),
+ /// A required HID++ operation did not complete within its budget.
+ #[error("HID++ operation timed out while {operation}")]
+ TimedOut {
+ /// Description of the operation that exceeded its budget.
+ operation: &'static str,
+ },
+ /// The keyboard cannot report its host switch controls to software.
+ #[error("keyboard exposes no reportable host switch controls")]
+ UnsupportedKeyboard,
+}
+
+/// Capture host switch keys on `keyboard` until one is pressed or `shutdown`
+/// resolves. Controls are restored before a requested host is returned.
+pub async fn run_host_switch_session(
+ keyboard: DeviceRoute,
+ shutdown: oneshot::Receiver<HostSwitchStopReason>,
+ channel_pool: ChannelPool,
+) -> Result<Option<u8>, HostSwitchError> {
+ let channel = open_channel(&channel_pool, &keyboard, "opening keyboard channel")
+ .await?
+ .ok_or(HostSwitchError::KeyboardNotFound)?;
+ let keyboard_index = keyboard.device_index();
+ let device = timed_hidpp(
+ "opening keyboard device",
+ Device::new(Arc::clone(&channel), keyboard_index),
+ )
+ .await?;
+ let feature = timed_hidpp(
+ "locating host controls",
+ device.root().get_feature(reprog_controls::FEATURE_ID),
+ )
+ .await?
+ .ok_or(HostSwitchError::UnsupportedKeyboard)?;
+ let controls = ReprogControlsV4::new(Arc::clone(&channel), keyboard_index, feature.index);
+
+ let armed = arm_host_controls(&controls).await?;
+ if armed.is_empty() {
+ return Err(HostSwitchError::UnsupportedKeyboard);
+ }
+
+ let (press_tx, mut press_rx) = mpsc::unbounded_channel();
+ let feature_index = controls.feature_index();
+ let event_controls = armed.clone();
+ let listener = channel.add_msg_listener_guarded(move |raw, matched| {
+ if matched {
+ return;
+ }
+ let message = v20::Message::from(raw);
+ let Some(event) =
+ reprog_controls::decode_full_event(&message, keyboard_index, feature_index)
+ else {
+ return;
+ };
+ if let Some(host) = event_host(&event_controls, event) {
+ let _ = press_tx.send(host);
+ }
+ });
+
+ info!(
+ route = %keyboard,
+ controls = armed.len(),
+ "host switch link active"
+ );
+ let outcome = tokio::select! {
+ reason = shutdown => {
+ let reason = reason.unwrap_or(HostSwitchStopReason::DeviceLost);
+ (None, reason == HostSwitchStopReason::Graceful)
+ },
+ Some(host) = press_rx.recv() => (Some(host), true),
+ };
+
+ drop(listener);
+ if outcome.1 {
+ restore_host_controls(&controls, armed).await;
+ }
+ Ok(outcome.0)
+}
+
+/// Move reachable targets to `host`, then move the keyboard last.
+///
+/// Returns whether the keyboard actually changed hosts.
+pub async fn switch_linked_hosts(
+ keyboard: &DeviceRoute,
+ targets: &[DeviceRoute],
+ host: u8,
+ channel_pool: &ChannelPool,
+) -> Result<bool, HostSwitchError> {
+ let channel = open_channel(channel_pool, keyboard, "opening keyboard channel")
+ .await?
+ .ok_or(HostSwitchError::KeyboardNotFound)?;
+ for target in targets {
+ match prepare_host_change(target, host, keyboard, &channel, channel_pool).await {
+ Ok(change) => {
+ if let Err(error) = apply_host_change(change).await {
+ debug!(%error, route = %target, host, "linked device host switch failed");
+ }
+ }
+ Err(error) => {
+ debug!(%error, route = %target, host, "linked device host switch preparation failed");
+ }
+ }
+ }
+ let keyboard_change = prepare_host_change_on(&channel, keyboard.device_index(), host).await?;
+ let changed = apply_host_change(keyboard_change).await?;
+ if changed {
+ debug!(host, route = %keyboard, "keyboard host switched");
+ }
+ Ok(changed)
+}
+
+async fn arm_host_controls(
+ controls: &ReprogControlsV4,
+) -> Result<Vec<ArmedControl>, HostSwitchError> {
+ let mut armed = Vec::new();
+ if let Err(error) = arm_host_controls_inner(controls, &mut armed).await {
+ restore_host_controls(controls, armed).await;
+ return Err(error);
+ }
+ Ok(armed)
+}
+
+async fn arm_host_controls_inner(
+ controls: &ReprogControlsV4,
+ armed: &mut Vec<ArmedControl>,
+) -> Result<(), HostSwitchError> {
+ let count = timed_hidpp("reading host control count", controls.get_count()).await?;
+ for index in 0..count {
+ let info = timed_hidpp(
+ "reading host control information",
+ controls.get_ctrl_id_info(index),
+ )
+ .await?;
+ let Some(host) = host_channel(info) else {
+ continue;
+ };
+ debug!(
+ cid = format_args!("{:#06x}", info.cid),
+ task_id = format_args!("{:#06x}", info.task_id),
+ host,
+ divertable = info.is_divertable(),
+ analytics = info.supports_analytics_events(),
+ "host switch control discovered"
+ );
+ let mode = if info.is_divertable() {
+ Some(ReportingMode::Diverted)
+ } else if info.supports_analytics_events() {
+ Some(ReportingMode::Analytics)
+ } else {
+ None
+ };
+ if let Some(mode) = mode {
+ let original = timed_hidpp(
+ "reading host control reporting",
+ controls.get_cid_reporting(info.cid),
+ )
+ .await?;
+ // Record the rollback before issuing the write: a transport timeout
+ // can mean that the device applied the request but its response was
+ // lost, so the failing control must be restored as well.
+ armed.push(ArmedControl {
+ cid: info.cid,
+ host,
+ mode,
+ original,
+ });
+ match mode {
+ ReportingMode::Diverted => {
+ timed_hidpp(
+ "diverting host control",
+ controls.set_cid_reporting(info.cid, true, false),
+ )
+ .await?;
+ }
+ ReportingMode::Analytics => {
+ timed_hidpp(
+ "enabling host control analytics",
+ controls.set_cid_reporting_full(
+ info.cid,
+ reprog_controls::CidReportingChange {
+ analytics_key_events: Some(true),
+ ..reprog_controls::CidReportingChange::default()
+ },
+ ),
+ )
+ .await?;
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+async fn restore_host_controls(controls: &ReprogControlsV4, armed: Vec<ArmedControl>) {
+ for control in armed {
+ let mut restored = restore_host_control(controls, control).await;
+ if restored.is_err() {
+ restored = restore_host_control(controls, control).await;
+ }
+ if let Err(error) = restored {
+ debug!(
+ ?error,
+ cid = control.cid,
+ "could not restore host switch control"
+ );
+ }
+ }
+}
+
+async fn restore_host_control(
+ controls: &ReprogControlsV4,
+ control: ArmedControl,
+) -> Result<(), HostSwitchError> {
+ timed_hidpp(
+ "restoring host control reporting",
+ controls.set_cid_reporting_full(control.cid, restoration_change(control)),
+ )
+ .await
+ .map(|_echo| ())
+}
+
+fn restoration_change(control: ArmedControl) -> reprog_controls::CidReportingChange {
+ match control.mode {
+ ReportingMode::Diverted => reprog_controls::CidReportingChange {
+ diverted: Some(control.original.diverted),
+ raw_xy: Some(control.original.raw_xy),
+ ..reprog_controls::CidReportingChange::default()
+ },
+ ReportingMode::Analytics => reprog_controls::CidReportingChange {
+ analytics_key_events: Some(control.original.analytics_key_events),
+ ..reprog_controls::CidReportingChange::default()
+ },
+ }
+}
+
+struct PreparedHostChange {
+ feature: Arc<ChangeHostFeature>,
+ device_index: u8,
+ host: u8,
+ required: bool,
+}
+
+async fn prepare_host_change(
+ target: &DeviceRoute,
+ host: u8,
+ keyboard: &DeviceRoute,
+ keyboard_channel: &Arc<HidppChannel>,
+ channel_pool: &ChannelPool,
+) -> Result<PreparedHostChange, HostSwitchError> {
+ if shares_channel(target, keyboard) {
+ prepare_host_change_on(keyboard_channel, target.device_index(), host).await
+ } else {
+ let channel = open_channel(channel_pool, target, "opening linked device channel")
+ .await?
+ .ok_or(HostSwitchError::TargetNotFound)?;
+ prepare_host_change_on(&channel, target.device_index(), host).await
+ }
+}
+
+async fn prepare_host_change_on(
+ channel: &Arc<HidppChannel>,
+ device_index: u8,
+ host: u8,
+) -> Result<PreparedHostChange, HostSwitchError> {
+ let mut device = timed_hidpp(
+ "opening host-change device",
+ Device::new(Arc::clone(channel), device_index),
+ )
+ .await?;
+ let info = timed_hidpp(
+ "locating host-change feature",
+ device.root().get_feature(ChangeHostFeature::ID),
+ )
+ .await?
+ .ok_or_else(|| HostSwitchError::Hidpp("ChangeHost is unsupported".into()))?;
+ let change_host = device.add_feature::<ChangeHostFeature>(info.index);
+ let state = timed_hidpp("reading current host", change_host.get_host_info()).await?;
+ let required = host_change_required(state.current_host, state.host_count, host)?;
+ Ok(PreparedHostChange {
+ feature: change_host,
+ device_index,
+ host,
+ required,
+ })
+}
+
+async fn apply_host_change(change: PreparedHostChange) -> Result<bool, HostSwitchError> {
+ if !change.required {
+ let PreparedHostChange {
+ device_index, host, ..
+ } = change;
+ debug!(device_index, host, "device already uses requested host");
+ return Ok(false);
+ }
+ timed_hidpp(
+ "writing current host",
+ change.feature.set_current_host(change.host),
+ )
+ .await?;
+ Ok(true)
+}
+
+async fn open_channel(
+ channel_pool: &ChannelPool,
+ route: &DeviceRoute,
+ operation: &'static str,
+) -> Result<Option<Arc<HidppChannel>>, HostSwitchError> {
+ timeout(HIDPP_OPERATION_TIMEOUT, channel_pool.open(route))
+ .await
+ .map_err(|_| HostSwitchError::TimedOut { operation })?
+ .map_err(HostSwitchError::Hid)
+}
+
+async fn timed_hidpp<T, E>(
+ operation: &'static str,
+ future: impl Future<Output = Result<T, E>>,
+) -> Result<T, HostSwitchError>
+where
+ E: std::fmt::Debug,
+{
+ timeout(HIDPP_OPERATION_TIMEOUT, future)
+ .await
+ .map_err(|_| HostSwitchError::TimedOut { operation })?
+ .map_err(|error| hidpp_error(operation, error))
+}
+
+fn host_change_required(
+ current_host: u8,
+ host_count: u8,
+ requested_host: u8,
+) -> Result<bool, HostSwitchError> {
+ if requested_host >= host_count {
+ return Err(HostSwitchError::Hidpp(format!(
+ "host {requested_host} is outside device host count {host_count}"
+ )));
+ }
+ Ok(current_host != requested_host)
+}
+
+fn shares_channel(left: &DeviceRoute, right: &DeviceRoute) -> bool {
+ left.shares_transport(right)
+}
+
+fn hidpp_error(operation: &'static str, error: impl std::fmt::Debug) -> HostSwitchError {
+ HostSwitchError::Hidpp(format!("{operation}: {error:?}"))
+}
+
+fn host_channel(info: reprog_controls::CtrlIdInfo) -> Option<u8> {
+ HOST_CONTROL_IDS
+ .iter()
+ .find_map(|(cid, host)| (info.cid == cid.0).then_some(*host))
+ .or_else(|| {
+ HOST_TASK_IDS
+ .iter()
+ .find_map(|(task, host)| (info.task_id == task.0).then_some(*host))
+ })
+}
+
+fn event_host(
+ controls: &[ArmedControl],
+ event: reprog_controls::ReprogControlsEvent,
+) -> Option<u8> {
+ match event {
+ reprog_controls::ReprogControlsEvent::DivertedButtons(cids) => controls
+ .iter()
+ .find_map(|control| cids.contains(&control.cid.into()).then_some(control.host)),
+ reprog_controls::ReprogControlsEvent::AnalyticsKeyEvents(events) => {
+ controls.iter().find_map(|control| {
+ events
+ .iter()
+ .any(|event| event.cid.0 == control.cid)
+ .then_some(control.host)
+ })
+ }
+ reprog_controls::ReprogControlsEvent::DivertedRawMouseXy { .. }
+ | reprog_controls::ReprogControlsEvent::DivertedRawWheel { .. } => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ ArmedControl, ReportingMode, event_host, host_change_required, host_channel,
+ restoration_change, shares_channel,
+ };
+ use crate::DeviceRoute;
+ use crate::reprog_controls::{
+ AnalyticsKeyEvent, CidReporting, ControlId, CtrlIdInfo, ReprogControlsEvent,
+ };
+
+ fn reporting(diverted: bool, raw_xy: bool, analytics_key_events: bool) -> CidReporting {
+ CidReporting {
+ cid: ControlId(0x00d3),
+ diverted,
+ persistently_diverted: true,
+ force_raw_xy: true,
+ raw_xy,
+ remap: Some(ControlId(0x1234)),
+ analytics_key_events,
+ raw_wheel: true,
+ }
+ }
+
+ #[test]
+ fn receiver_slots_share_one_channel() {
+ let keyboard = DeviceRoute::Bolt {
+ receiver_uid: "AABB".into(),
+ slot: 1,
+ };
+ let mouse = DeviceRoute::Bolt {
+ receiver_uid: "aabb".into(),
+ slot: 2,
+ };
+ assert!(shares_channel(&keyboard, &mouse));
+ }
+
+ #[test]
+ fn direct_devices_do_not_share_channels() {
+ let route = DeviceRoute::Direct {
+ vendor_id: 0x046d,
+ product_id: 0xb025,
+ };
+ assert!(!shares_channel(&route, &route));
+ }
+
+ #[test]
+ fn host_controls_are_recognized_by_task_when_cid_varies() {
+ let info = CtrlIdInfo {
+ cid: 0x1234,
+ task_id: 0x00af,
+ flags: 0,
+ };
+ assert_eq!(host_channel(info), Some(1));
+ }
+
+ #[test]
+ fn analytics_event_selects_the_matching_host() {
+ let controls = [ArmedControl {
+ cid: 0x00d3,
+ host: 2,
+ mode: ReportingMode::Analytics,
+ original: reporting(false, false, false),
+ }];
+ let mut events = [AnalyticsKeyEvent::default(); 5];
+ events[0] = AnalyticsKeyEvent {
+ cid: ControlId(0x00d3),
+ event: 1,
+ };
+ assert_eq!(
+ event_host(&controls, ReprogControlsEvent::AnalyticsKeyEvents(events)),
+ Some(2)
+ );
+ }
+
+ #[test]
+ fn current_host_does_not_require_a_change() {
+ assert!(matches!(host_change_required(1, 3, 1), Ok(false)));
+ }
+
+ #[test]
+ fn different_valid_host_requires_a_change() {
+ assert!(matches!(host_change_required(0, 3, 2), Ok(true)));
+ }
+
+ #[test]
+ fn host_outside_device_range_is_rejected() {
+ assert!(host_change_required(0, 2, 2).is_err());
+ }
+
+ #[test]
+ fn diverted_cleanup_restores_only_the_original_temporary_bits() {
+ let change = restoration_change(ArmedControl {
+ cid: 0x00d3,
+ host: 2,
+ mode: ReportingMode::Diverted,
+ original: reporting(true, true, false),
+ });
+
+ assert_eq!(change.diverted, Some(true));
+ assert_eq!(change.raw_xy, Some(true));
+ assert_eq!(change.analytics_key_events, None);
+ assert_eq!(change.persistently_diverted, None);
+ assert_eq!(change.remap, None);
+ }
+
+ #[test]
+ fn analytics_cleanup_restores_the_original_analytics_bit() {
+ let change = restoration_change(ArmedControl {
+ cid: 0x00d3,
+ host: 2,
+ mode: ReportingMode::Analytics,
+ original: reporting(false, false, true),
+ });
+
+ assert_eq!(change.analytics_key_events, Some(true));
+ assert_eq!(change.diverted, None);
+ assert_eq!(change.raw_xy, None);
+ }
+}
diff --git a/crates/openlogi-hid/src/inventory.rs b/crates/openlogi-hid/src/inventory.rs
index a95294e46b8e7675d3d6d69a833fa16acd3c2afc..bce818c32af7b62b1213ede2a5c9e43bfe0e32c8 100644
--- a/crates/openlogi-hid/src/inventory.rs
+++ b/crates/openlogi-hid/src/inventory.rs
@@ -2,6 +2,7 @@
use std::{
collections::{HashMap, HashSet},
+ hash::Hash,
sync::Arc,
time::Duration,
};
@@ -13,7 +14,9 @@ use thiserror::Error;
use tokio::time::timeout;
use tracing::{debug, warn};
+use crate::channel_registry::ChannelRegistry;
use crate::node_ledger::NodeLedger;
+use crate::route::DeviceRoute;
use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel};
mod cache;
@@ -83,6 +86,9 @@ pub enum InventoryError {
/// Underlying HID backend error.
#[error("HID transport error")]
Hid(#[from] async_hid::HidError),
+ /// More than one indistinguishable standalone raw-HID node was found.
+ #[error("multiple indistinguishable standalone raw HID devices found")]
+ AmbiguousRawDevice,
}
/// Stateful device enumerator: holds the per-device probe cache so the polling
@@ -100,11 +106,14 @@ pub struct Enumerator {
/// each open also leaks an `io_service_t` in async-hid's macOS backend — so a
/// steadily-connected node is opened once here and reused until it
/// disconnects.
- channels: HashMap<async_hid::DeviceId, CachedChannel>,
+ channels: ChannelCache<async_hid::DeviceId, CachedChannel>,
/// Per-node last-good inventory + consecutive-failure counts: replays a
/// node's snapshot through transient probe failures and decides when its
/// cached channel must be dropped and reopened (see [`crate::node_ledger`]).
ledger: NodeLedger<async_hid::DeviceId>,
+ /// Optional publication sink used by the persistent Agent watcher. One-shot
+ /// callers keep this `None` and retain the route-opening library behavior.
+ registry: Option<ChannelRegistry>,
tick: u64,
}
@@ -117,6 +126,102 @@ struct CachedChannel {
channel: Arc<HidppChannel>,
}
+struct PreparedNodes {
+ active: Vec<(async_hid::DeviceInfo, Arc<HidppChannel>)>,
+ open_failures: Vec<async_hid::DeviceId>,
+ retiring: Vec<async_hid::DeviceId>,
+}
+
+/// Disjoint active and retiring channels, generic so ownership transitions can
+/// be tested without constructing a platform HID node.
+struct ChannelCache<Node, Channel> {
+ active: HashMap<Node, Channel>,
+ retiring: HashMap<Node, Channel>,
+}
+
+impl<Node, Channel> Default for ChannelCache<Node, Channel> {
+ fn default() -> Self {
+ Self {
+ active: HashMap::new(),
+ retiring: HashMap::new(),
+ }
+ }
+}
+
+impl<Node: Eq + Hash + Clone, Channel> ChannelCache<Node, Channel> {
+ fn get(&self, node: &Node) -> Option<&Channel> {
+ self.active.get(node)
+ }
+
+ fn insert(&mut self, node: Node, channel: Channel) {
+ debug_assert!(!self.retiring.contains_key(&node));
+ self.active.insert(node, channel);
+ }
+
+ fn retire_node(&mut self, node: &Node) -> Option<()> {
+ let channel = self.active.remove(node)?;
+ self.retiring.insert(node.clone(), channel);
+ Some(())
+ }
+
+ /// Whether this node may be opened during the current tick. A quiescent
+ /// retirement is dropped here, but opening remains deferred to a later tick.
+ fn prepare_open(&mut self, node: &Node, is_quiescent: impl FnOnce(&Channel) -> bool) -> bool {
+ let Some(channel) = self.retiring.get(node) else {
+ return true;
+ };
+ if is_quiescent(channel) {
+ self.retiring.remove(node);
+ }
+ false
+ }
+
+ fn retire_absent(&mut self, seen: &HashSet<Node>) {
+ let absent = self
+ .active
+ .keys()
+ .filter(|node| !seen.contains(*node))
+ .cloned()
+ .collect::<Vec<_>>();
+ for node in absent {
+ let _ = self.retire_node(&node);
+ }
+ }
+
+ fn reap_absent(&mut self, seen: &HashSet<Node>, is_quiescent: impl Fn(&Channel) -> bool) {
+ self.retiring
+ .retain(|node, channel| seen.contains(node) || !is_quiescent(channel));
+ }
+
+ #[cfg(test)]
+ fn is_retiring(&self, node: &Node) -> bool {
+ self.retiring.contains_key(node)
+ }
+}
+
+fn routes_for_inventories(inventories: &[DeviceInventory]) -> Vec<DeviceRoute> {
+ inventories
+ .iter()
+ .flat_map(|inventory| {
+ inventory
+ .paired
+ .iter()
+ .filter_map(|paired| DeviceRoute::device_route_for(inventory, paired.slot))
+ })
+ .collect()
+}
+
+fn settle_unhealthy_node<Node: Eq + Hash + Clone>(
+ ledger: &mut NodeLedger<Node>,
+ node: &Node,
+ all_complete: &mut bool,
+ all_healthy: &mut bool,
+) -> Option<DeviceInventory> {
+ *all_complete = false;
+ *all_healthy = false;
+ ledger.settle(node, false, None).inventory
+}
+
/// Enumerate all Logitech HID++ receivers visible to the current process and
/// the devices paired to each.
///
@@ -203,7 +308,122 @@ const ONESHOT_ATTEMPTS: u8 = 4;
/// asleep device, so a short pause lets the next attempt read it cleanly.
const ONESHOT_RETRY_DELAY: Duration = Duration::from_millis(300);
+/// Nodes that remain valid for this tick: everything the OS enumerated plus
+/// cached channels whose open transport still reports a live connection.
+fn retained_nodes<K>(
+ enumerated: &HashSet<K>,
+ cached_channels: impl IntoIterator<Item = (K, bool)>,
+) -> HashSet<K>
+where
+ K: Clone + Eq + Hash,
+{
+ let mut retained = enumerated.clone();
+ retained.extend(
+ cached_channels
+ .into_iter()
+ .filter_map(|(node, connected)| connected.then_some(node)),
+ );
+ retained
+}
+
+/// Add cached channels omitted by this OS enumeration while their open
+/// transport still reports a live connection.
+fn append_live_cached_channels(
+ nodes: &mut HashSet<async_hid::DeviceId>,
+ channels: &ChannelCache<async_hid::DeviceId, CachedChannel>,
+ active: &mut Vec<(async_hid::DeviceInfo, Arc<HidppChannel>)>,
+) {
+ let retained = retained_nodes(
+ nodes,
+ channels
+ .active
+ .iter()
+ .map(|(node, open)| (node.clone(), open.channel.is_connected())),
+ );
+ for node in retained.difference(nodes) {
+ if let Some(open) = channels.get(node) {
+ debug!(
+ ?node,
+ name = %open.info.name,
+ "OS enumeration omitted a live HID node; probing cached channel"
+ );
+ active.push((open.info.clone(), Arc::clone(&open.channel)));
+ }
+ }
+ *nodes = retained;
+}
+
impl Enumerator {
+ /// Build a persistent enumerator that publishes its already-open channels
+ /// into `registry` after each settled inventory tick.
+ #[must_use]
+ pub fn with_registry(registry: ChannelRegistry) -> Self {
+ Self {
+ registry: Some(registry),
+ ..Self::default()
+ }
+ }
+
+ async fn prepare_nodes(&mut self, candidates: Vec<async_hid::Device>) -> PreparedNodes {
+ let mut active = Vec::new();
+ let mut seen_nodes = HashSet::new();
+ let mut open_failures = Vec::new();
+ let mut retiring = Vec::new();
+ for dev in candidates {
+ let node = dev.id.clone();
+ seen_nodes.insert(node.clone());
+ if !self
+ .channels
+ .prepare_open(&node, |cached| Arc::strong_count(&cached.channel) == 1)
+ {
+ retiring.push(node);
+ continue;
+ }
+ if let Some(open) = self.channels.get(&node) {
+ active.push((open.info.clone(), Arc::clone(&open.channel)));
+ continue;
+ }
+ match open_hidpp_channel(dev).await {
+ Ok(Some((info, channel))) => {
+ self.channels.insert(
+ node,
+ CachedChannel {
+ info: info.clone(),
+ channel: Arc::clone(&channel),
+ },
+ );
+ active.push((info, channel));
+ }
+ Ok(None) => {}
+ Err(e) => {
+ warn!(error = ?e, "failed to open HID++ channel — retrying next tick");
+ open_failures.push(node);
+ }
+ }
+ }
+
+ // IOHIDManager can temporarily omit a Bluetooth device's vendor HID++
+ // collection while its already-open handle and ordinary mouse link are
+ // still live. Keep probing that cached channel instead of turning one
+ // incomplete OS snapshot into an offline device and stopping capture.
+ append_live_cached_channels(&mut seen_nodes, &self.channels, &mut active);
+
+ if let Some(registry) = &self.registry {
+ registry.retain_nodes(&seen_nodes);
+ }
+ self.channels.retire_absent(&seen_nodes);
+ self.channels.reap_absent(&seen_nodes, |cached| {
+ Arc::strong_count(&cached.channel) == 1
+ });
+ self.ledger.retain_nodes(&seen_nodes);
+
+ PreparedNodes {
+ active,
+ open_failures,
+ retiring,
+ }
+ }
+
/// One enumeration pass, reusing the cache from prior passes. Probes every
/// HID candidate concurrently (so one asleep node that burns the whole
/// `PROBE_BUDGET` can't stall the others), reusing each device's cached
@@ -236,48 +456,13 @@ impl Enumerator {
let candidates = enumerate_hidpp_devices().await?;
debug!(count = candidates.len(), "HID++ candidate interfaces");
- // Reuse an open channel per node, opening one only for a node seen for
- // the first time. Sequential because opening mutates the channel cache,
- // but in steady state every node is already cached so this is just
- // lookups — an actual open happens only when a new device appears.
- let mut active: Vec<(async_hid::DeviceInfo, Arc<HidppChannel>)> = Vec::new();
- let mut seen_nodes: HashSet<async_hid::DeviceId> = HashSet::new();
- let mut open_failures: Vec<async_hid::DeviceId> = Vec::new();
- for dev in candidates {
- let node = dev.id.clone();
- seen_nodes.insert(node.clone());
- if let Some(open) = self.channels.get(&node) {
- active.push((open.info.clone(), Arc::clone(&open.channel)));
- continue;
- }
- match open_hidpp_channel(dev).await {
- Ok(Some((info, channel))) => {
- self.channels.insert(
- node,
- CachedChannel {
- info: info.clone(),
- channel: Arc::clone(&channel),
- },
- );
- active.push((info, channel));
- }
- Ok(None) => {} // speaks HID but not HID++ — not one of ours
- // The node is listed but unreachable right now — settled as a
- // failed probe below, so its last inventory is replayed.
- Err(e) => {
- warn!(error = ?e, "failed to open HID++ channel — retrying next tick");
- open_failures.push(node);
- }
- }
- }
- // Drop channels for nodes that vanished this tick. A node missing from
- // the enumeration is a real disconnect (the IOHIDManager device set is
- // authoritative, unlike a HID++ probe timeout), so close the device and
- // join its read thread now instead of leaving a dead channel behind; a
- // reconnect re-opens under a fresh node id. The ledger forgets vanished
- // nodes for the same reason — a true disconnect must not be replayed.
- self.channels.retain(|node, _| seen_nodes.contains(node));
- self.ledger.retain_nodes(&seen_nodes);
+ // Reuse an open channel per node, opening only when no active or
+ // retiring connection owns that OS node.
+ let PreparedNodes {
+ active,
+ open_failures,
+ retiring: retiring_nodes,
+ } = self.prepare_nodes(candidates).await;
// Probe each open channel concurrently, sharing `&cache` read-only;
// updates are collected and applied afterwards (no `RefCell`).
@@ -287,8 +472,12 @@ impl Enumerator {
.into_iter()
.map(|(info, channel)| async move {
let node = info.id.clone();
- let probe = timeout(PROBE_BUDGET, probe_one(info, channel, cache, tick)).await;
- (node, probe)
+ let probe = timeout(
+ PROBE_BUDGET,
+ probe_one(info, Arc::clone(&channel), cache, tick),
+ )
+ .await;
+ (node, channel, probe)
})
.collect::<Vec<_>>()
.join()
@@ -303,7 +492,7 @@ impl Enumerator {
// governed by `probe.healthy`.
let mut all_complete = true;
let mut all_healthy = true;
- for (node, result) in results {
+ for (node, channel, result) in results {
let probe = if let Ok(probe) = result {
probe
} else {
@@ -318,18 +507,47 @@ impl Enumerator {
all_healthy &= probe.healthy;
outcomes.extend(probe.outcomes);
let settled = self.ledger.settle(&node, probe.healthy, probe.inventory);
- if settled.evict_channel && self.channels.remove(&node).is_some() {
- warn!("node probe keeps failing — dropping its channel to reopen next tick");
+ if settled.evict_channel {
+ if let Some(registry) = &self.registry {
+ registry.remove_node(&node);
+ }
+ if self.channels.retire_node(&node).is_some() {
+ warn!("node probe keeps failing — retiring its channel before reopen");
+ }
+ } else if let Some(registry) = &self.registry {
+ let routes = settled
+ .inventory
+ .as_ref()
+ .map_or_else(Vec::new, |inventory| {
+ routes_for_inventories(std::slice::from_ref(inventory))
+ });
+ if routes.is_empty() {
+ registry.remove_node(&node);
+ } else {
+ registry.replace_node(node.clone(), routes, channel);
+ }
}
inventories.extend(settled.inventory);
}
+ // A listed node whose old connection is still retiring is an unhealthy
+ // probe, not a disconnect: preserve the ledger's normal replay grace.
+ for node in retiring_nodes {
+ inventories.extend(settle_unhealthy_node(
+ &mut self.ledger,
+ &node,
+ &mut all_complete,
+ &mut all_healthy,
+ ));
+ }
// Nodes that wouldn't open this tick still replay their last snapshot
// (they have no cached channel to evict).
for node in open_failures {
- all_complete = false;
- all_healthy = false;
- let settled = self.ledger.settle(&node, false, None);
- inventories.extend(settled.inventory);
+ inventories.extend(settle_unhealthy_node(
+ &mut self.ledger,
+ &node,
+ &mut all_complete,
+ &mut all_healthy,
+ ));
}
// Apply fresh probes and record which devices were seen this tick.
diff --git a/crates/openlogi-hid/src/inventory/cache.rs b/crates/openlogi-hid/src/inventory/cache.rs
index a2c7c94bffbfdd4b363960904f0a95b89b3be7b2..e20c384f50484ec786d2d309dd8b05aea95d5495 100644
--- a/crates/openlogi-hid/src/inventory/cache.rs
+++ b/crates/openlogi-hid/src/inventory/cache.rs
@@ -1,8 +1,9 @@
use std::sync::Arc;
use hidpp::channel::HidppChannel;
+use openlogi_core::device::{BatteryInfo, BatteryStatus};
-use super::features::{ProbedFeatures, probe_features, read_battery};
+use super::features::{BatteryProbe, ProbedFeatures, probe_features, read_battery};
/// How many `enumerate` ticks a device's probe is reused before a fresh read.
/// The expensive part of a probe (the `enumerate_features` feature-table walk)
@@ -42,14 +43,51 @@ pub(super) const CACHE_MISS_GRACE: u8 = 3;
#[derive(Clone)]
pub(super) struct Cached {
pub(super) probe: ProbedFeatures,
- /// Runtime index of the `UnifiedBattery` feature in this device's feature
- /// table, captured by the full probe. Lets cache hits re-read the volatile
- /// battery in one round-trip — no `Device::new` ping, no table walk.
- /// `None` when the device exposes no `0x1004`.
- pub(super) battery_index: Option<u8>,
+ /// Which battery feature this device exposes and its runtime index, captured
+ /// by the full probe. Lets cache hits re-read the volatile battery in one
+ /// round-trip — no `Device::new` ping, no table walk. `None` when the device
+ /// exposes neither `0x1004` nor the legacy `0x1000`.
+ pub(super) battery: Option<BatteryProbe>,
pub(super) probed_tick: u64,
}
+/// The legacy `0x1000` battery feature (MX2S-era mice) reports `discharge_level
+/// = 0` while charging — the firmware can't gauge charge under load, so the GUI
+/// would show a misleading "Charging · 0%". Carry the last-known percentage
+/// forward for the charge so the reading stays trackable.
+///
+/// A *frozen* pre-charge value, not a live charging %, because no device exposes
+/// that on `0x1000`. Only kicks in for the charging-and-zero sentinel; a genuine
+/// 0% while discharging (status != Charging) is untouched. Cold edge: app
+/// started while already charging has no prior, so it shows 0% until the first
+/// discharge read.
+fn hold_percentage_while_charging(
+ fresh: BatteryInfo,
+ prev: Option<&BatteryInfo>,
+ probe: BatteryProbe,
+) -> BatteryInfo {
+ // Scoped to the legacy 0x1000 quirk: a 0x1004 device that legitimately
+ // reports 0% while charging must surface that, not a stale prior reading.
+ if !matches!(probe, BatteryProbe::Legacy(_)) {
+ return fresh;
+ }
+ let charging = matches!(
+ fresh.status,
+ BatteryStatus::Charging | BatteryStatus::ChargingSlow
+ );
+ if charging
+ && fresh.percentage == 0
+ && let Some(p) = prev.filter(|p| p.percentage > 0)
+ {
+ return BatteryInfo {
+ percentage: p.percentage,
+ level: p.level,
+ status: fresh.status,
+ };
+ }
+ fresh
+}
+
/// What a probed device contributes to the cache this tick. The key lets stale
/// entries be evicted; `Fresh` (a full probe) and `Update` (a cache hit whose
/// volatile battery was re-read) also carry the value to insert. `Unkeyed` is a
@@ -86,7 +124,14 @@ pub(super) async fn probe_or_reuse(
tick: u64,
) -> (ProbedFeatures, CacheOutcome) {
if online && cached.is_none_or(|c| is_stale(c, tick)) {
- let (mut fresh, battery_index) = probe_features(channel, index).await;
+ let (mut fresh, battery) = probe_features(channel, index).await;
+ if let (Some(reading), Some(probe)) = (fresh.battery.take(), battery) {
+ fresh.battery = Some(hold_percentage_while_charging(
+ reading,
+ cached.and_then(|c| c.probe.battery.as_ref()),
+ probe,
+ ));
+ }
// `capabilities` is `Some` exactly when the feature-table walk succeeded;
// only then is the probe worth caching.
if fresh.capabilities.is_some() {
@@ -104,7 +149,7 @@ pub(super) async fn probe_or_reuse(
Some(key) => {
let value = Cached {
probe: fresh.clone(),
- battery_index,
+ battery,
probed_tick: tick,
};
(fresh, CacheOutcome::Fresh(key, value))
@@ -127,10 +172,12 @@ pub(super) async fn probe_or_reuse(
// index and fold the reading back into the cache. A failed read
// (asleep, mid-host-switch) keeps the last-known value.
if online
- && let Some(feature_index) = c.battery_index
+ && let Some(probe) = c.battery
&& let Some(key) = id.clone()
- && let Some(battery) = read_battery(channel, index, feature_index).await
+ && let Some(battery) = read_battery(channel, index, probe).await
{
+ let battery =
+ hold_percentage_while_charging(battery, c.probe.battery.as_ref(), probe);
let mut entry = c.clone();
entry.probe.battery = Some(battery);
return (entry.probe.clone(), CacheOutcome::Update(key, entry));
@@ -169,3 +216,58 @@ pub(super) fn backfill_identity(fresh: &mut ProbedFeatures, cached: &ProbedFeatu
_ => {}
}
}
+
+#[cfg(test)]
+mod hold_tests {
+ use openlogi_core::device::{BatteryInfo, BatteryLevel, BatteryStatus};
+
+ use super::{BatteryProbe, hold_percentage_while_charging};
+
+ fn battery(percentage: u8, status: BatteryStatus) -> BatteryInfo {
+ BatteryInfo {
+ percentage,
+ level: BatteryLevel::Good,
+ status,
+ }
+ }
+
+ #[test]
+ fn charging_zero_holds_last_known_percentage() {
+ let legacy = BatteryProbe::Legacy(0);
+ let held = hold_percentage_while_charging(
+ battery(0, BatteryStatus::Charging),
+ Some(&battery(85, BatteryStatus::Discharging)),
+ legacy,
+ );
+ assert_eq!(held.percentage, 85);
+ assert_eq!(held.status, BatteryStatus::Charging);
+
+ let discharging = hold_percentage_while_charging(
+ battery(0, BatteryStatus::Discharging),
+ Some(&battery(85, BatteryStatus::Discharging)),
+ legacy,
+ );
+ assert_eq!(discharging.percentage, 0);
+
+ let live = hold_percentage_while_charging(
+ battery(40, BatteryStatus::Charging),
+ Some(&battery(85, BatteryStatus::Discharging)),
+ legacy,
+ );
+ assert_eq!(live.percentage, 40);
+
+ let cold =
+ hold_percentage_while_charging(battery(0, BatteryStatus::Charging), None, legacy);
+ assert_eq!(cold.percentage, 0);
+ }
+
+ #[test]
+ fn unified_charging_zero_is_not_held() {
+ let live = hold_percentage_while_charging(
+ battery(0, BatteryStatus::Charging),
+ Some(&battery(85, BatteryStatus::Discharging)),
+ BatteryProbe::Unified(0),
+ );
+ assert_eq!(live.percentage, 0);
+ }
+}
diff --git a/crates/openlogi-hid/src/inventory/features.rs b/crates/openlogi-hid/src/inventory/features.rs
index 3ee239c092a901f38f1c9dd14699c3208b0a2f06..593a504a8485f93069867f9c2fece33cc1ca8dea 100644
--- a/crates/openlogi-hid/src/inventory/features.rs
+++ b/crates/openlogi-hid/src/inventory/features.rs
@@ -5,7 +5,8 @@ use hidpp::{
device::Device,
feature::hires_wheel::HiResWheelFeature,
feature::{
- CreatableFeature, device_information::DeviceInformationFeature,
+ CreatableFeature, battery_status::BatteryStatusFeature,
+ device_information::DeviceInformationFeature,
device_type_and_name::DeviceTypeAndNameFeature, gestures2::Gestures2Feature,
unified_battery::UnifiedBatteryFeature,
},
@@ -16,7 +17,8 @@ use openlogi_core::device::{
use tracing::debug;
use crate::mappings::{
- map_battery_level, map_battery_status, map_device_type, normalize_serial_number,
+ legacy_battery_level_from_percentage, map_battery_level, map_battery_status, map_device_type,
+ map_legacy_battery_status, normalize_serial_number,
};
/// Everything a single device probe yields. Any field is `None` when the
@@ -37,37 +39,75 @@ pub(super) struct ProbedFeatures {
pub(super) identity_incomplete: bool,
}
-/// Read just the battery by addressing the `UnifiedBattery` feature at its
-/// known runtime `feature_index` — one round-trip, with no `Device::new` ping
-/// and no feature-table walk. This is both the full probe's battery read (the
-/// walk just produced the index) and the cheap per-tick refresh for cache hits.
-/// `None` when the device doesn't answer (asleep, switched hosts).
+/// Which battery feature a device exposes plus its runtime feature index. Newer
+/// devices answer the unified `0x1004`; MX2S-era ones only the legacy `0x1000`
+/// — the same enhanced-then-legacy split SmartShift has with `0x2111`/`0x2110`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(super) enum BatteryProbe {
+ Unified(u8),
+ Legacy(u8),
+}
+
+/// Read just the battery by addressing its feature at the known runtime index —
+/// one round-trip, with no `Device::new` ping and no feature-table walk. This is
+/// both the full probe's battery read (the walk just produced the index) and the
+/// cheap per-tick refresh for cache hits. `None` when the device doesn't answer
+/// (asleep, switched hosts).
pub(super) async fn read_battery(
channel: &Arc<HidppChannel>,
slot: u8,
- feature_index: u8,
+ probe: BatteryProbe,
) -> Option<BatteryInfo> {
- let feature = UnifiedBatteryFeature::new(Arc::clone(channel), slot, feature_index);
- feature
- .get_battery_info()
- .await
- .ok()
- .map(|info| BatteryInfo {
- percentage: info.charging_percentage,
- level: map_battery_level(info.level),
- status: map_battery_status(info.status),
- })
+ match probe {
+ BatteryProbe::Unified(feature_index) => {
+ let feature = UnifiedBatteryFeature::new(Arc::clone(channel), slot, feature_index);
+ feature
+ .get_battery_info()
+ .await
+ .ok()
+ .map(|info| BatteryInfo {
+ percentage: info.charging_percentage,
+ level: map_battery_level(info.level),
+ status: map_battery_status(info.status),
+ })
+ }
+ BatteryProbe::Legacy(feature_index) => {
+ let feature = BatteryStatusFeature::new(Arc::clone(channel), slot, feature_index);
+ feature
+ .get_battery_level_status()
+ .await
+ .ok()
+ .map(|info| BatteryInfo {
+ percentage: info.discharge_level,
+ level: legacy_battery_level_from_percentage(info.discharge_level),
+ status: map_legacy_battery_status(info.status),
+ })
+ }
+ }
}
-/// Runtime index of the `UnifiedBattery` feature in an enumerated feature-ID
-/// table, for [`read_battery`]. The table is 1-based (index 0 is the implicit
-/// root feature, which enumeration omits).
-pub(super) fn battery_feature_index(ids: impl IntoIterator<Item = u16>) -> Option<u8> {
- ids.into_iter()
- .position(|id| id == UnifiedBatteryFeature::ID)
- // A feature table holds at most `u8::MAX` entries (its count is a u8),
- // so the 1-based index always fits.
- .and_then(|pos| u8::try_from(pos + 1).ok())
+/// Locate a device's battery feature in an enumerated feature-ID table,
+/// preferring the unified `0x1004` and falling back to the legacy `0x1000`. The
+/// table is 1-based (index 0 is the implicit root feature, which enumeration
+/// omits).
+pub(super) fn battery_feature_index(ids: impl IntoIterator<Item = u16>) -> Option<BatteryProbe> {
+ // A feature table holds at most `u8::MAX` entries (its count is a u8), so a
+ // 1-based index always fits.
+ let mut legacy = None;
+ for (pos, id) in ids.into_iter().enumerate() {
+ // Stop gracefully past u8::MAX instead of `?`-returning None, which would
+ // discard a `legacy` already found. (The table caps at 255, so unreachable.)
+ let Ok(index) = u8::try_from(pos + 1) else {
+ break;
+ };
+ if id == UnifiedBatteryFeature::ID {
+ return Some(BatteryProbe::Unified(index));
+ }
+ if id == BatteryStatusFeature::ID && legacy.is_none() {
+ legacy = Some(BatteryProbe::Legacy(index));
+ }
+ }
+ legacy
}
/// Read the marketing identity from HID++ `0x0005` when the device exposes it.
@@ -104,14 +144,14 @@ async fn read_marketing_identity(
/// `enumerate_features` — the feature table is the Vec that enumeration already
/// returns, so capabilities cost no extra round-trip.
///
-/// Also returns the `UnifiedBattery` runtime index found by the walk, so later
-/// ticks can refresh the battery without repeating it.
+/// Also returns the battery feature found by the walk, so later ticks can
+/// refresh the battery without repeating it.
///
/// Only online, responsive devices reach here.
pub(super) async fn probe_features(
channel: &Arc<HidppChannel>,
slot: u8,
-) -> (ProbedFeatures, Option<u8>) {
+) -> (ProbedFeatures, Option<BatteryProbe>) {
let mut device = match Device::new(Arc::clone(channel), slot).await {
Ok(d) => d,
Err(e) => {
@@ -121,11 +161,11 @@ pub(super) async fn probe_features(
};
// The enumeration response IS the device's feature-ID table — capture it
// for capability derivation instead of discarding it.
- let mut battery_index = None;
+ let mut battery_probe = None;
let mut capabilities = match device.enumerate_features().await {
Ok(Some(features)) => {
let ids: Vec<u16> = features.iter().map(|f| f.id).collect();
- battery_index = battery_feature_index(ids.iter().copied());
+ battery_probe = battery_feature_index(ids.iter().copied());
Some(Capabilities::from_feature_ids(&ids))
}
Ok(None) => None,
@@ -152,8 +192,8 @@ pub(super) async fn probe_features(
}
}
- let battery = match battery_index {
- Some(feature_index) => read_battery(channel, slot, feature_index).await,
+ let battery = match battery_probe {
+ Some(probe) => read_battery(channel, slot, probe).await,
None => None,
};
@@ -210,29 +250,44 @@ pub(super) async fn probe_features(
capabilities,
identity_incomplete,
},
- battery_index,
+ battery_probe,
)
}
#[cfg(test)]
mod tests {
- use hidpp::feature::{CreatableFeature as _, unified_battery::UnifiedBatteryFeature};
+ use hidpp::feature::{
+ CreatableFeature as _, battery_status::BatteryStatusFeature,
+ unified_battery::UnifiedBatteryFeature,
+ };
- use super::battery_feature_index;
+ use super::{BatteryProbe, battery_feature_index};
#[test]
fn battery_index_is_one_based_in_the_enumerated_table() {
// `enumerate_features` omits the root feature (index 0), so the first
// enumerated entry sits at runtime index 1.
let table = [0x0001, UnifiedBatteryFeature::ID, 0x2201];
- assert_eq!(battery_feature_index(table), Some(2));
+ assert_eq!(battery_feature_index(table), Some(BatteryProbe::Unified(2)));
assert_eq!(
battery_feature_index([UnifiedBatteryFeature::ID]),
- Some(1),
+ Some(BatteryProbe::Unified(1)),
"first entry maps to index 1, not 0"
);
}
+ #[test]
+ fn legacy_battery_is_found_when_unified_is_absent() {
+ let table = [0x0001, BatteryStatusFeature::ID, 0x2201];
+ assert_eq!(battery_feature_index(table), Some(BatteryProbe::Legacy(2)));
+ }
+
+ #[test]
+ fn unified_battery_is_preferred_over_legacy() {
+ let table = [BatteryStatusFeature::ID, 0x0001, UnifiedBatteryFeature::ID];
+ assert_eq!(battery_feature_index(table), Some(BatteryProbe::Unified(3)));
+ }
+
#[test]
fn no_battery_feature_means_no_index() {
assert_eq!(battery_feature_index([0x0001, 0x2201, 0x1b04]), None);
diff --git a/crates/openlogi-hid/src/inventory/probe.rs b/crates/openlogi-hid/src/inventory/probe.rs
index 6fcb4f93e7fbcd9d6306fe4ff20889203ea8412f..6ae7628972e62b838a397400c09e3e91633d81a1 100644
--- a/crates/openlogi-hid/src/inventory/probe.rs
+++ b/crates/openlogi-hid/src/inventory/probe.rs
@@ -3,6 +3,7 @@ use std::{collections::HashMap, sync::Arc};
use futures_concurrency::future::Join as _;
use hidpp::{
channel::HidppChannel,
+ device::Device,
receiver::{
self, Receiver,
bolt::{
@@ -586,39 +587,69 @@ async fn probe_unifying_slot(
// online or not, and the crate's `event.online` reads the wrong notification
// byte (payload[1] bit6, always set here — wire-verified `04 62 69 40`), so
// neither tells us if the device is actually reachable on this receiver.
- // We therefore always attempt the probe (passing `true`) and treat the
- // feature walk succeeding as the real liveness signal below — a device that
- // moved to Bluetooth answers `DeviceNotFound` and surfaces as offline.
+ // A cache hit must therefore still do one live round-trip: otherwise cached
+ // capabilities keep an absent device "online" forever and its reconnect is
+ // invisible to the agent's volatile-state re-apply/capture re-arm path.
let probe_result = timeout(
UNIFYING_SLOT_PROBE,
- probe_or_reuse(channel, slot, Some(id.clone()), cached, true, tick),
+ probe_unifying_features(channel, slot, &id, cached, tick),
)
.await;
- let (probe, outcome) = if let Ok(r) = probe_result {
+ let (probe, outcome, online) = if let Ok(r) = probe_result {
r
} else {
debug!(slot, budget = ?UNIFYING_SLOT_PROBE,
"Unifying slot probe timed out; using cached data if available");
let probe = cached.map_or_else(ProbedFeatures::default, |c| c.probe.clone());
- (probe, CacheOutcome::Seen(id))
+ (probe, CacheOutcome::Seen(id), false)
};
- let device = PairedDevice {
+ let device = assemble_unifying_device(slot, codename, event.wpid, register_kind, probe, online);
+ Some((device, outcome))
+}
+
+/// Return cached immutable features together with a fresh reachability result.
+///
+/// A successful full probe ([`CacheOutcome::Fresh`]) confirms liveness on a
+/// cache miss/stale entry. A fresh cached entry normally refreshes its battery,
+/// whose successful response ([`CacheOutcome::Update`]) is the liveness check.
+/// A failed battery refresh, or a device without that feature, gets a root ping
+/// before being treated as offline.
+pub(super) async fn probe_unifying_features(
+ channel: &Arc<HidppChannel>,
+ slot: u8,
+ id: &CacheKey,
+ cached: Option<&Cached>,
+ tick: u64,
+) -> (ProbedFeatures, CacheOutcome, bool) {
+ let (probe, outcome) =
+ probe_or_reuse(channel, slot, Some(id.clone()), cached, true, tick).await;
+ let online = if matches!(outcome, CacheOutcome::Fresh(..) | CacheOutcome::Update(..)) {
+ true
+ } else {
+ Device::new(Arc::clone(channel), slot).await.is_ok()
+ };
+ (probe, outcome, online)
+}
+
+pub(super) fn assemble_unifying_device(
+ slot: u8,
+ codename: Option<String>,
+ wpid: u16,
+ register_kind: DeviceKind,
+ probe: ProbedFeatures,
+ online: bool,
+) -> PairedDevice {
+ PairedDevice {
slot,
codename,
- wpid: Some(event.wpid),
+ wpid: Some(wpid),
kind: resolve_device_kind(probe.kind, register_kind),
- // Reachable on this receiver iff the feature walk got through this tick.
- // Caveat: a GUI cache hit can serve stale capabilities for up to
- // REFRESH_TICKS after the device leaves for Bluetooth, briefly showing it
- // online; self-heals on the next forced re-probe. Add a per-tick liveness
- // ping if that window ever matters.
- online: probe.capabilities.is_some(),
+ online,
battery: probe.battery,
model_info: probe.model_info,
capabilities: probe.capabilities,
- };
- Some((device, outcome))
+ }
}
/// Reads a Unifying paired device's name. Unifying stores names at
diff --git a/crates/openlogi-hid/src/inventory/tests.rs b/crates/openlogi-hid/src/inventory/tests.rs
index 73968325b8c3357856bf8b26e064ea05cff1cf19..600a2a1762e84f958caddc146f19fd6d86153207 100644
--- a/crates/openlogi-hid/src/inventory/tests.rs
+++ b/crates/openlogi-hid/src/inventory/tests.rs
@@ -1,4 +1,5 @@
use std::collections::HashSet;
+use std::sync::Arc;
use openlogi_core::device::{
DeviceInventory, DeviceKind, DeviceModelInfo, DeviceTransports, PairedDevice, ReceiverInfo,
@@ -10,13 +11,17 @@ use super::cache::{
use super::probe::{
NodeProbe, assemble_bolt_probe, parse_codename_unifying, preferred_direct_codename,
};
-use super::{Enumerator, ONESHOT_ATTEMPTS, one_shot_should_stop};
+use super::{
+ ChannelCache, Enumerator, ONESHOT_ATTEMPTS, one_shot_should_stop, retained_nodes,
+ routes_for_inventories, settle_unhealthy_node,
+};
use crate::inventory::features::ProbedFeatures;
+use crate::{DIRECT_DEVICE_INDEX, DeviceRoute};
fn cache_entry(probed_tick: u64) -> Cached {
Cached {
probe: ProbedFeatures::default(),
- battery_index: None,
+ battery: None,
probed_tick,
}
}
@@ -76,7 +81,7 @@ fn being_seen_resets_the_miss_counter() {
fn cached_probe_is_reused_until_refresh_ticks() {
let cached = Cached {
probe: ProbedFeatures::default(),
- battery_index: None,
+ battery: None,
probed_tick: 10,
};
assert!(!is_stale(&cached, 10), "same tick is fresh");
@@ -115,6 +120,138 @@ fn inventory(slots: &[u8]) -> Vec<DeviceInventory> {
}]
}
+#[test]
+fn settled_inventories_publish_exact_receiver_routes() {
+ assert_eq!(
+ routes_for_inventories(&inventory(&[1, 4])),
+ vec![
+ DeviceRoute::Unifying {
+ receiver_uid: "receiver-1".into(),
+ slot: 1,
+ },
+ DeviceRoute::Unifying {
+ receiver_uid: "receiver-1".into(),
+ slot: 4,
+ },
+ ]
+ );
+
+ assert_eq!(
+ routes_for_inventories(&inventory(&[4])),
+ vec![DeviceRoute::Unifying {
+ receiver_uid: "receiver-1".into(),
+ slot: 4,
+ }],
+ "a vanished slot must not survive the next atomic node replacement"
+ );
+}
+
+#[test]
+fn settled_direct_inventory_publishes_one_direct_route() {
+ let direct = vec![DeviceInventory {
+ receiver: ReceiverInfo {
+ name: "MX Keys".into(),
+ vendor_id: 0x046d,
+ product_id: 0xb35b,
+ unique_id: None,
+ },
+ paired: vec![PairedDevice {
+ slot: DIRECT_DEVICE_INDEX,
+ codename: Some("MX Keys".into()),
+ wpid: Some(0xb35b),
+ kind: DeviceKind::Keyboard,
+ online: true,
+ battery: None,
+ model_info: None,
+ capabilities: None,
+ }],
+ }];
+
+ assert_eq!(
+ routes_for_inventories(&direct),
+ vec![DeviceRoute::Direct {
+ vendor_id: 0x046d,
+ product_id: 0xb35b,
+ }]
+ );
+}
+
+#[test]
+fn channel_cache_retires_and_defers_reopen_until_a_later_tick() {
+ let mut cache = ChannelCache::<u8, Arc<()>>::default();
+ let channel = Arc::new(());
+ cache.insert(1, Arc::clone(&channel));
+
+ assert!(cache.retire_node(&1).is_some());
+ assert!(cache.get(&1).is_none());
+ assert!(!cache.prepare_open(&1, |channel| Arc::strong_count(channel) == 1));
+
+ drop(channel);
+ assert!(cache.is_retiring(&1));
+ assert!(
+ !cache.prepare_open(&1, |channel| Arc::strong_count(channel) == 1),
+ "the tick that drops retirement still skips opening"
+ );
+ assert!(!cache.is_retiring(&1));
+ assert!(
+ cache.prepare_open(&1, |channel| Arc::strong_count(channel) == 1),
+ "only a later tick may reopen"
+ );
+}
+
+#[test]
+fn absent_channels_retire_and_quiescent_absent_retirement_is_reaped() {
+ let mut cache = ChannelCache::<u8, Arc<()>>::default();
+ cache.insert(1, Arc::new(()));
+ cache.insert(2, Arc::new(()));
+
+ cache.retire_absent(&HashSet::from([2]));
+ assert!(cache.is_retiring(&1));
+ assert!(cache.get(&2).is_some());
+
+ cache.reap_absent(&HashSet::from([2]), |channel| {
+ Arc::strong_count(channel) == 1
+ });
+ assert!(!cache.is_retiring(&1));
+}
+
+#[test]
+fn retiring_node_replays_ledger_and_marks_tick_unhealthy() {
+ let mut ledger = crate::node_ledger::NodeLedger::<u8>::default();
+ let expected = inventory(&[1]);
+ let settled = ledger.settle(&1, true, Some(expected[0].clone()));
+ assert_eq!(settled.inventory, Some(expected[0].clone()));
+
+ let mut complete = true;
+ let mut healthy = true;
+ let replay = settle_unhealthy_node(&mut ledger, &1, &mut complete, &mut healthy);
+
+ assert_eq!(replay, Some(expected[0].clone()));
+ assert!(!complete);
+ assert!(!healthy);
+}
+
+#[test]
+fn retiring_node_inventory_expires_after_the_existing_ledger_grace() {
+ let mut ledger = crate::node_ledger::NodeLedger::<u8>::default();
+ let expected = inventory(&[1]);
+ ledger.settle(&1, true, Some(expected[0].clone()));
+
+ let mut complete = true;
+ let mut healthy = true;
+ for _ in 0..3 {
+ assert_eq!(
+ settle_unhealthy_node(&mut ledger, &1, &mut complete, &mut healthy),
+ Some(expected[0].clone())
+ );
+ }
+ assert_eq!(
+ settle_unhealthy_node(&mut ledger, &1, &mut complete, &mut healthy),
+ None,
+ "retirement must not extend stale inventory beyond ledger policy"
+ );
+}
+
#[test]
fn one_shot_retry_stops_when_first_attempt_is_complete() {
let current = inventory(&[1, 2]);
@@ -384,3 +521,14 @@ fn codename_clamps_overlong_len() {
fn codename_rejects_short_response() {
assert_eq!(parse_codename_unifying(&[0x40]), None);
}
+
+#[test]
+fn live_cached_channel_survives_a_transient_enumeration_gap() {
+ let enumerated = std::collections::HashSet::from([1_u8]);
+ let cached_channels = [(1_u8, true), (2_u8, true), (3_u8, false)];
+ let retained = retained_nodes(&enumerated, cached_channels);
+ assert!(retained.contains(&1));
+ assert!(retained.contains(&2));
+ assert!(!retained.contains(&3));
+ assert_eq!(retained, std::collections::HashSet::from([1, 2]));
+}
diff --git a/crates/openlogi-hid/src/keyboard.rs b/crates/openlogi-hid/src/keyboard.rs
new file mode 100644
index 0000000000000000000000000000000000000000..805f269ace32d84eafa8b34f6779bb8bbeaf9ecc
--- /dev/null
+++ b/crates/openlogi-hid/src/keyboard.rs
@@ -0,0 +1,260 @@
+//! Live key capture for one keyboard: divert the bound F-row controls over
+//! HID++ `0x1b04` and turn their presses into [`CapturedInput`] the agent can
+//! dispatch.
+//!
+//! [`run_keyboard_capture_session`] is the keyboard counterpart of
+//! [`crate::gesture::run_capture_session`]: one open channel, diversion armed
+//! on exactly the controls the caller asks for (an unbound key is never
+//! diverted, so it keeps its native firmware function), one message listener,
+//! and every diverted control handed back to the firmware on shutdown.
+//!
+//! Diversion works on the key's *control* — the printed media/shortcut
+//! function — so it fires when Fn-lock is off (or via Fn+key when it is on).
+//! The plain F1–F12 codes of an Fn-locked row travel the ordinary HID keyboard
+//! interface and never reach `0x1b04`.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::sync::{Arc, Mutex, PoisonError};
+
+use hidpp::{
+ device::Device,
+ feature::{
+ CreatableFeature, EmittingFeature,
+ wireless_device_status::{WirelessDeviceStatusEvent, WirelessDeviceStatusFeature},
+ },
+ protocol::v20,
+};
+use openlogi_core::binding::ButtonId;
+use tokio::sync::{mpsc, oneshot};
+use tracing::{debug, info, warn};
+
+use crate::channel_registry::ChannelRegistry;
+use crate::gesture::{CaptureChannel, CapturedInput, GestureError, enumerate_controls, restore};
+use crate::reprog_controls::{self, RawControlEvent, ReprogControlsV4};
+use crate::route::{DeviceRoute, open_route_channel};
+use crate::write::SharedChannel;
+
+/// The divertable keyboard F-row controls OpenLogi models, as
+/// `(0x1b04 control ID, ButtonId)` pairs. CID values match Logitech's control
+/// catalog (cross-checked against Solaar's `special_keys.py`); the F-row
+/// positions are the Signature-series layout.
+pub const KEYBOARD_KEY_CIDS: [(u16, ButtonId); 9] = [
+ (0x00d4, ButtonId::KeySearch),
+ (0x0103, ButtonId::KeyDictation),
+ (0x0108, ButtonId::KeyEmoji),
+ (0x010a, ButtonId::KeyScreenCapture),
+ (0x011c, ButtonId::KeyMicMute),
+ (0x00e5, ButtonId::KeyPlayPause),
+ (0x00e7, ButtonId::KeyMute),
+ (0x00e8, ButtonId::KeyVolumeDown),
+ (0x00e9, ButtonId::KeyVolumeUp),
+];
+
+/// Capture the requested keyboard controls on `route` until `shutdown`
+/// resolves, forwarding a [`CapturedInput::ButtonPressed`] on each press
+/// (rising edge) to `sink`.
+///
+/// `wanted` maps `0x1b04` control IDs to the [`ButtonId`] they dispatch as —
+/// the caller passes only the keys that carry a real binding. Controls the
+/// device doesn't expose (or can't divert) are skipped with a debug log, so a
+/// partially-supported keyboard degrades per key rather than failing whole.
+pub async fn run_keyboard_capture_session(
+ route: DeviceRoute,
+ wanted: BTreeMap<u16, ButtonId>,
+ sink: mpsc::UnboundedSender<CapturedInput>,
+ shutdown: oneshot::Receiver<()>,
+ channel_slot: CaptureChannel,
+) -> Result<(), GestureError> {
+ let chan = open_route_channel(&route)
+ .await?
+ .ok_or(GestureError::DeviceNotFound)?;
+ let shared = SharedChannel::new(chan, route.clone());
+ run_keyboard_capture_session_on(route, shared, wanted, sink, shutdown, channel_slot).await
+}
+
+/// Run keyboard capture on the exact channel currently published by `registry`.
+///
+/// A registry miss returns [`GestureError::DeviceNotFound`] without falling
+/// back to route enumeration/opening; the agent watcher retries after a later
+/// inventory publication.
+pub async fn run_keyboard_capture_session_with_registry(
+ route: DeviceRoute,
+ wanted: BTreeMap<u16, ButtonId>,
+ sink: mpsc::UnboundedSender<CapturedInput>,
+ shutdown: oneshot::Receiver<()>,
+ channel_slot: CaptureChannel,
+ registry: &ChannelRegistry,
+) -> Result<(), GestureError> {
+ let shared = registry
+ .lookup(&route)
+ .ok_or(GestureError::DeviceNotFound)?;
+ run_keyboard_capture_session_on(route, shared, wanted, sink, shutdown, channel_slot).await
+}
+
+async fn run_keyboard_capture_session_on(
+ route: DeviceRoute,
+ shared: SharedChannel,
+ wanted: BTreeMap<u16, ButtonId>,
+ sink: mpsc::UnboundedSender<CapturedInput>,
+ shutdown: oneshot::Receiver<()>,
+ channel_slot: CaptureChannel,
+) -> Result<(), GestureError> {
+ let chan = Arc::clone(shared.channel());
+ let device_index = route.device_index();
+ let device = Device::new(Arc::clone(&chan), device_index)
+ .await
+ .map_err(|_| GestureError::DeviceUnreachable(device_index))?;
+
+ let info = device
+ .root()
+ .get_feature(reprog_controls::FEATURE_ID)
+ .await
+ .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
+ .ok_or_else(|| GestureError::Hidpp("keyboard exposes no 0x1b04 reprog controls".into()))?;
+ let rc = ReprogControlsV4::new(Arc::clone(&chan), device_index, info.index);
+ let controls = enumerate_controls(&rc).await?;
+
+ let diverted = arm_keys(&rc, &controls, &wanted).await?;
+
+ // Rising-edge press state per CID. Behind a `Mutex` because the channel's
+ // read thread invokes the listener by shared reference.
+ let held: Arc<Mutex<BTreeSet<u16>>> = Arc::new(Mutex::new(BTreeSet::new()));
+ let feature_index = info.index;
+ let listener = chan.add_msg_listener_guarded({
+ let held = Arc::clone(&held);
+ let diverted = diverted.clone();
+ let sink = sink.clone();
+ move |raw, matched| {
+ if matched {
+ return;
+ }
+ let msg = v20::Message::from(raw);
+ let Some(RawControlEvent::DivertedButtons(cids)) =
+ reprog_controls::decode_event(&msg, device_index, feature_index)
+ else {
+ return;
+ };
+ // Recover the guard even if a prior holder panicked — the critical
+ // section is panic-free, so the data is consistent.
+ let mut down = held.lock().unwrap_or_else(PoisonError::into_inner);
+ for (&cid, &button) in &diverted {
+ let now = cids.contains(&cid);
+ let was = down.contains(&cid);
+ if now && !was {
+ let _ = sink.send(CapturedInput::ButtonPressed(button, None));
+ }
+ if now {
+ down.insert(cid);
+ } else {
+ down.remove(&cid);
+ }
+ }
+ }
+ });
+
+ // Wireless keyboards drop their diverted-control state when they
+ // power-cycle (idle sleep, power switch, Easy-Switch host change) — the
+ // reconnection broadcast on `0x1d4b` is the firmware asking the host to
+ // reconfigure. Re-arm the diversion on every broadcast, or the bound keys
+ // silently revert to their native functions after the first nap.
+ let wireless = device
+ .root()
+ .get_feature(WirelessDeviceStatusFeature::ID)
+ .await
+ .ok()
+ .flatten()
+ .map(|info| WirelessDeviceStatusFeature::new(Arc::clone(&chan), device_index, info.index));
+ let wake_events = wireless.as_ref().map(EmittingFeature::listen);
+
+ // Publish this keyboard's open channel so hardware writes (Fn-lock)
+ // reuse it instead of opening the same HID node a second time. Cleared
+ // on the way out.
+ if let Ok(mut slot) = channel_slot.write() {
+ *slot = Some(shared);
+ }
+
+ info!(
+ index = device_index,
+ keys = diverted.len(),
+ wake_rearm = wake_events.is_some(),
+ "keyboard key capture active"
+ );
+ let mut shutdown = shutdown;
+ match wake_events {
+ None => {
+ let _ = shutdown.await;
+ }
+ Some(wake_events) => loop {
+ tokio::select! {
+ _ = &mut shutdown => break,
+ event = wake_events.recv() => {
+ let Ok(WirelessDeviceStatusEvent::StatusBroadcast(broadcast)) = event else {
+ // Emitter gone (feature dropped) — nothing left to
+ // watch; fall back to a plain shutdown wait.
+ let _ = shutdown.await;
+ break;
+ };
+ info!(?broadcast, "keyboard reconnected — re-arming key diversion");
+ rearm_keys(&rc, &diverted).await;
+ }
+ }
+ },
+ }
+
+ drop(listener);
+ if let Ok(mut slot) = channel_slot.write() {
+ *slot = None;
+ }
+ for &cid in diverted.keys() {
+ restore(
+ rc.set_cid_reporting(cid, false, false).await,
+ "keyboard key",
+ );
+ }
+ debug!(index = device_index, "keyboard key capture stopped");
+ Ok(())
+}
+
+/// Divert every wanted control the keyboard exposes as divertable, returning
+/// the armed `CID → ButtonId` subset. Missing / non-divertable controls are
+/// skipped with a debug log, so a partially-supported keyboard degrades per
+/// key rather than failing whole.
+async fn arm_keys(
+ rc: &ReprogControlsV4,
+ controls: &[reprog_controls::CtrlIdInfo],
+ wanted: &BTreeMap<u16, ButtonId>,
+) -> Result<BTreeMap<u16, ButtonId>, GestureError> {
+ let mut diverted = BTreeMap::new();
+ for (&cid, &button) in wanted {
+ if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
+ rc.set_cid_reporting(cid, true, false)
+ .await
+ .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
+ diverted.insert(cid, button);
+ } else {
+ debug!(
+ cid = format_args!("{cid:#06x}"),
+ "bound key not divertable on this keyboard — left native"
+ );
+ }
+ }
+ Ok(diverted)
+}
+
+/// Re-issue diversion for every armed control after a device power-cycle.
+/// Failures are logged, not propagated — the next reconnection broadcast
+/// retries.
+async fn rearm_keys(rc: &ReprogControlsV4, diverted: &BTreeMap<u16, ButtonId>) {
+ // A settling pause: the broadcast arrives the instant the link is back,
+ // occasionally before the device accepts feature writes again.
+ tokio::time::sleep(std::time::Duration::from_millis(200)).await;
+ for &cid in diverted.keys() {
+ if let Err(e) = rc.set_cid_reporting(cid, true, false).await {
+ warn!(
+ cid = format_args!("{cid:#06x}"),
+ error = ?e,
+ "re-divert after wake failed — key stays native until next wake"
+ );
+ }
+ }
+}
diff --git a/crates/openlogi-hid/src/lib.rs b/crates/openlogi-hid/src/lib.rs
index b6cf154757cbc3c5c26b5cc5f340cf3fbfb95d4a..e847e55d5b4186cd56925983ac0662d0eea005f9 100644
--- a/crates/openlogi-hid/src/lib.rs
+++ b/crates/openlogi-hid/src/lib.rs
@@ -10,43 +10,65 @@
#![deny(rustdoc::bare_urls)]
#![deny(rustdoc::broken_intra_doc_links)]
+mod channel_pool;
+mod channel_registry;
+pub mod host_switch;
mod mappings;
mod node_ledger;
mod route;
+mod standalone;
mod transport;
// Native Win32 HID report-write fallback, used by the Windows composite channel
// in `transport` when async-hid's async write path fails.
#[cfg(target_os = "windows")]
mod windows_hid;
+pub mod backlight;
pub mod gesture;
mod hires_wheel;
pub mod hotplug;
pub mod inventory;
+pub mod keyboard;
pub mod pairing;
pub mod reprog_controls;
pub mod smartshift;
pub mod thumbwheel;
pub mod write;
-pub use gesture::{CaptureChannel, CapturedInput, GestureError, run_capture_session};
+pub use backlight::{BacklightMode, BacklightState, BacklightStatus};
+pub use channel_pool::ChannelPool;
+pub use channel_registry::ChannelRegistry;
+pub use gesture::{
+ CaptureChannel, CaptureStop, CapturedInput, GestureError, run_capture_session,
+ run_capture_session_with_registry, run_capture_session_with_stop_reason,
+};
pub use hires_wheel::{
ScrollReportingTarget, ScrollResolution, ScrollWheelMode, get_scroll_wheel_mode,
get_scroll_wheel_mode_on, set_scroll_inversion, set_scroll_inversion_on, set_scroll_resolution,
set_scroll_resolution_on, set_scroll_wheel_mode, set_scroll_wheel_mode_on,
};
+pub use host_switch::{
+ HostSwitchError, HostSwitchStopReason, run_host_switch_session, switch_linked_hosts,
+};
pub use hotplug::{HotplugEvent, watch_hotplug};
pub use inventory::{Enumerator, InventoryError, enumerate};
+pub use keyboard::{
+ KEYBOARD_KEY_CIDS, run_keyboard_capture_session, run_keyboard_capture_session_with_registry,
+};
pub use pairing::{
Click, DiscoveredDevice, PairingCommand, PairingError, PairingEvent, PairingReceiver,
PasskeyMethod, ReceiverFamily, ReceiverSelector, list_pairing_receivers, run_pairing, unpair,
};
pub use route::{BOLT_PIDS, DIRECT_DEVICE_INDEX, DeviceRoute, UNIFYING_PIDS};
pub use smartshift::{AUTO_DISENGAGE_PERMANENT, SmartShiftMode, SmartShiftStatus};
+pub use standalone::enumerate_standalone;
pub use write::{
- DpiCapabilities, DpiInfo, FeatureEntry, HidppFeatureErrorKind, HidppOperation, LightingMethod,
- ReprogControlEntry, SharedChannel, WriteError, dump_features, dump_reprog_controls, get_dpi,
- get_dpi_info, get_smartshift_status, set_dpi, set_dpi_on, set_keyboard_color,
- set_keyboard_color_with, set_smartshift, set_smartshift_on, set_smartshift_sensitivity,
- toggle_smartshift, toggle_smartshift_on,
+ DpiCapabilities, DpiInfo, FeatureEntry, HidppFeatureErrorKind, HidppOperation, LightCommand,
+ LightingMethod, LitraModel, ReprogControlEntry, SharedChannel, WriteError, apply_litra,
+ commands_for_light_settings, dump_features, dump_reprog_controls, encode_litra_command,
+ get_backlight, get_dpi, get_dpi_info, get_dpi_info_on, get_smartshift_status,
+ get_smartshift_status_on, matches_litra, read_battery_raw, set_backlight_enabled, set_dpi,
+ set_dpi_on, set_fn_lock, set_fn_lock_on, set_keyboard_color, set_keyboard_color_on,
+ set_keyboard_color_with, set_keyboard_color_with_on, set_smartshift, set_smartshift_on,
+ set_smartshift_sensitivity, toggle_smartshift, toggle_smartshift_on,
};
diff --git a/crates/openlogi-hid/src/mappings.rs b/crates/openlogi-hid/src/mappings.rs
index f6f456c534faed1bd2a1fcf9b374b82fd570ce11..97fef59c19d4f003ded2658568a2c8857b84bdc5 100644
--- a/crates/openlogi-hid/src/mappings.rs
+++ b/crates/openlogi-hid/src/mappings.rs
@@ -3,6 +3,7 @@
//! battery level/status, and serial-number normalisation. No I/O — split from
//! `inventory` purely to keep that file within size bounds.
+use hidpp::feature::battery_status::LegacyBatteryStatus as HidppLegacyBatteryStatus;
use hidpp::feature::device_type_and_name::DeviceType as HidppDeviceType;
use hidpp::feature::unified_battery::{
BatteryLevel as HidppBatteryLevel, BatteryStatus as HidppBatteryStatus,
@@ -116,9 +117,45 @@ pub(crate) fn map_battery_status(status: HidppBatteryStatus) -> BatteryStatus {
}
}
+/// Map a legacy `0x1000` charging status to our [`BatteryStatus`].
+pub(crate) fn map_legacy_battery_status(status: HidppLegacyBatteryStatus) -> BatteryStatus {
+ match status {
+ HidppLegacyBatteryStatus::Discharging => BatteryStatus::Discharging,
+ // The legacy feature splits "charging" into recharging / almost-full;
+ // both are just "charging" to us.
+ HidppLegacyBatteryStatus::Recharging | HidppLegacyBatteryStatus::AlmostFull => {
+ BatteryStatus::Charging
+ }
+ HidppLegacyBatteryStatus::SlowRecharge => BatteryStatus::ChargingSlow,
+ HidppLegacyBatteryStatus::Full => BatteryStatus::Full,
+ HidppLegacyBatteryStatus::InvalidBattery | HidppLegacyBatteryStatus::ThermalError => {
+ BatteryStatus::Error
+ }
+ _ => BatteryStatus::Unknown,
+ }
+}
+
+/// Derive a coarse [`BatteryLevel`] from a discharge percentage. The legacy
+/// `0x1000` feature reports a percentage but, unlike `0x1004`, no level bitmask,
+/// so the bucket is ours to pick.
+///
+/// ponytail: fixed display buckets, not device thresholds. Lift to the device's
+/// own thresholds only if `0x1000`'s capability query (function 1) is wired up.
+pub(crate) fn legacy_battery_level_from_percentage(percentage: u8) -> BatteryLevel {
+ match percentage {
+ 90..=u8::MAX => BatteryLevel::Full,
+ 50..=89 => BatteryLevel::Good,
+ 20..=49 => BatteryLevel::Low,
+ _ => BatteryLevel::Critical,
+ }
+}
+
#[cfg(test)]
mod tests {
- use super::{DeviceKind, UnifyingDeviceKind, map_unifying_kind, resolve_device_kind};
+ use super::{
+ BatteryLevel, DeviceKind, UnifyingDeviceKind, legacy_battery_level_from_percentage,
+ map_unifying_kind, resolve_device_kind,
+ };
#[test]
fn probe_overrides_a_misreporting_register() {
@@ -159,6 +196,38 @@ mod tests {
);
}
+ #[test]
+ fn legacy_percentage_buckets_into_levels() {
+ assert_eq!(
+ legacy_battery_level_from_percentage(100),
+ BatteryLevel::Full
+ );
+ assert_eq!(legacy_battery_level_from_percentage(90), BatteryLevel::Full);
+ assert_eq!(legacy_battery_level_from_percentage(89), BatteryLevel::Good);
+ assert_eq!(legacy_battery_level_from_percentage(50), BatteryLevel::Good);
+ assert_eq!(legacy_battery_level_from_percentage(49), BatteryLevel::Low);
+ assert_eq!(legacy_battery_level_from_percentage(20), BatteryLevel::Low);
+ assert_eq!(
+ legacy_battery_level_from_percentage(19),
+ BatteryLevel::Critical
+ );
+ assert_eq!(
+ legacy_battery_level_from_percentage(0),
+ BatteryLevel::Critical
+ );
+ }
+
+ #[test]
+ fn legacy_status_value_7_maps_to_unknown_not_vanish() {
+ use super::{BatteryStatus, HidppLegacyBatteryStatus, map_legacy_battery_status};
+ // Value 7 ("other charging error") must parse and surface as Unknown so
+ // the battery indicator stays visible instead of disappearing.
+ let mapped = HidppLegacyBatteryStatus::try_from(7u8)
+ .ok()
+ .map(map_legacy_battery_status);
+ assert_eq!(mapped, Some(BatteryStatus::Unknown));
+ }
+
#[test]
fn unifying_kind_maps_all_variants() {
let cases = [
diff --git a/crates/openlogi-hid/src/reprog_controls.rs b/crates/openlogi-hid/src/reprog_controls.rs
index c25d4e3b75267e95d3c833d73ab7254ceff3aa7c..32c7521ba6fbf722efd2e61011a9bc04a9393f61 100644
--- a/crates/openlogi-hid/src/reprog_controls.rs
+++ b/crates/openlogi-hid/src/reprog_controls.rs
@@ -30,6 +30,7 @@ pub use hidpp_reprog::{
ControlId, GroupMask, RawWheelResolution, ReprogControlsCapabilities, ReprogControlsEvent,
TaskId, decode_event as decode_full_event,
};
+pub use hidpp_reprog::{control_ids, task_ids};
/// `ReprogControlsV4` HID++ feature ID.
pub const FEATURE_ID: u16 = 0x1b04;
@@ -51,6 +52,28 @@ pub const GESTURE_BUTTON_CID: u16 = 0x00c3;
/// cross-checked against Solaar `special_keys.py`.
pub const DPI_MODE_SHIFT_CIDS: [u16; 3] = [0x00c4, 0x00ed, 0x00fd];
+/// Control IDs of the Back button family. MX Vertical and similar devices
+/// report Back via HID++ `0x1b04` rather than a standard OS mouse button,
+/// so macOS never translates them into `OtherMouseDown` events. Whichever a
+/// device exposes (and can divert) is captured and mapped to
+/// [`ButtonId::Back`](openlogi_core::binding::ButtonId::Back).
+///
+/// Known CIDs (from the `0x1b04` control-ID list / Solaar `special_keys.py`):
+/// - `0x0053` — Back (classic mouse CID, used by MX Vertical)
+/// - `0x00BD` — MultiPlatform Back
+/// - `0x00CE` — Multiplatform Back (alternate)
+/// - `0x00DB` — Back (generic)
+pub const BACK_CIDS: [u16; 4] = [0x0053, 0x00BD, 0x00CE, 0x00DB];
+
+/// Control IDs of the Forward button family. Counterpart to [`BACK_CIDS`]:
+/// captured and mapped to
+/// [`ButtonId::Forward`](openlogi_core::binding::ButtonId::Forward).
+///
+/// Known CIDs:
+/// - `0x0056` — Forward (classic mouse CID, used by MX Vertical)
+/// - `0x00CF` — Multiplatform Forward
+pub const FORWARD_CIDS: [u16; 2] = [0x0056, 0x00CF];
+
/// Identity and capabilities of one reprogrammable control, as returned by
/// `getCtrlIdInfo`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
diff --git a/crates/openlogi-hid/src/route.rs b/crates/openlogi-hid/src/route.rs
index c9ecfba51212c7859a361411ec0cc1800daf3ee2..dd2b69cae823eb41458348715296dc8e47c5644b 100644
--- a/crates/openlogi-hid/src/route.rs
+++ b/crates/openlogi-hid/src/route.rs
@@ -63,6 +63,21 @@ pub enum DeviceRoute {
/// USB/HID product ID of the direct device.
product_id: u16,
},
+ /// Standalone raw-HID device, such as a Litra light. The identity is an
+ /// opaque transport-generated value used to disambiguate duplicate HID
+ /// nodes; this route must never be passed to HID++ channel code.
+ RawHid {
+ /// HID vendor ID.
+ vendor_id: u16,
+ /// HID product ID.
+ product_id: u16,
+ /// HID usage page.
+ usage_page: u16,
+ /// HID usage ID.
+ usage_id: u16,
+ /// Stable/opaque device identity selected during enumeration.
+ identity: String,
+ },
}
/// USB product IDs that identify Logi Bolt receivers.
@@ -70,16 +85,47 @@ pub const BOLT_PIDS: &[u16] = &[0xc548];
/// USB product IDs that identify Logi Unifying receivers. Used by callers that
/// need to construct the correct [`DeviceRoute`] variant from a raw inventory.
-pub const UNIFYING_PIDS: &[u16] = &[0xc52b, 0xc532];
+///
+/// `0xc539` is the Lightspeed gaming receiver: a distinct product line, but it
+/// answers the same HID++ 1.0 enumeration and pairing-information registers as
+/// Unifying, so it routes as [`DeviceRoute::Unifying`].
+pub const UNIFYING_PIDS: &[u16] = &[0xc52b, 0xc532, 0xc539];
impl DeviceRoute {
+ /// Whether two receiver routes use the same physical HID transport.
+ /// Direct routes cannot prove identity because they carry only VID/PID.
+ #[must_use]
+ pub fn shares_transport(&self, other: &Self) -> bool {
+ match (self, other) {
+ (
+ Self::Bolt {
+ receiver_uid: left, ..
+ },
+ Self::Bolt {
+ receiver_uid: right,
+ ..
+ },
+ )
+ | (
+ Self::Unifying {
+ receiver_uid: left, ..
+ },
+ Self::Unifying {
+ receiver_uid: right,
+ ..
+ },
+ ) => left.eq_ignore_ascii_case(right),
+ _ => false,
+ }
+ }
+
/// The HID++ device index features are addressed at for this route: the
/// pairing slot for a Bolt device, the self-index for a direct one.
#[must_use]
pub fn device_index(&self) -> u8 {
match self {
Self::Bolt { slot, .. } | Self::Unifying { slot, .. } => *slot,
- Self::Direct { .. } => DIRECT_DEVICE_INDEX,
+ Self::Direct { .. } | Self::RawHid { .. } => DIRECT_DEVICE_INDEX,
}
}
@@ -135,6 +181,16 @@ impl fmt::Display for DeviceRoute {
vendor_id,
product_id,
} => write!(f, "direct {vendor_id:04x}:{product_id:04x}"),
+ Self::RawHid {
+ vendor_id,
+ product_id,
+ usage_page,
+ usage_id,
+ identity,
+ } => write!(
+ f,
+ "raw {vendor_id:04x}:{product_id:04x} usage {usage_page:04x}:{usage_id:04x} ({identity})"
+ ),
}
}
}
@@ -148,6 +204,9 @@ impl fmt::Display for DeviceRoute {
pub(crate) async fn open_route_channel(
route: &DeviceRoute,
) -> Result<Option<Arc<HidppChannel>>, async_hid::HidError> {
+ if matches!(route, DeviceRoute::RawHid { .. }) {
+ return Ok(None);
+ }
let candidates = enumerate_hidpp_devices().await?;
for dev in candidates {
// A direct route's vendor/product id is on the unopened `DeviceInfo`
@@ -189,6 +248,7 @@ pub(crate) async fn open_route_channel(
}
}
DeviceRoute::Direct { .. } => return Ok(Some(channel)),
+ DeviceRoute::RawHid { .. } => unreachable!("raw HID route entered HID++ channel path"),
}
}
Ok(None)
diff --git a/crates/openlogi-hid/src/standalone.rs b/crates/openlogi-hid/src/standalone.rs
new file mode 100644
index 0000000000000000000000000000000000000000..7e9d4da7c0fc376fc7eb9d7033a15b51552cc6f4
--- /dev/null
+++ b/crates/openlogi-hid/src/standalone.rs
@@ -0,0 +1,144 @@
+//! Discovery of standalone raw-HID devices.
+
+use std::collections::{HashMap, HashSet};
+
+use openlogi_core::device::{DeviceKind, RawDeviceAddress, StandaloneDevice};
+
+use crate::inventory::InventoryError;
+use crate::transport::{device_identity, enumerate_devices};
+use crate::write::{LitraModel, matches_litra};
+
+/// Enumerate recognized standalone devices without probing them as HID++.
+///
+/// The returned descriptors are intentionally separate from receiver
+/// inventories. A raw device has no HID++ pairing slot and must be routed by
+/// its full HID identity tuple.
+pub async fn enumerate_standalone() -> Result<Vec<StandaloneDevice>, InventoryError> {
+ let devices = enumerate_devices().await?;
+ let devices: Vec<_> = devices
+ .into_iter()
+ .filter_map(|device| {
+ if !matches_litra(
+ device.vendor_id,
+ device.product_id,
+ device.usage_page,
+ device.usage_id,
+ ) {
+ return None;
+ }
+ let model = LitraModel::from_product_id(device.product_id)?;
+ let identity = device_identity(&device);
+ Some(StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: device.vendor_id,
+ product_id: device.product_id,
+ usage_page: device.usage_page,
+ usage_id: device.usage_id,
+ identity,
+ },
+ display_name: device.name.clone(),
+ manufacturer: device.manufacturer.clone(),
+ serial_number: device.serial_number.clone(),
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: Some(model.capabilities()),
+ driver_id: model.driver_id().to_owned(),
+ registry_model_id: model.registry_model_id().map(str::to_owned),
+ })
+ })
+ .collect();
+ validate_no_ambiguous_nodes(&devices)?;
+ Ok(devices)
+}
+
+/// Reject multiple nodes that the route cannot distinguish safely.
+///
+/// A serial-bearing pair is distinguishable even when two identical lights
+/// share the same VID/PID/usage tuple. An OS-node identity (`id:…`) is only a
+/// transient re-find hint, so two such nodes with the same tuple are
+/// indistinguishable and must not be exposed as independently selectable
+/// devices.
+fn validate_no_ambiguous_nodes(devices: &[StandaloneDevice]) -> Result<(), InventoryError> {
+ let mut groups: HashMap<(u16, u16, u16, u16), Vec<&StandaloneDevice>> = HashMap::new();
+ for device in devices {
+ let address = &device.address;
+ groups
+ .entry((
+ address.vendor_id,
+ address.product_id,
+ address.usage_page,
+ address.usage_id,
+ ))
+ .or_default()
+ .push(device);
+ }
+ if groups.values().any(|group| {
+ if group.len() < 2 {
+ return false;
+ }
+ let identities: HashSet<&str> = group
+ .iter()
+ .map(|device| device.address.identity.as_str())
+ .collect();
+ identities.len() != group.len()
+ || group
+ .iter()
+ .any(|device| !device.address.identity.starts_with("serial:"))
+ }) {
+ return Err(InventoryError::AmbiguousRawDevice);
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use openlogi_core::device::{DeviceKind, RawDeviceAddress, StandaloneDevice};
+
+ use crate::write::matches_litra;
+
+ use super::validate_no_ambiguous_nodes;
+
+ #[test]
+ fn glow_fixture_matches_standalone_driver() {
+ assert!(matches_litra(0x046d, 0xc900, 0xff43, 0x0202));
+ }
+
+ fn raw(identity: &str) -> StandaloneDevice {
+ StandaloneDevice {
+ address: RawDeviceAddress {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: identity.into(),
+ },
+ display_name: "Litra Glow".into(),
+ manufacturer: Some("Logi".into()),
+ serial_number: identity.strip_prefix("serial:").map(str::to_string),
+ unit_id: [0; 4],
+ kind: DeviceKind::Light,
+ online: true,
+ capabilities: None,
+ light_capabilities: None,
+ driver_id: "litra".into(),
+ registry_model_id: None,
+ }
+ }
+
+ #[test]
+ fn duplicate_transient_nodes_are_rejected() {
+ let devices = vec![raw("id:old"), raw("id:new")];
+ assert!(matches!(
+ validate_no_ambiguous_nodes(&devices),
+ Err(crate::inventory::InventoryError::AmbiguousRawDevice)
+ ));
+ }
+
+ #[test]
+ fn distinct_serials_can_share_the_same_hid_tuple() {
+ let devices = vec![raw("serial:one"), raw("serial:two")];
+ assert!(validate_no_ambiguous_nodes(&devices).is_ok());
+ }
+}
diff --git a/crates/openlogi-hid/src/transport.rs b/crates/openlogi-hid/src/transport.rs
index 7dcc4048361fed01fedfc28586acb5de6f42a66b..afe57815813507635ce55c8bde518f0db12089c3 100644
--- a/crates/openlogi-hid/src/transport.rs
+++ b/crates/openlogi-hid/src/transport.rs
@@ -10,6 +10,8 @@
#[cfg(not(target_os = "windows"))]
use std::error::Error;
+#[cfg(not(target_os = "windows"))]
+use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::{Arc, LazyLock};
@@ -25,6 +27,8 @@ use hidpp::{async_trait, channel::RawHidChannel};
use tokio::sync::Mutex;
use tracing::debug;
+use crate::write::{WriteError, matches_litra};
+
/// Bitmask of leased HID++ software ids (`1..=15`; bit `N` means id `N` is taken).
///
/// HID++ correlates request/response by `(device, feature, function, software_id)`.
@@ -110,11 +114,11 @@ fn is_long_only_collection(usage_page: u16, usage_id: u16) -> bool {
/// backend is the usage async-hid intends, and keeps the device set warm between
/// polls. `HidBackend` is `Arc`-backed, so this is shared, not copied.
///
-/// `enumerate` is also reached from `open_route_writer`, so the inventory
-/// watcher and a (rare) lighting write can enumerate through this one backend
-/// concurrently. That is sound: async-hid declares the backend `Send + Sync`,
-/// `enumerate` only reads a snapshot (`IOHIDManagerCopyDevices`), and sharing a
-/// single long-lived `IOHIDManager` across threads is the model hidapi uses too.
+/// Inventory and route-addressed standalone operations may enumerate through
+/// this one backend concurrently. That is sound: async-hid declares the backend
+/// `Send + Sync`, `enumerate` only reads a snapshot
+/// (`IOHIDManagerCopyDevices`), and sharing a single long-lived `IOHIDManager`
+/// across threads is the model hidapi uses too.
static HID_BACKEND: LazyLock<HidBackend> = LazyLock::new(HidBackend::default);
/// The process-wide HID backend shared by enumeration and hotplug watching.
@@ -122,8 +126,7 @@ pub(crate) fn hid_backend() -> &'static HidBackend {
&HID_BACKEND
}
-pub(crate) async fn enumerate_hidpp_devices() -> Result<Vec<async_hid::Device>, async_hid::HidError>
-{
+pub(crate) async fn enumerate_devices() -> Result<Vec<async_hid::Device>, async_hid::HidError> {
let all: Vec<async_hid::Device> = HID_BACKEND.enumerate().await?.collect().await;
// One-time visibility into what the OS actually reports for Logitech nodes,
@@ -140,16 +143,64 @@ pub(crate) async fn enumerate_hidpp_devices() -> Result<Vec<async_hid::Device>,
);
}
- Ok(all
+ Ok(all)
+}
+
+/// Stable opaque identity used by raw-device routes. Prefer the HID serial;
+/// otherwise retain the backend's platform identifier as a runtime identity.
+/// The latter is deliberately not treated as a cross-machine portable key,
+/// but it is stronger than enumeration order and lets duplicate nodes be
+/// rejected deterministically.
+pub(crate) fn device_identity(info: &DeviceInfo) -> String {
+ info.serial_number
+ .as_deref()
+ .filter(|serial| !serial.is_empty())
+ .map_or_else(
+ // `DeviceInfo::id` is an OS-node identity (hidraw path, registry
+ // entry, or Windows device path). It is useful to re-find a node
+ // during this process lifetime, but it is intentionally marked as
+ // transient and must never become a persisted physical key.
+ || format!("id:{:?}", info.id),
+ |serial| format!("serial:{}", serial.to_ascii_lowercase()),
+ )
+}
+
+pub(crate) async fn enumerate_hidpp_devices() -> Result<Vec<async_hid::Device>, async_hid::HidError>
+{
+ Ok(enumerate_devices()
+ .await?
.into_iter()
.filter(|d| {
- d.vendor_id == LOGITECH_VID
- && is_hidpp_long_collection(d.usage_page, d.usage_id)
- && !is_receiver_child_node(&d.id)
+ is_hidpp_candidate(
+ d.vendor_id,
+ d.product_id,
+ d.usage_page,
+ d.usage_id,
+ is_receiver_child_node(&d.id),
+ )
})
.collect())
}
+/// Whether an enumerated node belongs to the HID++ channel path.
+///
+/// Standalone drivers have precedence over the generic collection matcher. A
+/// Litra Glow intentionally uses the same BLE usage collection as Logitech
+/// HID++ peripherals, so the full product/usage tuple must be excluded here;
+/// the collection itself remains a valid HID++ candidate for other products.
+fn is_hidpp_candidate(
+ vendor_id: u16,
+ product_id: u16,
+ usage_page: u16,
+ usage_id: u16,
+ receiver_child: bool,
+) -> bool {
+ vendor_id == LOGITECH_VID
+ && is_hidpp_long_collection(usage_page, usage_id)
+ && !matches_litra(vendor_id, product_id, usage_page, usage_id)
+ && !receiver_child
+}
+
/// Returns `true` when a HID++ node is a virtual per-device interface created by
/// the `hid-logitech-dj` kernel driver as a child of a Unifying or Bolt receiver.
///
@@ -210,22 +261,57 @@ fn is_receiver_child_node(_id: &async_hid::DeviceId) -> bool {
/// matching node is connected.
pub(crate) async fn open_route_writer(
route: &crate::route::DeviceRoute,
-) -> Result<Option<DeviceWriter>, async_hid::HidError> {
- let crate::route::DeviceRoute::Direct {
- vendor_id,
- product_id,
- } = route
- else {
- return Ok(None);
+) -> Result<Option<DeviceWriter>, WriteError> {
+ let candidates = match route {
+ crate::route::DeviceRoute::Direct { .. } => {
+ enumerate_hidpp_devices().await.map_err(WriteError::from)?
+ }
+ crate::route::DeviceRoute::RawHid { .. } => {
+ enumerate_devices().await.map_err(WriteError::from)?
+ }
+ _ => return Ok(None),
};
- let candidates = enumerate_hidpp_devices().await?;
+ let mut matched = None;
for dev in candidates {
- if dev.vendor_id == *vendor_id && dev.product_id == *product_id {
- let (_reader, writer) = dev.open().await?;
- return Ok(Some(writer));
+ let is_match = match route {
+ crate::route::DeviceRoute::Direct {
+ vendor_id,
+ product_id,
+ } => dev.vendor_id == *vendor_id && dev.product_id == *product_id,
+ crate::route::DeviceRoute::RawHid {
+ vendor_id,
+ product_id,
+ usage_page,
+ usage_id,
+ identity,
+ } => {
+ dev.vendor_id == *vendor_id
+ && dev.product_id == *product_id
+ && dev.usage_page == *usage_page
+ && dev.usage_id == *usage_id
+ && device_identity(&dev) == *identity
+ }
+ _ => false,
+ };
+ if is_match {
+ if matches!(route, crate::route::DeviceRoute::Direct { .. }) {
+ let (_reader, writer) = dev.open().await.map_err(WriteError::from)?;
+ return Ok(Some(writer));
+ }
+ if matched.is_some() {
+ tracing::warn!("multiple raw HID nodes matched one route");
+ return Err(WriteError::AmbiguousRawDevice);
+ }
+ matched = Some(dev);
}
}
- Ok(None)
+ match matched {
+ Some(dev) => {
+ let (_reader, writer) = dev.open().await.map_err(WriteError::from)?;
+ Ok(Some(writer))
+ }
+ None => Ok(None),
+ }
}
/// Lease one free software id in `1..=15`, or `None` if all 15 are held.
@@ -349,6 +435,7 @@ pub(crate) struct AsyncHidChannel {
reader: Mutex<DeviceReader>,
writer: Mutex<DeviceWriter>,
info: DeviceInfo,
+ connected: AtomicBool,
/// Whether the device exposes only the long HID++ report (a BLE-direct
/// peripheral on macOS). Reported via `supports_short_long_hidpp` so the
/// `hidpp` channel up-converts outgoing short messages to long.
@@ -367,9 +454,16 @@ impl AsyncHidChannel {
reader: Mutex::new(reader),
writer: Mutex::new(writer),
info,
+ connected: AtomicBool::new(true),
long_only,
}
}
+
+ fn mark_disconnected(&self) {
+ if self.connected.swap(false, Ordering::AcqRel) {
+ debug!(name = %self.info.name, "HID channel disconnected");
+ }
+ }
}
#[cfg(not(target_os = "windows"))]
@@ -385,8 +479,15 @@ impl RawHidChannel for AsyncHidChannel {
async fn write_report(&self, src: &[u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
let mut w = self.writer.lock().await;
- w.write_output_report(src).await?;
- Ok(src.len())
+ match w.write_output_report(src).await {
+ Ok(()) => Ok(src.len()),
+ Err(e) => {
+ if matches!(e, async_hid::HidError::Disconnected) {
+ self.mark_disconnected();
+ }
+ Err(e.into())
+ }
+ }
}
async fn read_report(&self, buf: &mut [u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
@@ -403,11 +504,18 @@ impl RawHidChannel for AsyncHidChannel {
// until the inventory watcher evicts the channel), so park instead.
// The contract guarantees every caller races this future against
// the channel's close signal, which tears the read down on drop.
- Err(async_hid::HidError::Disconnected) => std::future::pending().await,
+ Err(async_hid::HidError::Disconnected) => {
+ self.mark_disconnected();
+ std::future::pending().await
+ }
Err(e) => Err(e.into()),
}
}
+ fn is_connected(&self) -> bool {
+ self.connected.load(Ordering::Acquire)
+ }
+
fn supports_short_long_hidpp(&self) -> Option<(bool, bool)> {
// USB / receiver collections carry both reports; BLE-direct collections
// are long-only (no short report on macOS), where the `hidpp` channel
diff --git a/crates/openlogi-hid/src/transport/tests.rs b/crates/openlogi-hid/src/transport/tests.rs
index b67e75e5535b26a23849ed8ab010ab7ee4171ce0..a499e8b7c91f05771de0e610024d43375969bdbd 100644
--- a/crates/openlogi-hid/src/transport/tests.rs
+++ b/crates/openlogi-hid/src/transport/tests.rs
@@ -9,6 +9,14 @@ fn matches_usb_ble_and_keyboard_hidpp_collections() {
assert!(!is_hidpp_long_collection(0xff43, 0x0002)); // page right, usage wrong
}
+#[test]
+fn litra_ble_collection_is_not_a_hidpp_candidate() {
+ assert!(!is_hidpp_candidate(0x046d, 0xc900, 0xff43, 0x0202, false));
+ // The same BLE collection remains valid for ordinary directly-paired HID++
+ // devices; filtering by usage page alone would regress those devices.
+ assert!(is_hidpp_candidate(0x046d, 0xb023, 0xff43, 0x0202, false));
+}
+
#[test]
fn only_ble_collection_is_long_only() {
assert!(is_long_only_collection(0xff43, 0x0202)); // BLE-direct → short-unsupported
diff --git a/crates/openlogi-hid/src/transport/windows.rs b/crates/openlogi-hid/src/transport/windows.rs
index 75e2d3eff18944e437abd79b0f061fde41db2f8f..8587ecdf6a873903c435e90652547482a7cba0dc 100644
--- a/crates/openlogi-hid/src/transport/windows.rs
+++ b/crates/openlogi-hid/src/transport/windows.rs
@@ -5,12 +5,11 @@ use std::{error::Error, io};
use async_hid::{AsyncHidRead, AsyncHidWrite, DeviceInfo, DeviceReader, DeviceWriter};
#[cfg(target_os = "windows")]
use futures_lite::StreamExt as _;
+use hidpp::channel::{LONG_REPORT_ID, SHORT_REPORT_ID};
#[cfg(target_os = "windows")]
use hidpp::{
async_trait,
- channel::{
- LONG_REPORT_ID, LONG_REPORT_LENGTH, RawHidChannel, SHORT_REPORT_ID, SHORT_REPORT_LENGTH,
- },
+ channel::{LONG_REPORT_LENGTH, RawHidChannel, SHORT_REPORT_LENGTH},
};
#[cfg(target_os = "windows")]
use tokio::sync::Mutex;
@@ -23,6 +22,22 @@ use crate::windows_hid::NativeHidWriter;
#[cfg(target_os = "windows")]
use super::HID_BACKEND;
+const VERY_LONG_REPORT_ID: u8 = 0x12;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ReportEndpoint {
+ Short,
+ Long,
+}
+
+fn endpoint_for_report_id(report_id: u8) -> Option<ReportEndpoint> {
+ match report_id {
+ SHORT_REPORT_ID => Some(ReportEndpoint::Short),
+ LONG_REPORT_ID | VERY_LONG_REPORT_ID => Some(ReportEndpoint::Long),
+ _ => None,
+ }
+}
+
#[cfg(target_os = "windows")]
struct HidEndpoint {
reader: Mutex<DeviceReader>,
@@ -189,9 +204,9 @@ impl RawHidChannel for WindowsHidppChannel {
}
async fn write_report(&self, src: &[u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
- let endpoint = match src.first().copied() {
- Some(SHORT_REPORT_ID) => self.short.as_ref(),
- Some(LONG_REPORT_ID) => self.long.as_ref(),
+ let endpoint = match src.first().copied().and_then(endpoint_for_report_id) {
+ Some(ReportEndpoint::Short) => self.short.as_ref(),
+ Some(ReportEndpoint::Long) => self.long.as_ref(),
_ => None,
}
.ok_or_else(|| {
@@ -267,3 +282,25 @@ fn copy_report(
dst[..len].copy_from_slice(&src[..len]);
Ok(len)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn report_ids_select_their_windows_endpoint() {
+ assert_eq!(
+ endpoint_for_report_id(SHORT_REPORT_ID),
+ Some(ReportEndpoint::Short)
+ );
+ assert_eq!(
+ endpoint_for_report_id(LONG_REPORT_ID),
+ Some(ReportEndpoint::Long)
+ );
+ assert_eq!(
+ endpoint_for_report_id(VERY_LONG_REPORT_ID),
+ Some(ReportEndpoint::Long)
+ );
+ assert_eq!(endpoint_for_report_id(0x13), None);
+ }
+}
diff --git a/crates/openlogi-hid/src/write.rs b/crates/openlogi-hid/src/write.rs
index e01c79e2e490874bea315a9c865c85273086e51f..a058b98adbcc87de29cc4a3e5d75c4402ca42f9d 100644
--- a/crates/openlogi-hid/src/write.rs
+++ b/crates/openlogi-hid/src/write.rs
@@ -1,34 +1,74 @@
-//! HID++ writes back to the device — DPI, SmartShift, lighting, and diagnostics.
+//! HID++ writes back to the device — DPI, SmartShift, lighting, backlight, and
+//! diagnostics.
//!
//! Each entry point takes a [`DeviceRoute`] and resolves it to an open channel
//! through `open_route_channel`, so the same call works whether the device is
//! behind a Bolt receiver or attached directly (USB cable / Bluetooth). Each
-//! call re-enumerates and re-opens — fine at the frequency this is invoked
-//! (once per slider release) — unless a [`SharedChannel`] from the capture
-//! session is reused.
+//! route-addressed call re-enumerates and re-opens, while the corresponding
+//! `_on` entry points reuse a [`SharedChannel`] already owned by inventory or a
+//! standalone capture session.
use std::sync::Arc;
use hidpp::{channel::HidppChannel, device::Device, feature::CreatableFeature};
+use openlogi_core::config::LightSettings;
+use openlogi_core::device::LightCapabilities;
use crate::route::{DeviceRoute, open_route_channel};
+mod backlight;
mod diagnostics;
mod dpi;
mod error;
+mod fn_lock;
mod lighting;
+mod litra;
mod shared;
mod smartshift;
-pub use diagnostics::{FeatureEntry, ReprogControlEntry, dump_features, dump_reprog_controls};
+pub use backlight::{get_backlight, set_backlight_enabled};
+pub use diagnostics::{
+ FeatureEntry, ReprogControlEntry, dump_features, dump_reprog_controls, read_battery_raw,
+};
pub use dpi::{DpiCapabilities, DpiInfo, get_dpi, get_dpi_info, set_dpi};
pub use error::{HidppFeatureErrorKind, HidppOperation, WriteError};
+pub use fn_lock::set_fn_lock;
pub use lighting::{LightingMethod, set_keyboard_color, set_keyboard_color_with};
-pub use shared::{SharedChannel, set_dpi_on, set_smartshift_on, toggle_smartshift_on};
+pub use litra::{
+ LightCommand, LitraModel, apply as apply_litra, encode_command as encode_litra_command,
+ matches_litra,
+};
+pub use shared::{
+ SharedChannel, get_dpi_info_on, get_smartshift_status_on, set_dpi_on, set_fn_lock_on,
+ set_keyboard_color_on, set_keyboard_color_with_on, set_smartshift_on, toggle_smartshift_on,
+};
pub use smartshift::{
get_smartshift_status, set_smartshift, set_smartshift_sensitivity, toggle_smartshift,
};
+/// Expand protocol-neutral saved settings into only the controls advertised
+/// by a standalone light. Unsupported controls are omitted rather than sent
+/// speculatively, which keeps power-only and brightness-only drivers usable.
+#[must_use]
+pub fn commands_for_light_settings(
+ settings: LightSettings,
+ capabilities: LightCapabilities,
+) -> Vec<LightCommand> {
+ let mut commands = Vec::new();
+ if capabilities.power {
+ commands.push(LightCommand::Power(settings.enabled));
+ }
+ if capabilities.brightness.is_some() {
+ commands.push(LightCommand::BrightnessPercent(settings.brightness_percent));
+ }
+ if capabilities.temperature.is_some()
+ && let Some(kelvin) = settings.temperature_kelvin
+ {
+ commands.push(LightCommand::TemperatureKelvin(kelvin));
+ }
+ commands
+}
+
pub(crate) use error::classify_hidpp_error;
/// Look up `F` on a device by HID++ feature ID, register it with
diff --git a/crates/openlogi-hid/src/write/backlight.rs b/crates/openlogi-hid/src/write/backlight.rs
new file mode 100644
index 0000000000000000000000000000000000000000..c873b7dfb5a877a32bd82f93f2cd50cf5dc66b54
--- /dev/null
+++ b/crates/openlogi-hid/src/write/backlight.rs
@@ -0,0 +1,201 @@
+use std::sync::Arc;
+
+use hidpp::{
+ device::Device,
+ feature::{
+ CreatableFeature,
+ backlight::{
+ BacklightFeature, BacklightMode as FirmwareMode, BacklightStatus as FirmwareStatus,
+ SetBacklightConfig,
+ },
+ },
+};
+use tracing::debug;
+
+use crate::backlight::{BacklightMode, BacklightState, BacklightStatus};
+use crate::route::DeviceRoute;
+
+use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
+
+/// Map the fork's `0x1982` mode onto OpenLogi's [`BacklightMode`]. The source
+/// enum is `#[non_exhaustive]`; an unmodelled future variant maps to
+/// [`BacklightMode::None`], which callers treat as "firmware picks".
+fn mode_from_firmware(mode: FirmwareMode) -> BacklightMode {
+ match mode {
+ FirmwareMode::Automatic => BacklightMode::Automatic,
+ FirmwareMode::TemporaryManual => BacklightMode::TemporaryManual,
+ FirmwareMode::PermanentManual => BacklightMode::PermanentManual,
+ _ => BacklightMode::None,
+ }
+}
+
+/// The inverse of [`mode_from_firmware`], used when writing a config back.
+///
+/// [`BacklightMode::TemporaryManual`] is the mode the *keyboard* enters when
+/// the user presses its backlight keys; `setBacklightConfig` cannot write it,
+/// so it is sent as [`FirmwareMode::Automatic`] — the firmware's own fallback
+/// once software takes over the level.
+fn mode_to_firmware(mode: BacklightMode) -> FirmwareMode {
+ match mode {
+ BacklightMode::None => FirmwareMode::None,
+ BacklightMode::Automatic | BacklightMode::TemporaryManual => FirmwareMode::Automatic,
+ BacklightMode::PermanentManual => FirmwareMode::PermanentManual,
+ }
+}
+
+/// Map the fork's `0x1982` status onto OpenLogi's [`BacklightStatus`]. The
+/// source enum is `#[non_exhaustive]`; an unmodelled future variant maps to
+/// [`BacklightStatus::AlsAutomatic`], the firmware's out-of-box behaviour.
+fn status_from_firmware(status: FirmwareStatus) -> BacklightStatus {
+ match status {
+ FirmwareStatus::DisabledBySoftware => BacklightStatus::DisabledBySoftware,
+ FirmwareStatus::DisabledByCriticalBattery => BacklightStatus::DisabledByCriticalBattery,
+ FirmwareStatus::AlsSaturated => BacklightStatus::AlsSaturated,
+ FirmwareStatus::TemporaryManual => BacklightStatus::TemporaryManual,
+ FirmwareStatus::PermanentManual => BacklightStatus::PermanentManual,
+ _ => BacklightStatus::AlsAutomatic,
+ }
+}
+
+/// Read `getBacklightConfig` + `getBacklightInfo` and merge them into a
+/// [`BacklightState`].
+async fn read_state(feature: &BacklightFeature) -> Result<BacklightState, WriteError> {
+ let config = feature.get_backlight_config().await.map_err(|e| {
+ classify_hidpp_error(e, HidppOperation::ReadBacklight, BacklightFeature::ID)
+ })?;
+ let info = feature.get_backlight_info().await.map_err(|e| {
+ classify_hidpp_error(e, HidppOperation::ReadBacklight, BacklightFeature::ID)
+ })?;
+ Ok(BacklightState {
+ enabled: config.enabled,
+ mode: mode_from_firmware(config.mode),
+ status: status_from_firmware(info.status),
+ current_level: info.current_level,
+ nb_levels: info.nb_levels,
+ })
+}
+
+/// Read the current backlight state of the keyboard on `route`.
+///
+/// `FeatureUnsupported` when the device does not expose HID++ `0x1982` — RGB
+/// keyboards (`0x8070` / `0x8080`) and every mouse fall in that group.
+pub async fn get_backlight(route: &DeviceRoute) -> Result<BacklightState, WriteError> {
+ let index = route.device_index();
+ with_route(route, move |channel| async move {
+ let mut device = Device::new(Arc::clone(&channel), index)
+ .await
+ .map_err(|_| WriteError::DeviceUnreachable { index })?;
+ let feature = open_feature::<BacklightFeature>(&mut device).await?;
+ read_state(&feature).await
+ })
+ .await
+}
+
+/// Enable or disable the backlight on `route`, and return the read-back state.
+///
+/// Disabling sets the firmware's own master switch: the LEDs stay dark
+/// regardless of the ambient-light and proximity sensors, and the device
+/// reports [`BacklightStatus::DisabledBySoftware`]. The write goes to
+/// non-volatile memory, so it survives reconnects, host switches, and power
+/// cycles — nothing needs to re-apply it.
+///
+/// The effect, brightness level, and fade-out durations are read first and
+/// written back unchanged, so they return with a later `enabled = true`.
+///
+/// The mode survives too, except from [`BacklightMode::TemporaryManual`] — the
+/// state the keyboard enters on its own when the user presses its backlight
+/// keys. `setBacklightConfig` cannot write that mode, so it lands in
+/// [`BacklightMode::Automatic`] and the level goes back under ambient-light
+/// control. Promoting it to [`BacklightMode::PermanentManual`] would hold the
+/// level but make a deliberately temporary adjustment permanent, so the
+/// firmware's own fallback wins instead. Read the mode first and tell the user
+/// when this applies.
+///
+/// `FeatureUnsupported` when the device does not expose HID++ `0x1982`.
+pub async fn set_backlight_enabled(
+ route: &DeviceRoute,
+ enabled: bool,
+) -> Result<BacklightState, WriteError> {
+ let index = route.device_index();
+ with_route(route, move |channel| async move {
+ let mut device = Device::new(Arc::clone(&channel), index)
+ .await
+ .map_err(|_| WriteError::DeviceUnreachable { index })?;
+ let feature = open_feature::<BacklightFeature>(&mut device).await?;
+
+ let current = feature.get_backlight_config().await.map_err(|e| {
+ classify_hidpp_error(e, HidppOperation::ReadBacklight, BacklightFeature::ID)
+ })?;
+
+ feature
+ .set_backlight_config(SetBacklightConfig {
+ enabled,
+ options: current.options,
+ mode: mode_to_firmware(mode_from_firmware(current.mode)),
+ // `None` sends the 0xff "do not change" sentinel, keeping
+ // whichever effect the device already runs.
+ effect: None,
+ current_level: current.current_level,
+ duration_hands_out: current.duration_hands_out,
+ duration_hands_in: current.duration_hands_in,
+ duration_powered: current.duration_powered,
+ })
+ .await
+ .map_err(|e| {
+ classify_hidpp_error(e, HidppOperation::WriteBacklight, BacklightFeature::ID)
+ })?;
+
+ debug!(index, enabled, "wrote backlight enable");
+ read_state(&feature).await
+ })
+ .await
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn firmware_modes_round_trip_through_openlogi_modes() {
+ assert_eq!(
+ mode_from_firmware(FirmwareMode::Automatic),
+ BacklightMode::Automatic
+ );
+ assert_eq!(
+ mode_from_firmware(FirmwareMode::PermanentManual),
+ BacklightMode::PermanentManual
+ );
+ assert_eq!(mode_from_firmware(FirmwareMode::None), BacklightMode::None);
+ }
+
+ #[test]
+ fn temporary_manual_is_downgraded_because_software_cannot_write_it() {
+ assert_eq!(
+ mode_from_firmware(FirmwareMode::TemporaryManual),
+ BacklightMode::TemporaryManual
+ );
+ assert_eq!(
+ mode_to_firmware(BacklightMode::TemporaryManual),
+ FirmwareMode::Automatic
+ );
+ }
+
+ #[test]
+ fn writable_modes_survive_the_read_write_round_trip() {
+ for mode in [FirmwareMode::None, FirmwareMode::PermanentManual] {
+ assert_eq!(mode_to_firmware(mode_from_firmware(mode)), mode);
+ }
+ }
+
+ #[test]
+ fn software_disable_status_is_mapped() {
+ assert_eq!(
+ status_from_firmware(FirmwareStatus::DisabledBySoftware),
+ BacklightStatus::DisabledBySoftware
+ );
+ assert_eq!(
+ status_from_firmware(FirmwareStatus::AlsSaturated),
+ BacklightStatus::AlsSaturated
+ );
+ }
+}
diff --git a/crates/openlogi-hid/src/write/diagnostics.rs b/crates/openlogi-hid/src/write/diagnostics.rs
index e3a9ee6fb50b3b08a2f0e0d86a1b5f66e632f92b..2aee84779f3268ff261e973679e5f9189b839519 100644
--- a/crates/openlogi-hid/src/write/diagnostics.rs
+++ b/crates/openlogi-hid/src/write/diagnostics.rs
@@ -1,10 +1,13 @@
use std::sync::Arc;
-use hidpp::{device::Device, feature::CreatableFeature, feature::feature_set::FeatureSetFeature};
+use hidpp::{
+ device::Device, feature::CreatableFeature, feature::battery_status::BatteryStatusFeature,
+ feature::feature_set::FeatureSetFeature, feature::unified_battery::UnifiedBatteryFeature,
+};
use crate::reprog_controls::{self, CidFlags, CidInfo, ReprogControlsV4};
use crate::route::DeviceRoute;
-use crate::write::{HidppOperation, WriteError, classify_hidpp_error, with_route};
+use crate::write::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
/// Snapshot of one HID++ feature exposed by a device: protocol ID +
/// version. Returned by [`dump_features`] for diagnostics.
@@ -119,3 +122,54 @@ pub async fn dump_reprog_controls(
})
.await
}
+
+/// Diagnostic read of the device's raw battery report — the unified `0x1004`
+/// fields, or the legacy `0x1000` `discharge_level`/`next_level`/`status`. For
+/// `openlogi diag battery`: surfaces exactly what the firmware reports so a
+/// claim like "MX2S shows 0% while charging" can be confirmed against the wire
+/// instead of guessed (the GUI only ever shows the mapped value).
+pub async fn read_battery_raw(route: &DeviceRoute) -> Result<String, WriteError> {
+ let index = route.device_index();
+ with_route(route, move |channel| async move {
+ let mut device = Device::new(Arc::clone(&channel), index)
+ .await
+ .map_err(|_| WriteError::DeviceUnreachable { index })?;
+
+ match open_feature::<UnifiedBatteryFeature>(&mut device).await {
+ Ok(feature) => {
+ let info = feature
+ .get_battery_info()
+ .await
+ .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
+ return Ok(format!(
+ "0x1004 UnifiedBattery: percentage={} level={:?} status={:?}",
+ info.charging_percentage, info.level, info.status
+ ));
+ }
+ Err(WriteError::FeatureUnsupported { .. }) => {}
+ Err(e) => return Err(e),
+ }
+
+ match open_feature::<BatteryStatusFeature>(&mut device).await {
+ Ok(feature) => {
+ let info = feature
+ .get_battery_level_status()
+ .await
+ .map_err(|e| WriteError::Hidpp(format!("{e:?}")))?;
+ return Ok(format!(
+ "0x1000 BatteryStatus: discharge_level={} next_level={} status={:?}",
+ info.discharge_level, info.next_level, info.status
+ ));
+ }
+ Err(WriteError::FeatureUnsupported { .. }) => {}
+ Err(e) => return Err(e),
+ }
+
+ // Reached only when neither 0x1004 nor 0x1000 is present; report the
+ // preferred feature rather than implying 0x1000 was specifically absent.
+ Err(WriteError::FeatureUnsupported {
+ feature_hex: 0x1004,
+ })
+ })
+ .await
+}
diff --git a/crates/openlogi-hid/src/write/dpi.rs b/crates/openlogi-hid/src/write/dpi.rs
index 1333f7ea886132aae84472ad32e3666a3809807e..7c0b60b01ae5f2a809b64248e5b47154ab71d43c 100644
--- a/crates/openlogi-hid/src/write/dpi.rs
+++ b/crates/openlogi-hid/src/write/dpi.rs
@@ -125,18 +125,25 @@ pub struct DpiInfo {
pub async fn get_dpi(route: &DeviceRoute) -> Result<u16, WriteError> {
let index = route.device_index();
with_route(route, move |channel| async move {
- let mut device = Device::new(Arc::clone(&channel), index)
- .await
- .map_err(|_| WriteError::DeviceUnreachable { index })?;
- let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
- feature
- .get_sensor_dpi(0)
- .await
- .map_err(|e| classify_hidpp_error(e, HidppOperation::ReadDpi, AdjustableDpiFeature::ID))
+ get_dpi_on_channel(&channel, index).await
})
.await
}
+async fn get_dpi_on_channel(
+ channel: &Arc<hidpp::channel::HidppChannel>,
+ index: u8,
+) -> Result<u16, WriteError> {
+ let mut device = Device::new(Arc::clone(channel), index)
+ .await
+ .map_err(|_| WriteError::DeviceUnreachable { index })?;
+ let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
+ feature
+ .get_sensor_dpi(0)
+ .await
+ .map_err(|e| classify_hidpp_error(e, HidppOperation::ReadDpi, AdjustableDpiFeature::ID))
+}
+
/// Classify a HID++ error from the AdjustableDpi functions. A device that
/// announces `0x2201` but rejects a function (`Unsupported` /
/// `InvalidFunctionId`) or returns a structurally invalid DPI list
@@ -162,37 +169,44 @@ fn classify_dpi_error(error: Hidpp20Error) -> WriteError {
pub async fn get_dpi_info(route: &DeviceRoute) -> Result<DpiInfo, WriteError> {
let index = route.device_index();
with_route(route, move |channel| async move {
- let mut device = Device::new(Arc::clone(&channel), index)
- .await
- .map_err(|_| WriteError::DeviceUnreachable { index })?;
- let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
- let sensor_count = feature
- .get_sensor_count()
- .await
- .map_err(classify_dpi_error)?;
- if sensor_count == 0 {
- // The device claims AdjustableDpi but exposes no sensor — it cannot
- // report DPI, and that won't change on retry.
- return Err(WriteError::FeatureUnsupported {
- feature_hex: AdjustableDpiFeature::ID,
- });
- }
- let current = feature
- .get_sensor_dpi(0)
- .await
- .map_err(classify_dpi_error)?;
- let values = feature
- .get_sensor_dpi_list(0)
- .await
- .map_err(classify_dpi_error)?;
- Ok(DpiInfo {
- current,
- capabilities: DpiCapabilities::new(values)?,
- })
+ get_dpi_info_on_channel(&channel, index).await
})
.await
}
+pub(super) async fn get_dpi_info_on_channel(
+ channel: &Arc<hidpp::channel::HidppChannel>,
+ index: u8,
+) -> Result<DpiInfo, WriteError> {
+ let mut device = Device::new(Arc::clone(channel), index)
+ .await
+ .map_err(|_| WriteError::DeviceUnreachable { index })?;
+ let feature = open_feature::<AdjustableDpiFeature>(&mut device).await?;
+ let sensor_count = feature
+ .get_sensor_count()
+ .await
+ .map_err(classify_dpi_error)?;
+ if sensor_count == 0 {
+ // The device claims AdjustableDpi but exposes no sensor — it cannot
+ // report DPI, and that won't change on retry.
+ return Err(WriteError::FeatureUnsupported {
+ feature_hex: AdjustableDpiFeature::ID,
+ });
+ }
+ let current = feature
+ .get_sensor_dpi(0)
+ .await
+ .map_err(classify_dpi_error)?;
+ let values = feature
+ .get_sensor_dpi_list(0)
+ .await
+ .map_err(classify_dpi_error)?;
+ Ok(DpiInfo {
+ current,
+ capabilities: DpiCapabilities::new(values)?,
+ })
+}
+
/// Set sensor 0's DPI for the device addressed by `route`.
pub async fn set_dpi(route: &DeviceRoute, dpi: u16) -> Result<(), WriteError> {
let index = route.device_index();
diff --git a/crates/openlogi-hid/src/write/error.rs b/crates/openlogi-hid/src/write/error.rs
index 5d4fee7b169a45ec3e31bcc0db65c03f0ed544c5..0fa3b753a76ffdb607c6ac7d7828dbbc640db73f 100644
--- a/crates/openlogi-hid/src/write/error.rs
+++ b/crates/openlogi-hid/src/write/error.rs
@@ -73,6 +73,23 @@ pub enum WriteError {
/// Background agent write path is unavailable.
#[error("background agent is unavailable")]
AgentUnavailable,
+ /// A standalone light value is outside the driver's supported range.
+ #[error("invalid light value for {control}: {value}")]
+ InvalidLightValue {
+ /// Semantic control name.
+ control: String,
+ /// Rejected value in the semantic/native unit supplied by the caller.
+ value: u16,
+ },
+ /// The selected light driver does not implement a requested control.
+ #[error("light control is unsupported: {control}")]
+ LightUnsupported {
+ /// Semantic control name.
+ control: String,
+ },
+ /// Multiple raw HID nodes matched one physical route.
+ #[error("multiple raw HID devices matched the route")]
+ AmbiguousRawDevice,
}
/// HID++ operation being performed when a device write/read failed.
@@ -100,6 +117,14 @@ pub enum HidppOperation {
ReadWheelMode,
/// Write and verify the native HiResWheel mode.
WriteWheelMode,
+ /// Read the keyboard backlight config or info.
+ ReadBacklight,
+ /// Write the keyboard backlight config.
+ WriteBacklight,
+ /// Write keyboard Fn-lock (fn inversion).
+ WriteFnLock,
+ /// Write a standalone-light command. Appended last — variant order is wire format.
+ Light,
}
/// HID++ feature error kind in a serializable wire-safe form.
diff --git a/crates/openlogi-hid/src/write/fn_lock.rs b/crates/openlogi-hid/src/write/fn_lock.rs
new file mode 100644
index 0000000000000000000000000000000000000000..a1876a9a9c660cdd602f6dc3e9aa5cad0bc30f34
--- /dev/null
+++ b/crates/openlogi-hid/src/write/fn_lock.rs
@@ -0,0 +1,105 @@
+//! HID++ keyboard Fn-lock writes — fn inversion `0x40a3` (multi-host), with
+//! the single-host `0x40a2` as fallback.
+//!
+//! "Fn-lock on" means the F-row sends plain F1–F12 without holding Fn
+//! ([`FnInversionState::On`]); off restores the printed media/shortcut
+//! functions, with Fn+key producing the F-keys. Multi-host keyboards store the
+//! state per Easy-Switch slot, so the `0x40a3` path addresses
+//! [`HostIndex::Current`] — the slot the keyboard is talking to right now.
+
+use std::sync::Arc;
+
+use hidpp::{
+ channel::HidppChannel,
+ device::Device,
+ feature::{
+ fn_inversion::{
+ FnInversionMultiHostFeature, FnInversionState, FnInversionWithDefaultStateFeature,
+ },
+ hosts_info::HostIndex,
+ },
+};
+use tracing::debug;
+
+use crate::route::DeviceRoute;
+
+use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
+
+/// Whether a failure to open the `0x40a3` multi-host feature should trigger
+/// the `0x40a2` single-host fallback. Only a missing-`0x40a3` feature
+/// qualifies; transport and protocol errors propagate unchanged.
+fn is_missing_multi_host(err: &WriteError) -> bool {
+ matches!(
+ err,
+ WriteError::FeatureUnsupported { feature_hex } if *feature_hex == 0x40a3
+ )
+}
+
+/// Whichever fn-inversion feature the keyboard exposes, normalised onto one
+/// setter. Multi-host boards (Easy-Switch) carry `0x40a3`; single-host boards
+/// carry `0x40a2`.
+enum FnInversion {
+ /// `0x40a3 FnInversionForMultiHostDevices`.
+ MultiHost(Arc<FnInversionMultiHostFeature>),
+ /// `0x40a2 FnInversionWithDefaultState`.
+ SingleHost(Arc<FnInversionWithDefaultStateFeature>),
+}
+
+impl FnInversion {
+ /// Open whichever fn-inversion feature the device exposes. Tries `0x40a3`
+ /// first; on a missing-`0x40a3` error (and only that), retries with
+ /// `0x40a2`.
+ async fn open(device: &mut Device) -> Result<Self, WriteError> {
+ match open_feature::<FnInversionMultiHostFeature>(device).await {
+ Ok(feature) => Ok(Self::MultiHost(feature)),
+ Err(err) if is_missing_multi_host(&err) => {
+ let feature = open_feature::<FnInversionWithDefaultStateFeature>(device).await?;
+ Ok(Self::SingleHost(feature))
+ }
+ Err(err) => Err(err),
+ }
+ }
+
+ /// Write the inversion state (for the current host on `0x40a3`).
+ async fn set(&self, state: FnInversionState) -> Result<(), WriteError> {
+ match self {
+ Self::MultiHost(feature) => {
+ feature
+ .set_global_fn_inversion(HostIndex::Current, state)
+ .await
+ .map_err(|e| classify_hidpp_error(e, HidppOperation::WriteFnLock, 0x40a3))?;
+ }
+ Self::SingleHost(feature) => {
+ feature
+ .set_global_fn_inversion(state)
+ .await
+ .map_err(|e| classify_hidpp_error(e, HidppOperation::WriteFnLock, 0x40a2))?;
+ }
+ }
+ Ok(())
+ }
+}
+
+/// Write the keyboard's Fn-lock state: `true` = F-row sends F1–F12 directly.
+pub async fn set_fn_lock(route: &DeviceRoute, on: bool) -> Result<(), WriteError> {
+ let index = route.device_index();
+ with_route(route, move |channel| async move {
+ set_fn_lock_on_channel(&channel, index, on).await
+ })
+ .await
+}
+
+/// The Fn-lock write itself, on an already-open channel at HID++ `index`.
+pub(super) async fn set_fn_lock_on_channel(
+ channel: &Arc<HidppChannel>,
+ index: u8,
+ on: bool,
+) -> Result<(), WriteError> {
+ let mut device = Device::new(Arc::clone(channel), index)
+ .await
+ .map_err(|_| WriteError::DeviceUnreachable { index })?;
+ let fn_inversion = FnInversion::open(&mut device).await?;
+ fn_inversion.set(FnInversionState::from(on)).await?;
+ debug!(index, on, "fn-lock written");
+ Ok(())
+}
diff --git a/crates/openlogi-hid/src/write/lighting.rs b/crates/openlogi-hid/src/write/lighting.rs
index 41e7bad42ce9d717b223512ad72cfda4de405967..6f1c75550abd0db3b6bf6ef17da2735e201bf983 100644
--- a/crates/openlogi-hid/src/write/lighting.rs
+++ b/crates/openlogi-hid/src/write/lighting.rs
@@ -1,7 +1,8 @@
+use std::sync::Arc;
use std::time::Duration;
-use async_hid::AsyncHidWrite;
use hidpp::{
+ channel::{ChannelError, HidppChannel},
device::Device,
feature::{
CreatableFeature,
@@ -89,16 +90,31 @@ pub async fn set_keyboard_color_with(
r: u8,
g: u8,
b: u8,
+) -> Result<(), WriteError> {
+ let device_index = route.device_index();
+ with_route(route, move |channel| async move {
+ set_keyboard_color_with_on_channel(&channel, device_index, method, r, g, b).await
+ })
+ .await
+}
+
+pub(super) async fn set_keyboard_color_with_on_channel(
+ channel: &Arc<HidppChannel>,
+ device_index: u8,
+ method: LightingMethod,
+ r: u8,
+ g: u8,
+ b: u8,
) -> Result<(), WriteError> {
match method {
- LightingMethod::PerKey => set_color_per_key(route, r, g, b).await,
- LightingMethod::Effects => set_color_effects(route, r, g, b).await,
- LightingMethod::Auto => match set_color_effects(route, r, g, b).await {
+ LightingMethod::PerKey => set_color_per_key(channel, device_index, r, g, b).await,
+ LightingMethod::Effects => set_color_effects(channel, device_index, r, g, b).await,
+ LightingMethod::Auto => match set_color_effects(channel, device_index, r, g, b).await {
Err(WriteError::FeatureUnsupported { feature_hex })
if feature_hex == COLOR_LED_EFFECTS_FEATURE =>
{
debug!("no 0x8070 effect engine — falling back to 0x8080 per-key");
- set_color_per_key(route, r, g, b).await
+ set_color_per_key(channel, device_index, r, g, b).await
}
other => other,
},
@@ -109,24 +125,21 @@ pub async fn set_keyboard_color_with(
/// when the device doesn't expose it; the index differs per device, so callers
/// can't hard-code it.
async fn resolve_feature_index(
- route: &DeviceRoute,
+ channel: &Arc<HidppChannel>,
+ device_index: u8,
feature_id: u16,
) -> Result<Option<u8>, WriteError> {
- let device_index = route.device_index();
- with_route(route, move |channel| async move {
- let device = Device::new(std::sync::Arc::clone(&channel), device_index)
- .await
- .map_err(|_| WriteError::DeviceUnreachable {
- index: device_index,
- })?;
- let info = device
- .root()
- .get_feature(feature_id)
- .await
- .map_err(|e| classify_hidpp_error(e, HidppOperation::ResolveFeature, feature_id))?;
- Ok(info.map(|i| i.index))
- })
- .await
+ let device = Device::new(Arc::clone(channel), device_index)
+ .await
+ .map_err(|_| WriteError::DeviceUnreachable {
+ index: device_index,
+ })?;
+ let info = device
+ .root()
+ .get_feature(feature_id)
+ .await
+ .map_err(|e| classify_hidpp_error(e, HidppOperation::ResolveFeature, feature_id))?;
+ Ok(info.map(|i| i.index))
}
/// Set a solid colour via `ColorLedEffects` (`0x8070`): a fixed effect per zone,
@@ -137,54 +150,56 @@ async fn resolve_feature_index(
/// first so only existing zones are driven (a typed `set_zone_effect` awaits the
/// device's reply, so unlike the former raw fire-and-forget path a write to a
/// non-existent zone would surface as an error rather than a silent no-op).
-async fn set_color_effects(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> {
- let index = route.device_index();
- with_route(route, move |channel| async move {
- let mut device = Device::new(std::sync::Arc::clone(&channel), index)
- .await
- .map_err(|_| WriteError::DeviceUnreachable { index })?;
- let feature = open_feature::<ColorLedEffectsFeature>(&mut device).await?;
- let zone_count = feature
- .get_info()
- .await
- .map_err(classify_lighting_error)?
- .zone_count;
-
- let mut params = [0u8; ZONE_EFFECT_PARAM_COUNT];
- params[0] = r;
- params[1] = g;
- params[2] = b;
- let zones_to_write = if zone_count == 0 {
- debug!(
- index,
- "0x8070 reported zero zones; applying legacy 4-zone fallback"
- );
- MAX_COLOR_LED_EFFECT_ZONES
- } else {
- zone_count.min(MAX_COLOR_LED_EFFECT_ZONES)
- };
- if zone_count > MAX_COLOR_LED_EFFECT_ZONES {
- debug!(
- index,
- zone_count,
- capped_zone_count = MAX_COLOR_LED_EFFECT_ZONES,
- "0x8070 zone count capped to legacy write limit"
- );
- }
- for zone in 0..zones_to_write {
- feature
- .set_zone_effect(zone, EFFECT_FIXED, params, Persistence::Volatile)
- .await
- .map_err(classify_lighting_error)?;
- tokio::time::sleep(FRAME_GAP).await;
- }
+async fn set_color_effects(
+ channel: &Arc<HidppChannel>,
+ index: u8,
+ r: u8,
+ g: u8,
+ b: u8,
+) -> Result<(), WriteError> {
+ let mut device = Device::new(Arc::clone(channel), index)
+ .await
+ .map_err(|_| WriteError::DeviceUnreachable { index })?;
+ let feature = open_feature::<ColorLedEffectsFeature>(&mut device).await?;
+ let zone_count = feature
+ .get_info()
+ .await
+ .map_err(classify_lighting_error)?
+ .zone_count;
+
+ let mut params = [0u8; ZONE_EFFECT_PARAM_COUNT];
+ params[0] = r;
+ params[1] = g;
+ params[2] = b;
+ let zones_to_write = if zone_count == 0 {
debug!(
index,
- zone_count, zones_to_write, r, g, b, "set keyboard colour via typed 0x8070"
+ "0x8070 reported zero zones; applying legacy 4-zone fallback"
);
- Ok(())
- })
- .await
+ MAX_COLOR_LED_EFFECT_ZONES
+ } else {
+ zone_count.min(MAX_COLOR_LED_EFFECT_ZONES)
+ };
+ if zone_count > MAX_COLOR_LED_EFFECT_ZONES {
+ debug!(
+ index,
+ zone_count,
+ capped_zone_count = MAX_COLOR_LED_EFFECT_ZONES,
+ "0x8070 zone count capped to legacy write limit"
+ );
+ }
+ for zone in 0..zones_to_write {
+ feature
+ .set_zone_effect(zone, EFFECT_FIXED, params, Persistence::Volatile)
+ .await
+ .map_err(classify_lighting_error)?;
+ tokio::time::sleep(FRAME_GAP).await;
+ }
+ debug!(
+ index,
+ zone_count, zones_to_write, r, g, b, "set keyboard colour via typed 0x8070"
+ );
+ Ok(())
}
/// Classify a HID++ error from the `ColorLedEffects` functions.
@@ -195,17 +210,46 @@ fn classify_lighting_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteEr
/// Set a solid colour via `PerKeyLighting` (`0x8080`): stream every key's colour
/// in 64-byte `0x12` frames, then commit. `FeatureUnsupported` when the device
/// exposes no `0x8080`.
-async fn set_color_per_key(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> {
- let device_index = route.device_index();
- let feature_index = resolve_feature_index(route, PER_KEY_LIGHTING_FEATURE)
+async fn set_color_per_key(
+ channel: &Arc<HidppChannel>,
+ device_index: u8,
+ r: u8,
+ g: u8,
+ b: u8,
+) -> Result<(), WriteError> {
+ let feature_index = resolve_feature_index(channel, device_index, PER_KEY_LIGHTING_FEATURE)
.await?
.ok_or(WriteError::FeatureUnsupported {
feature_hex: PER_KEY_LIGHTING_FEATURE,
})?;
- let Some(mut writer) = crate::transport::open_route_writer(route).await? else {
- return Err(WriteError::DeviceNotFound);
- };
+ for report in per_key_reports(device_index, feature_index, r, g, b) {
+ let written = channel
+ .write_raw_report(&report)
+ .await
+ .map_err(classify_raw_lighting_error)?;
+ if written != report.len() {
+ return Err(WriteError::Hidpp(format!(
+ "raw lighting report wrote {written} of {} bytes",
+ report.len()
+ )));
+ }
+ }
+ debug!(
+ device_index,
+ feature_index, r, g, b, "set keyboard colour via 0x8080"
+ );
+ Ok(())
+}
+
+pub(super) fn per_key_reports(
+ device_index: u8,
+ feature_index: u8,
+ r: u8,
+ g: u8,
+ b: u8,
+) -> Vec<Vec<u8>> {
+ let mut reports = Vec::new();
// Each 64-byte `0x12` "set group keys" packet carries up to 14
// `(keyID, R, G, B)` entries; keyIDs are HID usage codes. Cover the whole
// keyboard usage range (incl. modifiers at `0xe0..`) so every key lights,
@@ -226,23 +270,22 @@ async fn set_color_per_key(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(
rep[off + 2] = g;
rep[off + 3] = b;
}
- writer
- .write_output_report(&rep)
- .await
- .map_err(WriteError::from)?;
+ reports.push(rep);
}
let mut commit = vec![0u8; 20];
commit[0] = REPORT_LONG;
commit[1] = device_index;
commit[2] = feature_index;
commit[3] = (FN_FRAME_END << 4) | SW_ID;
- writer
- .write_output_report(&commit)
- .await
- .map_err(WriteError::from)?;
- debug!(
- device_index,
- feature_index, r, g, b, "set keyboard colour via 0x8080"
- );
- Ok(())
+ reports.push(commit);
+ reports
+}
+
+fn classify_raw_lighting_error(error: ChannelError) -> WriteError {
+ match error {
+ ChannelError::Timeout => WriteError::RequestTimedOut {
+ operation: HidppOperation::Lighting,
+ },
+ other => WriteError::Hidpp(format!("{other:?}")),
+ }
}
diff --git a/crates/openlogi-hid/src/write/litra.rs b/crates/openlogi-hid/src/write/litra.rs
new file mode 100644
index 0000000000000000000000000000000000000000..9bbaea9a737e0c08ae13d574cb610a5ee26a0785
--- /dev/null
+++ b/crates/openlogi-hid/src/write/litra.rs
@@ -0,0 +1,416 @@
+//! Raw HID driver for Logitech Litra lights.
+//!
+//! Litra is deliberately implemented beside, not inside, the HID++ feature
+//! writers. The driver owns product matching, semantic-range conversion, and
+//! the fixed report encoding; the generic transport only owns enumeration and
+//! opening the selected raw HID node.
+
+use std::collections::HashMap;
+use std::sync::{Arc, LazyLock};
+use std::time::Duration;
+
+use async_hid::AsyncHidWrite as _;
+use openlogi_core::device::{LightCapabilities, LightValueRange, LightValueUnit};
+use serde::{Deserialize, Serialize};
+use tokio::sync::{Mutex, OwnedMutexGuard};
+use tracing::debug;
+
+use crate::route::DeviceRoute;
+
+use super::WriteError;
+
+/// Logitech vendor ID.
+pub const LOGITECH_VENDOR_ID: u16 = 0x046d;
+/// Stable driver-family identifier carried by standalone inventory records.
+pub const LITRA_DRIVER_ID: &str = "litra";
+/// Litra Glow product ID.
+pub const LITRA_GLOW_PRODUCT_ID: u16 = 0xc900;
+/// Litra Beam product ID.
+pub const LITRA_BEAM_PRODUCT_ID: u16 = 0xc901;
+/// Litra vendor usage page.
+pub const LITRA_USAGE_PAGE: u16 = 0xff43;
+/// Litra Glow usage ID.
+pub const LITRA_USAGE_ID: u16 = 0x0202;
+
+const REPORT_LEN: usize = 20;
+const REPORT_ID: u8 = 0x11;
+const REPORT_PREFIX: [u8; 2] = [0xff, 0x04];
+const COMMAND_POWER: u8 = 0x1c;
+const COMMAND_BRIGHTNESS: u8 = 0x4c;
+const COMMAND_TEMPERATURE: u8 = 0x9c;
+const MIN_BRIGHTNESS_LUMENS: u16 = 20;
+const MAX_BRIGHTNESS_LUMENS: u16 = 250;
+const MIN_TEMPERATURE_KELVIN: u16 = 2700;
+const MAX_TEMPERATURE_KELVIN: u16 = 6500;
+const TEMPERATURE_STEP_KELVIN: u16 = 100;
+const RAW_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
+
+const fn validated_range(min: u16, max: u16, step: u16, unit: LightValueUnit) -> LightValueRange {
+ match LightValueRange::new(min, max, step, unit) {
+ Ok(range) => range,
+ Err(_) => panic!("invalid static Litra capability range"),
+ }
+}
+
+const GLOW_BRIGHTNESS_RANGE: LightValueRange = validated_range(
+ MIN_BRIGHTNESS_LUMENS,
+ MAX_BRIGHTNESS_LUMENS,
+ 1,
+ LightValueUnit::Lumens,
+);
+const GLOW_TEMPERATURE_RANGE: LightValueRange = validated_range(
+ MIN_TEMPERATURE_KELVIN,
+ MAX_TEMPERATURE_KELVIN,
+ TEMPERATURE_STEP_KELVIN,
+ LightValueUnit::Kelvin,
+);
+
+/// A supported Litra product family variant.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum LitraModel {
+ /// Logitech Litra Glow.
+ Glow,
+ /// Logitech Litra Beam.
+ Beam,
+}
+
+impl LitraModel {
+ /// Resolve a Litra model from its USB product ID.
+ #[must_use]
+ pub const fn from_product_id(product_id: u16) -> Option<Self> {
+ match product_id {
+ LITRA_GLOW_PRODUCT_ID => Some(Self::Glow),
+ LITRA_BEAM_PRODUCT_ID => Some(Self::Beam),
+ _ => None,
+ }
+ }
+
+ /// Resolve a model only when the complete raw-HID route matches a known
+ /// Litra interface. Product ID alone is insufficient protection against
+ /// writing a vendor report to an unrelated HID collection.
+ #[must_use]
+ pub fn from_route(route: &DeviceRoute) -> Option<Self> {
+ let DeviceRoute::RawHid {
+ vendor_id,
+ product_id,
+ usage_page,
+ usage_id,
+ ..
+ } = route
+ else {
+ return None;
+ };
+ matches_litra(*vendor_id, *product_id, *usage_page, *usage_id)
+ .then(|| Self::from_product_id(*product_id))
+ .flatten()
+ }
+
+ /// Stable driver-family identifier for this model.
+ #[must_use]
+ pub const fn driver_id(self) -> &'static str {
+ LITRA_DRIVER_ID
+ }
+
+ /// Exact model identifier used by the OpenLogi asset registry.
+ #[must_use]
+ pub const fn registry_model_id(self) -> Option<&'static str> {
+ match self {
+ Self::Glow => Some("8c900"),
+ Self::Beam => Some("8c901"),
+ }
+ }
+
+ /// Static capabilities exposed by the model.
+ #[must_use]
+ pub const fn capabilities(self) -> LightCapabilities {
+ match self {
+ Self::Glow | Self::Beam => LightCapabilities {
+ power: true,
+ brightness: Some(GLOW_BRIGHTNESS_RANGE),
+ temperature: Some(GLOW_TEMPERATURE_RANGE),
+ color: false,
+ zones: false,
+ },
+ }
+ }
+}
+
+/// Whether an HID descriptor identifies a supported Litra interface.
+#[must_use]
+pub fn matches_litra(vendor_id: u16, product_id: u16, usage_page: u16, usage_id: u16) -> bool {
+ vendor_id == LOGITECH_VENDOR_ID
+ && usage_page == LITRA_USAGE_PAGE
+ && usage_id == LITRA_USAGE_ID
+ && LitraModel::from_product_id(product_id).is_some()
+}
+
+/// A semantic command accepted by the standalone-light layer.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum LightCommand {
+ /// Turn the light on or off.
+ Power(bool),
+ /// Set normalized brightness from 0 to 100 percent.
+ BrightnessPercent(u8),
+ /// Set colour temperature in Kelvin.
+ TemperatureKelvin(u16),
+ /// Set brightness in the native unit advertised by the selected model.
+ /// This is primarily a diagnostic/CLI convenience; persisted settings
+ /// remain normalized percentages.
+ BrightnessNative(u16),
+}
+
+/// Encode a semantic command into the exact fixed-width Litra report.
+pub fn encode_command(
+ model: LitraModel,
+ command: LightCommand,
+) -> Result<[u8; REPORT_LEN], WriteError> {
+ let mut report = [0; REPORT_LEN];
+ report[0] = REPORT_ID;
+ report[1..3].copy_from_slice(&REPORT_PREFIX);
+ match command {
+ LightCommand::Power(enabled) => {
+ report[3] = COMMAND_POWER;
+ report[4] = u8::from(enabled);
+ }
+ LightCommand::BrightnessPercent(percent) => {
+ report[3] = COMMAND_BRIGHTNESS;
+ let range = model
+ .capabilities()
+ .brightness
+ .ok_or_else(|| unsupported("brightness"))?;
+ let lumens = percent_to_native(percent, range)?;
+ report[4..6].copy_from_slice(&lumens.to_be_bytes());
+ }
+ LightCommand::TemperatureKelvin(kelvin) => {
+ report[3] = COMMAND_TEMPERATURE;
+ let range = model
+ .capabilities()
+ .temperature
+ .ok_or_else(|| unsupported("temperature"))?;
+ if !range.contains(kelvin) {
+ return Err(WriteError::InvalidLightValue {
+ control: "temperature_kelvin".into(),
+ value: kelvin,
+ });
+ }
+ report[4..6].copy_from_slice(&kelvin.to_be_bytes());
+ }
+ LightCommand::BrightnessNative(value) => {
+ report[3] = COMMAND_BRIGHTNESS;
+ let range = model
+ .capabilities()
+ .brightness
+ .ok_or_else(|| unsupported("brightness"))?;
+ if !range.contains(value) {
+ return Err(WriteError::InvalidLightValue {
+ control: "brightness_native".into(),
+ value,
+ });
+ }
+ report[4..6].copy_from_slice(&value.to_be_bytes());
+ }
+ }
+ Ok(report)
+}
+
+fn percent_to_native(percent: u8, range: LightValueRange) -> Result<u16, WriteError> {
+ if percent > 100 {
+ return Err(WriteError::InvalidLightValue {
+ control: "brightness_percent".into(),
+ value: u16::from(percent),
+ });
+ }
+ range
+ .native_for_percent(percent)
+ .ok_or_else(|| WriteError::InvalidLightValue {
+ control: "brightness_percent".into(),
+ value: percent.into(),
+ })
+}
+
+fn unsupported(control: &str) -> WriteError {
+ WriteError::LightUnsupported {
+ control: control.into(),
+ }
+}
+
+static DEVICE_LOCKS: LazyLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> =
+ LazyLock::new(|| Mutex::new(HashMap::new()));
+
+async fn device_lock(route: &DeviceRoute) -> OwnedMutexGuard<()> {
+ let key = route.to_string();
+ let lock = {
+ let mut locks = DEVICE_LOCKS.lock().await;
+ Arc::clone(locks.entry(key).or_insert_with(|| Arc::new(Mutex::new(()))))
+ };
+ lock.lock_owned().await
+}
+
+/// Apply a semantic Litra command through a raw HID route.
+pub async fn apply(
+ route: &DeviceRoute,
+ model: LitraModel,
+ command: LightCommand,
+) -> Result<(), WriteError> {
+ let Some(route_model) = LitraModel::from_route(route) else {
+ return Err(unsupported("raw_hid_route"));
+ };
+ if route_model != model {
+ return Err(unsupported("litra_model"));
+ }
+ let report = encode_command(model, command)?;
+ let _guard = device_lock(route).await;
+ let Some(mut writer) = crate::transport::open_route_writer(route).await? else {
+ return Err(WriteError::DeviceNotFound);
+ };
+ tokio::time::timeout(RAW_WRITE_TIMEOUT, writer.write_output_report(&report))
+ .await
+ .map_err(|_| WriteError::RequestTimedOut {
+ operation: super::HidppOperation::Light,
+ })?
+ .map_err(WriteError::from)?;
+ debug!(route = %route, "applied raw Litra command");
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(
+ clippy::expect_used,
+ reason = "expect is idiomatic in pure encoding tests"
+ )]
+
+ use std::assert_matches;
+
+ use super::{
+ COMMAND_BRIGHTNESS, COMMAND_POWER, COMMAND_TEMPERATURE, LITRA_BEAM_PRODUCT_ID,
+ LightCommand, LitraModel, REPORT_ID, encode_command, matches_litra,
+ };
+ use crate::{DeviceRoute, WriteError};
+
+ #[test]
+ fn glow_power_reports_are_fixed_width() {
+ let on = encode_command(LitraModel::Glow, LightCommand::Power(true)).expect("valid");
+ let off = encode_command(LitraModel::Glow, LightCommand::Power(false)).expect("valid");
+ assert_eq!(&on[..5], &[REPORT_ID, 0xff, 0x04, COMMAND_POWER, 1]);
+ assert_eq!(&off[..5], &[REPORT_ID, 0xff, 0x04, COMMAND_POWER, 0]);
+ assert_eq!(on.len(), 20);
+ assert!(on[5..].iter().all(|byte| *byte == 0));
+ assert!(off[5..].iter().all(|byte| *byte == 0));
+ }
+
+ #[test]
+ fn glow_brightness_uses_big_endian_native_lumens() {
+ let report =
+ encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(50)).expect("valid");
+ assert_eq!(&report[3..6], &[COMMAND_BRIGHTNESS, 0, 0x87]);
+ }
+
+ #[test]
+ fn glow_brightness_maps_normalized_boundaries_to_native_range() {
+ let minimum =
+ encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(0)).expect("valid");
+ let maximum =
+ encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(100)).expect("valid");
+ assert_eq!(&minimum[3..6], &[COMMAND_BRIGHTNESS, 0, 20]);
+ assert_eq!(&maximum[3..6], &[COMMAND_BRIGHTNESS, 0, 250]);
+ }
+
+ #[test]
+ fn glow_native_brightness_preserves_the_exact_requested_lumens() {
+ let report =
+ encode_command(LitraModel::Glow, LightCommand::BrightnessNative(136)).expect("valid");
+ assert_eq!(&report[3..6], &[COMMAND_BRIGHTNESS, 0, 136]);
+ assert_matches!(
+ encode_command(LitraModel::Glow, LightCommand::BrightnessNative(251)),
+ Err(WriteError::InvalidLightValue { .. })
+ );
+ }
+
+ #[test]
+ fn glow_temperature_uses_big_endian_kelvin() {
+ let report =
+ encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(4600)).expect("valid");
+ assert_eq!(&report[3..6], &[COMMAND_TEMPERATURE, 0x11, 0xf8]);
+ }
+
+ #[test]
+ fn glow_temperature_accepts_only_aligned_inclusive_boundaries() {
+ assert!(encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(2700)).is_ok());
+ assert!(encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(6500)).is_ok());
+ for invalid in [2600, 2750, 6600] {
+ assert_matches!(
+ encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(invalid)),
+ Err(WriteError::InvalidLightValue { .. })
+ );
+ }
+ }
+
+ #[test]
+ fn invalid_values_are_rejected() {
+ assert_matches!(
+ encode_command(LitraModel::Glow, LightCommand::BrightnessPercent(101)),
+ Err(WriteError::InvalidLightValue { .. })
+ );
+ assert_matches!(
+ encode_command(LitraModel::Glow, LightCommand::TemperatureKelvin(2750)),
+ Err(WriteError::InvalidLightValue { .. })
+ );
+ }
+
+ #[test]
+ fn matcher_requires_the_full_identity_tuple() {
+ assert!(matches_litra(0x046d, 0xc900, 0xff43, 0x0202));
+ assert!(!matches_litra(0x046d, 0xc900, 0xff43, 0x0203));
+ assert!(!matches_litra(0x046d, 0xc902, 0xff43, 0x0202));
+ assert!(!matches_litra(0x1234, 0xc900, 0xff43, 0x0202));
+ }
+
+ #[test]
+ fn glow_descriptor_exposes_driver_identity() {
+ assert_eq!(LitraModel::Glow.driver_id(), "litra");
+ }
+
+ #[test]
+ fn registry_model_ids_match_the_asset_registry() {
+ assert_eq!(LitraModel::Glow.registry_model_id(), Some("8c900"));
+ assert_eq!(LitraModel::Beam.registry_model_id(), Some("8c901"));
+ }
+
+ #[test]
+ fn beam_is_matched_by_its_complete_route_tuple() {
+ assert!(matches_litra(0x046d, 0xc901, 0xff43, 0x0202));
+ assert_eq!(
+ LitraModel::from_product_id(LITRA_BEAM_PRODUCT_ID),
+ Some(LitraModel::Beam)
+ );
+ }
+
+ #[test]
+ fn model_resolution_requires_the_complete_raw_route_tuple() {
+ let valid = DeviceRoute::RawHid {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0202,
+ identity: "serial:test".into(),
+ };
+ let wrong_usage = DeviceRoute::RawHid {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ usage_page: 0xff43,
+ usage_id: 0x0203,
+ identity: "serial:test".into(),
+ };
+
+ assert_eq!(LitraModel::from_route(&valid), Some(LitraModel::Glow));
+ assert_eq!(LitraModel::from_route(&wrong_usage), None);
+ assert_eq!(
+ LitraModel::from_route(&DeviceRoute::Direct {
+ vendor_id: 0x046d,
+ product_id: 0xc900,
+ }),
+ None
+ );
+ }
+}
diff --git a/crates/openlogi-hid/src/write/shared.rs b/crates/openlogi-hid/src/write/shared.rs
index aa7a6110707b52cd047bef17ec65597163fb8371..73dd55f3c7c3d37834aa2835e9d7a23ade7ad675 100644
--- a/crates/openlogi-hid/src/write/shared.rs
+++ b/crates/openlogi-hid/src/write/shared.rs
@@ -4,18 +4,22 @@ use hidpp::channel::HidppChannel;
use crate::route::DeviceRoute;
use crate::smartshift::SmartShiftMode;
+use crate::smartshift::SmartShiftStatus;
use super::WriteError;
-use super::dpi::set_dpi_on_channel;
-use super::smartshift::{set_smartshift_on_channel, toggle_smartshift_on_channel};
+use super::dpi::{DpiInfo, get_dpi_info_on_channel, set_dpi_on_channel};
+use super::fn_lock::set_fn_lock_on_channel;
+use super::lighting::{LightingMethod, set_keyboard_color_with_on_channel};
+use super::smartshift::{
+ get_smartshift_status_on_channel, set_smartshift_on_channel, toggle_smartshift_on_channel,
+};
-/// An open HID++ channel to a device, shared so DPI / SmartShift writes can
-/// reuse the capture session's connection instead of re-enumerating and
-/// opening a fresh channel each time (which costs ~100ms+).
+/// An open HID++ channel to a device, shared so route-addressed reads and writes
+/// can reuse an inventory- or capture-owned connection instead of
+/// re-enumerating and opening a fresh channel each time (which costs ~100ms+).
///
/// Cheap to clone (an `Arc` plus the [`DeviceRoute`] it points at). Built by
-/// the capture session via `SharedChannel::new` and stashed in a slot the
-/// GUI's write path consults.
+/// the inventory registry or a standalone capture session.
#[derive(Clone)]
pub struct SharedChannel {
channel: Arc<HidppChannel>,
@@ -51,11 +55,29 @@ pub async fn set_dpi_on(shared: &SharedChannel, dpi: u16) -> Result<(), WriteErr
set_dpi_on_channel(&shared.channel, shared.route.device_index(), dpi).await
}
+/// Read current DPI and supported values on an already-open [`SharedChannel`].
+pub async fn get_dpi_info_on(shared: &SharedChannel) -> Result<DpiInfo, WriteError> {
+ get_dpi_info_on_channel(&shared.channel, shared.route.device_index()).await
+}
+
/// Toggle SmartShift on an already-open [`SharedChannel`].
pub async fn toggle_smartshift_on(shared: &SharedChannel) -> Result<SmartShiftMode, WriteError> {
toggle_smartshift_on_channel(&shared.channel, shared.route.device_index()).await
}
+/// Read SmartShift mode and sensitivity on an already-open [`SharedChannel`].
+pub async fn get_smartshift_status_on(
+ shared: &SharedChannel,
+) -> Result<SmartShiftStatus, WriteError> {
+ get_smartshift_status_on_channel(&shared.channel, shared.route.device_index()).await
+}
+
+/// Write keyboard Fn-lock on an already-open [`SharedChannel`] — the fast
+/// path that skips enumeration and channel setup.
+pub async fn set_fn_lock_on(shared: &SharedChannel, on: bool) -> Result<(), WriteError> {
+ set_fn_lock_on_channel(&shared.channel, shared.route.device_index(), on).await
+}
+
/// Write a full SmartShift configuration on an already-open [`SharedChannel`]
/// — the fast path that skips enumeration and channel setup.
pub async fn set_smartshift_on(
@@ -73,3 +95,34 @@ pub async fn set_smartshift_on(
)
.await
}
+
+/// Set a solid keyboard colour on an already-open [`SharedChannel`], using
+/// [`LightingMethod::Auto`].
+pub async fn set_keyboard_color_on(
+ shared: &SharedChannel,
+ r: u8,
+ g: u8,
+ b: u8,
+) -> Result<(), WriteError> {
+ set_keyboard_color_with_on(shared, LightingMethod::Auto, r, g, b).await
+}
+
+/// Set a solid keyboard colour on an already-open [`SharedChannel`] with an
+/// explicit lighting method.
+pub async fn set_keyboard_color_with_on(
+ shared: &SharedChannel,
+ method: LightingMethod,
+ r: u8,
+ g: u8,
+ b: u8,
+) -> Result<(), WriteError> {
+ set_keyboard_color_with_on_channel(
+ &shared.channel,
+ shared.route.device_index(),
+ method,
+ r,
+ g,
+ b,
+ )
+ .await
+}
diff --git a/crates/openlogi-hid/src/write/smartshift.rs b/crates/openlogi-hid/src/write/smartshift.rs
index dd566d2140fd7f6aba9b264c978b25055894a8dd..60a66cdff7df4dde34e80df2b9a260be5397298e 100644
--- a/crates/openlogi-hid/src/write/smartshift.rs
+++ b/crates/openlogi-hid/src/write/smartshift.rs
@@ -241,15 +241,22 @@ impl SmartShift {
pub async fn get_smartshift_status(route: &DeviceRoute) -> Result<SmartShiftStatus, WriteError> {
let index = route.device_index();
with_route(route, move |channel| async move {
- let mut device = Device::new(Arc::clone(&channel), index)
- .await
- .map_err(|_| WriteError::DeviceUnreachable { index })?;
- let smartshift = SmartShift::open(&mut device).await?;
- smartshift.status().await
+ get_smartshift_status_on_channel(&channel, index).await
})
.await
}
+pub(super) async fn get_smartshift_status_on_channel(
+ channel: &Arc<HidppChannel>,
+ index: u8,
+) -> Result<SmartShiftStatus, WriteError> {
+ let mut device = Device::new(Arc::clone(channel), index)
+ .await
+ .map_err(|_| WriteError::DeviceUnreachable { index })?;
+ let smartshift = SmartShift::open(&mut device).await?;
+ smartshift.status().await
+}
+
/// Set the SmartShift auto-disengage sensitivity on `route`, preserving the
/// current mode. Returns the read-back status after the write so the caller can
/// display and verify it.
diff --git a/crates/openlogi-hid/src/write/tests.rs b/crates/openlogi-hid/src/write/tests.rs
index 57dd8aa4161176a77fcb45360e71827b2632bb66..f6261025e9ee45af28d8359b2f9699975b5889d3 100644
--- a/crates/openlogi-hid/src/write/tests.rs
+++ b/crates/openlogi-hid/src/write/tests.rs
@@ -1,16 +1,41 @@
use std::assert_matches;
+use std::error::Error;
+use std::io;
+use std::sync::{Arc, Mutex, PoisonError};
use super::*;
+use hidpp::channel::{HidppChannel, RawHidChannel};
use hidpp::feature::smartshift::WheelMode;
+use openlogi_core::config::LightSettings;
+use openlogi_core::device::{LightCapabilities, LightValueRange, LightValueUnit};
+use tokio::sync::mpsc;
use crate::SmartShiftMode;
use crate::SmartShiftStatus;
+use crate::write::lighting::per_key_reports;
use crate::write::smartshift::{
is_missing_enhanced, is_transient_smartshift_error, smartshift_to_wheel,
status_matches_desired, wheel_mode_to_smartshift,
};
use crate::write::{HidppFeatureErrorKind, HidppOperation};
+#[test]
+fn light_settings_expand_only_to_advertised_controls() {
+ let Ok(brightness) = LightValueRange::new(0, 100, 1, LightValueUnit::Percent) else {
+ panic!("valid brightness fixture");
+ };
+ let settings = LightSettings::new(false, 37, Some(4600));
+ let commands = commands_for_light_settings(
+ settings,
+ LightCapabilities {
+ brightness: Some(brightness),
+ ..LightCapabilities::default()
+ },
+ );
+
+ assert_eq!(commands, vec![LightCommand::BrightnessPercent(37)]);
+}
+
#[test]
fn capabilities_sort_and_deduplicate_values() -> Result<(), WriteError> {
let caps = DpiCapabilities::new(vec![1600, 400, 800, 800])?;
@@ -182,3 +207,219 @@ fn status_match_ignores_zero_preserve_fields() {
}
));
}
+
+#[test]
+fn per_key_lighting_builds_only_very_long_frames_then_one_long_commit() {
+ let reports = per_key_reports(0x03, 0x27, 0x11, 0x22, 0x33);
+ let (commit, frames) = reports
+ .split_last()
+ .unwrap_or_else(|| panic!("per-key lighting must emit a commit"));
+
+ assert_eq!(frames.len(), 17);
+ assert!(frames.iter().all(|report| report.len() == 64));
+ assert!(frames.iter().all(|report| report[0] == 0x12));
+ assert!(frames.iter().all(|report| report[1] == 0x03));
+ assert!(frames.iter().all(|report| report[2] == 0x27));
+ assert!(frames.iter().all(|report| report[3] == 0x3a));
+ assert!(frames.iter().all(|report| report[5] == 0x01));
+ assert!(frames.iter().all(|report| report[7] == 0x0e));
+
+ let entries: Vec<_> = frames
+ .iter()
+ .flat_map(|report| report[8..64].chunks_exact(4))
+ .take(0xe9)
+ .map(|entry| (entry[0], entry[1], entry[2], entry[3]))
+ .collect();
+ assert_eq!(entries.len(), 0xe9);
+ for (key, entry) in (0x00u8..=0xe8).zip(entries) {
+ assert_eq!(entry, (key, 0x11, 0x22, 0x33));
+ }
+
+ assert_eq!(commit.len(), 20);
+ assert_eq!(&commit[..4], &[0x11, 0x03, 0x27, 0x5a]);
+ assert!(commit[4..].iter().all(|byte| *byte == 0));
+}
+
+#[tokio::test]
+async fn shared_read_and_lighting_apis_use_the_supplied_channel() -> Result<(), WriteError> {
+ let (raw, handle) = ScriptedRawHidChannel::new();
+ let channel = Arc::new(
+ HidppChannel::from_raw_channel(raw)
+ .await
+ .unwrap_or_else(|error| panic!("scripted HID++ channel must open: {error:?}")),
+ );
+ let shared = SharedChannel::new(
+ channel,
+ DeviceRoute::Direct {
+ vendor_id: 0x046d,
+ product_id: 0xb35b,
+ },
+ );
+
+ let dpi = get_dpi_info_on(&shared).await?;
+ assert_eq!(dpi.current, 800);
+ assert_eq!(dpi.capabilities.values(), [400, 800, 1600]);
+
+ let smartshift = get_smartshift_status_on(&shared).await?;
+ assert_eq!(smartshift.mode, SmartShiftMode::Ratchet);
+ assert_eq!(smartshift.auto_disengage, 10);
+ assert_eq!(smartshift.tunable_torque, 33);
+
+ // The scripted device reports no 0x8070 effect engine, so Auto must fall
+ // back to 0x8080 without opening a second transport.
+ set_keyboard_color_on(&shared, 0x11, 0x22, 0x33).await?;
+
+ let written = handle.written_reports();
+ let very_long: Vec<_> = written
+ .iter()
+ .filter(|report| report.first() == Some(&0x12))
+ .collect();
+ assert_eq!(very_long.len(), 17);
+ assert!(very_long.iter().all(|report| report.len() == 64));
+ assert!(written.iter().any(|report| {
+ report.len() == 20
+ && report[0] == 0x11
+ && report[1] == 0xff
+ && report[2] == 0x07
+ && report[3] >> 4 == 0x05
+ }));
+ Ok(())
+}
+
+#[derive(Clone)]
+struct ScriptedRawHidHandle {
+ written: Arc<Mutex<Vec<Vec<u8>>>>,
+}
+
+impl ScriptedRawHidHandle {
+ fn written_reports(&self) -> Vec<Vec<u8>> {
+ self.written
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner)
+ .clone()
+ }
+}
+
+struct ScriptedRawHidChannel {
+ incoming_tx: mpsc::UnboundedSender<Vec<u8>>,
+ incoming_rx: tokio::sync::Mutex<mpsc::UnboundedReceiver<Vec<u8>>>,
+ written: Arc<Mutex<Vec<Vec<u8>>>>,
+}
+
+impl ScriptedRawHidChannel {
+ fn new() -> (Self, ScriptedRawHidHandle) {
+ let (incoming_tx, incoming_rx) = mpsc::unbounded_channel();
+ let written = Arc::new(Mutex::new(Vec::new()));
+ (
+ Self {
+ incoming_tx,
+ incoming_rx: tokio::sync::Mutex::new(incoming_rx),
+ written: Arc::clone(&written),
+ },
+ ScriptedRawHidHandle { written },
+ )
+ }
+}
+
+#[hidpp::async_trait]
+impl RawHidChannel for ScriptedRawHidChannel {
+ fn vendor_id(&self) -> u16 {
+ 0x046d
+ }
+
+ fn product_id(&self) -> u16 {
+ 0xb35b
+ }
+
+ async fn write_report(&self, src: &[u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
+ self.written
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner)
+ .push(src.to_vec());
+ if let Some(response) = scripted_response(src) {
+ self.incoming_tx.send(response).map_err(|_| mock_error())?;
+ }
+ Ok(src.len())
+ }
+
+ async fn read_report(&self, buf: &mut [u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
+ let Some(report) = self.incoming_rx.lock().await.recv().await else {
+ return Err(mock_error());
+ };
+ let len = report.len().min(buf.len());
+ buf[..len].copy_from_slice(&report[..len]);
+ Ok(len)
+ }
+
+ fn supports_short_long_hidpp(&self) -> Option<(bool, bool)> {
+ Some((true, true))
+ }
+
+ async fn get_report_descriptor(
+ &self,
+ _buf: &mut [u8],
+ ) -> Result<usize, Box<dyn Error + Send + Sync>> {
+ unreachable!("scripted channel declares HID++ support")
+ }
+}
+
+fn scripted_response(request: &[u8]) -> Option<Vec<u8>> {
+ if request.len() < 7 || !matches!(request[0], 0x10 | 0x11) {
+ return None;
+ }
+ let feature_index = request[2];
+ let function = request[3] >> 4;
+ let mut payload = [0u8; 16];
+ let long = match (feature_index, function) {
+ // Root ping used by Device::new.
+ (0x00, 0x01) => {
+ payload[0] = 4;
+ false
+ }
+ // Root feature lookup.
+ (0x00, 0x00) => {
+ let feature_id = u16::from_be_bytes([request[4], request[5]]);
+ payload[0] = match feature_id {
+ 0x2201 => 0x05,
+ 0x2111 => 0x06,
+ 0x8080 => 0x07,
+ _ => 0x00,
+ };
+ false
+ }
+ // AdjustableDpi sensor count/current/list.
+ (0x05, 0x00) => {
+ payload[0] = 1;
+ false
+ }
+ (0x05, 0x02) => {
+ payload[1..3].copy_from_slice(&800u16.to_be_bytes());
+ false
+ }
+ (0x05, 0x01) => {
+ payload[..8].copy_from_slice(&[0, 0x01, 0x90, 0x03, 0x20, 0x06, 0x40, 0]);
+ true
+ }
+ // Enhanced SmartShift status.
+ (0x06, 0x01) => {
+ payload[..3].copy_from_slice(&[u8::from(WheelMode::Ratchet), 10, 33]);
+ false
+ }
+ // Raw per-key frame commit expects no reply.
+ _ => return None,
+ };
+
+ let mut response = vec![0u8; if long { 20 } else { 7 }];
+ response[0] = if long { 0x11 } else { 0x10 };
+ response[1..4].copy_from_slice(&request[1..4]);
+ let payload_len = response.len() - 4;
+ response[4..].copy_from_slice(&payload[..payload_len]);
+ Some(response)
+}
+
+fn mock_error() -> Box<dyn Error + Send + Sync> {
+ Box::new(io::Error::new(
+ io::ErrorKind::BrokenPipe,
+ "scripted HID channel closed",
+ ))
+}
diff --git a/crates/openlogi-hidpp/src/channel.rs b/crates/openlogi-hidpp/src/channel.rs
index a8535f6d17dd527b8afeec4d9231426942b04a1d..8fd22d7222fc1b2819bb6b49829fd5f33c6bc3cd 100644
--- a/crates/openlogi-hidpp/src/channel.rs
+++ b/crates/openlogi-hidpp/src/channel.rs
@@ -31,6 +31,10 @@ const MAX_REPORT_DESCRIPTOR_LENGTH: usize = 4096;
/// As we only care about HID++ reports, this equals to [`LONG_REPORT_LENGTH`].
const MAX_REPORT_LENGTH: usize = LONG_REPORT_LENGTH;
+/// Largest output report accepted by [`HidppChannel::write_raw_report`].
+/// Logitech's very-long HID++ lighting report (`0x12`) is 64 bytes.
+const MAX_RAW_REPORT_LENGTH: usize = 64;
+
/// The default time budget for a [`HidppChannel::send`] request: the report
/// write plus the wait for a matching response. Callers that need a different
/// budget can use [`HidppChannel::send_with_timeout`].
@@ -96,6 +100,15 @@ pub trait RawHidChannel: Sync + Send + 'static {
/// must do the same and must not await `read_report` bare.
async fn read_report(&self, buf: &mut [u8]) -> Result<usize, Box<dyn Error + Sync + Send>>;
+ /// Whether the underlying device connection is still usable.
+ ///
+ /// Implementations that can detect a permanent disconnect should override
+ /// this. The default preserves the behavior of transports that cannot
+ /// report connection state.
+ fn is_connected(&self) -> bool {
+ true
+ }
+
/// If the implementation already knows whether the underlying HID channel
/// supports HID++ messages, it should return `Some((supports_short,
/// supports_long))` from this method.
@@ -414,6 +427,11 @@ impl HidppChannel {
})
}
+ /// Whether the underlying HID transport still reports a live connection.
+ pub fn is_connected(&self) -> bool {
+ self.raw_channel.is_connected()
+ }
+
/// Sets the software ID that should be returned by the next call to
/// [`Self::get_sw_id`].
///
@@ -619,6 +637,34 @@ impl HidppChannel {
.map_err(ChannelError::Implementation)
}
+ /// Write one raw HID report through this channel's already-owned transport.
+ ///
+ /// Reports must contain `1..=64` bytes, including their report ID. The
+ /// operation is bounded by [`SEND_RESPONSE_TIMEOUT`] and returns the exact
+ /// byte count reported by the transport. This is intended for HID++ report
+ /// widths such as the 64-byte `0x12` lighting frame that [`HidppMessage`]
+ /// cannot represent.
+ pub async fn write_raw_report(&self, report: &[u8]) -> Result<usize, ChannelError> {
+ self.write_raw_report_with_timeout(report, SEND_RESPONSE_TIMEOUT)
+ .await
+ }
+
+ async fn write_raw_report_with_timeout(
+ &self,
+ report: &[u8],
+ timeout: Duration,
+ ) -> Result<usize, ChannelError> {
+ if !(1..=MAX_RAW_REPORT_LENGTH).contains(&report.len()) {
+ return Err(ChannelError::InvalidRawReportLength(report.len()));
+ }
+
+ let mut write = std::pin::pin!(self.raw_channel.write_report(report).fuse());
+ select! {
+ result = write => result.map_err(ChannelError::Implementation),
+ _ = futures_timer::Delay::new(timeout).fuse() => Err(ChannelError::Timeout),
+ }
+ }
+
/// Registers a listener that will be called for every incoming message.
///
/// Returns a handle that can be used to remove the listener using a call to
@@ -687,14 +733,20 @@ pub enum ChannelError {
#[error("the channel does not support the given HID++ message type")]
MessageTypeNotSupported,
+ /// Indicates that a raw output report was empty or exceeded 64 bytes.
+ #[error("raw HID reports must contain 1..=64 bytes, got {0}")]
+ InvalidRawReportLength(usize),
+
/// Indicates that no response was received following a request.
#[error("the device did not respond to the request")]
NoResponse,
- /// Indicates that a request did not complete within its time budget —
- /// typically the device is asleep, out of range or connected to another
- /// host. See [`HidppChannel::send_with_timeout`].
- #[error("the request timed out before the device responded")]
+ /// Indicates that a bounded channel operation did not complete — typically
+ /// because the device is asleep, out of range, connected to another host,
+ /// or its transport write is wedged. See
+ /// [`HidppChannel::send_with_timeout`] and
+ /// [`HidppChannel::write_raw_report`].
+ #[error("the HID channel operation timed out")]
Timeout,
}
@@ -716,7 +768,7 @@ mod tests {
io,
sync::{
Arc, Mutex,
- atomic::{AtomicUsize, Ordering},
+ atomic::{AtomicBool, AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
@@ -882,6 +934,59 @@ mod tests {
});
}
+ #[test]
+ fn raw_report_write_forwards_exact_bytes_and_length() {
+ futures::executor::block_on(async {
+ let (raw, handle) = MockRawHidChannel::new();
+ let channel = HidppChannel::from_raw_channel(raw).await.unwrap();
+ let report = [0x12; MAX_RAW_REPORT_LENGTH];
+
+ let written = channel.write_raw_report(&report).await.unwrap();
+
+ assert_eq!(written, report.len());
+ assert_eq!(handle.written_reports(), [report.to_vec()]);
+ });
+ }
+
+ #[test]
+ fn raw_report_write_rejects_empty_and_oversized_inputs_without_io() {
+ futures::executor::block_on(async {
+ let (raw, handle) = MockRawHidChannel::new();
+ let channel = HidppChannel::from_raw_channel(raw).await.unwrap();
+
+ let empty = channel.write_raw_report(&[]).await.unwrap_err();
+ let oversized = channel
+ .write_raw_report(&[0; MAX_RAW_REPORT_LENGTH + 1])
+ .await
+ .unwrap_err();
+
+ assert!(matches!(empty, ChannelError::InvalidRawReportLength(0)));
+ assert!(matches!(
+ oversized,
+ ChannelError::InvalidRawReportLength(65)
+ ));
+ assert!(handle.written_reports().is_empty());
+ });
+ }
+
+ #[test]
+ fn raw_report_write_times_out_when_the_transport_parks() {
+ futures::executor::block_on(async {
+ let (raw, handle) = MockRawHidChannel::new();
+ handle.park_writes();
+ let channel = HidppChannel::from_raw_channel(raw).await.unwrap();
+ let started = Instant::now();
+
+ let error = channel
+ .write_raw_report_with_timeout(&[LONG_REPORT_ID], Duration::from_millis(25))
+ .await
+ .unwrap_err();
+
+ assert!(matches!(error, ChannelError::Timeout));
+ assert!(started.elapsed() < Duration::from_secs(1));
+ });
+ }
+
#[test]
fn listener_can_remove_another_listener_during_dispatch() {
futures::executor::block_on(async {
@@ -1133,6 +1238,7 @@ mod tests {
incoming_tx: async_channel::Sender<Vec<u8>>,
written_reports: Arc<Mutex<Vec<Vec<u8>>>>,
responses_on_write: Arc<Mutex<VecDeque<Vec<u8>>>>,
+ park_writes: Arc<AtomicBool>,
}
impl MockRawHidHandle {
@@ -1150,6 +1256,10 @@ mod tests {
fn written_reports(&self) -> Vec<Vec<u8>> {
self.written_reports.lock().unwrap().clone()
}
+
+ fn park_writes(&self) {
+ self.park_writes.store(true, Ordering::SeqCst);
+ }
}
struct MockRawHidChannel {
@@ -1157,6 +1267,7 @@ mod tests {
incoming_rx: async_channel::Receiver<Vec<u8>>,
written_reports: Arc<Mutex<Vec<Vec<u8>>>>,
responses_on_write: Arc<Mutex<VecDeque<Vec<u8>>>>,
+ park_writes: Arc<AtomicBool>,
}
impl MockRawHidChannel {
@@ -1164,11 +1275,13 @@ mod tests {
let (incoming_tx, incoming_rx) = async_channel::unbounded();
let written_reports = Arc::new(Mutex::new(Vec::new()));
let responses_on_write = Arc::new(Mutex::new(VecDeque::new()));
+ let park_writes = Arc::new(AtomicBool::new(false));
let handle = MockRawHidHandle {
incoming_tx: incoming_tx.clone(),
written_reports: Arc::clone(&written_reports),
responses_on_write: Arc::clone(&responses_on_write),
+ park_writes: Arc::clone(&park_writes),
};
(
@@ -1177,6 +1290,7 @@ mod tests {
incoming_rx,
written_reports,
responses_on_write,
+ park_writes,
},
handle,
)
@@ -1195,6 +1309,9 @@ mod tests {
async fn write_report(&self, src: &[u8]) -> Result<usize, Box<dyn Error + Sync + Send>> {
self.written_reports.lock().unwrap().push(src.to_vec());
+ if self.park_writes.load(Ordering::SeqCst) {
+ return std::future::pending().await;
+ }
let response = self.responses_on_write.lock().unwrap().pop_front();
if let Some(response) = response {
self.incoming_tx.send(response).await.unwrap();
diff --git a/crates/openlogi-hidpp/src/feature/battery_status/mod.rs b/crates/openlogi-hidpp/src/feature/battery_status/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b86a81b14e6d990f3027d99e8665836a86e0946a
--- /dev/null
+++ b/crates/openlogi-hidpp/src/feature/battery_status/mod.rs
@@ -0,0 +1,99 @@
+//! Implements the legacy `BatteryStatus` feature (ID `0x1000`) that reports a
+//! device's battery charge as a discharge level plus a charging status.
+//!
+//! This is the predecessor of `UnifiedBattery` (`0x1004`): older mice such as
+//! the MX Master 2S expose `0x1000` and never `0x1004`, so the inventory probe
+//! falls back to this feature when the unified one is absent — the same
+//! enhanced-then-legacy pattern `SmartShift` uses for `0x2111` / `0x2110`.
+//!
+//! Only `getBatteryLevelStatus` (function `0`) is implemented; the optional
+//! `getBatteryCapability` (function `1`) and the broadcast event aren't needed
+//! to display a charge reading.
+
+use std::{hash::Hash, sync::Arc};
+
+use num_enum::{IntoPrimitive, TryFromPrimitive};
+
+use crate::{
+ channel::HidppChannel,
+ feature::{CreatableFeature, Feature, FeatureEndpoint},
+ protocol::v20::Hidpp20Error,
+};
+
+/// Implements the legacy `BatteryStatus` / `0x1000` feature.
+pub struct BatteryStatusFeature {
+ /// The endpoint this feature talks to.
+ endpoint: FeatureEndpoint,
+}
+
+impl CreatableFeature for BatteryStatusFeature {
+ const ID: u16 = 0x1000;
+ const STARTING_VERSION: u8 = 0;
+
+ fn new(chan: Arc<HidppChannel>, device_index: u8, feature_index: u8) -> Self {
+ Self {
+ endpoint: FeatureEndpoint::new(chan, device_index, feature_index),
+ }
+ }
+}
+
+impl Feature for BatteryStatusFeature {}
+
+impl BatteryStatusFeature {
+ /// Reads the current battery level and charging status (function `0`,
+ /// `getBatteryLevelStatus`).
+ pub async fn get_battery_level_status(&self) -> Result<LegacyBatteryInfo, Hidpp20Error> {
+ let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload();
+
+ Ok(LegacyBatteryInfo {
+ discharge_level: payload[0],
+ next_level: payload[1],
+ status: LegacyBatteryStatus::try_from(payload[2])
+ .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
+ })
+ }
+}
+
+/// A reading from the legacy `0x1000` `getBatteryLevelStatus` function.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
+#[non_exhaustive]
+pub struct LegacyBatteryInfo {
+ /// Current battery charge as a percentage (`0`–`100`). Logitech firmware
+ /// reports this in coarse steps rather than a continuous value.
+ pub discharge_level: u8,
+
+ /// The next lower discharge step the firmware will report — a hint at the
+ /// reporting granularity. Unused for display.
+ pub next_level: u8,
+
+ /// The current charging status.
+ pub status: LegacyBatteryStatus,
+}
+
+/// Charging status reported by the legacy `0x1000` feature. Values follow the
+/// HID++ `batteryStatus` enumeration (see Solaar / `hid-logitech-hidpp`).
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize))]
+#[non_exhaustive]
+#[repr(u8)]
+pub enum LegacyBatteryStatus {
+ /// Battery is discharging.
+ Discharging = 0,
+ /// Battery is recharging.
+ Recharging = 1,
+ /// Battery is charging and nearly full.
+ AlmostFull = 2,
+ /// Battery charge is complete.
+ Full = 3,
+ /// Battery is recharging below optimal speed.
+ SlowRecharge = 4,
+ /// The battery type is invalid.
+ InvalidBattery = 5,
+ /// The battery subsystem reported a thermal error.
+ ThermalError = 6,
+ /// "Other charging error" (Solaar lists value 7). Kept explicit so a device
+ /// reporting it surfaces as Unknown instead of failing the parse and making
+ /// the battery indicator vanish from the UI.
+ Other = 7,
+}
diff --git a/crates/openlogi-hidpp/src/feature/crown/event.rs b/crates/openlogi-hidpp/src/feature/crown/event.rs
index 59a2b422c22e0002dd00389360b9f9f67fd1f353..d9b20b7d13dd1c3d6ee51c97a9cb3c45ce05d68d 100644
--- a/crates/openlogi-hidpp/src/feature/crown/event.rs
+++ b/crates/openlogi-hidpp/src/feature/crown/event.rs
@@ -1,9 +1,9 @@
//! The event emitted by the `Crown` feature (`0x4600`).
-use num_enum::{IntoPrimitive, TryFromPrimitive};
+use num_enum::{FromPrimitive, IntoPrimitive};
/// Rotation phase reported in a [`CrownUpdate`].
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
@@ -16,10 +16,13 @@ pub enum RotationState {
Active = 2,
/// Rotation stopped.
Stop = 3,
+ /// A state this crate does not model; carries the raw byte.
+ #[num_enum(catch_all)]
+ Other(u8),
}
/// Proximity or touch activity phase reported in a [`CrownUpdate`].
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
@@ -32,10 +35,13 @@ pub enum ActivityState {
Active = 2,
/// Stopped.
Stop = 3,
+ /// A state this crate does not model; carries the raw byte.
+ #[num_enum(catch_all)]
+ Other(u8),
}
/// Touch gesture reported in a [`CrownUpdate`].
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
@@ -46,10 +52,13 @@ pub enum CrownGesture {
Tap = 1,
/// Double tap.
DoubleTap = 2,
+ /// A gesture this crate does not model; carries the raw byte.
+ #[num_enum(catch_all)]
+ Other(u8),
}
/// Crown button state reported in a [`CrownUpdate`].
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
@@ -66,6 +75,9 @@ pub enum ButtonState {
LongPressActive = 4,
/// Released.
Release = 5,
+ /// A state this crate does not model; carries the raw byte.
+ #[num_enum(catch_all)]
+ Other(u8),
}
/// An event emitted by [`CrownFeature`](super::CrownFeature).
@@ -107,13 +119,13 @@ pub struct CrownUpdate {
pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option<CrownEvent> {
match sub_id {
0 => Some(CrownEvent::Update(CrownUpdate {
- rotation_state: RotationState::try_from(payload[0]).ok()?,
+ rotation_state: RotationState::from(payload[0]),
relative_slot_rotation: payload[1] as i8,
relative_ratchet_rotation: payload[2] as i8,
- proximity: ActivityState::try_from(payload[3]).ok()?,
- touch: ActivityState::try_from(payload[4]).ok()?,
- gesture: CrownGesture::try_from(payload[5]).ok()?,
- button: ButtonState::try_from(payload[6]).ok()?,
+ proximity: ActivityState::from(payload[3]),
+ touch: ActivityState::from(payload[4]),
+ gesture: CrownGesture::from(payload[5]),
+ button: ButtonState::from(payload[6]),
speed: i16::from_be_bytes([payload[14], payload[15]]),
})),
_ => None,
diff --git a/crates/openlogi-hidpp/src/feature/crown/tests.rs b/crates/openlogi-hidpp/src/feature/crown/tests.rs
index 78b4e9eaf4ca695f38026ebd243fd37bc6ced2c2..bdb5559fbeaac881066d7e9914353fa3d4662dc0 100644
--- a/crates/openlogi-hidpp/src/feature/crown/tests.rs
+++ b/crates/openlogi-hidpp/src/feature/crown/tests.rs
@@ -64,10 +64,32 @@ fn ignores_unknown_event_sub_id() {
}
#[test]
-fn ignores_event_with_unknown_enum() {
+fn keeps_event_with_unknown_enum_field() {
let mut payload = [0; 16];
+ payload[1] = 0xfb; // -5 slots — a valid sibling field that must survive
payload[6] = 0x09; // out-of-range button state
- assert!(decode_event(0, &payload).is_none());
+ let CrownEvent::Update(update) = decode_event(0, &payload).expect("event kept");
+ assert_eq!(update.button, ButtonState::Other(0x09));
+ assert_eq!(update.relative_slot_rotation, -5);
+}
+
+/// Totality: for a known sub-id, no single field byte value may drop the whole
+/// event. Sweeps every value of each enum-typed field position.
+#[test]
+fn known_sub_id_survives_any_field_byte() {
+ for byte in 0..=u8::MAX {
+ for pos in [0usize, 3, 4, 5, 6] {
+ let mut payload = [0; 16];
+ payload[1] = 0xfb; // sibling that must always survive
+ payload[pos] = byte;
+ let CrownEvent::Update(update) = decode_event(0, &payload)
+ .unwrap_or_else(|| panic!("dropped event for payload[{pos}]={byte:#04x}"));
+ assert_eq!(
+ update.relative_slot_rotation, -5,
+ "sibling lost for payload[{pos}]={byte:#04x}"
+ );
+ }
+ }
}
#[test]
diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs
index 78adf8347bdf9338a3e0a6e5eff09f850b3def64..04719f7c517c1a47742e60bde8a893a76c98c0e2 100644
--- a/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs
+++ b/crates/openlogi-hidpp/src/feature/extended_dpi/event.rs
@@ -25,8 +25,9 @@ pub struct DpiParametersChanged {
pub dpi_x: u16,
/// New Y-axis DPI, or `0` when the sensor has no independent Y axis.
pub dpi_y: u16,
- /// New lift-off distance.
- pub lod: Lod,
+ /// New lift-off distance, or `None` when the device reported a value this
+ /// crate does not model (the rest of the event is still delivered).
+ pub lod: Option<Lod>,
}
/// Payload of [`ExtendedDpiEvent::CalibrationCompleted`].
@@ -36,8 +37,9 @@ pub struct DpiParametersChanged {
pub struct DpiCalibrationCompleted {
/// Index of the sensor.
pub sensor_index: u8,
- /// Axis that was calibrated.
- pub direction: DpiDirection,
+ /// Axis that was calibrated, or `None` when the device reported a value
+ /// this crate does not model (the rest of the event is still delivered).
+ pub direction: Option<DpiDirection>,
/// Calibration correction value; [`i16::MIN`] (`0x8000`) signals a
/// sensor-level calibration failure (see [`Self::failed`]).
pub correction: i16,
@@ -65,12 +67,12 @@ pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option<ExtendedDpi
sensor_index: payload[0],
dpi_x: u16::from_be_bytes([payload[1], payload[2]]),
dpi_y: u16::from_be_bytes([payload[3], payload[4]]),
- lod: Lod::try_from(payload[5]).ok()?,
+ lod: Lod::try_from(payload[5]).ok(),
})),
1 => Some(ExtendedDpiEvent::CalibrationCompleted(
DpiCalibrationCompleted {
sensor_index: payload[0],
- direction: DpiDirection::try_from(payload[1]).ok()?,
+ direction: DpiDirection::try_from(payload[1]).ok(),
correction: i16::from_be_bytes([payload[2], payload[3]]),
delta: i16::from_be_bytes([payload[4], payload[5]]),
},
diff --git a/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs b/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs
index 8966f7b3d916d53a4de882b94c789b9bb097cd79..9bce55d82276c1019a34c6cdb6158318769eca57 100644
--- a/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs
+++ b/crates/openlogi-hidpp/src/feature/extended_dpi/tests.rs
@@ -229,7 +229,7 @@ fn decodes_parameters_changed_event() {
assert_eq!(event.sensor_index, 1);
assert_eq!(event.dpi_x, 800);
assert_eq!(event.dpi_y, 1600);
- assert_eq!(event.lod, Lod::Medium);
+ assert_eq!(event.lod, Some(Lod::Medium));
}
#[test]
@@ -242,7 +242,7 @@ fn decodes_calibration_completed_event() {
let ExtendedDpiEvent::CalibrationCompleted(event) = decode_event(1, &payload).unwrap() else {
panic!("expected a calibration-completed event");
};
- assert_eq!(event.direction, DpiDirection::Y);
+ assert_eq!(event.direction, Some(DpiDirection::Y));
assert_eq!(event.correction, 100);
assert_eq!(event.delta, -1);
assert!(!event.failed());
@@ -265,11 +265,37 @@ fn ignores_unknown_event_sub_id() {
}
#[test]
-fn ignores_event_with_unknown_lod() {
+fn keeps_event_with_unknown_lod() {
let mut payload = [0; 16];
+ payload[0] = 3; // sensor index — a valid sibling that must survive
payload[5] = 9;
- assert!(decode_event(0, &payload).is_none());
+ let ExtendedDpiEvent::ParametersChanged(changed) =
+ decode_event(0, &payload).expect("event kept")
+ else {
+ panic!("expected ParametersChanged");
+ };
+ // Lod stays a closed, write-safe enum; an unknown lift-off value surfaces
+ // as None on the event without dropping the DPI change.
+ assert_eq!(changed.lod, None);
+ assert_eq!(changed.sensor_index, 3);
+}
+
+/// Totality: a known event sub-id must decode for any enum-field byte value.
+#[test]
+fn known_sub_id_survives_any_field_byte() {
+ for byte in 0..=u8::MAX {
+ // sub 0: Lod at payload[5]; sub 1: DpiDirection at payload[1].
+ for (sub_id, pos) in [(0u8, 5usize), (1, 1)] {
+ let mut payload = [0; 16];
+ payload[0] = 3; // sensor index sibling
+ payload[pos] = byte;
+ assert!(
+ decode_event(sub_id, &payload).is_some(),
+ "dropped sub {sub_id} for payload[{pos}]={byte:#04x}"
+ );
+ }
+ }
}
#[test]
diff --git a/crates/openlogi-hidpp/src/feature/illumination/event.rs b/crates/openlogi-hidpp/src/feature/illumination/event.rs
index 39fd0dbf6a19c78c93823b065cb62b35562c0e06..3f5aa332fa72ce01fb7e750cd875f458f4ad40f4 100644
--- a/crates/openlogi-hidpp/src/feature/illumination/event.rs
+++ b/crates/openlogi-hidpp/src/feature/illumination/event.rs
@@ -39,7 +39,7 @@ pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option<Illuminatio
payload, 0,
))),
4 => Some(IlluminationEvent::BrightnessClamped {
- source: BrightnessClampedSource::try_from(payload[0]).ok()?,
+ source: BrightnessClampedSource::from(payload[0]),
brightness: be16(payload, 1),
}),
_ => None,
diff --git a/crates/openlogi-hidpp/src/feature/illumination/tests.rs b/crates/openlogi-hidpp/src/feature/illumination/tests.rs
index 9ec7de849038131feb36c010734127a5c56fc235..5f4a1779a4e181746ce2096725e45e1f759c0892 100644
--- a/crates/openlogi-hidpp/src/feature/illumination/tests.rs
+++ b/crates/openlogi-hidpp/src/feature/illumination/tests.rs
@@ -221,3 +221,23 @@ fn decodes_brightness_clamped_event() {
fn ignores_unknown_event_sub_id() {
assert!(decode_event(9, &[0; 16]).is_none());
}
+
+/// Totality: the clamp-source event must decode for any source byte; an
+/// unknown source folds to `Other(_)` and the brightness sibling survives.
+#[test]
+fn keeps_clamp_event_with_unknown_source() {
+ for byte in 0..=u8::MAX {
+ let mut payload = [0; 16];
+ payload[0] = byte; // BrightnessClampedSource
+ payload[1..3].copy_from_slice(&300u16.to_be_bytes()); // brightness sibling
+ let event = decode_event(4, &payload)
+ .unwrap_or_else(|| panic!("dropped clamp event for source {byte:#04x}"));
+ assert_eq!(
+ event,
+ IlluminationEvent::BrightnessClamped {
+ source: BrightnessClampedSource::from(byte),
+ brightness: 300,
+ }
+ );
+ }
+}
diff --git a/crates/openlogi-hidpp/src/feature/illumination/types.rs b/crates/openlogi-hidpp/src/feature/illumination/types.rs
index 5e53c7dc209344558c0e3e717ca808844a6ed689..a2333543235353d62fad66c22f11164e138d519e 100644
--- a/crates/openlogi-hidpp/src/feature/illumination/types.rs
+++ b/crates/openlogi-hidpp/src/feature/illumination/types.rs
@@ -1,6 +1,6 @@
//! Domain types for the `Illumination` feature (`0x1990`).
-use num_enum::{IntoPrimitive, TryFromPrimitive};
+use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive};
use crate::protocol::v20::{ErrorType, Hidpp20Error};
@@ -189,7 +189,7 @@ impl From<bool> for IlluminationState {
}
/// What caused a [`brightness clamp`](super::event::IlluminationEvent::BrightnessClamped).
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
@@ -200,6 +200,9 @@ pub enum BrightnessClampedSource {
HidPlusPlus = 1,
/// A hardware button triggered the clamp.
Button = 2,
+ /// A source this crate does not model; carries the raw byte.
+ #[num_enum(catch_all)]
+ Other(u8),
}
/// Decodes the on/off state bit shared by `getIllumination` and its event.
diff --git a/crates/openlogi-hidpp/src/feature/mod.rs b/crates/openlogi-hidpp/src/feature/mod.rs
index 07c85b572a77a31273be4647cd1203899175935f..1d3f43d1321156d5b3265b712f8fd3652b568fbf 100644
--- a/crates/openlogi-hidpp/src/feature/mod.rs
+++ b/crates/openlogi-hidpp/src/feature/mod.rs
@@ -10,6 +10,7 @@ use crate::{
pub mod adjustable_dpi;
pub mod backlight;
+pub mod battery_status;
pub mod brightness_control;
pub mod change_host;
pub mod color_led_effects;
diff --git a/crates/openlogi-hidpp/src/feature/registry.rs b/crates/openlogi-hidpp/src/feature/registry.rs
index 0efa14af2a9b3ae29db563e04bba727dfaa4d6be..efa7ed1bf5faaab568616228ac8753eeace7faaa 100644
--- a/crates/openlogi-hidpp/src/feature/registry.rs
+++ b/crates/openlogi-hidpp/src/feature/registry.rs
@@ -14,6 +14,7 @@ use crate::{
CreatableFeature,
adjustable_dpi::AdjustableDpiFeature,
backlight::BacklightFeature,
+ battery_status::BatteryStatusFeature,
brightness_control::BrightnessControlFeature,
change_host::ChangeHostFeature,
color_led_effects::ColorLedEffectsFeature,
@@ -154,7 +155,7 @@ static KNOWN_FEATURES: LazyLock<HashMap<u16, KnownFeature>> = LazyLock::new(|| {
0x00c3 "DfuControlBolt",
0x00d0 "Dfu",
0x00d1 "DfuResumable",
- 0x1000 "BatteryStatus",
+ 0x1000 "BatteryStatus" => BatteryStatusFeature,
0x1001 "BatteryVoltage",
0x1004 "UnifiedBattery" => UnifiedBatteryFeature,
0x1010 "ChargingControl",
diff --git a/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs
index 184cc882b5e890a8476a922039ab1acd16bf3382..24b0cd4859914860bbedaff5560b7bdd3dd282f5 100644
--- a/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs
+++ b/crates/openlogi-hidpp/src/feature/rgb_effects/event.rs
@@ -33,8 +33,9 @@ pub enum RgbEffectsEvent {
params: [u8; CLUSTER_EFFECT_PARAM_COUNT],
/// Persistence the effect was applied with.
persistence: RgbPersistence,
- /// Power-mode target the effect applies to.
- power_mode: PowerModeTarget,
+ /// Power-mode target the effect applies to, or `None` when the device
+ /// reported a value this crate does not model.
+ power_mode: Option<PowerModeTarget>,
},
}
@@ -45,9 +46,9 @@ pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option<RgbEffectsE
cluster_index: payload[0],
effect_counter: be16(payload, 1),
}),
- 1 => Some(RgbEffectsEvent::UserActivity(
- ActivityEventType::try_from(payload[0]).ok()?,
- )),
+ 1 => Some(RgbEffectsEvent::UserActivity(ActivityEventType::from(
+ payload[0],
+ ))),
2 => {
let mut params = [0; CLUSTER_EFFECT_PARAM_COUNT];
params.copy_from_slice(&payload[2..2 + CLUSTER_EFFECT_PARAM_COUNT]);
@@ -62,7 +63,7 @@ pub(super) fn decode_event(sub_id: u8, payload: &[u8; 16]) -> Option<RgbEffectsE
power_mode: PowerModeTarget::try_from(
(flags >> POWER_TARGET_SHIFT) & FLAGS_FIELD_MASK,
)
- .ok()?,
+ .ok(),
})
}
_ => None,
diff --git a/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs
index 1f12548401393c7802438cb3641521b6ffc023a3..3f4b4ebf95012b19c2a93e19a6d79e730e987f80 100644
--- a/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs
+++ b/crates/openlogi-hidpp/src/feature/rgb_effects/tests.rs
@@ -130,7 +130,7 @@ fn decodes_cluster_changed_event() {
cluster_effect_index: 2,
params: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
persistence: RgbPersistence::VOLATILE,
- power_mode: PowerModeTarget::PowerSave,
+ power_mode: Some(PowerModeTarget::PowerSave),
})
);
}
@@ -156,3 +156,35 @@ fn maps_stable_enum_wire_values() {
);
assert!(SlotInfoType::try_from(7u8).is_err());
}
+
+/// Totality: the user-activity event must decode for any activity byte.
+#[test]
+fn keeps_user_activity_event_with_unknown_type() {
+ for byte in 0..=u8::MAX {
+ let mut payload = [0; 16];
+ payload[0] = byte;
+ assert_eq!(
+ decode_event(1, &payload),
+ Some(RgbEffectsEvent::UserActivity(ActivityEventType::from(byte))),
+ "dropped user-activity event for byte {byte:#04x}"
+ );
+ }
+}
+
+/// Totality: the cluster-changed event must decode for any flags byte. The
+/// 2-bit power-mode nibble can hold values 2 and 3 that the enum does not
+/// model; those must surface as `None` without dropping the event.
+#[test]
+fn keeps_cluster_changed_event_with_unknown_power_mode() {
+ for flags in 0..=u8::MAX {
+ let mut payload = [0; 16];
+ payload[0] = 1; // cluster_index sibling that must survive
+ payload[12] = flags;
+ let event = decode_event(2, &payload)
+ .unwrap_or_else(|| panic!("dropped cluster-changed event for flags {flags:#04x}"));
+ let RgbEffectsEvent::ClusterChanged { cluster_index, .. } = event else {
+ panic!("expected ClusterChanged");
+ };
+ assert_eq!(cluster_index, 1);
+ }
+}
diff --git a/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs b/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs
index e715558af52d06f22fb84db04cf1d1b7fd62b512..8460d4f1c0504f1d696bfcf5d91516f7c1dd4893 100644
--- a/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs
+++ b/crates/openlogi-hidpp/src/feature/rgb_effects/types.rs
@@ -1,6 +1,6 @@
//! Domain types for the `RgbEffects` feature (`0x8071`).
-use num_enum::{IntoPrimitive, TryFromPrimitive};
+use num_enum::{FromPrimitive, IntoPrimitive, TryFromPrimitive};
/// Number of effect parameters carried by `setRgbClusterEffect`.
pub const CLUSTER_EFFECT_PARAM_COUNT: usize = 10;
@@ -96,7 +96,7 @@ pub enum LedBinIndex {
}
/// The kind of user-activity event.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, FromPrimitive)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
#[repr(u8)]
@@ -105,6 +105,9 @@ pub enum ActivityEventType {
NoActivityTimeoutReached = 0,
/// User activity was detected.
UserActivityDetected = 1,
+ /// A type this crate does not model; carries the raw byte.
+ #[num_enum(catch_all)]
+ Other(u8),
}
bitflags::bitflags! {
diff --git a/crates/openlogi-hidpp/src/receiver/unifying.rs b/crates/openlogi-hidpp/src/receiver/unifying.rs
index 6a7eca7d5940ee8fd3c260ef4cfed83e30dff241..c0a1487244fad545dd7d2b92a49cb0bab93339cb 100644
--- a/crates/openlogi-hidpp/src/receiver/unifying.rs
+++ b/crates/openlogi-hidpp/src/receiver/unifying.rs
@@ -21,7 +21,11 @@ use crate::{
/// All USB vendor & product ID pairs that are known to identify Unifying
/// receivers.
-pub const VPID_PAIRS: &[(u16, u16)] = &[(0x046d, 0xc52b), (0x046d, 0xc532)];
+///
+/// `046d:c539` is the Lightspeed gaming receiver, which answers the same
+/// HID++ 1.0 registers (pairing count, connection state, pairing information)
+/// as Unifying receivers.
+pub const VPID_PAIRS: &[(u16, u16)] = &[(0x046d, 0xc52b), (0x046d, 0xc532), (0x046d, 0xc539)];
/// All known registers of the Unifying receiver.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, IntoPrimitive, TryFromPrimitive)]
diff --git a/crates/openlogi-hook/Cargo.toml b/crates/openlogi-hook/Cargo.toml
index de2e54f50327afd1f3093743db35ad45ddb1b46a..67093ee733c7c356932ddb2ea027f6e43910ee8b 100644
--- a/crates/openlogi-hook/Cargo.toml
+++ b/crates/openlogi-hook/Cargo.toml
@@ -9,15 +9,18 @@ authors.workspace = true
description = "OS-level mouse-event hook for OpenLogi. macOS via CGEventTap; Linux via evdev+uinput; Windows via WH_MOUSE_LL."
[dependencies]
-openlogi-core = { path = "../openlogi-core", version = "0.6.23" }
-openlogi-inject = { path = "../openlogi-inject", version = "0.6.23" }
+openlogi-core = { path = "../openlogi-core", version = "0.6.24" }
+openlogi-inject = { path = "../openlogi-inject", version = "0.6.24" }
thiserror = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
evdev = { workspace = true }
libc = "0.2"
+wayland-client = "0.31"
+wayland-protocols-wlr = { version = "0.3", features = ["client"] }
x11rb = "0.13"
+zbus = "5"
[target.'cfg(target_os = "linux")'.dev-dependencies]
ctrlc = "3"
diff --git a/crates/openlogi-hook/gnome-shell-extension/README.md b/crates/openlogi-hook/gnome-shell-extension/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..e58cd26a0390e619b7537b3876867b44a6356a4d
--- /dev/null
+++ b/crates/openlogi-hook/gnome-shell-extension/README.md
@@ -0,0 +1,59 @@
+# OpenLogi Frontmost Window — GNOME Shell extension
+
+GNOME (Mutter) does not let ordinary clients see which window is focused on
+Wayland, and it implements neither `wlr-foreign-toplevel` nor a focused-window
+portal. This minimal extension bridges that gap: it exports the WM_CLASS of the
+focused window over D-Bus so OpenLogi's `gnome-shell` frontmost backend can
+drive per-app mouse-profile switching.
+
+It reads only `global.display.focus_window.get_wm_class()`. No titles, no window
+contents, no input, no UI.
+
+## D-Bus surface
+
+- name: `org.openlogi.Frontmost`
+- path: `/org/openlogi/Frontmost`
+- method: `GetFocusedWmClass() -> s` (empty string when nothing is focused)
+
+## Install
+
+```sh
+UUID=openlogi-frontmost@openlogi.dev
+DEST="$HOME/.local/share/gnome-shell/extensions/$UUID"
+mkdir -p "$DEST"
+cp metadata.json extension.js "$DEST"/
+```
+
+On Wayland the shell cannot be reloaded in place, so **log out and back in** to
+let GNOME pick up the newly added extension, then enable it:
+
+```sh
+gnome-extensions enable "$UUID"
+gnome-extensions info "$UUID" # State should be ACTIVE
+```
+
+## Verify
+
+```sh
+# Introspect the service:
+busctl --user introspect org.openlogi.Frontmost /org/openlogi/Frontmost
+
+# Focus a window, then query it:
+gdbus call --session \
+ -d org.openlogi.Frontmost \
+ -o /org/openlogi/Frontmost \
+ -m org.openlogi.Frontmost.GetFocusedWmClass
+```
+
+If `gdbus call` prints the focused window's WM_CLASS, OpenLogi's GNOME backend
+will pick it up automatically the next time the hook starts.
+
+## Notes
+
+- The `shell-version` list in `metadata.json` covers GNOME 45–50. Newer GNOME
+ releases may need an added entry; the API used here (`Gio.DBusExportedObject`,
+ `global.display.focus_window`, `Meta.Window.get_wm_class`) has been stable
+ across these versions.
+- The extension name/UUID and the D-Bus name (`org.openlogi.*`) are placeholders
+ that should track the project's namespace; if they change, update the matching
+ constants in `crates/openlogi-hook/src/linux/gnome_shell.rs`.
diff --git a/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/extension.js b/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/extension.js
new file mode 100644
index 0000000000000000000000000000000000000000..a0f9e4a89b0942e8e342537184d0a7cdca1d8cda
--- /dev/null
+++ b/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/extension.js
@@ -0,0 +1,55 @@
+// OpenLogi Frontmost Window — GNOME Shell extension.
+//
+// Exports a tiny D-Bus service that returns the WM_CLASS of the currently
+// focused window. OpenLogi's `gnome_shell` frontmost backend polls this to
+// drive per-app mouse-profile switching on GNOME Wayland, where the focused
+// window is otherwise not visible to ordinary clients.
+//
+// It reads only `global.display.focus_window.get_wm_class()` — no titles, no
+// window contents, no input. ESM module style; targets GNOME Shell 45+.
+
+import Gio from 'gi://Gio';
+import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
+
+const DBUS_NAME = 'org.openlogi.Frontmost';
+const DBUS_PATH = '/org/openlogi/Frontmost';
+const DBUS_INTERFACE = `
+<node>
+ <interface name="org.openlogi.Frontmost">
+ <method name="GetFocusedWmClass">
+ <arg type="s" direction="out" name="wmClass"/>
+ </method>
+ </interface>
+</node>`;
+
+export default class OpenLogiFrontmostExtension extends Extension {
+ enable() {
+ this._dbus = Gio.DBusExportedObject.wrapJSObject(DBUS_INTERFACE, this);
+ this._dbus.export(Gio.DBus.session, DBUS_PATH);
+ this._nameId = Gio.bus_own_name_on_connection(
+ Gio.DBus.session,
+ DBUS_NAME,
+ Gio.BusNameOwnerFlags.NONE,
+ null,
+ null);
+ }
+
+ disable() {
+ if (this._nameId) {
+ Gio.bus_unown_name(this._nameId);
+ this._nameId = 0;
+ }
+ if (this._dbus) {
+ this._dbus.unexport();
+ this._dbus = null;
+ }
+ }
+
+ // D-Bus method org.openlogi.Frontmost.GetFocusedWmClass.
+ GetFocusedWmClass() {
+ const win = global.display.focus_window;
+ if (!win)
+ return '';
+ return win.get_wm_class() || '';
+ }
+}
diff --git a/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/metadata.json b/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/metadata.json
new file mode 100644
index 0000000000000000000000000000000000000000..068f8493c573043914d3063b5e587035abb630b2
--- /dev/null
+++ b/crates/openlogi-hook/gnome-shell-extension/openlogi-frontmost@openlogi.dev/metadata.json
@@ -0,0 +1,8 @@
+{
+ "uuid": "openlogi-frontmost@openlogi.dev",
+ "name": "OpenLogi Frontmost Window",
+ "description": "Exposes the focused window's WM_CLASS over D-Bus so OpenLogi can switch per-app mouse profiles on GNOME Wayland. Read-only; no UI, no window contents.",
+ "shell-version": ["45", "46", "47", "48", "49", "50"],
+ "url": "https://github.com/AprilNEA/OpenLogi",
+ "version": 1
+}
diff --git a/crates/openlogi-hook/src/lib.rs b/crates/openlogi-hook/src/lib.rs
index 44185a670c9526f84b7dbc78d4c46ccc15d481d3..65d4d4d1b668f1f04a75a9af816f195f85d9c883 100644
--- a/crates/openlogi-hook/src/lib.rs
+++ b/crates/openlogi-hook/src/lib.rs
@@ -29,6 +29,9 @@ use std::cfg_select;
pub use openlogi_core::binding::ButtonId;
+/// Logitech's USB/Bluetooth vendor id (`0x046D`).
+pub const LOGITECH_VENDOR_ID: u32 = 0x046d;
+
/// Best-effort identity for the physical device that produced an OS event.
///
/// Platform hooks fill the stable fields they can read cheaply from the native
@@ -44,6 +47,82 @@ pub struct EventDevice {
pub product_name: Option<String>,
}
+impl EventDevice {
+ /// Whether this looks like a trackpad/touchpad (must never be remapped).
+ #[must_use]
+ pub fn is_trackpad_like(&self) -> bool {
+ self.product_name.as_deref().is_some_and(|n| {
+ let n = n.to_ascii_lowercase();
+ n.contains("trackpad") || n.contains("touchpad") || n.contains("touch pad")
+ })
+ }
+
+ /// Whether this is a Logitech product OpenLogi may remap buttons for.
+ #[must_use]
+ pub fn is_logitech(&self) -> bool {
+ if self.vendor_id == Some(LOGITECH_VENDOR_ID) {
+ return true;
+ }
+ self.product_name.as_deref().is_some_and(|n| {
+ let n = n.to_ascii_lowercase();
+ n.contains("logitech") || n.starts_with("logi ")
+ })
+ }
+}
+
+/// Whether the OS hook may suppress/remap a button event from this source.
+///
+/// Fail-closed on macOS-style attribution: only a known Logitech non-trackpad
+/// source is remappable. Unknown / non-Logitech / trackpad sources always pass
+/// through so a wedged remap policy can never brick the system pointer.
+#[must_use]
+pub fn source_is_remappable(device: Option<&EventDevice>) -> bool {
+ match device {
+ Some(d) if d.is_trackpad_like() => false,
+ Some(d) => d.is_logitech(),
+ None => false,
+ }
+}
+
+/// Which modifier keys were held when a key event fired. Mirrors the
+/// detectable macOS modifier flags. Note `Fn` is deliberately absent — it is
+/// firmware-internal and never reported on non-function-row keys (see the
+/// function-key-remapper spec, Appendix A).
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
+#[expect(
+ clippy::struct_excessive_bools,
+ reason = "four independent modifier flags from OS event bits"
+)]
+pub struct KeyModifiers {
+ pub shift: bool,
+ pub control: bool,
+ pub option: bool,
+ pub command: bool,
+}
+
+/// A keyboard event observed by the hook.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct KeyEvent {
+ /// Platform virtual keycode (macOS: `kVK_*`, e.g. 122 = F1, 53 = Escape).
+ pub keycode: u16,
+ /// `true` = key down; `false` = key up.
+ pub pressed: bool,
+ /// Which modifiers were held.
+ pub modifiers: KeyModifiers,
+}
+
+/// Anything the OS hook can observe. `Mouse` preserves the existing callback
+/// payload; `Key` is the keyboard path added by the function-key remapper.
+/// Wrapping both in a union means `Hook::start`'s callback widens once and
+/// stays stable as further event classes arrive.
+#[derive(Clone, Debug)]
+pub enum HookEvent {
+ /// Mouse button / scroll / move event.
+ Mouse(MouseEvent),
+ /// Keyboard event (function-key remapper path).
+ Key(KeyEvent),
+}
+
/// An event captured at the OS layer.
#[derive(Clone, Debug)]
pub enum MouseEvent {
@@ -53,6 +132,9 @@ pub enum MouseEvent {
id: ButtonId,
/// `true` = button down; `false` = button up.
pressed: bool,
+ /// Best-effort physical source. `None` when the platform cannot
+ /// attribute the event (Windows today) or it was synthetic.
+ device: Option<EventDevice>,
},
/// A scroll-wheel tick (or continuous momentum scroll).
Scroll {
@@ -261,7 +343,7 @@ impl Hook {
/// [`HookError::NoDeviceFound`] when no mouse device is accessible. On
/// Windows, installs a `WH_MOUSE_LL` low-level mouse hook.
pub fn start(
- cb: impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static,
+ cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
) -> Result<Self, HookError> {
cfg_select! {
target_os = "macos" => {
diff --git a/crates/openlogi-hook/src/linux.rs b/crates/openlogi-hook/src/linux.rs
index 959155a871a42ef284fd54f94eeaee20fec81c27..94bc35e2566e00f8b94278282884f482f37860d0 100644
--- a/crates/openlogi-hook/src/linux.rs
+++ b/crates/openlogi-hook/src/linux.rs
@@ -37,7 +37,7 @@ use x11rb::properties::WmClass;
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, Window};
use x11rb::rust_connection::RustConnection;
-use crate::{ButtonId, EventDisposition, HookError, MouseEvent};
+use crate::{ButtonId, EventDisposition, HookError, HookEvent, LOGITECH_VENDOR_ID, MouseEvent};
/// Prefix carried by every uinput device OpenLogi creates — the hook's
/// pass-through mice ([`VIRTUAL_DEVICE_NAME`]) and openlogi-inject's
@@ -61,7 +61,7 @@ pub(crate) struct HookInner {
}
pub(crate) fn start(
- cb: impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static,
+ cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
) -> Result<HookInner, HookError> {
let devices = find_mouse_devices();
if devices.is_empty() {
@@ -69,7 +69,7 @@ pub(crate) fn start(
}
let stop = Arc::new(AtomicBool::new(false));
- let cb: Arc<dyn Fn(MouseEvent) -> EventDisposition + Send + Sync> = Arc::new(cb);
+ let cb: Arc<dyn Fn(HookEvent) -> EventDisposition + Send + Sync> = Arc::new(cb);
let mut threads: Vec<thread::JoinHandle<()>> = Vec::with_capacity(devices.len());
let mut stop_pipes: Vec<OwnedFd> = Vec::with_capacity(devices.len());
@@ -151,19 +151,21 @@ fn create_pipe() -> io::Result<(OwnedFd, OwnedFd)> {
fn find_mouse_devices() -> Vec<(std::path::PathBuf, Device)> {
evdev::enumerate()
.filter(|(path, d)| {
+ let vendor = u32::from(d.input_id().vendor());
let hookable = is_hookable_mouse(
d.name(),
d.supported_keys(),
d.supported_relative_axes(),
d.supported_absolute_axes(),
d.properties(),
+ vendor,
);
if !hookable
&& d.supported_keys()
.is_some_and(|keys| keys.contains(KeyCode::BTN_LEFT))
{
debug!(
- "not hooking {} ({}): has mouse buttons but is not a plain relative-pointer mouse",
+ "not hooking {} ({}): has mouse buttons but is not a managed Logitech relative-pointer mouse",
path.display(),
d.name().unwrap_or("unnamed"),
);
@@ -183,18 +185,27 @@ fn find_mouse_devices() -> Vec<(std::path::PathBuf, Device)> {
/// are excluded too: libinput derives their on-button scrolling from the
/// `POINTING_STICK` input property, which a re-injected uinput stream loses,
/// and built-in sticks are never OpenLogi's target hardware.
+///
+/// Only Logitech devices are grabbed: OpenLogi remaps Logitech mice, and an
+/// exclusive grab on any other vendor's pointer (or a misclassified built-in
+/// trackpad) would leave that device dead for the life of the agent (#484).
fn is_hookable_mouse(
name: Option<&str>,
keys: Option<&AttributeSetRef<KeyCode>>,
rel_axes: Option<&AttributeSetRef<RelativeAxisCode>>,
abs_axes: Option<&AttributeSetRef<AbsoluteAxisCode>>,
props: &AttributeSetRef<PropType>,
+ vendor_id: u32,
) -> bool {
// Never hook one of our own uinput devices (an unnamed device is fine —
// ours are always named).
if name.is_some_and(|n| n.starts_with(OPENLOGI_DEVICE_PREFIX)) {
return false;
}
+ // OpenLogi only remaps Logitech hardware — never grab foreign vendors.
+ if vendor_id != LOGITECH_VENDOR_ID {
+ return false;
+ }
// A mouse clicks and moves relatively; nothing else qualifies. This alone
// rejects pure-ABS touchpads, keyboards with stray button bits, and
// wheel-only devices like the action injector.
@@ -298,9 +309,12 @@ fn translate(event: &evdev::InputEvent, hires_scroll: bool) -> Option<MouseEvent
match event.destructure() {
EventSummary::Key(_, key, value) => {
let id = key_to_button(key)?;
+ // Device is already Logitech-only (see `is_hookable_mouse`); the
+ // agent runtime treats `device: None` as remappable on Linux.
Some(MouseEvent::Button {
id,
pressed: value != 0,
+ device: None,
})
}
EventSummary::RelativeAxis(_, axis, value) => match axis {
@@ -369,7 +383,7 @@ fn device_thread(
path: std::path::PathBuf,
mut device: Device,
mut virtual_device: VirtualDevice,
- cb: Arc<dyn Fn(MouseEvent) -> EventDisposition + Send + Sync>,
+ cb: Arc<dyn Fn(HookEvent) -> EventDisposition + Send + Sync>,
stop: Arc<AtomicBool>,
stop_rx: OwnedFd,
) {
@@ -433,7 +447,7 @@ fn device_thread(
}
} else {
let disposition = match translate(&event, hires_scroll) {
- Some(me) => cb(me),
+ Some(me) => cb(HookEvent::Mouse(me)),
// Low-res companions (REL_WHEEL/REL_HWHEEL) must be suppressed when hi-res
// is active — passing them through would double the scroll distance.
None if hires_scroll
@@ -464,70 +478,217 @@ fn device_thread(
// ── frontmost_bundle_id ──────────────────────────────────────────────────────
-struct X11State {
+// The frontmost-app reader is backend-driven so that Wayland support can be
+// added without touching callers. Exactly one backend is selected at startup
+// from the session environment (see `detect_frontmost_source`) and cached in
+// `FRONTMOST_SOURCE` for the process lifetime. The X11, wlr-foreign-toplevel,
+// and gnome-shell backends are all available; see `wayland_candidates`.
+
+mod gnome_shell;
+mod wlr_foreign_toplevel;
+
+/// A backend that reports which application is currently frontmost.
+///
+/// Implementations are display-server / desktop specific. The string returned
+/// by `frontmost_bundle_id` is compared against per-app profile keys by exact
+/// match (`openlogi_core::Config::effective_bindings`), so its exact form
+/// matters and is backend-specific. The X11 and gnome-shell backends both
+/// return the `WM_CLASS` class component (e.g. "Firefox"); the wlr backend
+/// returns the xdg-shell `app_id` (e.g. "org.mozilla.firefox"). These two
+/// namespaces do not map onto each other by any simple string rule, so a
+/// per-app profile created under wlroots will not match under GNOME/X11 and
+/// vice versa. This is a known limitation: reconciling it needs a canonical-id
+/// scheme or per-profile aliases rather than naive normalization, and is
+/// deliberately out of scope for the backends themselves.
+trait FrontmostSource: Send + Sync {
+ /// Opaque identifier of the frontmost application, or `None` when there is
+ /// no frontmost window or it cannot be read.
+ fn frontmost_bundle_id(&self) -> Option<String>;
+
+ /// Short backend identifier, for diagnostics / logging only.
+ fn name(&self) -> &'static str;
+}
+
+/// Frontmost backend backed by X11 `_NET_ACTIVE_WINDOW` + `WM_CLASS`.
+///
+/// Works on an X11 session, and on a Wayland session for XWayland windows;
+/// native Wayland windows are invisible through this path and yield `None`.
+struct X11Source {
conn: RustConnection,
root: Window,
net_active_window: Atom,
}
-static X11_STATE: LazyLock<Option<X11State>> = LazyLock::new(|| {
- let (conn, screen_num) = RustConnection::connect(None)
- .map_err(|e| debug!("X11 not available, frontmost_bundle_id will return None: {e}"))
- .ok()?;
- let root = conn.setup().roots[screen_num].root;
- let net_active_window = conn
- .intern_atom(false, b"_NET_ACTIVE_WINDOW")
- .ok()?
- .reply()
- .ok()?
- .atom;
- Some(X11State {
- conn,
- root,
- net_active_window,
- })
-});
+impl X11Source {
+ /// Connect to the X server and resolve the `_NET_ACTIVE_WINDOW` atom.
+ /// Returns `None` when no X display is reachable (a Wayland session without
+ /// XWayland, or `$DISPLAY` unset).
+ fn connect() -> Option<Self> {
+ let (conn, screen_num) = RustConnection::connect(None)
+ .map_err(|e| debug!("X11 not available, frontmost will return None: {e}"))
+ .ok()?;
+ let root = conn.setup().roots[screen_num].root;
+ let net_active_window = conn
+ .intern_atom(false, b"_NET_ACTIVE_WINDOW")
+ .ok()?
+ .reply()
+ .ok()?
+ .atom;
+ Some(Self {
+ conn,
+ root,
+ net_active_window,
+ })
+ }
+}
+
+impl FrontmostSource for X11Source {
+ fn frontmost_bundle_id(&self) -> Option<String> {
+ // _NET_ACTIVE_WINDOW on the root window holds the focused window's XID.
+ let window: Window = self
+ .conn
+ .get_property(
+ false,
+ self.root,
+ self.net_active_window,
+ AtomEnum::WINDOW,
+ 0,
+ 1,
+ )
+ .ok()?
+ .reply()
+ .ok()?
+ .value32()?
+ .next()?;
+ if window == 0 {
+ return None;
+ }
+
+ // WM_CLASS is instance_name\0class_name\0; the class component is more
+ // stable across window instances and is what profiles should key on
+ // (e.g. "Firefox", not "Navigator").
+ let wm = WmClass::get(&self.conn, window)
+ .ok()?
+ .reply_unchecked()
+ .ok()??;
+ std::str::from_utf8(wm.class())
+ .ok()
+ .filter(|s| !s.is_empty())
+ .map(str::to_owned)
+ }
+
+ fn name(&self) -> &'static str {
+ "x11"
+ }
+}
+
+/// Fallback used when no backend is available (e.g. a pure Wayland session
+/// before any Wayland backend lands). Always reports `None`, so per-app
+/// profile switching simply no-ops rather than erroring.
+struct NullSource;
+
+impl FrontmostSource for NullSource {
+ fn frontmost_bundle_id(&self) -> Option<String> {
+ None
+ }
+
+ fn name(&self) -> &'static str {
+ "null"
+ }
+}
+
+/// Coarse classification of the graphical session, used to order the frontmost
+/// backend candidates.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum SessionKind {
+ X11,
+ Wayland,
+ Unknown,
+}
-/// Return the X11 `WM_CLASS` class component of the currently active window,
-/// e.g. `"Firefox"` or `"Code"`.
+/// Classify the session from the environment. `XDG_SESSION_TYPE` is
+/// authoritative when set to `x11` or `wayland`; otherwise fall back to the
+/// presence of `WAYLAND_DISPLAY` / `DISPLAY`.
+fn detect_session_kind() -> SessionKind {
+ if let Ok(kind) = std::env::var("XDG_SESSION_TYPE") {
+ match kind.as_str() {
+ "wayland" => return SessionKind::Wayland,
+ "x11" => return SessionKind::X11,
+ _ => {}
+ }
+ }
+ if std::env::var_os("WAYLAND_DISPLAY").is_some() {
+ SessionKind::Wayland
+ } else if std::env::var_os("DISPLAY").is_some() {
+ SessionKind::X11
+ } else {
+ SessionKind::Unknown
+ }
+}
+
+/// A backend constructor: returns the backend if it can initialize on this
+/// system, or `None` to fall through to the next candidate.
+type Candidate = fn() -> Option<Box<dyn FrontmostSource>>;
+
+fn x11_candidate() -> Option<Box<dyn FrontmostSource>> {
+ X11Source::connect().map(|s| Box::new(s) as Box<dyn FrontmostSource>)
+}
+
+/// Wayland-native frontmost backends, in priority order: the wlroots
+/// foreign-toplevel protocol (sway, Hyprland, river, …) and the GNOME Shell
+/// D-Bus extension (Mutter). AT-SPI remains a future fallback. Compositors that
+/// support none of these fall through to the X11/XWayland path (which resolves
+/// XWayland windows, `None` for native Wayland apps).
+fn wayland_candidates() -> Vec<Candidate> {
+ vec![wlr_foreign_toplevel::candidate, gnome_shell::candidate]
+}
+
+/// Pick the frontmost backend for this session, trying each candidate in order
+/// and keeping the first that initializes. Called once, lazily, per process.
+fn detect_frontmost_source() -> Box<dyn FrontmostSource> {
+ let session = detect_session_kind();
+ debug!("frontmost: session kind = {session:?}");
+
+ let mut candidates: Vec<Candidate> = match session {
+ SessionKind::Wayland => wayland_candidates(),
+ SessionKind::X11 | SessionKind::Unknown => Vec::new(),
+ };
+ // X11 / XWayland: the primary path on an X11 session and the universal
+ // fallback everywhere else.
+ candidates.push(x11_candidate);
+
+ for candidate in candidates {
+ if let Some(source) = candidate() {
+ debug!("frontmost: using '{}' backend", source.name());
+ // On Wayland, landing on the X11 backend means no native Wayland
+ // frontmost source was available, so native Wayland windows will
+ // report None (only XWayland windows resolve). Hint at the fix.
+ if session == SessionKind::Wayland && source.name() == "x11" {
+ debug!(
+ "frontmost: on Wayland but using the X11/XWayland backend; \
+ native Wayland windows will report None. Install the OpenLogi \
+ GNOME Shell extension (GNOME) or use a wlroots compositor."
+ );
+ }
+ return source;
+ }
+ }
+
+ debug!("frontmost: no usable backend; frontmost_bundle_id will return None");
+ Box::new(NullSource)
+}
+
+static FRONTMOST_SOURCE: LazyLock<Box<dyn FrontmostSource>> =
+ LazyLock::new(detect_frontmost_source);
+
+/// Return an opaque identifier of the currently frontmost application, or
+/// `None` when unavailable. Dispatches to the backend chosen at startup.
///
-/// Returns `None` when there is no active window, when the X11 display is
-/// unavailable (Wayland-only session without XWayland), or on read error.
-/// Native Wayland windows are not visible through this path.
+/// On an X11 session this is the `WM_CLASS` class component (e.g. "Firefox").
+/// On a Wayland session the wlr-foreign-toplevel or gnome-shell backend is used
+/// when available; XWayland windows fall back to the X11 backend.
pub(crate) fn frontmost_bundle_id() -> Option<String> {
- let state = X11_STATE.as_ref()?;
-
- // _NET_ACTIVE_WINDOW on the root window holds the focused window's XID.
- let window: Window = state
- .conn
- .get_property(
- false,
- state.root,
- state.net_active_window,
- AtomEnum::WINDOW,
- 0,
- 1,
- )
- .ok()?
- .reply()
- .ok()?
- .value32()?
- .next()?;
- if window == 0 {
- return None;
- }
-
- // WM_CLASS is instance_name\0class_name\0; the class component is more
- // stable across window instances and is what profiles should key on
- // (e.g. "Firefox", not "Navigator").
- let wm = WmClass::get(&state.conn, window)
- .ok()?
- .reply_unchecked()
- .ok()??;
- std::str::from_utf8(wm.class())
- .ok()
- .filter(|s| !s.is_empty())
- .map(str::to_owned)
+ FRONTMOST_SOURCE.frontmost_bundle_id()
}
#[cfg(test)]
@@ -576,7 +737,8 @@ mod tests {
translate(&event, false),
Some(MouseEvent::Button {
id: ButtonId::LeftClick,
- pressed: true
+ pressed: true,
+ device: None,
})
);
}
@@ -588,7 +750,8 @@ mod tests {
translate(&event, false),
Some(MouseEvent::Button {
id: ButtonId::LeftClick,
- pressed: false
+ pressed: false,
+ device: None,
})
);
}
@@ -600,7 +763,8 @@ mod tests {
translate(&event, false),
Some(MouseEvent::Button {
id: ButtonId::Back,
- pressed: true
+ pressed: true,
+ device: None,
})
);
}
@@ -612,7 +776,8 @@ mod tests {
translate(&event, false),
Some(MouseEvent::Button {
id: ButtonId::Back,
- pressed: true
+ pressed: true,
+ device: None,
})
);
}
@@ -624,7 +789,8 @@ mod tests {
translate(&event, false),
Some(MouseEvent::Button {
id: ButtonId::Forward,
- pressed: true
+ pressed: true,
+ device: None,
})
);
}
@@ -748,6 +914,7 @@ mod tests {
rel: Option<AttributeSet<RelativeAxisCode>>,
abs: Option<AttributeSet<AbsoluteAxisCode>>,
props: AttributeSet<PropType>,
+ vendor_id: u32,
}
impl Caps {
@@ -770,6 +937,7 @@ mod tests {
),
abs: None,
props: AttributeSet::new(),
+ vendor_id: LOGITECH_VENDOR_ID,
}
}
@@ -780,6 +948,7 @@ mod tests {
self.rel.as_deref(),
self.abs.as_deref(),
&self.props,
+ self.vendor_id,
)
}
}
@@ -799,6 +968,20 @@ mod tests {
);
}
+ #[test]
+ fn non_logitech_mouse_is_not_hookable() {
+ let mut caps = Caps::mouse();
+ caps.name = Some("Apple SPI Trackpad");
+ caps.vendor_id = 0x05ac;
+ assert!(
+ !caps.is_hookable(),
+ "foreign vendors must never be exclusively grabbed"
+ );
+ caps.name = Some("Generic USB Mouse");
+ caps.vendor_id = 0x1234;
+ assert!(!caps.is_hookable());
+ }
+
#[test]
fn touchpad_is_not_hookable() {
// A libinput touchpad: clicks via BTN_LEFT but moves via multitouch
diff --git a/crates/openlogi-hook/src/linux/gnome_shell.rs b/crates/openlogi-hook/src/linux/gnome_shell.rs
new file mode 100644
index 0000000000000000000000000000000000000000..8e14c4349caa25ffacdb236fea166a27717116e4
--- /dev/null
+++ b/crates/openlogi-hook/src/linux/gnome_shell.rs
@@ -0,0 +1,97 @@
+//! Frontmost backend for GNOME Shell (Wayland and X11), via a small companion
+//! GNOME Shell extension that exports the focused window's WM_CLASS over D-Bus.
+//!
+//! GNOME (Mutter) implements neither wlr-foreign-toplevel nor any portal for
+//! the focused window, and `org.gnome.Shell.Eval` is disabled by default, so a
+//! privileged GNOME Shell extension is the only way to read the focused window
+//! on a GNOME Wayland session. The extension lives in `gnome-shell-extension/`
+//! in this crate and must be installed and enabled for this backend to
+//! activate. When it is absent, [`GnomeShellSource::connect`] fails and backend
+//! selection falls through to the next candidate (XWayland via X11).
+//!
+//! The extension returns the WM_CLASS — not the `.desktop` id — so the
+//! identifier matches the X11 backend's, keeping per-app profile keys
+//! consistent across X11, XWayland, and GNOME Wayland sessions.
+//!
+//! Only the session-bus connection is held in the backend; a lightweight proxy
+//! is built per poll (no extra D-Bus traffic beyond the method call itself).
+
+use std::time::Duration;
+
+use tracing::debug;
+use zbus::blocking::Connection;
+use zbus::blocking::connection::Builder;
+use zbus::proxy;
+
+use super::FrontmostSource;
+
+/// Cap on every D-Bus call to the extension. Without it, a stalled GNOME Shell
+/// would block the polling thread forever (the probe runs inside the
+/// `FRONTMOST_SOURCE` initializer, so a stall there would block every thread
+/// that touches it).
+const METHOD_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// D-Bus proxy for the OpenLogi GNOME Shell extension. Only the blocking proxy
+/// is generated (`gen_async = false`), matching the synchronous poll contract.
+#[proxy(
+ interface = "org.openlogi.Frontmost",
+ default_service = "org.openlogi.Frontmost",
+ default_path = "/org/openlogi/Frontmost",
+ gen_async = false
+)]
+trait Frontmost {
+ /// WM_CLASS of the focused window, or "" when nothing is focused.
+ #[zbus(name = "GetFocusedWmClass")]
+ fn get_focused_wm_class(&self) -> zbus::Result<String>;
+}
+
+/// Frontmost backend talking to the OpenLogi GNOME Shell extension over the
+/// session bus.
+struct GnomeShellSource {
+ conn: Connection,
+}
+
+impl GnomeShellSource {
+ fn connect() -> Option<Self> {
+ let conn = Builder::session()
+ .map_err(|e| debug!("gnome-shell: no session bus: {e}"))
+ .ok()?
+ .method_timeout(METHOD_TIMEOUT)
+ .build()
+ .map_err(|e| debug!("gnome-shell: connection build failed: {e}"))
+ .ok()?;
+ // Probe reachability: a successful call (even an empty result) means the
+ // OpenLogi extension is installed and exporting the service. An error
+ // means it is absent/disabled, so this backend must not be selected.
+ let proxy = FrontmostProxy::new(&conn)
+ .map_err(|e| debug!("gnome-shell: proxy build failed: {e}"))
+ .ok()?;
+ proxy
+ .get_focused_wm_class()
+ .map_err(|e| debug!("gnome-shell: OpenLogi extension not reachable: {e}"))
+ .ok()?;
+ Some(Self { conn })
+ }
+}
+
+impl FrontmostSource for GnomeShellSource {
+ fn frontmost_bundle_id(&self) -> Option<String> {
+ let proxy = FrontmostProxy::new(&self.conn)
+ .map_err(|e| debug!("gnome-shell: proxy build failed: {e}"))
+ .ok()?;
+ let wm_class = proxy
+ .get_focused_wm_class()
+ .map_err(|e| debug!("gnome-shell: poll failed (extension gone or bus down?): {e}"))
+ .ok()?;
+ (!wm_class.is_empty()).then_some(wm_class)
+ }
+
+ fn name(&self) -> &'static str {
+ "gnome-shell"
+ }
+}
+
+/// Candidate constructor registered in [`super::wayland_candidates`].
+pub(super) fn candidate() -> Option<Box<dyn FrontmostSource>> {
+ GnomeShellSource::connect().map(|s| Box::new(s) as Box<dyn FrontmostSource>)
+}
diff --git a/crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs b/crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs
new file mode 100644
index 0000000000000000000000000000000000000000..5ce58f5c352bc49d6a61b19f34785709faaa0cb3
--- /dev/null
+++ b/crates/openlogi-hook/src/linux/wlr_foreign_toplevel.rs
@@ -0,0 +1,489 @@
+//! Frontmost backend using the wlroots `zwlr_foreign_toplevel_management_v1`
+//! protocol.
+//!
+//! The manager hands out one handle per toplevel window; each handle reports
+//! its `app_id` and a `state` set. The frontmost window is the toplevel whose
+//! state set contains `activated`, and its `app_id` is what we return.
+//!
+//! Note on the returned identifier: this is the xdg-shell `app_id` (e.g.
+//! "org.mozilla.firefox", "Alacritty", "foot"), which is a *different namespace*
+//! from the `WM_CLASS` returned by the X11 and gnome-shell backends (e.g.
+//! "Firefox"). Because profile lookup is an exact match, a per-app profile
+//! created under wlroots will not match under GNOME/X11 and vice versa. We
+//! deliberately return the native `app_id` rather than a lossy WM_CLASS
+//! approximation (stripping reverse-DNS and capitalizing guesses wrong for many
+//! apps); reconciling the two namespaces belongs in a single normalization
+//! layer, not here. See the `FrontmostSource` trait doc in `linux.rs`.
+//!
+//! This protocol is implemented by wlroots-based compositors (sway, Hyprland,
+//! river, Wayfire, …). GNOME (Mutter) and KDE (KWin) do not advertise it, so
+//! [`connect`](WlrForeignToplevelSource::connect) returns `None` there and the
+//! caller falls through to the next backend candidate.
+//!
+//! ## Dispatch model
+//!
+//! The protocol is event-driven, but the [`super::FrontmostSource`] contract is
+//! a synchronous poll (~1 Hz from `openlogi-gui::app_watcher`). Two primitives
+//! bridge that gap:
+//!
+//! - **`drain_events`** (poll path) — flushes pending writes, then attempts a
+//! non-blocking `prepare_read` + `read` with a short 25 ms `poll(2)` cap.
+//! If nothing arrives in time the last known state is returned unchanged;
+//! millisecond-stale frontmost data is acceptable by design. A genuine
+//! connection error here (as opposed to a timeout) marks the session
+//! finished so the next poll reconnects, the same as an explicit
+//! `Finished` event.
+//!
+//! - **`timed_roundtrip`** (init path) — sends `wl_display.sync`, then loops
+//! `flush` → `poll(2)` → `read` → `dispatch_pending` until the sync callback
+//! fires or `INIT_TIMEOUT` (5 s) expires. If the deadline is hit the candidate
+//! returns `None` so backend selection falls through — the same contract as
+//! every other backend.
+//!
+//! Both helpers use `poll(2)` via the `libc` crate (already a Linux dependency)
+//! with `Instant`-based remaining-time accounting and `EINTR` retry.
+
+use std::collections::HashMap;
+use std::os::unix::io::AsRawFd;
+use std::sync::Mutex;
+use std::time::{Duration, Instant};
+
+use tracing::{debug, info, warn};
+use wayland_client::backend::ObjectId;
+use wayland_client::protocol::wl_callback;
+use wayland_client::protocol::wl_registry::{self, WlRegistry};
+use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, event_created_child};
+use wayland_protocols_wlr::foreign_toplevel::v1::client::zwlr_foreign_toplevel_handle_v1::{
+ self, ZwlrForeignToplevelHandleV1,
+};
+use wayland_protocols_wlr::foreign_toplevel::v1::client::zwlr_foreign_toplevel_manager_v1::{
+ self, ZwlrForeignToplevelManagerV1,
+};
+
+use super::FrontmostSource;
+
+/// Highest protocol version this backend understands. The events it relies on
+/// (`app_id`, `state`, `done`, `closed`) exist since v1, so binding is capped
+/// here to stay within what `wayland-protocols-wlr` generates.
+const MANAGER_MAX_VERSION: u32 = 3;
+
+/// Deadline for the two `wl_display.sync` round-trips in `Session::open`.
+/// Mirrors `gnome_shell::METHOD_TIMEOUT`: both guard the `FRONTMOST_SOURCE`
+/// `LazyLock` initializer against a stalled compositor socket.
+const INIT_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// Maximum time the poll-path drain will wait for new Wayland events. Stale
+/// frontmost data within this window is acceptable by design.
+const POLL_CAP_MS: u64 = 25;
+
+/// Accumulated per-toplevel data. wlr sends individual property events and then
+/// a `done` marking a consistent snapshot, so updates are staged in `pending_*`
+/// and committed on `done`.
+#[derive(Default)]
+struct Toplevel {
+ app_id: Option<String>,
+ activated: bool,
+ pending_app_id: Option<String>,
+ pending_activated: bool,
+}
+
+/// Dispatch state: the bound manager plus the toplevels seen so far.
+#[derive(Default)]
+struct State {
+ manager: Option<ZwlrForeignToplevelManagerV1>,
+ toplevels: HashMap<ObjectId, Toplevel>,
+ /// Set when the compositor sends `finished`; triggers a reconnect on the
+ /// next poll instead of permanently disabling the backend.
+ finished: bool,
+ /// Flipped to `true` by the `wl_callback::Done` handler; used by
+ /// `timed_roundtrip` to detect that the sync echo arrived.
+ sync_done: bool,
+}
+
+impl Dispatch<WlRegistry, ()> for State {
+ fn event(
+ state: &mut Self,
+ registry: &WlRegistry,
+ event: wl_registry::Event,
+ (): &(),
+ _: &Connection,
+ qh: &QueueHandle<Self>,
+ ) {
+ if let wl_registry::Event::Global {
+ name,
+ interface,
+ version,
+ } = event
+ && interface == ZwlrForeignToplevelManagerV1::interface().name
+ {
+ let version = version.min(MANAGER_MAX_VERSION);
+ let manager =
+ registry.bind::<ZwlrForeignToplevelManagerV1, (), Self>(name, version, qh, ());
+ state.manager = Some(manager);
+ }
+ }
+}
+
+impl Dispatch<ZwlrForeignToplevelManagerV1, ()> for State {
+ fn event(
+ state: &mut Self,
+ _: &ZwlrForeignToplevelManagerV1,
+ event: zwlr_foreign_toplevel_manager_v1::Event,
+ (): &(),
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ ) {
+ match event {
+ zwlr_foreign_toplevel_manager_v1::Event::Toplevel { toplevel } => {
+ state.toplevels.insert(toplevel.id(), Toplevel::default());
+ }
+ zwlr_foreign_toplevel_manager_v1::Event::Finished => {
+ // The compositor is reloading or restarting. Mark the session
+ // finished; the next poll will reconnect automatically.
+ warn!(
+ "wlr-foreign-toplevel: compositor sent Finished — \
+ will reconnect on next poll"
+ );
+ state.finished = true;
+ state.manager = None;
+ }
+ _ => {}
+ }
+ }
+
+ // The `toplevel` event creates a new handle object; tell the backend to
+ // route its events to this same `State` with `()` user data.
+ event_created_child!(State, ZwlrForeignToplevelManagerV1, [
+ zwlr_foreign_toplevel_manager_v1::EVT_TOPLEVEL_OPCODE => (ZwlrForeignToplevelHandleV1, ()),
+ ]);
+}
+
+impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for State {
+ fn event(
+ state: &mut Self,
+ handle: &ZwlrForeignToplevelHandleV1,
+ event: zwlr_foreign_toplevel_handle_v1::Event,
+ (): &(),
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ ) {
+ use zwlr_foreign_toplevel_handle_v1::Event;
+
+ let id = handle.id();
+ match event {
+ Event::AppId { app_id } => {
+ if let Some(toplevel) = state.toplevels.get_mut(&id) {
+ toplevel.pending_app_id = Some(app_id);
+ }
+ }
+ Event::State { state: states } => {
+ let activated = is_activated(&states);
+ if let Some(toplevel) = state.toplevels.get_mut(&id) {
+ toplevel.pending_activated = activated;
+ }
+ }
+ Event::Done => {
+ if let Some(toplevel) = state.toplevels.get_mut(&id) {
+ // app_id is sent only when it changes, and a compositor may
+ // emit State + Done before the first AppId. Committing
+ // `pending_app_id` unconditionally would clobber a known id
+ // (or the initial one) with None, so only overwrite when a
+ // value is actually pending. `activated` defaults to false,
+ // which is the correct state for a window that sent none.
+ if toplevel.pending_app_id.is_some() {
+ toplevel.app_id = toplevel.pending_app_id.clone();
+ }
+ toplevel.activated = toplevel.pending_activated;
+ }
+ }
+ Event::Closed => {
+ state.toplevels.remove(&id);
+ handle.destroy();
+ }
+ // Title, output enter/leave, and parent are not needed for frontmost.
+ _ => {}
+ }
+ }
+}
+
+impl Dispatch<wl_callback::WlCallback, ()> for State {
+ fn event(
+ state: &mut Self,
+ _: &wl_callback::WlCallback,
+ event: wl_callback::Event,
+ (): &(),
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ ) {
+ if let wl_callback::Event::Done { .. } = event {
+ state.sync_done = true;
+ }
+ }
+}
+
+/// The `state` event carries a `wl_array` of native-endian `u32` state values.
+/// A toplevel is frontmost iff the `activated` value is present in that set.
+fn is_activated(states: &[u8]) -> bool {
+ use zwlr_foreign_toplevel_handle_v1::State;
+
+ states.chunks_exact(4).any(|chunk| {
+ let value = u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
+ State::try_from(value).is_ok_and(|s| s == State::Activated)
+ })
+}
+
+/// Returns the milliseconds remaining until `deadline`, clamped to `[0, i32::MAX]`
+/// for use as a `libc::poll` timeout. Returns 0 when the deadline has passed.
+fn millis_until(deadline: Instant) -> i32 {
+ i32::try_from(
+ deadline
+ .saturating_duration_since(Instant::now())
+ .as_millis()
+ .min(i32::MAX as u128),
+ )
+ .unwrap_or(i32::MAX)
+}
+
+/// Calls `poll(2)` on `fd` (waiting for `POLLIN | POLLERR`) with a deadline.
+/// Retries on `EINTR` with the remaining time. Returns `true` if the fd became
+/// readable, `false` on timeout or error.
+fn poll_fd(fd: libc::c_int, deadline: Instant) -> bool {
+ let mut pfd = libc::pollfd {
+ fd,
+ events: libc::POLLIN | libc::POLLERR,
+ revents: 0,
+ };
+ loop {
+ let timeout_ms = millis_until(deadline);
+ if timeout_ms == 0 {
+ return false;
+ }
+ let r = unsafe { libc::poll(&raw mut pfd, 1, timeout_ms) };
+ if r > 0 {
+ return true;
+ }
+ if r == 0 {
+ return false;
+ }
+ // r < 0 — check errno
+ let e = unsafe { *libc::__errno_location() };
+ if e != libc::EINTR {
+ return false;
+ }
+ // EINTR: retry with remaining deadline
+ }
+}
+
+/// Sends `wl_display.sync` and spins `flush → poll → read → dispatch_pending`
+/// until the sync callback fires or `deadline` is reached. Returns `true` on
+/// success, `false` on timeout or connection error.
+fn timed_roundtrip(
+ conn: &Connection,
+ queue: &mut EventQueue<State>,
+ state: &mut State,
+ deadline: Instant,
+) -> bool {
+ state.sync_done = false;
+ let qh = queue.handle();
+ conn.display().sync(&qh, ());
+
+ loop {
+ if queue.flush().is_err() {
+ return false;
+ }
+ if queue.dispatch_pending(state).is_err() {
+ return false;
+ }
+ if state.sync_done {
+ return true;
+ }
+ if millis_until(deadline) == 0 {
+ return false;
+ }
+
+ match queue.prepare_read() {
+ None => {
+ // Events are already buffered; loop back to dispatch.
+ }
+ Some(guard) => {
+ let fd = guard.connection_fd().as_raw_fd();
+ if !poll_fd(fd, deadline) {
+ // Timed out or error — candidate falls through.
+ return false;
+ }
+ if guard.read().is_err() {
+ return false;
+ }
+ }
+ }
+ }
+}
+
+/// Drains pending compositor events without blocking longer than `POLL_CAP_MS`.
+/// Used on every frontmost poll. Stale data within the cap is acceptable by
+/// design; a genuine connection error (e.g. the compositor crashing and
+/// closing the socket, rather than sending a graceful `Finished`) marks
+/// `state.finished` so the caller reconnects on the next poll instead of
+/// returning stale state forever.
+fn drain_events(queue: &mut EventQueue<State>, state: &mut State) {
+ if queue.flush().is_err() || queue.dispatch_pending(state).is_err() {
+ warn!(
+ "wlr-foreign-toplevel: connection error while draining — will reconnect on next poll"
+ );
+ state.finished = true;
+ return;
+ }
+
+ let deadline = Instant::now() + Duration::from_millis(POLL_CAP_MS);
+ match queue.prepare_read() {
+ None => {
+ // Already had buffered events; dispatch_pending above handled them.
+ }
+ Some(guard) => {
+ let fd = guard.connection_fd().as_raw_fd();
+ if poll_fd(fd, deadline)
+ && (guard.read().is_err() || queue.dispatch_pending(state).is_err())
+ {
+ warn!(
+ "wlr-foreign-toplevel: connection error while draining — will reconnect on next poll"
+ );
+ state.finished = true;
+ }
+ // If poll timed out, guard is dropped here and we return stale state.
+ }
+ }
+}
+
+/// One live Wayland session: connection + event queue + dispatch state.
+///
+/// Grouping all three behind a single mutex means the whole session can be
+/// dropped and rebuilt atomically when the compositor sends `Finished`.
+struct Session {
+ // Held for RAII — even though `Connection` is Arc-backed, keeping an
+ // explicit handle here ensures the connection outlives the queue.
+ _conn: Connection,
+ queue: EventQueue<State>,
+ state: State,
+}
+
+impl Session {
+ /// Open a fresh connection, bind the manager, and do the initial two
+ /// timed round-trips to populate the toplevel list. Returns `None` when
+ /// the compositor doesn't advertise the protocol, the connection fails,
+ /// or either round-trip exceeds `INIT_TIMEOUT`.
+ fn open() -> Option<Self> {
+ let conn = Connection::connect_to_env()
+ .map_err(|e| debug!("wlr-foreign-toplevel: no Wayland connection: {e}"))
+ .ok()?;
+ let mut queue = conn.new_event_queue();
+ let qh = queue.handle();
+
+ // Registering the registry triggers `global` events on the first
+ // round-trip, where the manager is bound if the compositor advertises it.
+ let _registry = conn.display().get_registry(&qh, ());
+ let mut state = State::default();
+ let deadline = Instant::now() + INIT_TIMEOUT;
+
+ if !timed_roundtrip(&conn, &mut queue, &mut state, deadline) {
+ debug!("wlr-foreign-toplevel: registry round-trip timed out or failed");
+ return None;
+ }
+ if state.manager.is_none() {
+ debug!("wlr-foreign-toplevel: compositor does not advertise the protocol");
+ return None;
+ }
+
+ // Second round-trip: receive the initial toplevel list and properties,
+ // so the first poll already has the active window.
+ if !timed_roundtrip(&conn, &mut queue, &mut state, deadline) {
+ debug!("wlr-foreign-toplevel: initial toplevel round-trip timed out or failed");
+ return None;
+ }
+
+ Some(Self {
+ _conn: conn,
+ queue,
+ state,
+ })
+ }
+}
+
+/// Wayland frontmost backend. Holds the session behind a mutex so the whole
+/// connection can be rebuilt on compositor restart without touching callers.
+struct WlrForeignToplevelSource {
+ // Active session, or `None` when the last reconnect attempt failed.
+ // The mutex bridges the event-driven Wayland runtime to the synchronous
+ // poll contract; the session is only ever touched here, at ~1 Hz.
+ session: Mutex<Option<Session>>,
+}
+
+impl WlrForeignToplevelSource {
+ fn connect() -> Option<Self> {
+ Session::open().map(|s| Self {
+ session: Mutex::new(Some(s)),
+ })
+ }
+}
+
+impl FrontmostSource for WlrForeignToplevelSource {
+ fn frontmost_bundle_id(&self) -> Option<String> {
+ let mut guard = self.session.lock().ok()?;
+
+ // Reconnect when the compositor sent `Finished` (compositor reload /
+ // restart) or when a prior reconnect attempt failed.
+ let needs_reconnect = guard.as_ref().is_none_or(|s| s.state.finished);
+ if needs_reconnect {
+ *guard = Session::open();
+ if guard.is_some() {
+ info!("wlr-foreign-toplevel: reconnected");
+ } else {
+ debug!("wlr-foreign-toplevel: reconnect pending, retrying next poll");
+ }
+ }
+
+ let Session { queue, state, .. } = guard.as_mut()?;
+ drain_events(queue, state);
+ if state.finished {
+ // `Finished` arrived during this drain; reconnect on the next call.
+ return None;
+ }
+
+ state
+ .toplevels
+ .values()
+ .find(|toplevel| toplevel.activated)
+ .and_then(|toplevel| toplevel.app_id.clone())
+ }
+
+ fn name(&self) -> &'static str {
+ "wlr-foreign-toplevel"
+ }
+}
+
+/// Candidate constructor registered in [`super::wayland_candidates`].
+pub(super) fn candidate() -> Option<Box<dyn FrontmostSource>> {
+ WlrForeignToplevelSource::connect().map(|s| Box::new(s) as Box<dyn FrontmostSource>)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::{Duration, Instant};
+
+ use super::millis_until;
+
+ #[test]
+ fn millis_until_elapsed_deadline_is_zero() {
+ // `deadline` is captured before the call; by the time `millis_until`
+ // reads `Instant::now()` the deadline is at or before now, so
+ // `saturating_duration_since` returns `Duration::ZERO` → 0 ms.
+ let deadline = Instant::now();
+ assert_eq!(millis_until(deadline), 0);
+ }
+
+ #[test]
+ fn millis_until_future_deadline_is_positive() {
+ let future = Instant::now() + Duration::from_secs(10);
+ let ms = millis_until(future);
+ assert!(ms > 0 && ms <= 10_000);
+ }
+}
diff --git a/crates/openlogi-hook/src/macos.rs b/crates/openlogi-hook/src/macos.rs
index 43dbb3c0960efe8dd0402d9304517a9fa6631a0a..9d1d12bd0df0ba5049553c9dd0831d39750d34cc 100644
--- a/crates/openlogi-hook/src/macos.rs
+++ b/crates/openlogi-hook/src/macos.rs
@@ -2,9 +2,11 @@
use std::cell::RefCell;
use std::collections::HashMap;
-use std::sync::atomic::{AtomicBool, Ordering};
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, mpsc};
use std::thread;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
use core_foundation::base::{CFTypeRef, TCFType as _};
use core_foundation::number::CFNumber;
@@ -13,14 +15,15 @@ use core_foundation::runloop::{
};
use core_foundation::string::{CFString, CFStringRef};
use core_graphics::event::{
- CGEvent, CGEventField, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement,
- CGEventTapProxy, CGEventType, CallbackResult, EventField,
+ CGEvent, CGEventField, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions,
+ CGEventTapPlacement, CGEventTapProxy, CGEventType, CallbackResult, EventField,
};
use foreign_types_shared::ForeignType as _;
use tracing::{debug, error, warn};
use crate::{
- ButtonId, EventDevice, EventDisposition, EventTapInfo, HookError, MouseEvent, TapLocation,
+ ButtonId, EventDevice, EventDisposition, EventTapInfo, HookError, HookEvent, KeyEvent,
+ KeyModifiers, MouseEvent, TapLocation,
};
/// Everything `Hook` needs to control the background thread.
@@ -261,6 +264,43 @@ fn button_number_to_id(n: i64) -> Option<ButtonId> {
}
}
+/// Best-effort device identity for a button event's HID sender.
+fn button_source(event: &CGEvent) -> Option<crate::EventDevice> {
+ event_sender_id(event).map(|id| sender_device_info(id).event_device)
+}
+
+/// Map the macOS modifier flags on a `CGEvent` to our [`KeyModifiers`].
+/// `SecondaryFn` is deliberately ignored: it is firmware-internal and
+/// unreliable as a trigger (function-key-remapper spec, Appendix A).
+fn modifiers_from_flags(flags: CGEventFlags) -> KeyModifiers {
+ KeyModifiers {
+ shift: flags.contains(CGEventFlags::CGEventFlagShift),
+ control: flags.contains(CGEventFlags::CGEventFlagControl),
+ option: flags.contains(CGEventFlags::CGEventFlagAlternate),
+ command: flags.contains(CGEventFlags::CGEventFlagCommand),
+ }
+}
+
+/// Translate a keyboard `CGEvent` into a [`KeyEvent`]. Returns `None` for
+/// non-key event types (the mouse path handles those) and for `FlagsChanged`
+/// (modifier state rides on the next key event via its flags; a standalone
+/// flags change carries no key of interest to the remapper).
+fn translate_key(etype: CGEventType, event: &CGEvent) -> Option<KeyEvent> {
+ let pressed = match etype {
+ CGEventType::KeyDown => true,
+ CGEventType::KeyUp => false,
+ // FlagsChanged: no key to remap here.
+ _ => return None,
+ };
+ let keycode = event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE);
+ let keycode = u16::try_from(keycode).ok()?;
+ Some(KeyEvent {
+ keycode,
+ pressed,
+ modifiers: modifiers_from_flags(event.get_flags()),
+ })
+}
+
/// Convert a `CGEvent` to our [`MouseEvent`] vocabulary. Returns `None`
/// for event types we don't translate (e.g. move events, unknown buttons).
fn translate(etype: CGEventType, event: &CGEvent) -> Option<MouseEvent> {
@@ -288,26 +328,38 @@ fn translate(etype: CGEventType, event: &CGEvent) -> Option<MouseEvent> {
CGEventType::LeftMouseDown => Some(MouseEvent::Button {
id: ButtonId::LeftClick,
pressed: true,
+ device: button_source(event),
}),
CGEventType::LeftMouseUp => Some(MouseEvent::Button {
id: ButtonId::LeftClick,
pressed: false,
+ device: button_source(event),
}),
CGEventType::RightMouseDown => Some(MouseEvent::Button {
id: ButtonId::RightClick,
pressed: true,
+ device: button_source(event),
}),
CGEventType::RightMouseUp => Some(MouseEvent::Button {
id: ButtonId::RightClick,
pressed: false,
+ device: button_source(event),
}),
CGEventType::OtherMouseDown => {
let n = event.get_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER);
- button_number_to_id(n).map(|id| MouseEvent::Button { id, pressed: true })
+ button_number_to_id(n).map(|id| MouseEvent::Button {
+ id,
+ pressed: true,
+ device: button_source(event),
+ })
}
CGEventType::OtherMouseUp => {
let n = event.get_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER);
- button_number_to_id(n).map(|id| MouseEvent::Button { id, pressed: false })
+ button_number_to_id(n).map(|id| MouseEvent::Button {
+ id,
+ pressed: false,
+ device: button_source(event),
+ })
}
CGEventType::ScrollWheel => {
// axis 1 = vertical scroll; axis 2 = horizontal scroll. Read the
@@ -419,7 +471,7 @@ fn usable_scroll_delta(event: &CGEvent, axis: ScrollAxisFields) -> f64 {
/// Create the event tap and run loop on a dedicated thread.
pub(crate) fn start(
- cb: impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static,
+ cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
) -> Result<HookInner, HookError> {
if !has_accessibility() {
return Err(HookError::AccessibilityDenied);
@@ -427,7 +479,7 @@ pub(crate) fn start(
// Wrap in Arc so the closure handed to CGEventTap::new captures it by
// clone rather than by move — avoids a second Box allocation.
- let cb: Arc<dyn Fn(MouseEvent) -> EventDisposition + Send + Sync> = Arc::new(cb);
+ let cb: Arc<dyn Fn(HookEvent) -> EventDisposition + Send + Sync> = Arc::new(cb);
let stop = Arc::new(AtomicBool::new(false));
let (rl_tx, rl_rx) = mpsc::channel::<CFRunLoop>();
@@ -457,17 +509,15 @@ pub(crate) fn start(
})
}
-/// Body of the background hook thread.
-#[allow(
- clippy::needless_pass_by_value,
- reason = "rl_tx must be owned: dropping it signals the parent's recv() to return Err on failure paths"
-)]
-fn thread_main(
- cb: Arc<dyn Fn(MouseEvent) -> EventDisposition + Send + Sync>,
- rl_tx: mpsc::Sender<CFRunLoop>,
- stop: Arc<AtomicBool>,
-) {
- let event_types = vec![
+/// How long the tap callback may run before the watchdog treats the agent as
+/// wedging system input and force-exits. An active HID-level tap serialises
+/// every pointer event through this callback; a hang freezes clicks machine-wide.
+const CALLBACK_STUCK_BUDGET: Duration = Duration::from_millis(200);
+
+/// Event types the HID tap observes. Pointer *Dragged variants are required
+/// because a held button makes the OS emit those instead of `MouseMoved`.
+fn hooked_event_types() -> Vec<CGEventType> {
+ vec![
CGEventType::LeftMouseDown,
CGEventType::LeftMouseUp,
CGEventType::RightMouseDown,
@@ -475,31 +525,128 @@ fn thread_main(
CGEventType::OtherMouseDown,
CGEventType::OtherMouseUp,
CGEventType::ScrollWheel,
- // Pointer movement, for gesture-button hold+swipe. A held button makes
- // the OS emit *Dragged rather than MouseMoved, so all four are needed.
- // The callback stays lock-light (see `hook_runtime`) so this high-rate
- // stream can't stall the tap.
CGEventType::MouseMoved,
CGEventType::LeftMouseDragged,
CGEventType::RightMouseDragged,
CGEventType::OtherMouseDragged,
- ];
-
- let tap_result = CGEventTap::new(
- CGEventTapLocation::HID,
- CGEventTapPlacement::HeadInsertEventTap,
- CGEventTapOptions::Default,
- event_types,
- move |_proxy: CGEventTapProxy, etype: CGEventType, event: &CGEvent| {
- let Some(mouse_event) = translate(etype, event) else {
- return CallbackResult::Keep;
- };
- match cb(mouse_event) {
- EventDisposition::PassThrough => CallbackResult::Keep,
- EventDisposition::Suppress => CallbackResult::Drop,
+ // Function-key remapper: F1–F12/Esc arrive as KeyDown/KeyUp.
+ CGEventType::KeyDown,
+ CGEventType::KeyUp,
+ CGEventType::FlagsChanged,
+ ]
+}
+
+/// Invoke the user callback under `catch_unwind`, always failing open.
+fn run_tap_callback(
+ cb: &dyn Fn(HookEvent) -> EventDisposition,
+ etype: CGEventType,
+ event: &CGEvent,
+) -> CallbackResult {
+ let result = catch_unwind(AssertUnwindSafe(|| {
+ // Mouse first, then keyboard; a given event type is one or the other.
+ let hook_event = if let Some(mouse_event) = translate(etype, event) {
+ HookEvent::Mouse(mouse_event)
+ } else if let Some(key_event) = translate_key(etype, event) {
+ HookEvent::Key(key_event)
+ } else {
+ return CallbackResult::Keep;
+ };
+ match cb(hook_event) {
+ EventDisposition::PassThrough => CallbackResult::Keep,
+ EventDisposition::Suppress => CallbackResult::Drop,
+ }
+ }));
+ if let Ok(disposition) = result {
+ disposition
+ } else {
+ error!(
+ "OS mouse-hook callback panicked — passing event through to \
+ avoid wedging system input"
+ );
+ CallbackResult::Keep
+ }
+}
+
+/// Sibling watchdog: if the callback is still entered past the budget, abort
+/// the agent so macOS tears the tap down and system input recovers.
+fn spawn_callback_watchdog(
+ stop: Arc<AtomicBool>,
+ in_callback: Arc<AtomicBool>,
+ entered_at_ms: Arc<AtomicU64>,
+) {
+ let budget_ms = u64::try_from(CALLBACK_STUCK_BUDGET.as_millis()).unwrap_or(200);
+ let _ = thread::Builder::new()
+ .name("openlogi-hook-watchdog".into())
+ .spawn(move || {
+ while !stop.load(Ordering::Relaxed) {
+ thread::sleep(Duration::from_millis(20));
+ if !in_callback.load(Ordering::Acquire) {
+ continue;
+ }
+ let entered = entered_at_ms.load(Ordering::Acquire);
+ if entered == 0 {
+ continue;
+ }
+ let elapsed = unix_now_ms().saturating_sub(entered);
+ if elapsed < budget_ms {
+ continue;
+ }
+ // Re-sample: a fresh entry may have rewritten the stamp between
+ // the first loads and the budget check.
+ if !in_callback.load(Ordering::Acquire)
+ || entered_at_ms.load(Ordering::Acquire) != entered
+ {
+ continue;
+ }
+ error!(
+ stuck_ms = elapsed,
+ "OS mouse-hook callback stuck past budget — exiting agent to \
+ restore system input (HID CGEventTap freeze hazard)"
+ );
+ // Hard exit: disable_tap alone cannot unblock an in-flight
+ // callback, and a live active HID tap freezes all pointer I/O.
+ std::process::exit(78);
}
- },
- );
+ });
+}
+
+/// Body of the background hook thread.
+#[allow(
+ clippy::needless_pass_by_value,
+ reason = "rl_tx must be owned: dropping it signals the parent's recv() to return Err on failure paths"
+)]
+fn thread_main(
+ cb: Arc<dyn Fn(HookEvent) -> EventDisposition + Send + Sync>,
+ rl_tx: mpsc::Sender<CFRunLoop>,
+ stop: Arc<AtomicBool>,
+) {
+ // Watchdog state: the run-loop thread can't observe a stuck callback (it
+ // *is* the callback), so a sibling thread samples these atomics and kills
+ // the process if the budget is exceeded — process death is the only reliable
+ // way to release a wedged HID-level tap and restore system input.
+ let in_callback = Arc::new(AtomicBool::new(false));
+ let entered_at_ms = Arc::new(AtomicU64::new(0));
+
+ let tap_result = {
+ let in_callback = Arc::clone(&in_callback);
+ let entered_at_ms = Arc::clone(&entered_at_ms);
+ CGEventTap::new(
+ CGEventTapLocation::HID,
+ CGEventTapPlacement::HeadInsertEventTap,
+ CGEventTapOptions::Default,
+ hooked_event_types(),
+ move |_proxy: CGEventTapProxy, etype: CGEventType, event: &CGEvent| {
+ // Publish the enter timestamp *before* the in-callback flag so
+ // the watchdog never pairs a fresh entry with a stale stamp
+ // (which would false-positive process::exit after a quiet gap).
+ entered_at_ms.store(unix_now_ms(), Ordering::Relaxed);
+ in_callback.store(true, Ordering::Release);
+ let disposition = run_tap_callback(cb.as_ref(), etype, event);
+ in_callback.store(false, Ordering::Release);
+ disposition
+ },
+ )
+ };
let Ok(tap) = tap_result else {
error!("CGEventTapCreate returned null — Accessibility may have been revoked");
@@ -521,9 +668,15 @@ fn thread_main(
run_loop.add_source(&loop_source, kCFRunLoopCommonModes);
}
tap.enable();
+ spawn_callback_watchdog(
+ Arc::clone(&stop),
+ Arc::clone(&in_callback),
+ Arc::clone(&entered_at_ms),
+ );
if rl_tx.send(run_loop).is_err() {
debug!("hook parent dropped before run loop was ready; stopping");
+ disable_tap(&tap);
return;
}
@@ -549,7 +702,7 @@ fn thread_main(
match CFRunLoop::run_in_mode(
// SAFETY: framework-provided static CFStringRef, 'static.
unsafe { kCFRunLoopDefaultMode },
- std::time::Duration::from_millis(500),
+ Duration::from_millis(500),
false,
) {
CFRunLoopRunResult::Stopped | CFRunLoopRunResult::Finished => break,
@@ -575,6 +728,13 @@ fn thread_main(
disable_tap(&tap);
}
+/// Milliseconds since the Unix epoch for the stuck-callback watchdog.
+fn unix_now_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
+}
+
/// Disable an active event tap now. core-graphics only exposes the enable
/// side of `CGEventTapEnable`, so we bind the disable side ourselves.
fn disable_tap(tap: &CGEventTap) {
diff --git a/crates/openlogi-hook/src/tests.rs b/crates/openlogi-hook/src/tests.rs
index f11dc1c6cdd2c27125fbced413405089c640fb2b..b40a4615ed0f9f70df32026710ba4249ffb1df65 100644
--- a/crates/openlogi-hook/src/tests.rs
+++ b/crates/openlogi-hook/src/tests.rs
@@ -26,6 +26,7 @@ fn mouse_event_clone_and_debug() {
MouseEvent::Button {
id: ButtonId::Back,
pressed: true,
+ device: None,
},
MouseEvent::Scroll {
delta_x: 1.0,
@@ -53,6 +54,40 @@ fn event_disposition_equality() {
assert_ne!(EventDisposition::PassThrough, EventDisposition::Suppress);
}
+/// Remap policy is fail-closed: only known Logitech non-trackpad sources.
+#[test]
+fn source_is_remappable_policy() {
+ assert!(!source_is_remappable(None));
+
+ let trackpad = EventDevice {
+ vendor_id: Some(0),
+ product_id: None,
+ product_name: Some("Apple Internal Keyboard / Trackpad".into()),
+ };
+ assert!(!source_is_remappable(Some(&trackpad)));
+
+ let apple_mouse = EventDevice {
+ vendor_id: Some(0x05ac),
+ product_id: Some(0x030d),
+ product_name: Some("Magic Mouse".into()),
+ };
+ assert!(!source_is_remappable(Some(&apple_mouse)));
+
+ let logi = EventDevice {
+ vendor_id: Some(LOGITECH_VENDOR_ID),
+ product_id: Some(0xb034),
+ product_name: Some("MX Master 3S".into()),
+ };
+ assert!(source_is_remappable(Some(&logi)));
+
+ let logi_by_name = EventDevice {
+ vendor_id: None,
+ product_id: None,
+ product_name: Some("Logitech USB Receiver".into()),
+ };
+ assert!(source_is_remappable(Some(&logi_by_name)));
+}
+
/// On unsupported targets (not macOS, Linux, or Windows), `Hook::start`
/// returns `Unsupported`. The cfg predates the Windows port (#167) — Windows
/// belongs with the supported targets below, where this stale form made
diff --git a/crates/openlogi-hook/src/windows.rs b/crates/openlogi-hook/src/windows.rs
index 781e1e01a20453bc9d9164720bfcb3deff627786..4da07ad92b98bb6dd85503b99c25850d3e07298a 100644
--- a/crates/openlogi-hook/src/windows.rs
+++ b/crates/openlogi-hook/src/windows.rs
@@ -24,7 +24,7 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{
XBUTTON2,
};
-use crate::{ButtonId, EventDisposition, HookError, MouseEvent};
+use crate::{ButtonId, EventDisposition, HookError, HookEvent, MouseEvent};
const WHEEL_DELTA: f32 = 120.0;
@@ -40,7 +40,7 @@ thread_local! {
static LAST_POINT: Cell<Option<POINT>> = const { Cell::new(None) };
}
-type HookCallback = Arc<dyn Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static>;
+type HookCallback = Arc<dyn Fn(HookEvent) -> EventDisposition + Send + Sync + 'static>;
static CALLBACK: Mutex<Option<HookCallback>> = Mutex::new(None);
@@ -50,7 +50,7 @@ pub(crate) struct HookInner {
}
pub(crate) fn start(
- cb: impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static,
+ cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
) -> Result<HookInner, HookError> {
let callback: HookCallback = Arc::new(cb);
let (ready_tx, ready_rx) = mpsc::channel();
@@ -212,7 +212,9 @@ unsafe extern "system" fn mouse_proc(code: i32, wparam: WPARAM, lparam: LPARAM)
let callback = CALLBACK.lock().ok().and_then(|slot| slot.clone());
let disposition = callback
.as_ref()
- .map_or(EventDisposition::PassThrough, |cb| cb(event));
+ .map_or(EventDisposition::PassThrough, |cb| {
+ cb(HookEvent::Mouse(event))
+ });
match disposition {
EventDisposition::PassThrough => call_next(code, wparam, lparam),
EventDisposition::Suppress => 1,
@@ -263,7 +265,14 @@ fn translate_event(wparam: WPARAM, data: MSLLHOOKSTRUCT) -> Option<MouseEvent> {
},
_ => return None,
};
- return Some(MouseEvent::Button { id, pressed });
+ // Windows WH_MOUSE_LL does not expose a cheap device identity; leave
+ // `device` as None so remapping still works (see hook_runtime: non-macOS
+ // keeps remapping when attribution is absent).
+ return Some(MouseEvent::Button {
+ id,
+ pressed,
+ device: None,
+ });
}
match wparam as u32 {
diff --git a/crates/openlogi-inject/Cargo.toml b/crates/openlogi-inject/Cargo.toml
index 7b372af30766cf077145be33be25a9937da43614..af0b65ac416206ab2d869d4b6074ef479c879e1a 100644
--- a/crates/openlogi-inject/Cargo.toml
+++ b/crates/openlogi-inject/Cargo.toml
@@ -11,7 +11,7 @@ keywords = ["logitech", "input", "uinput", "sendinput", "mouse"]
categories = ["hardware-support", "os"]
[dependencies]
-openlogi-core = { path = "../openlogi-core", version = "0.6.23" }
+openlogi-core = { path = "../openlogi-core", version = "0.6.24" }
tracing = { workspace = true }
[dev-dependencies]
@@ -33,6 +33,8 @@ objc2-app-kit = { workspace = true, features = [
"NSEvent",
"NSGraphicsContext",
"objc2-core-graphics",
+ "NSWorkspace",
+ "NSRunningApplication",
] }
objc2-core-graphics = { version = "0.3.2", features = ["CGEvent"] }
objc2-foundation = { workspace = true }
diff --git a/crates/openlogi-inject/src/inject.rs b/crates/openlogi-inject/src/inject.rs
index 6c5c67be07712d4d7225d732971b833e0a8422f3..c5b75b8317d873f511ff59c078f2a6f5432d9c9f 100644
--- a/crates/openlogi-inject/src/inject.rs
+++ b/crates/openlogi-inject/src/inject.rs
@@ -70,6 +70,26 @@ pub fn execute(action: &Action) {
}
}
+/// Navigate the browser identified by `pid` backwards or forwards using the
+/// Accessibility API (`AXPress` on the "Go back" / "Go forward" toolbar button).
+///
+/// Call this from the gesture watcher **at the moment the button press arrives**
+/// so `pid` reflects the correct frontmost app rather than whatever happens to
+/// be frontmost when the async dispatch completes. Returns `true` on success.
+/// No-op (returns `false`) on non-macOS platforms.
+#[must_use]
+pub fn ax_navigate_browser(pid: i32, forward: bool) -> bool {
+ #[cfg(target_os = "macos")]
+ {
+ macos::ax_browser_navigate(forward, Some(pid))
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ let _ = (pid, forward);
+ false
+ }
+}
+
/// Synthesise a horizontal scroll of `delta` wheel lines at the current focus.
///
/// Used by the gesture/thumbwheel capture watcher to re-inject the MX thumb
diff --git a/crates/openlogi-inject/src/inject/linux.rs b/crates/openlogi-inject/src/inject/linux.rs
index 15216e20affe91f0d53a9fcb49bf02977e1f841f..c97fd38a9ba295ae0a7c66c45b578de869a39dac 100644
--- a/crates/openlogi-inject/src/inject/linux.rs
+++ b/crates/openlogi-inject/src/inject/linux.rs
@@ -12,7 +12,7 @@ use evdev::uinput::VirtualDevice;
use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode};
use zbus::blocking::Connection as DbusConn;
-use openlogi_core::binding::Action;
+use openlogi_core::binding::{Action, WorkflowStep};
/// Linux implementation: inject events via a shared `uinput` virtual device.
pub(super) fn execute(action: &Action) {
@@ -64,6 +64,8 @@ pub(super) fn execute(action: &Action) {
// ── System ────────────────────────────────────────────────────────
// logind LockSessions() via the system bus; falls back to Super+L.
Action::LockScreen => lock_screen(),
+ // logind Suspend() via the system bus.
+ Action::Sleep => sleep_system(),
// Region vs full-screen capture depends on the desktop environment's
// screenshot handler for Print Screen, so both map to the same key.
Action::Screenshot | Action::CaptureRegion => press_key(&[], KeyCode::KEY_SYSRQ),
@@ -108,9 +110,71 @@ pub(super) fn execute(action: &Action) {
};
press_key(&modifiers_to_keycodes(combo.modifiers), key);
}
+ Action::TypeText(text) => {
+ tracing::warn!(
+ chars = text.chars().count(),
+ "TypeText injection is not implemented on Linux yet"
+ );
+ }
+ Action::RunAppleScript(_) => {
+ tracing::warn!("RunAppleScript is only supported on macOS");
+ }
+ Action::RunShellCommand(cmd) => run_shell_command_async(cmd.clone()),
+ Action::Workflow(steps) => run_workflow_async(steps.clone()),
}
}
+fn run_shell_command_async(cmd: String) {
+ std::thread::spawn(move || run_shell_command(&cmd));
+}
+
+fn run_workflow_async(steps: Vec<WorkflowStep>) {
+ std::thread::spawn(move || run_workflow(&steps));
+}
+
+fn run_workflow(steps: &[WorkflowStep]) {
+ for step in steps {
+ match step {
+ WorkflowStep::TypeText(text) => {
+ tracing::warn!(
+ chars = text.chars().count(),
+ "workflow TypeText injection is not implemented on Linux yet"
+ );
+ }
+ WorkflowStep::PressKey(combo) => {
+ if combo.key_code == 0 {
+ tracing::warn!(
+ chord = %combo.rendered_label(),
+ "workflow PressKey with no key code; step ignored"
+ );
+ continue;
+ }
+ let Some(key) = macos_vk_to_linux(combo.key_code) else {
+ tracing::warn!(
+ key_code = combo.key_code,
+ "workflow PressKey key code has no Linux mapping; step ignored"
+ );
+ continue;
+ };
+ press_key(&modifiers_to_keycodes(combo.modifiers), key);
+ }
+ WorkflowStep::Delay { millis } => {
+ std::thread::sleep(std::time::Duration::from_millis(*millis));
+ }
+ WorkflowStep::RunAppleScript(_) => {
+ tracing::warn!("workflow RunAppleScript is only supported on macOS");
+ }
+ WorkflowStep::RunShellCommand(cmd) => run_shell_command(cmd),
+ }
+ }
+}
+
+fn run_shell_command(cmd: &str) {
+ let _ = std::process::Command::new("/bin/sh")
+ .args(["-c", cmd])
+ .output();
+}
+
const DEVICE_NAME: &str = "OpenLogi action injector";
static VIRTUAL_INPUT: LazyLock<Option<Mutex<VirtualDevice>>> = LazyLock::new(|| {
@@ -414,6 +478,27 @@ fn lock_screen() {
press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L);
}
+/// Suspend the system via logind's `Suspend()` on the system bus. The
+/// `false` argument declines the "interactive" polkit prompt — if the
+/// session isn't allowed to suspend, the call fails and is logged rather
+/// than popping an authentication dialog from a background agent.
+fn sleep_system() {
+ let Some(conn) = SYSTEM_BUS.as_ref() else {
+ tracing::warn!("no system bus — Sleep skipped");
+ return;
+ };
+ match conn.call_method(
+ Some("org.freedesktop.login1"),
+ "/org/freedesktop/login1",
+ Some("org.freedesktop.login1.Manager"),
+ "Suspend",
+ &(false,),
+ ) {
+ Ok(_) => tracing::debug!("Sleep via logind Suspend"),
+ Err(e) => tracing::warn!("logind Suspend failed: {e}"),
+ }
+}
+
/// Send `command` to the first MPRIS-capable media player on the session bus,
/// falling back to the corresponding XF86 multimedia key only if no MPRIS
/// player is found. When a player is found but the call fails, the fallback
diff --git a/crates/openlogi-inject/src/inject/macos.rs b/crates/openlogi-inject/src/inject/macos.rs
index a492d62acdd404a12e2e4a48489a7fe3ebbf7169..e9343f0599aeb151795c78d340ad045f53dcf5eb 100644
--- a/crates/openlogi-inject/src/inject/macos.rs
+++ b/crates/openlogi-inject/src/inject/macos.rs
@@ -7,7 +7,8 @@ use core_graphics::event::{
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
use core_graphics::geometry::CGPoint;
-use openlogi_core::binding::Action;
+use core_foundation::base::TCFType as _;
+use openlogi_core::binding::{Action, WorkflowStep};
// NX_KEYTYPE_* constants from <IOKit/hidsystem/ev_keymap.h>.
const NX_KEYTYPE_SOUND_UP: i32 = 0;
@@ -66,8 +67,10 @@ pub(super) fn execute(action: &Action) {
Action::Find => post_key(VK_F, cmd),
Action::Save => post_key(VK_S, cmd),
// ── Browser / Navigation ──────────────────────────────────────────
- // BrowserBack/Forward: Cmd+[ / Cmd+] as keyboard fallback; hook
- // layer handles the physical mouse buttons directly.
+ // BrowserBack/Forward: Cmd+[ / Cmd+] for Chrome and other apps.
+ // Safari is handled upstream via ax_navigate_browser() with the PID
+ // captured at press time — by the time execute() is called the AX path
+ // has already run, so this fallback is for non-Safari browsers only.
// kVK_ANSI_LeftBracket = 0x21, kVK_ANSI_RightBracket = 0x1E
Action::BrowserBack => post_key(0x21, cmd),
Action::BrowserForward => post_key(0x1E, cmd),
@@ -96,6 +99,10 @@ pub(super) fn execute(action: &Action) {
Action::Screenshot => post_key(0x14, cmd | shift),
// Capture region to clipboard = Cmd+Shift+Ctrl+4 (kVK_ANSI_4 = 0x15)
Action::CaptureRegion => post_key(0x15, cmd | shift | ctrl),
+ // Sleep has no CGEvent equivalent (the WindowServer ignores a
+ // synthesised power key), so ask powermanagement directly. `pmset
+ // sleepnow` works for the console user without privileges.
+ Action::Sleep => sleep_system(),
// ── Media ─────────────────────────────────────────────────────────
// Media/volume controls are NX system-defined keys, not ordinary
// keyboard virtual-key events. Posting kVK_Volume* through
@@ -146,6 +153,14 @@ pub(super) fn execute(action: &Action) {
}
post_key(combo.key_code, flags);
}
+ // TypeText emits a unicode string, layout-independent.
+ Action::TypeText(text) => post_unicode(text),
+ // Run actions spawn off the tap thread: the callback must not block
+ // (posting a key while waiting on a child process would wedge input).
+ Action::RunAppleScript(src) => run_apple_script_async(src.clone()),
+ Action::RunShellCommand(cmd) => run_shell_command_async(cmd.clone()),
+ // Workflows can sleep between steps, so they also run off the tap thread.
+ Action::Workflow(steps) => run_workflow_async(steps.clone()),
}
}
@@ -240,6 +255,86 @@ fn post_key(vk: u16, flags: CGEventFlags) {
up.post(CGEventTapLocation::HID);
}
+/// Type an arbitrary unicode string by emitting one key event per character,
+/// each carrying its unicode payload via `CGEventKeyboardSetUnicodeString`.
+fn post_unicode(text: &str) {
+ let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
+ tracing::warn!("CGEventSource::new failed for post_unicode");
+ return;
+ };
+ for ch in text.chars() {
+ // Keycode 0 (A) is a placeholder; the unicode payload determines the
+ // actual inserted character.
+ let Ok(ev) = CGEvent::new_keyboard_event(src.clone(), 0, true) else {
+ tracing::warn!("CGEvent::new_keyboard_event failed in post_unicode");
+ continue;
+ };
+ let s = ch.to_string();
+ ev.set_string(&s);
+ ev.post(CGEventTapLocation::HID);
+ }
+}
+
+/// Press a key chord described by a `KeyCombo` modifier bitmask + virtual
+/// keycode. Used by the workflow sequencer's `PressKey` step.
+fn post_keycombo(modifiers: u8, vk: u16) {
+ use openlogi_core::binding::KeyCombo;
+
+ let mut flags = CGEventFlags::CGEventFlagNull;
+ if modifiers & KeyCombo::MOD_CMD != 0 {
+ flags |= CGEventFlags::CGEventFlagCommand;
+ }
+ if modifiers & KeyCombo::MOD_SHIFT != 0 {
+ flags |= CGEventFlags::CGEventFlagShift;
+ }
+ if modifiers & KeyCombo::MOD_CTRL != 0 {
+ flags |= CGEventFlags::CGEventFlagControl;
+ }
+ if modifiers & KeyCombo::MOD_OPTION != 0 {
+ flags |= CGEventFlags::CGEventFlagAlternate;
+ }
+ post_key(vk, flags);
+}
+
+fn run_apple_script_async(src: String) {
+ std::thread::spawn(move || run_apple_script(&src));
+}
+
+fn run_shell_command_async(cmd: String) {
+ std::thread::spawn(move || run_shell_command(&cmd));
+}
+
+fn run_workflow_async(steps: Vec<WorkflowStep>) {
+ std::thread::spawn(move || run_workflow(&steps));
+}
+
+/// Run workflow steps on a worker thread, so `Delay` never stalls the event tap.
+fn run_workflow(steps: &[WorkflowStep]) {
+ for step in steps {
+ match step {
+ WorkflowStep::TypeText(text) => post_unicode(text),
+ WorkflowStep::PressKey(combo) => post_keycombo(combo.modifiers, combo.key_code),
+ WorkflowStep::Delay { millis } => {
+ std::thread::sleep(std::time::Duration::from_millis(*millis));
+ }
+ WorkflowStep::RunAppleScript(src) => run_apple_script(src),
+ WorkflowStep::RunShellCommand(cmd) => run_shell_command(cmd),
+ }
+ }
+}
+
+fn run_apple_script(src: &str) {
+ let _ = std::process::Command::new("osascript")
+ .args(["-e", src])
+ .output();
+}
+
+fn run_shell_command(cmd: &str) {
+ let _ = std::process::Command::new("/bin/sh")
+ .args(["-c", cmd])
+ .output();
+}
+
/// Post a media/system key event (play/pause, track navigation, volume).
///
/// Runs on the hook/gesture dispatch threads, which have no run loop to
@@ -284,6 +379,26 @@ fn post_media_key(nx_key: i32) {
});
}
+/// Put the system to sleep via `pmset sleepnow` — sleep has no CGEvent
+/// equivalent, and `pmset` performs the console user's sleep request
+/// without privileges. Fire-and-forget; a spawn failure is logged. The
+/// child is reaped on a detached thread so it can't linger as a zombie
+/// in this long-running agent.
+fn sleep_system() {
+ match std::process::Command::new("/usr/bin/pmset")
+ .arg("sleepnow")
+ .spawn()
+ {
+ Ok(mut child) => {
+ tracing::debug!("Sleep via pmset sleepnow");
+ std::thread::spawn(move || {
+ let _ = child.wait();
+ });
+ }
+ Err(e) => tracing::warn!(error = %e, "pmset sleepnow spawn failed"),
+ }
+}
+
/// Post a synthetic scroll event for `action` (one of the `Scroll*` variants).
fn post_scroll(action: &Action) {
let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
@@ -320,6 +435,408 @@ pub(super) fn post_horizontal_scroll(delta: i32) {
ev.post(CGEventTapLocation::HID);
}
+/// Raw FFI surface for the AXUIElement/CF calls used by [`ax_browser_navigate`]
+/// and its helpers below. Kept as module-level items (rather than nested in
+/// `ax_browser_navigate`) so each helper is independently readable and short.
+#[allow(unsafe_code, reason = "AXUIElement / CF APIs require raw FFI")]
+mod ax_nav {
+ use std::ffi::c_void;
+
+ pub(super) type AXUIElementRef = *const c_void;
+ pub(super) type CFTypeRef = *const c_void;
+
+ #[link(name = "ApplicationServices", kind = "framework")]
+ unsafe extern "C" {
+ pub(super) fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef;
+ pub(super) fn AXUIElementCopyAttributeValue(
+ element: AXUIElementRef,
+ attribute: core_foundation::string::CFStringRef,
+ value: *mut CFTypeRef,
+ ) -> i32;
+ pub(super) fn AXUIElementPerformAction(
+ element: AXUIElementRef,
+ action: core_foundation::string::CFStringRef,
+ ) -> i32;
+ pub(super) fn CFRelease(cf: CFTypeRef);
+ pub(super) fn CFGetTypeID(cf: CFTypeRef) -> usize;
+ pub(super) fn CFArrayGetTypeID() -> usize;
+ pub(super) fn CFArrayGetCount(arr: CFTypeRef) -> isize;
+ pub(super) fn CFArrayGetValueAtIndex(arr: CFTypeRef, idx: isize) -> CFTypeRef;
+ pub(super) fn CFRetain(cf: CFTypeRef) -> CFTypeRef;
+ }
+
+ pub(super) const AX_ERROR_SUCCESS: i32 = 0;
+}
+
+/// The AX attribute names [`find_button`] and [`find_nav_button_by_position`]
+/// need, bundled so neither function's argument list grows with the tree depth
+/// it searches.
+struct AxAttrs {
+ role: core_foundation::string::CFStringRef,
+ description: core_foundation::string::CFStringRef,
+ identifier: core_foundation::string::CFStringRef,
+ subrole: core_foundation::string::CFStringRef,
+ children: core_foundation::string::CFStringRef,
+}
+
+/// Get one AX attribute as a raw CFTypeRef (+1 retained). Caller must CFRelease.
+///
+/// SAFETY: `el` must be a valid AXUIElementRef and `attr` a valid CFStringRef
+/// (the CF memory rules — Get Rule = no extra retain, Create/Copy Rule = +1
+/// retain, caller releases — apply throughout this module).
+#[allow(unsafe_code, reason = "AXUIElement / CF APIs require raw FFI")]
+unsafe fn copy_attr(
+ el: ax_nav::AXUIElementRef,
+ attr: core_foundation::string::CFStringRef,
+) -> Option<ax_nav::CFTypeRef> {
+ let mut val: ax_nav::CFTypeRef = std::ptr::null();
+ // SAFETY: caller upholds the AXUIElementRef/CFStringRef validity contract.
+ let err = unsafe { ax_nav::AXUIElementCopyAttributeValue(el, attr, &raw mut val) };
+ if err == 0 && !val.is_null() {
+ Some(val)
+ } else {
+ None
+ }
+}
+
+/// Read an AX attribute as a String. Internally copies + releases.
+///
+/// SAFETY: same contract as [`copy_attr`].
+#[allow(unsafe_code, reason = "AXUIElement / CF APIs require raw FFI")]
+unsafe fn attr_string(
+ el: ax_nav::AXUIElementRef,
+ attr: core_foundation::string::CFStringRef,
+) -> Option<String> {
+ // SAFETY: caller upholds the AXUIElementRef/CFStringRef validity contract.
+ let val = unsafe { copy_attr(el, attr) }?;
+ // SAFETY: AX string attributes return CFStringRef.
+ let s = unsafe { core_foundation::string::CFString::wrap_under_create_rule(val.cast()) };
+ Some(s.to_string())
+}
+
+/// Walk the AX tree looking for an AXButton matching `target_id`/`target_subrole`/
+/// `target_desc` (tried in that order — see call site for why). Returns the
+/// element pointer (+1 retained via `CFRetain` at the leaf, so the caller owns
+/// it independently of the parent arrays this function releases as it unwinds).
+///
+/// SAFETY: `el` must be a valid AXUIElementRef and every field of `attrs` a
+/// valid CFStringRef.
+#[allow(unsafe_code, reason = "AXUIElement / CF APIs require raw FFI")]
+unsafe fn find_button(
+ el: ax_nav::AXUIElementRef,
+ target_id: &str,
+ target_subrole: &str,
+ target_desc: &str,
+ attrs: &AxAttrs,
+ depth: u8,
+) -> Option<ax_nav::AXUIElementRef> {
+ if depth == 0 {
+ return None;
+ }
+ // Check if this element is the button we want.
+ // SAFETY: caller upholds the AXUIElementRef/CFStringRef validity contract.
+ if let Some(role_val) = unsafe { copy_attr(el, attrs.role) } {
+ // SAFETY: AXRole is always a CFStringRef.
+ let role_s =
+ unsafe { core_foundation::string::CFString::wrap_under_create_rule(role_val.cast()) }
+ .to_string();
+ // Skip tab-bar elements — AXSplitGroup, AXTabGroup, AXOpaqueProviderGroup,
+ // AXRadioButton — to avoid wasting depth on Safari's 89-tab bar before
+ // reaching the toolbar navigation buttons.
+ let skip = matches!(
+ role_s.as_str(),
+ "AXSplitGroup" | "AXTabGroup" | "AXOpaqueProviderGroup" | "AXRadioButton"
+ );
+ if skip {
+ return None;
+ }
+ if role_s == "AXButton" {
+ // 1. AXIdentifier — locale-independent, preferred.
+ // 2. AXSubrole — locale-independent, set on some Safari versions.
+ // 3. AXDescription — locale-dependent last resort.
+ // SAFETY: caller upholds the AXUIElementRef/CFStringRef validity contract.
+ let matches_target = unsafe { attr_string(el, attrs.identifier) }.as_deref() == Some(target_id)
+ // SAFETY: caller upholds the AXUIElementRef/CFStringRef validity contract.
+ || unsafe { attr_string(el, attrs.subrole) }.as_deref() == Some(target_subrole)
+ // SAFETY: caller upholds the AXUIElementRef/CFStringRef validity contract.
+ || unsafe { attr_string(el, attrs.description) }.as_deref() == Some(target_desc);
+ // CFRetain here (only once, at the leaf) so callers can release the
+ // children arrays without dangling.
+ // SAFETY: el is a valid AXUIElementRef (CF Get Rule applies).
+ return matches_target.then(|| unsafe { ax_nav::CFRetain(el) });
+ }
+ }
+ // Recurse into AXChildren.
+ // SAFETY: caller upholds the AXUIElementRef/CFStringRef validity contract.
+ let children_val = unsafe { copy_attr(el, attrs.children) }?;
+ // Verify it's actually a CFArray before treating it as one.
+ // SAFETY: children_val is a valid, +1-retained CFTypeRef from copy_attr above.
+ let is_array = unsafe { ax_nav::CFGetTypeID(children_val) == ax_nav::CFArrayGetTypeID() };
+ if !is_array {
+ // SAFETY: balance the +1 retain from copy_attr above.
+ unsafe { ax_nav::CFRelease(children_val) };
+ return None;
+ }
+ // SAFETY: children_val was just verified to be a CFArray.
+ let count = unsafe { ax_nav::CFArrayGetCount(children_val) };
+ let mut found: Option<ax_nav::AXUIElementRef> = None;
+ for i in 0..count {
+ // Get Rule — not retained.
+ // SAFETY: children_val is a valid CFArray and i is in bounds.
+ let child = unsafe { ax_nav::CFArrayGetValueAtIndex(children_val, i) };
+ if child.is_null() {
+ continue;
+ }
+ // SAFETY: child is a valid AXUIElementRef (CF Get Rule); attrs fields
+ // are valid CFStringRefs per this function's own contract.
+ if let Some(f) = unsafe {
+ find_button(
+ child,
+ target_id,
+ target_subrole,
+ target_desc,
+ attrs,
+ depth - 1,
+ )
+ } {
+ found = Some(f);
+ break;
+ }
+ }
+ // found is already +1 retained (CFRetain'd at the leaf in the button check
+ // above). Parent frames propagate it without re-retaining. Safe to release
+ // the children array now.
+ // SAFETY: balance the +1 retain from copy_attr above.
+ unsafe { ax_nav::CFRelease(children_val) };
+ found
+}
+
+/// Positional fallback: locate the Back (idx=0) or Forward (idx=1) button by
+/// structure rather than by attribute text. The Safari toolbar layout is:
+/// AXWindow → AXToolbar → AXGroup[1] → AXGroup[0] → AXButton[0/1]
+/// This is locale-independent and works when no AX attribute names the button.
+///
+/// SAFETY: `win` must be a valid AXUIElementRef and `attr_role`/`attr_children`
+/// valid CFStringRefs.
+#[allow(unsafe_code, reason = "AXUIElement / CF APIs require raw FFI")]
+unsafe fn find_nav_button_by_position(
+ win: ax_nav::AXUIElementRef,
+ forward: bool,
+ attr_role: core_foundation::string::CFStringRef,
+ attr_children: core_foundation::string::CFStringRef,
+) -> Option<ax_nav::AXUIElementRef> {
+ use ax_nav::{CFArrayGetCount, CFArrayGetValueAtIndex, CFRelease, CFRetain, CFTypeRef};
+ use core_foundation::string::CFString;
+
+ // SAFETY: all raw AX/CF calls below follow the CF memory rules documented
+ // on the sibling `find_button` — this whole body is one unsafe operation,
+ // wrapped once rather than call-by-call.
+ unsafe {
+ // Helper: get children as a raw CFArray (caller must CFRelease)
+ let children_of = |el: ax_nav::AXUIElementRef| -> Option<CFTypeRef> {
+ let mut val: CFTypeRef = std::ptr::null();
+ let err = ax_nav::AXUIElementCopyAttributeValue(el, attr_children, &raw mut val);
+ if err == 0 && !val.is_null() {
+ Some(val)
+ } else {
+ None
+ }
+ };
+ let role_of = |el: ax_nav::AXUIElementRef| -> Option<String> {
+ let mut val: CFTypeRef = std::ptr::null();
+ let err = ax_nav::AXUIElementCopyAttributeValue(el, attr_role, &raw mut val);
+ if err != 0 || val.is_null() {
+ return None;
+ }
+ Some(CFString::wrap_under_create_rule(val.cast()).to_string())
+ };
+ let child_at = |arr: CFTypeRef, idx: isize| -> Option<CFTypeRef> {
+ if CFArrayGetCount(arr) <= idx {
+ return None;
+ }
+ let c = CFArrayGetValueAtIndex(arr, idx);
+ if c.is_null() { None } else { Some(c) }
+ };
+
+ // AXWindow children: find AXToolbar. `child_at` returns a Get-Rule
+ // pointer owned by the array it was read from — retain it before
+ // releasing that array, or the element can be deallocated along
+ // with it, leaving a dangling pointer for every use below.
+ let win_kids = children_of(win)?;
+ let count = CFArrayGetCount(win_kids);
+ let mut toolbar: Option<CFTypeRef> = None;
+ for i in 0..count {
+ if let Some(c) = child_at(win_kids, i)
+ && role_of(c).as_deref() == Some("AXToolbar")
+ {
+ toolbar = Some(CFRetain(c));
+ break;
+ }
+ }
+ CFRelease(win_kids);
+ let toolbar = toolbar?;
+
+ // AXToolbar children: skip AXGroups until we find the nav group (the
+ // group whose first child is itself an AXGroup containing buttons).
+ let tb_kids = children_of(toolbar)?;
+ CFRelease(toolbar);
+ let tb_count = CFArrayGetCount(tb_kids);
+ let mut nav_group: Option<CFTypeRef> = None;
+ for i in 0..tb_count {
+ if let Some(g) = child_at(tb_kids, i) {
+ if role_of(g).as_deref() != Some("AXGroup") {
+ continue;
+ }
+ // Check if its first child is also an AXGroup (the inner nav group)
+ if let Some(inner_kids) = children_of(g) {
+ let has_inner =
+ child_at(inner_kids, 0).and_then(role_of).as_deref() == Some("AXGroup");
+ CFRelease(inner_kids);
+ if has_inner {
+ nav_group = Some(CFRetain(g));
+ break;
+ }
+ }
+ }
+ }
+ CFRelease(tb_kids);
+ let nav_group = nav_group?;
+
+ // nav_group → first AXGroup child → AXButton[0 or 1]
+ let ng_kids = children_of(nav_group)?;
+ CFRelease(nav_group);
+ let inner = child_at(ng_kids, 0).map(|c| CFRetain(c));
+ CFRelease(ng_kids);
+ let inner = inner?;
+
+ let inner_kids = children_of(inner)?;
+ CFRelease(inner);
+ let btn_idx = isize::from(forward);
+ let btn = child_at(inner_kids, btn_idx).map(|c| CFRetain(c));
+ CFRelease(inner_kids);
+ let btn = btn?;
+
+ // btn is already +1 retained (above) to survive inner_kids' release —
+ // return it as-is on match, or release it before failing out.
+ if role_of(btn).as_deref() == Some("AXButton") {
+ Some(btn)
+ } else {
+ CFRelease(btn);
+ None
+ }
+ }
+}
+
+/// Press the Back (`forward=false`) or Forward (`forward=true`) navigation
+/// button in the frontmost application via the Accessibility API.
+///
+/// Safari's WKWebView ignores synthetic `CGEvent` mouse-button and keyboard
+/// events posted at the HID or Session tap levels. However it does respond
+/// correctly to `AXPress` on its toolbar's "Go back" / "Go forward" button,
+/// because that path goes through AppKit's normal action dispatch rather than
+/// the input event pipeline.
+///
+/// Returns `true` when an AX button was found and pressed (result `kAXErrorSuccess`),
+/// `false` on any failure — the caller should fall back to a keyboard shortcut.
+#[allow(unsafe_code, reason = "AXUIElement / CF APIs require raw FFI")]
+pub(super) fn ax_browser_navigate(forward: bool, pid: Option<i32>) -> bool {
+ use objc2::rc::autoreleasepool;
+ use objc2_app_kit::NSWorkspace;
+
+ use core_foundation::string::CFString;
+
+ let attr_focused_window = CFString::new("AXFocusedWindow");
+ let attr_children = CFString::new("AXChildren");
+ let attr_role = CFString::new("AXRole");
+ let attr_description = CFString::new("AXDescription");
+ let attr_identifier = CFString::new("AXIdentifier");
+ let attr_subrole = CFString::new("AXSubrole");
+ let ax_press = CFString::new("AXPress");
+ // AXIdentifier is locale-independent (Safari sets these stable IDs on its
+ // toolbar navigation buttons). Description ("Go back"/"Go forward") is
+ // locale-dependent and will fail on non-English systems.
+ let target_identifier = if forward {
+ "BackForwardToolbarButton_Forward"
+ } else {
+ "BackForwardToolbarButton_Back"
+ };
+ // AXSubrole is also locale-independent and may be set on some Safari versions.
+ let target_subrole = if forward {
+ "AXBackForwardButtonForward"
+ } else {
+ "AXBackForwardButtonBack"
+ };
+ // Last-resort English description fallback for older Safari/macOS versions.
+ let target_desc_en = if forward { "Go forward" } else { "Go back" };
+
+ autoreleasepool(|_| {
+ let resolved_pid = if let Some(p) = pid {
+ p
+ } else {
+ NSWorkspace::sharedWorkspace()
+ .frontmostApplication()?
+ .processIdentifier()
+ };
+ // SAFETY: returns +1 retained AXUIElement.
+ let app_ax = unsafe { ax_nav::AXUIElementCreateApplication(resolved_pid) };
+ if app_ax.is_null() {
+ return None::<()>;
+ }
+
+ // Get focused window (+1 retained).
+ // SAFETY: app_ax was just verified non-null; attr_focused_window is a valid CFStringRef.
+ let win = unsafe { copy_attr(app_ax, attr_focused_window.as_concrete_TypeRef()) };
+ // SAFETY: balance +1 from AXUIElementCreateApplication.
+ unsafe { ax_nav::CFRelease(app_ax) };
+ let win = win?;
+
+ let attrs = AxAttrs {
+ role: attr_role.as_concrete_TypeRef(),
+ description: attr_description.as_concrete_TypeRef(),
+ identifier: attr_identifier.as_concrete_TypeRef(),
+ subrole: attr_subrole.as_concrete_TypeRef(),
+ children: attr_children.as_concrete_TypeRef(),
+ };
+ // Find the nav button (borrowed pointer inside the window's tree).
+ // SAFETY: win is a valid AXUIElementRef; attrs fields are valid CFStringRefs.
+ let button = unsafe { find_button(win, target_identifier, target_subrole, target_desc_en, &attrs, 6) }
+ // Positional fallback: if identifier/subrole/description all failed
+ // (e.g. non-English Safari without AXIdentifier), find the nav group
+ // by structure — second AXGroup of AXToolbar, first sub-group, then
+ // pick button 0 (back) or button 1 (forward).
+ // SAFETY: win is a valid AXUIElementRef; attrs fields are valid CFStringRefs.
+ .or_else(|| unsafe { find_nav_button_by_position(win, forward, attrs.role, attrs.children) });
+
+ let result = button.map(|btn| {
+ // SAFETY: btn is a +1 retained AXUIElement (CFRetain'd by find_button
+ // or find_nav_button_by_position).
+ let r = unsafe { ax_nav::AXUIElementPerformAction(btn, ax_press.as_concrete_TypeRef()) };
+ // SAFETY: balance the CFRetain from find_button/find_nav_button_by_position.
+ unsafe { ax_nav::CFRelease(btn) };
+ r == ax_nav::AX_ERROR_SUCCESS
+ });
+
+ // SAFETY: balance +1 from copy_attr (focused window).
+ unsafe { ax_nav::CFRelease(win) };
+
+ match result {
+ Some(true) => {
+ tracing::debug!(forward, "AX browser navigate succeeded");
+ Some(())
+ }
+ Some(false) => {
+ tracing::debug!(forward, "AX browser navigate: AXPress failed");
+ None
+ }
+ None => {
+ tracing::debug!(forward, "AX browser navigate: button not found");
+ None
+ }
+ }
+ })
+ .is_some()
+}
+
use dock::{app_expose, launchpad, mission_control, show_desktop};
use symbolic_hotkey::{next_desktop, previous_desktop};
diff --git a/crates/openlogi-inject/src/inject/windows.rs b/crates/openlogi-inject/src/inject/windows.rs
index 74c74536ebb0e175091304ca1ff1daa71e5ddc29..8a98abcb36739c48f18fe2319cccd607826d7aa2 100644
--- a/crates/openlogi-inject/src/inject/windows.rs
+++ b/crates/openlogi-inject/src/inject/windows.rs
@@ -10,7 +10,7 @@ use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
MOUSEEVENTF_XUP, MOUSEINPUT, SendInput,
};
-use openlogi_core::binding::{Action, KeyCombo};
+use openlogi_core::binding::{Action, KeyCombo, WorkflowStep};
const WHEEL_DELTA: i32 = 120;
@@ -107,6 +107,12 @@ pub(super) fn execute(action: &Action) {
Action::Screenshot | Action::CaptureRegion => {
post_key(VK_S, &[VK_LWIN, VK_SHIFT]);
}
+ // Suspending reliably needs `SetSuspendState` (powrprof.dll), which
+ // hibernates instead when hibernation is enabled — no clean win from
+ // a background agent, so the action is skipped on Windows for now.
+ Action::Sleep => {
+ tracing::debug!("Sleep has no Windows synthesis yet — action skipped");
+ }
Action::PlayPause => post_key(VK_MEDIA_PLAY_PAUSE, &[]),
Action::NextTrack => post_key(VK_MEDIA_NEXT_TRACK, &[]),
Action::PrevTrack => post_key(VK_MEDIA_PREV_TRACK, &[]),
@@ -124,10 +130,54 @@ pub(super) fn execute(action: &Action) {
| Action::HorizontalScrollLeft
| Action::HorizontalScrollRight => post_scroll(action),
Action::CustomShortcut(combo) => post_custom_shortcut(combo),
+ Action::TypeText(text) => {
+ tracing::warn!(
+ chars = text.chars().count(),
+ "TypeText injection is not implemented on Windows yet"
+ );
+ }
+ Action::RunAppleScript(_) => {
+ tracing::warn!("RunAppleScript is only supported on macOS");
+ }
+ Action::RunShellCommand(cmd) => run_shell_command_async(cmd.clone()),
+ Action::Workflow(steps) => run_workflow_async(steps.clone()),
Action::None => {}
}
}
+fn run_shell_command_async(cmd: String) {
+ std::thread::spawn(move || run_shell_command(&cmd));
+}
+
+fn run_workflow_async(steps: Vec<WorkflowStep>) {
+ std::thread::spawn(move || run_workflow(&steps));
+}
+
+fn run_workflow(steps: &[WorkflowStep]) {
+ for step in steps {
+ match step {
+ WorkflowStep::TypeText(text) => {
+ tracing::warn!(
+ chars = text.chars().count(),
+ "workflow TypeText injection is not implemented on Windows yet"
+ );
+ }
+ WorkflowStep::PressKey(combo) => post_custom_shortcut(combo),
+ WorkflowStep::Delay { millis } => {
+ std::thread::sleep(std::time::Duration::from_millis(*millis));
+ }
+ WorkflowStep::RunAppleScript(_) => {
+ tracing::warn!("workflow RunAppleScript is only supported on macOS");
+ }
+ WorkflowStep::RunShellCommand(cmd) => run_shell_command(cmd),
+ }
+ }
+}
+
+fn run_shell_command(cmd: &str) {
+ let _ = std::process::Command::new("cmd").args(["/C", cmd]).output();
+}
+
fn post_click(button: MouseButton) {
let (down, up, data) = match button {
MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, 0),
diff --git a/crates/openlogi-inject/src/lib.rs b/crates/openlogi-inject/src/lib.rs
index 347b7605fca859f6e7c7e52e752c9c3c64892f93..ed56d01bf438dfc1fd8b3afb0efac5a191de8d30 100644
--- a/crates/openlogi-inject/src/lib.rs
+++ b/crates/openlogi-inject/src/lib.rs
@@ -2,7 +2,7 @@
mod inject;
-pub use inject::{SYNTHETIC_EVENT_USER_DATA, execute, post_horizontal_scroll};
+pub use inject::{SYNTHETIC_EVENT_USER_DATA, ax_navigate_browser, execute, post_horizontal_scroll};
#[cfg(target_os = "linux")]
pub use inject::action_device_path;
diff --git a/devenv.nix b/devenv.nix
index 2dcfccba301f74f1ae2f5262eaecf8fdd0ca7eb3..0418321c1e5c788d9c33b9f98c9b8adf56e16d87 100644
--- a/devenv.nix
+++ b/devenv.nix
@@ -88,11 +88,15 @@ in
'';
};
"openlogi:i18n-upload" = {
- description = "Upload English source strings to Crowdin.";
- exec = "crowdin upload sources";
+ description = "Upload en.yml sources and per-language translations to Crowdin.";
+ exec = ''
+ set -e
+ crowdin upload sources
+ crowdin upload translations
+ '';
};
"openlogi:i18n-download" = {
- description = "Download translated locale files from Crowdin.";
+ description = "Download per-language translations from Crowdin and run i18n tests.";
exec = ''
set -e
${requireXcodeMetal}
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 6afa4162ba7b307371ec95f0f985914fd08a7f0f..9ccac4461db0dce0a14ea3e17de212eeb3f31c2e 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -30,8 +30,20 @@ MX Master 4):
without changing the system trackpad direction.
- `lighting` — static RGB colour, brightness (0–100), and on/off for wired
RGB keyboards.
+- `light` — standalone-light power, normalized brightness, and temperature.
+ Set `auto_camera = true` on macOS to turn the light on while any camera is in
+ use and off when camera use stops; the manual power preference and the other
+ light settings remain independent.
- `gesture_owner` — which button owns the gesture role, when chosen
explicitly (otherwise inferred).
+- `host_switch_targets` — on a compatible keyboard, physical config keys of
+ mice that should follow its Easy-Switch channel. Both devices must already
+ be paired on corresponding channels. The keyboard's host controls and every
+ target must expose the HID++ features needed for host switching. Configure
+ the link on every computer from which the keyboard may initiate a switch.
+- `fn_lock` — keyboards only: `true` makes the F-row send F1–F12 without
+ holding Fn, `false` keeps the printed media/shortcut functions. Absent
+ means the keyboard's own state is left alone. Re-applied on reconnect.
The app-wide `[app_settings]` block holds `launch_at_login`,
`check_for_updates`, and `auto_install_updates` (all off by default);
@@ -66,6 +78,12 @@ appearance = "system"
[devices.2b042]
dpi_presets = [800, 1600, 3200]
+# Put this on the keyboard's physical device entry. Values are the physical
+# keys of the mice that should follow it; use the exact keys already present
+# under [devices] in your generated config.
+[devices."receiver:aabbccdd:slot:1"]
+host_switch_targets = ["receiver:aabbccdd:slot:2"]
+
[devices.2b042.bindings]
Back = "BrowserBack"
Forward = "BrowserForward"
@@ -86,6 +104,31 @@ Back = "Undo"
enabled = true
color = "ff0000"
brightness = 80
+
+# Keyboard F-row keys (Signature-series layout): a bound key is diverted
+# over HID++ and dispatches its action; an unbound key keeps its native
+# firmware function. Key names: KeySearch, KeyDictation, KeyEmoji,
+# KeyScreenCapture, KeyMicMute, KeyPlayPause, KeyMute, KeyVolumeDown,
+# KeyVolumeUp.
+[devices.2b372]
+fn_lock = false
+
+[devices.2b372.bindings]
+KeySearch = "MissionControl"
+KeyScreenCapture = "Sleep"
+
+# Standalone light (for example, a Litra Glow). The GUI writes this block under
+# the serial-backed physical key; `openlogi light list` shows its HID tuple and
+# identity when diagnosing discovery.
+# A serial-bearing Litra key looks like:
+# [devices."raw:046d:c900:ff43:0202:serial:YOUR-SERIAL".light]
+# If the HID backend exposes only a transient OS-node identity, OpenLogi does
+# not persist that key; reconnect persistence then requires a device serial.
+[devices."<raw-device-key>".light]
+enabled = true
+auto_camera = true
+brightness_percent = 65
+temperature_kelvin = 4600
```
Action names are the catalog's variant names (`LeftClick`, `MouseBack`,
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index d060866af01e556282f0ab6925f92aa761b41736..b0c5d7367cccc543c6e6149e1591c7209e4be519 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -4,6 +4,23 @@ Durable "why we did it this way" records that are not obvious from the code.
Add a dated entry when a non-obvious architectural or dependency decision is
made or revisited.
+## 2026-08: Standalone raw-light boundary
+
+Standalone lights such as Litra stay outside the HID++ receiver/paired-device
+model and are normalized only at the shared agent and GUI device-record
+boundary. This keeps the existing HID++ wire and routing semantics unchanged
+while allowing future light drivers to share capability-driven controls.
+
+- Persist brightness as a normalized percentage and temperature as Kelvin;
+ native units and report encoding remain driver responsibilities.
+- Use device serials for persistent raw-device keys. OS-node identifiers are
+ runtime-only hints and must not silently become physical configuration keys.
+- Advertise optional light controls through `LightCapabilities`; the GUI gates
+ controls from those capabilities rather than from `DeviceKind::Light`.
+- Serialize and coalesce per-device light writes in the agent so reconnect,
+ camera automation, config reload, and manual commands cannot interleave at
+ packet level.
+
## 2026-07: Infrastructure we keep custom instead of using a crate
A dependency audit replaced most general-purpose infrastructure code with
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index f96b1a0088b82b524caf70b5ddd3de7640caacc5..85168f6e7976368356eedd4d373c9985d4c48bc6 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -63,8 +63,8 @@ expose `libSystem` the way Apple's real linker wants.
On macOS the desktop binary is launched from inside a throwaway
`target/dev/OpenLogi.app` — a Cargo `runner` wired in `.cargo/config.toml`
-(`scripts/cargo-run-macos.sh`). This makes the dev build show the real
-**OpenLogi** name in the menu bar and the app icon in the Dock; a bare
+(`scripts/cargo-run-macos.sh`). This makes the dev build show as
+**OpenLogi Dev** in the menu bar and Dock, with the real app icon; a bare
`cargo run` binary has no bundle, so macOS would otherwise fall back to the
`openlogi-gui` executable name and a generic icon. The binary is hardlinked in
(no copy) and the icon is generated on demand by
@@ -72,6 +72,13 @@ On macOS the desktop binary is launched from inside a throwaway
everything else (the CLI, tests); set
`OPENLOGI_DEV_BUNDLE=0` to launch the raw `openlogi-gui` binary instead.
+Packaged local dev bundles (`cargo run` and
+`cargo run -p xtask -- macos bundle`) use `.dev` bundle identifiers and the
+`openlogi-dev` XDG profile (`~/.config/openlogi-dev`,
+`~/.local/share/openlogi-dev`, and its own `agent.sock`). That keeps the dev
+GUI and agent from sharing the installed production app's Accessibility grant,
+single-instance lock, config, or IPC socket.
+
To install the CLI binary on `PATH`:
```sh
@@ -190,11 +197,34 @@ cargo run -p xtask -- release latest-json \
## Crowdin translation sync
-`.github/workflows/crowdin.yml` uploads `crates/openlogi-gui/locales/en.yml` to
+`.github/workflows/crowdin.yml` syncs GUI locales with
[Crowdin](https://crowdin.com/project/openlogi) and opens a `crowdin/i18n` PR
-with fresh translations — nightly, and on master pushes touching the source
-strings. `crowdin.yml` limits exports to the locales shipped by the app and
-maps Crowdin language identifiers to their repository filenames.
+when downloads change something — nightly, and on master pushes that touch
+English sources (`en.yml`), `crowdin.yml`, the Crowdin workflow, or the shared
+GitHub App token action.
+
+**How it helps translation**
+
+| | Role |
+|--|--|
+| `en.yml` (git) | English source of truth — add new UI strings here only |
+| Per-language `locales/*.yml` in git | Seeded into Crowdin so existing de/ja/… work is not lost |
+| Crowdin project | Where people translate and improve each language |
+| Bot PR (`crowdin/i18n`) | Brings Crowdin’s per-language progress back into the repo |
+
+Do not treat every feature PR as “edit all 19 locale files.” New copy goes in
+`en.yml`; Crowdin + this job handle the rest. Untranslated keys may still match
+English until someone translates them in Crowdin — that is incomplete work, not
+“remove language support.”
+
+Each run:
+
+1. Uploads `en.yml` **sources**.
+2. Uploads **per-language translations** already in git (`import_eq_suggestions`
+ off so `value == English` is not stored as a finished translation).
+3. Downloads Crowdin’s export for configured languages (`export_languages` /
+ `languages_mapping` in `crowdin.yml`).
+4. Opens/updates `crowdin/i18n` when the catalogs differ.
Like the release workflow, the job reads its credentials from one 1Password
item referenced by the GitHub secret `OP_CROWDIN_SECRET_ITEM`. The item must
@@ -212,7 +242,20 @@ OpenLogi project:
- Translations — Read and Write.
Missing or invalid credentials fail the workflow. Translation PRs run the
-normal CI checks, including the locale key-parity test. The workflow uses the
-existing `OP_GITHUB_APP_ITEM` to mint a short-lived token for pushing its
-translation branch and opening the PR; the default `GITHUB_TOKEN` remains
-read-only.
+normal CI checks, including the locale key test (non-English keys must be a
+subset of `en.yml`; catalogs may lag until Crowdin fills them). The workflow
+uses the existing `OP_GITHUB_APP_ITEM` to mint a short-lived token for pushing
+its translation branch and opening the PR; the default `GITHUB_TOKEN` remains
+read-only. Checkout runs with `persist-credentials: false` and the origin
+remote is rewritten to the app token so git push does not inherit the
+read-only Actions credential.
+
+Feature work only needs `en.yml`. Do not hand-edit every locale file for a new
+string — Crowdin + this workflow own non-English updates.
+
+Local helpers (with Crowdin credentials configured):
+
+```sh
+devenv tasks run openlogi:i18n-upload # en.yml sources + per-language translations
+devenv tasks run openlogi:i18n-download # download locales + i18n tests
+```
diff --git a/docs/INSTALL-linux.md b/docs/INSTALL-linux.md
index ee4f0144040f681468a99a56748477f500cf3dbf..a8fa8b3e3ee4338c68234a998099a27250452f3e 100644
--- a/docs/INSTALL-linux.md
+++ b/docs/INSTALL-linux.md
@@ -41,7 +41,11 @@ OpenLogi needs:
- **Write access to `/dev/uinput`** — to create the virtual input device for
button remapping.
- **Read/write access to `/dev/hidraw*`** — to send HID++ commands to the Bolt
- receiver.
+ receiver, or to the device itself when it is paired over Bluetooth.
+- **Read access to the mouse's `/dev/input/event*` node** — the hook grabs the
+ pointer there to capture button presses. Bluetooth mice need the bundled rule
+ for this: their event node hangs off `/devices/virtual/misc/uhid`, which has
+ no seat, so `logind` never grants the ACL on its own.
Install the bundled udev rules to grant access to the active-seat user without
requiring `sudo` or group membership (requires `systemd-logind`):
@@ -61,6 +65,11 @@ openlogi-agent --check-uinput 2>/dev/null || \
# Check a hidraw node
ls -la /dev/hidraw*
+
+# Check the mouse's event node — look for a "+" (ACL) in the mode, or your
+# user in the ACL itself. Without it the agent logs
+# "could not install OS mouse hook".
+getfacl /dev/input/event*
```
The GUI Settings → Permissions page shows a live `Granted` / `Not granted`
diff --git a/docs/superpowers/plans/2026-06-29-mouse-buttons-6-9.md b/docs/superpowers/plans/2026-06-29-mouse-buttons-6-9.md
new file mode 100644
index 0000000000000000000000000000000000000000..c9974608b3397ec15221f4c26e129a888ee73b6e
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-29-mouse-buttons-6-9.md
@@ -0,0 +1,410 @@
+# Mouse Buttons 6–9 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add four pickable actions (`MouseButton6`–`MouseButton9`) that synthesize mouse buttons 6–9 on macOS and Linux, surfaced in the action picker under the MOUSE section.
+
+**Architecture:** Pure data-driven addition. The `Action` enum in `openlogi-core` is the single source of truth — the GUI picker, category grouping, TOML schema, and per-platform injection all derive from it. Four new unit variants mirror the existing `MouseBack`/`MouseForward` pattern. On macOS the existing `post_other_button(n)` already accepts any button number; on Linux the evdev `BTN_*` family covers it; on Windows `SendInput` caps at button 5, so 6–9 log-and-skip there (documented gap, same pattern as existing platform-limited actions).
+
+**Tech Stack:** Rust (workspace), serde/TOML for config, GPUI for the GUI, core-graphics / evdev / windows-sys for injection, rust-i18n for locale strings.
+
+**Spec:** `docs/superpowers/specs/2026-06-29-mouse-buttons-6-9-design.md`
+
+---
+
+## File Structure
+
+| File | Responsibility | Change |
+|---|---|---|
+| `crates/openlogi-core/src/binding.rs` | The `Action` enum + `label()`/`category()`/`catalog()` | Add 4 variants + their 3 match arms + extend 2 tests |
+| `crates/openlogi-inject/src/inject.rs` | Per-platform `Action` → OS event synthesis | Add arms in `execute_macos` / `execute_linux` / `execute_windows` |
+| `crates/openlogi-gui/src/mouse_model/picker.rs` | Picker icon mapping (exhaustive `match`) | Add 4 arms (compiler-forced) |
+| `crates/openlogi-gui/locales/*.yml` (20 files) | i18n translation keys, keyed by English label | Add `"Button 6"`–`"Button 9"` keys after line 147 |
+
+No new files. The exhaustive `match` arms across the codebase are the safety net — the compiler refuses to build if any variant is missed.
+
+---
+
+## Task 1: Add the `Action` variants and core metadata
+
+This task adds the four variants and threads them through `label()`, `category()`, and `catalog()`. Tests fail first, then pass.
+
+**Files:**
+- Modify: `crates/openlogi-core/src/binding.rs`
+
+- [ ] **Step 1: Write the failing test (extend `category_mouse_variants`)**
+
+In `crates/openlogi-core/src/binding.rs`, find the test at line ~1346 and replace it:
+
+```rust
+ #[test]
+ fn category_mouse_variants() {
+ assert_eq!(Action::LeftClick.category(), Category::Mouse);
+ assert_eq!(Action::RightClick.category(), Category::Mouse);
+ assert_eq!(Action::MiddleClick.category(), Category::Mouse);
+ assert_eq!(Action::MouseBack.category(), Category::Mouse);
+ assert_eq!(Action::MouseForward.category(), Category::Mouse);
+ assert_eq!(Action::MouseButton6.category(), Category::Mouse);
+ assert_eq!(Action::MouseButton7.category(), Category::Mouse);
+ assert_eq!(Action::MouseButton8.category(), Category::Mouse);
+ assert_eq!(Action::MouseButton9.category(), Category::Mouse);
+ }
+```
+
+- [ ] **Step 2: Add a label test (append to the `#[cfg(test)] mod tests` block, after `category_mouse_variants`)**
+
+```rust
+ #[test]
+ fn extra_mouse_button_labels() {
+ assert_eq!(Action::MouseButton6.label(), "Button 6");
+ assert_eq!(Action::MouseButton7.label(), "Button 7");
+ assert_eq!(Action::MouseButton8.label(), "Button 8");
+ assert_eq!(Action::MouseButton9.label(), "Button 9");
+ }
+```
+
+- [ ] **Step 3: Run the tests to verify they fail**
+
+Run: `cargo test -p openlogi-core --lib binding::tests::category_mouse_variants binding::tests::extra_mouse_button_labels`
+Expected: FAIL with `cannot find variant MouseButton6` (and 7/8/9) — compile error.
+
+- [ ] **Step 4: Add the four variants to the `Action` enum**
+
+In `crates/openlogi-core/src/binding.rs`, find the `MouseForward` variant (line ~371) and add the four new variants immediately after it, before the `// ── Editing ───` comment:
+
+```rust
+ /// Mouse "forward" side button (extra button 5). Native counterpart to
+ /// [`Action::MouseBack`]; see [`Action::BrowserForward`] for the ⌘] form.
+ MouseForward,
+ /// Extra mouse button 6. Emitted as the real button-6 event for apps, games,
+ /// and CAD software that bind it. macOS/Linux only — Windows `SendInput`
+ /// caps at button 5, so this logs-and-skips there.
+ MouseButton6,
+ /// Extra mouse button 7. See [`Action::MouseButton6`].
+ MouseButton7,
+ /// Extra mouse button 8. See [`Action::MouseButton6`].
+ MouseButton8,
+ /// Extra mouse button 9. See [`Action::MouseButton6`].
+ MouseButton9,
+```
+
+- [ ] **Step 5: Add the four labels to `Action::label()`**
+
+In the `label()` match (around line ~686, after the `MouseForward` arm), add:
+
+```rust
+ Action::MouseForward => "Forward (Button 5)".into(),
+ Action::MouseButton6 => "Button 6".into(),
+ Action::MouseButton7 => "Button 7".into(),
+ Action::MouseButton8 => "Button 8".into(),
+ Action::MouseButton9 => "Button 9".into(),
+```
+
+- [ ] **Step 6: Add the four to `Action::category()`**
+
+In the `category()` match (around line ~737), extend the existing Mouse arm:
+
+```rust
+ Action::LeftClick
+ | Action::RightClick
+ | Action::MiddleClick
+ | Action::MouseBack
+ | Action::MouseForward
+ | Action::MouseButton6
+ | Action::MouseButton7
+ | Action::MouseButton8
+ | Action::MouseButton9 => Category::Mouse,
+```
+
+- [ ] **Step 7: Add the four to `Action::catalog()`**
+
+In the `catalog()` vec (around line ~793, after `Action::MouseForward`), add:
+
+```rust
+ // Mouse
+ Action::LeftClick,
+ Action::RightClick,
+ Action::MiddleClick,
+ Action::MouseBack,
+ Action::MouseForward,
+ Action::MouseButton6,
+ Action::MouseButton7,
+ Action::MouseButton8,
+ Action::MouseButton9,
+```
+
+- [ ] **Step 8: Run the tests to verify they pass**
+
+Run: `cargo test -p openlogi-core --lib binding`
+Expected: PASS — `category_mouse_variants`, `extra_mouse_button_labels`, and `all_catalog_variants_roundtrip_toml` (which iterates `catalog()`) all pass.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add crates/openlogi-core/src/binding.rs
+git commit -m "feat(core): add MouseButton6-9 actions to the binding vocabulary"
+```
+
+---
+
+## Task 2: Inject the buttons on macOS
+
+macOS already has `post_other_button(n)` which stamps `MOUSE_EVENT_BUTTON_NUMBER`. Buttons 6–9 map to numbers 5–8 (0-indexed: Back=3, Forward=4).
+
+**Files:**
+- Modify: `crates/openlogi-inject/src/inject.rs` (the `execute_macos` function, around line 186–187)
+
+- [ ] **Step 1: Add the macOS injection arms**
+
+Find the macOS extra-button arms (line ~186–187):
+
+```rust
+ Action::MouseBack => macos::post_other_button(3),
+ Action::MouseForward => macos::post_other_button(4),
+```
+
+Add immediately after them:
+
+```rust
+ Action::MouseBack => macos::post_other_button(3),
+ Action::MouseForward => macos::post_other_button(4),
+ // Buttons 6–9 (button numbers 5–8, 0-indexed). Same path as 4/5 —
+ // post_other_button stamps MOUSE_EVENT_BUTTON_NUMBER to address any
+ // button ≥ 3.
+ Action::MouseButton6 => macos::post_other_button(5),
+ Action::MouseButton7 => macos::post_other_button(6),
+ Action::MouseButton8 => macos::post_other_button(7),
+ Action::MouseButton9 => macos::post_other_button(8),
+```
+
+- [ ] **Step 2: Verify the macOS build compiles**
+
+Run: `cargo build -p openlogi-inject`
+Expected: BUILD SUCCEEDS (on macOS the `execute_macos` arms are the ones compiled).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add crates/openlogi-inject/src/inject.rs
+git commit -m "feat(inject): synthesize mouse buttons 6-9 on macOS"
+```
+
+---
+
+## Task 3: Inject the buttons on Linux
+
+evdev 0.13.2 exposes `BTN_BACK`, `BTN_FORWARD`, `BTN_TASK`, and `BTN_0` as `KeyCode` constants. These are the conventional codes for extra mouse buttons beyond the side pair.
+
+**Files:**
+- Modify: `crates/openlogi-inject/src/inject.rs` (the `execute_linux` function, around line 77–78)
+
+- [ ] **Step 1: Add the Linux injection arms**
+
+Find the Linux extra-button arms (line ~77–78):
+
+```rust
+ Action::MouseBack => linux::click(KeyCode::BTN_SIDE),
+ Action::MouseForward => linux::click(KeyCode::BTN_EXTRA),
+```
+
+Add immediately after them:
+
+```rust
+ Action::MouseBack => linux::click(KeyCode::BTN_SIDE),
+ Action::MouseForward => linux::click(KeyCode::BTN_EXTRA),
+ // Buttons 6–9 use the evdev extra-button codes beyond the side pair.
+ Action::MouseButton6 => linux::click(KeyCode::BTN_FORWARD),
+ Action::MouseButton7 => linux::click(KeyCode::BTN_BACK),
+ Action::MouseButton8 => linux::click(KeyCode::BTN_TASK),
+ Action::MouseButton9 => linux::click(KeyCode::BTN_0),
+```
+
+- [ ] **Step 2: Verify it compiles on Linux (cross-check)**
+
+This is a `#[cfg(target_os = "linux")]` block. On macOS it won't be compiled, so to verify the `KeyCode::BTN_*` constants resolve, run a Linux target check:
+
+Run: `cargo check -p openlogi-inject --target x86_64-unknown-linux-gnu`
+Expected: If the target is installed, CHECK SUCCEEDS. If not installed, this step is skipped — the constant names (`BTN_FORWARD`/`BTN_BACK`/`BTN_TASK`/`BTN_0`) are confirmed present in evdev 0.13.2 (see spec's button-number table), and CI will catch any mismatch on Linux.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add crates/openlogi-inject/src/inject.rs
+git commit -m "feat(inject): synthesize mouse buttons 6-9 on Linux via evdev BTN_*"
+```
+
+---
+
+## Task 4: Log-and-skip on Windows
+
+Windows `SendInput` mouse input carries flags for buttons 1–5 only; there is no flag for button 6+. The codebase's established pattern for "no platform equivalent" is a `tracing::debug!` log and skip (see the macOS-only navigation actions at `inject.rs:104`). Mirror that.
+
+**Files:**
+- Modify: `crates/openlogi-inject/src/inject.rs` (the `execute_windows` function, around line 290–291)
+
+- [ ] **Step 1: Add the Windows log-and-skip arms**
+
+Find the Windows extra-button arms (line ~290–291):
+
+```rust
+ Action::MouseBack => windows::post_click(windows::MouseButton::Back),
+ Action::MouseForward => windows::post_click(windows::MouseButton::Forward),
+```
+
+Add immediately after them:
+
+```rust
+ Action::MouseBack => windows::post_click(windows::MouseButton::Back),
+ Action::MouseForward => windows::post_click(windows::MouseButton::Forward),
+ // Windows SendInput carries flags for buttons 1–5 only; there is no
+ // flag for button 6+, so these log-and-skip (same pattern as the
+ // macOS-only navigation actions). macOS/Linux emit them natively.
+ Action::MouseButton6
+ | Action::MouseButton7
+ | Action::MouseButton8
+ | Action::MouseButton9 => {
+ tracing::debug!(
+ action = action.label(),
+ "mouse buttons 6-9 are not supported on Windows — press ignored"
+ );
+ }
+```
+
+- [ ] **Step 2: Verify it compiles on Windows (cross-check)**
+
+Run: `cargo check -p openlogi-inject --target x86_64-pc-windows-msvc`
+Expected: If the target is installed, CHECK SUCCEEDS. If not, skip — CI covers Windows. (No new types are referenced; `tracing::debug!` and `action.label()` already exist.)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add crates/openlogi-inject/src/inject.rs
+git commit -m "feat(inject): log-and-skip mouse buttons 6-9 on Windows"
+```
+
+---
+
+## Task 5: Add picker icons (compiler-forced)
+
+The picker's `action_icon_path` is an exhaustive `match` with no wildcard. After Task 1, the macOS/GUI build will fail here until the four variants are mapped. Reuse the existing generic mouse icon (`action-icons/mouse.svg`, already used by `MiddleClick`).
+
+**Files:**
+- Modify: `crates/openlogi-gui/src/mouse_model/picker.rs` (the `action_icon_path` match, line ~304–305)
+
+- [ ] **Step 1: Add the four icon arms**
+
+Find the MouseBack/MouseForward arms (line ~304–305):
+
+```rust
+ Action::MouseBack => "action-icons/circle-arrow-left.svg",
+ Action::MouseForward => "action-icons/circle-arrow-right.svg",
+```
+
+Add immediately after them:
+
+```rust
+ Action::MouseBack => "action-icons/circle-arrow-left.svg",
+ Action::MouseForward => "action-icons/circle-arrow-right.svg",
+ // Buttons 6–9 have no canonical glyph; reuse the generic mouse icon
+ // (same as MiddleClick). The button number is in the label.
+ Action::MouseButton6
+ | Action::MouseButton7
+ | Action::MouseButton8
+ | Action::MouseButton9 => "action-icons/mouse.svg",
+```
+
+- [ ] **Step 2: Verify the full workspace builds (this is the compile-time gate)**
+
+Run: `cargo build -p openlogi-gui`
+Expected: BUILD SUCCEEDS. If any variant is still missing an arm anywhere, this fails — that's the safety net working.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add crates/openlogi-gui/src/mouse_model/picker.rs
+git commit -m "feat(gui): pick icons for mouse buttons 6-9 in the action picker"
+```
+
+---
+
+## Task 6: Add i18n keys to all 20 locale files
+
+The picker translates action labels via `t!(action.label())` — keyed by the English string. The existing `"Back (Button 4)"` / `"Forward (Button 5)"` keys live at line ~146–147 of every locale file. New keys `"Button 6"`–`"Button 9"` must be added so the labels translate. For non-English locales, the translation mirrors the English form plus the localized "Button" word where the locale already uses one — but since the English label is intentionally number-only, the safest correct default is to leave the translated value identical to the key (English fallback) except where the locale clearly localizes "Button" (most do not for raw button numbers).
+
+**Files:**
+- Modify: all 20 files in `crates/openlogi-gui/locales/*.yml`
+
+- [ ] **Step 1: Add the four keys to `en.yml` (the source)**
+
+In `crates/openlogi-gui/locales/en.yml`, after line 147 (`"Forward (Button 5)": "Forward (Button 5)"`), add:
+
+```yaml
+"Forward (Button 5)": "Forward (Button 5)"
+"Button 6": "Button 6"
+"Button 7": "Button 7"
+"Button 8": "Button 8"
+"Button 9": "Button 9"
+```
+
+- [ ] **Step 2: Add the same four keys to each of the other 19 locale files**
+
+For each file in `crates/openlogi-gui/locales/` except `en.yml`, after the `"Forward (Button 5)"` line (which exists at line ~147 in every file — confirmed), append:
+
+```yaml
+"Button 6": "Button 6"
+"Button 7": "Button 7"
+"Button 8": "Button 8"
+"Button 9": "Button 9"
+```
+
+The 19 files are: `da.yml de.yml el.yml es.yml fi.yml fr.yml it.yml ja.yml ko.yml nb.yml nl.yml pl.yml pt-BR.yml pt-PT.yml ru.yml sv.yml zh-CN.yml zh-HK.yml zh-TW.yml`.
+
+(Values are left as the English string — these are raw button numbers with no semantic name to localize. A crowdin pass can refine later; the project already uses crowdin per `crowdin.yml`. Leaving them English-identical is the correct fallback and matches how `en.yml` itself is authored.)
+
+- [ ] **Step 3: Verify the GUI still builds and the i18n test passes**
+
+Run: `cargo test -p openlogi-gui --lib i18n`
+Expected: PASS. (The i18n test at `i18n.rs:185+` checks specific known strings, not exhaustively, so it won't break — but it confirms the locale loader still parses all files.)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add crates/openlogi-gui/locales/*.yml
+git commit -m "feat(gui): add Button 6-9 translation keys to all locales"
+```
+
+---
+
+## Task 7: Final whole-workspace verification
+
+Confirm everything builds and tests pass end-to-end.
+
+- [ ] **Step 1: Build the whole workspace**
+
+Run: `cargo build --workspace`
+Expected: BUILD SUCCEEDS on macOS (the dev platform). This compiles `execute_macos`, the picker, the core — everything reachable on this host.
+
+- [ ] **Step 2: Run the whole test suite**
+
+Run: `cargo test --workspace`
+Expected: ALL PASS. Key tests: `binding::tests::category_mouse_variants`, `binding::tests::extra_mouse_button_labels`, `binding::tests::all_catalog_variants_roundtrip_toml` (now exercises the 4 new variants' TOML roundtrip).
+
+- [ ] **Step 3: Manual smoke check (optional but recommended)**
+
+Build and run the GUI, open a device, click a rebindable button (e.g. Gesture Button), and confirm "Button 6"–"Button 9" appear in the MOUSE section of the action picker. Bind one and confirm it fires (e.g. an app that binds MB6).
+
+Run: `cargo run -p openlogi-gui`
+
+- [ ] **Step 4: Final commit if any fixups were needed**
+
+If steps 1–2 surfaced anything to fix, commit it. Otherwise this task produces no commit.
+
+---
+
+## Self-Review Notes
+
+**Spec coverage:** Every layer in the spec's "Per-layer changes" table maps to a task — enum/label/category/catalog (Task 1), macOS inject (Task 2), Linux inject (Task 3), Windows log-and-skip (Task 4), picker icons (Task 5), i18n keys (Task 6). The spec's "Testing" section is covered by the test edits in Task 1 and the full-suite run in Task 7. Platform-coverage table matches (Windows gap explicit in Task 4).
+
+**No placeholders:** Every code step shows the exact code. The two `cargo check --target` steps for Linux/Windows explicitly document the "skip if target not installed" fallback rather than hiding it.
+
+**Type consistency:** Variant names `MouseButton6`–`MouseButton9` are identical across all tasks. macOS button numbers (5–8) are internally consistent with the existing 3/4 for Back/Forward. Linux `KeyCode` constants are confirmed in evdev 0.13.2.
diff --git a/docs/superpowers/plans/2026-06-30-function-key-remapper-m1.md b/docs/superpowers/plans/2026-06-30-function-key-remapper-m1.md
new file mode 100644
index 0000000000000000000000000000000000000000..e1618a810054a44c74fe231a5e8f04372b0f8a59
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-30-function-key-remapper-m1.md
@@ -0,0 +1,728 @@
+# Function-Key Remapper — M1 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Capture F1–F12 + Esc key presses (and Shift/Ctrl/Opt/Cmd-qualified combos) and remap each to any action in the existing palette, plus three new execution actions (`TypeText`, `RunAppleScript`, `RunShellCommand`).
+
+**Architecture:** Three layers, each mirroring the mouse path. (1) `openlogi-hook` gains keyboard event types and a `KeyEvent` vocabulary alongside `MouseEvent`. (2) `openlogi-core` gains three `Action` variants and a `[keyboard.bindings]` config section keyed by keycode+modifiers. (3) `openlogi-inject` gains a `post_unicode` text-typing primitive and the new action arms. The hook callback routes keyboard events through the same `EventDisposition` (PassThrough/Suppress) the mouse side uses, so remapped keys are suppressed exactly as remapped mouse buttons are.
+
+**Tech Stack:** Rust workspace; `CGEventTap` (macOS) for capture; `CGEventKeyboardSetUnicodeString` for text typing; `std::process::Command` for AppleScript/shell; serde/TOML for config.
+
+**Spec:** `docs/superpowers/specs/2026-06-30-function-key-remapper-design.md` (M1 scope only; M2 Workflow and M3 media-key capture are separate plans).
+
+---
+
+## File Structure
+
+| File | Responsibility | Change |
+|---|---|---|
+| `crates/openlogi-hook/src/lib.rs` | The event vocabulary (`MouseEvent`, `EventDisposition`) | Add `KeyEvent` + `HookEvent` union; widen the callback signature |
+| `crates/openlogi-hook/src/macos.rs` | The `CGEventTap` capture | Add keyboard event types to the mask; `translate` keyboard events; macOS keycode table for F-keys |
+| `crates/openlogi-core/src/binding.rs` | The `Action` enum + `label`/`category`/`catalog` | Add `TypeText`/`RunAppleScript`/`RunShellCommand` variants (excluded from catalog — power-user escape hatch) |
+| `crates/openlogi-core/src/config.rs` | Config loading | Add `[keyboard]` section + `KeyTrigger` (keycode + modifiers) |
+| `crates/openlogi-inject/src/inject.rs` | Action → OS event synthesis | Add `post_unicode` primitive; three new `Action` arms in `execute_macos` |
+| `crates/openlogi-agent-core/src/hook_runtime.rs` | Dispatches hook events → actions | Route `KeyEvent` → look up keyboard binding → execute action → Suppress |
+
+No new files except where a table cell says "Add". The exhaustive `match` arms across the codebase are the safety net (the picker icon `match`, the inject `match`) — they fail to compile if a variant is missed, exactly as with mouse buttons 6–9.
+
+---
+
+## Task 1: Add the `KeyEvent` vocabulary and widen the hook callback
+
+This is the foundational change: the hook must be able to report keyboard events, not just mouse. We add a `KeyEvent` type and a `HookEvent` union so the existing `Hook::start` callback can receive either, then update the (single) call site.
+
+**Files:**
+- Modify: `crates/openlogi-hook/src/lib.rs:47` (add `KeyEvent`, `HookEvent` near `MouseEvent`)
+- Modify: `crates/openlogi-hook/src/lib.rs` (the `Hook::start` signature — find it via `grep -n "pub fn start" crates/openlogi-hook/src/lib.rs`)
+- Modify: `crates/openlogi-agent-core/src/hook_runtime.rs:115` (the single call site)
+
+- [ ] **Step 1: Read the current `MouseEvent` + `Hook::start` signature**
+
+Run: `sed -n '40,100p' crates/openlogi-hook/src/lib.rs && grep -n "pub fn start" crates/openlogi-hook/src/lib.rs`
+Note the `MouseEvent` enum (around line 47), `EventDisposition` (line 95), and the `start` signature's callback type `impl Fn(MouseEvent) -> EventDisposition`.
+
+- [ ] **Step 2: Add `KeyEvent` + `KeyModifiers` + `HookEvent` to `lib.rs`**
+
+Immediately above `pub enum MouseEvent {` (line 47), add:
+
+```rust
+/// Which modifier keys were held when a key event fired. Mirrors the
+/// detectable macOS modifier flags (everything *except* Fn — see spec
+/// Appendix A; Fn is firmware-internal and never reported on non-function-row
+/// keys).
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
+pub struct KeyModifiers {
+ pub shift: bool,
+ pub control: bool,
+ pub option: bool,
+ pub command: bool,
+}
+
+/// A keyboard event observed by the hook.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct KeyEvent {
+ /// macOS virtual keycode (e.g. 122 = F1, 53 = Escape).
+ pub keycode: u16,
+ /// `true` = key down; `false` = key up.
+ pub pressed: bool,
+ /// Which modifiers were held.
+ pub modifiers: KeyModifiers,
+}
+
+/// Anything the hook can observe. `Mouse` keeps the existing callback shape;
+/// `Key` is the new keyboard path. Wrapping in a union means the callback
+/// signature widens once (here) and stays stable as more event classes arrive.
+#[derive(Debug, Clone, Copy)]
+pub enum HookEvent {
+ Mouse(MouseEvent),
+ Key(KeyEvent),
+}
+```
+
+- [ ] **Step 3: Widen the `Hook::start` callback to `HookEvent`**
+
+Find `pub fn start(` in `lib.rs`. Change every occurrence of the callback parameter type
+`impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static`
+to
+`impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static`
+(on all platform stubs — macOS, Linux, Windows, and the `Unsupported` fallback).
+
+- [ ] **Step 4: Update the single call site in `hook_runtime.rs:115`**
+
+The callback currently matches `MouseEvent` variants directly. Wrap the existing body
+to only act on `HookEvent::Mouse` and pass through keys for now:
+
+```rust
+let result = Hook::start(move |event| match event {
+ HookEvent::Mouse(mouse_event) => match mouse_event {
+ MouseEvent::Button { id, pressed } => {
+ // ... existing body unchanged ...
+ }
+ MouseEvent::Moved { delta_x, delta_y } => {
+ // ... existing body unchanged ...
+ }
+ MouseEvent::CaptureInterrupted => {
+ // ... existing body unchanged ...
+ }
+ MouseEvent::Scroll { .. } => EventDisposition::PassThrough,
+ },
+ HookEvent::Key(_) => EventDisposition::PassThrough, // wired up in Task 6
+});
+```
+
+Add the import: `use openlogi_hook::{EventDisposition, Hook, HookEvent, MouseEvent};`
+
+- [ ] **Step 5: Build + run the full hook + agent-core tests**
+
+Run: `cargo test -p openlogi-hook -p openlogi-agent-core`
+Expected: PASS. The keyboard path is inert (`PassThrough`), so behavior is unchanged; this just proves the widened signature compiles and nothing regresses.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add crates/openlogi-hook/src/lib.rs crates/openlogi-agent-core/src/hook_runtime.rs
+git commit -m "refactor(hook): widen hook callback to HookEvent (Mouse | Key)
+
+Adds KeyEvent + KeyModifiers + HookEvent vocabulary alongside MouseEvent.
+Hook::start's callback now receives HookEvent; hook_runtime wraps its
+existing MouseEvent body and passes keys through inertly. No behavior
+change yet — keyboard capture lands in the next task."
+```
+
+---
+
+## Task 2: Capture keyboard events in the macOS `CGEventTap`
+
+Extend the existing tap (currently mouse-only) to also subscribe to keyboard event types, and translate them into `KeyEvent`s. F-keys are proven to arrive here (F1 = keycode 122 + `SecondaryFn` flag); this task makes the tap see them.
+
+**Files:**
+- Modify: `crates/openlogi-hook/src/macos.rs:452` (the `event_types` vec)
+- Modify: `crates/openlogi-hook/src/macos.rs:257` (`translate` — add keyboard arms) and the callback closure at `:475`
+
+- [ ] **Step 1: Add keyboard event types to the tap mask**
+
+In `macos.rs`, the `event_types` vec (line 452) currently lists only mouse types. Append:
+
+```rust
+ let event_types = vec![
+ CGEventType::LeftMouseDown,
+ CGEventType::LeftMouseUp,
+ // ... existing mouse types unchanged ...
+ CGEventType::OtherMouseDragged,
+ // NEW — keyboard capture for the function-key remapper (M1).
+ CGEventType::KeyDown,
+ CGEventType::KeyUp,
+ CGEventType::FlagsChanged,
+ ];
+```
+
+- [ ] **Step 2: Add a keyboard-translation helper**
+
+Above the existing `fn translate(...)` (line 257), add:
+
+```rust
+/// Map the macOS modifier flags on a `CGEvent` to our [`KeyModifiers`].
+/// `SecondaryFn` is deliberately ignored — it is firmware-internal and
+/// unreliable as a trigger (see spec Appendix A).
+fn modifiers_from_flags(flags: CGEventFlags) -> KeyModifiers {
+ KeyModifiers {
+ shift: flags.contains(CGEventFlags::MASK_SHIFT),
+ control: flags.contains(CGEventFlags::MASK_CONTROL),
+ option: flags.contains(CGEventFlags::MASK_ALTERNATE),
+ command: flags.contains(CGEventFlags::MASK_COMMAND),
+ }
+}
+
+/// Translate a keyboard `CGEvent` into a [`KeyEvent`]. Returns `None` for
+/// non-key event types (handled by the mouse path) or for `FlagsChanged`
+/// alone (modifier state is reported on the subsequent key event).
+fn translate_key(etype: CGEventType, event: &CGEvent) -> Option<KeyEvent> {
+ let (pressed, keycode) = match etype {
+ CGEventType::KeyDown => (true, event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE) as u16),
+ CGEventType::KeyUp => (false, event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE) as u16),
+ // FlagsChanged carries no keycode of interest here; modifiers ride on
+ // the next key event via its flags. Drop it.
+ _ => return None,
+ };
+ Some(KeyEvent {
+ keycode,
+ pressed,
+ modifiers: modifiers_from_flags(event.get_flags()),
+ })
+}
+```
+
+(Add `use core_graphics::event::EventField;` to the imports if not already present — check with `grep -n "use core_graphics" crates/openlogi-hook/src/macos.rs | head`.)
+
+- [ ] **Step 3: Route keyboard events through the callback**
+
+The callback closure (line 475) currently does `let Some(mouse_event) = translate(etype, event)`. Replace it to build a `HookEvent` from either path:
+
+```rust
+ move |_proxy: CGEventTapProxy, etype: CGEventType, event: &CGEvent| {
+ let hook_event = if let Some(mouse_event) = translate(etype, event) {
+ HookEvent::Mouse(mouse_event)
+ } else if let Some(key_event) = translate_key(etype, event) {
+ HookEvent::Key(key_event)
+ } else {
+ return CallbackResult::Keep;
+ };
+ match cb(hook_event) {
+ EventDisposition::PassThrough => CallbackResult::Keep,
+ EventDisposition::Suppress => CallbackResult::Drop,
+ }
+ },
+```
+
+- [ ] **Step 4: Build the hook crate**
+
+Run: `cargo build -p openlogi-hook`
+Expected: BUILD SUCCEEDS. The `CGEventFlags::MASK_*` constant names must match what `core-graphics` exposes; if any is named differently, the compiler error names the correct constant — fix and rebuild.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add crates/openlogi-hook/src/macos.rs
+git commit -m "feat(hook): capture keyboard events in the macOS CGEventTap
+
+Adds KeyDown/KeyUp/FlagsChanged to the tap mask, plus translate_key()
+which maps a key CGEvent to our KeyEvent (keycode + press state +
+detectable modifiers, ignoring SecondaryFn). The callback now builds a
+HookEvent from either the mouse or key path. F1-F12/Esc are now observed;
+nothing acts on them yet."
+```
+
+---
+
+## Task 3: Add the three execution `Action` variants
+
+The action palette gains `TypeText`, `RunAppleScript`, `RunShellCommand`. These are power-user escape hatches (like `CustomShortcut`), so they are **excluded from the default catalog** — they must be hand-authored in config. This task only adds the variants + their `label`/`category`/TOML shape; injection lands in Task 5.
+
+**Files:**
+- Modify: `crates/openlogi-core/src/binding.rs` — `Action` enum (near `CustomShortcut(KeyCombo)` at line 483), `label()` (:679), `category()` (:731), `catalog()` (:787)
+
+- [ ] **Step 1: Add the failing test for the new variants' category + label**
+
+In `binding.rs`, append to the `#[cfg(test)] mod tests` block:
+
+```rust
+ #[test]
+ fn power_user_action_labels_and_category() {
+ assert_eq!(Action::TypeText("hi".into()).label(), "Type \"hi\"");
+ assert_eq!(Action::RunAppleScript("osascript".into()).label(), "Run AppleScript");
+ assert_eq!(Action::RunShellCommand("echo hi".into()).label(), "Run Command");
+ // All three are power-user escape hatches: never in the default catalog,
+ // but classed as Editing so a hand-authored binding has a home group.
+ assert_eq!(Action::TypeText("x".into()).category(), Category::Editing);
+ assert_eq!(Action::RunAppleScript("x".into()).category(), Category::Editing);
+ assert_eq!(Action::RunShellCommand("x".into()).category(), Category::Editing);
+ }
+
+ #[test]
+ fn power_user_actions_excluded_from_catalog() {
+ let cat = Action::catalog();
+ assert!(cat.iter().all(|a| !matches!(a,
+ Action::TypeText(_) | Action::RunAppleScript(_) | Action::RunShellCommand(_))));
+ }
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `cargo test -p openlogi-core --lib binding::tests::power_user`
+Expected: FAIL with `cannot find variant TypeText` — compile error.
+
+- [ ] **Step 3: Add the three variants to the `Action` enum**
+
+After `CustomShortcut(KeyCombo),` (line 483), add:
+
+```rust
+ /// Type an arbitrary string by emitting unicode characters (macOS
+ /// `CGEventKeyboardSetUnicodeString`). Used for macro text. Power-user
+ /// escape hatch — excluded from the default catalog.
+ TypeText(String),
+ /// Run an AppleScript via `osascript -e <source>`. Power-user escape hatch.
+ RunAppleScript(String),
+ /// Run a shell command via `/bin/sh -c <command>`. Power-user escape hatch.
+ RunShellCommand(String),
+```
+
+- [ ] **Step 4: Add the three labels**
+
+In `label()` (:679), in the `match` (after the `CustomShortcut` arm), add:
+
+```rust
+ Action::TypeText(s) => format!("Type \"{s}\"").into(),
+ Action::RunAppleScript(_) => "Run AppleScript".into(),
+ Action::RunShellCommand(_) => "Run Command".into(),
+```
+
+- [ ] **Step 5: Add the three category arms**
+
+In `category()` (:731), extend the existing `Editing` arm that already holds
+`CustomShortcut`:
+
+```rust
+ | Action::CustomShortcut(_)
+ | Action::TypeText(_)
+ | Action::RunAppleScript(_)
+ | Action::RunShellCommand(_) => Category::Editing,
+```
+
+- [ ] **Step 6: Confirm `catalog()` excludes them**
+
+`catalog()` (:787) is an explicit list — by NOT adding the three variants to it,
+they are excluded. Verify the existing `catalog_excludes_custom_shortcut` test
+pattern and confirm no test forces them in. No code change needed here; the
+test in Step 1 asserts exclusion.
+
+- [ ] **Step 7: Run the tests to verify they pass + the TOML roundtrip still works**
+
+Run: `cargo test -p openlogi-core --lib binding`
+Expected: PASS — including the new tests and the existing
+`all_catalog_variants_roundtrip_toml` (the new variants aren't in the catalog,
+but add a manual roundtrip assertion for `TypeText` in the test block):
+
+```rust
+ #[test]
+ fn power_user_actions_roundtrip_toml() {
+ for action in [
+ Action::TypeText("hello".into()),
+ Action::RunAppleScript("beep".into()),
+ Action::RunShellCommand("date".into()),
+ ] {
+ let toml = toml::to_string(&action).unwrap();
+ let back: Action = toml::from_str(&toml).unwrap();
+ assert_eq!(action, back);
+ }
+ }
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add crates/openlogi-core/src/binding.rs
+git commit -m "feat(core): add TypeText / RunAppleScript / RunShellCommand actions
+
+Three power-user escape-hatch actions (excluded from the default catalog,
+classed as Editing). TypeText emits a unicode string; the two Run actions
+spawn osascript / sh. Injection arms land in the inject task."
+```
+
+---
+
+## Task 4: Add the `[keyboard]` config section + `KeyTrigger`
+
+The config gains a new top-level `[keyboard]` table mapping trigger strings (`"f1"`, `"shift+f1"`) to actions. This is independent of the per-device `[devices]` bindings.
+
+**Files:**
+- Modify: `crates/openlogi-core/src/config.rs` — the `Config` struct (find via `grep -n "pub struct Config" crates/openlogi-core/src/config.rs`)
+- Create: the `KeyTrigger` type + trigger-string parser lives in `config.rs` (single file, follow existing patterns)
+
+- [ ] **Step 1: Read the current `Config` struct + a device-binding sample**
+
+Run: `sed -n '/pub struct Config/,/^}/p' crates/openlogi-core/src/config.rs`
+Note the existing fields (`devices`, `app_settings`, `schema_version`) and how
+bindings are typed.
+
+- [ ] **Step 2: Write the failing test for the trigger-string parser + config load**
+
+Append to `config.rs`'s test module:
+
+```rust
+ #[test]
+ fn key_trigger_parses_bare_and_modified() {
+ // Bare function key.
+ let t: KeyTrigger = "f1".parse().unwrap();
+ assert_eq!(t.keycode, 122);
+ assert!(t.modifiers.is_empty());
+ // Modifier-qualified.
+ let t: KeyTrigger = "shift+cmd+f5".parse().unwrap();
+ assert_eq!(t.keycode, 96); // F5
+ assert!(t.modifiers.shift && t.modifiers.command);
+ assert!(!t.modifiers.control && !t.modifiers.option);
+ }
+
+ #[test]
+ fn keyboard_section_loads_from_toml() {
+ let toml = r#"
+[keyboard.bindings]
+"f1" = { TypeText = "hi" }
+"shift+f2" = "VolumeUp"
+"#;
+ let cfg: Config = toml::from_str(toml).unwrap();
+ assert_eq!(cfg.keyboard.bindings.len(), 2);
+ assert!(cfg.keyboard.bindings.contains_key(&"f1".parse::<KeyTrigger>().unwrap()));
+ }
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+Run: `cargo test -p openlogi-core --lib config::`
+Expected: FAIL — `KeyTrigger` and `keyboard` field don't exist.
+
+- [ ] **Step 4: Add `KeyTrigger` + the parser + `KeyboardConfig`**
+
+In `config.rs`, add (types first, then impls):
+
+```rust
+use std::str::FromStr;
+
+/// A keyboard trigger: a keycode plus an optional modifier mask. The parse
+/// format is `[mod+]+key`, e.g. `"f1"`, `"shift+cmd+f5"`. Modifier names are
+/// `shift`, `control` (alias `ctrl`), `option` (alias `alt`), `command`
+/// (alias `cmd`). Key names: `esc`, `f1`..`f12`.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct KeyTrigger {
+ pub keycode: u16,
+ pub modifiers: KeyModifiers,
+}
+
+impl KeyModifiers {
+ pub fn is_empty(&self) -> bool {
+ !self.shift && !self.control && !self.option && !self.command
+ }
+}
+
+#[derive(Debug, Default)]
+pub struct ParseTriggerError(String);
+impl std::fmt::Display for ParseTriggerError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "invalid key trigger: {}", self.0)
+ }
+}
+impl std::error::Error for ParseTriggerError {}
+
+impl FromStr for KeyTrigger {
+ type Err = ParseTriggerError;
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let mut mods = KeyModifiers::default();
+ let mut parts = s.split('+').map(str::trim);
+ let mut last: Option<&str> = None;
+ for part in parts.by_ref() {
+ match part.to_ascii_lowercase().as_str() {
+ "shift" => mods.shift = true,
+ "control" | "ctrl" => mods.control = true,
+ "option" | "alt" => mods.option = true,
+ "command" | "cmd" => mods.command = true,
+ _ => { last = Some(part); break; }
+ }
+ }
+ let key = last.or_else(|| parts.next()).ok_or_else(|| ParseTriggerError("no key".into()))?;
+ let keycode = match key.to_ascii_lowercase().as_str() {
+ "esc" => 53,
+ "f1" => 122, "f2" => 120, "f3" => 99, "f4" => 118,
+ "f5" => 96, "f6" => 97, "f7" => 98, "f8" => 100,
+ "f9" => 101, "f10" => 109, "f11" => 103, "f12" => 111,
+ other => return Err(ParseTriggerError(format!("unknown key '{other}'"))),
+ };
+ Ok(KeyTrigger { keycode, modifiers: mods })
+ }
+}
+
+/// The top-level `[keyboard]` table.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct KeyboardConfig {
+ /// Maps a trigger string (parsed into [`KeyTrigger`]) to its action.
+ /// Keyed by a `KeyTrigger`-rendered string for stable TOML.
+ #[serde(default)]
+ pub bindings: std::collections::HashMap<KeyTrigger, openlogi_core::binding::Action>,
+}
+```
+
+(`KeyModifiers` lives in `openlogi-hook`; re-export or duplicate the four bools
+in `openlogi-core` to avoid a core→hook dependency. Prefer duplicating — core
+must stay leaf-level. Use a `core::KeyModifiers` with the same shape and
+convert at the boundary in Task 6.)
+
+Add the field to `Config`:
+
+```rust
+ #[serde(default)]
+ pub keyboard: KeyboardConfig,
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `cargo test -p openlogi-core --lib config::`
+Expected: PASS — parser + TOML load both green.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add crates/openlogi-core/src/config.rs
+git commit -m "feat(core): add [keyboard] config section + KeyTrigger parser
+
+KeyTrigger parses '[mod+]+key' strings (f1, shift+cmd+f5, esc) into a
+keycode + modifier mask, using the macOS F-key virtual keycodes. The
+[keyboard.bindings] table maps triggers to Actions, independent of the
+per-device bindings."
+```
+
+---
+
+## Task 5: Add the `post_unicode` primitive + inject the three new actions
+
+The execution layer. `post_unicode` types a string via `CGEventKeyboardSetUnicodeString`; the three new `Action` arms call it (for `TypeText`) or spawn a process (for the two Run actions).
+
+**Files:**
+- Modify: `crates/openlogi-inject/src/inject.rs:516` (add `post_unicode` next to `post_key`) and the `execute_macos` match arms
+
+- [ ] **Step 1: Add the `post_unicode` primitive to the macOS mod**
+
+In the `mod macos {` block (after `post_media_key`, line 541), add:
+
+```rust
+ /// Type an arbitrary unicode string by emitting a single key event per
+ /// character whose payload is set via `CGEventKeyboardSetUnicodeString`.
+ /// This sidesteps the keyboard layout entirely — characters are injected
+ /// as unicode, so "bite me" types verbatim regardless of layout.
+ pub(super) fn post_unicode(text: &str) {
+ for ch in text.chars() {
+ let mut buf = [0u16; 2];
+ let s: Cow<str> = Cow::Owned(ch.to_string());
+ let _ = s; // unused; the unicode string is set on the event below.
+ let event = CGEvent::new(None);
+ event.set_flags(CGEventFlags::empty());
+ // CGEventKeyboardSetUnicodeString: max 20 UTF-16 units per call;
+ // one char at a time is simplest and always in-bounds.
+ let units: Vec<u16> = ch.encode_utf16(&mut buf).to_vec();
+ unsafe {
+ core_foundation::string::CFString::from(&*units.iter()
+ .filter_map(|&u| char::from_u32(u as u32))
+ .collect::<String>());
+ }
+ // Use the core-graphics binding's keyboard-set-unicode path:
+ event.set_string_from_utf16(&units);
+ event.post(CGEventTapLocation::HID);
+ }
+ }
+```
+
+NOTE: the exact `core-graphics` API for setting a unicode string on a `CGEvent`
+varies by crate version — `set_string_from_utf16` is the typical name. If the
+compiler rejects it, run `grep -rn "KeyboardSetUnicodeString\|set_string\|unicode" ~/.cargo/registry/src/*/core-graphics-*/src/event.rs` to find the exact method
+name in the pinned version, and use that. The contract is: one `CGEvent` per
+character, unicode payload set, posted to HID.
+
+- [ ] **Step 2: Add the three `execute_macos` arms**
+
+In `execute_macos` (find the `match action {` and the `CustomShortcut` arm near line 142), add:
+
+```rust
+ Action::TypeText(text) => macos::post_unicode(text),
+ Action::RunAppleScript(src) => {
+ // Fire-and-forget; the agent must not block the event tap thread.
+ let src = src.clone();
+ std::thread::spawn(move || {
+ let _ = std::process::Command::new("osascript")
+ .args(["-e", &src])
+ .output();
+ });
+ }
+ Action::RunShellCommand(cmd) => {
+ let cmd = cmd.clone();
+ std::thread::spawn(move || {
+ let _ = std::process::Command::new("/bin/sh")
+ .args(["-c", &cmd])
+ .output();
+ });
+ }
+```
+
+(The Run actions spawn off the tap thread because the tap callback must not
+block — posting a key while the tap is waiting on a child process wedges input.
+Same discipline the existing mouse actions follow.)
+
+- [ ] **Step 3: Build the inject crate**
+
+Run: `cargo build -p openlogi-inject`
+Expected: BUILD SUCCEEDS once the `post_unicode` API name matches the pinned
+core-graphics version (resolve per the NOTE in Step 1 if needed).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add crates/openlogi-inject/src/inject.rs
+git commit -m "feat(inject): post_unicode primitive + TypeText/Run* execution
+
+post_unicode types a string one char at a time via
+CGEventKeyboardSetUnicodeString (layout-independent). TypeText uses it;
+RunAppleScript spawns osascript, RunShellCommand spawns /bin/sh, both
+off the tap thread so a slow script can't wedge input."
+```
+
+---
+
+## Task 6: Wire keyboard events → bindings → actions in `hook_runtime`
+
+The integration task. A `KeyEvent` arrives; look it up in the `[keyboard.bindings]` table (by keycode + modifiers); if matched, execute the action and `Suppress` the original key; else `PassThrough`.
+
+**Files:**
+- Modify: `crates/openlogi-agent-core/src/hook_runtime.rs` (the `HookEvent::Key(_)` arm from Task 1, Step 4)
+
+- [ ] **Step 1: Read how mouse bindings are looked up + executed**
+
+Run: `grep -n "bindings\|MouseEvent::Button\|inject\|execute" crates/openlogi-agent-core/src/hook_runtime.rs | head -20`
+Note how `MouseEvent::Button { id, pressed }` finds its action and calls into
+`openlogi-inject`. Mirror that for keys.
+
+- [ ] **Step 2: Replace the inert `HookEvent::Key(_)` arm with real lookup**
+
+The binding state needs access to the loaded `Config`'s `keyboard.bindings`.
+Capture an `Arc<HashMap<KeyTrigger, Action>>` into the hook closure (same way
+the mouse bindings are captured — find the existing `Arc` capture pattern in
+`hook_runtime.rs` and mirror it). Then:
+
+```rust
+ HookEvent::Key(KeyEvent { keycode, pressed: true, modifiers }) => {
+ // Only act on key-down (avoid double-fire on key-up).
+ let trigger = KeyTrigger { keycode, modifiers: convert_modifiers(modifiers) };
+ match keyboard_bindings.get(&trigger) {
+ Some(action) => {
+ execute_action(action); // reuse the existing mouse-action executor
+ EventDisposition::Suppress // eat the original key
+ }
+ None => EventDisposition::PassThrough,
+ }
+ }
+ HookEvent::Key(_) => EventDisposition::PassThrough, // key-up, ignore
+```
+
+`convert_modifiers` maps `hook::KeyModifiers` → `config::KeyModifiers` (the
+duplicate-type boundary noted in Task 4, Step 4). Add it as a small `fn` in
+`hook_runtime.rs`.
+
+- [ ] **Step 3: Build + run agent-core tests**
+
+Run: `cargo test -p openlogi-agent-core`
+Expected: PASS. No new test here — the integration is exercised manually in
+Task 7 (the unit-testable seams are the parser and the action arms, both
+already covered).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add crates/openlogi-agent-core/src/hook_runtime.rs
+git commit -m "feat(agent): dispatch keyboard events to [keyboard] bindings
+
+A key-down whose keycode+modifiers match a [keyboard.bindings] entry
+executes its action and suppresses the original key; unmatched keys pass
+through. Reuses the existing action executor; key-up is ignored."
+```
+
+---
+
+## Task 7: Manual end-to-end verification on hardware
+
+M1 is complete at this point. This task verifies it on real hardware — the
+critical check, per the spec's "test incrementally on hardware" note.
+
+- [ ] **Step 1: Build the dev agent**
+
+Run: `cargo build -p openlogi-agent`
+
+- [ ] **Step 2: Add a test binding to config**
+
+Append to `~/.config/openlogi/config.toml`:
+
+```toml
+[keyboard.bindings]
+"f1" = { TypeText = "hello from F1" }
+```
+
+- [ ] **Step 3: Stop the installed agent and run the dev agent foreground**
+
+```sh
+launchctl bootout gui/$(id -u)/org.openlogi.agent
+# also quit the GUI so it doesn't respawn the agent
+osascript -e 'tell application "OpenLogi" to quit'
+sleep 2
+OPENLOGI_LOG=debug target/debug/openlogi-agent
+```
+
+- [ ] **Step 4: Press F1 in a text field**
+
+Expected: the text "hello from F1" is typed. The original F1 is suppressed (no
+brightness/media action fires).
+
+- [ ] **Step 5: Verify the failure modes don't wedge input**
+
+- Press an **unbound** key (e.g. `a`) — it must type normally (PassThrough works).
+- Hold the agent running for 30s of mixed typing — input must not freeze. If it
+ does, the tap is wedging; revisit Task 2 (the documented HID-tap failure mode).
+
+- [ ] **Step 6: Restore the installed agent**
+
+```sh
+launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.openlogi.agent.plist
+```
+
+- [ ] **Step 7: Commit any fixups + tag the milestone**
+
+If Steps 4–5 surfaced anything, fix and commit. Then:
+
+```bash
+git commit --allow-empty -m "chore: M1 complete — F-key capture + action palette verified on hardware"
+```
+
+---
+
+## Self-Review Notes
+
+**Spec coverage (M1 scope):** Execution actions (TypeText/RunAppleScript/RunShellCommand) → Task 3 + 5. `[keyboard.bindings]` config + KeyTrigger → Task 4. F-key capture → Task 2. Modifier-qualified combos → Task 4 (parser) + Task 6 (dispatch). Press-to-bind is **deferred to a later M1.x** — it's a UI/UX flow, not a correctness gap, and adding it here would balloon the plan; flagged honestly rather than hidden. Fixed F-key list → covered by the parser's `f1..f12`/`esc` table (Task 4). Suppress-on-remap → Task 6 + verified in Task 7 Step 5. Media-key reassignment (existing `post_media_key`) is already wired and reachable as a binding target (Task 4's `Action` is the full enum).
+
+**Placeholder scan:** Task 5 Step 1 has an explicit NOTE about resolving the exact `core-graphics` unicode API name against the pinned version — this is a *known unknown with a resolution path*, not a placeholder; the grep command finds the real method name. No "TBD"/"implement later"/"add error handling" anywhere.
+
+**Type consistency:** `KeyEvent` (hook) ↔ `KeyModifiers` (hook) ↔ `KeyTrigger` (config) ↔ `KeyModifiers` (config, duplicate) — the `convert_modifiers` boundary fn in Task 6 bridges the intentional duplicate (core stays leaf-level, no core→hook dep). `Action::TypeText(String)` etc. consistent across Tasks 3/5/6.
+
+**Out of scope (separate plans):** M2 `Workflow` sequencer, M3 media-key capture, press-to-bind UI, per-app keyboard profiles, Windows/Linux capture.
+
+## Execution Handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-06-30-function-key-remapper-m1.md`. Two execution options:
+
+**1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration. Best for a multi-task plan touching the input hook (where each task changes observable behavior).
+
+**2. Inline Execution** — execute tasks in this session with checkpoints for review.
+
+Which approach?
diff --git a/docs/superpowers/specs/2026-06-29-mouse-buttons-6-9-design.md b/docs/superpowers/specs/2026-06-29-mouse-buttons-6-9-design.md
new file mode 100644
index 0000000000000000000000000000000000000000..b88a4561e2bdb27870b0d9680164807c3fb78440
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-29-mouse-buttons-6-9-design.md
@@ -0,0 +1,153 @@
+# Design: Mouse buttons 6–9
+
+**Status:** Approved
+**Date:** 2026-06-29
+**Scope:** Add four pickable actions that synthesize mouse buttons 6–9, so apps
+that bind those buttons (CAD, games, Blender, MMO-mouse emulators) receive them.
+
+## Problem
+
+OpenLogi's `Action` vocabulary caps mouse output at "button 5"
+(`MouseForward`). There is no way to emit mouse buttons 6–9, even though the
+underlying injection layer on macOS and Linux already supports arbitrary button
+numbers. Users who want a physical button to produce a button-6-or-higher event
+have no path today.
+
+## Goal / non-goals
+
+**Goal:** Let any rebindable `ButtonId` be mapped to an emitted mouse button
+6, 7, 8, or 9, selectable from the action picker like any existing action.
+
+**Non-goals:**
+
+- No new physical button capture — these are *output* actions bound to existing
+ physical buttons (e.g. Gesture Button → button 6).
+- No new picker UI. The catalog auto-surfaces new variants under the MOUSE
+ section.
+- No Windows support for buttons 6–9. `SendInput`'s mouse path carries flags
+ for buttons 1–5 only; 6–9 are a documented macOS/Linux-only gap (Windows
+ remains an "untested preview" per the README). See
+ [Platform coverage](#platform-coverage).
+
+## Background: why this is small
+
+Every layer of the action stack is data-driven from the `Action` enum:
+
+- The GUI picker (`crates/openlogi-gui/src/mouse_model/picker.rs`) builds its
+ rows by calling `Action::catalog()` and grouping by `Action::category()`.
+- The picker's icon mapping (`picker.rs:298`) is an exhaustive `match` with
+ **no wildcard arm**, so the compiler refuses to build if a new variant lacks
+ an icon entry.
+- `Action::label()` / `category()` / `catalog()` drive the picker text,
+ grouping, and TOML roundtrip tests.
+- The injection layer on macOS (`post_other_button(n)`) already accepts any
+ button number via the `MOUSE_EVENT_BUTTON_NUMBER` field; Linux `evdev`
+ exposes `BTN_BACK`/`BTN_FORWARD`/`BTN_TASK`/`BTN_0`…
+
+So the work is: four enum variants, each threaded through the data-driven
+machinery that already exists for `MouseBack`/`MouseForward`.
+
+## Design
+
+### New variants
+
+Append four unit variants to `Action` in `crates/openlogi-core/src/binding.rs`,
+directly after `MouseForward`, mirroring that pattern exactly:
+
+```rust
+/// Extra mouse button 6. Emitted as the real button-6 event for apps/games/CAD
+/// that bind it. macOS/Linux only — Windows SendInput caps at button 5.
+MouseButton6,
+MouseButton7,
+MouseButton8,
+MouseButton9,
+```
+
+**Naming:** variant identifiers `MouseButton6`..`MouseButton9`; display labels
+`"Button 6"`..`"Button 9"`. Unlike `Back`/`Forward`, these numbers have no
+universal semantic meaning, so they carry no semantic name.
+
+### Per-layer changes
+
+| Layer | File | Change |
+|---|---|---|
+| Enum | `openlogi-core/src/binding.rs` | +4 variants after `MouseForward` |
+| `Action::label()` | same | `"Button 6"` … `"Button 9"` |
+| `Action::category()` | same | all 4 → `Category::Mouse` |
+| `Action::catalog()` | same | append all 4 to the catalog (Mouse group) |
+| Picker icon map | `openlogi-gui/src/mouse_model/picker.rs:298` | map all 4 to an existing generic icon (`action-icons/mouse.svg`) — the existing exhaustive `match` forces this |
+| Inject — macOS | `openlogi-inject/src/inject.rs` (`execute_macos`) | `MouseButton6..9` → `macos::post_other_button(5..=8)` |
+| Inject — Linux | `openlogi-inject/src/inject.rs` (`execute_linux`) | → `BTN_FORWARD` / `BTN_BACK` / `BTN_TASK` / `BTN_0` |
+| Inject — Windows | `openlogi-inject/src/inject.rs` (`execute_windows`) | log-and-skip (`tracing::debug!`, same pattern as the macOS-only navigation actions at `inject.rs:104`) |
+
+### Button-number mapping
+
+The macOS convention (0-indexed, from existing `MouseBack`=3, `MouseForward`=4)
+extends naturally:
+
+| Action | macOS `post_other_button` arg | Linux evdev `KeyCode` |
+|---|---|---|
+| `MouseButton6` | 5 | `BTN_FORWARD` |
+| `MouseButton7` | 6 | `BTN_BACK` |
+| `MouseButton8` | 7 | `BTN_TASK` |
+| `MouseButton9` | 8 | `BTN_0` |
+
+> **Open question for implementation:** the Linux `BTN_*` assignment above is a
+> reasonable convention (the evdev `BTN_BACK/FORWARD/TASK/0..9` family is how
+> multi-button mice report extras), but exact code choice is a convention call
+> that should be confirmed against how target apps read buttons on Linux.
+> macOS numbers are unambiguous.
+
+### TOML / config schema
+
+Unit variants serialize as bare strings via serde's default external tagging —
+identical to `MouseBack`/`MouseForward`:
+
+```toml
+[devices."<addr>".bindings]
+GestureButton = "MouseButton6"
+# In gesture form:
+Back = { Click = "MouseButton7" }
+```
+
+**Stability contract preserved:** existing variant names are frozen; these are
+purely additive new names. **No `schema_version` bump, no migration.** Older
+OpenLogi builds reading a config containing `MouseButton6` will error on the
+unknown variant (acceptable — same as any newer-schema config on older code).
+
+### Platform coverage
+
+| Platform | Buttons 6–9 | Notes |
+|---|---|---|
+| macOS | ✅ Full | `post_other_button` already takes any number |
+| Linux | ✅ Full | evdev `BTN_*` family |
+| Windows | ❌ Log-and-skip | `SendInput` mouse path (`inject.rs:1416`) has flags for buttons 1–5 only; no flag exists for 6+. Documented gap, matches the codebase's existing "no platform equivalent → debug log + skip" pattern. |
+
+Windows users who bind these actions see nothing on press and a debug log line;
+no crash, no misfire. Given Windows is an untested preview and the requester is
+on macOS, this boundary is acceptable and explicitly out of scope to fix here.
+
+## Testing
+
+- **TOML roundtrip:** `all_catalog_variants_roundtrip_toml` already iterates
+ `catalog()`, so the four new entries are covered automatically once in the
+ catalog.
+- **Category:** extend `category_mouse_variants` to assert all four map to
+ `Category::Mouse`.
+- **Compile-time guarantee:** the exhaustive picker icon `match` (no wildcard)
+ fails to build if any variant is missed — this is the primary safety net.
+
+## Risks
+
+- **Linux `BTN_*` choice** — convention rather than correctness; see open
+ question above. Low impact (target apps are the test).
+- **Pickup-row clutter** — four more entries in the MOUSE group. Acceptable;
+ matches user intent for a "full set".
+- None to the input-capture path — these are pure output/synthesis actions.
+
+## Out of scope
+
+- Windows support for buttons 6–9.
+- A parameterized `MouseButton(n)` variant (Approach B) — rejected as
+ disproportionate picker UI for a fixed set of four.
+- Capturing buttons 6–9 as *input* from exotic hardware.
diff --git a/docs/superpowers/specs/2026-06-30-function-key-remapper-design.md b/docs/superpowers/specs/2026-06-30-function-key-remapper-design.md
new file mode 100644
index 0000000000000000000000000000000000000000..5a5907aa18e410d1e79ec3ce9752d0dd77302605
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-30-function-key-remapper-design.md
@@ -0,0 +1,243 @@
+# Design: Function-Key Remapper
+
+**Status:** Draft (pending user review)
+**Date:** 2026-06-30
+**Scope:** Turn every capturable function-row key (and, in a later milestone, the
+system media keys) into a fully programmable trigger that can reassign media
+keys, type macro strings, run AppleScript, run shell commands, or execute a
+timed multi-step workflow.
+
+## Motivation
+
+OpenLogi today remaps **mouse** buttons only. Its event hook captures no
+keyboard events at all, despite a rich output `Action` palette (media-key
+emission, `CustomShortcut` chords, browser/app navigation). Users get no value
+out of the function row beyond what the firmware already does — and the
+firmware's defaults frequently don't fit (e.g. volume keys are useless when an
+external amp manages audio; the emoji/Globe key is unwanted).
+
+The function row is a captive, always-there set of physical triggers that
+*can* be observed (proven empirically: F1 arrives at a `CGEventTap` as keycode
+122 with the `SecondaryFn`/`0x80000000` flag), and there is no reason a device-
+remapping app should leave it unconfigurable. This design makes every
+capturable key a fully programmable one.
+
+## Goals / non-goals
+
+**Goals**
+- Remap F1–F12 + Esc (the literal function-row keys) to arbitrary actions.
+- A powerful action palette: reassign to any media key, type a macro string,
+ run AppleScript, run a shell command, or run a timed multi-step workflow.
+- Modifier-qualified combos (Shift/Ctrl/Opt/Cmd + function key) so one physical
+ key hosts multiple actions.
+- A press-to-bind capture flow so any capturable key can be bound without
+ picking from a fixed list.
+
+**Non-goals (for this design)**
+- **Capturing the `Fn` modifier itself** as a trigger. Proven infeasible: the Fn
+ flag attaches only to function-row keys, never to letters, numbers, or other
+ modifiers. `Fn+Q` is byte-identical to plain `Q` at the event tap. Fn is
+ firmware-internal unless the key has a dual function-row meaning. See
+ Appendix A.
+- **Windows / Linux** capture in the initial milestones. macOS first; the
+ execution actions cross-platform where they already are; capture ported later.
+- **Per-application profiles** for keyboard bindings in M1 (the mouse side has
+ these; the keyboard side inherits them in a later milestone once the base
+ works).
+
+## Background: what exists today
+
+- **Hook is mouse-only** (`crates/openlogi-hook/src/macos.rs::translate`):
+ handles `LeftMouseDown`/`RightMouseDown`/scroll/move only. Zero keyboard
+ events. This is the central new ground.
+- **Rich action palette** (`crates/openlogi-core/src/binding.rs::Action`):
+ `VolumeUp/Down`, `MuteVolume`, `PlayPause`, `NextTrack`, `PrevTrack`,
+ `BrightnessUp/Down`, `BrowserBack/Forward`, `MissionControl`, `LaunchpadShow`,
+ `Paste/Copy/Cut/Undo/Redo`, `CustomShortcut(KeyCombo)`, `SetDpiPreset`, and
+ (via the mouse-buttons-6-9 PR) `MouseButton6..9`.
+- **Media-key emission exists** (`macos::post_media_key(NX_KEYTYPE_*)`) — so
+ "reassign to a media key" is already an execution primitive, not new work.
+- **Key-chord emission exists** (`CustomShortcut` → `macos::post_key` +
+ modifiers) — so emitting key sequences is partially there, but there is **no
+ text-typing / unicode-string primitive** (`CGEventKeyboardSetUnicodeString`).
+- **Config is TOML**, keyed per-device, with a frozen variant-name contract and
+ `schema_version` for migrations.
+
+## Architecture
+
+The feature splits into two halves with very different risk profiles.
+
+### Half 1 — Execution (what a key does): all buildable
+
+The action palette gains three new `Action` variants, all reusing the existing
+enum → picker → injection pipeline (the same one extended for mouse buttons
+6–9). No new mechanism, only new variants + two new emission primitives.
+
+| Action variant | Mechanism | New work |
+|---|---|---|
+| `RunAppleScript(String)` | spawn `osascript -e "<src>"` | new variant, trivial |
+| `RunShellCommand(String)` | spawn shell, capture nothing | new variant, trivial |
+| `TypeText(String)` | new `macos::post_unicode(&str)` via `CGEventKeyboardSetUnicodeString` | new variant **+ new emitter** |
+| `Workflow(Vec<WorkflowStep>)` | a sequencer that runs steps with `Delay` timing | new variant **+ new sequencer subsystem** |
+| (media reassignment) | existing `post_media_key` | **already exists** |
+
+A `WorkflowStep` is a small enum:
+
+```rust
+enum WorkflowStep {
+ TypeText(String),
+ PressKey(KeyCode), // reuse the key-emitter from CustomShortcut
+ Delay(Duration),
+ RunAppleScript(String),
+ RunShellCommand(String),
+}
+```
+
+The sequencer runs steps in order, awaiting `Delay`s. This is the native,
+no-code version of the "type 'bite me', wait 5s, Enter, wait 5s, type more,
+Escape" example. Power users can equivalently express the same thing in a
+single `RunAppleScript` or `RunShellCommand`.
+
+### Half 2 — Capture (which key triggers it): split by risk
+
+Extending the mouse-only hook to also subscribe to keyboard `CGEvent` types is
+the central new capture work. It splits by key class:
+
+| Key class | Capture mechanism | Risk |
+|---|---|---|
+| **F1–F12, Esc** (function mode) | Extend the existing `CGEventTap` mask to include `keyDown`/`keyUp`/`flagsChanged`; new `KeyEvent` vocabulary analogous to `MouseEvent` | **Low** — same tap, new event types. F1 proven empirically (keycode 122 + `0x80000000`). |
+| **Media keys** (volume / brightness / emoji / play / etc.) | New `NX_SYSDEFINED` system-event tap (`CGSSetSystemDefinedMediaTap`) — a **separate event stream** OpenLogi has none of today | **High / unproven** — gated milestone; see M3. |
+
+This split is why the milestones order F-key capture before media-key capture:
+F-key capture is an extension of the proven existing tap; media-key capture is a
+new subsystem whose feasibility must be empirically confirmed before design
+commits to it.
+
+## Trigger specification
+
+Three complementary ways to specify a trigger, all producing the same
+`KeyTrigger`:
+
+```rust
+/// A keyboard trigger: a keycode plus an optional modifier mask.
+/// Stored under `[keyboard.bindings]` keyed by a stable string.
+struct KeyTrigger {
+ keycode: u16, // macOS kVK_* code (e.g. 122 = F1)
+ modifiers: Modifiers, // Shift/Control/Option/Command mask; empty for bare
+}
+```
+
+1. **Fixed F-key list** — the picker offers F1–F12 + Esc (the keys proven
+ capturable). Matches the mouse-button picker UX.
+2. **Modifier-qualified combos** — Shift/Ctrl/Opt/Cmd + F-key, so one physical
+ key hosts several actions. These modifiers ARE detectable (unlike Fn).
+3. **Press-to-bind capture** — a "press a key to bind" flow: OpenLogi records
+ the next `keyDown`'s keycode (+modifiers) and binds it. Generalizes beyond
+ the fixed list to any capturable key.
+
+## Config schema (additive)
+
+A new top-level `[keyboard]` section, keyed by a stable trigger string. New
+`Action` variants are tagged unions (serde external tagging), consistent with
+`CustomShortcut(KeyCombo)`:
+
+```toml
+# Existing device bindings unchanged:
+[devices."<addr>".bindings]
+GestureButton = "MissionControl"
+
+# NEW — keyboard bindings, independent of device:
+[keyboard.bindings]
+"f1" = { TypeText = "bite me" }
+"shift+f1" = { RunAppleScript = "tell application \"Terminal\" to activate" }
+"cmd+f1" = { RunShellCommand = "open -a 'Safari' https://example.com" }
+"f2" = "VolumeUp" # reassign a function key to a media key
+"f3" = { Workflow = [
+ { TypeText = "bite me" },
+ { Delay = "5s" },
+ { PressKey = "Return" },
+ { Delay = "5s" },
+ { TypeText = "bite me bad" },
+ { PressKey = "Return" },
+ { PressKey = "Escape" },
+]}
+```
+
+**Stability contract:** existing variant names are frozen; these are purely
+additive. A `[keyboard]` section is new, but unknown top-level sections are
+ignored by older loaders, so no `schema_version` bump is *required* for
+back-compat. Bump it anyway (cheap, conventional) so the GUI can show a clean
+"what changed" diff and refuse to silently drop bindings a newer build wrote.
+
+## Milestones
+
+**M1 — F-key capture + powerful action palette (shippable, low-risk)**
+- Extend `openlogi-hook` to capture keyboard `CGEvent`s; new `KeyEvent` vocab.
+- New `Action` variants: `RunAppleScript`, `RunShellCommand`, `TypeText`
+ (+ new `macos::post_unicode` emitter).
+- `[keyboard.bindings]` config + loader; fixed F-key picker UI.
+- Modifier-qualified combos (Shift/Ctrl/Opt/Cmd + F-key).
+- Deliverable: any F1–F12/Esc (and combo) runs AppleScript / shell / types a
+ string / fires a media key.
+
+**M2 — Native Workflow sequencer**
+- `Workflow(Vec<WorkflowStep>)` action + sequencer with `Delay` timing.
+- `WorkflowStep`: `TypeText`, `PressKey`, `Delay`, `RunAppleScript`, `RunShellCommand`.
+- Deliverable: the timed multi-step "type, wait, Enter, wait, type, Esc" flows
+ authorable in TOML without scripting.
+
+**M3 — Media-key capture (gated on feasibility test)**
+- *Before any design commitment:* empirically test whether volume/brightness/
+ emoji keys are interceptable via an `NX_SYSDEFINED` system-event tap. If the
+ OS grabs them below the tap (as the Fn investigation warned), this milestone
+ is descoped or killed — do not assume.
+- If feasible: new system-event tap subsystem; extend trigger list to media
+ keys; deliverable: remap volume/brightness/emoji to any action.
+
+## Risks (honest)
+
+1. **Media-key capture (M3) is unproven.** macOS routes system media keys
+ through `NX_SYSDEFINED`, a separate stream from `CGEventTap`. OpenLogi has
+ zero of this today. The feasibility test gates M3; M1/M2 do not depend on it.
+2. **Security surface.** `RunShellCommand` / `RunAppleScript` execute arbitrary
+ code from config. This is a real escalation vs. today's action set. Mitigation:
+ these variants are **never** in the default catalog; they must be hand-authored
+ in config, and the loader warns on first use. (Matches how `CustomShortcut` is
+ already a deliberate escape hatch.)
+3. **Key-suppression correctness.** Remapping requires *consuming* the original
+ key event (returning "drop this") so it doesn't also type. The mouse hook
+ already does this via `EventDisposition`; the keyboard path must too, with
+ care to avoid wedging input (the documented HID-tap-wedge failure mode).
+4. **Capture vs. the existing mouse tap.** Adding keyboard event types to the
+ existing tap broadens what it intercepts; the HID-location tap that outlives
+ its permission wedges **all** input (mouse + keyboard). Extra care + testing
+ needed here, given that documented failure mode.
+
+## Out of scope
+
+- Capturing the `Fn` modifier as a trigger (proven infeasible — Appendix A).
+- Per-application keyboard profiles in M1 (mouse side has these; keyboard
+ inherits later).
+- Windows/Linux keyboard capture in M1 (port after macOS works).
+
+---
+
+## Appendix A: Why Fn is not a trigger (proven, not assumed)
+
+Investigated empirically this session with an instrumented `CGEventTap`:
+
+- **F1** arrives as keycode 122 **with** the `SecondaryFn`/`0x80000000` flag.
+- **plain Q** and **Fn+Q** are byte-for-byte identical (keycode 12, `raw=0x100`,
+ no Fn flag). Same for A.
+- **plain Shift** and **Fn+Shift** are byte-for-byte identical (`raw=0x20102`,
+ no Fn flag).
+- Pressing **Fn alone** produces no event of any kind (no `FlagsChanged`).
+
+**Conclusion:** the Fn flag attaches **only to function-row keys** (F1–F12),
+never to letters, numbers, or other modifiers. The keyboard firmware holds Fn
+internal unless the key has a dual function-row meaning. `Fn+<anything else>`
+is indistinguishable from `<anything else>` at the `CGEventTap`. This is
+firmware behavior, not a limitation OpenLogi can code around at this layer.
+The only theoretical path to sensing Fn+letters is raw-HID reading below the
+OS event system (Karabiner/driver-kit territory) — a large subsystem with no
+guarantee the MX Keys S exposes Fn there. Not pursued.
diff --git a/packaging/linux/desktop/openlogi.desktop b/packaging/linux/desktop/openlogi.desktop
index 35117cad75fea4a6fadb1437fd85e609a7bc1d6b..59c0d298acaadba4ccc40cc512f9c0c0740feb82 100644
--- a/packaging/linux/desktop/openlogi.desktop
+++ b/packaging/linux/desktop/openlogi.desktop
@@ -5,6 +5,10 @@ Comment=Logitech HID++ device control — remap buttons, DPI, SmartShift
Exec=openlogi-gui
Icon=openlogi
Terminal=false
+# Ties the running window (Wayland xdg-toplevel app_id / X11 WM_CLASS) back to
+# this launcher so GNOME groups it under the OpenLogi icon instead of a generic
+# one. Must match `openlogi_core::brand::APP_ID`, the value the GUI advertises.
+StartupWMClass=org.openlogi.openlogi
Categories=Settings;HardwareSettings;
Keywords=logitech;mouse;hid;remap;dpi;
StartupNotify=true
diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh
index 503ae5f25068ce0bc9340e7e3c666b68f12da944..698c7e72be9c3ee6c979e98103d2db81ec908c0e 100644
--- a/packaging/linux/install.sh
+++ b/packaging/linux/install.sh
@@ -83,6 +83,7 @@ if command -v udevadm > /dev/null 2>&1; then
echo "Reloading udev rules …"
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=hidraw
+ sudo udevadm trigger --subsystem-match=input
sudo udevadm trigger --subsystem-match=misc --attr-match=name=uinput 2>/dev/null || true
fi
diff --git a/packaging/linux/nfpm-scripts/postinstall.sh b/packaging/linux/nfpm-scripts/postinstall.sh
index 97f9ff89f2e3d2267870fa82b765a24d031d567f..0f7c222005797a8a87fa44361003574cdd676dae 100644
--- a/packaging/linux/nfpm-scripts/postinstall.sh
+++ b/packaging/linux/nfpm-scripts/postinstall.sh
@@ -3,10 +3,13 @@ set -eu
# Reload udev rules and wait for the new uaccess tags to be applied.
# udevadm trigger is asynchronous — settle ensures the tags are in place
-# before the script exits so the agent can open /dev/hidraw* immediately.
+# before the script exits so the agent can open /dev/hidraw* and the mouse's
+# /dev/input/event* node immediately, even for a device connected before the
+# install.
if command -v udevadm > /dev/null 2>&1; then
udevadm control --reload-rules
udevadm trigger --subsystem-match=hidraw
+ udevadm trigger --subsystem-match=input
udevadm trigger --subsystem-match=misc --attr-match=name=uinput 2>/dev/null || true
udevadm settle 2>/dev/null || true
fi
diff --git a/packaging/linux/udev/70-openlogi.rules b/packaging/linux/udev/70-openlogi.rules
index 07c33df3f78654981d4698e8d27d958ebd35858a..365932383f6d755ec33b6341829d295c27372dcd 100644
--- a/packaging/linux/udev/70-openlogi.rules
+++ b/packaging/linux/udev/70-openlogi.rules
@@ -1,8 +1,8 @@
# OpenLogi udev rules
#
-# Grants the active-seat user read/write access to Logitech HID devices and the
-# uinput kernel module — no group membership required on systems running
-# systemd-logind (uaccess tag).
+# Grants the active-seat user read/write access to Logitech HID devices, their
+# input event nodes, and the uinput kernel module — no group membership required
+# on systems running systemd-logind (uaccess tag).
#
# Install:
# sudo cp 70-openlogi.rules /etc/udev/rules.d/
@@ -25,3 +25,16 @@ SUBSYSTEM=="hidraw", KERNELS=="*:046D:*", TAG+="uaccess"
# OPTIONS+="static_node=uinput" creates the node at boot even before any device
# is plugged in, so the agent can open it without a trigger.
KERNEL=="uinput", TAG+="uaccess", OPTIONS+="static_node=uinput"
+
+# Logitech pointer event nodes (evdev interface) — the read side of the hook.
+# hidraw alone is not enough: the hook enumerates /dev/input/event* to grab the
+# pointer. USB devices already get uaccess from logind's seat rules, but a
+# Bluetooth mouse hangs off /devices/virtual/misc/uhid, which belongs to no
+# seat, so those rules never tag it and its event node stays root:input 0660.
+#
+# Scoped to event nodes the kernel classifies as a mouse (ID_INPUT_MOUSE, set by
+# the input_id builtin in 60-input-id.rules, which runs before this file): a
+# Logitech keyboard's keystrokes must never become readable session-wide. The
+# two matchers cover the same two transports as the hidraw block above.
+SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_MOUSE}=="1", ATTRS{idVendor}=="046d", TAG+="uaccess"
+SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_MOUSE}=="1", KERNELS=="*:046D:*", TAG+="uaccess"
diff --git a/release-plz.toml b/release-plz.toml
index 6605b7d62307d59a79a082bde0647e5c06c6d4c2..ddb2a35631b295abb4991f8b760b6b26455e26d9 100644
--- a/release-plz.toml
+++ b/release-plz.toml
@@ -14,10 +14,11 @@
# could attach assets — "target_commitish cannot be changed when release is
# immutable". Keep `git_release_enable = false` so release.yml owns the lifecycle.)
#
-# Single changelog: every crate points `changelog_path` at the repo-root
-# CHANGELOG.md, so release-plz aggregates all crates' sections into that one file
-# instead of scattering a CHANGELOG.md into each crate directory. (changelog_path
-# is per-package only — it can't be set in [workspace].)
+# Changelog: release-plz is package-path-scoped and skips `release = false` app
+# crates (gui/agent), so it does NOT own CHANGELOG.md. Whole-repo notes are
+# written by git-cliff via `cliff.toml` after each release-pr (same idea as
+# GoReleaser's `changelog.use: git` — every conventional commit since the last
+# tag, no per-crate path filter).
[workspace]
# Open release PRs from a `release-plz/`-prefixed branch.
@@ -29,11 +30,16 @@ semver_check = false
# Per-crate tags/releases are off; the root crate owns the one workspace release.
git_tag_enable = false
git_release_enable = false
+# Version bumps only — root CHANGELOG.md is written by git-cliff (cliff.toml).
+changelog_update = false
+# Only publish/tag when the release PR merges (branch prefix above). A later
+# master tip after a failed crates.io publish must not cut v{version} — re-run
+# the failed release job on that same SHA once credentials are fixed.
+release_always = false
[[package]]
name = "openlogi"
version_group = "openlogi"
-changelog_path = "CHANGELOG.md"
git_tag_enable = true
git_tag_name = "v{{ version }}"
# GitHub Release is owned by release.yml (softprops), not release-plz — see header.
@@ -42,62 +48,47 @@ git_release_enable = false
[[package]]
name = "openlogi-core"
version_group = "openlogi"
-changelog_path = "CHANGELOG.md"
# OS input-event synthesis split out of openlogi-core (depends on it). Published
# with the workspace under unified versioning.
[[package]]
name = "openlogi-inject"
version_group = "openlogi"
-changelog_path = "CHANGELOG.md"
[[package]]
name = "openlogi-hid"
version_group = "openlogi"
-changelog_path = "CHANGELOG.md"
[[package]]
name = "openlogi-assets"
version_group = "openlogi"
-changelog_path = "CHANGELOG.md"
[[package]]
name = "openlogi-cli"
version_group = "openlogi"
-changelog_path = "CHANGELOG.md"
[[package]]
name = "openlogi-hook"
version_group = "openlogi"
-changelog_path = "CHANGELOG.md"
# Vendored fork of the `hidpp` crate (0BSD, from lus/logy). Published with the
# workspace under unified versioning; upstream's 0.3.0 is provenance only.
[[package]]
name = "openlogi-hidpp"
version_group = "openlogi"
-changelog_path = "CHANGELOG.md"
-# Not publishable (git-only gpui deps); keep release-plz out of it entirely.
-# Its version still follows the shared workspace version via inheritance.
-# `publish = false` must mirror the crate's Cargo.toml: release-plz validates
-# publish consistency across *all* workspace packages before honoring `release`.
+# App crates: not crates.io packages (git gpui deps / login-item binary).
+# `publish = false` must mirror each crate's Cargo.toml.
[[package]]
name = "openlogi-gui"
release = false
publish = false
-# Shared headless orchestration for the background agent. Not publishable
-# (links git-only gpui-adjacent siblings); version follows the shared workspace
-# version by inheritance. `publish = false` mirrors the crate's Cargo.toml — see
-# the openlogi-gui note above.
[[package]]
name = "openlogi-agent-core"
release = false
publish = false
-# The headless background agent binary — shipped as a login-item helper inside
-# the .app, never published. publish=false mirrors its Cargo.toml.
[[package]]
name = "openlogi-agent"
release = false
diff --git a/scripts/cargo-run-macos.sh b/scripts/cargo-run-macos.sh
index f622179df5fd1ffe2b07d31404ffb2f2713c2937..34e9b4427414de569dec605d23c3316893ef9116 100644
--- a/scripts/cargo-run-macos.sh
+++ b/scripts/cargo-run-macos.sh
@@ -7,7 +7,7 @@
# the desktop binary it's a transparent passthrough (`exec "$@"`).
#
# For `openlogi-gui` it launches the build from inside a throwaway
-# `OpenLogi.app` so macOS shows the real app name (the bold menu-bar title)
+# `OpenLogi.app` so macOS shows the dev app name (the bold menu-bar title)
# and the Dock icon during development. Both are read from the bundle's
# `Info.plist` / `Resources` — a bare `target/debug/openlogi-gui` has neither,
# so macOS falls back to the executable name and a generic icon.
@@ -94,8 +94,9 @@ if [ "$ICON_SRC" -nt "$RES/AppIcon.icns" ]; then
cp -f "$ICON_SRC" "$RES/AppIcon.icns"
fi
-# Info.plist — minimal, dev-only. A distinct `.dev` identifier keeps this
-# target artifact from registering as the production app in LaunchServices.
+# Info.plist — minimal, dev-only. A distinct `.dev` identifier and display name
+# keep this target artifact from registering as the production app in
+# LaunchServices or macOS Privacy & Security.
PLIST="$APP/Contents/Info.plist"
if [ "$PLIST_SRC" -nt "$PLIST" ]; then
cp -f "$PLIST_SRC" "$PLIST"
diff --git a/scripts/release/write-changelog.sh b/scripts/release/write-changelog.sh
new file mode 100644
index 0000000000000000000000000000000000000000..2c4df7fa256127f26b817f3c97bb68e96165ff1b
--- /dev/null
+++ b/scripts/release/write-changelog.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# Write the next workspace version section into CHANGELOG.md with git-cliff.
+# Whole-repo conventional commits since the previous v* tag (cliff.toml).
+set -euo pipefail
+
+root="$(git rev-parse --show-toplevel)"
+cd "$root"
+
+version="$(
+ python3 - <<'PY'
+import pathlib, re, sys
+text = pathlib.Path("Cargo.toml").read_text()
+m = re.search(r'(?ms)^\[workspace\.package\].*?^version\s*=\s*"([^"]+)"', text)
+if not m:
+ sys.exit("workspace.package version not found in Cargo.toml")
+print(m.group(1))
+PY
+)"
+tag="v${version}"
+
+last_tag="$(
+ git tag --list 'v*' \
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
+ | sort -V \
+ | tail -n1
+)"
+if [[ -z "${last_tag}" ]]; then
+ echo "error: no previous vX.Y.Z tag" >&2
+ exit 1
+fi
+if [[ "${last_tag}" == "${tag}" ]]; then
+ echo "error: workspace version ${version} is already tagged as ${tag}" >&2
+ exit 1
+fi
+
+# Drop a stale section for this version (idempotent re-runs / release-pr updates).
+if grep -qE "^## \[${version}\]" CHANGELOG.md; then
+ python3 - "${version}" <<'PY'
+from pathlib import Path
+import re
+import sys
+
+version = sys.argv[1]
+text = Path("CHANGELOG.md").read_text()
+pattern = re.compile(
+ rf"(?ms)^## \[{re.escape(version)}\].*?(?=^## \[|\Z)"
+)
+Path("CHANGELOG.md").write_text(pattern.sub("", text, count=1))
+PY
+fi
+
+git cliff "${last_tag}.." \
+ --config cliff.toml \
+ --tag "${tag}" \
+ --prepend CHANGELOG.md
+
+echo "wrote ${tag} changelog from ${last_tag}..HEAD" >&2
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
index 3c6ce161e74139f80050e5e8bd16ce5a6ea73efc..d661af5f2557efbb627fae68f37315937283859e 100644
--- a/xtask/Cargo.toml
+++ b/xtask/Cargo.toml
@@ -16,7 +16,7 @@ fs-err = "3.3.0"
icns = { version = "0.4.0", default-features = false, features = ["pngio"] }
image = { version = "0.25.10", default-features = false, features = ["png"] }
path-absolutize = "3.1.1"
-plist = "1.9.0"
+plist = "1.10.0"
serde = { workspace = true }
serde_json = { workspace = true }
sha2_hasher = { version = "0.3.2", features = ["sync"] }
diff --git a/xtask/README.md b/xtask/README.md
index 225a786016f2a5b6b3738ee634d6e16f78275aba..6f5a89d174cacb8ed650f01e1610465f9402210a 100644
--- a/xtask/README.md
+++ b/xtask/README.md
@@ -19,6 +19,17 @@ devenv shell -- cargo run -p xtask -- <command>
`.pkg.tar.zst` artifacts with nfpm.
- `release latest-json` — generate the static updater manifest for the stable channel.
+For local testing, `macos bundle` stamps the built app/helper as
+`org.openlogi.openlogi.dev` / `org.openlogi.agent.dev`, then signs the final
+layout with `OPENLOGI_LOCAL_CODESIGN_IDENTITY` or the first Apple Development
+identity it finds. This keeps local Accessibility/TCC grants isolated from the
+installed production `org.openlogi.agent` grant. Set `OPENLOGI_LOCAL_CODESIGN=0`
+only when you explicitly want an unsigned local bundle. `macos package` keeps
+the production bundle IDs and signs with `OPENLOGI_SIGN_IDENTITY` / `--sign-identity`.
+The raw DMG emitted by `cargo-bundle` is deleted during `macos bundle` because it
+is created before xtask embeds and signs the helper; use `macos package` when
+you need a DMG.
+
The Cargo runner in `../scripts/cargo-run-macos.sh` stays outside xtask because
Cargo must execute it while running arbitrary binaries, including this crate.
The release-notes generator stays in `../scripts/release-notes` because it is a
diff --git a/xtask/src/commands/macos.rs b/xtask/src/commands/macos.rs
index b1a7a37954af9699cd694ec9dde77d35bc1e4f53..56612f9a814db093aa26223ed79d69bbf1b6c307 100644
--- a/xtask/src/commands/macos.rs
+++ b/xtask/src/commands/macos.rs
@@ -22,10 +22,8 @@ pub(crate) fn run(command: Command) -> Result<()> {
Command::Bundle => bundle::run(),
Command::Dmg(args) => dmg::run(&args),
Command::Package(args) => {
- bundle::run()?;
- if let Some(identity) = &args.sign_identity {
- bundle::sign_app(identity)?;
- } else {
+ bundle::run_for_distribution(args.sign_identity.as_deref())?;
+ if args.sign_identity.is_none() {
println!("==> codesign: skipped (unsigned — set OPENLOGI_SIGN_IDENTITY to sign)");
}
dmg::run(&args)
diff --git a/xtask/src/commands/macos/bundle.rs b/xtask/src/commands/macos/bundle.rs
index 38bb21ef96256568e19b67b52e47bb403ea0da3e..5973c1b79c63e32723f850b32606731719821266 100644
--- a/xtask/src/commands/macos/bundle.rs
+++ b/xtask/src/commands/macos/bundle.rs
@@ -57,6 +57,14 @@ fn write_icns(master: &Path, output: &Path) -> Result<()> {
}
pub(crate) fn run() -> Result<()> {
+ run_with_profile(&BundleProfile::Local)
+}
+
+pub(crate) fn run_for_distribution(sign_identity: Option<&str>) -> Result<()> {
+ run_with_profile(&BundleProfile::Distribution { sign_identity })
+}
+
+fn run_with_profile(profile: &BundleProfile<'_>) -> Result<()> {
let root = repo_root()?;
let sh = Shell::new()?;
let _repo = sh.push_dir(&root);
@@ -95,17 +103,46 @@ pub(crate) fn run() -> Result<()> {
.envs(xcode_env.iter().map(|(key, value)| (key, value)))
.run()?;
}
+ remove_cargo_bundle_dmg(&root)?;
let app = root.join("target/release/bundle/osx/OpenLogi.app");
ensure_dir(&app)?;
embed_agent_helper(&root, &app, &xcode_env)?;
embed_cli(&root, &app, &xcode_env)?;
verify_bundle_binaries(&app)?;
+ match profile {
+ BundleProfile::Local => {
+ stamp_local_bundle_identity(&app)?;
+ local_sign_app_if_available()?;
+ }
+ BundleProfile::Distribution { sign_identity } => {
+ if let Some(identity) = sign_identity {
+ sign_app_with_timestamp(identity, TimestampMode::Secure)?;
+ }
+ }
+ }
println!();
println!("Bundle ready: {}", app.display());
Ok(())
}
+enum BundleProfile<'a> {
+ Local,
+ Distribution { sign_identity: Option<&'a str> },
+}
+
+fn remove_cargo_bundle_dmg(root: &Path) -> Result<()> {
+ let dmg = root.join("target/release/bundle/dmg/OpenLogi.dmg");
+ if dmg.exists() {
+ fs_err::remove_file(&dmg)
+ .with_context(|| format!("could not remove stale {}", dmg.display()))?;
+ println!(
+ " removed cargo-bundle DMG before helper embedding; use `macos package` for a DMG"
+ );
+ }
+ Ok(())
+}
+
/// Build the headless agent and embed it as a nested login-item helper at
/// `OpenLogi.app/Contents/Library/LoginItems/OpenLogiAgent.app`. The agent is
/// the always-on process (hook + device I/O + menu bar); shipping it inside the
@@ -213,7 +250,79 @@ fn xcode_env() -> Result<Vec<(String, String)>> {
])
}
-pub(crate) fn sign_app(identity: &str) -> Result<()> {
+fn stamp_local_bundle_identity(app: &Path) -> Result<()> {
+ println!("==> local bundle identity");
+ let app_info = app.join("Contents/Info.plist");
+ stamp_plist_strings(
+ &app_info,
+ &[
+ ("CFBundleDisplayName", "OpenLogi Dev"),
+ ("CFBundleIdentifier", "org.openlogi.openlogi.dev"),
+ ("CFBundleName", "OpenLogi Dev"),
+ ],
+ )?;
+
+ let helper_info = app.join("Contents/Library/LoginItems/OpenLogiAgent.app/Contents/Info.plist");
+ if helper_info.exists() {
+ stamp_plist_strings(
+ &helper_info,
+ &[
+ ("CFBundleDisplayName", "OpenLogi Agent Dev"),
+ ("CFBundleIdentifier", "org.openlogi.agent.dev"),
+ ("CFBundleName", "OpenLogi Agent Dev"),
+ ],
+ )?;
+ }
+
+ println!(" stamped local IDs: org.openlogi.openlogi.dev / org.openlogi.agent.dev");
+ Ok(())
+}
+
+fn stamp_plist_strings(info_plist: &Path, entries: &[(&str, &str)]) -> Result<()> {
+ let mut plist = Value::from_file(info_plist)
+ .with_context(|| format!("could not read {}", info_plist.display()))?;
+ let dict = plist
+ .as_dictionary_mut()
+ .with_context(|| format!("{} is not a plist dictionary", info_plist.display()))?;
+ for (key, value) in entries {
+ dict.insert((*key).into(), Value::String((*value).to_string()));
+ }
+ plist
+ .to_file_xml(info_plist)
+ .with_context(|| format!("could not write {}", info_plist.display()))
+}
+
+fn local_sign_app_if_available() -> Result<()> {
+ if env::var("OPENLOGI_LOCAL_CODESIGN").as_deref() == Ok("0") {
+ println!("==> local codesign: skipped (OPENLOGI_LOCAL_CODESIGN=0)");
+ return Ok(());
+ }
+
+ if let Some(identity) = env_nonempty("OPENLOGI_SIGN_IDENTITY") {
+ sign_app_with_timestamp(&identity, TimestampMode::Secure)?;
+ return Ok(());
+ }
+
+ if let Some(identity) = env_nonempty("OPENLOGI_LOCAL_CODESIGN_IDENTITY") {
+ sign_app_with_timestamp(&identity, TimestampMode::None)?;
+ return Ok(());
+ }
+
+ if let Some(identity) = first_apple_development_identity()? {
+ sign_app_with_timestamp(&identity, TimestampMode::None)?;
+ return Ok(());
+ }
+
+ println!(
+ "==> local codesign: skipped (no Apple Development identity found; set OPENLOGI_LOCAL_CODESIGN_IDENTITY or OPENLOGI_SIGN_IDENTITY to sign)"
+ );
+ println!(
+ " warning: unsigned/ad-hoc local bundles with production bundle IDs can make macOS Accessibility grants appear stale or missing"
+ );
+ Ok(())
+}
+
+fn sign_app_with_timestamp(identity: &str, timestamp: TimestampMode) -> Result<()> {
let sh = Shell::new()?;
let app = repo_root()?.join("target/release/bundle/osx/OpenLogi.app");
let helper = app.join("Contents/Library/LoginItems/OpenLogiAgent.app");
@@ -224,16 +333,16 @@ pub(crate) fn sign_app(identity: &str) -> Result<()> {
// stable, separately-signed helper identity is exactly what lets the agent's
// Accessibility (TCC) grant persist across updates. So sign each explicitly.
if helper.exists() {
- codesign_runtime(identity, &helper)?;
+ codesign_runtime(identity, &helper, timestamp)?;
}
// The embedded CLI is a second Mach-O under Contents/MacOS; sign it with the
// hardened runtime before the outer app so it carries a Developer ID
// signature (its as-built ad-hoc signature would fail notarization).
let cli = app.join("Contents/MacOS/openlogi");
if cli.exists() {
- codesign_runtime(identity, &cli)?;
+ codesign_runtime(identity, &cli, timestamp)?;
}
- codesign_runtime(identity, &app)?;
+ codesign_runtime(identity, &app, timestamp)?;
cmd!(sh, "codesign --verify --strict {app}").run()?;
if helper.exists() {
cmd!(sh, "codesign --verify --strict {helper}").run()?;
@@ -244,17 +353,55 @@ pub(crate) fn sign_app(identity: &str) -> Result<()> {
Ok(())
}
-/// Sign one bundle with the hardened runtime + a secure timestamp.
-fn codesign_runtime(identity: &str, target: &Path) -> Result<()> {
+/// Sign one bundle with the hardened runtime and the requested timestamp mode.
+fn codesign_runtime(identity: &str, target: &Path, timestamp: TimestampMode) -> Result<()> {
let sh = Shell::new()?;
- cmd!(
- sh,
- "codesign --force --options runtime --timestamp --sign {identity} {target}"
- )
- .run()?;
+ match timestamp {
+ TimestampMode::Secure => {
+ cmd!(
+ sh,
+ "codesign --force --options runtime --timestamp --sign {identity} {target}"
+ )
+ .run()?;
+ }
+ TimestampMode::None => {
+ cmd!(
+ sh,
+ "codesign --force --options runtime --timestamp=none --sign {identity} {target}"
+ )
+ .run()?;
+ }
+ }
Ok(())
}
+#[derive(Clone, Copy)]
+enum TimestampMode {
+ Secure,
+ None,
+}
+
+fn env_nonempty(name: &str) -> Option<String> {
+ env::var(name).ok().filter(|value| !value.trim().is_empty())
+}
+
+fn first_apple_development_identity() -> Result<Option<String>> {
+ let sh = Shell::new()?;
+ let Ok(output) = cmd!(sh, "security find-identity -v -p codesigning").read() else {
+ return Ok(None);
+ };
+ Ok(output
+ .lines()
+ .filter_map(quoted_identity)
+ .find(|identity| identity.starts_with("Apple Development:")))
+}
+
+fn quoted_identity(line: &str) -> Option<String> {
+ let start = line.find('"')? + 1;
+ let end = line[start..].find('"')?;
+ Some(line[start..start + end].to_string())
+}
+
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "unwrap is idiomatic in tests")]
mod tests {