From cccf0f0c71f011a301c83c0d3d4b26cc4017144f Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 08:15:34 +0800 Subject: [PATCH 1/3] docs(gui): serve the platform FFI guide as AGENTS.md Rename the macOS-FFI directory guide from CLAUDE.md to AGENTS.md so agents that follow the nested-AGENTS.md convention pick it up, and keep a thin CLAUDE.md that imports it for Claude Code. --- crates/openlogi-gui/src/platform/AGENTS.md | 96 +++++++++++++++++++++ crates/openlogi-gui/src/platform/CLAUDE.md | 97 +--------------------- 2 files changed, 97 insertions(+), 96 deletions(-) create mode 100644 crates/openlogi-gui/src/platform/AGENTS.md diff --git a/crates/openlogi-gui/src/platform/AGENTS.md b/crates/openlogi-gui/src/platform/AGENTS.md new file mode 100644 index 00000000..aa9848ed --- /dev/null +++ b/crates/openlogi-gui/src/platform/AGENTS.md @@ -0,0 +1,96 @@ +# `platform/` — macOS native FFI + +This directory is OpenLogi's macOS-native surface. The Objective-C FFI here runs +on **`objc2`** (0.6 / framework crates 0.3): `Retained` smart pointers, typed +AppKit objects, `define_class!` for subclasses. The whole workspace's ObjC-runtime +FFI is exactly these files — keep them in sync: + +- `status_item.rs` — safe `objc2` wrappers over `NSStatusItem` / `NSMenu` / `NSMenuItem`. +- `tray.rs` — the OpenLogi menu-bar semantics + the `OpenLogiMenuTarget` (`define_class!`). +- `permissions.rs` — `CBCentralManager.authorization` (`objc2` class lookup) + `IOHIDCheckAccess` (C FFI). +- `crates/openlogi-hook/src/macos.rs` — CGEventTap (on `core-graphics`, see below) + the `NSWorkspace` frontmost-app read (`objc2`). + +Spawning the agent under its own macOS TCC identity (so its Accessibility / +Input-Monitoring grants aren't attributed to the GUI, issue #214) lives in the +external [`disclaim`](https://crates.io/crates/disclaim) crate — `posix_spawn` + +the private `responsibility_spawnattrs_setdisclaim`, not ObjC. `ipc_client`'s +`spawn_agent` uses it; there is no in-tree FFI for it. + +`single_instance.rs` (fs4 lock), `launch_agent.rs` (plist via `std::fs`), `updater.rs` +(gpui_updater) contain **no** ObjC FFI — don't add any. + +## Ownership: `Retained`, never raw `id` + +`objc2` makes ownership a value: a `Retained` releases exactly once on `Drop`. +That is *why* this code can't reproduce issue #99 (a `+1` `NSString` leaked on every +2 s tray refresh under the old `cocoa`/`objc` 0.x path). + +- Every string is `NSString::from_str(s)` → a `Retained` used as a borrowed + temporary; it releases at the end of the statement. **There is no `nsstring()` helper + and no autorelease pool in the tray path** — don't reintroduce either. +- `alloc`/`init`/`new`/`copy` and the framework getters return `Retained` / + `Option>`; you keep what you need in a field and let `Drop` free it. +- **Never** call manual `retain`/`release`/`autorelease`, add raw `cocoa`/`objc` 0.x, or + build a bespoke retain/release helper layer — that re-derives `Retained`, worse. + +## Thread affinity is in the type system + +- `NSMenu` and `NSMenuItem` are `#[thread_kind = MainThreadOnly]` → their `Retained` is + `!Send`. `NSStatusItem`, `NSImage`, `NSWorkspace` are `AnyThread` (their `Retained` is + still `!Send`, because a bare ObjC object is `!Sync`). +- Constructing a `MainThreadOnly` object needs a `MainThreadMarker` (`NSMenu::new(mtm)`, + `NSMenuItem::alloc(mtm)`, `status_item.button(mtm)`). Mutating an already-held + `Retained` (`setTitle`/`setHidden`) does **not** — possessing the `!Send` + handle already proves you're on the main thread. +- The tray's state lives in a **`thread_local`** (`TRAY`), not a `static`: a `Retained` + of a `MainThreadOnly`/ObjC object can't satisfy a `Sync` static. `install`/`show_in_dock`/ + `hide_from_dock` obtain `mtm` via `MainThreadMarker::new()` at the GPUI→objc2 boundary + (they always run on GPUI's main thread). Do **not** copy gpui's own + `NSThread.isMainThread` + `dispatch2` runtime-check idiom here — we use the compile-time + `MainThreadMarker` guarantee. + +## The `unsafe` that remains (and the `# SAFETY` rule) + +`objc2` marks only a few calls `unsafe`; each `unsafe` block does one operation with a +`SAFETY` comment (workspace lint policy). The current set: + +- `NSMenuItem::initWithTitle_action_keyEquivalent` + `setTarget:` (raw selector; target is a + *weak* reference, so the tray retains `MenuTarget` for the app's lifetime). +- `msg_send![super(this), init]` in `MenuTarget::new`. +- `NSString::to_str(pool)` in the hook (borrow tied to the pool). +- the hook's accessibility C FFI + the `CBCentralManager` class-method send. + +`status_item.rs`/`tray.rs` opt into `#[expect(unsafe_code)]` locally; `unsafe_code` stays +`deny` for the gui crate otherwise. + +## CGEventTap stays on `core-graphics` — on purpose + +The event tap in `openlogi-hook/macos.rs` is **not** migrated. `objc2-core-graphics` 0.3 +*does* expose `CGEvent::tap_create`/`tap_enable` (it's not an availability gap), but the +tap's Accessibility-revoke **freeze-hazard** state machine (the 500 ms run-loop slice + +self-disable on its own thread) is load-bearing and must stay byte-for-byte. Only the +`NSWorkspace` frontmost-app read moved to `objc2`. Don't "modernize" the tap casually. + +## Off-main autorelease pools + +Tray code needs no pool (it runs on the main run loop, and `Retained` frees deterministically). +The hook's `frontmost_bundle_id` runs on a watcher thread with no run loop, so it keeps an +explicit `objc2::rc::autoreleasepool` — that's the *only* place in this crate and the hook a +pool belongs. (`openlogi-core`'s `post_media_key` follows the same pattern for media-key +`NSEvent`s on the dispatch threads.) + +## Dependencies + +`cocoa` / `objc` 0.x are gone from this crate's and the hook's direct deps (they remain in +`Cargo.lock` only transitively via gpui — expected). Use `cargo add` for objc2 framework +crates, then **verify the `zed`/`gpui-component` git pins in `Cargo.lock` didn't move** (the +gpui pin is held only by the lock; a resolve can bump it — restore with `cargo update -p gpui +--precise `). + +## Build & verify + +The gui crate needs the real Xcode toolchain for gpui's Metal shader compile: +`DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`, `SDKROOT=$(xcrun --show-sdk-path)`, +`xcbuild` stripped from `PATH`. Behavioural checks (tray icon shows, Open/Quit fire, device +rows update) need the running app. Confirm an FFI memory fix with `leaks` over a multi-minute +session: the `CFString`/`NSString` count must stay **flat** (the empirical inverse of #99). diff --git a/crates/openlogi-gui/src/platform/CLAUDE.md b/crates/openlogi-gui/src/platform/CLAUDE.md index aa9848ed..43c994c2 100644 --- a/crates/openlogi-gui/src/platform/CLAUDE.md +++ b/crates/openlogi-gui/src/platform/CLAUDE.md @@ -1,96 +1 @@ -# `platform/` — macOS native FFI - -This directory is OpenLogi's macOS-native surface. The Objective-C FFI here runs -on **`objc2`** (0.6 / framework crates 0.3): `Retained` smart pointers, typed -AppKit objects, `define_class!` for subclasses. The whole workspace's ObjC-runtime -FFI is exactly these files — keep them in sync: - -- `status_item.rs` — safe `objc2` wrappers over `NSStatusItem` / `NSMenu` / `NSMenuItem`. -- `tray.rs` — the OpenLogi menu-bar semantics + the `OpenLogiMenuTarget` (`define_class!`). -- `permissions.rs` — `CBCentralManager.authorization` (`objc2` class lookup) + `IOHIDCheckAccess` (C FFI). -- `crates/openlogi-hook/src/macos.rs` — CGEventTap (on `core-graphics`, see below) + the `NSWorkspace` frontmost-app read (`objc2`). - -Spawning the agent under its own macOS TCC identity (so its Accessibility / -Input-Monitoring grants aren't attributed to the GUI, issue #214) lives in the -external [`disclaim`](https://crates.io/crates/disclaim) crate — `posix_spawn` + -the private `responsibility_spawnattrs_setdisclaim`, not ObjC. `ipc_client`'s -`spawn_agent` uses it; there is no in-tree FFI for it. - -`single_instance.rs` (fs4 lock), `launch_agent.rs` (plist via `std::fs`), `updater.rs` -(gpui_updater) contain **no** ObjC FFI — don't add any. - -## Ownership: `Retained`, never raw `id` - -`objc2` makes ownership a value: a `Retained` releases exactly once on `Drop`. -That is *why* this code can't reproduce issue #99 (a `+1` `NSString` leaked on every -2 s tray refresh under the old `cocoa`/`objc` 0.x path). - -- Every string is `NSString::from_str(s)` → a `Retained` used as a borrowed - temporary; it releases at the end of the statement. **There is no `nsstring()` helper - and no autorelease pool in the tray path** — don't reintroduce either. -- `alloc`/`init`/`new`/`copy` and the framework getters return `Retained` / - `Option>`; you keep what you need in a field and let `Drop` free it. -- **Never** call manual `retain`/`release`/`autorelease`, add raw `cocoa`/`objc` 0.x, or - build a bespoke retain/release helper layer — that re-derives `Retained`, worse. - -## Thread affinity is in the type system - -- `NSMenu` and `NSMenuItem` are `#[thread_kind = MainThreadOnly]` → their `Retained` is - `!Send`. `NSStatusItem`, `NSImage`, `NSWorkspace` are `AnyThread` (their `Retained` is - still `!Send`, because a bare ObjC object is `!Sync`). -- Constructing a `MainThreadOnly` object needs a `MainThreadMarker` (`NSMenu::new(mtm)`, - `NSMenuItem::alloc(mtm)`, `status_item.button(mtm)`). Mutating an already-held - `Retained` (`setTitle`/`setHidden`) does **not** — possessing the `!Send` - handle already proves you're on the main thread. -- The tray's state lives in a **`thread_local`** (`TRAY`), not a `static`: a `Retained` - of a `MainThreadOnly`/ObjC object can't satisfy a `Sync` static. `install`/`show_in_dock`/ - `hide_from_dock` obtain `mtm` via `MainThreadMarker::new()` at the GPUI→objc2 boundary - (they always run on GPUI's main thread). Do **not** copy gpui's own - `NSThread.isMainThread` + `dispatch2` runtime-check idiom here — we use the compile-time - `MainThreadMarker` guarantee. - -## The `unsafe` that remains (and the `# SAFETY` rule) - -`objc2` marks only a few calls `unsafe`; each `unsafe` block does one operation with a -`SAFETY` comment (workspace lint policy). The current set: - -- `NSMenuItem::initWithTitle_action_keyEquivalent` + `setTarget:` (raw selector; target is a - *weak* reference, so the tray retains `MenuTarget` for the app's lifetime). -- `msg_send![super(this), init]` in `MenuTarget::new`. -- `NSString::to_str(pool)` in the hook (borrow tied to the pool). -- the hook's accessibility C FFI + the `CBCentralManager` class-method send. - -`status_item.rs`/`tray.rs` opt into `#[expect(unsafe_code)]` locally; `unsafe_code` stays -`deny` for the gui crate otherwise. - -## CGEventTap stays on `core-graphics` — on purpose - -The event tap in `openlogi-hook/macos.rs` is **not** migrated. `objc2-core-graphics` 0.3 -*does* expose `CGEvent::tap_create`/`tap_enable` (it's not an availability gap), but the -tap's Accessibility-revoke **freeze-hazard** state machine (the 500 ms run-loop slice + -self-disable on its own thread) is load-bearing and must stay byte-for-byte. Only the -`NSWorkspace` frontmost-app read moved to `objc2`. Don't "modernize" the tap casually. - -## Off-main autorelease pools - -Tray code needs no pool (it runs on the main run loop, and `Retained` frees deterministically). -The hook's `frontmost_bundle_id` runs on a watcher thread with no run loop, so it keeps an -explicit `objc2::rc::autoreleasepool` — that's the *only* place in this crate and the hook a -pool belongs. (`openlogi-core`'s `post_media_key` follows the same pattern for media-key -`NSEvent`s on the dispatch threads.) - -## Dependencies - -`cocoa` / `objc` 0.x are gone from this crate's and the hook's direct deps (they remain in -`Cargo.lock` only transitively via gpui — expected). Use `cargo add` for objc2 framework -crates, then **verify the `zed`/`gpui-component` git pins in `Cargo.lock` didn't move** (the -gpui pin is held only by the lock; a resolve can bump it — restore with `cargo update -p gpui ---precise `). - -## Build & verify - -The gui crate needs the real Xcode toolchain for gpui's Metal shader compile: -`DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`, `SDKROOT=$(xcrun --show-sdk-path)`, -`xcbuild` stripped from `PATH`. Behavioural checks (tray icon shows, Open/Quit fire, device -rows update) need the running app. Confirm an FFI memory fix with `leaks` over a multi-minute -session: the `CFString`/`NSString` count must stay **flat** (the empirical inverse of #99). +@AGENTS.md From 06a8abae6f60177023eb03dfd79dbb90158985b3 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 08:15:49 +0800 Subject: [PATCH 2/3] docs: add root agent guides and path-scoped rules AGENTS.md is the tool-agnostic contract: architecture map, the devenv/direnv build gate, Rust type-system standards, git/GitHub and release conventions, and an index of subsystem rules. CLAUDE.md imports it and adds Claude Code specifics. The .claude/rules/ files carry the deep per-subsystem constraints (GUI theming and pins, i18n key parity, append-only IPC wire format, HID++ vocabularies, hook freeze hazard) and are now tracked via a narrowed .gitignore carve-out. --- .claude/rules/gui.md | 31 +++++++ .claude/rules/hidpp.md | 27 ++++++ .claude/rules/hook.md | 17 ++++ .claude/rules/i18n.md | 24 +++++ .claude/rules/ipc-protocol.md | 24 +++++ .gitignore | 6 +- AGENTS.md | 163 ++++++++++++++++++++++++++++++++++ CLAUDE.md | 12 +++ 8 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 .claude/rules/gui.md create mode 100644 .claude/rules/hidpp.md create mode 100644 .claude/rules/hook.md create mode 100644 .claude/rules/i18n.md create mode 100644 .claude/rules/ipc-protocol.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.claude/rules/gui.md b/.claude/rules/gui.md new file mode 100644 index 00000000..3cc1c75f --- /dev/null +++ b/.claude/rules/gui.md @@ -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 `. +- 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. diff --git a/.claude/rules/hidpp.md b/.claude/rules/hidpp.md new file mode 100644 index 00000000..0fb49096 --- /dev/null +++ b/.claude/rules/hidpp.md @@ -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. diff --git a/.claude/rules/hook.md b/.claude/rules/hook.md new file mode 100644 index 00000000..2f76a2bf --- /dev/null +++ b/.claude/rules/hook.md @@ -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. diff --git a/.claude/rules/i18n.md b/.claude/rules/i18n.md new file mode 100644 index 00000000..601abd12 --- /dev/null +++ b/.claude/rules/i18n.md @@ -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 + `.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. diff --git a/.claude/rules/ipc-protocol.md b/.claude/rules/ipc-protocol.md new file mode 100644 index 00000000..7e480a4e --- /dev/null +++ b/.claude/rules/ipc-protocol.md @@ -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. diff --git a/.gitignore b/.gitignore index 3ee5d25c..804378f5 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e0b19105 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,163 @@ +# 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` 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 ` 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 `). +- 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` | +| `crates/openlogi-gui/src/platform/**` (ObjC FFI) | `crates/openlogi-gui/src/platform/AGENTS.md` | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..21c173d7 --- /dev/null +++ b/CLAUDE.md @@ -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. From 8ca1b137c0fb2360d2df76381579a1faf8d8dffb Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 13 Jul 2026 08:29:40 +0800 Subject: [PATCH 3/3] docs: add xtask/packaging rule to the agent guides Point at xtask/README.md as the crate contract and add what it doesn't cover: workspace lints apply to xtask too, the committed-PNG icon pipeline (no CDN fetch, Dock icon cache), declarative nfpm/WiX package manifests, and why cargo-run-macos.sh and the release-notes tool stay outside xtask. --- .claude/rules/xtask.md | 30 ++++++++++++++++++++++++++++++ AGENTS.md | 1 + 2 files changed, 31 insertions(+) create mode 100644 .claude/rules/xtask.md diff --git a/.claude/rules/xtask.md b/.claude/rules/xtask.md new file mode 100644 index 00000000..482a94c7 --- /dev/null +++ b/.claude/rules/xtask.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index e0b19105..7b8c5f7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,4 +160,5 @@ before editing that area. | `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` |