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 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'" 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..40af0c0be 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); @@ -341,7 +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_hook_command(handle, HookKind::Leave); } } } @@ -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 @@ -574,29 +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}"), } }); } } + +#[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"), + } + } +}