Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .claude/rules/gui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
paths:
- "crates/openlogi-gui/**"
---

# GUI (GPUI + gpui-component)

- The UI stack is GPUI + gpui-component — a settled choice; don't propose alternatives.
- `gpui`/`gpui_platform` track zed's default branch on purpose; the compatible zed
commit is pinned **only in `Cargo.lock`**, in lockstep with the `gpui-component` rev.
After any `cargo add`/`cargo update`, check the pins didn't move; restore with
`cargo update -p gpui --precise <rev>`.
- Two color systems must agree: the bespoke `theme.rs` `Palette` (hand-painted
surfaces) and gpui-component's `cx.theme()` (widget chrome). Only the `ThemeMode` is
shared between them. A "white box under dark UI" or a surface that doesn't flip with
the OS appearance is a ThemeMode wiring bug — fix that, not per-element `bg()`.
- Trait imports must be unconditional for cross-platform widgets: a
`#[cfg(target_os = "macos")]`-gated `use gpui::StatefulInteractiveElement as _;`
compiles fine locally but breaks the Linux/Windows CI jobs the moment an ungated
element calls `.id(..).on_click(..)`. When adding such an element, ungate the import.
- Icons are not limited to gpui-component's `IconName`: vendor any SVG (must use
`stroke="currentColor"`) into `action-icons/`, register it in `app_assets.rs`'s
`ACTION_ICONS`, render via `Icon::empty().path("action-icons/….svg")`.
- Config panels/tabs gate on `Capabilities` (derived from the HID++ feature table),
**never** on device `kind` — kind is identity-only (icon/label). A new panel means a
new capability in `Capabilities::from_feature_ids` plus a `tabs_for` arm.
- Mouse-diagram hotspots come from Logi metadata; if the metadata omits a button
marker, omit the button — never synthesize hotspot positions.
- Verifying UI changes needs the running app: re-`cargo run -p openlogi-gui` (a plain
`cargo build` leaves the dev bundle stale) after quitting the previous instance
(singleton lock). The GUI shows only the empty state unless the agent is running.
27 changes: 27 additions & 0 deletions .claude/rules/hidpp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
paths:
- "crates/openlogi-hidpp/**"
- "crates/openlogi-hid/**"
---

# HID++ layers

- `openlogi-hidpp` is a vendored fork of the `hidpp` crate (lib name `hidpp`, 0BSD).
It deliberately does not inherit the workspace lints — keep its upstream-derived
style. Document protocol facts from the official Logitech HID++ feature specs rather
than guessing byte layouts; offsets that were reverse-engineered are marked as such
in comments — keep those marks honest.
- Feature wrappers are typed end to end: the registry data-macro + `FeatureEndpoint`
pattern, `num_enum` for discriminants, `bitflags` with `from_bits_retain` where
unknown bits are legal. Unknown wire values surface as errors
(`UnsupportedResponse`-style), never as silent defaults.
- Device "kind" flows through four incompatible vocabularies (Bolt pairing register,
feature `0x0005` `DeviceType`, the assets-registry string, and
`openlogi_core::device::DeviceKind`) — the same small integers mean different things
in each. Never cross them by raw value; convert at the boundary. `kind` is
identity-only; capability decisions come from the feature table.
- Enumeration runs on a poll with cache/ledger grace logic so sleeping or briefly
unreachable devices keep their identity and panels. Changes to probing must keep the
"replay last-good inventory through transient failures" behavior intact — run the
inventory/watcher tests and think about the partial-failure paths, not just clean
enumeration.
17 changes: 17 additions & 0 deletions .claude/rules/hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
paths:
- "crates/openlogi-hook/**"
---

# Input hook (CGEventTap / evdev / WH_MOUSE_LL)

- macOS: the CGEventTap freeze-hazard state machine is load-bearing. The tap must
self-disable when Accessibility is revoked, on its own thread, with the bounded
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 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
macOS-green build never compiles. CI lints them; treat the linux/windows CI jobs as
the check, not local builds.
24 changes: 24 additions & 0 deletions .claude/rules/i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
paths:
- "crates/openlogi-gui/locales/**"
- "crates/openlogi-gui/src/i18n.rs"
---

# 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").
- 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.
24 changes: 24 additions & 0 deletions .claude/rules/ipc-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
paths:
- "crates/openlogi-agent-core/**"
- "crates/openlogi-agent/**"
---

# Agent IPC — the wire format is append-only

The GUI and agent speak tarpc over bincode on an `interprocess` local socket
(`openlogi-agent-core/src/ipc.rs`). bincode encodes the enum **variant index** and
tarpc encodes the **method order**, so the wire format is positional:

