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
276 changes: 276 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ panic = "abort"

[build-dependencies]
shadow-rs = "1.2.0"
# compiles everywhere, only does work when targeting windows; kept
# unconditional because target-gated build-dependencies break when
# cross-compiling from windows to other platforms
winresource = "0.1"

[dependencies]
input-event = { path = "input-event", version = "0.4.0" }
Expand Down Expand Up @@ -71,6 +75,9 @@ notify = "8.2.0"
[target.'cfg(unix)'.dependencies]
libc = "0.2.148"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_System_Console"] }

[features]
default = [
"gtk",
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,14 @@ It is of the form "aa:bb:cc:..."
Authorized devices can be persisted using the configuration file (see [Configuration](#configuration)).

If the device still can not be entered, make sure you have UDP port `4242` (or the one selected) opened up in your firewall.

#### Tray icon (Windows)

On Windows, Lan Mouse runs as a notification-area application:
Closing the window only hides it and Lan Mouse keeps running in the tray,
where the icon offers a menu to re-open the window or quit the app entirely.
Launching `lan-mouse` again opens a separate window.
Set `LAN_MOUSE_HIDDEN=1` in the environment to start quietly into the tray without opening the window.
</details>

<details>
Expand Down
Binary file added build-aux/windows/lan-mouse.ico
Binary file not shown.
17 changes: 17 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ fn main() {
.build()
.expect("shadow build");

embed_windows_icon();

let unix = cfg!(unix);
let macos = cfg!(target_os = "macos");

Expand Down Expand Up @@ -59,3 +61,18 @@ fn main() {
println!("cargo::rustc-cfg=x11_emulation");
}
}

/// Embed the app icon into the executable so Explorer, the Start menu
/// and pinned taskbar shortcuts display it. Decided by the TARGET
/// platform env var, not cfg!(), which describes the build script's
/// host here.
fn embed_windows_icon() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
println!("cargo::rerun-if-changed=build-aux/windows/lan-mouse.ico");
winresource::WindowsResource::new()
.set_icon("build-aux/windows/lan-mouse.ico")
.compile()
.expect("embed windows icon resource");
}
5 changes: 5 additions & 0 deletions lan-mouse-gtk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,10 @@ log = "0.4.20"
lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.3.0" }
thiserror = "2.0.0"

[target.'cfg(windows)'.dependencies]
# default features (gtk, libxdo) only apply to the linux backend, which
# is unreachable behind this windows-only target dependency
tray-icon = { version = "0.24.1", default-features = false }

[build-dependencies]
glib-build-tools = { version = "0.20.0" }
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions lan-mouse-gtk/resources/resources.gresource.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@
</gresource>
<gresource prefix="/de/feschber/LanMouse/icons">
<file compressed="true" preprocess="xml-stripblanks">de.feschber.LanMouse.svg</file>
<!--
Pre-rendered rasters of the app icon in hicolor layout, so that
the icon name "de.feschber.LanMouse" set on the window resolves
through IconTheme::add_resource_path on platforms without a
system-installed icon theme (Windows, AppImage). Deliberately
PNG: its decoder is built into gdk-pixbuf, while SVG needs the
external librsvg loader module, which distributed builds may
lack.
-->
<file alias="16x16/apps/de.feschber.LanMouse.png">icons/16x16/apps/de.feschber.LanMouse.png</file>
<file alias="32x32/apps/de.feschber.LanMouse.png">icons/32x32/apps/de.feschber.LanMouse.png</file>
<file alias="48x48/apps/de.feschber.LanMouse.png">icons/48x48/apps/de.feschber.LanMouse.png</file>
<file alias="64x64/apps/de.feschber.LanMouse.png">icons/64x64/apps/de.feschber.LanMouse.png</file>
<file alias="128x128/apps/de.feschber.LanMouse.png">icons/128x128/apps/de.feschber.LanMouse.png</file>
<file alias="256x256/apps/de.feschber.LanMouse.png">icons/256x256/apps/de.feschber.LanMouse.png</file>
<!--
Bundled Adwaita symbolic icons so the GTK frontend has a complete icon set
on platforms (notably macOS) where the Adwaita icon theme is not installed.
Expand Down
29 changes: 28 additions & 1 deletion lan-mouse-gtk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ mod macos_privacy;
#[cfg(target_os = "macos")]
mod macos_status_item;
mod window;
#[cfg(windows)]
mod windows_status_item;

use std::{env, process, str, sync::OnceLock};

Expand Down Expand Up @@ -260,6 +262,23 @@ fn build_ui(app: &Application) {
});
}

