Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
7 changes: 7 additions & 0 deletions lan-mouse-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ struct Client {
ips: Option<Vec<IpAddr>>,
#[arg(long)]
enter_hook: Option<String>,
#[arg(long)]
leave_hook: Option<String>,
}

#[derive(Clone, Subcommand, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}
}
Expand Down
5 changes: 5 additions & 0 deletions lan-mouse-ipc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ pub struct ClientConfig {
pub pos: Position,
/// enter hook
pub cmd: Option<String>,
/// leave hook
pub leave_cmd: Option<String>,
}

impl Default for ClientConfig {
Expand All @@ -150,6 +152,7 @@ impl Default for ClientConfig {
fix_ips: Default::default(),
pos: Default::default(),
cmd: None,
leave_cmd: None,
}
}
}
Expand Down Expand Up @@ -253,6 +256,8 @@ pub enum FrontendRequest {
RemoveAuthorizedKey(String),
/// change the hook command
UpdateEnterHook(u64, Option<String>),
/// change the leave hook command
UpdateLeaveHook(u64, Option<String>),
/// save config file
SaveConfiguration,
}
Expand Down
30 changes: 29 additions & 1 deletion src/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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?;
}
Expand Down Expand Up @@ -368,14 +385,25 @@ 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?;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

}
Ok(())
}

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");
Comment on lines +404 to +406
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

// 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
Expand Down
16 changes: 16 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>) {
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) {
Expand All @@ -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<String> {
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<ClientHandle> {
self.clients
Expand Down
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ struct TomlClient {
position: Option<Position>,
activate_on_startup: Option<bool>,
enter_hook: Option<String>,
leave_hook: Option<String>,
}

impl ConfigToml {
Expand Down Expand Up @@ -262,12 +263,14 @@ pub struct ConfigClient {
pub pos: Position,
pub active: bool,
pub enter_hook: Option<String>,
pub leave_hook: Option<String>,
}

impl From<TomlClient> 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);
Expand All @@ -279,6 +282,7 @@ impl From<TomlClient> for ConfigClient {
pos,
active,
enter_hook,
leave_hook,
}
}
}
Expand All @@ -298,6 +302,7 @@ impl From<ConfigClient> 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,
Expand All @@ -306,6 +311,7 @@ impl From<ConfigClient> for TomlClient {
position,
activate_on_startup,
enter_hook,
leave_hook,
}
}
}
Expand Down
48 changes: 39 additions & 9 deletions src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -565,6 +573,11 @@ impl Service {
self.broadcast_client(handle);
}

fn update_leave_hook(&mut self, handle: ClientHandle, leave_hook: Option<String>) {
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
Expand All @@ -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"),
}
}
}
Loading