feat: add leave_hook symmetric to enter_hook#448
Conversation
Per-client leave_hook fires sh -c <cmd> when capture for that client is released. Wired through a single release_capture() chokepoint covering release_bind, network send failure, Destroy-of-active-client, and Release request paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing enter_hook field has been undocumented since it was added in feschber#130. Add it alongside the new leave_hook to the example config so both per-client hooks are discoverable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds support for per-client “leave hooks” that run when capture is released for a client, wiring the new setting through config, IPC, CLI, and capture/service event handling.
Changes:
- Introduce
leave_hook/leave_cmdin config and IPC models, plus CLI flag support. - Emit a new
ICaptureEvent::ClientLefton release paths and handle it in the service. - Execute configured leave commands via
sh -cwhen a client is left.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/service.rs | Handle UpdateLeaveHook, persist leave_hook, and spawn leave-hook commands on ClientLeft. |
| src/config.rs | Add leave_hook to TOML and runtime config conversion paths. |
| src/client.rs | Store and expose per-client leave_cmd in ClientManager. |
| src/capture.rs | Add ClientLeft event and ensure release paths trigger it. |
| lan-mouse-ipc/src/lib.rs | Extend IPC structs/enums with leave-hook fields and requests. |
| lan-mouse-cli/src/lib.rs | Add --leave-hook flag and send UpdateLeaveHook on create. |
| config.toml | Document and example-configure leave_hook. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| self.event_tx | ||
| .send(ICaptureEvent::ClientLeft(handle)) | ||
| .expect("channel closed"); |
There was a problem hiding this comment.
Leaving this as-is to stay consistent with the surrounding pattern: the two pre-existing event_tx.send(...).expect("channel closed") sites (CaptureBegin at L350 and ClientEntered at L371) use the same idiom, and request_rx.recv().expect("channel closed") at L303 follows the same convention. The event_tx receiver lives in the service task that owns the capture task — if it's gone the daemon is already tearing down. Changing only the new site would create a one-of-four inconsistency; if we want to soften this it should be a separate refactor across all four call sites.
| // fires and active_client is cleared (without this the | ||
| // active_client field would stay stale until the next | ||
| // Begin from a different handle). | ||
| self.release_capture(capture).await?; |
There was a problem hiding this comment.
The error-propagation surface doesn't actually change here. Inside release_capture() everything is either infallible-or-logged (the event_tx.send panics on closed channel like its siblings; the conn.send calls for key-up/modifier-reset/Leave all use if let Err(e) = ... { log::warn!(...) } — they never propagate). The only ?-propagated error is the final capture.release().await at the end, which is exactly what the old capture.release().await? returned. So this site propagates the same CaptureError as before — it just additionally fires the leave_hook and clears active_client along the way, both of which we want in this code path.
| fn spawn_leave_hook_command(&self, handle: ClientHandle) { | ||
| let Some(cmd) = self.client_manager.get_leave_cmd(handle) else { | ||
| return; | ||
| }; | ||
| tokio::task::spawn_local(async move { | ||
| log::info!("spawning leave command!"); | ||
| let mut child = match Command::new("sh").arg("-c").arg(cmd.as_str()).spawn() { | ||
| Ok(c) => c, | ||
| Err(e) => { | ||
| log::warn!("could not execute leave cmd: {e}"); | ||
| return; | ||
| } | ||
| }; | ||
| match child.wait().await { | ||
| Ok(s) => { | ||
| if s.success() { | ||
| log::info!("{cmd} exited successfully"); | ||
| } else { | ||
| log::warn!("{cmd} exited with {s}"); | ||
| } | ||
| } | ||
| Err(e) => log::warn!("{cmd}: {e}"), | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
Done in 3313b2e — collapsed both into a single spawn_hook_command(handle, HookKind) with the kind carrying the human label into log messages (which now also include the client handle for both enter and leave).
The Swatinem/rust-cache action was restored before brew installed the
native dependencies. When Homebrew updated glib (2.88.0 → newer) the
cached Rust build artifacts still referenced the old versioned Cellar
path (/opt/homebrew/Cellar/glib/2.88.0/lib), causing a linker failure:
ld: library 'gio-2.0' not found
Fix by:
1. Moving `brew install` before the rust-cache step so libs are current
before the cache is consulted.
2. Capturing `brew list --versions glib gtk4 libadwaita` into a
prefix-key so the cache key changes whenever those packages update,
forcing a clean Rust build with fresh pkg-config paths.
Linux and Windows cache behaviour is unchanged (MACOS_LIB_VER unset →
empty prefix-key → same default v0- prefix).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Reference implementation for hook driven input changes Add to ~/.config/lan-mouse/config.toml Script that swaps super/alt & inverts scrolling for Hyprland host and MacOS client |
The two spawn_*_hook_command functions were ~35 lines of near-byte- identical glue (only the get_*_cmd call and log prefixes differed). Replace with a single spawn_hook_command(handle, HookKind) and let the kind carry the human label into log lines (which now also include the client handle). No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Mirrors the existing
enter_hookmechanism with aleave_hookthat fires when capture for a client is released.Motivation: external scripts that change host state on cross (e.g. swapping keyboard layout, toggling natural scroll, repositioning windows) currently have no symmetric "you're back" signal —
enter_hookfires deterministically but the only way to detect release today is to pollhyprctl cursorpos/ similar, which is fragile because the local cursor isn't actually pinned during capture on Wayland.Design
A single new IPC capture event
ICaptureEvent::ClientLeft(handle)emitted fromrelease_capture()insrc/capture.rs. Every release path funnels through that function:Leaveevent over the wireCaptureRequest::Destroywhen the active client is being torn down (with an explicit note about hostname-change client recreation)CaptureRequest::ReleaseWiring otherwise exactly mirrors
enter_hook:src/config.rsenter_hook: Option<String>leave_hook: Option<String>lan-mouse-ipc/src/lib.rsenter_cmdonClientConfig,UpdateEnterHookrequestleave_cmd,UpdateLeaveHooksrc/client.rsset_enter_hook/get_enter_cmdset_leave_hook/get_leave_cmdsrc/service.rsupdate_enter_hook,spawn_hook_command, dispatch onClientEnteredupdate_leave_hook,spawn_leave_hook_command, dispatch onClientLeftlan-mouse-cli/src/lib.rs--enter-hookflag onadd-client--leave-hookflagDocs
The existing
enter_hookhas been undocumented since #130. Second commit backfills both in the exampleconfig.tomlso the per-client hook surface is discoverable.Out of scope
lan-mouse-gtkfor the new field (text-config only for now)notify-sendhooks across crossing + release_bind release on Wayland/Hyprland)Test plan
cargo build --release(1m 20s, no warnings)cargo clippy --all-targets --release -- -D warningscleanenter_hookandleave_hookset tonotify-sendstrings, daemon restarted from built binary, cross to client and release via chord — both notifications fire as expectedLeave(cross-back from the remote side)cli deactivateof the active client