// Mirror macOS: the daemon is always a child of this process on
// Windows (no external-daemon detection), so the tray is set up
// unconditionally. Only if tray creation actually fails does the
// window keep the default close-means-quit behavior, since a
// hidden window would otherwise be unreachable.
#[cfg(windows)]
let tray_active = {
let tray_active = windows_status_item::setup(app, &window);
if tray_active {
window.connect_close_request(|window| {
window.set_visible(false);
glib::Propagation::Stop
});
}
tray_active
};

glib::spawn_future_local(clone!(
#[weak]
window,
Expand Down Expand Up @@ -307,7 +326,15 @@ fn build_ui(app: &Application) {
}
));

#[cfg(not(target_os = "macos"))]
// Present on every launch unless
// `LAN_MOUSE_HIDDEN=1` requests a quiet start into the tray.
// Without a tray the window is always presented.
#[cfg(windows)]
if !tray_active || env::var_os("LAN_MOUSE_HIDDEN").is_none() {
window.present();
}

#[cfg(all(not(windows), not(target_os = "macos")))]
window.present();

// On macOS, default to presenting the main window on every launch
Expand Down
210 changes: 210 additions & 0 deletions lan-mouse-gtk/src/windows_status_item.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//! System tray (notification area) integration for Windows.
//!
//! Counterpart to `macos_status_item` / `linux_status_item`: registers a
//! tray icon with an "Open / Quit" menu so the main window can close
//! into the tray instead of quitting the application. Uses the
//! `tray-icon` crate (`Shell_NotifyIconW` under the hood), which also
//! re-adds the icon after an explorer.exe restart (TaskbarCreated
//! broadcast).
//!
//! Must be called on the GTK thread: the tray's hidden message window
//! is created on the calling thread and its WndProc is serviced by the
//! win32 message pump inside the GLib main loop.

use std::cell::RefCell;

use adw::prelude::*;
use gtk::{gdk_pixbuf, gio, glib, glib::clone};
use tray_icon::{
Icon, MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent,
menu::{IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem},
};

use crate::window::Window;

const APP_ICON_RESOURCE: &str = "/de/feschber/LanMouse/icons/de.feschber.LanMouse.svg";

/// Raster fallback: PNG decoding is built into gdk-pixbuf, while SVG
/// needs the external librsvg loader module, which distributed builds
/// may lack.
const APP_ICON_RESOURCE_PNG: &str =
"/de/feschber/LanMouse/icons/128x128/apps/de.feschber.LanMouse.png";

/// 32 px stays crisp at the common 100–200% DPI scales; Windows scales
/// down to the actual small-icon metric as needed.
const ICON_SIZE: i32 = 32;

enum TrayEvent {
Open,
Quit,
}

struct StatusItem {
/// Keeps the application running while no window is visible.
_hold: gio::ApplicationHoldGuard,
/// Dropping the `TrayIcon` removes the icon from the notification
/// area, so it lives here for the lifetime of the app.
_tray_icon: TrayIcon,
}

thread_local! {
static STATUS_ITEM: RefCell<Option<StatusItem>> = const { RefCell::new(None) };
}

fn forward(tx: &async_channel::Sender<TrayEvent>, event: TrayEvent) {
// The tray handlers run inside the WndProc on the GTK thread — the
// same thread that drains the channel — so blocking on a full
// channel would deadlock. Dropping an event on overflow is fine:
// both events are idempotent user actions.
if tx.try_send(event).is_err() {
log::warn!("tray event dropped: frontend channel closed or full");
}
}

