OpenLogi is a native, local-first alternative to Logitech Options+ written in Rust:
button remapping, DPI, SmartShift, and per-app profiles for Logitech HID++ devices
(Bolt/Unifying receiver, Bluetooth-direct, wired) β no account, no telemetry, plain-TOML
config. macOS and Linux are first-class; Windows is a young but shipping port.
Dual-licensed MIT/Apache-2.0; the design/ brand assets are proprietary.
The developer handbook (toolchain, packaging, release pipeline) is docs/DEVELOPMENT.md. This file is the agent-facing contract: the architecture map plus the global workflow. Everything subsystem-specific lives in the path-scoped rule files indexed at the bottom β read the matching one before touching an area.
For runtime HID and input state, the long-running GUI and overlay are pure IPC
clients; the agent owns the input hook and HID I/O. The CLI is a diagnostic
exception: openlogi list prefers a compatible agent snapshot and falls back to
direct enumeration when none is available, while hardware-diagnostic subcommands
access devices directly.
| Crate | Role |
|---|---|
crates/openlogi |
The CLI binary β thin wrapper over openlogi-cli |
crates/openlogi-core |
Pure types: TOML config, device model, action catalog, locale negotiation. No I/O, no async (feature-gated host reads: fs, locale) |
crates/openlogi-device-registry |
Pure hardware identity registry: receiver protocols and standalone-device driver metadata |
crates/openlogi-hidpp |
Hard fork of the hidpp protocol crate (lib name hidpp, 0BSD) |
crates/openlogi-hidpp-derive |
Private derive macro for openlogi-hidpp feature boilerplate |
crates/openlogi-device |
The HID++ device layer: enumeration, probing, writes, sessions, pairing. Knows no host β expressed against HidBackend |
crates/openlogi-hid |
That layer wired to this host: async-hid transport, macOS Input Monitoring, the on-disk probe cache |
crates/openlogi-camera |
Cross-platform Logitech UVC enumeration, capture, controls, and Camera-permission APIs |
crates/openlogi-assets |
Device-render registry + cached fetch from OpenLogi asset mirrors |
crates/openlogi-cli |
CLI dispatch: agent-backed inventory when available, plus direct hardware diagnostics |
crates/openlogi-hook |
OS input capture: CGEventTap / evdev+uinput / WH_MOUSE_LL |
crates/openlogi-inject |
OS input synthesis: CGEvent / uinput+MPRIS / SendInput |
crates/openlogi-agent-core |
Shared agent orchestration: hook runtime, HID++ writes, DPI cycle, Actions Ring session state |
crates/openlogi-ipc |
The tarpc IPC contract (src/ipc.rs) + its local-socket transport, shared by the agent and its clients |
crates/openlogi-agent |
The openlogi-agent binary β runtime HID/input server |
crates/openlogi-permissions |
Privacy-permission status + System-Settings deep links: macOS TCC reads, Linux device-file probes. Reads only β never prompts |
crates/openlogi-ui |
Presentation shared by the two GPUI processes: ring geometry/icons, the GPUI asset source, the shared locale catalogs. Depends on gpui but not gpui-component |
crates/openlogi-desktop |
GPUI + gpui-component desktop app β polls the agent, no HID/input I/O |
crates/openlogi-overlay |
The openlogi-overlay binary β cursor-centred Actions Ring, a pure IPC client |
xtask |
cargo xtask maintenance: bundling, packaging, release manifest |
- IPC clients β agent speak tarpc/bincode over an
interprocesslocal socket. The wire format is versioned and append-only β readcrates/openlogi-ipc/AGENTS.mdbefore touching it. - Three processes ship in the bundle β GUI, agent, overlay β and the overlay is a
sibling of the GUI, not a part of it: it links
openlogi-ui, neveropenlogi-desktop. Anything both need goes inopenlogi-ui, and every dependency added there lands in the overlay too (.claude/rules/gui.mdhas the rule). - Platform code is cfg-gated per crate (
[target.'cfg(target_os = β¦)'.dependencies])..claude/rules/objc-ffi.mdis the contract for the workspace's macOS native FFI and maintains the canonical file-by-file inventory β read it before editing that surface.
- Treat every issue, user report, and review finding as a claim. Verify it against the current head and the most direct available evidence before accepting its diagnosis.
- Fix the verified root cause at its owning module and lifecycle boundary. Do not hide a broken owner or lifecycle behind a shim, fallback, or one-use abstraction.
Nix/devenv is optional β rustup + rust-toolchain.toml is enough. If devenv is
installed, direnv loads it; otherwise .envrc prints a notice and leaves PATH
alone so system cargo works. With devenv active, cargo may only be on PATH
inside the shell β run from the repo root (or direnv exec . β¦), including
git (the hooks need cargo):
cargo check -p openlogi-core
# when cargo is only inside devenv:
direnv exec . cargo check -p openlogi-core
direnv exec . git commit β¦Use the narrowest command that can disprove the change while code is still moving. Do not run full-workspace Clippy, tests, or rustdoc after every edit; broad checks are a final gate, not an inner development loop.
- Define one proof first. Pick the focused test, compile target, or runtime behavior that demonstrates the requested outcome.
- Inner loop: run that proof only. For Rust, prefer
cargo test -p <crate> <test-filter>for behavior andcargo check -p <crate>for API/type feedback. A one-line or docs-only edit does not justify Clippy. - Once the code is stable: run formatter check, the relevant tests, and Clippy
once for each crate actually changed (
cargo clippy -p <crate> --all-targets -- -D warnings). For a shared public API,cargo checkits affected consumers; do not Clippy every consumer unless their source changed orcargo checkexposes a problem there. - Before push only: choose the affected-package or full local gate below from the final diff. If a gate command fails, fix the cause with a focused command, then rerun that tier once after the tree is final again.
Do not rerun an identical broad command merely because a later edit touched an unrelated file. Do rerun the focused check whose inputs changed. If no commit or push was requested, the task does not need the push gate solely because this file documents one; report the targeted verification that was actually relevant.
The local gate keeps predictable failures off a PR update; CI then sweeps the whole workspace on its other hosts. CI does not run for an ordinary branch push that has no open PR, so never use it as a substitute for the local tier.
A Rust-bearing diff changes Rust source or an input that controls how the Rust
workspace builds or is validated. A truly non-Rust diff does not need Rust commands
merely because it is being pushed. Run the applicable checks from
.claude/rules/ci.md instead (shell, Nix, packaging, and so on).
For a Rust-bearing diff, derive the affected package set from the final tree:
every changed workspace package plus every workspace package that depends on one of
them, transitively. Run cargo tree --workspace --target all --invert <changed> for
each changed package and take the union of workspace packages in the output. Count
that set, not edited crate directories β changing only openlogi-core still affects
much of the application. When the set is uncertain, use the full tier.
Affected-package tier β allowed only when all of these are true:
- no Rust-bearing commit was rebased and no conflict was resolved since the last full gate;
- the diff changes no workspace-wide build or validation input: any
Cargo.tomlorbuild.rs,Cargo.lock,rust-toolchain.toml,.cargo/**, lint/format/hook configuration, devenv configuration, CI workflows, or the local CI runner.
Run fmt plus Clippy and tests for the whole affected set, not just the packages whose files changed:
export RUSTFLAGS="-D warnings"
cargo fmt --all -- --check
cargo clippy -p <affected>β¦ --all-targets -- -D warnings
cargo test -p <affected>β¦Repeat -p for every package in the set. The mandatory pre-push hook still runs
full-workspace Clippy and non-GUI rustdoc before Git contacts the remote; this tier
does not authorize skipping that backstop.
Full tier β required after a Rust-bearing rebase or conflict resolution, for any workspace-wide input above, when the affected set cannot be derived reliably, or whenever a subsystem rule explicitly requires it:
export RUSTFLAGS="-D warnings" # CI sets this globally; clippy `-D warnings` is not the same
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps \
--document-private-items --exclude openlogi-ui --exclude openlogi-desktop \
--exclude openlogi-overlay --exclude openlogi-agent
# or: devenv tasks run openlogi:check
# every CI job this host can reproduce: cargo xtask ciExit non-zero in either tier β fix, rerun that tier on the final tree, then push. Do not push a known-red tree "to see if CI likes it." CI is confirmation, not the first compile.
The rustdoc step mirrors CI's rustdoc (non-GUI crates) job and catches what the
other three cannot: a broken intra-doc link is neither a compile error nor a clippy
lint. The GPUI crates are excluded because documenting them drags in the whole
graphics toolchain; everything else is covered by exclusion rather than by a list, so
a new crate is documented by default. The classic silent breakage β handing a trait
impl to a derive macro kills every Type::trait_method doc link β is explained in
.claude/rules/rust.md.
The local gate is the host-OS subset. The pipeline is .github/workflows/ci.yml
(Linux clippy, macOS+Linux MSRV, rustdoc, Linux tests excluding desktop, macOS
--all-targets tests, typos, cargo-deny, Windows clippy, wasm portability, shell
lint). macOS-green is not that matrix. To run every job this machine can reproduce:
cargo xtask ci
cargo xtask ci --list # job β command table
cargo xtask ci rustfmt clippy # one job, names match CI
# or: devenv tasks run openlogi:ciThe runner sets RUSTFLAGS=-D warnings the way CI does. A skipped job (wrong
OS, missing cargo-deny, no MSRV toolchain) is not a pass β name it as not
run in the PR Testing section. Full map, including "if you changed X, run Y":
.claude/rules/ci.md.
prek hooks (prek.toml): typos and cargo fmt at commit; full-workspace clippy
and rustdoc 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.
Push checklist (agents):
- Rebase/merge conflicts fully resolved β no
<<<<<<<left, no half-ported APIs. - Applicable local gate green on the final tree: non-Rust checks for a non-Rust diff; affected-package tier when none of the full-tier triggers above applies; full otherwise.
- Additional pipeline jobs required by the diff run by name with
cargo xtask ci <job>β¦. Skipped jobs stay named as not run β never claimed green. Mapping:.claude/rules/ci.md. - If cfg-gated files changed (any
#[cfg(target_os = β¦)]block, in any crate): cross-lint or hand-audit against master β macOS-green proves nothing there; see.claude/rules/cross-platform.md. - If wire types changed:
PROTOCOL_VERSIONbumped andcargo test -p openlogi-ipc --test wire_formatgreen β seecrates/openlogi-ipc/AGENTS.md. - If locales changed: every
crates/openlogi-ui/locales/*.tomlcarries the same keys asen.toml(new keys at the same position); runcargo test -p openlogi-ui localefor catalog parity andcargo test -p openlogi-desktop i18nfor catalog wiring and desktop resolution β see.claude/rules/i18n.md. - Only then
git push/ force-push to the PR branch.
- Dev-run with
cargo run -p openlogi-desktopβ a cargo runner wraps the build intotarget/dev/OpenLogi.appwith the same identity, helper and plist tables packaging uses. The macOS GUI build needs full Xcode for GPUI's Metal shaders; devenv sets the env when present (direnv reloadif the shader compile fails there). cargo builddoes NOT refresh that bundle, and a second instance exits on the singleton lock: quit the old instance and re-runbefore judging a UI change "not applied".- Each dev run first stops the agent and overlay the previous one left behind β they
are LaunchServices-launched (for their own TCC identity), not children of the GUI,
and a surviving agent relaunches itself ~20 s later β then starts the freshly
built agent and waits for its socket, so the GUI's first IPC connect succeeds
instead of exercising the production spawn-on-unreachable fallback.
OPENLOGI_DEV_AGENT=0opts out of all of it. - No hardware attached?
cargo run -p openlogi-agent --bin openlogi-agent-mockserves a scripted inventory over the dev IPC socket, so the GUI runs unmodified and the production app stays untouched. - Mechanics, dev profiles, and the mock's scope:
docs/DEVELOPMENT.md.
Edition 2024, MSRV = current stable (1.98), one shared workspace lint table. The
floor tracks stable instead of trailing it β raise it the day a release ships
something worth using, and run devenv update rust-overlay with it so the local
toolchain stops being older than CI's. The full standards β the
lint table and what it changes day to day, typed-invariant style, house rules on
refactoring, dependencies, and module layout β live in .claude/rules/rust.md,
loaded for any Rust or Cargo.toml edit.
- Conventional commits:
type(scope): imperative lowercase description. Types in use:feat fix refactor chore docs ci perf style build test. Scopes are crate short names (gui agent hidpp hid core hook ipc cli assets xtask) or cross-cutting concerns (release ci i18n windows linux macos tray infra).i18nis a scope, not a type. - Branches:
type/kebab-descriptionoffmaster. Substantial or risky work goes in a 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 localmaster. - 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 conventional. Wait for the Greptile review check and CI before merging β findings get fixed, replied to, and resolved, not ignored. - PR bodies:
## Summary,## Changes(per-crate bullets),## Testinglisting the exact commands run plus hardware-verification status (say "not runtime-tested on hardware" when true β real-hardware verification is the maintainer's job, so every fix PR states how to test it), and a closingFixes #Nline. Screenshots for UI changes. - All GitHub artifacts β PR titles/bodies, commits, issues, reviews, comments β are written in English.
- Never add AI attribution ("Generated with β¦", AI co-author trailers) to commits, PRs, or issues β including when adopting contributors' work.
- 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 fresh master in a worktree, fix review findings, run the applicable local gate on the rebased tip (a Rust-bearing rebase takes the full tier), then push to the fork branch; preserve authorship (Co-authored-bywhen 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 concurrency is per branch (
ci-${{ workflow }}-${{ ref }}withcancel-in-progress: true). Approving or re-running an old SHA on the same branch cancels the current-head run. Only approve / re-run workflows whosehead_shaequals the PR's current head. - After a force-push, wait for the new runs; do not re-approve stale
action_requiredjobs from earlier commits on that branch. - First-time-fork PRs may sit in
action_requireduntil a maintainer approves the workflow run β that is fine; still do not push until the local gate is green.
release-plz drives releases: one unified workspace version, ONE root CHANGELOG.md
(never per-crate changelogs), and a single v{version} tag that only release-plz
creates β never hand-create the tag. Published GitHub releases are immutable:
never re-run a failed release job or re-dispatch on an existing tag.
release-plz.toml is the versioning contract β don't trim it.
Claude Code loads the .claude/rules/ files per matching path and a crate's
own AGENTS.md when working inside it; other agents: read the listed file
before editing that area.
| Area | Rule file |
|---|---|
reproducing CI jobs locally (every ci.yml job β command) |
.claude/rules/ci.md |
any *.rs / Cargo.toml (workspace Rust standards) |
.claude/rules/rust.md |
crates/openlogi-desktop/**, crates/openlogi-ui/**, crates/openlogi-overlay/** (GPUI) |
.claude/rules/gui.md |
crates/openlogi-desktop/** (that crate's own contract and map) |
crates/openlogi-desktop/AGENTS.md |
locale catalogs/negotiation and each binary's rust_i18n::i18n! wiring |
.claude/rules/i18n.md |
crates/openlogi-ipc/**, plus every crate whose serde types ride the wire (openlogi-agent-core, openlogi-agent, openlogi-core, openlogi-hid) |
crates/openlogi-ipc/AGENTS.md |
| cfg-gated platform code, including hook/inject/hid, camera, and agent autostart/resume | .claude/rules/cross-platform.md |
crates/openlogi-hidpp/** (hard fork of hidpp) |
crates/openlogi-hidpp/AGENTS.md |
crates/openlogi-device/**, crates/openlogi-hid/** (the HID++ layer seam) |
crates/openlogi-device/AGENTS.md |
crates/openlogi-hook/** (event taps) |
crates/openlogi-hook/AGENTS.md |
xtask/**, packaging/**, .github/scripts/** |
xtask/AGENTS.md (+ xtask/README.md) |
| macOS native FFI (the rule carries the canonical path inventory) | .claude/rules/objc-ffi.md |
The rules above load from the file you are editing. Some work has no file to key
on: triaging a user report, or deciding what a symptom means. That lives in
.claude/skills/, which Claude Code offers by task description; other agents
should read the SKILL.md when the task matches.
| Task | Skill |
|---|---|
| a macOS report of no devices / "Failed to open device" / which permission to grant, and any change to the permission, helper-launch, or bundle-signing code | .claude/skills/openlogi-macos-permissions/SKILL.md |
Everything else under .claude/skills/ is a per-developer symlink into
.agents/skills/ and is not part of the project β see .gitignore.