- Service methods are append-only; never reorder or remove. `protocol_version` must
remain method 0 forever — the takeover handshake probes it across versions.
- serde enums that cross the IPC boundary are append-only too. serde encodes the
declaration index, NOT a `#[repr(u8)]` discriminant — the two can disagree.
- Any wire change bumps `PROTOCOL_VERSION` (checked strict-equal at connect) and
regenerates the golden tests in `crates/openlogi-agent-core/tests/wire_format.rs`,
including the pinned-version assertion — the failure message prints the bytes.
- The goldens use tokio-serde's `Bincode::default()` = bincode `DefaultOptions`
(varint, little-endian); the free `bincode::serialize` functions are fixint and do
NOT produce matching bytes.
- Debug-build agents never take over a running release agent — that is by design (a
dev agent must not displace the user's production agent), not a bug.
30 changes: 30 additions & 0 deletions .claude/rules/xtask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
paths:
- "xtask/**"
- "packaging/**"
- "scripts/**"
---

# xtask & packaging tooling

- `xtask/README.md` is the contract for this crate — module layout mirrors the CLI
hierarchy, `xshell` for short-lived external tools (`cargo`, `create-dmg`, `codesign`,
`nfpm`), `std::process::Command` only for real process control, crates (not shell-outs)
for structured data, no thin wrappers around tools that already own a task. Read it
before adding a command.
- xtask is linted like product code: the workspace `clippy::pedantic` +
`unwrap_used`/`expect_used` warns run with `-D warnings` — use `?` and combinators,
not `unwrap`/`expect`, even in "script" code.
- App icon: the master is the **committed** `design/icon/openlogi.png` (1024²);
`cargo xtask macos icns` downscales it via `sips` + `iconutil`. The build never
fetches the icon from the CDN — a build-time fetch was tried and deliberately
reverted; don't reintroduce it. After changing the icon, macOS caches by bundle
path: `touch target/dev/OpenLogi.app && killall Dock` to see it.
- Package contents are declarative, not coded: Linux `.deb`/`.rpm` in
`packaging/linux/nfpm.yaml` (plus udev rules, systemd unit, desktop entry beside it),
Windows MSI in `packaging/windows/OpenLogi.wxs`. Packaging env overrides
(`OPENLOGI_SIGN_IDENTITY`, `OPENLOGI_BUNDLE_ASSETS`, `PKG_ARCH`, …) are documented in
`docs/DEVELOPMENT.md`.
- `scripts/cargo-run-macos.sh` (the dev-run bundle wrapper) stays outside xtask on
purpose — cargo must exec it while running arbitrary binaries, including xtask itself.
`scripts/release-notes/` is a dedicated Node/Octokit tool; don't wrap it in xtask.
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ devenv.local.yaml
/UI.md
/PLAN.md

# Claude Code worktree + transcripts — per-developer state.
/.claude/
# Claude Code worktrees, transcripts, local settings — per-developer state.
# The path-scoped agent rules in .claude/rules/ are project config and stay tracked.
/.claude/*
!/.claude/rules/

# macOS finder metadata
.DS_Store
Expand Down
164 changes: 164 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# OpenLogi — Agent Guide

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](docs/DEVELOPMENT.md). This file is the agent-facing contract;
subsystem deep-rules are indexed at the bottom.

## Architecture

Three tiers ship in one install: the **GUI** is a pure IPC client, the **agent** is a
background server owning the input hook and ALL device I/O, and shared orchestration
sits beneath both.

| Crate | Role |
|---|---|
| `openlogi` (root package, `src/`) | The CLI binary — thin wrapper over `openlogi-cli` |
| `crates/openlogi-core` | Pure types: TOML config, device model, action catalog. No I/O, no async |
| `crates/openlogi-hidpp` | Vendored fork of the `hidpp` protocol crate (**lib name `hidpp`**, 0BSD) |
| `crates/openlogi-hid` | Device discovery + HID++ writes over `async-hid` |
| `crates/openlogi-assets` | Device-render registry + cached fetch from assets.openlogi.org |
| `crates/openlogi-cli` | `clap` command tree: `list`, `assets`, `diag` |
| `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 orchestration + the tarpc IPC contract (`src/ipc.rs`) |
| `crates/openlogi-agent` | The `openlogi-agent` binary — hook + device I/O server |
| `crates/openlogi-gui` | GPUI + gpui-component desktop app — polls the agent, no device I/O |
| `xtask` | `cargo xtask` maintenance: bundling, packaging, release manifest |

- GUI ↔ agent speak tarpc/bincode over an `interprocess` local socket. The wire format
is versioned and **append-only** — read `.claude/rules/ipc-protocol.md` before touching it.
- Platform code is cfg-gated per crate (`[target.'cfg(target_os = …)'.dependencies]`).
The workspace's ObjC FFI is centralized in `crates/openlogi-gui/src/platform/` — read
that directory's `AGENTS.md` before editing it.

## Build, run, verify

The toolchain lives in a devenv (Nix) shell — **cargo is not on the bare PATH**. Run
everything through direnv from the repo root, including git (the hooks need cargo):

```sh
direnv exec . cargo clippy --workspace --all-targets -- -D warnings
direnv exec . git commit …
```

- Full local gate (same as CI): `devenv tasks run openlogi:check` = `fmt --check` +
`clippy -D warnings` + workspace tests. It 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).
- The macOS GUI build needs full Xcode for GPUI's Metal shaders; devenv sets
`DEVELOPER_DIR`/`SDKROOT`. If the shader compile fails, `direnv reload` first.
- Dev-run the app with `cargo run -p openlogi-gui` — a cargo runner wraps it 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);
`devenv tasks run openlogi:check-windows` cross-lints the ring-free subset locally.
Don't claim cross-platform success without CI.

## Rust standards

Edition 2024, MSRV 1.88. Workspace lints (root `Cargo.toml`): `unsafe_code = "deny"`
(opt out per item with `#[expect(unsafe_code, reason = "…")]` plus a `// SAFETY:`
comment), `clippy::pedantic` at warn, `unwrap_used`/`expect_used` at warn.
`openlogi-hidpp` deliberately does not inherit workspace lints (vendored code). Any
lint suppression carries a `reason`.

Encode invariants in the type system instead of checking them at runtime:

- Wire/firmware values get typed wrappers: `num_enum` for discriminants, `bitflags`
(`from_bits_retain` when unknown bits are legal) for flag sets. Unknown wire values
surface as **errors** (`UnsupportedResponse`-style), never as silent fallbacks.
- Replace long parameter lists with Change/Params structs; make illegal combinations
unrepresentable rather than validated.
- Ownership models resources (`Retained<T>` in the ObjC FFI) and thread affinity is
proven by types (`MainThreadMarker`, `!Send` handles), not by runtime checks.
- Libraries return `thiserror` types; binaries may use `anyhow`.

House style:

- **Root-cause fixes only.** Never layer compatibility shims over a broken abstraction —
refactor it. Never change product code to work around a dev-environment quirk; debug
the environment (or a release build) instead.
- **Prefer mature crates over hand-rolled logic** (retry/backoff, hashing, paths, …).
Check `cargo tree | grep <candidate>` before adding a dependency and use `cargo add`
so versions come from the registry. After ANY dependency change, verify the
`gpui`/`gpui-component` git pins in `Cargo.lock` didn't move (they are held only by
the lock; restore with `cargo update -p gpui --precise <rev>`).
- Module layout: a module with its own semantics is `foo.rs` (children in a sibling
`foo/`); `foo/mod.rs` is only for pure namespace shells. Never both for one module.
- Keep files reasonably sized (split around ~500 lines) into real modules — never
simulate structure with `// ---- section ----` banner comments. But don't
over-extract either: inline single-use helpers.
- rustdoc every public item. Comments state non-obvious constraints only.
- Tests cover failure and edge paths, not just the happy path (state machines
especially). No tautological tests that mirror the implementation; never weaken an
assertion or special-case an input to make a test pass.

## Git & GitHub

- 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`). `i18n` is a scope, not a type.
- Branches: `type/kebab-description` off `master`. 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.
- 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), `## Testing` listing the
exact commands run plus hardware-verification status (say "not runtime-tested on
hardware" when true), and a closing `Fixes #N` line. 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
master in a worktree, fix review findings, push to the fork branch; preserve
authorship (`Co-authored-by` when re-homing work).
- 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.

## Releases

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.

## Verification

Define the concrete check that proves a change works before writing it — a failing test
that should pass, a command whose output should change, a behavior in the running app —
and loop on that check. Real-hardware verification (physical mice, receivers) is the
maintainer's job: every fix PR states how to test it. Report outcomes honestly,
including what was NOT verified.

## Subsystem rules — read before touching

Claude Code loads these automatically per path; other agents: read the listed file
before editing that area.

| Area | Rule file |
|---|---|
| `crates/openlogi-gui/**` (GPUI app) | `.claude/rules/gui.md` |
| `crates/openlogi-gui/locales/**`, `src/i18n.rs` | `.claude/rules/i18n.md` |
| `crates/openlogi-agent-core/**`, `crates/openlogi-agent/**` (IPC wire) | `.claude/rules/ipc-protocol.md` |
| `crates/openlogi-hidpp/**`, `crates/openlogi-hid/**` | `.claude/rules/hidpp.md` |
| `crates/openlogi-hook/**` (event taps) | `.claude/rules/hook.md` |
| `xtask/**`, `packaging/**`, `scripts/**` | `.claude/rules/xtask.md` (+ `xtask/README.md`) |
| `crates/openlogi-gui/src/platform/**` (ObjC FFI) | `crates/openlogi-gui/src/platform/AGENTS.md` |
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@AGENTS.md

## Claude Code specifics

- Path-scoped deep rules live in `.claude/rules/` and load automatically when you touch
matching files; the index is at the bottom of AGENTS.md.
- GPUI and gpui-component reference skills may be present locally under `.agents/skills/`
(`gpui`, `gpui-component`, `gpui-entity`, `gpui-layout-and-style`, …; gitignored,
per-developer). When they are available, consult them for GUI work instead of guessing
gpui APIs from training data — the gpui pin moves and APIs drift.
- `crates/openlogi-gui/src/platform/CLAUDE.md` imports that directory's `AGENTS.md`
(the macOS ObjC FFI contract) when you work in that subtree.
Loading
Loading