/// Set up the tray icon. Returns whether it was registered; on `false`
/// the caller must keep the default close-means-quit behavior, or the
/// application would become unreachable once the window is hidden.
pub fn setup(app: &adw::Application, window: &Window) -> bool {
let already_initialized = STATUS_ITEM.with(|item| item.borrow().is_some());
if already_initialized {
return true;
}

let Some(icon) = render_icon_rgba(ICON_SIZE) else {
log::warn!("failed to render tray icon, closing the window will quit");
return false;
};

let menu = Menu::new();
let open_item = MenuItem::new("Open Lan Mouse", true, None);
let quit_item = MenuItem::new("Quit Lan Mouse", true, None);
if let Err(e) = menu.append_items(&[
&open_item as &dyn IsMenuItem,
&PredefinedMenuItem::separator(),
&quit_item,
]) {
log::warn!("failed to build tray menu ({e}), closing the window will quit");
return false;
}

let (tx, rx) = async_channel::bounded(4);

// The handlers must be installed before build(): events dispatch
// from the tray's WndProc as soon as the icon exists. They require
// `Send + Sync`, so they cannot capture GTK objects — forward
// through the channel to the main-loop drain below instead.
let open_id = open_item.id().clone();
let quit_id = quit_item.id().clone();
let menu_tx = tx.clone();
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
if *event.id() == open_id {
forward(&menu_tx, TrayEvent::Open);
} else if *event.id() == quit_id {
forward(&menu_tx, TrayEvent::Quit);
}
}));
// Single left click opens the window — same as SNI `activate` on
// Linux. DoubleClick is redundant with that (the first click
// already opened) and is ignored.
TrayIconEvent::set_event_handler(Some(move |event: TrayIconEvent| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
forward(&tx, TrayEvent::Open);
}
}));

let tray_icon = match TrayIconBuilder::new()
.with_tooltip("Lan Mouse")
.with_icon(icon)
.with_menu(Box::new(menu))
// left click presents the window; the menu stays on right click
.with_menu_on_left_click(false)
.build()
{
Ok(tray_icon) => tray_icon,
Err(e) => {
log::warn!("no system tray available ({e}), closing the window will quit");
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
TrayIconEvent::set_event_handler(None::<fn(TrayIconEvent)>);
return false;
}
};
log::debug!("windows_status_item registered");

STATUS_ITEM.with(|item| {
item.replace(Some(StatusItem {
_hold: app.hold(),
_tray_icon: tray_icon,
}));
});

glib::spawn_future_local(clone!(
#[weak]
app,
#[weak]
window,
async move {
while let Ok(event) = rx.recv().await {
match event {
TrayEvent::Open => window.present(),
TrayEvent::Quit => app.quit(),
}
}
}
));

true
}

/// Render the bundled app icon into packed RGBA8 for
/// [`Icon::from_rgba`]: the SVG for crispness where the loader is
/// available, the pre-rendered PNG otherwise.
fn render_icon_rgba(size: i32) -> Option<Icon> {
[APP_ICON_RESOURCE, APP_ICON_RESOURCE_PNG]
.iter()
.find_map(|resource| render_resource_rgba(resource, size))
}

fn render_resource_rgba(resource: &str, size: i32) -> Option<Icon> {
let pixbuf = match gdk_pixbuf::Pixbuf::from_resource_at_scale(resource, size, size, true) {
Ok(pixbuf) => pixbuf,
Err(e) => {
log::debug!("failed to render tray icon {resource} at {size}px: {e}");
return None;
}
};

let width = pixbuf.width();
let height = pixbuf.height();
let rowstride = pixbuf.rowstride() as usize;
let n_channels = pixbuf.n_channels() as usize;
if pixbuf.bits_per_sample() != 8 || !(3..=4).contains(&n_channels) {
log::warn!("unexpected pixbuf format for tray icon");
return None;
}
let bytes = pixbuf.read_pixel_bytes();

// pixbuf rows are RGB(A) with rowstride padding; from_rgba wants
// packed RGBA8.
let mut data = Vec::with_capacity(width as usize * height as usize * 4);
for row in 0..height as usize {
let row = &bytes[row * rowstride..];
for pixel in 0..width as usize {
let pixel = &row[pixel * n_channels..][..n_channels];
let alpha = if n_channels == 4 { pixel[3] } else { 0xff };
data.extend_from_slice(&[pixel[0], pixel[1], pixel[2], alpha]);
}
}

match Icon::from_rgba(data, width as u32, height as u32) {
Ok(icon) => Some(icon),
Err(e) => {
log::warn!("failed to create tray icon: {e}");
None
}
}
}
Loading