From 04920068131119dcf67b3514d62a8fdb23a1b919 Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Tue, 26 May 2026 15:09:32 -0700 Subject: [PATCH 1/4] feat: add leave_hook symmetric to enter_hook Per-client leave_hook fires sh -c 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) --- lan-mouse-cli/src/lib.rs | 7 +++++++ lan-mouse-ipc/src/lib.rs | 5 +++++ src/capture.rs | 30 +++++++++++++++++++++++++++++- src/client.rs | 16 ++++++++++++++++ src/config.rs | 6 ++++++ src/service.rs | 39 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 1 deletion(-) diff --git a/lan-mouse-cli/src/lib.rs b/lan-mouse-cli/src/lib.rs index 884e92898..13fed2ee3 100644 --- a/lan-mouse-cli/src/lib.rs +++ b/lan-mouse-cli/src/lib.rs @@ -35,6 +35,8 @@ struct Client { ips: Option>, #[arg(long)] enter_hook: Option, + #[arg(long)] + leave_hook: Option, } #[derive(Clone, Subcommand, Debug, PartialEq, Eq)] @@ -88,6 +90,7 @@ async fn execute(cmd: CliSubcommand) -> Result<(), CliError> { port, ips, enter_hook, + leave_hook, }) => { tx.request(FrontendRequest::Create).await?; while let Some(e) = rx.next().await { @@ -108,6 +111,10 @@ async fn execute(cmd: CliSubcommand) -> Result<(), CliError> { tx.request(FrontendRequest::UpdateEnterHook(handle, Some(enter_hook))) .await?; } + if let Some(leave_hook) = leave_hook { + tx.request(FrontendRequest::UpdateLeaveHook(handle, Some(leave_hook))) + .await?; + } break; } } diff --git a/lan-mouse-ipc/src/lib.rs b/lan-mouse-ipc/src/lib.rs index a2c71126d..16be89942 100644 --- a/lan-mouse-ipc/src/lib.rs +++ b/lan-mouse-ipc/src/lib.rs @@ -140,6 +140,8 @@ pub struct ClientConfig { pub pos: Position, /// enter hook pub cmd: Option, + /// leave hook + pub leave_cmd: Option, } impl Default for ClientConfig { @@ -150,6 +152,7 @@ impl Default for ClientConfig { fix_ips: Default::default(), pos: Default::default(), cmd: None, + leave_cmd: None, } } } @@ -253,6 +256,8 @@ pub enum FrontendRequest { RemoveAuthorizedKey(String), /// change the hook command UpdateEnterHook(u64, Option), + /// change the leave hook command + UpdateLeaveHook(u64, Option), /// save config file SaveConfiguration, } diff --git a/src/capture.rs b/src/capture.rs index 8f739bd2e..9ae1ba180 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -37,6 +37,13 @@ pub(crate) enum ICaptureEvent { /// either the remote client leaving its device region, /// a new device entering the screen or the release bind. ClientEntered(u64), + /// The previously active client was left, i.e. capture + /// was released for the given handle. Mirrors + /// [`ICaptureEvent::ClientEntered`] for the leave side + /// and fires on every release path (release-bind chord, + /// remote `Leave`, explicit `Release` request, send + /// failure, or destroy of the active capture). + ClientLeft(u64), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -301,6 +308,16 @@ impl CaptureTask { capture.create(h, p).await?; } CaptureRequest::Destroy(h) => { + // If the capture we're tearing down is the + // currently-active one, treat this as a + // release for hook purposes. The release_capture + // path also clears active_client and flushes + // pressed-key state to the peer; without this, + // `cli deactivate` (or a hostname change + // re-creating the client) would skip leave_hook. + if self.active_client == Some(h) { + self.release_capture(capture).await?; + } self.remove_capture(h); capture.destroy(h).await?; } @@ -368,7 +385,11 @@ impl CaptureTask { if let Err(e) = self.conn.send(event, handle).await { const DUR: Duration = Duration::from_millis(500); debounce!(PREV_LOG, DUR, log::warn!("releasing capture: {e}")); - capture.release().await?; + // Funnel through release_capture so the leave_hook + // 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?; } Ok(()) } @@ -376,6 +397,13 @@ impl CaptureTask { async fn release_capture(&mut self, capture: &mut InputCapture) -> Result<(), CaptureError> { // If we have an active client, notify them we're leaving if let Some(handle) = self.active_client.take() { + // Surface the leave to the service layer so it can fire + // the per-client leave_hook. Sent before the network + // teardown below so we never race against the peer + // disappearing. + self.event_tx + .send(ICaptureEvent::ClientLeft(handle)) + .expect("channel closed"); // Synthesize key-up events for every key still held in the // capture's pressed_keys set BEFORE sending Leave. Without // this, pressing the release-bind chord (typically all four diff --git a/src/client.rs b/src/client.rs index 3229c8c6a..5d4de6aa4 100644 --- a/src/client.rs +++ b/src/client.rs @@ -33,6 +33,7 @@ impl ClientManager { port: config_client.port, pos: config_client.pos, cmd: config_client.enter_hook, + leave_cmd: config_client.leave_hook, }; let state = ClientState { active: config_client.active, @@ -236,6 +237,13 @@ impl ClientManager { } } + /// update the leave hook command of the client + pub(crate) fn set_leave_hook(&self, handle: ClientHandle, leave_hook: Option) { + if let Some((c, _s)) = self.clients.borrow_mut().get_mut(handle as usize) { + c.leave_cmd = leave_hook; + } + } + /// set resolving status of the client pub(crate) fn set_resolving(&self, handle: ClientHandle, status: bool) { if let Some((_, s)) = self.clients.borrow_mut().get_mut(handle as usize) { @@ -251,6 +259,14 @@ impl ClientManager { .and_then(|(c, _)| c.cmd.clone()) } + /// get the leave hook command + pub(crate) fn get_leave_cmd(&self, handle: ClientHandle) -> Option { + self.clients + .borrow() + .get(handle as usize) + .and_then(|(c, _)| c.leave_cmd.clone()) + } + /// returns all clients that are currently registered pub(crate) fn registered_clients(&self) -> Vec { self.clients diff --git a/src/config.rs b/src/config.rs index 3faa59bfe..55b8c0409 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,6 +67,7 @@ struct TomlClient { position: Option, activate_on_startup: Option, enter_hook: Option, + leave_hook: Option, } impl ConfigToml { @@ -262,12 +263,14 @@ pub struct ConfigClient { pub pos: Position, pub active: bool, pub enter_hook: Option, + pub leave_hook: Option, } impl From for ConfigClient { fn from(toml: TomlClient) -> Self { let active = toml.activate_on_startup.unwrap_or(false); let enter_hook = toml.enter_hook; + let leave_hook = toml.leave_hook; let hostname = toml.hostname; let ips = HashSet::from_iter(toml.ips.into_iter().flatten()); let port = toml.port.unwrap_or(DEFAULT_PORT); @@ -279,6 +282,7 @@ impl From for ConfigClient { pos, active, enter_hook, + leave_hook, } } } @@ -298,6 +302,7 @@ impl From for TomlClient { let position = Some(client.pos); let activate_on_startup = if client.active { Some(true) } else { None }; let enter_hook = client.enter_hook; + let leave_hook = client.leave_hook; Self { hostname, host_name, @@ -306,6 +311,7 @@ impl From for TomlClient { position, activate_on_startup, enter_hook, + leave_hook, } } } diff --git a/src/service.rs b/src/service.rs index d0772f660..df119861d 100644 --- a/src/service.rs +++ b/src/service.rs @@ -217,6 +217,9 @@ impl Service { FrontendRequest::UpdateEnterHook(handle, enter_hook) => { self.update_enter_hook(handle, enter_hook) } + FrontendRequest::UpdateLeaveHook(handle, leave_hook) => { + self.update_leave_hook(handle, leave_hook) + } FrontendRequest::SaveConfiguration => self.save_config(), } } @@ -232,6 +235,7 @@ impl Service { pos: c.pos, active: s.active, enter_hook: c.cmd, + leave_hook: c.leave_cmd, }) .collect(); self.config.set_clients(clients); @@ -343,6 +347,10 @@ impl Service { log::info!("entering client {handle} ..."); self.spawn_hook_command(handle); } + ICaptureEvent::ClientLeft(handle) => { + log::info!("leaving client {handle} ..."); + self.spawn_leave_hook_command(handle); + } } } @@ -565,6 +573,11 @@ impl Service { self.broadcast_client(handle); } + fn update_leave_hook(&mut self, handle: ClientHandle, leave_hook: Option) { + self.client_manager.set_leave_hook(handle, leave_hook); + self.broadcast_client(handle); + } + fn broadcast_client(&mut self, handle: ClientHandle) { let event = self .client_manager @@ -599,4 +612,30 @@ impl Service { } }); } + + 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}"), + } + }); + } } From fa50a41f114cf582c103462ab04de99f70fff851 Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Tue, 26 May 2026 16:33:55 -0700 Subject: [PATCH 2/4] docs: example enter_hook + leave_hook in config.toml The existing enter_hook field has been undocumented since it was added in #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) --- config.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config.toml b/config.toml index b74158d93..37eb382b7 100644 --- a/config.toml +++ b/config.toml @@ -32,3 +32,8 @@ hostname = "thorium" ips = ["192.168.178.189", "192.168.178.172"] # optional port port = 4242 +# optional shell command run via `sh -c` when the cursor enters this client +enter_hook = "notify-send 'entered thorium'" +# optional shell command run via `sh -c` when capture for this client is +# released (release_bind chord, peer Leave, or `cli deactivate`) +leave_hook = "notify-send 'left thorium'" From 02d6ed12a4d4b02b244d20fc2e9c17d46be45d39 Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Tue, 26 May 2026 16:57:21 -0700 Subject: [PATCH 3/4] fix(ci): bust stale Homebrew glib cache on macOS runners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/rust.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e62f71082..dc782e860 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -40,7 +40,6 @@ jobs: - test steps: - uses: actions/checkout@v6 - - uses: Swatinem/rust-cache@v2 - name: Install Linux deps if: runner.os == 'Linux' run: | @@ -49,6 +48,13 @@ jobs: - name: Install macOS dependencies if: runner.os == 'macOS' run: brew install gtk4 libadwaita imagemagick + - name: Record macOS native lib versions for cache key + if: runner.os == 'macOS' + run: | + echo "MACOS_LIB_VER=$(brew list --versions glib gtk4 libadwaita | tr '\n' '_')" >> "$GITHUB_ENV" + - uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ env.MACOS_LIB_VER }} - name: Install Windows Dependencies - create gtk dir if: runner.os == 'Windows' run: mkdir C:\gtk-build\gtk\x64\release From 3313b2e728d491e988ae770d5855b0707eead577 Mon Sep 17 00:00:00 2001 From: Ty Smith Date: Wed, 27 May 2026 12:46:07 -0700 Subject: [PATCH 4/4] refactor: collapse enter/leave hook spawning into shared helper 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) --- src/service.rs | 59 +++++++++++++++++++++----------------------------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/src/service.rs b/src/service.rs index df119861d..40af0c0be 100644 --- a/src/service.rs +++ b/src/service.rs @@ -345,11 +345,11 @@ impl Service { } ICaptureEvent::ClientEntered(handle) => { log::info!("entering client {handle} ..."); - self.spawn_hook_command(handle); + self.spawn_hook_command(handle, HookKind::Enter); } ICaptureEvent::ClientLeft(handle) => { log::info!("leaving client {handle} ..."); - self.spawn_leave_hook_command(handle); + self.spawn_hook_command(handle, HookKind::Leave); } } } @@ -587,55 +587,46 @@ impl Service { self.notify_frontend(event); } - fn spawn_hook_command(&self, handle: ClientHandle) { - let Some(cmd) = self.client_manager.get_enter_cmd(handle) else { - return; + fn spawn_hook_command(&self, handle: ClientHandle, kind: HookKind) { + let cmd = match kind { + HookKind::Enter => self.client_manager.get_enter_cmd(handle), + HookKind::Leave => self.client_manager.get_leave_cmd(handle), }; + let Some(cmd) = cmd else { return }; tokio::task::spawn_local(async move { - log::info!("spawning command!"); + log::info!("spawning {kind} hook for client {handle}"); let mut child = match Command::new("sh").arg("-c").arg(cmd.as_str()).spawn() { Ok(c) => c, Err(e) => { - log::warn!("could not execute cmd: {e}"); + log::warn!("could not execute {kind} hook for client {handle}: {e}"); return; } }; match child.wait().await { Ok(s) => { if s.success() { - log::info!("{cmd} exited successfully"); + log::info!("{kind} hook for client {handle} ({cmd}) exited successfully"); } else { - log::warn!("{cmd} exited with {s}"); + log::warn!("{kind} hook for client {handle} ({cmd}) exited with {s}"); } } - Err(e) => log::warn!("{cmd}: {e}"), + Err(e) => log::warn!("{kind} hook for client {handle} ({cmd}): {e}"), } }); } +} - 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}"), - } - }); +#[derive(Clone, Copy, Debug)] +enum HookKind { + Enter, + Leave, +} + +impl std::fmt::Display for HookKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HookKind::Enter => f.write_str("enter"), + HookKind::Leave => f.write_str("leave"), + } } }