Repository navigation
Conversation
noobydp
force-pushed
the
codex/pr-970-staging
branch
from
August 28, 2026 18:01
0bb38cc to
dd35a58
Compare
noobydp
force-pushed
the
fix/easy-switch-forwarding
branch
13 times, most recently
from
August 31, 2026 08:54
335f1b6 to
5408594
Compare
The software-id allocator was a per-process static, so the agent and any other OpenLogi process opening the same HID node (a CLI diag run, the GUI) both leased id 1. macOS, Windows and Linux hand every input report to every open handle, so each channel saw the other's replies, and a reply whose header matched a pending request was taken as its answer. A root getFeature reply carries no feature id: one taken from the other process pinned the wrong feature index for the rest of the session — DPI writes failed with InvalidFunctionId, control diverts with InvalidArgument, and the agent's control capture came up with no buttons until restarted with no other process talking to the device. Add openlogi_device::host_lock — exclusive OS file locks under the temp directory (std File::try_lock; no new dependency, no unsafe) — and back each lease with one, so a second process skips ids the first holds. The OS releases the lock with its holder, so a crash cannot strand an id. If the lock directory is unusable the lease falls back to the process-local table, reported once. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…sses HID++ 1.0 register replies carry no software id, and an empty-slot error reply carries no sub-register byte either, so two OpenLogi processes probing the same receiver at once still took each other's replies after the sub-register echo check: each probe's real slot read was answered by the other probe's empty-slot error, both probes reported the receiver with no devices, and the agent retired a channel that was fine (dropping its control capture for the re-arm). Hold a host_lock keyed by the node's identity for the register phase of a Bolt or Unifying probe — unique id, pairing count, arrival drain, slot identities — bounded at 5 s, past which the probe proceeds unlocked as before. The per-device feature walks stay unlocked: they address each device by index under the process's own software id. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
A reply is matched to the oldest pending request whose predicate accepts it, and every predicate keys on the report's first three bytes: device, feature (HID++1.0: sub id), function and software id (HID++1.0: register address). Two requests sharing those bytes get replies nothing on the wire can tell apart, and a wireless receiver answers them out of order when a radio packet is retransmitted. The agent's startup runs several device sessions at once — the settings writer, the gesture capture, and any GUI request that lands over IPC — each resolving features through root getFeature with the identical header. On a Bolt-connected MX Master 4 the trace shows the thumbwheel's index (0x2150) handed to the wheel session and the wheel's (0x2121) to the thumbwheel session: the wheel is then 'unsupported', DPI writes fail with InvalidFunctionId, the control divert with InvalidArgument, and the capture comes up with no buttons. Park a request whose header is already in flight until that request is answered, cancelled, or times out; the wait counts against the caller's timeout. The check and the registration happen under one lock, so two parked requests woken together cannot both slip in. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
rustdoc runs with `-D warnings` in CI, and `read_long_sub_register`'s public docs linked `is_rap_response`, which is private. Name it in code font instead. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
The software-id lease was keyed on the id alone, so every HID node on the host drew from one pool of fifteen: with fifteen channels open across OpenLogi processes, an open on an unrelated node was refused — no live reads, writes or capture for that device until some channel elsewhere closed. Report streams of different nodes never meet, so ids only have to be unique per node. Leases are now `(node, id)`: the in-process table is keyed that way and the OS lock file is named after the node's own lock name, beside its register-phase lock, in the one host lock directory `host_lock` already owns — which also retires the transport's private copy of the file-locking code. The channel's `Leased` policy carries the lease as an owned handle dropped with the policy instead of a bare `fn(u8)` callback, since a free function cannot tell which node's id it is returning. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…ly lands Serialising same-header requests left one hole: a request that timed out or was cancelled unanswered freed its header at once, and its reply — still on its way — then answered the next request with that header. The response predicate has no generation to tell them apart, so two distinct same-header calls could still swap replies; a receiver answering a register read a few hundred milliseconds after a tight probe budget gave up on it is the realistic case. An abandoned request now leaves its header reserved until its reply arrives and is discarded, or for STALE_REPLY_GRACE (1 s) at most, so a device that never answers costs the next request one grace window rather than a whole timeout. A byte-identical re-ask — what the feature-table read does when the link drops a report — does not wait: the outstanding reply answers it as well as its own would, so it goes out at once, adopts the owed reply, and is answered by whichever comes first; the channel keeps counting the other so a later, different request cannot be answered by it either. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…lock `lock_within` fell through to an unlocked probe once the wait budget passed, on the theory that the holder was stuck. A holder still going past five seconds is more often a live probe failing slowly — each unanswered register read costs it a request timeout — and joining it unlocked restored the exact race the lock exists for: the second process consumed the first's header-only HID++ 1.0 error replies. Contention past the budget now settles a failed probe instead: "couldn't check", which the ledger already handles by replaying the node's last snapshot and retrying next tick. `lock_within` distinguishes that from the lock directory being unusable, the one case with nothing to wait for, where the probe runs unarbitrated as it did before locks existed. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…ailure A probe that could not take the receiver's register-phase lock settled as a failed probe, so each such tick advanced the node's failure count: two consecutive contentions retired the channel and tore down capture behind it, although this process had skipped the node's I/O entirely and had no evidence the channel was unwell. Deferral is now its own verdict. The ledger replays the node's last-good inventory without touching the failure count or requesting eviction — a channel that was never asked cannot have failed — while the verdict still fails the pass's `all_healthy`, so the one-shot retry re-probes as it would after a failure. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…dentical re-ask Byte-identical requests do not have interchangeable replies. A DPI read abandoned before its reply, a DPI write acknowledged, and the read re-asked all fit inside one STALE_REPLY_GRACE, and the unconditional re-ask exception let the re-ask adopt the first read's late reply — the value from before the write — while its own reply was counted as the stale one. The channel now quarantines by default whatever the new request's bytes are. Adoption is an explicit choice of the caller, AbandonedReply::AdoptIdentical on send_with / send_v20_with / FeatureEndpoint::call_with, and the only caller that makes it is the FeatureSet feature: its table is fixed for the life of the connection, so a walk that re-asks for an entry the link dropped is not held for the grace on every retry (without it, half the re-ask attempts of read_feature_entry never reached the wire). Tests: the read-after-write case through send_v20 and the mock; the default policy parking an identical re-ask; the opt-in adopting the owed reply; and that the abandoned request's own policy plays no part. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
The register-phase lock only works if conflicting callers participate, and two did not: open_route_channel and pairing's receiver selection both read the receiver's unique id — a HID++ 1.0 register read — unlocked, and a pairing session writes the notification, discovery and pairing registers for its whole run. The phase moves out of the inventory probe into host_lock as lock_receiver_registers / ReceiverRegisterPhase, with one wait (RECEIVER_REGISTER_WAIT, 8 s) for every taker. A route open whose receiver is held elsewhere past the wait passes the node over this time — the caller sees the same None as for a receiver that is not there — rather than read into the holder's replies. Pairing takes the phase once in open_receiver and holds it through the session's last restore write; inventory probes in every process defer to it meanwhile and pick the new pairing up on release. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…t starts RECEIVER_PROBE_BUDGET (13 s) was sized for a receiver probe's honest worst case — the 1.5 s arrival drain, the register pass, and one slot's 10 s walk — and the enumerator started it around probe_one, which now began by waiting up to five seconds for the node's register phase. A working receiver whose phase another process released four seconds in could therefore trip the budget before its slow slot reached its own timeout-and-cache fallback; that is a Failed verdict, not Deferred, and two in a row retire the channel. probe_one now takes the phase first and only then bounds the probe's I/O, with every deadline of a pass in one ProbeDeadlines value (production defaults unchanged) so the composition is one place to read and one value for a test to shrink. The probes share a PassContext instead of threading cache, clock, event sink and deadlines separately. The Unifying probe keeps its phase to the end rather than dropping it after the arrival drain: a slot whose feature walk exposes no marketing name reads its codename from the receiver's 0xB5 register after the walk, and an error reply to one 0xB5 sub-register is indistinguishable from another's. The shared wait (8 s) allows for the walks running under it. Composed tests with a scripted Bolt receiver whose paired mouse never answers: a phase held 600 ms and released inside the wait, with drain plus slot fallback that together with the wait outrun the budget, still settles healthy on the cached slot; a phase held past the wait defers without a byte of I/O; and I/O alone outrunning the budget is still a failed probe. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
A deferred probe preserved the node ledger but its empty outcomes still ran through apply_outcomes and evict_unseen, so four deferred passes in a row (CACHE_MISS_GRACE = 3) deleted the receiver's feature-cache entries — and, for Bolt, persisted the deletion — while its last-good inventory and channel stood and no device I/O had failed. The next real probe with a device offline or its walk failing then had no capabilities or model data to fall back on. The enumerator now records the cache entries each node contributes, and a deferred tick passes those to evict_unseen as frozen: neither seen nor missed, counters standing until the node is actually probed again. Entries of nodes that were checked or removed age exactly as before; an evicted entry stops being any node's, and a vanished node's set goes with its ledger. Regression test through NodeProbe::deferred, settle_probe, apply_outcomes and evict_unseen, with a checked node aging alongside. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…efore its first probe A warm start loads the persisted Bolt entries before any receiver answers, so a receiver whose register phase another process held from the first pass had no record of what was its, froze nothing, and four such passes deleted — and persisted the deletion of — the very capabilities deferral exists to keep. A node deferred with no record now holds every entry no node has claimed: nothing that was checked contributed them, so nothing that was checked can have found them missing. Its first real probe attributes what is its, and normal aging resumes from there. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
Give independent receiver replay scenarios process- and test-specific node identities so their host-wide register locks do not contend. Update the recording replay fixture to own its software-id lease. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
Keep cache ownership unknown until the first healthy probe. A failed initial probe must not create an empty ownership record that makes subsequent deferrals age persisted entries. Cover failure followed by deferrals, preservation of the miss count and persisted cache, and eventual eviction after real failures resume. Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
orchestrator/tests.rs was 1042 lines: 37 tests over six areas. Each is its own module now — device_list, device_settings, reapply, publication, camera, capture_plans — and tests.rs keeps the imports and the fixtures more than one area uses (the orchestrator and device builders, the inventories). Every child starts with `use super::*`. Pure move: no test body changed; 37 tests before and after.
channel/tests.rs was 1191 lines: 30 tests over five areas of the channel. Each is its own module now — lease, send, raw_and_listeners, send_v20, same_header — and tests.rs keeps the imports and the fixtures more than one area uses (the mock channel helpers, the message builders, the pending-table probes); the lease recorder and the same-header constants moved with the only tests that use them. Every child starts with `use super::*`. Pure move: no test body changed; 30 tests before and after.
…/tests.rs host_switch.rs was 1041 lines, 400 of them an inline tests module. The module is host_switch/tests.rs now, beside the restore submodule that already lived there; `#[cfg(test)] mod tests;` stays where the inline module was. Pure move: the module body is unchanged apart from one level of indentation; 21 tests before and after. host_switch.rs is 639 lines.
…rker.rs runtime/button.rs was 1022 lines: the press, session and event types, the input handle and the owner, and below them the 320 lines the worker thread runs — draining commands and inputs, settling long presses, and emitting events. The worker is button/worker.rs now; the owner spawns run_worker as before, and the four worker steps the tests drive directly are imported back under cfg(test). Pure move: no line inside the moved functions changed beyond pub(super) on what the owner and the tests call. button.rs is 702 lines.
…PI modules inject/macos.rs was 1130 lines: key and click synthesis, media keys, scroll synthesis with its three quantisers, the Accessibility-tree browser navigation with its Safari check, three inline modules of dlopen'd private SPI, and an inline tests module. Six of those are files under inject/macos/ now — scroll.rs, browser.rs, app_services.rs, dock.rs, symbolic_hotkey.rs, tests.rs — and macos.rs keeps the execute dispatch and CGEvent key and click posting, declares the modules in one place, and re-exports the three entry points inject.rs calls. The FFI inventory in .agents/rules/objc-ffi.md lists the new files and watches the directory. Pure move: the inline modules changed only their indentation; the scroll and browser code only its imports and the visibility of what macos.rs and inject.rs call (pub(in crate::inject) for the two re-exported entry points). Every moved code line exists verbatim in the parent. macos.rs is 515 lines.
…view.rs features/mouse/view.rs was 1084 lines: the model view and its layout, the binding labels beside the render, the silhouette and hotspot elements, and an inline tests module. The labels — BindingLabel, LabelTrigger, label_control and binding_label_for_control, 220 lines — are view/labels.rs now, and the tests are view/tests.rs; the view calls the two label entry points as before. Pure move: the tests changed only their indentation and gained the one import they used to reach through the view's; the label code changed only its imports and pub(super) on what the view reaches. view.rs is 703 lines.
…s.rs features/camera/controls.rs was 1076 lines: the panel state and its device I/O, and below them 390 lines of free functions building what it renders — the profile chips, a slider or chip row per control, the frequency and binary rows, the reset button, and the label and unit helpers. Those are controls/rows.rs now; the panel's render calls the same entry points. Pure move: the row code changed only its imports and pub(super) on what the panel calls. controls.rs is 688 lines.
Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
openlogi-desktop sits outside the rustdoc CI job, so its broken intra-doc links go unnoticed; `cargo doc -p openlogi-desktop --document-private-items` under -D warnings reported twelve. Two were left by yesterday's splits — [`key_points`] became ambiguous with the module of the same name, and the import pruning took away the LABEL_H a doc link relied on — and ten were older: renamed methods (AppState::current_device → current_record, battery_context → Self::status), a cfg-gated variant, a test function, a macro_rules! macro, and items no longer in scope. Links now carry the path they need or become plain code spans where rustdoc cannot link (a variant built only off macOS, a macro_rules! macro, a test). The crate documents clean under -D warnings.
* chore: release v0.8.6 * chore(release): write whole-repo changelog --------- Co-authored-by: aprilnea[bot] <263112868+aprilnea[bot]@users.noreply.github.com> Co-authored-by: aprilnea[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: AprilNEA <github@sku.moe>
Co-authored-by: AprilNEA <github@sku.moe>
…#1456) * feat(hid): recognise Nano receiver 046d:c534 (MK270, MK295) The receiver-identity registry had no entry for USB product ID 0xc534, the Nano receiver bundled with budget wireless combos such as the MK270 and MK295, so OpenLogi never enumerated it or the paired keyboard/mouse. Cross-checked against Solaar's NANO_RECEIVER_C534 as the established reference implementation, alongside the neighboring 0xc537 Nano receiver already registered here. Fixes AprilNEA#866 * test(hid): pin the new c534 receiver's exact identity mapping receiver_identities_are_unique only proves no duplicate identity was introduced; it says nothing about whether 0xc534 resolves to the right brand and protocol. Adds a focused regression test alongside the existing one for the c54d receiver.
* fix(agent): divert a single-bound gesture button Turning gestures off leaves the gesture button on Single(MissionControl). The capture plan compared that against the vestigial single-action default, left the button native, and the firmware gives it no host action, so the press did nothing while the GUI showed Mission Control. Gesture sources now divert for any single action except None, the same rule the haptic panel already used. * docs(core): drop the stale native-capture note from set_gesture_mode
Co-authored-by: AprilNEA <github@sku.moe>
Co-authored-by: AprilNEA <github@sku.moe>
Co-authored-by: AprilNEA <github@sku.moe>
AprilNEA#1366) * fix(gui): prefer firmware codename over catalog name for M650 variants The asset catalog stores one static displayName per depot regardless of which extended_model_id colour/hand variant a physical unit reports. A plain Signature M650 (BLE direct) shares the Signature M650 L depot's modelId (2b02a), so it always rendered as "Signature M650 L". Add variant_display_name(): when the catalog name is exactly the device's own reported name plus extra trailing qualifier word(s) (e.g. "L"), prefer the device's own wording. A catalog name that isn't a superset of the codename is left untouched, so the catalog stays authoritative outside this variant-suffix case. Fixes AprilNEA#1332 Co-authored-by: AprilNEA <github@sku.moe> * fix(gui): only strip a recognized variant qualifier from the catalog name variant_display_name treated any trailing catalog word(s) beyond the codename's length as a wrong variant suffix to strip. That also matches the opposite, legitimate case: a terse codename ("MX Master") against a catalog name carrying a real generation suffix ("MX Master 3S") the codename doesn't repeat — stripping "3S" there loses a correct, more specific model name. Narrow the override to catalog names whose only trailing difference is a recognized variant-qualifier word (currently "L"/"Left", the AprilNEA#1332 case), leaving a real model word like "3S" or "X" untouched. Co-authored-by: AprilNEA <github@sku.moe> * test(gui): cover per-device names with shared asset caching Co-authored-by: AprilNEA <github@sku.moe> --------- Co-authored-by: AprilNEA <github@sku.moe>
* chore: release v0.8.8 * chore(release): write whole-repo changelog --------- Co-authored-by: aprilnea[bot] <263112868+aprilnea[bot]@users.noreply.github.com> Co-authored-by: aprilnea[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Align each volume preset label with its existing forward/backward binding pair and add a regression test for that relationship. Addresses the preset-label problem in AprilNEA#1414; the separate Linux uinput permission report remains open.
Keep the D-Bus executor alive while observer signal streams are dropped. Fixes AprilNEA#1417 Co-authored-by: Sungyong Cho <46742040+sungyongcho@users.noreply.github.com>
Re-register an enabled agent service only when launchctl reports its job missing, while preserving disabled login-item settings. Co-authored-by: raphael <raphael.jostan@tum.de>
Skip click-through overlays when selecting the pointer target and recognize Mission Control system overlays without relaxing dispatch guards. Co-authored-by: pseudo <cdodge@pseudoseed.com>
* chore: release v0.8.9 * chore(release): write whole-repo changelog --------- Co-authored-by: aprilnea[bot] <263112868+aprilnea[bot]@users.noreply.github.com> Co-authored-by: aprilnea[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…ecycle Merge upstream master, retain pending restoration until a fresh keyboard channel, and forward genuine departure announcements through follower channels. Preserve physical inventory tracking and reject stale session completions. Keep source-specific validation, exact control reporting modes, and regression coverage for retirement, reconnect cleanup, and bounded event-time slot reads.
Only active restoration writes block a waiting transition. Ready restoration tokens retain ownership and prevent their own keyboard from rearming without blocking another keyboard indefinitely. Add a replay-backed multi-keyboard regression that fails under the previous policy.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Re-home the analytics-only keyboard work from AprilNEA#718 onto the
current host-switch architecture while preserving Samuel Phinizy's authorship.
Make keyboard-initiated linked switching react promptly to receiver-exclusive
operations, handle keyboards that begin departing before normal probing or
cleanup completes, and prevent stale session completions from moving followers.
Changes
transitions explicitly, validate reported host slots, restore analytics
capture under a short latency budget, and move followers through their own
channels when the keyboard has already left.
wake capture managers immediately, preserve announcement-aware reconnect
capture, and reject obsolete session generations before they can start a
transition.
bounded analytics cleanup, exclusive-access wakeups, and stale completions.
Testing
cargo fmt --all -- --checkRUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets -- -D warningsRUSTFLAGS="-D warnings" cargo test --workspaceRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items --exclude openlogi-ui --exclude openlogi-desktop --exclude openlogi-overlay --exclude openlogi-agentRUSTFLAGS="-D warnings" cargo check -p openlogi-hidpp -p openlogi-device --target wasm32-unknown-unknowntyposwas not run locally becausetypos-cliis unavailable.OpenLogi/Options+ testing covered forward and reverse switching, but the
three-PC rapid-switch and sleeping-destination cases still need a clean
OpenLogi-only retest.
Continues AprilNEA#718
Depends on AprilNEA#970
Code and PR drafted with Codex