Compare commits

..

100 Commits

Author SHA1 Message Date
Ferdinand Schober
ac5ba3da37 Merge branch 'main' into move-feature-flags-to-build-script 2026-05-16 17:09:29 +02:00
Ferdinand Schober
1fa3800d3c windows: fix clippy lints 2026-05-16 17:09:06 +02:00
Ferdinand Schober
743895516c same for input-emulation 2026-05-16 12:23:26 +02:00
Ferdinand Schober
b65ca44ea2 move feature flags to build.rs 2026-05-16 12:16:09 +02:00
Jon Kinney
3e7b04c184 deps: downgrade libadwaita feature flag to v1_1
Per maintainer review feedback. The bump to v1_2 in commit
a45e00e was unnecessary — none of the macOS menubar work uses
a libadwaita 1.2-only API (no AdwMessageDialog, AdwAboutWindow,
AdwBanner, etc.). Drop the floor back to v1.1 so distros and
flake users on slightly older libadwaita aren't pushed forward
just to run the GTK frontend.

Verified with cargo clean -p libadwaita followed by cargo check
-p lan-mouse-gtk: libadwaita rebuilds with v1_1 features only
and the workspace compiles, clippy-clean, tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
f252567ef9 chore: silence two pre-existing clippy lints
- macos_privacy.rs: drop the redundant inner #![cfg(target_os
  = "macos")]; lib.rs already gates the mod declaration with
  #[cfg(target_os = "macos")] (clippy::duplicated_attributes).
- macos_status_item.rs: inline status_item into the format
  string (clippy::uninlined_format_args).

No behavior change. Brings `cargo clippy --workspace
--all-targets --all-features -- -D warnings` back to clean.
2026-04-29 22:59:43 +02:00
Jon Kinney
53c668b355 macos: re-present window on app re-launch
Closing the menubar window hides it; re-launching Lan Mouse.app
via Finder, `open`, or the Dock should bring the window back —
but it didn't, because the kAEReopenApplication Apple Event was
silently dropped. NSApp's default 'aevt'/'rapp' handler funnels
the event into applicationShouldHandleReopen:hasVisibleWindows:,
and GtkApplication owns NSApp's delegate without implementing
that selector.

Register the existing status-item delegate as a direct handler
for 'aevt'/'rapp' via NSAppleEventManager. setEventHandler:
replaces NSApplication's default, so we receive the event in
our code and re-present the window plus call
activateIgnoringOtherApps:. Extract a present_window() helper
so the menubar's "Open Lan Mouse" item and the new re-open
handler share one code path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
e5862e10e3 style: apply cargo fmt
No behavior changes. Brings three files back in line with the
project's `style_edition = "2024"` rustfmt config so subsequent
edits don't carry unrelated formatting in their diffs.
2026-04-29 22:59:43 +02:00
Jon Kinney
e863cdb801 macos: refresh display bounds on reconfiguration
InputCaptureState fetches the active-display bounds once in new()
via CGDisplay::active_displays(), and crossed() / start_capture()
both consult those frozen bounds for every barrier check and
cursor warp. Plug in a monitor, change resolution, or rearrange
displays in System Settings and the bounds go stale immediately:
the cursor either stops crossing at the new edge or warps to the
old edge coordinates.

Register a Quartz CGDisplayRegisterReconfigurationCallback on the
event-tap thread's CFRunLoop and route a new
ProducerEvent::DisplayReconfigured into the existing producer
channel. The producer task re-runs update_bounds() on the live
state, so subsequent barrier checks use the current geometry.

The callback fires twice per change — once with the
kCGDisplayBeginConfigurationFlag (BEFORE the bounds update) and
once after — we filter the begin-phase out and only refresh on
the post-change notification. The callback registration is
removed and the leaked sender Box is reclaimed when the run loop
exits, so create/destroy cycles don't leak channel senders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
bb1cc805c1 macos: opt out of App Nap via NSAppSleepDisabled
LSUIElement processes are prime App Nap candidates when idle, and
once App-Napped macOS throttles user-space timers. The ping_pong
keepalive loop in src/connect.rs uses tokio sleeps with a 2 s
ping cycle; if those sleeps slip past the peer's 1 s grace the
peer's watchdog tears down the DTLS connection and the daemon
reconnects from a fresh ephemeral port on the next event — the
"silence-then-new-source-port" pattern visible in the peer's logs
after long idle.

Setting NSAppSleepDisabled keeps the timers running on schedule
across long idle windows. No code-side changes; the key is
honored by macOS for the lifetime of the process.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
3b4b3a51aa macos: re-enable CGEventTap on tap timeout
The kernel disables a session-level CGEventTap when its callback
runs longer than ~1 s on a single event — typical causes are heavy
load, scheduler contention, or the process being briefly suspended
(App Nap on a long idle, debugger pause). It is not a fatal
condition: Apple's documented recovery is to call CGEventTapEnable
and resume processing. Before this change the tap stayed dead until
the user manually clicked Re-enable from the menubar.

Stash the tap's mach port pointer in an Arc<OnceLock<usize>> set
immediately after CGEventTap::new returns, and on
TapDisabledByTimeout call CGEventTapEnable from the callback to
revive the tap while preserving capture state — the user doesn't
see the cursor pop back to the local screen mid-session for a
transient slow callback.

TapDisabledByUserInput keeps the existing teardown path: those
causes (TCC Accessibility revoked mid-session, secure-input mode,
explicit kill) are not safely recoverable from inside the
callback, and the existing fallthrough-fix from
59d9e45 / d1e963e still applies there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
373e382152 fix(capture): release peer keys on release-bind
When the user pressed the release-bind chord (typically all four
modifiers) the down events for the chord were forwarded to the peer
while capture was active, but the matching up events arrived after
the local tap flipped to passthrough and were never forwarded. The
peer was left with phantom held modifiers until either its watchdog
ran (1+ s) or the Leave message was processed — and Leave is sent
over UDP/DTLS and can be lost.

Drain the capture's pressed_keys set in release_capture and emit a
KeyboardEvent::Key{state: 0} for every still-held key, plus a
zeroed KeyboardEvent::Modifiers, before sending Leave. The receiver
already maintains pressed_keys per handle and processes these
key-up events through its normal path, so no protocol change is
required and an unmodified peer picks up the fix automatically.

The receiver-side release_keys safety net stays in place for the
genuine packet-loss / disconnect-without-Leave cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
10fd728804 macos: default to showing the window on every launch
Replaces the narrow LAN_MOUSE_RELAUNCHED signal with an inverted
opt-out: present the main window on every macOS launch unless
LAN_MOUSE_HIDDEN=1 is in the environment.

User feedback: on any manual launch (Dock, Finder, `open`, post-
grant relaunch) the window should come up so the user has a visible
confirmation the app is alive and can see its current state. A
hidden-to-menu-bar-only launch should be opt-in for a LaunchAgent /
login-item configuration, not the default.

LaunchAgent plists can set the env via `EnvironmentVariables` and
`RunAtLoad=true` for a quiet boot launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
99344a3104 macos: present the window on the post-grant relaunch
After the user clicks Relaunch on the warning row, the new instance
started hidden in the menu bar — no visible confirmation that the
relaunch actually worked. Present the window on that specific
launch so the user sees the app come up healthy.

Mechanism: relaunch_bundle() sets LAN_MOUSE_RELAUNCHED=1 via
`open --env` when spawning the new instance. build_ui reads the
env var and calls window.present() only when it's set. Normal
fresh launches (from Finder / Dock / Launchpad / any other
Launch Services path) continue to start hidden in the menu bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
94e9301e9c macos: quit immediately when Accessibility is revoked mid-session
Even with the earlier event-tap-callback cleanup fix, revoking
Accessibility in System Settings while Lan Mouse was running with
an active capture tap could still leave system input wedged — clicks
and keypresses silently dropped until the app was force-quit.

The reliable fix is to not rely on in-process tap teardown at all.
When AX is revoked:

- The kernel guarantees an active CGEventTap is dismantled when the
  owning process exits.
- SIGINT+wait on the daemon child (main.rs already does this on
  GUI exit) drops the daemon's tap and restores the cursor.

So: continuously poll AX state (1-second GLib timer, replacing the
one-shot grant watcher), and on a revoke transition call
`app.quit()`. Input is restored within ~1-2 seconds regardless of
capture state — no force-quit required, no stuck cursor, no silently
consumed events beyond the brief window until the poller fires.

The grant-transition case is preserved: on a 0→1 flip the warning
row swaps to its "relaunch required" state, same as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
07cc40f6ba fix(input-capture): don't drop events after TCC Accessibility revocation
When the user revokes Accessibility in System Settings while Lan
Mouse is in a captured session (cursor on a remote client), the
session-level event tap receives `TapDisabledByUserInput`. The
previous flow was:

  1. Callback sends `ProducerEvent::EventTapDisabled` to notify_tx.
  2. Callback falls through the `current_pos.is_some()` branch and
     returns `CallbackResult::Drop` — *this very event*, plus any
     racing callback still in flight, get `set_type(Null)`'d and
     consumed.
  3. Outer task calls `handle_producer_event(..).unwrap_or_else(|e|
     log::error!(..))` — the `EventTapDisabled` error is just logged,
     the loop keeps running, `current_pos` stays `Some`, cursor
     stays hidden.

Net effect for the user: mouse motion keeps working (pass-through
when the tap is fully dead), but clicks and keypresses in the brief
disable window silently disappear, and the cursor is still hidden
where the captured session left it. Input looks broken until the
app is force-quit.

Fix:
- In the callback, when `TapDisabled*` fires, clear `current_pos`
  and `CGDisplay::show_cursor` synchronously, then return
  `CallbackResult::Keep` so this event (and any subsequent racing
  one) can't hit the drop branch.
- Mirror the cleanup in `handle_producer_event`'s
  `EventTapDisabled` arm so even if the outer task only logs the
  error, state is still released.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
5d7d14fbf7 macos: fold relaunch prompt into the warning row instead of a toast
The cut-off toast UX ("Accessibility granted. Relaunch Lan Mouse so
capture and emulat…") was unreadable in a compact window and split
the "grant" and "relaunch" flows into two disconnected surfaces. Fold
everything into the existing warning row with state-dependent content:

- AX missing:
    title    = "input capture is disabled"
    subtitle = "grant Accessibility permission to enable"
    button   = "Grant"   → opens System Settings → Accessibility

- AX granted, daemon still bailed:
    title    = "relaunch required"
    subtitle = "Accessibility granted — restart to activate capture
                and emulation"
    button   = "Relaunch" → spawns a fresh bundle via `open` after
                            a 1s delay, then quits.

- Both active: row hidden.

The emulation_status_row is kept hidden on macOS because capture and
emulation share the same TCC gate — a single row is sufficient and
two identical-looking warnings were noisy. `handle_emulation` still
exists for the non-macOS platforms where the rows are distinct.

Side effects:
- `relaunch_bundle` moved from lib.rs to macos_privacy so imp.rs can
  call it from the row button handler.
- AX watcher callback shrinks to `window.present()` +
  `refresh_capture_emulation_status()`; the toast-based dialog is
  gone along with its helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
2dc9ebb6cd macos: hide Reenable warning row once Accessibility is granted
The yellow "input capture is disabled" / "input emulation is disabled"
rows were showing simultaneously with the Relaunch toast after a
live AX grant, double-prompting the user for the same action.

Gate the warning row visibility on Accessibility actually being
missing: when AX is granted but capture/emulation remain inactive,
we're in the pending-relaunch state and the Relaunch toast is the
authoritative prompt. Trigger a status refresh from the AX watcher
so the rows hide the instant AX flips to granted, not when the
daemon next reports status.

On non-macOS platforms the visibility logic is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
8a444f98dd macos: drop the CapturePane / EmulationPane enums
Now that we always route Reenable clicks to the Accessibility pane on
macOS 13+ (AX transitively covers Input Monitoring listen-only and
Post Event, and the bundle isn't listed in the separate panes anyway),
the CapturePane / EmulationPane enums and their non-Accessibility
variants are dead weight. Remove them along with:

- `missing_capture_pane` / `missing_emulation_pane`
- `open_input_monitoring_settings` / `open_post_event_settings`
- `input_monitoring_granted` / `post_event_granted` preflight wrappers
- the `CGPreflightListenEventAccess` / `CGPreflightPostEventAccess`
  FFI declarations in lan-mouse-gtk (the daemon crates keep their own)

`handle_capture` / `handle_emulation` collapse to a single helper that
opens the Accessibility pane if AX is missing, otherwise just retries.
`ensure_listed_in_input_monitoring` is kept because it still has a
side effect on macOS 13/14, where Input Monitoring is a separately-
granted category.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
b3cade9bac macos: prompt to relaunch after live Accessibility grant
The daemon subprocess initializes at startup and bails immediately if
Accessibility is missing ("accessibility permission is required"). If
the user then grants AX mid-session via the system prompt, the daemon
has no way to retry from its bailed state — capture and emulation
stay dead until the next restart. Make the GUI watch for the AX
transition and surface a toast with a "Relaunch" button that quits
the app and spawns a fresh instance via Launch Services.

While here:
- Route capture/emulation "missing pane" fallback to the Accessibility
  pane instead of the Input Monitoring / Post Event panes when AX is
  already granted. On macOS 13+ those separate grants auto-confer via
  Accessibility and the bundle typically isn't listed in the IM pane
  at all, so the old navigation was a dead end.
- Reword the status-row subtitles so the action is clearer: the user
  now sees "click Reenable to grant permission" instead of a generic
  "required for outgoing connections".
- Bump libadwaita feature flag to v1_2 for AdwToast button signals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
cbdb86ce05 fix: write a default config.toml on every Config::new()
The previous fix created the config directory but left config.toml
absent on a first launch, which surfaced as two "No such file or
directory (os error 2)" warnings (one from the main process, one
from the spawned daemon child) and left the app starting up with
config_toml=None until the GUI persisted something.

Write ConfigToml::default() to the path if it doesn't exist, so
every entry point — GUI main, spawned daemon, CLI, test commands
— gets a concrete file to read, and first-launch logs stay clean.

Also reorders Config::new() so both the directory creation and the
file bootstrap run before the first read attempt, eliminating the
warning at the source rather than hiding it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
5e79743bd0 macos: per-pane TCC navigation and Sequoia-tolerant permission flow
On macOS the three TCC grants (Accessibility, Input Monitoring, Post
Event) live in separate Privacy panes. Before this change the
"Reenable" row sent the user to Accessibility regardless of which
grant was actually missing, and the daemon's own permission checks
re-fired the Accessibility prompt on every retry.

- lan-mouse-gtk/src/macos_privacy.rs: new module that exposes silent
  preflight checks (AXIsProcessTrusted, CGPreflightListenEventAccess,
  CGPreflightPostEventAccess), per-pane URL-scheme navigation, and
  a Once-guarded fire_initial_prompts() called from build_ui. The
  initial-prompt path only fires the Accessibility prompt if AX is
  missing and then returns; secondary registrations run only after
  AX is granted, which prevents a double Accessibility alert on
  Sequoia where Post Event is nested under Accessibility.
- Input Monitoring registration attempts CGEventTapCreate at
  kCGSessionEventTap (not kCGHIDEventTap) so a failure surfaces as
  an Input Monitoring signal rather than triggering an Accessibility
  prompt as a side effect.
- lan-mouse-gtk/src/window/imp.rs: handle_capture / handle_emulation
  switch on the missing-pane enum and navigate to the specific pane
  via x-apple.systempreferences:... URLs before re-requesting.
- lan-mouse-gtk/resources/window.ui: pill class on the Reenable
  buttons so the hover padding matches the rest of libadwaita.
- input-capture/src/macos.rs, input-emulation/src/macos.rs: make
  request_*_permission() a silent preflight (AXIsProcessTrusted /
  CGPreflightListenEventAccess / CGPreflightPostEventAccess), so the
  daemon no longer fires TCC prompts on retry — all prompting is
  owned by the GUI.
- input-capture/src/error.rs, input-emulation/src/error.rs: new
  error variants so the GUI can distinguish missing-AX from
  missing-IM / missing-PostEvent for pane routing.

Verified on macOS 15.5: first launch fires a single AX prompt;
second launch (AX granted) registers under Input Monitoring via the
session-tap attempt and requests Post Event. Sequoia auto-grants the
listen-only path via AX so the IM list may stay empty, which is the
intended OS behavior and no longer blocks capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
903b0504e0 macos: run as LSUIElement menubar app with NSStatusItem
Ship Lan Mouse on macOS as an accessory app (no Dock icon, no main
window on launch) with a status-bar item for show/quit. Closing the
window hides it instead of quitting so the menu bar stays the primary
surface.

- build-aux/macos-lsui-element.plist: LSUIElement=true plus
  NSInputMonitoringUsageDescription and NSAppleEventsUsageDescription
  (merged into Info.plist via cargo-bundle's osx_info_plist_exts).
- lan-mouse-gtk/src/macos_status_item.rs: NSStatusItem setup via raw
  objc_msgSend FFI. Loads a bundled 22pt PNG as a template image so
  it auto-tints for light/dark menu bars.
- scripts/makeicns.sh: emit Contents/Resources/menubar-template.png
  from the existing SVG.
- scripts/copy-macos-dylib.sh: flatten cargo-bundle's preserved
  target/ subdir under Resources so NSBundle pathForResource: finds
  the template image.
- lan-mouse-gtk/src/lib.rs: register the new modules, set up a
  Cmd+Q-wired quit action, configure bundle env vars (schemas,
  XDG_DATA_DIRS, GTK_DATA_PREFIX) when running from inside the .app,
  and filter the known upstream Gtk theme-parser warning spam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
c40e10505b fix: create ~/.config/lan-mouse/ on first launch
notify::Watcher fails on macOS if the config directory doesn't
exist. Create it with create_dir_all before calling config.watch().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
f858a7de00 makeicns.sh: produce Big Sur+ style macOS app icon
The previous script generated a 1024x1024 icon from the SVG with no
squircle background and no transparent padding, which caused macOS
to render the Dock/Finder icon noticeably larger than first-party
apps and without the rounded-square shape users expect.

Rewrite the script to follow Apple's Big Sur+ icon template:

  - 1024x1024 canvas
  - 824x824 white squircle, centered (100px transparent padding outside)
  - Artwork rendered at 560x560, centered inside the squircle
  - Squircle corner radius ~22.5% of the squircle size

Use rsvg-convert to rasterize the SVG (ImageMagick crops this
particular SVG when rendering directly), then composite onto the
squircle background in two steps for reliability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Jon Kinney
e6cd1630b2 bundle Adwaita symbolic icons in gresource
On macOS the Adwaita icon theme is not installed by default, so
symbolic icons (edit-copy, auth-fingerprint, network-wired,
dialog-warning, etc.) render as the "image-missing" placeholder.

Bundle the symbolic SVGs used by the GTK frontend into the embedded
gresource so the app is self-contained and doesn't depend on any
system-installed icon theme. The existing
`IconTheme::add_resource_path("/de/feschber/LanMouse/icons")` call
already tells GTK to search this prefix, so no code changes are
needed.

Icons are sourced from Adwaita and placed under the standard
`scalable/{actions,devices,places,status}/` hicolor layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:59:43 +02:00
Ferdinand Schober
a878c985f0 automatically update config when changed (#402) 2026-04-09 12:04:21 +02:00
Ferdinand Schober
aef05f386f fix: config initialization in authorize 2026-04-08 17:43:25 +02:00
Ferdinand Schober
38920917cd update ashpd 2026-04-08 13:11:18 +02:00
Ferdinand Schober
1075a90c5b update dependencies 2026-04-08 13:00:36 +02:00
Ferdinand Schober
2e1b5278ce fix: README badge 2026-03-25 13:36:57 +01:00
Jon Stelly
4d8f7d7813 chore: developer experience - pre-commit hook, ai instructions, yaml formatting (#374)
* chore: developer experience - pre-commit hook, ai instructions, yaml formatting for prettier

* no prettierrc, editorconfig instead

* fixes from copilot suggestions

---------

Co-authored-by: Ferdinand Schober <ferdinandschober20@gmail.com>
2026-03-25 13:34:17 +01:00
Ferdinand Schober
0ef8edb7b2 update checkout and upload-artifact actions 2026-03-25 13:31:20 +01:00
Jon Stelly
3eba50a0d3 feat: workflow and build updates (#372)
* feat: workflow and build updates

- macos 15 runners
- add linux arm build
- combine pre-release and tagged-release workflows

* remove old release job

* rename x86-64 -> x86_64

* rename arm -> arm64

* downgrade arm runner to ubuntu-22.04

---------

Co-authored-by: Ferdinand Schober <ferdinandschober20@gmail.com>
Co-authored-by: Ferdinand Schober <ferdinand.schober@fau.de>
2026-03-25 13:09:28 +01:00
Ferdinand Schober
9540739d89 add cancel in progress for CI 2026-03-25 12:44:59 +01:00
Ferdinand Schober
810e25a7fc Fix CI (#400)
apparently inkscape is now required on macos
2026-03-25 11:46:35 +01:00
Ferdinand Schober
9af5f9452e fix icon build (#399) 2026-03-24 15:06:03 +01:00
Ferdinand Schober
7fa3d2fafd update makeicns.sh 2026-03-24 14:02:16 +01:00
onelock
cd9fc43af4 fix: nix evaluation warnings + flake improvements (#395)
* fix: nix evaluation warning

* nix: minor flake improvements/maintenance

* nix: fix macos build errors

* nix: minor cleanup/fixes

* nix: remove redundant deps

* nix: remove reliance on systems input
2026-03-24 12:55:31 +01:00
Ty Smith
27225ed564 fix(macos): forward back/forward mouse buttons in capture and emulation (#392)
* fix(macos): forward back/forward mouse buttons in capture and emulation

OtherMouseDown/Up events on macOS carry a button number field that
distinguishes middle (2), back (3), and forward (4) buttons. The
capture backend was unconditionally mapping all OtherMouse events to
BTN_MIDDLE, silently dropping back/forward. The emulation backend had
no match arms for BTN_BACK/BTN_FORWARD, causing them to be dropped
with a warning.

Fix capture by reading MOUSE_EVENT_BUTTON_NUMBER and mapping 3->BTN_BACK,
4->BTN_FORWARD. Fix emulation by adding match arms for BTN_BACK/BTN_FORWARD
and setting MOUSE_EVENT_BUTTON_NUMBER on the emitted CGEvent so macOS
apps receive the correct button identity.

* fix(macos): track button state and double-clicks by evdev code instead of CGMouseButton

Back, forward, and middle buttons all map to CGMouseButton::Center on
macOS, which caused them to share a single pressed-state boolean and
alias in double-click detection. Replace the ButtonState struct with a
HashSet<u32> keyed by evdev button code so each button is tracked
independently.

---------

Co-authored-by: Ferdinand Schober <ferdinandschober20@gmail.com>
2026-02-22 17:45:53 +01:00
Kenichi Nakamura
bcf9c35301 Fix stuck modifiers (#385)
fixes #357
2026-02-22 17:45:14 +01:00
Ferdinand Schober
e8ff3957df CI: fix cargo build command 2026-02-20 16:45:42 +01:00
Ferdinand Schober
466fe4b3bd update cachix and disable magic nix-cache (#393)
magic nix cache seems to hang forever.
2026-02-20 16:43:57 +01:00
Peter Hutterer
ad63b6ba20 Handle the RemoteDesktop portal restore token correctly (#383)
For a session to actually persist, we need to request a persistence mode
which we already do. The portal then returns a restore-token (in the
form of an uuid) to us as part of the response to Start.

This token must then be passed into the *next* session during
SelectDevices to restore the previous session.

The token is officially a single-use token, so we need to overwrite it
every time. In practise the current XDP implementation may re-use the
token but we cannot rely on that.

Reading and writing the token is not async since we expect them to be
uuid-length.

Closes #74.
2026-02-11 13:27:32 +01:00
Ferdinand Schober
e80625648e build releases on ubuntu 22.04 (#382) 2026-02-10 07:29:45 +01:00
Ferdinand Schober
96c63374d0 rust.yml: run fmt/build/check/test separately (#375) 2026-02-08 16:54:42 +01:00
Ferdinand Schober
b8fdbb35ac fix: build failure in lan-mouse-ipc standalone 2026-02-08 14:22:46 +01:00
Ferdinand Schober
5d5f4bbe6f fix: build failure in input-capture standalone 2026-02-08 14:19:47 +01:00
Ferdinand Schober
8e96025f12 clear config, when unable to parse 2026-02-08 13:27:38 +01:00
Ferdinand Schober
f01459b2a8 fix crash when config file does not exist 2026-02-08 13:23:29 +01:00
Ferdinand Schober
394c018e11 ad fixme for memory leak 2026-02-08 13:14:11 +01:00
Ferdinand Schober
648b2b58a4 Save config (#345)
* add setters for clients and authorized keys

* impl change config request

* basic saving functionality

* save config automatically

* add TODO comment
2026-02-07 18:36:07 +01:00
Ferdinand Schober
a987f93133 Update systemd service instructions
closes #298
2026-02-07 13:23:09 +01:00
Jon Stelly
0d96948c26 fix: remote key-up on triggered release (#371)
Fixes #369
2026-02-06 16:06:19 +01:00
Ferdinand Schober
7863e8b110 CI: update macos runners 2026-02-06 15:32:56 +01:00
Ferdinand Schober
708a40d0da macos: fix memory leak
probably should use an AutoreleasePool as well
2026-02-06 15:29:24 +01:00
skifli
3922b45bd9 feat: add binary cache to instructions (#353) 2025-12-04 12:09:10 +01:00
Ferdinand Schober
640fa995a4 improve reliability of connections (#349) 2025-11-03 18:04:18 +01:00
Ferdinand Schober
bdafaa07e5 macos: fix scroll capture (#350) 2025-11-03 18:04:09 +01:00
NeoTheFox
3f13714d8a Add rustfmt.toml for explicit styling (#348)
* Propose an explicit .rustfnt.toml
Use 2024 style, 4 spaces for tabs and epand the default width a tad

* Auto-format the existing code with new rules
2025-11-02 11:52:01 +01:00
Ferdinand Schober
3483d242e2 fix inconsistent mouse capture on macos (#346) 2025-10-31 14:43:28 +01:00
Ferdinand Schober
35773dfd07 macos: fix modifier capture (#342) 2025-10-30 20:16:27 +01:00
Ferdinand Schober
f91b6bd3c1 macos: reset double click when mouse is moved (#341) 2025-10-30 00:48:24 +01:00
Ferdinand Schober
2d1a037eba macos: fix duplicated key release event (#340) 2025-10-29 18:37:24 +01:00
Ferdinand Schober
057f6e2567 macos: emulate double / triple click (#338) 2025-10-29 17:46:15 +01:00
Ferdinand Schober
99c8bc5567 macsos: use ScrollEventUnit::LINE for mousewheel (#337) 2025-10-29 16:18:46 +01:00
Ferdinand Schober
0dd413e989 prevent authorization request spamming windows (#335) 2025-10-28 07:25:01 +01:00
Ferdinand Schober
4e5a66340a Partially Revert "slow scrolling chrome with emulation=wlroots capture=layer-shell (#318) (#325)" (#334)
The division by 120 was correct.
2025-10-27 15:56:30 +01:00
Micah R Ledbetter
0a0d91b0da Include libadwaita and other dependencies in the app bundle on macOS (#271)
* Include libadwaita and other dependencies in the app bundle on macOS

* Fix missing pipes

* Use recent bash for associative array support (declare -A)

* Use correct path for homebrew bash on Intel macOS

* Get homebrew path from the brew command

* Simplify copy-macos-dylib and convert to POSIX sh

Remove need for recent bash altogether

* Fix permissions nit

* Update macOS dylib copy script path in release workflow

* fix a few typos

* fix script invocation in pre-release.yml

---------

Co-authored-by: Apoorv Khandelwal <mail@apoorvkh.com>
Co-authored-by: Ferdinand Schober <ferdinandschober20@gmail.com>
2025-10-14 13:35:07 +02:00
ayykamp
94e6372218 Add development flatpak manifest (#328) 2025-10-12 15:09:41 +02:00
Thomas Matthijs
39b79d88a5 slow scrolling chrome with emulation=wlroots capture=layer-shell (#318) (#325)
Using niri as compositor on both sides resulting in: emulation=wlroots capture=layer-shell

Mouse scrolling works fine in terminals, but only scrolls very small amount in google-chrome (in wayland mode)

Using 'wev' to show events, using the real mouse shows

[        15:      wl_pointer] axis_source: 0 (wheel)
[        15:      wl_pointer] axis_value120: axis: 0 (vertical), value120: 120
[        15:      wl_pointer] axis_relative_direction: axis: 0 (vertical), direction: 0
[        15:      wl_pointer] axis: time: 50410752; axis: 0 (vertical), value: 15.000000

Using the lan-mouse shows:

[        15:      wl_pointer] axis_source: 2 (continuous)
[        15:      wl_pointer] axis_value120: axis: 0 (vertical), value120: 1
[        15:      wl_pointer] axis_relative_direction: axis: 0 (vertical), direction: 0
[        15:      wl_pointer] axis: time: -1913142096; axis: 0 (vertical), value: 20.000000

Without axis_source, scrolling over (pinned) tabs also skips one tab.
2025-10-09 00:28:26 +02:00
ayykamp
5ad90ca6a5 doc: add missing closing tag in README (#326) 2025-10-08 19:54:49 +02:00
ayykamp
68df27ab2c doc: add instructions to install from terra repo on fedora (#308) 2025-10-08 17:12:13 +02:00
Ferdinand Schober
eb1dcbddb0 update dependencies (#302)
* update dependencies

* update windows

* clippy: inline format args

* update flake

* update core-graphics

* fix poll after completion error

* fix ashpd?!
2025-10-08 16:10:32 +02:00
Ferdinand Schober
9f10ebcbd2 Macos cleanup event thread (#324) 2025-10-08 02:00:37 +02:00
Ferdinand Schober
e29eb7134c macos: fix a crash when InputCapture is dropped (#323) 2025-10-08 00:22:52 +02:00
Ferdinand Schober
e46fe60b3e fix parent class types in key_row widget (#300)
closes #294
2025-06-12 18:17:36 +02:00
Ferdinand Schober
37a4e236b8 fix clippy warnings from rust 1.87 (#301) 2025-06-12 17:52:23 +02:00
Leon Linhart
b8063a8138 Capture horizontal scroll on Windows (#283) 2025-04-02 02:39:49 +02:00
Ferdinand Schober
5a3a21c2c0 clients should not be mandatory in configuration (#285)
closes #284
2025-04-01 13:22:08 +02:00
Ferdinand Schober
3ec23d7171 unauthorized device accept notification (#282)
* ask the user to accept unauthorized devices

* only alert on actual error
2025-03-22 22:50:19 +01:00
Michel Lao
15296263b2 Fix parsing TOML key 'position' and values (#281)
* fix parsing toml key position and values

* Using rename_all instead rename over each enum

* rename struct field directly

---------

Co-authored-by: Ferdinand Schober <ferdinand.schober@fau.de>
2025-03-21 14:02:38 +01:00
Ferdinand Schober
5736919f89 Update README.md
update cli interface usage
2025-03-16 00:36:21 +01:00
Ferdinand Schober
1ece2a417d Update README.md 2025-03-15 18:48:25 +01:00
Ferdinand Schober
e101ff281b Update config.toml 2025-03-15 18:48:05 +01:00
Ferdinand Schober
532383ef65 Update README.md 2025-03-15 18:47:18 +01:00
Ferdinand Schober
92f652df2e feat: simplify and change configuration (#279)
*breaking change*
this changes the configuration syntax, allowing for an unlimited amount of configured clients.
Also a first step towards enabling a "save config" feature.
2025-03-15 18:45:19 +01:00
Ferdinand Schober
2f6a3629ad remove cli frontend in favour of cli subcommand (#278)
this removes the cli frontend entirely, replacing it with a subcommand instead
2025-03-15 18:20:25 +01:00
Ferdinand Schober
7898f2362c Update README.md (#277) 2025-03-15 16:51:23 +01:00
Ferdinand Schober
50a778452e Gtk frontend rework (#276)
client configuration now applies immediately instead of after enabling / disabling clients.
Also fixes a potential feedback loop when changing settings.
2025-03-15 01:21:53 +01:00
Ferdinand Schober
f247300f8c cancel previous dns request if a new one is made (#275) 2025-03-14 23:07:21 +01:00
Micah R Ledbetter
5d1745a60c Add cmd-q shortcut on macOS (#270) 2025-02-28 15:28:35 +01:00
Ferdinand Schober
615c75817a fix clippy lint 2025-02-27 16:57:53 +01:00
Ferdinand Schober
03407b9826 update flake 2025-02-27 16:57:38 +01:00
Ferdinand Schober
89684e1481 fix names in pre release as well 2025-02-21 13:45:00 +01:00
Ferdinand Schober
a1d4effcf9 fix file names 2025-02-21 13:28:52 +01:00
Ferdinand Schober
da054b7a9a fix bundle path 2025-02-21 12:45:54 +01:00
Micah R Ledbetter
508d066700 Build a macOS bundle for Intel and ARM (#266)
* Build a macOS bundle for Intel and ARM

* Build icon.icns file in a script

* Add imagemagick

* Add macOS bundling to pre/tagged-release actions
2025-02-21 12:35:13 +01:00
Ferdinand Schober
21c24f7fa1 layer-shell: handle added/removed globals
closes #253
2025-02-13 22:10:12 +01:00
Ferdinand Schober
3e1c3e95b7 use shadow-rs instead of executing git describe
this removes git from the build dependencies
2025-01-27 16:51:25 +01:00
100 changed files with 6291 additions and 3603 deletions

24
.editorconfig Normal file
View File

@@ -0,0 +1,24 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.rs]
indent_style = space
indent_size = 4
max_line_length = 100
[*.{yml,yaml}]
indent_style = space
indent_size = 2
[*.toml]
indent_style = space
indent_size = 4
[*.nix]
indent_style = space
indent_size = 2

34
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
# Ensure we're at the repo root
cd "$(git rev-parse --show-toplevel)" || exit 1
echo "Running cargo fmt (auto-format)..."
# Run formatter to apply fixes but do not stage them. If formatting changed,
# fail the commit so the user can review and stage the changes manually.
cargo fmt --all
if ! git diff --quiet --exit-code; then
echo "" >&2
echo "ERROR: cargo fmt modified files. Review changes, stage them, and commit again." >&2
git --no-pager diff --name-only
exit 1
fi
echo "Running cargo clippy..."
# Matches CI: deny warnings to keep code health strict
if ! cargo clippy --workspace --all-targets --all-features -- -D warnings; then
echo "" >&2
echo "ERROR: clippy found warnings/errors. Fix them before committing." >&2
exit 1
fi
echo "Running cargo test..."
if ! cargo test --workspace --all-features; then
echo "" >&2
echo "ERROR: Some tests failed. Fix tests before committing." >&2
exit 1
fi
echo "All pre-commit checks passed."
exit 0

View File

@@ -1,40 +1,50 @@
name: Binary Cache name: Nix Binary Cache
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
on: [push, pull_request, workflow_dispatch]
jobs: jobs:
nix: nix:
strategy: strategy:
matrix: matrix:
os: os:
- ubuntu-latest - ubuntu-latest
- macos-13 - macos-15-intel
- macos-14 - macos-latest
name: "Build" name: "Build"
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
submodules: recursive submodules: recursive
- uses: DeterminateSystems/nix-installer-action@main # - uses: DeterminateSystems/nix-installer-action@main
with: # with:
logger: pretty # logger: pretty
- uses: DeterminateSystems/magic-nix-cache-action@main # - uses: DeterminateSystems/magic-nix-cache-action@main
- uses: cachix/cachix-action@v14 - uses: cachix/install-nix-action@v31
with: - uses: cachix/cachix-action@v16
name: lan-mouse with:
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' name: lan-mouse
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build lan-mouse (x86_64-linux) - name: Build lan-mouse (x86_64-linux)
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
run: nix build --print-build-logs --show-trace .#packages.x86_64-linux.lan-mouse run: nix build --print-build-logs --show-trace .#packages.x86_64-linux.lan-mouse
- name: Build lan-mouse (x86_64-darwin) - name: Build lan-mouse (x86_64-darwin)
if: matrix.os == 'macos-13' if: matrix.os == 'macos-15-intel'
run: nix build --print-build-logs --show-trace .#packages.x86_64-darwin.lan-mouse run: nix build --print-build-logs --show-trace .#packages.x86_64-darwin.lan-mouse
- name: Build lan-mouse (aarch64-darwin)
if: matrix.os == 'macos-14'
run: nix build --print-build-logs --show-trace .#packages.aarch64-darwin.lan-mouse
- name: Build lan-mouse (aarch64-darwin)
if: matrix.os == 'macos-latest'
run: nix build --print-build-logs --show-trace .#packages.aarch64-darwin.lan-mouse

View File

@@ -1,132 +0,0 @@
name: "pre-release"
on:
push:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
linux-release-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install libx11-dev libxtst-dev
sudo apt-get install libadwaita-1-dev libgtk-4-dev
- name: Release Build
run: cargo build --release
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-linux
path: target/release/lan-mouse
windows-release-build:
runs-on: windows-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
# needed for cache restore
- name: create gtk dir
run: mkdir C:\gtk-build\gtk\x64\release
- uses: actions/cache@v3
id: cache
with:
path: c:/gtk-build/gtk/x64/release/**
key: gtk-windows-build
restore-keys: gtk-windows-build
- name: Update path
run: |
echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo $env:GITHUB_PATH
echo $env:PATH
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
# choco install msys2
# choco install visualstudio2022-workload-vctools
# choco install pkgconfiglite
py -m venv .venv
.venv\Scripts\activate.ps1
py -m pip install gvsbuild
# see https://github.com/wingtk/gvsbuild/pull/1004
Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin"
Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin"
gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg
Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin"
Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin"
- uses: actions/checkout@v4
- name: Release Build
run: cargo build --release
- name: Create Archive
run: |
mkdir "lan-mouse-windows"
Get-Childitem -Path "C:\\gtk-build\\gtk\\x64\\release\\bin\\*.dll" -File -Recurse | Copy-Item -Destination "lan-mouse-windows"
Copy-Item -Path "target\release\lan-mouse.exe" -Destination "lan-mouse-windows"
Compress-Archive -Path "lan-mouse-windows\*" -DestinationPath lan-mouse-windows.zip
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-windows
path: lan-mouse-windows.zip
macos-release-build:
runs-on: macos-13
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: brew install gtk4 libadwaita
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-intel
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos-intel
path: lan-mouse-macos-intel
macos-aarch64-release-build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: brew install gtk4 libadwaita
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-aarch64
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos-aarch64
path: lan-mouse-macos-aarch64
pre-release:
name: "Pre Release"
needs: [windows-release-build, linux-release-build, macos-release-build]
runs-on: "ubuntu-latest"
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
- name: Create Release
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "latest"
prerelease: true
title: "Development Build"
files: |
lan-mouse-linux/lan-mouse
lan-mouse-macos-intel/lan-mouse-macos-intel
lan-mouse-macos-aarch64/lan-mouse-macos-aarch64
lan-mouse-windows/lan-mouse-windows.zip

204
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,204 @@
name: "Release"
run-name: "Release - ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.name || github.ref_name }}"
on:
push:
branches: [ "main" ]
tags:
- v**
workflow_dispatch:
inputs:
name:
description: 'Development release name'
required: false
default: ''
env:
CARGO_TERM_COLOR: always
jobs:
linux-release-build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install libx11-dev libxtst-dev
sudo apt-get install libadwaita-1-dev libgtk-4-dev
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-linux-x86_64
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: lan-mouse-linux-x86_64
path: lan-mouse-linux-x86_64
linux-arm64-release-build:
runs-on: ubuntu-22.04-arm
steps:
- uses: actions/checkout@v6
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install libx11-dev libxtst-dev
sudo apt-get install libadwaita-1-dev libgtk-4-dev
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-linux-arm64
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: lan-mouse-linux-arm64
path: lan-mouse-linux-arm64
windows-release-build:
runs-on: windows-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
# needed for cache restore
- name: create gtk dir
run: mkdir C:\gtk-build\gtk\x64\release
- uses: actions/cache@v3
id: cache
with:
path: c:/gtk-build/gtk/x64/release/**
key: gtk-windows-build
restore-keys: gtk-windows-build
- name: Update path
run: |
echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo $env:GITHUB_PATH
echo $env:PATH
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
# choco install msys2
# choco install visualstudio2022-workload-vctools
# choco install pkgconfiglite
py -m venv .venv
.venv\Scripts\activate.ps1
py -m pip install gvsbuild
# see https://github.com/wingtk/gvsbuild/pull/1004
Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin"
Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin"
gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg
Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin"
Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin"
- uses: actions/checkout@v6
- name: Release Build
run: cargo build --release
- name: Create Archive
run: |
mkdir "lan-mouse-windows"
Get-Childitem -Path "C:\\gtk-build\\gtk\\x64\\release\\bin\\*.dll" -File -Recurse | Copy-Item -Destination "lan-mouse-windows"
Copy-Item -Path "target\release\lan-mouse.exe" -Destination "lan-mouse-windows"
Compress-Archive -Path "lan-mouse-windows\*" -DestinationPath lan-mouse-windows-x86_64.zip
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: lan-mouse-windows-x86_64
path: lan-mouse-windows-x86_64.zip
macos-release-build:
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v6
- name: install dependencies
run: |
brew install --cask inkscape
brew install gtk4 libadwaita imagemagick librsvg
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-intel
- name: Make icns
run: scripts/makeicns.sh
- name: Install cargo bundle
run: cargo install cargo-bundle
- name: Bundle
run: |
cargo bundle --release
scripts/copy-macos-dylib.sh "target/release/bundle/osx/Lan Mouse.app/Contents/MacOS/lan-mouse"
- name: Zip bundle
run: |
cd target/release/bundle/osx
zip -r "lan-mouse-macos-intel.zip" "Lan Mouse.app"
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: lan-mouse-macos-intel
path: target/release/bundle/osx/lan-mouse-macos-intel.zip
macos-arm64-release-build:
runs-on: macos-15
steps:
- uses: actions/checkout@v6
- name: install dependencies
run: |
brew install --cask inkscape
brew install gtk4 libadwaita imagemagick librsvg
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-arm64
- name: Make icns
run: scripts/makeicns.sh
- name: Install cargo bundle
run: cargo install cargo-bundle
- name: Bundle
run: |
cargo bundle --release
scripts/copy-macos-dylib.sh "target/release/bundle/osx/Lan Mouse.app/Contents/MacOS/lan-mouse"
- name: Zip bundle
run: |
cd target/release/bundle/osx
zip -r "lan-mouse-macos-arm64.zip" "Lan Mouse.app"
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: lan-mouse-macos-arm64
path: target/release/bundle/osx/lan-mouse-macos-arm64.zip
release:
name: "Release"
needs: [windows-release-build, linux-release-build, linux-arm64-release-build, macos-release-build, macos-arm64-release-build]
runs-on: "ubuntu-latest"
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
- name: Create Pre-Release
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ github.event.inputs.name || github.ref_name }}
name: ${{ github.event.inputs.name || github.ref_name }}
prerelease: true
generate_release_notes: true
files: |
lan-mouse-linux-x86_64/lan-mouse-linux-x86_64
lan-mouse-linux-arm64/lan-mouse-linux-arm64
lan-mouse-macos-intel/lan-mouse-macos-intel.zip
lan-mouse-macos-arm64/lan-mouse-macos-arm64.zip
lan-mouse-windows-x86_64/lan-mouse-windows-x86_64.zip
- name: Create Tagged Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: |
lan-mouse-linux-x86_64/lan-mouse-linux-x86_64
lan-mouse-linux-arm64/lan-mouse-linux-arm64
lan-mouse-macos-intel/lan-mouse-macos-intel.zip
lan-mouse-macos-arm64/lan-mouse-macos-arm64.zip
lan-mouse-windows-x86_64/lan-mouse-windows-x86_64.zip

View File

@@ -9,126 +9,92 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs: jobs:
build-linux: fmt:
name: Formatting
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: install dependencies - name: cargo fmt
run: | run: cargo fmt --check
sudo apt-get update
sudo apt-get install libx11-dev libxtst-dev
sudo apt-get install libadwaita-1-dev libgtk-4-dev
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Check Formatting
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-features --all-targets -- --deny warnings
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse
path: target/debug/lan-mouse
build-windows:
runs-on: windows-latest
ci:
name: ${{ matrix.job }} ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
- macos-15-intel
job:
- build
- check
- clippy
- test
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/setup-python@v5 - uses: Swatinem/rust-cache@v2
with: - name: Install Linux deps
python-version: '3.11' if: runner.os == 'Linux'
# needed for cache restore run: |
- name: create gtk dir sudo apt-get update
run: mkdir C:\gtk-build\gtk\x64\release sudo apt-get install libx11-dev libxtst-dev libadwaita-1-dev libgtk-4-dev
- uses: actions/cache@v3 - name: Install macOS dependencies
id: cache if: runner.os == 'macOS'
with: run: brew install gtk4 libadwaita imagemagick
path: c:/gtk-build/gtk/x64/release/** - name: Install Windows Dependencies - create gtk dir
key: gtk-windows-build if: runner.os == 'Windows'
restore-keys: gtk-windows-build run: mkdir C:\gtk-build\gtk\x64\release
- name: Update path - name: Install Windows Dependencies - install gtk from cache
run: | uses: actions/cache@v3
echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append if: runner.os == 'Windows'
echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append id: cache
echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append with:
echo $env:GITHUB_PATH path: c:/gtk-build/gtk/x64/release/**
echo $env:PATH key: gtk-windows-build
- name: Install dependencies restore-keys: gtk-windows-build
if: steps.cache.outputs.cache-hit != 'true' - name: Install Windows Dependencies - update PATH
run: | if: runner.os == 'Windows'
# choco install msys2 run: |
# choco install visualstudio2022-workload-vctools echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# choco install pkgconfiglite echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
py -m venv .venv echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
.venv\Scripts\activate.ps1 echo $env:GITHUB_PATH
py -m pip install gvsbuild echo $env:PATH
# see https://github.com/wingtk/gvsbuild/pull/1004 - name: Install Windows dependencies - build gtk
Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin" if: runner.os == 'Windows' && steps.cache.outputs.cache-hit != 'true'
Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin" run: |
gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg # choco install msys2
Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin" # choco install visualstudio2022-workload-vctools
Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin" # choco install pkgconfiglite
- name: Build py -m venv .venv
run: cargo build --verbose .venv\Scripts\activate.ps1
- name: Run tests py -m pip install gvsbuild
run: cargo test --verbose gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg
- name: Check Formatting - name: cargo build
run: cargo fmt --check if: matrix.job == 'build'
- name: Clippy run: cargo build
run: cargo clippy --all-features --all-targets -- --deny warnings
- name: Copy Gtk Dlls
run: Get-Childitem -Path "C:\\gtk-build\\gtk\\x64\\release\\bin\\*.dll" -File -Recurse | Copy-Item -Destination "target\\debug"
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-windows
path: |
target/debug/lan-mouse.exe
target/debug/*.dll
build-macos: - name: cargo check
runs-on: macos-13 if: matrix.job == 'check'
steps: run: cargo check --workspace --all-targets --all-features
- uses: actions/checkout@v4
- name: install dependencies
run: brew install gtk4 libadwaita
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Check Formatting
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-features --all-targets -- --deny warnings
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos
path: target/debug/lan-mouse
build-macos-aarch64: - name: cargo test
runs-on: macos-14 if: matrix.job == 'test'
steps: run: cargo test --workspace --all-features
- uses: actions/checkout@v4
- name: install dependencies - name: cargo clippy
run: brew install gtk4 libadwaita if: matrix.job == 'clippy'
- name: Build run: cargo clippy --workspace --all-targets --all-features -- -D warnings
run: cargo build --verbose
- name: Run tests - uses: clechasseur/rs-clippy-check@v4
run: cargo test --verbose if: matrix.job == 'clippy'
- name: Check Formatting with:
run: cargo fmt --check args: --workspace --all-targets --all-features
- name: Clippy
run: cargo clippy --all-features --all-targets -- --deny warnings
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos-aarch64
path: target/debug/lan-mouse

View File

@@ -1,126 +0,0 @@
name: "Tagged Release"
on:
push:
tags:
- v**
jobs:
linux-release-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install libx11-dev libxtst-dev
sudo apt-get install libadwaita-1-dev libgtk-4-dev
- name: Release Build
run: cargo build --release
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-linux
path: target/release/lan-mouse
windows-release-build:
runs-on: windows-latest
steps:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
# needed for cache restore
- name: create gtk dir
run: mkdir C:\gtk-build\gtk\x64\release
- uses: actions/cache@v3
id: cache
with:
path: c:/gtk-build/gtk/x64/release/**
key: gtk-windows-build
restore-keys: gtk-windows-build
- name: Update path
run: |
echo "PKG_CONFIG=C:\gtk-build\gtk\x64\release\bin\pkgconf.exe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
echo "C:\pkg-config-lite-0.28-1\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo "C:\gtk-build\gtk\x64\release\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo $env:GITHUB_PATH
echo $env:PATH
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
# choco install msys2
# choco install visualstudio2022-workload-vctools
# choco install pkgconfiglite
py -m venv .venv
.venv\Scripts\activate.ps1
py -m pip install gvsbuild
# see https://github.com/wingtk/gvsbuild/pull/1004
Move-Item "C:\Program Files\Git\usr\bin" "C:\Program Files\Git\usr\notbin"
Move-Item "C:\Program Files\Git\bin" "C:\Program Files\Git\notbin"
gvsbuild build --msys-dir=C:\msys64 gtk4 libadwaita librsvg
Move-Item "C:\Program Files\Git\usr\notbin" "C:\Program Files\Git\usr\bin"
Move-Item "C:\Program Files\Git\notbin" "C:\Program Files\Git\bin"
- uses: actions/checkout@v4
- name: Release Build
run: cargo build --release
- name: Create Archive
run: |
mkdir "lan-mouse-windows"
Get-Childitem -Path "C:\\gtk-build\\gtk\\x64\\release\\bin\\*.dll" -File -Recurse | Copy-Item -Destination "lan-mouse-windows"
Copy-Item -Path "target\release\lan-mouse.exe" -Destination "lan-mouse-windows"
Compress-Archive -Path "lan-mouse-windows\*" -DestinationPath lan-mouse-windows.zip
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-windows
path: lan-mouse-windows.zip
macos-release-build:
runs-on: macos-13
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: brew install gtk4 libadwaita
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-intel
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos-intel
path: lan-mouse-macos-intel
macos-aarch64-release-build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: install dependencies
run: brew install gtk4 libadwaita
- name: Release Build
run: |
cargo build --release
cp target/release/lan-mouse lan-mouse-macos-aarch64
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: lan-mouse-macos-aarch64
path: lan-mouse-macos-aarch64
tagged-release:
name: "Tagged Release"
needs: [windows-release-build, linux-release-build, macos-release-build]
runs-on: "ubuntu-latest"
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
- name: Create Release
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
prerelease: false
files: |
lan-mouse-linux/lan-mouse
lan-mouse-macos-intel/lan-mouse-macos-intel
lan-mouse-macos-aarch64/lan-mouse-macos-aarch64
lan-mouse-windows/lan-mouse-windows.zip

4
.gitignore vendored
View File

@@ -8,3 +8,7 @@ result
*.pem *.pem
*.csr *.csr
extfile.conf extfile.conf
# flatpak files
.flatpak-builder
repo

4
.rustfmt.toml Normal file
View File

@@ -0,0 +1,4 @@
style_edition = "2024"
max_width = 100
tab_spaces = 4

65
AGENTS.md Normal file
View File

@@ -0,0 +1,65 @@
# Lan Mouse Agent Instructions
## Overview
Lan Mouse is an open-source Software KVM sharing mouse/keyboard input across local networks. The Rust workspace combines a GTK frontend, CLI/daemon mode, and multi-OS capture/emulation backends for Linux, Windows, and macOS.
## Core principles
- **Scope discipline.** Only implement what was requested; describe follow-up work instead of absorbing it.
- **Clarify OS behavior.** Ask when requirements touch OS-specific capture/emulation (they differ significantly).
- **Docs stay current.** Update [README.md](README.md) or [DOC.md](DOC.md) when touching public APIs or platform support.
- **Rust idioms.** Use `Result`/`Option`, `thiserror` for errors, descriptive logs, and concise comments for non-obvious invariants.
## Terminology
- **Client:** A remote machine that can receive or send input events. Each client is either _active_ (receiving events) or _inactive_ (can send events back). This mutual exclusion prevents feedback loops.
- **Backend:** OS-specific implementation for capture or emulation (e.g., libei, layer-shell, wlroots, X11, Windows, macOS).
- **Handle:** A per-client identifier used to route events and track state (pressed keys, position).
## Architecture
**Pipeline:** `input-capture``lan-mouse-ipc``input-emulation`
- **input-capture:** Reads OS events into a `Stream<CaptureEvent>`. Backends tried in priority order (libei → layer-shell → X11 → fallback). Tracks `pressed_keys` to avoid stuck modifiers. `position_map` queues events when multiple clients share a screen edge.
- **input-emulation:** Replays events via the `Emulation` trait (`consume`, `create`, `destroy`, `terminate`). Maintains `pressed_keys` and releases them on disconnect.
- **lan-mouse-ipc / lan-mouse-proto:** Protocol glue and serialization. Events are UDP; connection requests are TCP on the same port. Version bumps required when serialization changes.
- **input-event:** Shared scancode enums and abstract event types—extend here, don't duplicate translations.
## Feature & cfg discipline
- Feature flags live in root `Cargo.toml`. Gate OS-specific modules with tight cfgs (e.g., `cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))`).
- Prefer module-level gating over per-function cfgs to avoid empty stubs.
- New backends: add feature in `Cargo.toml`, create gated module, log backend selection.
## Async patterns
- Tokio runtime with `futures` streams and `async_trait`. Model new flows as streams or async methods.
- Avoid blocking; use `spawn_blocking` if needed. Prefer existing single-threaded stream handling.
- `InputCapture` implements `Stream` and manually pumps backends—don't short-circuit this logic.
## Commands
```sh
cargo build --workspace # full build
cargo build -p <crate> # single crate
cargo test --workspace # all tests
cargo fmt && cargo clippy --workspace --all-targets --all-features # lint
RUST_LOG=lan_mouse=debug cargo run # debug logging
```
Run from repo root—no `cd` in scripts.
## Testing
- Unit tests for utilities; integration tests for protocol behavior.
- OS-specific backends: test via GTK/CLI on target OS or document manual verification.
- Dummy backend exercises pipeline without real dependencies.
- Verify `terminate()` releases keys on unexpected disconnect.
## Workflow
1. Clarify ambiguous requirements, especially OS-specific behavior.
2. Implement minimal change; flag follow-up work.
3. Add proportional tests; run `cargo test` on affected crates.
4. Run `cargo fmt` and `cargo clippy --workspace --all-targets --all-features`.

2568
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@ strip = true
panic = "abort" panic = "abort"
[build-dependencies] [build-dependencies]
shadow-rs = "0.38.0" shadow-rs = "1.2.0"
[dependencies] [dependencies]
input-event = { path = "input-event", version = "0.3.0" } input-event = { path = "input-event", version = "0.3.0" }
@@ -34,10 +34,11 @@ lan-mouse-cli = { path = "lan-mouse-cli", version = "0.2.0" }
lan-mouse-gtk = { path = "lan-mouse-gtk", version = "0.2.0", optional = true } lan-mouse-gtk = { path = "lan-mouse-gtk", version = "0.2.0", optional = true }
lan-mouse-ipc = { path = "lan-mouse-ipc", version = "0.2.0" } lan-mouse-ipc = { path = "lan-mouse-ipc", version = "0.2.0" }
lan-mouse-proto = { path = "lan-mouse-proto", version = "0.2.0" } lan-mouse-proto = { path = "lan-mouse-proto", version = "0.2.0" }
shadow-rs = { version = "0.38.0", features = ["metadata"] } shadow-rs = { version = "1.2.0", features = ["metadata"] }
hickory-resolver = "0.24.1" hickory-resolver = "0.25.2"
toml = "0.8" toml = "0.8"
toml_edit = { version = "0.22", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
log = "0.4.20" log = "0.4.20"
env_logger = "0.11.3" env_logger = "0.11.3"
@@ -58,14 +59,15 @@ slab = "0.4.9"
thiserror = "2.0.0" thiserror = "2.0.0"
tokio-util = "0.7.11" tokio-util = "0.7.11"
local-channel = "0.1.5" local-channel = "0.1.5"
webrtc-dtls = { version = "0.10.0", features = ["pem"] } webrtc-dtls = { version = "0.12.0", features = ["pem"] }
webrtc-util = "0.9.0" webrtc-util = "0.11.0"
rustls = { version = "0.23.12", default-features = false, features = [ rustls = { version = "0.23.12", default-features = false, features = [
"std", "std",
"ring", "ring",
] } ] }
rcgen = "0.13.1" rcgen = "0.13.1"
sha2 = "0.10.8" sha2 = "0.10.8"
notify = "8.2.0"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2.148" libc = "0.2.148"
@@ -89,3 +91,10 @@ libei_emulation = ["input-event/libei", "input-emulation/libei"]
wlroots_emulation = ["input-emulation/wlroots"] wlroots_emulation = ["input-emulation/wlroots"]
x11_emulation = ["input-emulation/x11"] x11_emulation = ["input-emulation/x11"]
rdp_emulation = ["input-emulation/remote_desktop_portal"] rdp_emulation = ["input-emulation/remote_desktop_portal"]
[package.metadata.bundle]
name = "Lan Mouse"
icon = ["target/icon.icns"]
identifier = "de.feschber.LanMouse"
osx_info_plist_exts = ["build-aux/macos-lsui-element.plist"]
resources = ["target/menubar-template.png"]

111
README.md
View File

@@ -1,4 +1,9 @@
# Lan Mouse # Lan Mouse
[![CI](https://github.com/feschber/lan-mouse/actions/workflows/rust.yml/badge.svg)](https://github.com/feschber/lan-mouse/actions/workflows/rust.yml) [![Cachix](https://github.com/feschber/lan-mouse/actions/workflows/cachix.yml/badge.svg)](https://github.com/feschber/lan-mouse/actions/workflows/cachix.yml) [![Release](https://github.com/feschber/lan-mouse/actions/workflows/release.yml/badge.svg)](https://github.com/feschber/lan-mouse/actions/workflows/release.yml)
[![crates.io](https://img.shields.io/crates/v/lan-mouse.svg)](https://crates.io/crates/lan-mouse) [![license](https://img.shields.io/crates/l/lan-mouse.svg)](https://github.com/feschber/lan-mouse/blob/main/Cargo.toml)
Lan Mouse is a *cross-platform* mouse and keyboard sharing software similar to universal-control on Apple devices. Lan Mouse is a *cross-platform* mouse and keyboard sharing software similar to universal-control on Apple devices.
It allows for using multiple PCs via a single set of mouse and keyboard. It allows for using multiple PCs via a single set of mouse and keyboard.
This is also known as a Software KVM switch. This is also known as a Software KVM switch.
@@ -81,15 +86,38 @@ paru -S lan-mouse-git
- flake: [README.md](./nix/README.md) - flake: [README.md](./nix/README.md)
</details> </details>
<details>
<summary>Fedora</summary>
You can install Lan Mouse from the [Terra Repository](https://terra.fyralabs.com).
After enabling Terra:
```sh
dnf install lan-mouse
```
</details>
<details>
<summary>MacOS</summary>
- Download the package for your Mac (Intel or ARM) from the releases page
- Unzip it
- Remove the quarantine with `xattr -rd com.apple.quarantine "Lan Mouse.app"`
- Launch the app
- Use the menu bar item to open the settings window or quit Lan Mouse. Bundled macOS builds run as a menu bar app and do not keep a Dock icon visible.
- Grant accessibility permissions in System Preferences
</details>
<details> <details>
<summary>Manual Installation</summary> <summary>Manual Installation</summary>
First make sure to [install the necessary dependencies](#installing-dependencies). First make sure to [install the necessary dependencies](#installing-dependencies-for-development--compiling-from-source).
Precompiled release binaries for Windows, MacOS and Linux are available in the [releases section](https://github.com/feschber/lan-mouse/releases). Precompiled release binaries for Windows, MacOS and Linux are available in the [releases section](https://github.com/feschber/lan-mouse/releases).
For Windows, the depenedencies are included in the .zip file, for other operating systems see [Installing Dependencies](#installing-dependencies). For Windows, the depenedencies are included in the .zip file, for other operating systems see [Installing Dependencies](#installing-dependencies-for-development--compiling-from-source).
Alternatively, the `lan-mouse` binary can be compiled from source (see below). Alternatively, the `lan-mouse` binary can be compiled from source (see below).
@@ -144,23 +172,51 @@ rust toolchain.
Additionally, available backends and frontends can be configured manually via Additionally, available backends and frontends can be configured manually via
[cargo features](https://doc.rust-lang.org/cargo/reference/features.html). [cargo features](https://doc.rust-lang.org/cargo/reference/features.html).
E.g. if only wayland support is needed, the following command produces E.g. if only support for sway is needed, the following command produces
an executable with just support for wayland: an executable with support for only the `layer-shell` capture backend
and `wlroots` emulation backend:
```sh ```sh
cargo build --no-default-features --features wayland cargo build --no-default-features --features layer_shell_capture,wlroots_emulation
``` ```
For a detailed list of available features, checkout the [Cargo.toml](./Cargo.toml) For a detailed list of available features, checkout the [Cargo.toml](./Cargo.toml)
</details> </details>
## Development
## Installing Dependencies for Development / Compiling from Source ### Git pre-commit hook
This repository includes a local git hooks directory `.githooks/` with a `pre-commit` script that enforces formatting, lints, and tests before allowing a commit. It is optional to enable it, but it will prevent you from committing code with failing unit tests or that needs clippy/fmt fixes. To enable the hook locally:
1. Make the hook executable:
```sh
chmod +x .githooks/pre-commit
```
2. Point git to the hooks directory (one-time per clone):
```sh
git config core.hooksPath .githooks
```
The `pre-commit` script runs `cargo fmt --all` (and fails if files were modified), `cargo clippy --workspace --all-targets --all-features -- -D warnings`, and `cargo test --workspace --all-features`.
### Dependencies & Compiling from Source
<details> <details>
<summary>MacOS</summary> <summary>MacOS</summary>
```sh ```sh
brew install libadwaita pkg-config # Install dependencies
brew install libadwaita pkg-config imagemagick
cargo install cargo-bundle
# Create the macOS icon file
scripts/makeicns.sh
# Create the .app bundle
cargo bundle
# Copy all dynamic libraries into the bundle, and update the bundle to find them there
scripts/copy-macos-dylib.sh
``` ```
</details> </details>
@@ -267,19 +323,17 @@ If the device still can not be entered, make sure you have UDP port `4242` (or t
<details> <details>
<summary>Command Line Interface</summary> <summary>Command Line Interface</summary>
The cli interface can be enabled using `--frontend cli` as commandline arguments. The cli interface can be accessed by passing `cli` as a commandline argument.
Type `help` to list the available commands. Use
E.g.:
```sh ```sh
$ cargo run --release -- --frontend cli lan-mouse cli help
(...)
> connect <host> left|right|top|bottom
(...)
> list
(...)
> activate 0
``` ```
to list the available commands and
```sh
lan-mouse cli <cmd> help
```
for information on how to use a specific command.
</details> </details>
<details> <details>
@@ -287,11 +341,14 @@ $ cargo run --release -- --frontend cli
Lan Mouse can be launched in daemon mode to keep it running in the background (e.g. for use in a systemd-service). Lan Mouse can be launched in daemon mode to keep it running in the background (e.g. for use in a systemd-service).
To do so, add `--daemon` to the commandline args: To do so, use the `daemon` subcommand:
```sh ```sh
lan-mouse --daemon lan-mouse daemon
``` ```
</details>
## Systemd Service
In order to start lan-mouse with a graphical session automatically, In order to start lan-mouse with a graphical session automatically,
the [systemd-service](service/lan-mouse.service) can be used: the [systemd-service](service/lan-mouse.service) can be used:
@@ -303,7 +360,9 @@ cp service/lan-mouse.service ~/.config/systemd/user
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable --now lan-mouse.service systemctl --user enable --now lan-mouse.service
``` ```
</details> > [!Important]
> Make sure to point `ExecStart=/usr/bin/lan-mouse daemon` to the actual `lan-mouse` binary (in case it is not under `/usr/bin`, e.g. when installed manually.
## Configuration ## Configuration
To automatically load clients on startup, the file `$XDG_CONFIG_HOME/lan-mouse/config.toml` is parsed. To automatically load clients on startup, the file `$XDG_CONFIG_HOME/lan-mouse/config.toml` is parsed.
@@ -325,9 +384,6 @@ release_bind = [ "KeyA", "KeyS", "KeyD", "KeyF" ]
# optional port (defaults to 4242) # optional port (defaults to 4242)
port = 4242 port = 4242
# # optional frontend -> defaults to gtk if available
# # possible values are "cli" and "gtk"
# frontend = "gtk"
# list of authorized tls certificate fingerprints that # list of authorized tls certificate fingerprints that
# are accepted for incoming traffic # are accepted for incoming traffic
@@ -335,7 +391,9 @@ port = 4242
"bc:05:ab:7a:a4:de:88:8c:2f:92:ac:bc:b8:49:b8:24:0d:44:b3:e6:a4:ef:d7:0b:6c:69:6d:77:53:0b:14:80" = "iridium" "bc:05:ab:7a:a4:de:88:8c:2f:92:ac:bc:b8:49:b8:24:0d:44:b3:e6:a4:ef:d7:0b:6c:69:6d:77:53:0b:14:80" = "iridium"
# define a client on the right side with host name "iridium" # define a client on the right side with host name "iridium"
[right] [[clients]]
# position (left | right | top | bottom)
position = "right"
# hostname # hostname
hostname = "iridium" hostname = "iridium"
# activate this client immediately when lan-mouse is started # activate this client immediately when lan-mouse is started
@@ -344,7 +402,8 @@ activate_on_startup = true
ips = ["192.168.178.156"] ips = ["192.168.178.156"]
# define a client on the left side with IP address 192.168.178.189 # define a client on the left side with IP address 192.168.178.189
[left] [[clients]]
position = "left"
# The hostname is optional: When no hostname is specified, # The hostname is optional: When no hostname is specified,
# at least one ip address needs to be specified. # at least one ip address needs to be specified.
hostname = "thorium" hostname = "thorium"

View File

@@ -0,0 +1,50 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/flatpak/flatpak-builder/refs/heads/main/data/flatpak-manifest.schema.json
app-id: de.feschber.LanMouse
runtime: org.gnome.Platform
runtime-version: "48"
sdk: org.gnome.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
- org.freedesktop.Sdk.Extension.llvm20
command: /app/bin/lan-mouse
build-options:
append-path: "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm20/bin"
env:
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "clang"
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold"
"CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER": "clang"
"CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold"
build-args:
"--share=network"
prepend-ld-library-path:
"/usr/lib/sdk/llvm19/lib"
finish-args:
- "--socket=wayland"
- "--socket=fallback-x11"
- "--device=dri"
- "--socket=session-bus"
- "--share=network"
- "--filesystem=xdg-config"
- "--env=RUST_BACKTRACE=1"
- "--env=RUST_LOG=lan-mouse=debug"
- "--env=GTK_PATH=/app/lib/gtk-4.0"
modules:
- name: lan-mouse
buildsystem: simple
build-options:
build-args:
- "--share=network"
append-path: /usr/lib/sdk/rust-stable/bin
env:
CARGO_HOME: /run/build/lan-mouse/cargo
build-commands:
- cargo fetch --manifest-path Cargo.toml --verbose
- cargo build
- install -Dm0755 target/debug/lan-mouse /app/bin/lan-mouse
- install -Dm0644 lan-mouse-gtk/resources/de.feschber.LanMouse.svg ${FLATPAK_DEST}/share/icons/hicolor/scalable/apps/${FLATPAK_ID}.svg
- install -Dm0644 de.feschber.LanMouse.desktop ${FLATPAK_DEST}/share/applications/${FLATPAK_ID}.desktop
sources:
- type: dir
path: ..

View File

@@ -0,0 +1,8 @@
<key>LSUIElement</key>
<true/>
<key>NSAppSleepDisabled</key>
<true/>
<key>NSInputMonitoringUsageDescription</key>
<string>Lan Mouse needs Input Monitoring access to capture keyboard and mouse input and forward it to remote machines on your network.</string>
<key>NSAppleEventsUsageDescription</key>
<string>Lan Mouse uses Apple Events to deliver synthesized keyboard and mouse events to the system.</string>

View File

@@ -1,14 +1,10 @@
# example configuration # example configuration
# capture_backend = "LayerShell" # configure release bind
release_bind = [ "KeyA", "KeyS", "KeyD", "KeyF" ]
# release bind
release_bind = ["KeyA", "KeyS", "KeyD", "KeyF"]
# optional port (defaults to 4242) # optional port (defaults to 4242)
port = 4242 port = 4242
# optional frontend -> defaults to gtk if available
# frontend = "gtk"
# list of authorized tls certificate fingerprints that # list of authorized tls certificate fingerprints that
# are accepted for incoming traffic # are accepted for incoming traffic
@@ -16,14 +12,19 @@ port = 4242
"bc:05:ab:7a:a4:de:88:8c:2f:92:ac:bc:b8:49:b8:24:0d:44:b3:e6:a4:ef:d7:0b:6c:69:6d:77:53:0b:14:80" = "iridium" "bc:05:ab:7a:a4:de:88:8c:2f:92:ac:bc:b8:49:b8:24:0d:44:b3:e6:a4:ef:d7:0b:6c:69:6d:77:53:0b:14:80" = "iridium"
# define a client on the right side with host name "iridium" # define a client on the right side with host name "iridium"
[right] [[clients]]
# position (left | right | top | bottom)
position = "right"
# hostname # hostname
hostname = "iridium" hostname = "iridium"
# activate this client immediately when lan-mouse is started
activate_on_startup = true
# optional list of (known) ip addresses # optional list of (known) ip addresses
ips = ["192.168.178.156"] ips = ["192.168.178.156"]
# define a client on the left side with IP address 192.168.178.189 # define a client on the left side with IP address 192.168.178.189
[left] [[clients]]
position = "left"
# The hostname is optional: When no hostname is specified, # The hostname is optional: When no hostname is specified,
# at least one ip address needs to be specified. # at least one ip address needs to be specified.
hostname = "thorium" hostname = "thorium"

View File

@@ -1,14 +0,0 @@
app-id: de.feschber.LanMouse
runtime: org.freedesktop.Platform
runtime-version: '22.08'
sdk: org.freedesktop.Sdk
command: target/release/lan-mouse
modules:
- name: hello
buildsystem: simple
build-commands:
- cargo build --release
- install -D lan-mouse /app/bin/lan-mouse
sources:
- type: file
path: target/release/lan-mouse

1
dylibs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*

12
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1728018373, "lastModified": 1772963539,
"narHash": "sha256-NOiTvBbRLIOe5F6RbHaAh6++BNjsb149fGZd1T4+KBg=", "narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "bc947f541ae55e999ffdb4013441347d83b00feb", "rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -29,11 +29,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1728181869, "lastModified": 1773025773,
"narHash": "sha256-sQXHXsjIcGEoIHkB+RO6BZdrPfB+43V1TEpyoWRI3ww=", "narHash": "sha256-Wik8+xApNfldpUFjPmJkPdg0RrvUPSWGIZis+A/0N1w=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "cd46aa3906c14790ef5cbe278d9e54f2c38f95c0", "rev": "3c06fdbbd36ff60386a1e590ee0cd52dcd1892bf",
"type": "github" "type": "github"
}, },
"original": { "original": {

139
flake.nix
View File

@@ -7,60 +7,87 @@
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
}; };
outputs = { outputs =
self, {
nixpkgs, nixpkgs,
rust-overlay, rust-overlay,
... self,
}: let ...
inherit (nixpkgs) lib; }:
genSystems = lib.genAttrs [ let
"aarch64-darwin" inherit (nixpkgs) lib;
"aarch64-linux" forEachPkgs =
"x86_64-darwin" f:
"x86_64-linux" lib.genAttrs
]; [
pkgsFor = system: "aarch64-darwin"
import nixpkgs { "aarch64-linux"
inherit system; "x86_64-darwin"
"x86_64-linux"
overlays = [ ]
rust-overlay.overlays.default (
]; system:
}; let
mkRustToolchain = pkgs: pkgs = import nixpkgs {
pkgs.rust-bin.stable.latest.default.override { inherit system;
extensions = ["rust-src"]; overlays = [ rust-overlay.overlays.default ];
}; };
pkgs = genSystems (system: import nixpkgs {inherit system;}); # Default toolchain for devshell
in { rustToolchain = pkgs.rust-bin.stable.latest.default.override {
packages = genSystems (system: rec { extensions = [
default = pkgs.${system}.callPackage ./nix {}; # includes already:
lan-mouse = default; # rustc
}); # cargo
homeManagerModules.default = import ./nix/hm-module.nix self; # rust-std
devShells = genSystems (system: let # rust-docs
pkgs = pkgsFor system; # rustfmt-preview
rust = mkRustToolchain pkgs; # clippy-preview
in { "rust-analyzer"
default = pkgs.mkShell { "rust-src"
packages = with pkgs; [ ];
rust };
rust-analyzer-unwrapped # Minimal toolchain for builds (rustc + cargo + rust-std only)
pkg-config rustToolchainForBuild = pkgs.rust-bin.stable.latest.minimal;
xorg.libX11 in
gtk4 f { inherit pkgs rustToolchain rustToolchainForBuild; }
libadwaita );
librsvg in
xorg.libXtst {
] ++ lib.optionals stdenv.isDarwin packages = forEachPkgs (
(with darwin.apple_sdk_11_0.frameworks; [ { pkgs, rustToolchainForBuild, ... }:
CoreGraphics let
ApplicationServices customRustPlatform = pkgs.makeRustPlatform {
]); cargo = rustToolchainForBuild;
rustc = rustToolchainForBuild;
RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library"; };
}; lan-mouse = pkgs.callPackage ./nix { rustPlatform = customRustPlatform; };
}); in
}; {
default = lan-mouse;
inherit lan-mouse;
}
);
devShells = forEachPkgs (
{ pkgs, rustToolchain, ... }:
{
default = pkgs.mkShell {
packages =
with pkgs;
[
rustToolchain
pkg-config
gtk4
libadwaita
librsvg
]
++ lib.optionals pkgs.stdenv.isLinux [
libX11
libXtst
];
env.RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
};
}
);
homeManagerModules.default = import ./nix/hm-module.nix self;
};
} }

View File

@@ -12,7 +12,7 @@ futures-core = "0.3.30"
log = "0.4.22" log = "0.4.22"
input-event = { path = "../input-event", version = "0.3.0" } input-event = { path = "../input-event", version = "0.3.0" }
memmap = "0.7" memmap = "0.7"
tempfile = "3.8" tempfile = "3.25.0"
thiserror = "2.0.0" thiserror = "2.0.0"
tokio = { version = "1.32.0", features = [ tokio = { version = "1.32.0", features = [
"io-util", "io-util",
@@ -23,6 +23,7 @@ tokio = { version = "1.32.0", features = [
"rt", "rt",
"sync", "sync",
"signal", "signal",
"time",
] } ] }
once_cell = "1.19.0" once_cell = "1.19.0"
async-trait = "0.1.81" async-trait = "0.1.81"
@@ -40,21 +41,22 @@ wayland-protocols-wlr = { version = "0.3.1", features = [
"client", "client",
], optional = true } ], optional = true }
x11 = { version = "2.21.0", features = ["xlib", "xtest"], optional = true } x11 = { version = "2.21.0", features = ["xlib", "xtest"], optional = true }
ashpd = { version = "0.10", default-features = false, features = [ ashpd = { version = "0.13.9", default-features = false, features = [
"input_capture",
"tokio", "tokio",
], optional = true } ], optional = true }
reis = { version = "0.4", features = ["tokio"], optional = true } reis = { version = "0.5.0", features = ["tokio"], optional = true }
[target.'cfg(target_os="macos")'.dependencies] [target.'cfg(target_os="macos")'.dependencies]
core-graphics = { version = "0.24.0", features = ["highsierra"] } core-graphics = { version = "0.25.0", features = ["highsierra"] }
core-foundation = "0.10.0" core-foundation = "0.10.0"
core-foundation-sys = "0.8.6" core-foundation-sys = "0.8.6"
libc = "0.2.155" libc = "0.2.155"
keycode = "0.4.0" keycode = "1.0.0"
bitflags = "2.6.0" bitflags = "2.6.0"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.58.0", features = [ windows = { version = "0.61.2", features = [
"Win32_System_LibraryLoader", "Win32_System_LibraryLoader",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_Foundation", "Win32_Foundation",

25
input-capture/build.rs Normal file
View File

@@ -0,0 +1,25 @@
fn main() {
let unix = cfg!(unix);
let layer_shell = cfg!(feature = "layer_shell");
let libei = cfg!(feature = "libei");
let x11 = cfg!(feature = "x11");
let macos = cfg!(target_os = "macos");
let libei = unix && !macos && libei;
let layer_shell = unix && !macos && layer_shell;
let x11 = unix && !macos && x11;
println!("cargo::rustc-check-cfg=cfg(layer_shell)");
println!("cargo::rustc-check-cfg=cfg(libei)");
println!("cargo::rustc-check-cfg=cfg(x11)");
if layer_shell {
println!("cargo::rustc-cfg=layer_shell");
}
if libei {
println!("cargo::rustc-cfg=libei");
}
if x11 {
println!("cargo::rustc-cfg=x11");
}
}

View File

@@ -1,6 +1,6 @@
use std::f64::consts::PI; use std::f64::consts::PI;
use std::pin::Pin; use std::pin::Pin;
use std::task::{ready, Context, Poll}; use std::task::{Context, Poll, ready};
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;

View File

@@ -8,16 +8,16 @@ pub enum InputCaptureError {
Capture(#[from] CaptureError), Capture(#[from] CaptureError),
} }
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] #[cfg(layer_shell)]
use std::io; use std::io;
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] #[cfg(layer_shell)]
use wayland_client::{ use wayland_client::{
ConnectError, DispatchError,
backend::WaylandError, backend::WaylandError,
globals::{BindError, GlobalError}, globals::{BindError, GlobalError},
ConnectError, DispatchError,
}; };
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
use ashpd::desktop::ResponseError; use ashpd::desktop::ResponseError;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -31,13 +31,13 @@ pub enum CaptureError {
EndOfStream, EndOfStream,
#[error("io error: `{0}`")] #[error("io error: `{0}`")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
#[error("libei error: `{0}`")] #[error("libei error: `{0}`")]
Reis(#[from] reis::Error), Reis(#[from] reis::Error),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
#[error(transparent)] #[error(transparent)]
Portal(#[from] ashpd::Error), Portal(#[from] ashpd::Error),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
#[error("libei disconnected - reason: `{0}`")] #[error("libei disconnected - reason: `{0}`")]
Disconnected(String), Disconnected(String),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -61,13 +61,13 @@ pub enum CaptureError {
pub enum CaptureCreationError { pub enum CaptureCreationError {
#[error("no backend available")] #[error("no backend available")]
NoAvailableBackend, NoAvailableBackend,
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
#[error("error creating input-capture-portal backend: `{0}`")] #[error("error creating input-capture-portal backend: `{0}`")]
Libei(#[from] LibeiCaptureCreationError), Libei(#[from] LibeiCaptureCreationError),
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] #[cfg(layer_shell)]
#[error("error creating layer-shell capture backend: `{0}`")] #[error("error creating layer-shell capture backend: `{0}`")]
LayerShell(#[from] LayerShellCaptureCreationError), LayerShell(#[from] LayerShellCaptureCreationError),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
#[error("error creating x11 capture backend: `{0}`")] #[error("error creating x11 capture backend: `{0}`")]
X11(#[from] X11InputCaptureCreationError), X11(#[from] X11InputCaptureCreationError),
#[cfg(windows)] #[cfg(windows)]
@@ -80,7 +80,7 @@ pub enum CaptureCreationError {
impl CaptureCreationError { impl CaptureCreationError {
/// request was intentionally denied by the user /// request was intentionally denied by the user
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
pub(crate) fn cancelled_by_user(&self) -> bool { pub(crate) fn cancelled_by_user(&self) -> bool {
matches!( matches!(
self, self,
@@ -89,20 +89,20 @@ impl CaptureCreationError {
))) )))
) )
} }
#[cfg(not(all(unix, feature = "libei", not(target_os = "macos"))))] #[cfg(not(libei))]
pub(crate) fn cancelled_by_user(&self) -> bool { pub(crate) fn cancelled_by_user(&self) -> bool {
false false
} }
} }
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum LibeiCaptureCreationError { pub enum LibeiCaptureCreationError {
#[error("xdg-desktop-portal: `{0}`")] #[error("xdg-desktop-portal: `{0}`")]
Ashpd(#[from] ashpd::Error), Ashpd(#[from] ashpd::Error),
} }
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] #[cfg(layer_shell)]
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[error("{protocol} protocol not supported: {inner}")] #[error("{protocol} protocol not supported: {inner}")]
pub struct WaylandBindError { pub struct WaylandBindError {
@@ -110,14 +110,14 @@ pub struct WaylandBindError {
protocol: &'static str, protocol: &'static str,
} }
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] #[cfg(layer_shell)]
impl WaylandBindError { impl WaylandBindError {
pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self { pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self {
Self { inner, protocol } Self { inner, protocol }
} }
} }
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] #[cfg(layer_shell)]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum LayerShellCaptureCreationError { pub enum LayerShellCaptureCreationError {
#[error(transparent)] #[error(transparent)]
@@ -134,7 +134,7 @@ pub enum LayerShellCaptureCreationError {
Io(#[from] io::Error), Io(#[from] io::Error),
} }
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum X11InputCaptureCreationError { pub enum X11InputCaptureCreationError {
#[error("X11 input capture is not yet implemented :(")] #[error("X11 input capture is not yet implemented :(")]
@@ -149,6 +149,10 @@ pub enum MacosCaptureCreationError {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[error("event tap creation failed")] #[error("event tap creation failed")]
EventTapCreation, EventTapCreation,
#[error("accessibility permission is required")]
AccessibilityPermission,
#[error("input monitoring permission is required")]
InputMonitoringPermission,
#[error("failed to set CG Cursor property")] #[error("failed to set CG Cursor property")]
CGCursorProperty, CGCursorProperty,
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]

View File

@@ -1,12 +1,13 @@
use async_trait::async_trait; use async_trait::async_trait;
use futures_core::Stream; use futures_core::Stream;
use std::{ use std::{
collections::VecDeque, collections::{HashSet, VecDeque},
env, env,
fmt::{self, Display},
io::{self, ErrorKind}, io::{self, ErrorKind},
os::fd::{AsFd, RawFd}, os::fd::{AsFd, RawFd},
pin::Pin, pin::Pin,
task::{ready, Context, Poll}, task::{Context, Poll, ready},
}; };
use tokio::io::unix::AsyncFd; use tokio::io::unix::AsyncFd;
@@ -44,18 +45,20 @@ use wayland_protocols_wlr::layer_shell::v1::client::{
}; };
use wayland_client::{ use wayland_client::{
Connection, Dispatch, DispatchError, EventQueue, QueueHandle, WEnum,
backend::{ReadEventsGuard, WaylandError}, backend::{ReadEventsGuard, WaylandError},
delegate_noop, delegate_noop,
globals::{registry_queue_init, GlobalListContents}, globals::{Global, GlobalList, GlobalListContents, registry_queue_init},
protocol::{ protocol::{
wl_buffer, wl_compositor, wl_buffer, wl_compositor,
wl_keyboard::{self, WlKeyboard}, wl_keyboard::{self, WlKeyboard},
wl_output::{self, WlOutput}, wl_output::{self, WlOutput},
wl_pointer::{self, WlPointer}, wl_pointer::{self, WlPointer},
wl_region, wl_registry, wl_seat, wl_shm, wl_shm_pool, wl_region,
wl_registry::{self, WlRegistry},
wl_seat, wl_shm, wl_shm_pool,
wl_surface::WlSurface, wl_surface::WlSurface,
}, },
Connection, Dispatch, DispatchError, EventQueue, QueueHandle, WEnum,
}; };
use input_event::{Event, KeyboardEvent, PointerEvent}; use input_event::{Event, KeyboardEvent, PointerEvent};
@@ -63,8 +66,8 @@ use input_event::{Event, KeyboardEvent, PointerEvent};
use crate::{CaptureError, CaptureEvent}; use crate::{CaptureError, CaptureEvent};
use super::{ use super::{
error::{LayerShellCaptureCreationError, WaylandBindError},
Capture, Position, Capture, Position,
error::{LayerShellCaptureCreationError, WaylandBindError},
}; };
struct Globals { struct Globals {
@@ -75,28 +78,42 @@ struct Globals {
seat: wl_seat::WlSeat, seat: wl_seat::WlSeat,
shm: wl_shm::WlShm, shm: wl_shm::WlShm,
layer_shell: ZwlrLayerShellV1, layer_shell: ZwlrLayerShellV1,
outputs: Vec<WlOutput>,
xdg_output_manager: ZxdgOutputManagerV1, xdg_output_manager: ZxdgOutputManagerV1,
} }
#[derive(Debug, Clone)] #[derive(Clone, Debug)]
struct Output {
wl_output: WlOutput,
global: Global,
info: Option<OutputInfo>,
pending_info: OutputInfo,
has_xdg_info: bool,
}
impl Display for Output {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(info) = &self.info {
write!(
f,
"{} {}x{} @pos {:?} ({})",
info.name, info.size.0, info.size.1, info.position, info.description
)
} else {
write!(f, "unknown output")
}
}
}
#[derive(Clone, Debug, Default)]
struct OutputInfo { struct OutputInfo {
description: String,
name: String, name: String,
position: (i32, i32), position: (i32, i32),
size: (i32, i32), size: (i32, i32),
} }
impl OutputInfo {
fn new() -> Self {
Self {
name: "".to_string(),
position: (0, 0),
size: (0, 0),
}
}
}
struct State { struct State {
active_positions: HashSet<Position>,
pointer: Option<WlPointer>, pointer: Option<WlPointer>,
keyboard: Option<WlKeyboard>, keyboard: Option<WlKeyboard>,
pointer_lock: Option<ZwpLockedPointerV1>, pointer_lock: Option<ZwpLockedPointerV1>,
@@ -104,12 +121,13 @@ struct State {
shortcut_inhibitor: Option<ZwpKeyboardShortcutsInhibitorV1>, shortcut_inhibitor: Option<ZwpKeyboardShortcutsInhibitorV1>,
active_windows: Vec<Arc<Window>>, active_windows: Vec<Arc<Window>>,
focused: Option<Arc<Window>>, focused: Option<Arc<Window>>,
g: Globals, global_list: GlobalList,
globals: Globals,
wayland_fd: RawFd, wayland_fd: RawFd,
read_guard: Option<ReadEventsGuard>, read_guard: Option<ReadEventsGuard>,
qh: QueueHandle<Self>, qh: QueueHandle<Self>,
pending_events: VecDeque<(Position, CaptureEvent)>, pending_events: VecDeque<(Position, CaptureEvent)>,
output_info: Vec<(WlOutput, OutputInfo)>, outputs: Vec<Output>,
scroll_discrete_pending: bool, scroll_discrete_pending: bool,
} }
@@ -142,7 +160,7 @@ impl Window {
size: (i32, i32), size: (i32, i32),
) -> Window { ) -> Window {
log::debug!("creating window output: {output:?}, size: {size:?}"); log::debug!("creating window output: {output:?}, size: {size:?}");
let g = &state.g; let g = &state.globals;
let (width, height) = match pos { let (width, height) = match pos {
Position::Left | Position::Right => (1, size.1 as u32), Position::Left | Position::Right => (1, size.1 as u32),
@@ -203,41 +221,36 @@ impl Drop for Window {
} }
} }
fn get_edges(outputs: &[(WlOutput, OutputInfo)], pos: Position) -> Vec<(WlOutput, i32)> { fn get_edges(outputs: &[Output], pos: Position) -> Vec<(Output, i32)> {
outputs outputs
.iter() .iter()
.map(|(o, i)| { .filter_map(|output| {
( output.info.as_ref().map(|info| {
o.clone(), (
match pos { output.clone(),
Position::Left => i.position.0, match pos {
Position::Right => i.position.0 + i.size.0, Position::Left => info.position.0,
Position::Top => i.position.1, Position::Right => info.position.0 + info.size.0,
Position::Bottom => i.position.1 + i.size.1, Position::Top => info.position.1,
}, Position::Bottom => info.position.1 + info.size.1,
) },
)
})
}) })
.collect() .collect()
} }
fn get_output_configuration(state: &State, pos: Position) -> Vec<(WlOutput, OutputInfo)> { fn get_output_configuration(state: &State, pos: Position) -> Vec<Output> {
// get all output edges corresponding to the position // get all output edges corresponding to the position
let edges = get_edges(&state.output_info, pos); let edges = get_edges(&state.outputs, pos);
log::debug!("edges: {edges:?}"); let opposite_edges = get_edges(&state.outputs, pos.opposite());
let opposite_edges = get_edges(&state.output_info, pos.opposite());
// remove those edges that are at the same position // remove those edges that are at the same position
// as an opposite edge of a different output // as an opposite edge of a different output
let outputs: Vec<WlOutput> = edges edges
.iter() .iter()
.filter(|(_, edge)| !opposite_edges.iter().map(|(_, e)| *e).any(|e| &e == edge)) .filter(|(_, edge)| !opposite_edges.iter().map(|(_, e)| *e).any(|e| &e == edge))
.map(|(o, _)| o.clone()) .map(|(o, _)| o.clone())
.collect();
state
.output_info
.iter()
.filter(|(o, _)| outputs.contains(o))
.map(|(o, i)| (o.clone(), i.clone()))
.collect() .collect()
} }
@@ -259,36 +272,36 @@ fn draw(f: &mut File, (width, height): (u32, u32)) {
impl LayerShellInputCapture { impl LayerShellInputCapture {
pub fn new() -> std::result::Result<Self, LayerShellCaptureCreationError> { pub fn new() -> std::result::Result<Self, LayerShellCaptureCreationError> {
let conn = Connection::connect_to_env()?; let conn = Connection::connect_to_env()?;
let (g, mut queue) = registry_queue_init::<State>(&conn)?; let (global_list, mut queue) = registry_queue_init::<State>(&conn)?;
let qh = queue.handle(); let qh = queue.handle();
let compositor: wl_compositor::WlCompositor = g let compositor: wl_compositor::WlCompositor = global_list
.bind(&qh, 4..=5, ()) .bind(&qh, 4..=5, ())
.map_err(|e| WaylandBindError::new(e, "wl_compositor 4..=5"))?; .map_err(|e| WaylandBindError::new(e, "wl_compositor 4..=5"))?;
let xdg_output_manager: ZxdgOutputManagerV1 = g let xdg_output_manager: ZxdgOutputManagerV1 = global_list
.bind(&qh, 1..=3, ()) .bind(&qh, 1..=3, ())
.map_err(|e| WaylandBindError::new(e, "xdg_output_manager 1..=3"))?; .map_err(|e| WaylandBindError::new(e, "xdg_output_manager 1..=3"))?;
let shm: wl_shm::WlShm = g let shm: wl_shm::WlShm = global_list
.bind(&qh, 1..=1, ()) .bind(&qh, 1..=1, ())
.map_err(|e| WaylandBindError::new(e, "wl_shm"))?; .map_err(|e| WaylandBindError::new(e, "wl_shm"))?;
let layer_shell: ZwlrLayerShellV1 = g let layer_shell: ZwlrLayerShellV1 = global_list
.bind(&qh, 3..=4, ()) .bind(&qh, 3..=4, ())
.map_err(|e| WaylandBindError::new(e, "wlr_layer_shell 3..=4"))?; .map_err(|e| WaylandBindError::new(e, "wlr_layer_shell 3..=4"))?;
let seat: wl_seat::WlSeat = g let seat: wl_seat::WlSeat = global_list
.bind(&qh, 7..=8, ()) .bind(&qh, 7..=8, ())
.map_err(|e| WaylandBindError::new(e, "wl_seat 7..=8"))?; .map_err(|e| WaylandBindError::new(e, "wl_seat 7..=8"))?;
let pointer_constraints: ZwpPointerConstraintsV1 = g let pointer_constraints: ZwpPointerConstraintsV1 = global_list
.bind(&qh, 1..=1, ()) .bind(&qh, 1..=1, ())
.map_err(|e| WaylandBindError::new(e, "zwp_pointer_constraints_v1"))?; .map_err(|e| WaylandBindError::new(e, "zwp_pointer_constraints_v1"))?;
let relative_pointer_manager: ZwpRelativePointerManagerV1 = g let relative_pointer_manager: ZwpRelativePointerManagerV1 = global_list
.bind(&qh, 1..=1, ()) .bind(&qh, 1..=1, ())
.map_err(|e| WaylandBindError::new(e, "zwp_relative_pointer_manager_v1"))?; .map_err(|e| WaylandBindError::new(e, "zwp_relative_pointer_manager_v1"))?;
let shortcut_inhibit_manager: Result< let shortcut_inhibit_manager: Result<
ZwpKeyboardShortcutsInhibitManagerV1, ZwpKeyboardShortcutsInhibitManagerV1,
WaylandBindError, WaylandBindError,
> = g > = global_list
.bind(&qh, 1..=1, ()) .bind(&qh, 1..=1, ())
.map_err(|e| WaylandBindError::new(e, "zwp_keyboard_shortcuts_inhibit_manager_v1")); .map_err(|e| WaylandBindError::new(e, "zwp_keyboard_shortcuts_inhibit_manager_v1"));
// layer-shell backend still works without this protocol so we make it an optional dependency // layer-shell backend still works without this protocol so we make it an optional dependency
@@ -297,65 +310,41 @@ impl LayerShellInputCapture {
to the client"); to the client");
} }
let shortcut_inhibit_manager = shortcut_inhibit_manager.ok(); let shortcut_inhibit_manager = shortcut_inhibit_manager.ok();
let outputs = vec![];
let g = Globals {
compositor,
shm,
layer_shell,
seat,
pointer_constraints,
relative_pointer_manager,
shortcut_inhibit_manager,
outputs,
xdg_output_manager,
};
// flush outgoing events
queue.flush()?;
let wayland_fd = queue.as_fd().as_raw_fd();
let mut state = State { let mut state = State {
active_positions: Default::default(),
pointer: None, pointer: None,
keyboard: None, keyboard: None,
g, global_list,
globals: Globals {
compositor,
shm,
layer_shell,
seat,
pointer_constraints,
relative_pointer_manager,
shortcut_inhibit_manager,
xdg_output_manager,
},
pointer_lock: None, pointer_lock: None,
rel_pointer: None, rel_pointer: None,
shortcut_inhibitor: None, shortcut_inhibitor: None,
active_windows: Vec::new(), active_windows: Vec::new(),
focused: None, focused: None,
qh, qh,
wayland_fd, wayland_fd: queue.as_fd().as_raw_fd(),
read_guard: None, read_guard: None,
pending_events: VecDeque::new(), pending_events: VecDeque::new(),
output_info: vec![], outputs: vec![],
scroll_discrete_pending: false, scroll_discrete_pending: false,
}; };
// dispatch registry to () again, in order to read all wl_outputs for global in state.global_list.contents().clone_list() {
conn.display().get_registry(&state.qh, ()); state.register_global(global);
log::debug!("==============> requested registry");
// roundtrip to read wl_output globals
queue.roundtrip(&mut state)?;
log::debug!("==============> roundtrip 1 done");
// read outputs
for output in state.g.outputs.iter() {
state
.g
.xdg_output_manager
.get_xdg_output(output, &state.qh, output.clone());
} }
// roundtrip to read xdg_output events // flush outgoing events
queue.roundtrip(&mut state)?; queue.flush()?;
log::debug!("==============> roundtrip 2 done");
for i in &state.output_info {
log::debug!("{:#?}", i.1);
}
let read_guard = loop { let read_guard = loop {
match queue.prepare_read() { match queue.prepare_read() {
@@ -379,6 +368,7 @@ impl LayerShellInputCapture {
fn delete_client(&mut self, pos: Position) { fn delete_client(&mut self, pos: Position) {
let inner = self.0.get_mut(); let inner = self.0.get_mut();
inner.state.active_positions.remove(&pos);
// remove all windows corresponding to this client // remove all windows corresponding to this client
while let Some(i) = inner.state.active_windows.iter().position(|w| w.pos == pos) { while let Some(i) = inner.state.active_windows.iter().position(|w| w.pos == pos) {
inner.state.active_windows.remove(i); inner.state.active_windows.remove(i);
@@ -388,6 +378,52 @@ impl LayerShellInputCapture {
} }
impl State { impl State {
fn update_output_info(&mut self, name: u32) {
let output = self
.outputs
.iter_mut()
.find(|o| o.global.name == name)
.expect("output not found");
if output.has_xdg_info {
output.info.replace(output.pending_info.clone());
self.update_windows();
}
}
fn register_global(&mut self, global: Global) {
if global.interface.as_str() == "wl_output" {
log::debug!("new output global: wl_output {}", global.name);
let wl_output = self.global_list.registry().bind::<WlOutput, _, _>(
global.name,
4,
&self.qh,
global.name,
);
self.globals
.xdg_output_manager
.get_xdg_output(&wl_output, &self.qh, global.name);
self.outputs.push(Output {
wl_output,
global,
info: None,
has_xdg_info: false,
pending_info: Default::default(),
})
}
}
fn deregister_global(&mut self, name: u32) {
self.outputs.retain(|o| {
if o.global.name == name {
log::debug!("{o} (global {:?}) removed", o.global);
o.wl_output.release();
false
} else {
true
}
});
}
fn grab( fn grab(
&mut self, &mut self,
surface: &WlSurface, surface: &WlSurface,
@@ -408,7 +444,7 @@ impl State {
// lock pointer // lock pointer
if self.pointer_lock.is_none() { if self.pointer_lock.is_none() {
self.pointer_lock = Some(self.g.pointer_constraints.lock_pointer( self.pointer_lock = Some(self.globals.pointer_constraints.lock_pointer(
surface, surface,
pointer, pointer,
None, None,
@@ -420,7 +456,7 @@ impl State {
// request relative input // request relative input
if self.rel_pointer.is_none() { if self.rel_pointer.is_none() {
self.rel_pointer = Some(self.g.relative_pointer_manager.get_relative_pointer( self.rel_pointer = Some(self.globals.relative_pointer_manager.get_relative_pointer(
pointer, pointer,
qh, qh,
(), (),
@@ -428,10 +464,14 @@ impl State {
} }
// capture modifier keys // capture modifier keys
if let Some(shortcut_inhibit_manager) = &self.g.shortcut_inhibit_manager { if let Some(shortcut_inhibit_manager) = &self.globals.shortcut_inhibit_manager {
if self.shortcut_inhibitor.is_none() { if self.shortcut_inhibitor.is_none() {
self.shortcut_inhibitor = self.shortcut_inhibitor = Some(shortcut_inhibit_manager.inhibit_shortcuts(
Some(shortcut_inhibit_manager.inhibit_shortcuts(surface, &self.g.seat, qh, ())); surface,
&self.globals.seat,
qh,
(),
));
} }
} }
} }
@@ -469,21 +509,39 @@ impl State {
} }
fn add_client(&mut self, pos: Position) { fn add_client(&mut self, pos: Position) {
self.active_positions.insert(pos);
let outputs = get_output_configuration(self, pos); let outputs = get_output_configuration(self, pos);
log::debug!("outputs: {outputs:?}"); log::info!(
outputs.iter().for_each(|(o, i)| { "adding capture for position {pos} - using outputs: {:?}",
let window = Window::new(self, &self.qh, o, pos, i.size); outputs
let window = Arc::new(window); .iter()
self.active_windows.push(window); .map(|o| o
.info
.as_ref()
.map(|i| i.name.to_owned())
.unwrap_or("unknown output".to_owned()))
.collect::<Vec<_>>()
);
outputs.iter().for_each(|o| {
if let Some(info) = o.info.as_ref() {
let window = Window::new(self, &self.qh, &o.wl_output, pos, info.size);
let window = Arc::new(window);
self.active_windows.push(window);
}
}); });
} }
fn update_windows(&mut self) { fn update_windows(&mut self) {
log::debug!("updating windows"); log::info!("active outputs: ");
log::debug!("output info: {:?}", self.output_info); for output in self.outputs.iter().filter(|o| o.info.is_some()) {
let clients: Vec<_> = self.active_windows.drain(..).map(|w| w.pos).collect(); log::info!(" * {output}");
for pos in clients { }
self.active_windows.clear();
let active_positions = self.active_positions.iter().cloned().collect::<Vec<_>>();
for pos in active_positions {
self.add_client(pos); self.add_client(pos);
} }
} }
@@ -524,17 +582,17 @@ impl Inner {
match self.queue.dispatch_pending(&mut self.state) { match self.queue.dispatch_pending(&mut self.state) {
Ok(_) => {} Ok(_) => {}
Err(DispatchError::Backend(WaylandError::Io(e))) => { Err(DispatchError::Backend(WaylandError::Io(e))) => {
log::error!("Wayland Error: {}", e); log::error!("Wayland Error: {e}");
} }
Err(DispatchError::Backend(e)) => { Err(DispatchError::Backend(e)) => {
panic!("backend error: {}", e); panic!("backend error: {e}");
} }
Err(DispatchError::BadMessage { Err(DispatchError::BadMessage {
sender_id, sender_id,
interface, interface,
opcode, opcode,
}) => { }) => {
panic!("bad message {}, {} , {}", sender_id, interface, opcode); panic!("bad message {sender_id}, {interface} , {opcode}");
} }
} }
} }
@@ -755,7 +813,7 @@ impl Dispatch<WlPointer, ()> for State {
})), })),
)); ));
} }
wl_pointer::Event::Frame {} => { wl_pointer::Event::Frame => {
// TODO properly handle frame events // TODO properly handle frame events
// we simply insert a frame event on the client side // we simply insert a frame event on the client side
// after each event for now // after each event for now
@@ -835,7 +893,7 @@ impl Dispatch<ZwpRelativePointerV1, ()> for State {
} = event } = event
{ {
if let Some(window) = &app.focused { if let Some(window) = &app.focused {
let time = (((utime_hi as u64) << 32 | utime_lo as u64) / 1000) as u32; let time = ((((utime_hi as u64) << 32) | utime_lo as u64) / 1000) as u32;
app.pending_events.push_back(( app.pending_events.push_back((
window.pos, window.pos,
CaptureEvent::Input(Event::Pointer(PointerEvent::Motion { time, dx, dy })), CaptureEvent::Input(Event::Pointer(PointerEvent::Motion { time, dx, dy })),
@@ -872,94 +930,89 @@ impl Dispatch<ZwlrLayerSurfaceV1, ()> for State {
} }
// delegate wl_registry events to App itself // delegate wl_registry events to App itself
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State { impl Dispatch<WlRegistry, GlobalListContents> for State {
fn event(
_state: &mut Self,
_proxy: &wl_registry::WlRegistry,
_event: <wl_registry::WlRegistry as wayland_client::Proxy>::Event,
_data: &GlobalListContents,
_conn: &Connection,
_qhandle: &QueueHandle<Self>,
) {
}
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event( fn event(
state: &mut Self, state: &mut Self,
registry: &wl_registry::WlRegistry, _registry: &WlRegistry,
event: <wl_registry::WlRegistry as wayland_client::Proxy>::Event, event: <WlRegistry as wayland_client::Proxy>::Event,
_: &(), _data: &GlobalListContents,
_: &Connection, _conn: &Connection,
qh: &QueueHandle<Self>, _qh: &QueueHandle<Self>,
) { ) {
match event { match event {
wl_registry::Event::Global { wl_registry::Event::Global {
name, name,
interface, interface,
version: _, version,
} => { } => {
if interface.as_str() == "wl_output" { state.register_global(Global {
log::debug!("wl_output global"); name,
state interface,
.g version,
.outputs });
.push(registry.bind::<WlOutput, _, _>(name, 4, qh, ())) }
} wl_registry::Event::GlobalRemove { name } => {
state.deregister_global(name);
} }
wl_registry::Event::GlobalRemove { .. } => {}
_ => {} _ => {}
} }
} }
} }
impl Dispatch<ZxdgOutputV1, WlOutput> for State { impl Dispatch<ZxdgOutputV1, u32> for State {
fn event( fn event(
state: &mut Self, state: &mut Self,
_: &ZxdgOutputV1, _: &ZxdgOutputV1,
event: <ZxdgOutputV1 as wayland_client::Proxy>::Event, event: <ZxdgOutputV1 as wayland_client::Proxy>::Event,
wl_output: &WlOutput, name: &u32,
_: &Connection, _: &Connection,
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
) { ) {
log::debug!("xdg-output - {event:?}"); let output = state
let output_info = match state.output_info.iter_mut().find(|(o, _)| o == wl_output) { .outputs
Some((_, c)) => c, .iter_mut()
None => { .find(|o| o.global.name == *name)
let output_info = OutputInfo::new(); .expect("output");
state.output_info.push((wl_output.clone(), output_info));
&mut state.output_info.last_mut().unwrap().1
}
};
log::debug!("xdg_output {name} - {event:?}");
match event { match event {
zxdg_output_v1::Event::LogicalPosition { x, y } => { zxdg_output_v1::Event::LogicalPosition { x, y } => {
output_info.position = (x, y); output.pending_info.position = (x, y);
output.has_xdg_info = true;
} }
zxdg_output_v1::Event::LogicalSize { width, height } => { zxdg_output_v1::Event::LogicalSize { width, height } => {
output_info.size = (width, height); output.pending_info.size = (width, height);
output.has_xdg_info = true;
}
zxdg_output_v1::Event::Done => {
log::warn!("Use of deprecated xdg-output event \"done\"");
state.update_output_info(*name);
} }
zxdg_output_v1::Event::Done => {}
zxdg_output_v1::Event::Name { name } => { zxdg_output_v1::Event::Name { name } => {
output_info.name = name; output.pending_info.name = name;
output.has_xdg_info = true;
} }
zxdg_output_v1::Event::Description { .. } => {} zxdg_output_v1::Event::Description { description } => {
_ => {} output.pending_info.description = description;
output.has_xdg_info = true;
}
_ => todo!(),
} }
} }
} }
impl Dispatch<WlOutput, ()> for State { impl Dispatch<WlOutput, u32> for State {
fn event( fn event(
state: &mut Self, state: &mut Self,
_proxy: &WlOutput, _wl_output: &WlOutput,
event: <WlOutput as wayland_client::Proxy>::Event, event: <WlOutput as wayland_client::Proxy>::Event,
_data: &(), name: &u32,
_conn: &Connection, _conn: &Connection,
_qhandle: &QueueHandle<Self>, _qhandle: &QueueHandle<Self>,
) { ) {
log::debug!("wl_output {name} - {event:?}");
if let wl_output::Event::Done = event { if let wl_output::Event::Done = event {
state.update_windows(); state.update_output_info(*name);
} }
} }
} }

View File

@@ -2,32 +2,32 @@ use std::{
collections::{HashMap, HashSet, VecDeque}, collections::{HashMap, HashSet, VecDeque},
fmt::Display, fmt::Display,
mem::swap, mem::swap,
task::{ready, Poll}, task::{Poll, ready},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use futures::StreamExt; use futures::StreamExt;
use futures_core::Stream; use futures_core::Stream;
use input_event::{scancode, Event, KeyboardEvent}; use input_event::{Event, KeyboardEvent, scancode};
pub use error::{CaptureCreationError, CaptureError, InputCaptureError}; pub use error::{CaptureCreationError, CaptureError, InputCaptureError};
pub mod error; pub mod error;
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
mod libei; mod libei;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod macos; mod macos;
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] #[cfg(layer_shell)]
mod layer_shell; mod layer_shell;
#[cfg(windows)] #[cfg(windows)]
mod windows; mod windows;
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
mod x11; mod x11;
/// fallback input capture (does not produce events) /// fallback input capture (does not produce events)
@@ -79,17 +79,17 @@ impl Display for Position {
Position::Top => "top", Position::Top => "top",
Position::Bottom => "bottom", Position::Bottom => "bottom",
}; };
write!(f, "{}", pos) write!(f, "{pos}")
} }
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Backend { pub enum Backend {
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
InputCapturePortal, InputCapturePortal,
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] #[cfg(layer_shell)]
LayerShell, LayerShell,
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
X11, X11,
#[cfg(windows)] #[cfg(windows)]
Windows, Windows,
@@ -101,11 +101,11 @@ pub enum Backend {
impl Display for Backend { impl Display for Backend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
Backend::InputCapturePortal => write!(f, "input-capture-portal"), Backend::InputCapturePortal => write!(f, "input-capture-portal"),
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] #[cfg(layer_shell)]
Backend::LayerShell => write!(f, "layer-shell"), Backend::LayerShell => write!(f, "layer-shell"),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
Backend::X11 => write!(f, "X11"), Backend::X11 => write!(f, "X11"),
#[cfg(windows)] #[cfg(windows)]
Backend::Windows => write!(f, "windows"), Backend::Windows => write!(f, "windows"),
@@ -171,6 +171,19 @@ impl InputCapture {
self.capture.release().await self.capture.release().await
} }
/// Drain and return every key the capture has forwarded as
/// down-but-not-up. The caller is expected to synthesize key-up
/// events to the remote peer for each — otherwise the peer
/// retains phantom-held keys after capture is released. The
/// canonical case is the release-bind chord
/// (Ctrl+Shift+Alt+Meta): the down events were sent while
/// capture was active, but the matching up events arrive after
/// the local tap has flipped to passthrough and never reach
/// the peer.
pub fn take_pressed_keys(&mut self) -> HashSet<scancode::Linux> {
std::mem::take(&mut self.pressed_keys)
}
/// destroy the input capture /// destroy the input capture
pub async fn terminate(&mut self) -> Result<(), CaptureError> { pub async fn terminate(&mut self) -> Result<(), CaptureError> {
self.capture.terminate().await self.capture.terminate().await
@@ -285,11 +298,11 @@ async fn create_backend(
CaptureCreationError, CaptureCreationError,
> { > {
match backend { match backend {
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
Backend::InputCapturePortal => Ok(Box::new(libei::LibeiInputCapture::new().await?)), Backend::InputCapturePortal => Ok(Box::new(libei::LibeiInputCapture::new().await?)),
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] #[cfg(layer_shell)]
Backend::LayerShell => Ok(Box::new(layer_shell::LayerShellInputCapture::new()?)), Backend::LayerShell => Ok(Box::new(layer_shell::LayerShellInputCapture::new()?)),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
Backend::X11 => Ok(Box::new(x11::X11InputCapture::new()?)), Backend::X11 => Ok(Box::new(x11::X11InputCapture::new()?)),
#[cfg(windows)] #[cfg(windows)]
Backend::Windows => Ok(Box::new(windows::WindowsInputCapture::new())), Backend::Windows => Ok(Box::new(windows::WindowsInputCapture::new())),
@@ -314,11 +327,11 @@ async fn create(
} }
for backend in [ for backend in [
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
Backend::InputCapturePortal, Backend::InputCapturePortal,
#[cfg(all(unix, feature = "layer_shell", not(target_os = "macos")))] #[cfg(layer_shell)]
Backend::LayerShell, Backend::LayerShell,
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
Backend::X11, Backend::X11,
#[cfg(windows)] #[cfg(windows)]
Backend::Windows, Backend::Windows,

View File

@@ -1,10 +1,10 @@
use ashpd::{ use ashpd::{
desktop::{ desktop::{
input_capture::{
Activated, ActivatedBarrier, Barrier, BarrierID, Capabilities, InputCapture, Region,
Zones,
},
Session, Session,
input_capture::{
Activated, ActivatedBarrier, Barrier, BarrierID, Capabilities, CreateSessionOptions,
InputCapture, Region, ReleaseOptions, Zones,
},
}, },
enumflags2::BitFlags, enumflags2::BitFlags,
}; };
@@ -28,8 +28,8 @@ use std::{
}; };
use tokio::{ use tokio::{
sync::{ sync::{
mpsc::{self, Receiver, Sender},
Notify, Notify,
mpsc::{self, Receiver, Sender},
}, },
task::JoinHandle, task::JoinHandle,
}; };
@@ -42,8 +42,8 @@ use input_event::Event;
use crate::CaptureEvent; use crate::CaptureEvent;
use super::{ use super::{
error::{CaptureError, LibeiCaptureCreationError},
Capture as LanMouseInputCapture, Position, Capture as LanMouseInputCapture, Position,
error::{CaptureError, LibeiCaptureCreationError},
}; };
/* there is a bug in xdg-remote-desktop-portal-gnome / mutter that /* there is a bug in xdg-remote-desktop-portal-gnome / mutter that
@@ -58,8 +58,8 @@ enum LibeiNotifyEvent {
} }
#[allow(dead_code)] #[allow(dead_code)]
pub struct LibeiInputCapture<'a> { pub struct LibeiInputCapture {
input_capture: Pin<Box<InputCapture<'a>>>, input_capture: Pin<Box<InputCapture>>,
capture_task: JoinHandle<Result<(), CaptureError>>, capture_task: JoinHandle<Result<(), CaptureError>>,
event_rx: Receiver<(Position, CaptureEvent)>, event_rx: Receiver<(Position, CaptureEvent)>,
notify_capture: Sender<LibeiNotifyEvent>, notify_capture: Sender<LibeiNotifyEvent>,
@@ -130,12 +130,15 @@ fn select_barriers(
} }
async fn update_barriers( async fn update_barriers(
input_capture: &InputCapture<'_>, input_capture: &InputCapture,
session: &Session<'_, InputCapture<'_>>, session: &Session<InputCapture>,
active_clients: &[Position], active_clients: &[Position],
next_barrier_id: &mut NonZeroU32, next_barrier_id: &mut NonZeroU32,
) -> Result<(Vec<ICBarrier>, HashMap<BarrierID, Position>), ashpd::Error> { ) -> Result<(Vec<ICBarrier>, HashMap<BarrierID, Position>), ashpd::Error> {
let zones = input_capture.zones(session).await?.response()?; let zones = input_capture
.zones(session, Default::default())
.await?
.response()?;
log::debug!("zones: {zones:?}"); log::debug!("zones: {zones:?}");
let (barriers, id_map) = select_barriers(&zones, active_clients, next_barrier_id); let (barriers, id_map) = select_barriers(&zones, active_clients, next_barrier_id);
@@ -144,31 +147,38 @@ async fn update_barriers(
let ashpd_barriers: Vec<Barrier> = barriers.iter().copied().map(|b| b.into()).collect(); let ashpd_barriers: Vec<Barrier> = barriers.iter().copied().map(|b| b.into()).collect();
let response = input_capture let response = input_capture
.set_pointer_barriers(session, &ashpd_barriers, zones.zone_set()) .set_pointer_barriers(
session,
&ashpd_barriers,
zones.zone_set(),
Default::default(),
)
.await?; .await?;
let response = response.response()?; let response = response.response()?;
log::debug!("{response:?}"); log::debug!("{response:?}");
Ok((barriers, id_map)) Ok((barriers, id_map))
} }
async fn create_session<'a>( async fn create_session(
input_capture: &'a InputCapture<'a>, input_capture: &InputCapture,
) -> std::result::Result<(Session<'a, InputCapture<'a>>, BitFlags<Capabilities>), ashpd::Error> { ) -> std::result::Result<(Session<InputCapture>, BitFlags<Capabilities>), ashpd::Error> {
log::debug!("creating input capture session"); log::debug!("creating input capture session");
let create_session_options = CreateSessionOptions::default().set_capabilities(
Capabilities::Keyboard | Capabilities::Pointer | Capabilities::Touchscreen,
);
input_capture input_capture
.create_session( .create_session(None, create_session_options)
None,
Capabilities::Keyboard | Capabilities::Pointer | Capabilities::Touchscreen,
)
.await .await
} }
async fn connect_to_eis( async fn connect_to_eis(
input_capture: &InputCapture<'_>, input_capture: &InputCapture,
session: &Session<'_, InputCapture<'_>>, session: &Session<InputCapture>,
) -> Result<(ei::Context, Connection, EiConvertEventStream), CaptureError> { ) -> Result<(ei::Context, Connection, EiConvertEventStream), CaptureError> {
log::debug!("connect_to_eis"); log::debug!("connect_to_eis");
let fd = input_capture.connect_to_eis(session).await?; let fd = input_capture
.connect_to_eis(session, Default::default())
.await?;
// create unix stream from fd // create unix stream from fd
let stream = UnixStream::from(fd); let stream = UnixStream::from(fd);
@@ -201,10 +211,10 @@ async fn libei_event_handler(
} }
} }
impl LibeiInputCapture<'_> { impl LibeiInputCapture {
pub async fn new() -> std::result::Result<Self, LibeiCaptureCreationError> { pub async fn new() -> std::result::Result<Self, LibeiCaptureCreationError> {
let input_capture = Box::pin(InputCapture::new().await?); let input_capture = Box::pin(InputCapture::new().await?);
let input_capture_ptr = input_capture.as_ref().get_ref() as *const InputCapture<'static>; let input_capture_ptr = input_capture.as_ref().get_ref() as *const InputCapture;
let first_session = Some(create_session(unsafe { &*input_capture_ptr }).await?); let first_session = Some(create_session(unsafe { &*input_capture_ptr }).await?);
let (event_tx, event_rx) = mpsc::channel(1); let (event_tx, event_rx) = mpsc::channel(1);
@@ -238,10 +248,10 @@ impl LibeiInputCapture<'_> {
} }
async fn do_capture( async fn do_capture(
input_capture: *const InputCapture<'static>, input_capture: *const InputCapture,
mut capture_event: Receiver<LibeiNotifyEvent>, mut capture_event: Receiver<LibeiNotifyEvent>,
notify_release: Arc<Notify>, notify_release: Arc<Notify>,
session: Option<(Session<'_, InputCapture<'_>>, BitFlags<Capabilities>)>, session: Option<(Session<InputCapture>, BitFlags<Capabilities>)>,
event_tx: Sender<(Position, CaptureEvent)>, event_tx: Sender<(Position, CaptureEvent)>,
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
) -> Result<(), CaptureError> { ) -> Result<(), CaptureError> {
@@ -307,7 +317,7 @@ async fn do_capture(
// disable capture // disable capture
log::debug!("disabling input capture"); log::debug!("disabling input capture");
if let Err(e) = input_capture.disable(&session).await { if let Err(e) = input_capture.disable(&session, Default::default()).await {
log::warn!("input_capture.disable(&session) {e}"); log::warn!("input_capture.disable(&session) {e}");
} }
if let Err(e) = session.close().await { if let Err(e) = session.close().await {
@@ -336,8 +346,8 @@ async fn do_capture(
} }
async fn do_capture_session( async fn do_capture_session(
input_capture: &InputCapture<'_>, input_capture: &InputCapture,
session: &mut Session<'_, InputCapture<'_>>, session: &mut Session<InputCapture>,
event_tx: &Sender<(Position, CaptureEvent)>, event_tx: &Sender<(Position, CaptureEvent)>,
active_clients: &[Position], active_clients: &[Position],
next_barrier_id: &mut NonZeroU32, next_barrier_id: &mut NonZeroU32,
@@ -356,7 +366,7 @@ async fn do_capture_session(
update_barriers(input_capture, session, active_clients, next_barrier_id).await?; update_barriers(input_capture, session, active_clients, next_barrier_id).await?;
log::debug!("enabling session"); log::debug!("enabling session");
input_capture.enable(session).await?; input_capture.enable(session, Default::default()).await?;
// cancellation token to release session // cancellation token to release session
let release_session = Arc::new(Notify::new()); let release_session = Arc::new(Notify::new());
@@ -462,9 +472,9 @@ async fn do_capture_session(
Ok(()) Ok(())
} }
async fn release_capture<'a>( async fn release_capture(
input_capture: &InputCapture<'a>, input_capture: &InputCapture,
session: &Session<'a, InputCapture<'a>>, session: &Session<InputCapture>,
activated: Activated, activated: Activated,
current_pos: Position, current_pos: Position,
) -> Result<(), CaptureError> { ) -> Result<(), CaptureError> {
@@ -484,9 +494,10 @@ async fn release_capture<'a>(
}; };
// release 1px to the right of the entered zone // release 1px to the right of the entered zone
let cursor_position = (x as f64 + dx, y as f64 + dy); let cursor_position = (x as f64 + dx, y as f64 + dy);
input_capture let release_options = ReleaseOptions::default()
.release(session, activated.activation_id(), Some(cursor_position)) .set_activation_id(activated.activation_id())
.await?; .set_cursor_position(Some(cursor_position));
input_capture.release(session, release_options).await?;
Ok(()) Ok(())
} }
@@ -561,7 +572,7 @@ async fn handle_ei_event(
} }
#[async_trait] #[async_trait]
impl LanMouseInputCapture for LibeiInputCapture<'_> { impl LanMouseInputCapture for LibeiInputCapture {
async fn create(&mut self, pos: Position) -> Result<(), CaptureError> { async fn create(&mut self, pos: Position) -> Result<(), CaptureError> {
let _ = self let _ = self
.notify_capture .notify_capture
@@ -587,14 +598,18 @@ impl LanMouseInputCapture for LibeiInputCapture<'_> {
self.cancellation_token.cancel(); self.cancellation_token.cancel();
let task = &mut self.capture_task; let task = &mut self.capture_task;
log::debug!("waiting for capture to terminate..."); log::debug!("waiting for capture to terminate...");
let res = task.await.expect("libei task panic"); let res = if !task.is_finished() {
log::debug!("done!"); task.await.expect("libei task panic")
} else {
Ok(())
};
self.terminated = true; self.terminated = true;
log::debug!("done!");
res res
} }
} }
impl Drop for LibeiInputCapture<'_> { impl Drop for LibeiInputCapture {
fn drop(&mut self) { fn drop(&mut self) {
if !self.terminated { if !self.terminated {
/* this workaround is needed until async drop is stabilized */ /* this workaround is needed until async drop is stabilized */
@@ -603,10 +618,10 @@ impl Drop for LibeiInputCapture<'_> {
} }
} }
impl Stream for LibeiInputCapture<'_> { impl Stream for LibeiInputCapture {
type Item = Result<(Position, CaptureEvent), CaptureError>; type Item = Result<(Position, CaptureEvent), CaptureError>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Option<Self::Item>> {
match self.capture_task.poll_unpin(cx) { match self.capture_task.poll_unpin(cx) {
Poll::Ready(r) => match r.expect("failed to join") { Poll::Ready(r) => match r.expect("failed to join") {
Ok(()) => Poll::Ready(None), Ok(()) => Poll::Ready(None),

View File

@@ -1,31 +1,42 @@
use super::{error::MacosCaptureCreationError, Capture, CaptureError, CaptureEvent, Position}; use super::{Capture, CaptureError, CaptureEvent, Position, error::MacosCaptureCreationError};
use async_trait::async_trait; use async_trait::async_trait;
use bitflags::bitflags; use bitflags::bitflags;
use core_foundation::base::{kCFAllocatorDefault, CFRelease}; use core_foundation::{
use core_foundation::date::CFTimeInterval; base::{CFRelease, TCFType, kCFAllocatorDefault},
use core_foundation::number::{kCFBooleanTrue, CFBooleanRef}; date::CFTimeInterval,
use core_foundation::runloop::{kCFRunLoopCommonModes, CFRunLoop, CFRunLoopSource}; number::{CFBooleanRef, kCFBooleanTrue},
use core_foundation::string::{kCFStringEncodingUTF8, CFStringCreateWithCString, CFStringRef}; runloop::{CFRunLoop, CFRunLoopSource, kCFRunLoopCommonModes},
use core_graphics::base::{kCGErrorSuccess, CGError}; string::{CFStringCreateWithCString, CFStringRef, kCFStringEncodingUTF8},
use core_graphics::display::{CGDisplay, CGPoint}; };
use core_graphics::event::{ use core_graphics::{
CGEvent, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, base::{CGError, kCGErrorSuccess},
CGEventTapProxy, CGEventType, EventField, display::{CGDisplay, CGPoint},
event::{
CGEvent, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions,
CGEventTapPlacement, CGEventTapProxy, CGEventType, CallbackResult, EventField,
},
event_source::{CGEventSource, CGEventSourceStateID},
}; };
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
use futures_core::Stream; use futures_core::Stream;
use input_event::{Event, KeyboardEvent, PointerEvent, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT}; use input_event::{
BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, Event, KeyboardEvent, PointerEvent,
};
use keycode::{KeyMap, KeyMapping}; use keycode::{KeyMap, KeyMapping};
use libc::c_void; use libc::c_void;
use once_cell::unsync::Lazy; use once_cell::unsync::Lazy;
use std::collections::HashSet; use std::{
use std::ffi::{c_char, CString}; collections::HashSet,
use std::pin::Pin; ffi::{CString, c_char},
use std::sync::Arc; pin::Pin,
use std::task::{ready, Context, Poll}; sync::{Arc, OnceLock},
use std::thread::{self}; task::{Context, Poll, ready},
use tokio::sync::mpsc::{self, Receiver, Sender}; thread::{self},
use tokio::sync::{oneshot, Mutex}; };
use tokio::sync::{
Mutex,
mpsc::{self, Receiver, Sender},
oneshot,
};
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct Bounds { struct Bounds {
@@ -37,9 +48,16 @@ struct Bounds {
#[derive(Debug)] #[derive(Debug)]
struct InputCaptureState { struct InputCaptureState {
/// active capture positions
active_clients: Lazy<HashSet<Position>>, active_clients: Lazy<HashSet<Position>>,
/// the currently entered capture position, if any
current_pos: Option<Position>, current_pos: Option<Position>,
/// position where the cursor was captured
enter_position: Option<CGPoint>,
/// bounds of the input capture area
bounds: Bounds, bounds: Bounds,
/// current state of modifier keys
modifier_state: XMods,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -49,6 +67,7 @@ enum ProducerEvent {
Destroy(Position), Destroy(Position),
Grab(Position), Grab(Position),
EventTapDisabled, EventTapDisabled,
DisplayReconfigured,
} }
impl InputCaptureState { impl InputCaptureState {
@@ -56,7 +75,9 @@ impl InputCaptureState {
let mut res = Self { let mut res = Self {
active_clients: Lazy::new(HashSet::new), active_clients: Lazy::new(HashSet::new),
current_pos: None, current_pos: None,
enter_position: None,
bounds: Bounds::default(), bounds: Bounds::default(),
modifier_state: Default::default(),
}; };
res.update_bounds()?; res.update_bounds()?;
Ok(res) Ok(res)
@@ -96,45 +117,34 @@ impl InputCaptureState {
Ok(()) Ok(())
} }
// We can't disable mouse movement when in a client so we need to reset the cursor position /// start the input capture by
// to the edge of the screen, the cursor will be hidden but we dont want it to appear in a fn start_capture(&mut self, event: &CGEvent, position: Position) -> Result<(), CaptureError> {
// random location when we exit the client let mut location = event.location();
fn reset_mouse_position(&self, event: &CGEvent) -> Result<(), CaptureError> { let edge_offset = 1.0;
if let Some(pos) = self.current_pos { // move cursor location to display bounds
let location = event.location(); match position {
let edge_offset = 1.0; Position::Left => location.x = self.bounds.xmin + edge_offset,
Position::Right => location.x = self.bounds.xmax - edge_offset,
Position::Top => location.y = self.bounds.ymin + edge_offset,
Position::Bottom => location.y = self.bounds.ymax - edge_offset,
};
self.enter_position = Some(location);
self.reset_cursor()
}
// After the cursor is warped no event is produced but the next event /// resets the cursor to the position, where the capture started
// will carry the delta from the warp so only half the delta is needed to move the cursor fn reset_cursor(&mut self) -> Result<(), CaptureError> {
let delta_y = event.get_double_value_field(EventField::MOUSE_EVENT_DELTA_Y) / 2.0; let pos = self.enter_position.expect("capture active");
let delta_x = event.get_double_value_field(EventField::MOUSE_EVENT_DELTA_X) / 2.0; log::trace!("Resetting cursor position to: {}, {}", pos.x, pos.y);
CGDisplay::warp_mouse_cursor_position(pos).map_err(CaptureError::WarpCursor)
}
let mut new_x = location.x + delta_x; fn hide_cursor(&self) -> Result<(), CaptureError> {
let mut new_y = location.y + delta_y; CGDisplay::hide_cursor(&CGDisplay::main()).map_err(CaptureError::CoreGraphics)
}
match pos { fn show_cursor(&self) -> Result<(), CaptureError> {
Position::Left => { CGDisplay::show_cursor(&CGDisplay::main()).map_err(CaptureError::CoreGraphics)
new_x = self.bounds.xmin + edge_offset;
}
Position::Right => {
new_x = self.bounds.xmax - edge_offset;
}
Position::Top => {
new_y = self.bounds.ymin + edge_offset;
}
Position::Bottom => {
new_y = self.bounds.ymax - edge_offset;
}
}
let new_pos = CGPoint::new(new_x, new_y);
log::trace!("Resetting cursor position to: {new_x}, {new_y}");
return CGDisplay::warp_mouse_cursor_position(new_pos)
.map_err(CaptureError::WarpCursor);
}
Err(CaptureError::ResetMouseWithoutClient)
} }
async fn handle_producer_event( async fn handle_producer_event(
@@ -145,15 +155,13 @@ impl InputCaptureState {
match producer_event { match producer_event {
ProducerEvent::Release => { ProducerEvent::Release => {
if self.current_pos.is_some() { if self.current_pos.is_some() {
CGDisplay::show_cursor(&CGDisplay::main()) self.show_cursor()?;
.map_err(CaptureError::CoreGraphics)?;
self.current_pos = None; self.current_pos = None;
} }
} }
ProducerEvent::Grab(pos) => { ProducerEvent::Grab(pos) => {
if self.current_pos.is_none() { if self.current_pos.is_none() {
CGDisplay::hide_cursor(&CGDisplay::main()) self.hide_cursor()?;
.map_err(CaptureError::CoreGraphics)?;
self.current_pos = Some(pos); self.current_pos = Some(pos);
} }
} }
@@ -163,14 +171,37 @@ impl InputCaptureState {
ProducerEvent::Destroy(p) => { ProducerEvent::Destroy(p) => {
if let Some(current) = self.current_pos { if let Some(current) = self.current_pos {
if current == p { if current == p {
CGDisplay::show_cursor(&CGDisplay::main()) self.show_cursor()?;
.map_err(CaptureError::CoreGraphics)?;
self.current_pos = None; self.current_pos = None;
}; };
} }
self.active_clients.remove(&p); self.active_clients.remove(&p);
} }
ProducerEvent::EventTapDisabled => return Err(CaptureError::EventTapDisabled), ProducerEvent::EventTapDisabled => {
// Tap death can happen mid-capture (TCC Accessibility
// revoked, tap-timeout, etc). Release state so we
// don't leave the cursor hidden even if the outer
// task only logs this error rather than propagating.
if self.current_pos.is_some() {
self.show_cursor()?;
self.current_pos = None;
}
return Err(CaptureError::EventTapDisabled);
}
ProducerEvent::DisplayReconfigured => {
// The macOS display configuration changed — a monitor
// was plugged in/out, the resolution changed, the
// arrangement was rearranged, etc. Re-fetch the
// active-display bounds so barrier crossings and the
// cursor-warp on capture-start use the current
// geometry instead of whatever was true at process
// start.
if let Err(e) = self.update_bounds() {
log::warn!("failed to refresh display bounds: {e}");
} else {
log::info!("display reconfigured: {:?}", self.bounds);
}
}
}; };
Ok(()) Ok(())
} }
@@ -180,6 +211,7 @@ fn get_events(
ev_type: &CGEventType, ev_type: &CGEventType,
ev: &CGEvent, ev: &CGEvent,
result: &mut Vec<CaptureEvent>, result: &mut Vec<CaptureEvent>,
modifier_state: &mut XMods,
) -> Result<(), CaptureError> { ) -> Result<(), CaptureError> {
fn map_pointer_event(ev: &CGEvent) -> PointerEvent { fn map_pointer_event(ev: &CGEvent) -> PointerEvent {
PointerEvent::Motion { PointerEvent::Motion {
@@ -215,29 +247,42 @@ fn get_events(
}))); })));
} }
CGEventType::FlagsChanged => { CGEventType::FlagsChanged => {
let mut mods = XMods::empty(); let mut depressed = XMods::empty();
let mut mods_locked = XMods::empty(); let mut mods_locked = XMods::empty();
let cg_flags = ev.get_flags(); let cg_flags = ev.get_flags();
if cg_flags.contains(CGEventFlags::CGEventFlagShift) { if cg_flags.contains(CGEventFlags::CGEventFlagShift) {
mods |= XMods::ShiftMask; depressed |= XMods::ShiftMask;
} }
if cg_flags.contains(CGEventFlags::CGEventFlagControl) { if cg_flags.contains(CGEventFlags::CGEventFlagControl) {
mods |= XMods::ControlMask; depressed |= XMods::ControlMask;
} }
if cg_flags.contains(CGEventFlags::CGEventFlagAlternate) { if cg_flags.contains(CGEventFlags::CGEventFlagAlternate) {
mods |= XMods::Mod1Mask; depressed |= XMods::Mod1Mask;
} }
if cg_flags.contains(CGEventFlags::CGEventFlagCommand) { if cg_flags.contains(CGEventFlags::CGEventFlagCommand) {
mods |= XMods::Mod4Mask; depressed |= XMods::Mod4Mask;
} }
if cg_flags.contains(CGEventFlags::CGEventFlagAlphaShift) { if cg_flags.contains(CGEventFlags::CGEventFlagAlphaShift) {
mods |= XMods::LockMask; depressed |= XMods::LockMask;
mods_locked |= XMods::LockMask; mods_locked |= XMods::LockMask;
} }
// check if pressed or released
let state = if depressed > *modifier_state { 1 } else { 0 };
*modifier_state = depressed;
if let Ok(key) = map_key(ev) {
let key_event = CaptureEvent::Input(Event::Keyboard(KeyboardEvent::Key {
time: 0,
key,
state,
}));
result.push(key_event);
}
let modifier_event = KeyboardEvent::Modifiers { let modifier_event = KeyboardEvent::Modifiers {
depressed: mods.bits(), depressed: depressed.bits(),
latched: 0, latched: 0,
locked: mods_locked.bits(), locked: mods_locked.bits(),
group: 0, group: 0,
@@ -286,35 +331,73 @@ fn get_events(
}))) })))
} }
CGEventType::OtherMouseDown => { CGEventType::OtherMouseDown => {
let btn_num = ev.get_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER);
let button = match btn_num {
3 => BTN_BACK,
4 => BTN_FORWARD,
_ => BTN_MIDDLE,
};
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Button { result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Button {
time: 0, time: 0,
button: BTN_MIDDLE, button,
state: 1, state: 1,
}))) })))
} }
CGEventType::OtherMouseUp => { CGEventType::OtherMouseUp => {
let btn_num = ev.get_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER);
let button = match btn_num {
3 => BTN_BACK,
4 => BTN_FORWARD,
_ => BTN_MIDDLE,
};
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Button { result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Button {
time: 0, time: 0,
button: BTN_MIDDLE, button,
state: 0, state: 0,
}))) })))
} }
CGEventType::ScrollWheel => { CGEventType::ScrollWheel => {
let v = ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_1); if ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_IS_CONTINUOUS) != 0 {
let h = ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_2); let v =
if v != 0 { ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_1);
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Axis { let h =
time: 0, ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_2);
axis: 0, // Vertical if v != 0 {
value: v as f64, result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Axis {
}))); time: 0,
} axis: 0, // Vertical
if h != 0 { value: v as f64,
result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Axis { })));
time: 0, }
axis: 1, // Horizontal if h != 0 {
value: h as f64, result.push(CaptureEvent::Input(Event::Pointer(PointerEvent::Axis {
}))); time: 0,
axis: 1, // Horizontal
value: h as f64,
})));
}
} else {
// line based scrolling
const LINES_PER_STEP: i32 = 3;
const V120_STEPS_PER_LINE: i32 = 120 / LINES_PER_STEP;
let v = ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_1);
let h = ev.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_2);
if v != 0 {
result.push(CaptureEvent::Input(Event::Pointer(
PointerEvent::AxisDiscrete120 {
axis: 0, // Vertical
value: V120_STEPS_PER_LINE * v as i32,
},
)));
}
if h != 0 {
result.push(CaptureEvent::Input(Event::Pointer(
PointerEvent::AxisDiscrete120 {
axis: 1, // Horizontal
value: V120_STEPS_PER_LINE * h as i32,
},
)));
}
} }
} }
_ => (), _ => (),
@@ -327,6 +410,14 @@ fn create_event_tap<'a>(
notify_tx: Sender<ProducerEvent>, notify_tx: Sender<ProducerEvent>,
event_tx: Sender<(Position, CaptureEvent)>, event_tx: Sender<(Position, CaptureEvent)>,
) -> Result<CGEventTap<'a>, MacosCaptureCreationError> { ) -> Result<CGEventTap<'a>, MacosCaptureCreationError> {
// Shared slot for the tap's mach port pointer. Stored as `usize`
// because raw pointers aren't `Send`, but the integer
// representation is — and CGEventTapEnable is documented as
// thread-safe. Set immediately after CGEventTap::new returns;
// read by the callback to recover from a TapDisabledByTimeout.
let tap_mach_port: Arc<OnceLock<usize>> = Arc::new(OnceLock::new());
let tap_mach_port_cb = Arc::clone(&tap_mach_port);
let cg_events_of_interest: Vec<CGEventType> = vec![ let cg_events_of_interest: Vec<CGEventType> = vec![
CGEventType::LeftMouseDown, CGEventType::LeftMouseDown,
CGEventType::LeftMouseUp, CGEventType::LeftMouseUp,
@@ -344,62 +435,114 @@ fn create_event_tap<'a>(
CGEventType::FlagsChanged, CGEventType::FlagsChanged,
]; ];
let event_tap_callback = let event_tap_callback = move |_proxy: CGEventTapProxy,
move |_proxy: CGEventTapProxy, event_type: CGEventType, cg_ev: &CGEvent| { event_type: CGEventType,
log::trace!("Got event from tap: {event_type:?}"); cg_ev: &CGEvent| {
let mut state = client_state.blocking_lock(); log::trace!("Got event from tap: {event_type:?}");
let mut pos = None; let mut state = client_state.blocking_lock();
let mut res_events = vec![]; let mut capture_position = None;
let mut res_events = vec![];
if matches!(event_type, CGEventType::TapDisabledByTimeout) {
// The kernel disables the tap when our callback runs
// longer than ~1s on a single event — typical causes
// are heavy load, scheduler contention, or this
// process being briefly suspended (e.g. App Nap on a
// long idle). It is NOT a fatal condition: Apple's
// documented recovery is to call CGEventTapEnable
// and resume processing. Re-enable in place and KEEP
// existing capture state so the user doesn't see the
// cursor pop back to the local screen mid-session.
if let Some(&port) = tap_mach_port_cb.get() {
log::warn!("CGEventTap disabled by timeout — re-enabling");
unsafe {
CGEventTapEnable(port as *mut c_void, true);
}
} else {
log::error!(
"CGEventTap disabled by timeout, but mach port not yet stored — cannot re-enable"
);
}
return CallbackResult::Keep;
}
if matches!(event_type, CGEventType::TapDisabledByUserInput) {
// Deliberate kill — secure-input mode (e.g. password
// field), TCC Accessibility revoked mid-session, or
// the user disabling event-monitoring. We can't
// recover from this; drop captured state synchronously
// and return Keep on this event. Otherwise the
// `current_pos.is_some()` branch below would drop this
// event (and any racing callback still in flight) back
// into `CallbackResult::Drop`, silently eating the
// user's clicks and keypresses while the tap winds
// down. Clear state + show the cursor here, then
// notify the producer loop so the service can tear
// down cleanly.
log::error!("CGEventTap disabled by user input, releasing capture state");
if state.current_pos.is_some() {
let _ = CGDisplay::show_cursor(&CGDisplay::main());
state.current_pos = None;
}
notify_tx
.blocking_send(ProducerEvent::EventTapDisabled)
.unwrap_or_else(|e| {
log::error!("Failed to send notification: {e}");
});
return CallbackResult::Keep;
}
// Are we in a client?
if let Some(current_pos) = state.current_pos {
capture_position = Some(current_pos);
get_events(
&event_type,
cg_ev,
&mut res_events,
&mut state.modifier_state,
)
.unwrap_or_else(|e| {
log::error!("Failed to get events: {e}");
});
// Keep (hidden) cursor at the edge of the screen
if matches!( if matches!(
event_type, event_type,
CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput CGEventType::MouseMoved
| CGEventType::LeftMouseDragged
| CGEventType::RightMouseDragged
| CGEventType::OtherMouseDragged
) { ) {
log::error!("CGEventTap disabled"); state.reset_cursor().unwrap_or_else(|e| log::warn!("{e}"));
notify_tx
.blocking_send(ProducerEvent::EventTapDisabled)
.unwrap_or_else(|e| {
log::error!("Failed to send notification: {e}");
});
}
// Are we in a client?
if let Some(current_pos) = state.current_pos {
pos = Some(current_pos);
get_events(&event_type, cg_ev, &mut res_events).unwrap_or_else(|e| {
log::error!("Failed to get events: {e}");
});
// Keep (hidden) cursor at the edge of the screen
if matches!(event_type, CGEventType::MouseMoved) {
state.reset_mouse_position(cg_ev).unwrap_or_else(|e| {
log::error!("Failed to reset mouse position: {e}");
})
}
} }
} else if matches!(event_type, CGEventType::MouseMoved) {
// Did we cross a barrier? // Did we cross a barrier?
else if matches!(event_type, CGEventType::MouseMoved) { if let Some(new_pos) = state.crossed(cg_ev) {
if let Some(new_pos) = state.crossed(cg_ev) { capture_position = Some(new_pos);
pos = Some(new_pos); state
res_events.push(CaptureEvent::Begin); .start_capture(cg_ev, new_pos)
notify_tx .unwrap_or_else(|e| log::warn!("{e}"));
.blocking_send(ProducerEvent::Grab(new_pos)) res_events.push(CaptureEvent::Begin);
.expect("Failed to send notification"); notify_tx
} .blocking_send(ProducerEvent::Grab(new_pos))
.expect("Failed to send notification");
} }
}
if let Some(pos) = pos { if let Some(pos) = capture_position {
res_events.iter().for_each(|e| { res_events.iter().for_each(|e| {
event_tx // error must be ignored, since the event channel
.blocking_send((pos, *e)) // may already be closed when the InputCapture instance is dropped.
.expect("Failed to send event"); let _ = event_tx.blocking_send((pos, *e));
}); });
// Returning None should stop the event from being processed // Returning Drop should stop the event from being processed
// but core fundation still returns the event // but core fundation still returns the event
cg_ev.set_type(CGEventType::Null); cg_ev.set_type(CGEventType::Null);
} CallbackResult::Drop
Some(cg_ev.to_owned()) } else {
}; CallbackResult::Keep
}
};
let tap = CGEventTap::new( let tap = CGEventTap::new(
CGEventTapLocation::Session, CGEventTapLocation::Session,
@@ -410,8 +553,15 @@ fn create_event_tap<'a>(
) )
.map_err(|_| MacosCaptureCreationError::EventTapCreation)?; .map_err(|_| MacosCaptureCreationError::EventTapCreation)?;
// Hand the mach port pointer to the callback so it can re-enable
// the tap on TapDisabledByTimeout. The pointer is valid for the
// lifetime of `tap` (which lives on the event-tap thread until
// the run loop exits).
let port_ptr = tap.mach_port().as_concrete_TypeRef() as usize;
let _ = tap_mach_port.set(port_ptr);
let tap_source: CFRunLoopSource = tap let tap_source: CFRunLoopSource = tap
.mach_port .mach_port()
.create_runloop_source(0) .create_runloop_source(0)
.expect("Failed creating loop source"); .expect("Failed creating loop source");
@@ -426,31 +576,89 @@ fn event_tap_thread(
client_state: Arc<Mutex<InputCaptureState>>, client_state: Arc<Mutex<InputCaptureState>>,
event_tx: Sender<(Position, CaptureEvent)>, event_tx: Sender<(Position, CaptureEvent)>,
notify_tx: Sender<ProducerEvent>, notify_tx: Sender<ProducerEvent>,
ready: std::sync::mpsc::Sender<Result<(), MacosCaptureCreationError>>, ready: std::sync::mpsc::Sender<Result<CFRunLoop, MacosCaptureCreationError>>,
exit: oneshot::Sender<Result<(), &'static str>>, exit: oneshot::Sender<()>,
) { ) {
// Clone now: create_event_tap consumes notify_tx into its closure.
let display_notify_tx = notify_tx.clone();
let _tap = match create_event_tap(client_state, notify_tx, event_tx) { let _tap = match create_event_tap(client_state, notify_tx, event_tx) {
Err(e) => { Err(e) => {
ready.send(Err(e)).expect("channel closed"); ready.send(Err(e)).expect("channel closed");
return; return;
} }
Ok(tap) => { Ok(tap) => {
ready.send(Ok(())).expect("channel closed"); let run_loop = CFRunLoop::get_current();
ready.send(Ok(run_loop)).expect("channel closed");
tap tap
} }
}; };
CFRunLoop::run_current();
let _ = exit.send(Err("tap thread exited")); // Register a Quartz display-reconfiguration callback so the
// capture state's bounds get refreshed when the user plugs in a
// monitor, changes resolution, or rearranges displays. The
// callback runs on this thread's CFRunLoop. Box-leak the sender
// so the C side has a stable user_info pointer; reclaim it after
// the run loop exits.
let display_user_info = Box::into_raw(Box::new(display_notify_tx)) as *mut c_void;
unsafe {
CGDisplayRegisterReconfigurationCallback(
display_reconfiguration_callback,
display_user_info,
);
}
log::debug!("running CFRunLoop...");
CFRunLoop::run_current();
log::debug!("event tap thread exiting!...");
unsafe {
CGDisplayRemoveReconfigurationCallback(display_reconfiguration_callback, display_user_info);
// Reclaim the leaked sender Box so we don't leak a tokio
// channel sender on every capture create/destroy cycle.
drop(Box::from_raw(
display_user_info as *mut Sender<ProducerEvent>,
));
}
let _ = exit.send(());
}
/// Quartz display-reconfiguration callback. Fires twice per change:
/// once with `kCGDisplayBeginConfigurationFlag` set (BEFORE the
/// change is applied — the bounds are still stale at this point),
/// then again afterwards with the actual change flags (Add, Remove,
/// Mode, DesktopShapeChanged, etc.). Skip the begin phase; on the
/// real notification, kick the producer task to refresh bounds.
extern "C" fn display_reconfiguration_callback(_display: u32, flags: u32, user_info: *mut c_void) {
const K_CG_DISPLAY_BEGIN_CONFIGURATION_FLAG: u32 = 1 << 0;
if flags & K_CG_DISPLAY_BEGIN_CONFIGURATION_FLAG != 0 {
return;
}
if user_info.is_null() {
return;
}
// SAFETY: user_info is a Box::into_raw of Sender<ProducerEvent>
// owned by `event_tap_thread`. It's valid for the lifetime of
// that thread; the registration is removed before the box is
// freed. The callback only fires while the run loop is running
// on that thread, so we know the box is live here.
let sender = unsafe { &*(user_info as *const Sender<ProducerEvent>) };
if let Err(e) = sender.blocking_send(ProducerEvent::DisplayReconfigured) {
log::warn!("failed to notify display reconfiguration: {e}");
}
} }
pub struct MacOSInputCapture { pub struct MacOSInputCapture {
event_rx: Receiver<(Position, CaptureEvent)>, event_rx: Receiver<(Position, CaptureEvent)>,
notify_tx: Sender<ProducerEvent>, notify_tx: Sender<ProducerEvent>,
run_loop: CFRunLoop,
} }
impl MacOSInputCapture { impl MacOSInputCapture {
pub async fn new() -> Result<Self, MacosCaptureCreationError> { pub async fn new() -> Result<Self, MacosCaptureCreationError> {
request_macos_capture_permissions()?;
let state = Arc::new(Mutex::new(InputCaptureState::new()?)); let state = Arc::new(Mutex::new(InputCaptureState::new()?));
let (event_tx, event_rx) = mpsc::channel(32); let (event_tx, event_rx) = mpsc::channel(32);
let (notify_tx, mut notify_rx) = mpsc::channel(32); let (notify_tx, mut notify_rx) = mpsc::channel(32);
@@ -475,36 +683,73 @@ impl MacOSInputCapture {
}); });
// wait for event tap creation result // wait for event tap creation result
ready_rx.recv().expect("channel closed")?; let run_loop = ready_rx.recv().expect("channel closed")?;
let _tap_task: tokio::task::JoinHandle<()> = tokio::task::spawn_local(async move { let _tap_task: tokio::task::JoinHandle<()> = tokio::task::spawn_local(async move {
loop { loop {
tokio::select! { tokio::select! {
producer_event = notify_rx.recv() => { producer_event = notify_rx.recv() => {
let producer_event = producer_event.expect("channel closed"); let Some(producer_event) = producer_event else {
break;
};
let mut state = state.lock().await; let mut state = state.lock().await;
state.handle_producer_event(producer_event).await.unwrap_or_else(|e| { state.handle_producer_event(producer_event).await.unwrap_or_else(|e| {
log::error!("Failed to handle producer event: {e}"); log::error!("Failed to handle producer event: {e}");
}) })
} }
_ = &mut tap_exit_rx => break,
res = &mut tap_exit_rx => {
if let Err(e) = res.expect("channel closed") {
log::error!("Tap thread failed: {:?}", e);
break;
}
}
} }
} }
// show cursor
let _ = CGDisplay::show_cursor(&CGDisplay::main());
}); });
Ok(Self { Ok(Self {
event_rx, event_rx,
notify_tx, notify_tx,
run_loop,
}) })
} }
} }
fn request_macos_capture_permissions() -> Result<(), MacosCaptureCreationError> {
// Call both request functions unconditionally so macOS surfaces both
// TCC prompts on the very first launch. TCC always returns `false` the
// first time a permission is requested (the grant only becomes visible
// on the next process launch), so returning early on the first failure
// would skip the second prompt and force the user through an extra
// relaunch just to see it.
let accessibility = request_accessibility_permission();
let input_monitoring = request_input_monitoring_permission();
if !accessibility {
return Err(MacosCaptureCreationError::AccessibilityPermission);
}
if !input_monitoring {
return Err(MacosCaptureCreationError::InputMonitoringPermission);
}
Ok(())
}
fn request_accessibility_permission() -> bool {
// Silent check. The GUI owns the one-time user-visible prompt at
// startup (see lan_mouse_gtk::macos_privacy) so retries triggered by
// clicking the "Reenable" button don't pop a fresh Accessibility
// alert every time.
unsafe { AXIsProcessTrusted() }
}
fn request_input_monitoring_permission() -> bool {
// Silent check, same reasoning as above.
unsafe { CGPreflightListenEventAccess() }
}
impl Drop for MacOSInputCapture {
fn drop(&mut self) {
self.run_loop.stop();
}
}
#[async_trait] #[async_trait]
impl Capture for MacOSInputCapture { impl Capture for MacOSInputCapture {
async fn create(&mut self, pos: Position) -> Result<(), CaptureError> { async fn create(&mut self, pos: Position) -> Result<(), CaptureError> {
@@ -570,6 +815,30 @@ extern "C" {
event_source: CGEventSource, event_source: CGEventSource,
seconds: CFTimeInterval, seconds: CFTimeInterval,
); );
fn CGPreflightListenEventAccess() -> bool;
/// Re-enable an event tap that was disabled by a
/// `kCGEventTapDisabledByTimeout` event. The Apple-documented
/// recovery path: see Quartz Event Services Reference. The `tap`
/// argument is a `CFMachPortRef`; we pass the raw pointer so we
/// can store it as `usize` for cross-thread sharing.
fn CGEventTapEnable(tap: *mut c_void, enable: bool);
/// Register a callback invoked when the display configuration
/// changes (monitor add/remove, resolution change, mirror,
/// rearrange, etc). See Quartz Display Services Reference.
fn CGDisplayRegisterReconfigurationCallback(
callback: extern "C" fn(u32, u32, *mut c_void),
user_info: *mut c_void,
) -> CGError;
fn CGDisplayRemoveReconfigurationCallback(
callback: extern "C" fn(u32, u32, *mut c_void),
user_info: *mut c_void,
) -> CGError;
}
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> bool;
} }
unsafe fn configure_cf_settings() -> Result<(), MacosCaptureCreationError> { unsafe fn configure_cf_settings() -> Result<(), MacosCaptureCreationError> {
@@ -579,6 +848,7 @@ unsafe fn configure_cf_settings() -> Result<(), MacosCaptureCreationError> {
let event_source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState) let event_source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState)
.map_err(|_| MacosCaptureCreationError::EventSourceCreation)?; .map_err(|_| MacosCaptureCreationError::EventSourceCreation)?;
CGEventSourceSetLocalEventsSuppressionInterval(event_source, 0.05); CGEventSourceSetLocalEventsSuppressionInterval(event_source, 0.05);
// FIXME Memory Leak
// This is a private settings that allows the cursor to be hidden while in the background. // This is a private settings that allows the cursor to be hidden while in the background.
// It is used by Barrier and other apps. // It is used by Barrier and other apps.

View File

@@ -5,7 +5,7 @@ use futures::Stream;
use std::pin::Pin; use std::pin::Pin;
use std::task::ready; use std::task::ready;
use tokio::sync::mpsc::{channel, Receiver}; use tokio::sync::mpsc::{Receiver, channel};
use super::{Capture, CaptureError, CaptureEvent, Position}; use super::{Capture, CaptureError, CaptureEvent, Position};

View File

@@ -6,33 +6,32 @@ use std::default::Default;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::{Arc, Condvar, Mutex}; use std::sync::{Arc, Condvar, Mutex};
use std::thread; use std::thread;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use windows::core::{w, PCWSTR}; use tokio::sync::mpsc::error::TrySendError;
use windows::Win32::Foundation::{FALSE, HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM}; use windows::Win32::Foundation::{FALSE, HWND, LPARAM, LRESULT, RECT, WPARAM};
use windows::Win32::Graphics::Gdi::{ use windows::Win32::Graphics::Gdi::{
EnumDisplayDevicesW, EnumDisplaySettingsW, DEVMODEW, DISPLAY_DEVICEW, DEVMODEW, DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICEW, ENUM_CURRENT_SETTINGS,
DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, ENUM_CURRENT_SETTINGS, EnumDisplayDevicesW, EnumDisplaySettingsW,
}; };
use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::System::Threading::GetCurrentThreadId; use windows::Win32::System::Threading::GetCurrentThreadId;
use windows::core::{PCWSTR, w};
use windows::Win32::UI::WindowsAndMessaging::{ use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, CreateWindowExW, DispatchMessageW, GetMessageW, PostThreadMessageW, CallNextHookEx, CreateWindowExW, DispatchMessageW, EDD_GET_DEVICE_INTERFACE_NAME, GetMessageW,
RegisterClassW, SetWindowsHookExW, TranslateMessage, EDD_GET_DEVICE_INTERFACE_NAME, HHOOK, HOOKPROC, KBDLLHOOKSTRUCT, LLKHF_EXTENDED, MSG, MSLLHOOKSTRUCT, PostThreadMessageW,
HMENU, HOOKPROC, KBDLLHOOKSTRUCT, LLKHF_EXTENDED, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, RegisterClassW, SetWindowsHookExW, TranslateMessage, WH_KEYBOARD_LL, WH_MOUSE_LL, WINDOW_STYLE,
WH_MOUSE_LL, WINDOW_STYLE, WM_DISPLAYCHANGE, WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDOWN, WM_DISPLAYCHANGE, WM_KEYDOWN, WM_KEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN,
WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEHWHEEL, WM_MOUSEMOVE, WM_MOUSEWHEEL, WM_RBUTTONDOWN, WM_RBUTTONUP,
WM_RBUTTONUP, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSW, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_USER, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSW, WNDPROC,
WNDPROC,
}; };
use input_event::{ use input_event::{
BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, Event, KeyboardEvent, PointerEvent,
scancode::{self, Linux}, scancode::{self, Linux},
Event, KeyboardEvent, PointerEvent, BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT,
}; };
use super::{display_util, CaptureEvent, Position}; use super::{CaptureEvent, Position, display_util};
pub(crate) struct EventThread { pub(crate) struct EventThread {
request_buffer: Arc<Mutex<Vec<ClientUpdate>>>, request_buffer: Arc<Mutex<Vec<ClientUpdate>>>,
@@ -128,7 +127,7 @@ thread_local! {
fn get_msg() -> Option<MSG> { fn get_msg() -> Option<MSG> {
unsafe { unsafe {
let mut msg = std::mem::zeroed(); let mut msg = std::mem::zeroed();
let ret = GetMessageW(addr_of_mut!(msg), HWND::default(), 0, 0); let ret = GetMessageW(addr_of_mut!(msg), None, 0, 0);
match ret.0 { match ret.0 {
0 => None, 0 => None,
x if x > 0 => Some(msg), x if x > 0 => Some(msg),
@@ -176,14 +175,15 @@ fn start_routine(
/* register hooks */ /* register hooks */
unsafe { unsafe {
let _ = SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, HINSTANCE::default(), 0).unwrap(); let _ = SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, None, 0).unwrap();
let _ = SetWindowsHookExW(WH_KEYBOARD_LL, kybrd_proc, HINSTANCE::default(), 0).unwrap(); let _ = SetWindowsHookExW(WH_KEYBOARD_LL, kybrd_proc, None, 0).unwrap();
} }
let instance = unsafe { GetModuleHandleW(None).unwrap() }; let instance = unsafe { GetModuleHandleW(None).unwrap() };
let instance = instance.into();
let window_class: WNDCLASSW = WNDCLASSW { let window_class: WNDCLASSW = WNDCLASSW {
lpfnWndProc: window_proc, lpfnWndProc: window_proc,
hInstance: instance.into(), hInstance: instance,
lpszClassName: w!("lan-mouse-message-window-class"), lpszClassName: w!("lan-mouse-message-window-class"),
..Default::default() ..Default::default()
}; };
@@ -213,20 +213,17 @@ fn start_routine(
0, 0,
0, 0,
0, 0,
HWND::default(), None,
HMENU::default(), None,
instance, Some(instance),
None, None,
) )
.expect("CreateWindowExW"); .expect("CreateWindowExW");
} }
/* run message loop */ /* run message loop */
loop { while let Some(msg) = get_msg() {
// mouse / keybrd proc do not actually return a message // mouse / keybrd proc do not actually return a message
let Some(msg) = get_msg() else {
break;
};
if msg.hwnd.0.is_null() { if msg.hwnd.0.is_null() {
/* messages sent via PostThreadMessage */ /* messages sent via PostThreadMessage */
match msg.wParam.0 { match msg.wParam.0 {
@@ -312,7 +309,7 @@ unsafe extern "system" fn mouse_proc(ncode: i32, wparam: WPARAM, lparam: LPARAM)
/* no client was active */ /* no client was active */
if !active { if !active {
return CallNextHookEx(HHOOK::default(), ncode, wparam, lparam); return CallNextHookEx(None, ncode, wparam, lparam);
} }
/* get active client if any */ /* get active client if any */
@@ -337,7 +334,7 @@ unsafe extern "system" fn mouse_proc(ncode: i32, wparam: WPARAM, lparam: LPARAM)
unsafe extern "system" fn kybrd_proc(ncode: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { unsafe extern "system" fn kybrd_proc(ncode: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
/* get active client if any */ /* get active client if any */
let Some(client) = ACTIVE_CLIENT.get() else { let Some(client) = ACTIVE_CLIENT.get() else {
return CallNextHookEx(HHOOK::default(), ncode, wparam, lparam); return CallNextHookEx(None, ncode, wparam, lparam);
}; };
/* convert to key event */ /* convert to key event */
@@ -388,7 +385,10 @@ fn enumerate_displays(display_rects: &mut Vec<RECT>) {
if ret == FALSE { if ret == FALSE {
break; break;
} }
if device.StateFlags & DISPLAY_DEVICE_ATTACHED_TO_DESKTOP != 0 { if device
.StateFlags
.contains(DISPLAY_DEVICE_ATTACHED_TO_DESKTOP)
{
devices.push(device.DeviceName); devices.push(device.DeviceName);
} }
} }
@@ -537,6 +537,10 @@ fn to_mouse_event(wparam: WPARAM, lparam: LPARAM) -> Option<PointerEvent> {
state: if p == WM_XBUTTONDOWN as usize { 1 } else { 0 }, state: if p == WM_XBUTTONDOWN as usize { 1 } else { 0 },
}) })
} }
WPARAM(p) if p == WM_MOUSEHWHEEL as usize => Some(PointerEvent::AxisDiscrete120 {
axis: 1, // Horizontal
value: mouse_low_level.mouseData as i32 >> 16,
}),
w => { w => {
log::warn!("unknown mouse event: {w:?}"); log::warn!("unknown mouse event: {w:?}");
None None

View File

@@ -3,7 +3,7 @@ use std::task::Poll;
use async_trait::async_trait; use async_trait::async_trait;
use futures_core::Stream; use futures_core::Stream;
use super::{error::X11InputCaptureCreationError, Capture, CaptureError, CaptureEvent, Position}; use super::{Capture, CaptureError, CaptureEvent, Position, error::X11InputCaptureCreationError};
pub struct X11InputCapture {} pub struct X11InputCapture {}

View File

@@ -21,6 +21,7 @@ tokio = { version = "1.32.0", features = [
"rt", "rt",
"sync", "sync",
"signal", "signal",
"time"
] } ] }
once_cell = "1.19.0" once_cell = "1.19.0"
@@ -39,18 +40,22 @@ wayland-protocols-misc = { version = "0.3.1", features = [
"client", "client",
], optional = true } ], optional = true }
x11 = { version = "2.21.0", features = ["xlib", "xtest"], optional = true } x11 = { version = "2.21.0", features = ["xlib", "xtest"], optional = true }
ashpd = { version = "0.10", default-features = false, features = [ ashpd = { version = "0.13.9", default-features = false, features = [
"remote_desktop",
"screencast",
"tokio", "tokio",
], optional = true } ], optional = true }
reis = { version = "0.4", features = ["tokio"], optional = true } reis = { version = "0.5.0", features = ["tokio"], optional = true }
[target.'cfg(target_os="macos")'.dependencies] [target.'cfg(target_os="macos")'.dependencies]
bitflags = "2.6.0" bitflags = "2.6.0"
core-graphics = { version = "0.24.0", features = ["highsierra"] } core-foundation = "0.10.0"
keycode = "0.4.0" core-foundation-sys = "0.8.6"
core-graphics = { version = "0.25.0", features = ["highsierra"] }
keycode = "1.0.0"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.58.0", features = [ windows = { version = "0.61.2", features = [
"Win32_System_LibraryLoader", "Win32_System_LibraryLoader",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_Foundation", "Win32_Foundation",

31
input-emulation/build.rs Normal file
View File

@@ -0,0 +1,31 @@
fn main() {
let unix = cfg!(unix);
let libei = cfg!(feature = "libei");
let x11 = cfg!(feature = "x11");
let macos = cfg!(target_os = "macos");
let wlroots = cfg!(feature = "wlroots");
let rdp = cfg!(feature = "remote_desktop_portal");
let libei = unix && !macos && libei;
let wlroots = unix && !macos && wlroots;
let x11 = unix && !macos && x11;
let rdp = unix && !macos && rdp;
println!("cargo::rustc-check-cfg=cfg(wlroots)");
println!("cargo::rustc-check-cfg=cfg(libei)");
println!("cargo::rustc-check-cfg=cfg(x11)");
println!("cargo::rustc-check-cfg=cfg(rdp)");
if libei {
println!("cargo::rustc-cfg=libei");
}
if x11 {
println!("cargo::rustc-cfg=x11");
}
if wlroots {
println!("cargo::rustc-cfg=wlroots");
}
if rdp {
println!("cargo::rustc-cfg=rdp");
}
}

View File

@@ -6,37 +6,29 @@ pub enum InputEmulationError {
Emulate(#[from] EmulationError), Emulate(#[from] EmulationError),
} }
#[cfg(all( #[cfg(any(libei, rdp))]
unix, use ashpd::{Error::Response, desktop::ResponseError};
any(feature = "remote_desktop_portal", feature = "libei"),
not(target_os = "macos")
))]
use ashpd::{desktop::ResponseError, Error::Response};
use std::io; use std::io;
use thiserror::Error; use thiserror::Error;
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] #[cfg(wlroots)]
use wayland_client::{ use wayland_client::{
ConnectError, DispatchError,
backend::WaylandError, backend::WaylandError,
globals::{BindError, GlobalError}, globals::{BindError, GlobalError},
ConnectError, DispatchError,
}; };
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum EmulationError { pub enum EmulationError {
#[error("event stream closed")] #[error("event stream closed")]
EndOfStream, EndOfStream,
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
#[error("libei error: `{0}`")] #[error("libei error: `{0}`")]
Libei(#[from] reis::Error), Libei(#[from] reis::Error),
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] #[cfg(wlroots)]
#[error("wayland error: `{0}`")] #[error("wayland error: `{0}`")]
Wayland(#[from] wayland_client::backend::WaylandError), Wayland(#[from] wayland_client::backend::WaylandError),
#[cfg(all( #[cfg(any(rdp, libei))]
unix,
any(feature = "remote_desktop_portal", feature = "libei"),
not(target_os = "macos")
))]
#[error("xdg-desktop-portal: `{0}`")] #[error("xdg-desktop-portal: `{0}`")]
Ashpd(#[from] ashpd::Error), Ashpd(#[from] ashpd::Error),
#[error("io error: `{0}`")] #[error("io error: `{0}`")]
@@ -45,16 +37,16 @@ pub enum EmulationError {
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum EmulationCreationError { pub enum EmulationCreationError {
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] #[cfg(wlroots)]
#[error("wlroots backend: `{0}`")] #[error("wlroots backend: `{0}`")]
Wlroots(#[from] WlrootsEmulationCreationError), Wlroots(#[from] WlrootsEmulationCreationError),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
#[error("libei backend: `{0}`")] #[error("libei backend: `{0}`")]
Libei(#[from] LibeiEmulationCreationError), Libei(#[from] LibeiEmulationCreationError),
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] #[cfg(rdp)]
#[error("xdg-desktop-portal: `{0}`")] #[error("xdg-desktop-portal: `{0}`")]
Xdp(#[from] XdpEmulationCreationError), Xdp(#[from] XdpEmulationCreationError),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
#[error("x11: `{0}`")] #[error("x11: `{0}`")]
X11(#[from] X11EmulationCreationError), X11(#[from] X11EmulationCreationError),
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -70,7 +62,7 @@ pub enum EmulationCreationError {
impl EmulationCreationError { impl EmulationCreationError {
/// request was intentionally denied by the user /// request was intentionally denied by the user
pub(crate) fn cancelled_by_user(&self) -> bool { pub(crate) fn cancelled_by_user(&self) -> bool {
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
if matches!( if matches!(
self, self,
EmulationCreationError::Libei(LibeiEmulationCreationError::Ashpd(Response( EmulationCreationError::Libei(LibeiEmulationCreationError::Ashpd(Response(
@@ -79,7 +71,7 @@ impl EmulationCreationError {
) { ) {
return true; return true;
} }
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] #[cfg(rdp)]
if matches!( if matches!(
self, self,
EmulationCreationError::Xdp(XdpEmulationCreationError::Ashpd(Response( EmulationCreationError::Xdp(XdpEmulationCreationError::Ashpd(Response(
@@ -92,7 +84,7 @@ impl EmulationCreationError {
} }
} }
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] #[cfg(wlroots)]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum WlrootsEmulationCreationError { pub enum WlrootsEmulationCreationError {
#[error(transparent)] #[error(transparent)]
@@ -109,7 +101,7 @@ pub enum WlrootsEmulationCreationError {
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] #[cfg(wlroots)]
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[error("wayland protocol \"{protocol}\" not supported: {inner}")] #[error("wayland protocol \"{protocol}\" not supported: {inner}")]
pub struct WaylandBindError { pub struct WaylandBindError {
@@ -117,14 +109,14 @@ pub struct WaylandBindError {
protocol: &'static str, protocol: &'static str,
} }
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] #[cfg(wlroots)]
impl WaylandBindError { impl WaylandBindError {
pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self { pub(crate) fn new(inner: BindError, protocol: &'static str) -> Self {
Self { inner, protocol } Self { inner, protocol }
} }
} }
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum LibeiEmulationCreationError { pub enum LibeiEmulationCreationError {
#[error(transparent)] #[error(transparent)]
@@ -135,14 +127,14 @@ pub enum LibeiEmulationCreationError {
Reis(#[from] reis::Error), Reis(#[from] reis::Error),
} }
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] #[cfg(rdp)]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum XdpEmulationCreationError { pub enum XdpEmulationCreationError {
#[error(transparent)] #[error(transparent)]
Ashpd(#[from] ashpd::Error), Ashpd(#[from] ashpd::Error),
} }
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum X11EmulationCreationError { pub enum X11EmulationCreationError {
#[error("could not open display")] #[error("could not open display")]
@@ -154,6 +146,10 @@ pub enum X11EmulationCreationError {
pub enum MacOSEmulationCreationError { pub enum MacOSEmulationCreationError {
#[error("could not create event source")] #[error("could not create event source")]
EventSourceCreation, EventSourceCreation,
#[error("accessibility permission is required")]
AccessibilityPermission,
#[error("input control permission is required")]
InputControlPermission,
} }
#[cfg(windows)] #[cfg(windows)]

View File

@@ -11,16 +11,16 @@ pub use self::error::{EmulationCreationError, EmulationError, InputEmulationErro
#[cfg(windows)] #[cfg(windows)]
mod windows; mod windows;
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
mod x11; mod x11;
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] #[cfg(wlroots)]
mod wlroots; mod wlroots;
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] #[cfg(rdp)]
mod xdg_desktop_portal; mod xdg_desktop_portal;
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
mod libei; mod libei;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -34,13 +34,13 @@ pub type EmulationHandle = u64;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Backend { pub enum Backend {
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] #[cfg(wlroots)]
Wlroots, Wlroots,
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
Libei, Libei,
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] #[cfg(rdp)]
Xdp, Xdp,
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
X11, X11,
#[cfg(windows)] #[cfg(windows)]
Windows, Windows,
@@ -52,13 +52,13 @@ pub enum Backend {
impl Display for Backend { impl Display for Backend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] #[cfg(wlroots)]
Backend::Wlroots => write!(f, "wlroots"), Backend::Wlroots => write!(f, "wlroots"),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
Backend::Libei => write!(f, "libei"), Backend::Libei => write!(f, "libei"),
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] #[cfg(rdp)]
Backend::Xdp => write!(f, "xdg-desktop-portal"), Backend::Xdp => write!(f, "xdg-desktop-portal"),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
Backend::X11 => write!(f, "X11"), Backend::X11 => write!(f, "X11"),
#[cfg(windows)] #[cfg(windows)]
Backend::Windows => write!(f, "windows"), Backend::Windows => write!(f, "windows"),
@@ -78,13 +78,13 @@ pub struct InputEmulation {
impl InputEmulation { impl InputEmulation {
async fn with_backend(backend: Backend) -> Result<InputEmulation, EmulationCreationError> { async fn with_backend(backend: Backend) -> Result<InputEmulation, EmulationCreationError> {
let emulation: Box<dyn Emulation> = match backend { let emulation: Box<dyn Emulation> = match backend {
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] #[cfg(wlroots)]
Backend::Wlroots => Box::new(wlroots::WlrootsEmulation::new()?), Backend::Wlroots => Box::new(wlroots::WlrootsEmulation::new()?),
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
Backend::Libei => Box::new(libei::LibeiEmulation::new().await?), Backend::Libei => Box::new(libei::LibeiEmulation::new().await?),
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
Backend::X11 => Box::new(x11::X11Emulation::new()?), Backend::X11 => Box::new(x11::X11Emulation::new()?),
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] #[cfg(rdp)]
Backend::Xdp => Box::new(xdg_desktop_portal::DesktopPortalEmulation::new().await?), Backend::Xdp => Box::new(xdg_desktop_portal::DesktopPortalEmulation::new().await?),
#[cfg(windows)] #[cfg(windows)]
Backend::Windows => Box::new(windows::WindowsEmulation::new()?), Backend::Windows => Box::new(windows::WindowsEmulation::new()?),
@@ -109,13 +109,13 @@ impl InputEmulation {
} }
for backend in [ for backend in [
#[cfg(all(unix, feature = "wlroots", not(target_os = "macos")))] #[cfg(wlroots)]
Backend::Wlroots, Backend::Wlroots,
#[cfg(all(unix, feature = "libei", not(target_os = "macos")))] #[cfg(libei)]
Backend::Libei, Backend::Libei,
#[cfg(all(unix, feature = "remote_desktop_portal", not(target_os = "macos")))] #[cfg(rdp)]
Backend::Xdp, Backend::Xdp,
#[cfg(all(unix, feature = "x11", not(target_os = "macos")))] #[cfg(x11)]
Backend::X11, Backend::X11,
#[cfg(windows)] #[cfg(windows)]
Backend::Windows, Backend::Windows,

View File

@@ -1,25 +1,26 @@
use futures::{future, StreamExt}; use futures::{StreamExt, future};
use std::{ use std::{
io, env, fs, io,
os::{fd::OwnedFd, unix::net::UnixStream}, os::{fd::OwnedFd, unix::net::UnixStream},
path::PathBuf,
sync::{ sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex, RwLock, Arc, Mutex, RwLock,
atomic::{AtomicBool, Ordering},
}, },
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use ashpd::desktop::{ use ashpd::desktop::{
remote_desktop::{DeviceType, RemoteDesktop},
PersistMode, Session, PersistMode, Session,
remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use reis::{ use reis::{
ei::{ ei::{
self, button::ButtonState, handshake::ContextType, keyboard::KeyState, Button, Keyboard, self, Button, Keyboard, Pointer, Scroll, button::ButtonState, handshake::ContextType,
Pointer, Scroll, keyboard::KeyState,
}, },
event::{self, Connection, DeviceCapability, DeviceEvent, EiEvent, SeatEvent}, event::{self, Connection, DeviceCapability, DeviceEvent, EiEvent, SeatEvent},
tokio::EiConvertEventStream, tokio::EiConvertEventStream,
@@ -29,7 +30,7 @@ use input_event::{Event, KeyboardEvent, PointerEvent};
use crate::error::EmulationError; use crate::error::EmulationError;
use super::{error::LibeiEmulationCreationError, Emulation, EmulationHandle}; use super::{Emulation, EmulationHandle, error::LibeiEmulationCreationError};
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct Devices { struct Devices {
@@ -39,42 +40,85 @@ struct Devices {
keyboard: Arc<RwLock<Option<(ei::Device, ei::Keyboard)>>>, keyboard: Arc<RwLock<Option<(ei::Device, ei::Keyboard)>>>,
} }
pub(crate) struct LibeiEmulation<'a> { pub(crate) struct LibeiEmulation {
context: ei::Context, context: ei::Context,
conn: event::Connection, conn: event::Connection,
devices: Devices, devices: Devices,
ei_task: JoinHandle<()>, ei_task: JoinHandle<()>,
error: Arc<Mutex<Option<EmulationError>>>, error: Arc<Mutex<Option<EmulationError>>>,
libei_error: Arc<AtomicBool>, libei_error: Arc<AtomicBool>,
_remote_desktop: RemoteDesktop<'a>, _remote_desktop: RemoteDesktop,
session: Session<'a, RemoteDesktop<'a>>, session: Session<RemoteDesktop>,
} }
async fn get_ei_fd<'a>( /// Get the path to the RemoteDesktop token file
) -> Result<(RemoteDesktop<'a>, Session<'a, RemoteDesktop<'a>>, OwnedFd), ashpd::Error> { fn get_token_file_path() -> PathBuf {
let cache_dir = env::var("XDG_CACHE_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = env::var("HOME").expect("HOME not set");
PathBuf::from(home).join(".cache")
});
cache_dir.join("lan-mouse").join("remote-desktop.token")
}
/// Read the RemoteDesktop token from file
fn read_token() -> Option<String> {
let token_path = get_token_file_path();
match fs::read_to_string(&token_path) {
Ok(token) => Some(token.trim().to_string()),
Err(_) => None,
}
}
/// Write the RemoteDesktop token to file
fn write_token(token: &str) -> io::Result<()> {
let token_path = get_token_file_path();
if let Some(parent) = token_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&token_path, token)?;
Ok(())
}
async fn get_ei_fd() -> Result<(RemoteDesktop, Session<RemoteDesktop>, OwnedFd), ashpd::Error> {
let remote_desktop = RemoteDesktop::new().await?; let remote_desktop = RemoteDesktop::new().await?;
let restore_token = read_token();
log::debug!("creating session ..."); log::debug!("creating session ...");
let session = remote_desktop.create_session().await?; let session = remote_desktop.create_session(Default::default()).await?;
log::debug!("selecting devices ..."); log::debug!("selecting devices ...");
remote_desktop let options = SelectDevicesOptions::default()
.select_devices( .set_devices(DeviceType::Keyboard | DeviceType::Pointer)
&session, .set_persist_mode(PersistMode::ExplicitlyRevoked)
DeviceType::Keyboard | DeviceType::Pointer, .set_restore_token(restore_token.as_deref());
None, remote_desktop.select_devices(&session, options).await?;
PersistMode::ExplicitlyRevoked,
)
.await?;
log::info!("requesting permission for input emulation"); log::info!("requesting permission for input emulation");
let _devices = remote_desktop.start(&session, None).await?.response()?; let start_response = remote_desktop
.start(&session, None, Default::default())
.await?
.response()?;
let fd = remote_desktop.connect_to_eis(&session).await?; // The restore token is only valid once, we need to re-save it each time
if let Some(token_str) = start_response.restore_token() {
if let Err(e) = write_token(token_str) {
log::warn!("failed to save RemoteDesktop token: {}", e);
}
}
let fd = remote_desktop
.connect_to_eis(&session, Default::default())
.await?;
Ok((remote_desktop, session, fd)) Ok((remote_desktop, session, fd))
} }
impl LibeiEmulation<'_> { impl LibeiEmulation {
pub(crate) async fn new() -> Result<Self, LibeiEmulationCreationError> { pub(crate) async fn new() -> Result<Self, LibeiEmulationCreationError> {
let (_remote_desktop, session, eifd) = get_ei_fd().await?; let (_remote_desktop, session, eifd) = get_ei_fd().await?;
let stream = UnixStream::from(eifd); let stream = UnixStream::from(eifd);
@@ -109,14 +153,14 @@ impl LibeiEmulation<'_> {
} }
} }
impl Drop for LibeiEmulation<'_> { impl Drop for LibeiEmulation {
fn drop(&mut self) { fn drop(&mut self) {
self.ei_task.abort(); self.ei_task.abort();
} }
} }
#[async_trait] #[async_trait]
impl Emulation for LibeiEmulation<'_> { impl Emulation for LibeiEmulation {
async fn consume( async fn consume(
&mut self, &mut self,
event: Event, event: Event,

View File

@@ -1,4 +1,4 @@
use super::{error::EmulationError, Emulation, EmulationHandle}; use super::{Emulation, EmulationHandle, error::EmulationError};
use async_trait::async_trait; use async_trait::async_trait;
use bitflags::bitflags; use bitflags::bitflags;
use core_graphics::base::CGFloat; use core_graphics::base::CGFloat;
@@ -10,53 +10,50 @@ use core_graphics::event::{
ScrollEventUnit, ScrollEventUnit,
}; };
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
use input_event::{scancode, Event, KeyboardEvent, PointerEvent}; use input_event::{
BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, Event, KeyboardEvent, PointerEvent,
scancode,
};
use keycode::{KeyMap, KeyMapping}; use keycode::{KeyMap, KeyMapping};
use std::cell::Cell; use std::cell::Cell;
use std::ops::{Index, IndexMut}; use std::collections::HashSet;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::{Duration, Instant};
use tokio::{sync::Notify, task::JoinHandle}; use tokio::{sync::Notify, task::JoinHandle};
use super::error::MacOSEmulationCreationError; use super::error::MacOSEmulationCreationError;
const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis(500); const DEFAULT_REPEAT_DELAY: Duration = Duration::from_millis(500);
const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis(32); const DEFAULT_REPEAT_INTERVAL: Duration = Duration::from_millis(32);
const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(500);
pub(crate) struct MacOSEmulation { pub(crate) struct MacOSEmulation {
/// global event source for all events
event_source: CGEventSource, event_source: CGEventSource,
/// task handle for key repeats
repeat_task: Option<JoinHandle<()>>, repeat_task: Option<JoinHandle<()>>,
button_state: ButtonState, /// current state of the mouse buttons (tracked by evdev button code)
pressed_buttons: HashSet<u32>,
/// button previously pressed (evdev button code)
previous_button: Option<u32>,
/// timestamp of previous click (button down)
previous_button_click: Option<Instant>,
/// click state, i.e. number of clicks in quick succession
button_click_state: i64,
/// current modifier state
modifier_state: Rc<Cell<XMods>>, modifier_state: Rc<Cell<XMods>>,
/// notify to cancel key repeats
notify_repeat_task: Arc<Notify>, notify_repeat_task: Arc<Notify>,
} }
struct ButtonState { /// Maps an evdev button code to the CGEventType used for drag events.
left: bool, fn drag_event_type(button: u32) -> CGEventType {
right: bool, match button {
center: bool, BTN_LEFT => CGEventType::LeftMouseDragged,
} BTN_RIGHT => CGEventType::RightMouseDragged,
// middle, back, forward, and any other button all use OtherMouseDragged
impl Index<CGMouseButton> for ButtonState { _ => CGEventType::OtherMouseDragged,
type Output = bool;
fn index(&self, index: CGMouseButton) -> &Self::Output {
match index {
CGMouseButton::Left => &self.left,
CGMouseButton::Right => &self.right,
CGMouseButton::Center => &self.center,
}
}
}
impl IndexMut<CGMouseButton> for ButtonState {
fn index_mut(&mut self, index: CGMouseButton) -> &mut Self::Output {
match index {
CGMouseButton::Left => &mut self.left,
CGMouseButton::Right => &mut self.right,
CGMouseButton::Center => &mut self.center,
}
} }
} }
@@ -64,16 +61,16 @@ unsafe impl Send for MacOSEmulation {}
impl MacOSEmulation { impl MacOSEmulation {
pub(crate) fn new() -> Result<Self, MacOSEmulationCreationError> { pub(crate) fn new() -> Result<Self, MacOSEmulationCreationError> {
request_macos_emulation_permissions()?;
let event_source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState) let event_source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState)
.map_err(|_| MacOSEmulationCreationError::EventSourceCreation)?; .map_err(|_| MacOSEmulationCreationError::EventSourceCreation)?;
let button_state = ButtonState {
left: false,
right: false,
center: false,
};
Ok(Self { Ok(Self {
event_source, event_source,
button_state, pressed_buttons: HashSet::new(),
previous_button: None,
previous_button_click: None,
button_click_state: 0,
repeat_task: None, repeat_task: None,
notify_repeat_task: Arc::new(Notify::new()), notify_repeat_task: Arc::new(Notify::new()),
modifier_state: Rc::new(Cell::new(XMods::empty())), modifier_state: Rc::new(Cell::new(XMods::empty())),
@@ -89,6 +86,9 @@ impl MacOSEmulation {
// there can only be one repeating key and it's // there can only be one repeating key and it's
// always the last to be pressed // always the last to be pressed
self.cancel_repeat_task().await; self.cancel_repeat_task().await;
// initial key event
key_event(self.event_source.clone(), key, 1, self.modifier_state.get());
// repeat task
let event_source = self.event_source.clone(); let event_source = self.event_source.clone();
let notify = self.notify_repeat_task.clone(); let notify = self.notify_repeat_task.clone();
let modifiers = self.modifier_state.clone(); let modifiers = self.modifier_state.clone();
@@ -121,6 +121,42 @@ impl MacOSEmulation {
} }
} }
fn request_macos_emulation_permissions() -> Result<(), MacOSEmulationCreationError> {
// Request both permissions up front so the user sees both TCC prompts
// on the first launch. See the matching comment in input-capture/src/
// macos.rs::request_macos_capture_permissions for the rationale.
let accessibility = request_accessibility_permission();
let input_control = request_input_control_permission();
if !accessibility {
return Err(MacOSEmulationCreationError::AccessibilityPermission);
}
if !input_control {
return Err(MacOSEmulationCreationError::InputControlPermission);
}
Ok(())
}
fn request_accessibility_permission() -> bool {
// Silent check. The GUI owns the one-time user-visible prompt at
// startup (see lan_mouse_gtk::macos_privacy).
unsafe { AXIsProcessTrusted() }
}
fn request_input_control_permission() -> bool {
unsafe { CGPreflightPostEventAccess() }
}
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGPreflightPostEventAccess() -> bool;
}
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> bool;
}
fn key_event(event_source: CGEventSource, key: u16, state: u8, modifiers: XMods) { fn key_event(event_source: CGEventSource, key: u16, state: u8, modifiers: XMods) {
let event = match CGEvent::new_keyboard_event(event_source, key, state != 0) { let event = match CGEvent::new_keyboard_event(event_source, key, state != 0) {
Ok(e) => e, Ok(e) => e,
@@ -161,12 +197,12 @@ fn get_display_at_point(x: CGFloat, y: CGFloat) -> Option<CGDirectDisplayID> {
}; };
if error != 0 { if error != 0 {
log::warn!("error getting displays at point ({}, {}): {}", x, y, error); log::warn!("error getting displays at point ({x}, {y}): {error}");
return Option::None; return Option::None;
} }
if display_count == 0 { if display_count == 0 {
log::debug!("no displays found at point ({}, {})", x, y); log::debug!("no displays found at point ({x}, {y})");
return Option::None; return Option::None;
} }
@@ -224,150 +260,192 @@ impl Emulation for MacOSEmulation {
event: Event, event: Event,
_handle: EmulationHandle, _handle: EmulationHandle,
) -> Result<(), EmulationError> { ) -> Result<(), EmulationError> {
log::trace!("{event:?}");
match event { match event {
Event::Pointer(pointer_event) => match pointer_event { Event::Pointer(pointer_event) => {
PointerEvent::Motion { time: _, dx, dy } => { match pointer_event {
let mut mouse_location = match self.get_mouse_location() { PointerEvent::Motion { time: _, dx, dy } => {
Some(l) => l, let mut mouse_location = match self.get_mouse_location() {
None => { Some(l) => l,
log::warn!("could not get mouse location!"); None => {
return Ok(()); log::warn!("could not get mouse location!");
} return Ok(());
}; }
};
let (new_mouse_x, new_mouse_y) = let (new_mouse_x, new_mouse_y) =
clamp_to_screen_space(mouse_location.x, mouse_location.y, dx, dy); clamp_to_screen_space(mouse_location.x, mouse_location.y, dx, dy);
mouse_location.x = new_mouse_x; mouse_location.x = new_mouse_x;
mouse_location.y = new_mouse_y; mouse_location.y = new_mouse_y;
let mut event_type = CGEventType::MouseMoved; // If any button is held, emit a drag event for it;
if self.button_state.left { // otherwise emit a normal mouse-moved event.
event_type = CGEventType::LeftMouseDragged let event_type = self
} else if self.button_state.right { .pressed_buttons
event_type = CGEventType::RightMouseDragged .iter()
} else if self.button_state.center { .next()
event_type = CGEventType::OtherMouseDragged .map(|&btn| drag_event_type(btn))
}; .unwrap_or(CGEventType::MouseMoved);
let event = match CGEvent::new_mouse_event( let event = match CGEvent::new_mouse_event(
self.event_source.clone(), self.event_source.clone(),
event_type, event_type,
mouse_location, mouse_location,
CGMouseButton::Left, CGMouseButton::Left,
) { ) {
Ok(e) => e, Ok(e) => e,
Err(_) => { Err(_) => {
log::warn!("mouse event creation failed!"); log::warn!("mouse event creation failed!");
return Ok(()); return Ok(());
}
};
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_X, dx as i64);
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_Y, dy as i64);
event.post(CGEventTapLocation::HID);
}
PointerEvent::Button {
time: _,
button,
state,
} => {
// button number for OtherMouse events (3 = back, 4 = forward, etc.)
let cg_button_number: Option<i64> = match button {
BTN_BACK => Some(3),
BTN_FORWARD => Some(4),
_ => None,
};
let (event_type, mouse_button) = match (button, state) {
(BTN_LEFT, 1) => (CGEventType::LeftMouseDown, CGMouseButton::Left),
(BTN_LEFT, 0) => (CGEventType::LeftMouseUp, CGMouseButton::Left),
(BTN_RIGHT, 1) => (CGEventType::RightMouseDown, CGMouseButton::Right),
(BTN_RIGHT, 0) => (CGEventType::RightMouseUp, CGMouseButton::Right),
(BTN_MIDDLE, 1) => (CGEventType::OtherMouseDown, CGMouseButton::Center),
(BTN_MIDDLE, 0) => (CGEventType::OtherMouseUp, CGMouseButton::Center),
(BTN_BACK, 1) | (BTN_FORWARD, 1) => {
(CGEventType::OtherMouseDown, CGMouseButton::Center)
}
(BTN_BACK, 0) | (BTN_FORWARD, 0) => {
(CGEventType::OtherMouseUp, CGMouseButton::Center)
}
_ => {
log::warn!("invalid button event: {button},{state}");
return Ok(());
}
};
// store button state using the evdev button code so
// back, forward, and middle are tracked independently
if state == 1 {
self.pressed_buttons.insert(button);
} else {
self.pressed_buttons.remove(&button);
} }
};
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_X, dx as i64);
event.set_integer_value_field(EventField::MOUSE_EVENT_DELTA_Y, dy as i64);
event.post(CGEventTapLocation::HID);
}
PointerEvent::Button {
time: _,
button,
state,
} => {
let (event_type, mouse_button) = match (button, state) {
(b, 1) if b == input_event::BTN_LEFT => {
(CGEventType::LeftMouseDown, CGMouseButton::Left)
}
(b, 0) if b == input_event::BTN_LEFT => {
(CGEventType::LeftMouseUp, CGMouseButton::Left)
}
(b, 1) if b == input_event::BTN_RIGHT => {
(CGEventType::RightMouseDown, CGMouseButton::Right)
}
(b, 0) if b == input_event::BTN_RIGHT => {
(CGEventType::RightMouseUp, CGMouseButton::Right)
}
(b, 1) if b == input_event::BTN_MIDDLE => {
(CGEventType::OtherMouseDown, CGMouseButton::Center)
}
(b, 0) if b == input_event::BTN_MIDDLE => {
(CGEventType::OtherMouseUp, CGMouseButton::Center)
}
_ => {
log::warn!("invalid button event: {button},{state}");
return Ok(());
}
};
// store button state
self.button_state[mouse_button] = state == 1;
let location = self.get_mouse_location().unwrap(); // update double-click tracking using the evdev button
let event = match CGEvent::new_mouse_event( // code so that back/forward don't alias with middle
self.event_source.clone(), if state == 1 {
event_type, if self.previous_button == Some(button)
location, && self
mouse_button, .previous_button_click
) { .is_some_and(|i| i.elapsed() < DOUBLE_CLICK_INTERVAL)
Ok(e) => e, {
Err(()) => { self.button_click_state += 1;
log::warn!("mouse event creation failed!"); } else {
return Ok(()); self.button_click_state = 1;
}
self.previous_button = Some(button);
self.previous_button_click = Some(Instant::now());
} }
};
event.post(CGEventTapLocation::HID); log::debug!("click_state: {}", self.button_click_state);
let location = self.get_mouse_location().unwrap();
let event = match CGEvent::new_mouse_event(
self.event_source.clone(),
event_type,
location,
mouse_button,
) {
Ok(e) => e,
Err(()) => {
log::warn!("mouse event creation failed!");
return Ok(());
}
};
event.set_integer_value_field(
EventField::MOUSE_EVENT_CLICK_STATE,
self.button_click_state,
);
// Set the button number for extra buttons (back=3, forward=4)
if let Some(btn_num) = cg_button_number {
event.set_integer_value_field(
EventField::MOUSE_EVENT_BUTTON_NUMBER,
btn_num,
);
}
event.post(CGEventTapLocation::HID);
}
PointerEvent::Axis {
time: _,
axis,
value,
} => {
let value = value as i32;
let (count, wheel1, wheel2, wheel3) = match axis {
0 => (1, value, 0, 0), // 0 = vertical => 1 scroll wheel device (y axis)
1 => (2, 0, value, 0), // 1 = horizontal => 2 scroll wheel devices (y, x) -> (0, x)
_ => {
log::warn!("invalid scroll event: {axis}, {value}");
return Ok(());
}
};
let event = match CGEvent::new_scroll_event(
self.event_source.clone(),
ScrollEventUnit::PIXEL,
count,
wheel1,
wheel2,
wheel3,
) {
Ok(e) => e,
Err(()) => {
log::warn!("scroll event creation failed!");
return Ok(());
}
};
event.post(CGEventTapLocation::HID);
}
PointerEvent::AxisDiscrete120 { axis, value } => {
const LINES_PER_STEP: i32 = 3;
let (count, wheel1, wheel2, wheel3) = match axis {
0 => (1, value / (120 / LINES_PER_STEP), 0, 0), // 0 = vertical => 1 scroll wheel device (y axis)
1 => (2, 0, value / (120 / LINES_PER_STEP), 0), // 1 = horizontal => 2 scroll wheel devices (y, x) -> (0, x)
_ => {
log::warn!("invalid scroll event: {axis}, {value}");
return Ok(());
}
};
let event = match CGEvent::new_scroll_event(
self.event_source.clone(),
ScrollEventUnit::LINE,
count,
wheel1,
wheel2,
wheel3,
) {
Ok(e) => e,
Err(()) => {
log::warn!("scroll event creation failed!");
return Ok(());
}
};
event.post(CGEventTapLocation::HID);
}
} }
PointerEvent::Axis {
time: _, // reset button click state in case it's not a button event
axis, if !matches!(pointer_event, PointerEvent::Button { .. }) {
value, self.button_click_state = 0;
} => {
let value = value as i32;
let (count, wheel1, wheel2, wheel3) = match axis {
0 => (1, value, 0, 0), // 0 = vertical => 1 scroll wheel device (y axis)
1 => (2, 0, value, 0), // 1 = horizontal => 2 scroll wheel devices (y, x) -> (0, x)
_ => {
log::warn!("invalid scroll event: {axis}, {value}");
return Ok(());
}
};
let event = match CGEvent::new_scroll_event(
self.event_source.clone(),
ScrollEventUnit::PIXEL,
count,
wheel1,
wheel2,
wheel3,
) {
Ok(e) => e,
Err(()) => {
log::warn!("scroll event creation failed!");
return Ok(());
}
};
event.post(CGEventTapLocation::HID);
} }
PointerEvent::AxisDiscrete120 { axis, value } => { }
let (count, wheel1, wheel2, wheel3) = match axis {
0 => (1, value, 0, 0), // 0 = vertical => 1 scroll wheel device (y axis)
1 => (2, 0, value, 0), // 1 = horizontal => 2 scroll wheel devices (y, x) -> (0, x)
_ => {
log::warn!("invalid scroll event: {axis}, {value}");
return Ok(());
}
};
let event = match CGEvent::new_scroll_event(
self.event_source.clone(),
ScrollEventUnit::PIXEL,
count,
wheel1,
wheel2,
wheel3,
) {
Ok(e) => e,
Err(()) => {
log::warn!("scroll event creation failed!");
return Ok(());
}
};
event.post(CGEventTapLocation::HID);
}
},
Event::Keyboard(keyboard_event) => match keyboard_event { Event::Keyboard(keyboard_event) => match keyboard_event {
KeyboardEvent::Key { KeyboardEvent::Key {
time: _, time: _,
@@ -381,18 +459,15 @@ impl Emulation for MacOSEmulation {
return Ok(()); return Ok(());
} }
}; };
let is_modifier = update_modifiers(&self.modifier_state, key, state);
if is_modifier {
modifier_event(self.event_source.clone(), self.modifier_state.get());
}
match state { match state {
// pressed // pressed
1 => self.spawn_repeat_task(code).await, 1 => self.spawn_repeat_task(code).await,
_ => self.cancel_repeat_task().await, _ => self.cancel_repeat_task().await,
} }
update_modifiers(&self.modifier_state, key, state);
key_event(
self.event_source.clone(),
code,
state,
self.modifier_state.get(),
);
} }
KeyboardEvent::Modifiers { KeyboardEvent::Modifiers {
depressed, depressed,

View File

@@ -1,22 +1,22 @@
use super::error::{EmulationError, WindowsEmulationCreationError}; use super::error::{EmulationError, WindowsEmulationCreationError};
use input_event::{ use input_event::{
scancode, Event, KeyboardEvent, PointerEvent, BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, Event, KeyboardEvent, PointerEvent,
BTN_RIGHT, scancode,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use std::ops::BitOrAssign; use std::ops::BitOrAssign;
use std::time::Duration; use std::time::Duration;
use tokio::task::AbortHandle; use tokio::task::AbortHandle;
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT_0, KEYEVENTF_EXTENDEDKEY, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP,
};
use windows::Win32::UI::Input::KeyboardAndMouse::{ use windows::Win32::UI::Input::KeyboardAndMouse::{
INPUT, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, INPUT, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE,
MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN,
MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP,
MOUSEEVENTF_WHEEL, MOUSEINPUT, MOUSEEVENTF_WHEEL, MOUSEINPUT,
}; };
use windows::Win32::UI::Input::KeyboardAndMouse::{
INPUT_0, KEYEVENTF_EXTENDEDKEY, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, SendInput,
};
use windows::Win32::UI::WindowsAndMessaging::{XBUTTON1, XBUTTON2}; use windows::Win32::UI::WindowsAndMessaging::{XBUTTON1, XBUTTON2};
use super::{Emulation, EmulationHandle}; use super::{Emulation, EmulationHandle};

View File

@@ -1,6 +1,6 @@
use crate::error::EmulationError; use crate::error::EmulationError;
use super::{error::WlrootsEmulationCreationError, Emulation}; use super::{Emulation, error::WlrootsEmulationCreationError};
use async_trait::async_trait; use async_trait::async_trait;
use bitflags::bitflags; use bitflags::bitflags;
use std::collections::HashMap; use std::collections::HashMap;
@@ -8,11 +8,11 @@ use std::io;
use std::os::fd::{AsFd, OwnedFd}; use std::os::fd::{AsFd, OwnedFd};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use wayland_client::backend::WaylandError;
use wayland_client::WEnum; use wayland_client::WEnum;
use wayland_client::backend::WaylandError;
use wayland_client::protocol::wl_keyboard::{self, WlKeyboard}; use wayland_client::protocol::wl_keyboard::{self, WlKeyboard};
use wayland_client::protocol::wl_pointer::{Axis, ButtonState}; use wayland_client::protocol::wl_pointer::{Axis, AxisSource, ButtonState};
use wayland_client::protocol::wl_seat::WlSeat; use wayland_client::protocol::wl_seat::WlSeat;
use wayland_protocols_wlr::virtual_pointer::v1::client::{ use wayland_protocols_wlr::virtual_pointer::v1::client::{
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1 as VpManager, zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1 as VpManager,
@@ -25,16 +25,15 @@ use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
}; };
use wayland_client::{ use wayland_client::{
delegate_noop, Connection, Dispatch, EventQueue, QueueHandle, delegate_noop,
globals::{registry_queue_init, GlobalListContents}, globals::{GlobalListContents, registry_queue_init},
protocol::{wl_registry, wl_seat}, protocol::{wl_registry, wl_seat},
Connection, Dispatch, EventQueue, QueueHandle,
}; };
use input_event::{scancode, Event, KeyboardEvent, PointerEvent}; use input_event::{Event, KeyboardEvent, PointerEvent, scancode};
use super::error::WaylandBindError;
use super::EmulationHandle; use super::EmulationHandle;
use super::error::WaylandBindError;
struct State { struct State {
keymap: Option<(u32, OwnedFd, u32)>, keymap: Option<(u32, OwnedFd, u32)>,
@@ -163,13 +162,13 @@ impl Emulation for WlrootsEmulation {
async fn create(&mut self, handle: EmulationHandle) { async fn create(&mut self, handle: EmulationHandle) {
self.state.add_client(handle); self.state.add_client(handle);
if let Err(e) = self.queue.flush() { if let Err(e) = self.queue.flush() {
log::error!("{}", e); log::error!("{e}");
} }
} }
async fn destroy(&mut self, handle: EmulationHandle) { async fn destroy(&mut self, handle: EmulationHandle) {
self.state.destroy_client(handle); self.state.destroy_client(handle);
if let Err(e) = self.queue.flush() { if let Err(e) = self.queue.flush() {
log::error!("{}", e); log::error!("{e}");
} }
} }
async fn terminate(&mut self) { async fn terminate(&mut self) {
@@ -210,7 +209,8 @@ impl VirtualInput {
PointerEvent::AxisDiscrete120 { axis, value } => { PointerEvent::AxisDiscrete120 { axis, value } => {
let axis: Axis = (axis as u32).try_into()?; let axis: Axis = (axis as u32).try_into()?;
self.pointer self.pointer
.axis_discrete(now, axis, value as f64 / 6., value / 120); .axis_discrete(now, axis, value as f64 / 8., value / 120);
self.pointer.axis_source(AxisSource::Wheel);
self.pointer.frame(); self.pointer.frame();
} }
} }
@@ -221,7 +221,7 @@ impl VirtualInput {
self.keyboard.key(time, key, state as u32); self.keyboard.key(time, key, state as u32);
if let Ok(mut mods) = self.modifiers.lock() { if let Ok(mut mods) = self.modifiers.lock() {
if mods.update_by_key_event(key, state) { if mods.update_by_key_event(key, state) {
log::trace!("Key triggers modifier change: {:?}", mods); log::trace!("Key triggers modifier change: {mods:?}");
self.keyboard.modifiers( self.keyboard.modifiers(
mods.mask_pressed().bits(), mods.mask_pressed().bits(),
0, 0,
@@ -330,7 +330,7 @@ impl XMods {
fn update_by_key_event(&mut self, key: u32, state: u8) -> bool { fn update_by_key_event(&mut self, key: u32, state: u8) -> bool {
if let Ok(key) = scancode::Linux::try_from(key) { if let Ok(key) = scancode::Linux::try_from(key) {
log::trace!("Attempting to process modifier from: {:#?}", key); log::trace!("Attempting to process modifier from: {key:#?}");
let pressed_mask = match key { let pressed_mask = match key {
scancode::Linux::KeyLeftShift | scancode::Linux::KeyRightShift => XMods::ShiftMask, scancode::Linux::KeyLeftShift | scancode::Linux::KeyRightShift => XMods::ShiftMask,
scancode::Linux::KeyLeftCtrl | scancode::Linux::KeyRightCtrl => XMods::ControlMask, scancode::Linux::KeyLeftCtrl | scancode::Linux::KeyRightCtrl => XMods::ControlMask,
@@ -348,7 +348,7 @@ impl XMods {
// unchanged // unchanged
if pressed_mask.is_empty() && locked_mask.is_empty() { if pressed_mask.is_empty() && locked_mask.is_empty() {
log::trace!("{:#?} is not a modifier key", key); log::trace!("{key:#?} is not a modifier key");
return false; return false;
} }
match state { match state {

View File

@@ -6,12 +6,12 @@ use x11::{
}; };
use input_event::{ use input_event::{
Event, KeyboardEvent, PointerEvent, BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, BTN_BACK, BTN_FORWARD, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, Event, KeyboardEvent, PointerEvent,
}; };
use crate::error::EmulationError; use crate::error::EmulationError;
use super::{error::X11EmulationCreationError, Emulation, EmulationHandle}; use super::{Emulation, EmulationHandle, error::X11EmulationCreationError};
pub(crate) struct X11Emulation { pub(crate) struct X11Emulation {
display: *mut xlib::Display, display: *mut xlib::Display,
@@ -23,7 +23,7 @@ impl X11Emulation {
pub(crate) fn new() -> Result<Self, X11EmulationCreationError> { pub(crate) fn new() -> Result<Self, X11EmulationCreationError> {
let display = unsafe { let display = unsafe {
match xlib::XOpenDisplay(ptr::null()) { match xlib::XOpenDisplay(ptr::null()) {
d if d == ptr::null::<xlib::Display>() as *mut xlib::Display => { d if std::ptr::eq(d, ptr::null_mut::<xlib::Display>()) => {
Err(X11EmulationCreationError::OpenDisplay) Err(X11EmulationCreationError::OpenDisplay)
} }
display => Ok(display), display => Ok(display),

View File

@@ -1,7 +1,10 @@
use ashpd::{ use ashpd::{
desktop::{ desktop::{
remote_desktop::{Axis, DeviceType, KeyState, RemoteDesktop},
PersistMode, Session, PersistMode, Session,
remote_desktop::{
Axis, DeviceType, KeyState, NotifyPointerAxisOptions, RemoteDesktop,
SelectDevicesOptions,
},
}, },
zbus::AsyncDrop, zbus::AsyncDrop,
}; };
@@ -15,34 +18,33 @@ use input_event::{
use crate::error::EmulationError; use crate::error::EmulationError;
use super::{error::XdpEmulationCreationError, Emulation, EmulationHandle}; use super::{Emulation, EmulationHandle, error::XdpEmulationCreationError};
pub(crate) struct DesktopPortalEmulation<'a> { pub(crate) struct DesktopPortalEmulation {
proxy: RemoteDesktop<'a>, proxy: RemoteDesktop,
session: Session<'a, RemoteDesktop<'a>>, session: Session<RemoteDesktop>,
} }
impl<'a> DesktopPortalEmulation<'a> { impl DesktopPortalEmulation {
pub(crate) async fn new() -> Result<DesktopPortalEmulation<'a>, XdpEmulationCreationError> { pub(crate) async fn new() -> Result<DesktopPortalEmulation, XdpEmulationCreationError> {
log::debug!("connecting to org.freedesktop.portal.RemoteDesktop portal ..."); log::debug!("connecting to org.freedesktop.portal.RemoteDesktop portal ...");
let proxy = RemoteDesktop::new().await?; let proxy = RemoteDesktop::new().await?;
// retry when user presses the cancel button // retry when user presses the cancel button
log::debug!("creating session ..."); log::debug!("creating session ...");
let session = proxy.create_session().await?; let session = proxy.create_session(Default::default()).await?;
log::debug!("selecting devices ..."); log::debug!("selecting devices ...");
proxy let options = SelectDevicesOptions::default()
.select_devices( .set_devices(DeviceType::Keyboard | DeviceType::Pointer)
&session, .set_persist_mode(PersistMode::ExplicitlyRevoked);
DeviceType::Keyboard | DeviceType::Pointer, proxy.select_devices(&session, options).await?;
None,
PersistMode::ExplicitlyRevoked,
)
.await?;
log::info!("requesting permission for input emulation"); log::info!("requesting permission for input emulation");
let _devices = proxy.start(&session, None).await?.response()?; let _devices = proxy
.start(&session, None, Default::default())
.await?
.response()?;
log::debug!("started session"); log::debug!("started session");
let session = session; let session = session;
@@ -52,7 +54,7 @@ impl<'a> DesktopPortalEmulation<'a> {
} }
#[async_trait] #[async_trait]
impl Emulation for DesktopPortalEmulation<'_> { impl Emulation for DesktopPortalEmulation {
async fn consume( async fn consume(
&mut self, &mut self,
event: input_event::Event, event: input_event::Event,
@@ -62,7 +64,7 @@ impl Emulation for DesktopPortalEmulation<'_> {
Pointer(p) => match p { Pointer(p) => match p {
PointerEvent::Motion { time: _, dx, dy } => { PointerEvent::Motion { time: _, dx, dy } => {
self.proxy self.proxy
.notify_pointer_motion(&self.session, dx, dy) .notify_pointer_motion(&self.session, dx, dy, Default::default())
.await?; .await?;
} }
PointerEvent::Button { PointerEvent::Button {
@@ -75,7 +77,12 @@ impl Emulation for DesktopPortalEmulation<'_> {
_ => KeyState::Pressed, _ => KeyState::Pressed,
}; };
self.proxy self.proxy
.notify_pointer_button(&self.session, button as i32, state) .notify_pointer_button(
&self.session,
button as i32,
state,
Default::default(),
)
.await?; .await?;
} }
PointerEvent::AxisDiscrete120 { axis, value } => { PointerEvent::AxisDiscrete120 { axis, value } => {
@@ -84,7 +91,12 @@ impl Emulation for DesktopPortalEmulation<'_> {
_ => Axis::Horizontal, _ => Axis::Horizontal,
}; };
self.proxy self.proxy
.notify_pointer_axis_discrete(&self.session, axis, value / 120) .notify_pointer_axis_discrete(
&self.session,
axis,
value / 120,
Default::default(),
)
.await?; .await?;
} }
PointerEvent::Axis { PointerEvent::Axis {
@@ -101,7 +113,12 @@ impl Emulation for DesktopPortalEmulation<'_> {
Axis::Horizontal => (value, 0.), Axis::Horizontal => (value, 0.),
}; };
self.proxy self.proxy
.notify_pointer_axis(&self.session, dx, dy, true) .notify_pointer_axis(
&self.session,
dx,
dy,
NotifyPointerAxisOptions::default().set_finish(true),
)
.await?; .await?;
} }
}, },
@@ -117,7 +134,12 @@ impl Emulation for DesktopPortalEmulation<'_> {
_ => KeyState::Pressed, _ => KeyState::Pressed,
}; };
self.proxy self.proxy
.notify_keyboard_keycode(&self.session, key as i32, state) .notify_keyboard_keycode(
&self.session,
key as i32,
state,
Default::default(),
)
.await?; .await?;
} }
KeyboardEvent::Modifiers { .. } => { KeyboardEvent::Modifiers { .. } => {
@@ -141,9 +163,8 @@ impl Emulation for DesktopPortalEmulation<'_> {
} }
} }
impl AsyncDrop for DesktopPortalEmulation<'_> { impl AsyncDrop for DesktopPortalEmulation {
#[doc = r" Perform the async cleanup."] #[doc = r" Perform the async cleanup."]
#[must_use]
#[allow(clippy::type_complexity, clippy::type_repetition_in_bounds)] #[allow(clippy::type_complexity, clippy::type_repetition_in_bounds)]
fn async_drop<'async_trait>( fn async_drop<'async_trait>(
self, self,

View File

@@ -14,7 +14,7 @@ serde = { version = "1.0", features = ["derive"] }
thiserror = "2.0.0" thiserror = "2.0.0"
[target.'cfg(all(unix, not(target_os="macos")))'.dependencies] [target.'cfg(all(unix, not(target_os="macos")))'.dependencies]
reis = { version = "0.4", optional = true } reis = { version = "0.5.0", optional = true }
[features] [features]
default = ["libei"] default = ["libei"]

View File

@@ -112,8 +112,8 @@ impl Display for KeyboardEvent {
impl Display for Event { impl Display for Event {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Event::Pointer(p) => write!(f, "{}", p), Event::Pointer(p) => write!(f, "{p}"),
Event::Keyboard(k) => write!(f, "{}", k), Event::Keyboard(k) => write!(f, "{k}"),
} }
} }
} }

View File

@@ -9,6 +9,8 @@ repository = "https://github.com/feschber/lan-mouse"
[dependencies] [dependencies]
futures = "0.3.30" futures = "0.3.30"
lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" } lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" }
clap = { version = "4.4.11", features = ["derive"] }
thiserror = "2.0.0"
tokio = { version = "1.32.0", features = [ tokio = { version = "1.32.0", features = [
"io-util", "io-util",
"io-std", "io-std",

View File

@@ -1,153 +0,0 @@
use std::{
fmt::Display,
str::{FromStr, SplitWhitespace},
};
use lan_mouse_ipc::{ClientHandle, Position};
pub(super) enum CommandType {
NoCommand,
Help,
Connect,
Disconnect,
Activate,
Deactivate,
List,
SetHost,
SetPort,
}
#[derive(Debug)]
pub(super) struct InvalidCommand {
cmd: String,
}
impl Display for InvalidCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid command: \"{}\"", self.cmd)
}
}
impl FromStr for CommandType {
type Err = InvalidCommand;
fn from_str(s: &str) -> std::prelude::v1::Result<Self, Self::Err> {
match s {
"connect" => Ok(Self::Connect),
"disconnect" => Ok(Self::Disconnect),
"activate" => Ok(Self::Activate),
"deactivate" => Ok(Self::Deactivate),
"list" => Ok(Self::List),
"set-host" => Ok(Self::SetHost),
"set-port" => Ok(Self::SetPort),
"help" => Ok(Self::Help),
_ => Err(InvalidCommand { cmd: s.to_string() }),
}
}
}
#[derive(Debug)]
pub(super) enum Command {
None,
Help,
Connect(Position, String, Option<u16>),
Disconnect(ClientHandle),
Activate(ClientHandle),
Deactivate(ClientHandle),
List,
SetHost(ClientHandle, String),
SetPort(ClientHandle, Option<u16>),
}
impl CommandType {
pub(super) fn usage(&self) -> &'static str {
match self {
CommandType::Help => "help",
CommandType::NoCommand => "",
CommandType::Connect => "connect left|right|top|bottom <host> [<port>]",
CommandType::Disconnect => "disconnect <id>",
CommandType::Activate => "activate <id>",
CommandType::Deactivate => "deactivate <id>",
CommandType::List => "list",
CommandType::SetHost => "set-host <id> <host>",
CommandType::SetPort => "set-port <id> <host>",
}
}
}
pub(super) enum CommandParseError {
Usage(CommandType),
Invalid(InvalidCommand),
}
impl Display for CommandParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Usage(cmd) => write!(f, "usage: {}", cmd.usage()),
Self::Invalid(cmd) => write!(f, "{}", cmd),
}
}
}
impl FromStr for Command {
type Err = CommandParseError;
fn from_str(cmd: &str) -> Result<Self, Self::Err> {
let mut args = cmd.split_whitespace();
let cmd_type: CommandType = match args.next() {
Some(c) => c.parse().map_err(CommandParseError::Invalid),
None => Ok(CommandType::NoCommand),
}?;
match cmd_type {
CommandType::Help => Ok(Command::Help),
CommandType::NoCommand => Ok(Command::None),
CommandType::Connect => parse_connect_cmd(args),
CommandType::Disconnect => parse_disconnect_cmd(args),
CommandType::Activate => parse_activate_cmd(args),
CommandType::Deactivate => parse_deactivate_cmd(args),
CommandType::List => Ok(Command::List),
CommandType::SetHost => parse_set_host(args),
CommandType::SetPort => parse_set_port(args),
}
}
}
fn parse_connect_cmd(mut args: SplitWhitespace<'_>) -> Result<Command, CommandParseError> {
const USAGE: CommandParseError = CommandParseError::Usage(CommandType::Connect);
let pos = args.next().ok_or(USAGE)?.parse().map_err(|_| USAGE)?;
let host = args.next().ok_or(USAGE)?.to_string();
let port = args.next().and_then(|p| p.parse().ok());
Ok(Command::Connect(pos, host, port))
}
fn parse_disconnect_cmd(mut args: SplitWhitespace<'_>) -> Result<Command, CommandParseError> {
const USAGE: CommandParseError = CommandParseError::Usage(CommandType::Disconnect);
let id = args.next().ok_or(USAGE)?.parse().map_err(|_| USAGE)?;
Ok(Command::Disconnect(id))
}
fn parse_activate_cmd(mut args: SplitWhitespace<'_>) -> Result<Command, CommandParseError> {
const USAGE: CommandParseError = CommandParseError::Usage(CommandType::Activate);
let id = args.next().ok_or(USAGE)?.parse().map_err(|_| USAGE)?;
Ok(Command::Activate(id))
}
fn parse_deactivate_cmd(mut args: SplitWhitespace<'_>) -> Result<Command, CommandParseError> {
const USAGE: CommandParseError = CommandParseError::Usage(CommandType::Deactivate);
let id = args.next().ok_or(USAGE)?.parse().map_err(|_| USAGE)?;
Ok(Command::Deactivate(id))
}
fn parse_set_host(mut args: SplitWhitespace<'_>) -> Result<Command, CommandParseError> {
const USAGE: CommandParseError = CommandParseError::Usage(CommandType::SetHost);
let id = args.next().ok_or(USAGE)?.parse().map_err(|_| USAGE)?;
let host = args.next().ok_or(USAGE)?.parse().map_err(|_| USAGE)?;
Ok(Command::SetHost(id, host))
}
fn parse_set_port(mut args: SplitWhitespace<'_>) -> Result<Command, CommandParseError> {
const USAGE: CommandParseError = CommandParseError::Usage(CommandType::SetPort);
let id = args.next().ok_or(USAGE)?.parse().map_err(|_| USAGE)?;
let port = args.next().and_then(|p| p.parse().ok());
Ok(Command::SetPort(id, port))
}

View File

@@ -1,320 +1,170 @@
use clap::{Args, Parser, Subcommand};
use futures::StreamExt; use futures::StreamExt;
use tokio::{
io::{AsyncBufReadExt, BufReader},
task::LocalSet,
};
use std::io::{self, Write}; use std::{net::IpAddr, time::Duration};
use thiserror::Error;
use self::command::{Command, CommandType};
use lan_mouse_ipc::{ use lan_mouse_ipc::{
AsyncFrontendEventReader, AsyncFrontendRequestWriter, ClientConfig, ClientHandle, ClientState, ClientHandle, ConnectionError, FrontendEvent, FrontendRequest, IpcError, Position,
FrontendEvent, FrontendRequest, IpcError, DEFAULT_PORT, connect_async,
}; };
mod command; #[derive(Debug, Error)]
pub enum CliError {
/// is the service running?
#[error("could not connect: `{0}` - is the service running?")]
ServiceNotRunning(#[from] ConnectionError),
#[error("error communicating with service: {0}")]
Ipc(#[from] IpcError),
}
pub fn run() -> Result<(), IpcError> { #[derive(Parser, Clone, Debug, PartialEq, Eq)]
let runtime = tokio::runtime::Builder::new_current_thread() #[command(name = "lan-mouse-cli", about = "LanMouse CLI interface")]
.enable_io() pub struct CliArgs {
.enable_time() #[command(subcommand)]
.build()?; command: CliSubcommand,
runtime.block_on(LocalSet::new().run_until(async move { }
let (rx, tx) = lan_mouse_ipc::connect_async().await?;
let mut cli = Cli::new(rx, tx); #[derive(Args, Clone, Debug, PartialEq, Eq)]
cli.run().await struct Client {
}))?; #[arg(long)]
hostname: Option<String>,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
ips: Option<Vec<IpAddr>>,
#[arg(long)]
enter_hook: Option<String>,
}
#[derive(Clone, Subcommand, Debug, PartialEq, Eq)]
enum CliSubcommand {
/// add a new client
AddClient(Client),
/// remove an existing client
RemoveClient { id: ClientHandle },
/// activate a client
Activate { id: ClientHandle },
/// deactivate a client
Deactivate { id: ClientHandle },
/// list configured clients
List,
/// change hostname
SetHost {
id: ClientHandle,
host: Option<String>,
},
/// change port
SetPort { id: ClientHandle, port: u16 },
/// set position
SetPosition { id: ClientHandle, pos: Position },
/// set ips
SetIps { id: ClientHandle, ips: Vec<IpAddr> },
/// re-enable capture
EnableCapture,
/// re-enable emulation
EnableEmulation,
/// authorize a public key
AuthorizeKey {
description: String,
sha256_fingerprint: String,
},
/// deauthorize a public key
RemoveAuthorizedKey { sha256_fingerprint: String },
/// save configuration to file
SaveConfig,
}
pub async fn run(args: CliArgs) -> Result<(), CliError> {
execute(args.command).await?;
Ok(()) Ok(())
} }
struct Cli { async fn execute(cmd: CliSubcommand) -> Result<(), CliError> {
clients: Vec<(ClientHandle, ClientConfig, ClientState)>, let (mut rx, mut tx) = connect_async(Some(Duration::from_millis(500))).await?;
changed: Option<ClientHandle>, match cmd {
rx: AsyncFrontendEventReader, CliSubcommand::AddClient(Client {
tx: AsyncFrontendRequestWriter, hostname,
} port,
ips,
impl Cli { enter_hook,
fn new(rx: AsyncFrontendEventReader, tx: AsyncFrontendRequestWriter) -> Cli { }) => {
Self { tx.request(FrontendRequest::Create).await?;
clients: vec![], while let Some(e) = rx.next().await {
changed: None, if let FrontendEvent::Created(handle, _, _) = e? {
rx, if let Some(hostname) = hostname {
tx, tx.request(FrontendRequest::UpdateHostname(handle, Some(hostname)))
} .await?;
}
async fn run(&mut self) -> Result<(), IpcError> {
let stdin = tokio::io::stdin();
let stdin = BufReader::new(stdin);
let mut stdin = stdin.lines();
/* initial state sync */
self.clients = loop {
match self.rx.next().await {
Some(Ok(e)) => {
if let FrontendEvent::Enumerate(clients) = e {
break clients;
} }
} if let Some(port) = port {
Some(Err(e)) => return Err(e), tx.request(FrontendRequest::UpdatePort(handle, port))
None => return Ok(()), .await?;
}
};
loop {
prompt()?;
tokio::select! {
line = stdin.next_line() => {
let Some(line) = line? else {
break Ok(());
};
let cmd: Command = match line.parse() {
Ok(cmd) => cmd,
Err(e) => {
eprintln!("{e}");
continue;
}
};
self.execute(cmd).await?;
}
event = self.rx.next() => {
if let Some(event) = event {
self.handle_event(event?);
} else {
break Ok(());
} }
} if let Some(ips) = ips {
} tx.request(FrontendRequest::UpdateFixIps(handle, ips))
if let Some(handle) = self.changed.take() { .await?;
self.update_client(handle).await?;
}
}
}
async fn update_client(&mut self, handle: ClientHandle) -> Result<(), IpcError> {
self.tx.request(FrontendRequest::GetState(handle)).await?;
while let Some(Ok(event)) = self.rx.next().await {
self.handle_event(event.clone());
if let FrontendEvent::State(_, _, _) | FrontendEvent::NoSuchClient(_) = event {
break;
}
}
Ok(())
}
async fn execute(&mut self, cmd: Command) -> Result<(), IpcError> {
match cmd {
Command::None => {}
Command::Connect(pos, host, port) => {
let request = FrontendRequest::Create;
self.tx.request(request).await?;
let handle = loop {
if let Some(Ok(event)) = self.rx.next().await {
match event {
FrontendEvent::Created(h, c, s) => {
self.clients.push((h, c, s));
break h;
}
_ => {
self.handle_event(event);
continue;
}
}
} }
}; if let Some(enter_hook) = enter_hook {
for request in [ tx.request(FrontendRequest::UpdateEnterHook(handle, Some(enter_hook)))
FrontendRequest::UpdateHostname(handle, Some(host.clone())), .await?;
FrontendRequest::UpdatePort(handle, port.unwrap_or(DEFAULT_PORT)),
FrontendRequest::UpdatePosition(handle, pos),
] {
self.tx.request(request).await?;
}
self.update_client(handle).await?;
}
Command::Disconnect(id) => {
self.tx.request(FrontendRequest::Delete(id)).await?;
loop {
if let Some(Ok(event)) = self.rx.next().await {
self.handle_event(event.clone());
if let FrontendEvent::Deleted(_) = event {
self.handle_event(event);
break;
}
} }
} break;
}
Command::Activate(id) => {
self.tx.request(FrontendRequest::Activate(id, true)).await?;
self.update_client(id).await?;
}
Command::Deactivate(id) => {
self.tx
.request(FrontendRequest::Activate(id, false))
.await?;
self.update_client(id).await?;
}
Command::List => {
self.tx.request(FrontendRequest::Enumerate()).await?;
while let Some(e) = self.rx.next().await {
let event = e?;
self.handle_event(event.clone());
if let FrontendEvent::Enumerate(_) = event {
break;
}
}
}
Command::SetHost(handle, host) => {
let request = FrontendRequest::UpdateHostname(handle, Some(host.clone()));
self.tx.request(request).await?;
self.update_client(handle).await?;
}
Command::SetPort(handle, port) => {
let request = FrontendRequest::UpdatePort(handle, port.unwrap_or(DEFAULT_PORT));
self.tx.request(request).await?;
self.update_client(handle).await?;
}
Command::Help => {
for cmd_type in [
CommandType::List,
CommandType::Connect,
CommandType::Disconnect,
CommandType::Activate,
CommandType::Deactivate,
CommandType::SetHost,
CommandType::SetPort,
] {
eprintln!("{}", cmd_type.usage());
} }
} }
} }
Ok(()) CliSubcommand::RemoveClient { id } => tx.request(FrontendRequest::Delete(id)).await?,
} CliSubcommand::Activate { id } => tx.request(FrontendRequest::Activate(id, true)).await?,
CliSubcommand::Deactivate { id } => {
fn find_mut( tx.request(FrontendRequest::Activate(id, false)).await?
&mut self, }
handle: ClientHandle, CliSubcommand::List => {
) -> Option<&mut (ClientHandle, ClientConfig, ClientState)> { tx.request(FrontendRequest::Enumerate()).await?;
self.clients.iter_mut().find(|(h, _, _)| *h == handle) while let Some(e) = rx.next().await {
} if let FrontendEvent::Enumerate(clients) = e? {
for (handle, config, state) in clients {
fn remove( let host = config.hostname.unwrap_or("unknown".to_owned());
&mut self, let port = config.port;
handle: ClientHandle, let pos = config.pos;
) -> Option<(ClientHandle, ClientConfig, ClientState)> { let active = state.active;
let idx = self.clients.iter().position(|(h, _, _)| *h == handle); let ips = state.ips;
idx.map(|i| self.clients.swap_remove(i)) println!(
} "id {handle}: {host}:{port} ({pos}) active: {active}, ips: {ips:?}"
fn handle_event(&mut self, event: FrontendEvent) {
match event {
FrontendEvent::Changed(h) => self.changed = Some(h),
FrontendEvent::Created(h, c, s) => {
eprint!("client added ({h}): ");
print_config(&c);
eprint!(" ");
print_state(&s);
eprintln!();
self.clients.push((h, c, s));
}
FrontendEvent::NoSuchClient(h) => {
eprintln!("no such client: {h}");
}
FrontendEvent::State(h, c, s) => {
if let Some((_, config, state)) = self.find_mut(h) {
let old_host = config.hostname.clone().unwrap_or("\"\"".into());
let new_host = c.hostname.clone().unwrap_or("\"\"".into());
if old_host != new_host {
eprintln!(
"client {h}: hostname updated ({} -> {})",
old_host, new_host
); );
} }
if config.port != c.port { break;
eprintln!("client {h} changed port: {} -> {}", config.port, c.port);
}
if config.fix_ips != c.fix_ips {
eprintln!("client {h} ips updated: {:?}", c.fix_ips)
}
*config = c;
if state.active ^ s.active {
eprintln!(
"client {h} {}",
if s.active { "activated" } else { "deactivated" }
);
}
*state = s;
} }
} }
FrontendEvent::Deleted(h) => {
if let Some((h, c, _)) = self.remove(h) {
eprint!("client {h} removed (");
print_config(&c);
eprintln!(")");
}
}
FrontendEvent::PortChanged(p, e) => {
if let Some(e) = e {
eprintln!("failed to change port: {e}");
} else {
eprintln!("changed port to {p}");
}
}
FrontendEvent::Enumerate(clients) => {
self.clients = clients;
self.print_clients();
}
FrontendEvent::Error(e) => {
eprintln!("ERROR: {e}");
}
FrontendEvent::CaptureStatus(s) => {
eprintln!("capture status: {s:?}")
}
FrontendEvent::EmulationStatus(s) => {
eprintln!("emulation status: {s:?}")
}
FrontendEvent::AuthorizedUpdated(fingerprints) => {
eprintln!("authorized keys changed:");
for (desc, fp) in fingerprints {
eprintln!("{desc}: {fp}");
}
}
FrontendEvent::PublicKeyFingerprint(fp) => {
eprintln!("the public key fingerprint of this device is {fp}");
}
FrontendEvent::IncomingConnected(..) => {}
FrontendEvent::IncomingDisconnected(..) => {}
} }
} CliSubcommand::SetHost { id, host } => {
tx.request(FrontendRequest::UpdateHostname(id, host))
fn print_clients(&mut self) { .await?
for (h, c, s) in self.clients.iter() {
eprint!("client {h}: ");
print_config(c);
eprint!(" ");
print_state(s);
eprintln!();
} }
CliSubcommand::SetPort { id, port } => {
tx.request(FrontendRequest::UpdatePort(id, port)).await?
}
CliSubcommand::SetPosition { id, pos } => {
tx.request(FrontendRequest::UpdatePosition(id, pos)).await?
}
CliSubcommand::SetIps { id, ips } => {
tx.request(FrontendRequest::UpdateFixIps(id, ips)).await?
}
CliSubcommand::EnableCapture => tx.request(FrontendRequest::EnableCapture).await?,
CliSubcommand::EnableEmulation => tx.request(FrontendRequest::EnableEmulation).await?,
CliSubcommand::AuthorizeKey {
description,
sha256_fingerprint,
} => {
tx.request(FrontendRequest::AuthorizeKey(
description,
sha256_fingerprint,
))
.await?
}
CliSubcommand::RemoveAuthorizedKey { sha256_fingerprint } => {
tx.request(FrontendRequest::RemoveAuthorizedKey(sha256_fingerprint))
.await?
}
CliSubcommand::SaveConfig => tx.request(FrontendRequest::SaveConfiguration).await?,
} }
}
fn prompt() -> io::Result<()> {
eprint!("lan-mouse > ");
std::io::stderr().flush()?;
Ok(()) Ok(())
} }
fn print_config(c: &ClientConfig) {
eprint!(
"{}:{} ({}), ips: {:?}",
c.hostname.clone().unwrap_or("(no hostname)".into()),
c.port,
c.pos,
c.fix_ips
);
}
fn print_state(s: &ClientState) {
eprint!("active: {}, dns: {:?}", s.active, s.ips);
}

View File

@@ -13,6 +13,7 @@ async-channel = { version = "2.1.1" }
hostname = "0.4.0" hostname = "0.4.0"
log = "0.4.20" log = "0.4.20"
lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" } lan-mouse-ipc = { path = "../lan-mouse-ipc", version = "0.2.0" }
thiserror = "2.0.0"
[build-dependencies] [build-dependencies]
glib-build-tools = { version = "0.20.0" } glib-build-tools = { version = "0.20.0" }

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.0"/>
<template class="AuthorizationWindow" parent="AdwWindow">
<property name="modal">True</property>
<property name="width-request">180</property>
<property name="default-width">180</property>
<property name="height-request">180</property>
<property name="default-height">180</property>
<property name="title" translatable="yes">Unauthorized Device</property>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="vexpand">True</property>
<child type="top">
<object class="AdwHeaderBar">
<style>
<class name="flat"/>
</style>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">30</property>
<property name="margin-start">30</property>
<property name="margin-end">30</property>
<property name="margin-top">30</property>
<property name="margin-bottom">30</property>
<child>
<object class="GtkLabel">
<property name="label">An unauthorized Device is trying to connect. Do you want to authorize this Device?</property>
<property name="width-request">100</property>
<property name="wrap">word-wrap</property>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title">sha256 fingerprint</property>
<child>
<object class="AdwActionRow">
<property name="child">
<object class="GtkLabel" id="fingerprint">
<property name="ellipsize">PANGO_ELLIPSIZE_NONE</property>
<property name="vexpand">True</property>
<property name="hexpand">False</property>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<property name="xalign">0.5</property>
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="width-chars">64</property>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<property name="margin-start">30</property>
<property name="margin-end">30</property>
<property name="margin-top">30</property>
<property name="margin-bottom">30</property>
<property name="orientation">horizontal</property>
<property name="spacing">30</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="valign">end</property>
<child>
<object class="GtkButton" id="cancel_button">
<signal name="clicked" handler="handle_cancel" swapped="true"/>
<property name="label" translatable="yes">Cancel</property>
<property name="can-shrink">True</property>
<property name="height-request">50</property>
<property name="hexpand">True</property>
</object>
</child>
<child>
<object class="GtkButton" id="confirm_button">
<signal name="clicked" handler="handle_confirm" swapped="true"/>
<property name="label" translatable="yes">Authorize</property>
<property name="can-shrink">True</property>
<property name="height-request">50</property>
<property name="hexpand">True</property>
<style>
<class name="destructive-action"/>
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</template>
</interface>

View File

@@ -5,7 +5,6 @@
<!-- enabled --> <!-- enabled -->
<child type="prefix"> <child type="prefix">
<object class="GtkSwitch" id="enable_switch"> <object class="GtkSwitch" id="enable_switch">
<signal name="state_set" handler="handle_client_set_state" swapped="true"/>
<property name="valign">center</property> <property name="valign">center</property>
<property name="halign">end</property> <property name="halign">end</property>
<property name="tooltip-text" translatable="yes">enable</property> <property name="tooltip-text" translatable="yes">enable</property>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 0 3 c 0 -1.644531 1.355469 -3 3 -3 h 5 c 1.644531 0 3 1.355469 3 3 c 0 0.550781 -0.449219 1 -1 1 s -1 -0.449219 -1 -1 c 0 -0.570312 -0.429688 -1 -1 -1 h -5 c -0.570312 0 -1 0.429688 -1 1 v 5 c 0 0.570312 0.429688 1 1 1 c 0.550781 0 1 0.449219 1 1 s -0.449219 1 -1 1 c -1.644531 0 -3 -1.355469 -3 -3 z m 5 5 c 0 -1.644531 1.355469 -3 3 -3 h 5 c 1.644531 0 3 1.355469 3 3 v 5 c 0 1.644531 -1.355469 3 -3 3 h -5 c -1.644531 0 -3 -1.355469 -3 -3 z m 2 0 v 5 c 0 0.570312 0.429688 1 1 1 h 5 c 0.570312 0 1 -0.429688 1 -1 v -5 c 0 -0.570312 -0.429688 -1 -1 -1 h -5 c -0.570312 0 -1 0.429688 -1 1 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 765 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 0 c -4.410156 0 -8 3.589844 -8 8 s 3.589844 8 8 8 s 8 -3.589844 8 -8 s -3.589844 -8 -8 -8 z m 0 2 c 3.332031 0 6 2.667969 6 6 s -2.667969 6 -6 6 s -6 -2.667969 -6 -6 s 2.667969 -6 6 -6 z m -2.03125 2.96875 c -0.265625 0 -0.519531 0.105469 -0.707031 0.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 1.292969 1.292969 l -1.292969 1.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 1.292969 -1.292969 l 1.292969 1.292969 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 l -1.292969 -1.292969 l 1.292969 -1.292969 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 s -0.519531 0.105469 -0.707031 0.292969 l -1.292969 1.292969 l -1.292969 -1.292969 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 13.753906 4.660156 c 0.175782 -0.199218 0.261719 -0.460937 0.246094 -0.726562 c -0.019531 -0.265625 -0.140625 -0.511719 -0.339844 -0.6875 c -0.199218 -0.175782 -0.460937 -0.261719 -0.726562 -0.246094 c -0.265625 0.019531 -0.511719 0.140625 -0.6875 0.339844 l -6.296875 7.195312 l -2.242188 -2.242187 c -0.390625 -0.390625 -1.023437 -0.390625 -1.414062 0 c -0.1875 0.1875 -0.292969 0.441406 -0.292969 0.707031 s 0.105469 0.519531 0.292969 0.707031 l 3 3 c 0.195312 0.195313 0.464843 0.304688 0.738281 0.292969 c 0.277344 -0.007812 0.539062 -0.132812 0.722656 -0.339844 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 743 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 7 1 v 6 h -6 v 2 h 6 v 6 h 2 v -6 h 6 v -2 h -6 v -6 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 7.085938 2 c 0.574218 0.007812 1.152343 0.085938 1.726562 0.238281 c 3.054688 0.820313 5.1875 3.597657 5.1875 6.761719 h 2 v 1 h -0.007812 c 0.003906 0.265625 -0.101563 0.519531 -0.285157 0.707031 l -2 2 c -0.390625 0.390625 -1.023437 0.390625 -1.414062 0 l -2 -2 c -0.1875 -0.1875 -0.289063 -0.441406 -0.289063 -0.707031 h -0.003906 v -1 h 2 c 0 -2.269531 -1.515625 -4.242188 -3.707031 -4.832031 c -2.1875 -0.585938 -4.488281 0.367187 -5.625 2.332031 c -1.132813 1.964844 -0.808594 4.429688 0.796875 6.035156 c 0.390625 0.390625 0.390625 1.023438 0 1.414063 s -1.023438 0.390625 -1.414063 0 c -2.238281 -2.238281 -2.695312 -5.710938 -1.113281 -8.449219 c 1.1875 -2.054688 3.304688 -3.324219 5.578125 -3.480469 c 0.1875 -0.015625 0.378906 -0.023437 0.570313 -0.019531 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 943 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 13.753906 4.660156 c 0.175782 -0.199218 0.261719 -0.460937 0.246094 -0.726562 c -0.019531 -0.265625 -0.140625 -0.511719 -0.339844 -0.6875 c -0.199218 -0.175782 -0.460937 -0.261719 -0.726562 -0.246094 c -0.265625 0.019531 -0.511719 0.140625 -0.6875 0.339844 l -6.296875 7.195312 l -2.242188 -2.242187 c -0.390625 -0.390625 -1.023437 -0.390625 -1.414062 0 c -0.1875 0.1875 -0.292969 0.441406 -0.292969 0.707031 s 0.105469 0.519531 0.292969 0.707031 l 3 3 c 0.195312 0.195313 0.464843 0.304688 0.738281 0.292969 c 0.277344 -0.007812 0.539062 -0.132812 0.722656 -0.339844 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 743 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e3436">
<path d="m 1 2 h 14 v 2 h -14 z m 0 0"/>
<path d="m 1 7 h 14 v 2 h -14 z m 0 0"/>
<path d="m 1 12 h 14 v 2 h -14 z m 0 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 314 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 3 2 c -0.265625 0 -0.519531 0.105469 -0.707031 0.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 l 4.292969 4.292969 l -4.292969 4.292969 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 4.292969 -4.292969 l 4.292969 4.292969 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 l -4.292969 -4.292969 l 4.292969 -4.292969 c 0.390625 -0.390625 0.390625 -1.023437 0 -1.414062 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 s -0.519531 0.105469 -0.707031 0.292969 l -4.292969 4.292969 l -4.292969 -4.292969 c -0.1875 -0.1875 -0.441406 -0.292969 -0.707031 -0.292969 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 822 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 8.074219 0 c -1.203125 -0.0117188 -2.40625 0.285156 -3.492188 0.890625 c -0.480469 0.269531 -0.652343 0.878906 -0.382812 1.359375 c 0.269531 0.484375 0.878906 0.65625 1.359375 0.386719 c 1.550781 -0.867188 3.4375 -0.847657 4.972656 0.050781 c 1.53125 0.898438 2.46875 2.535156 2.46875 4.3125 v 1 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -1 c 0 -0.019531 0 -0.039062 -0.003906 -0.054688 c -0.019532 -2.460937 -1.332032 -4.738281 -3.457032 -5.984374 c -1.070312 -0.628907 -2.265624 -0.9492192 -3.46875 -0.960938 z m -5.199219 2.832031 c -0.066406 0 -0.132812 0.007813 -0.195312 0.023438 c -0.257813 0.058593 -0.484376 0.21875 -0.625 0.445312 c -0.6875 1.109375 -1.054688 2.390625 -1.054688 3.699219 v 5.0625 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -5.0625 c 0 -0.933594 0.261719 -1.851562 0.753906 -2.644531 c 0.292969 -0.46875 0.148438 -1.082031 -0.320312 -1.375 c -0.167969 -0.105469 -0.363282 -0.15625 -0.558594 -0.148438 z m 5.125 0.167969 c -2.199219 0 -4 1.800781 -4 4 v 1 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -1 c 0 -1.117188 0.882812 -2 2 -2 s 2 0.882812 2 2 v 5 s 0.007812 0.441406 0.175781 0.941406 s 0.5 1.148438 1.117188 1.765625 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 c -0.382812 -0.382813 -0.550781 -0.734375 -0.632812 -0.984375 s -0.074219 -0.308594 -0.074219 -0.308594 v -5 c 0 -2.199219 -1.800781 -4 -4 -4 z m 0 3 c -0.550781 0 -1 0.449219 -1 1 v 5 s 0 0.59375 0.144531 1.320312 c 0.144531 0.726563 0.414063 1.652344 1.148438 2.386719 c 0.390625 0.390625 1.023437 0.390625 1.414062 0 s 0.390625 -1.023437 0 -1.414062 c -0.265625 -0.265625 -0.496093 -0.839844 -0.601562 -1.363281 c -0.105469 -0.523438 -0.105469 -0.929688 -0.105469 -0.929688 v -5 c 0 -0.550781 -0.449219 -1 -1 -1 z m -3 4 c -0.550781 0 -1 0.449219 -1 1 v 3 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -3 c 0 -0.550781 -0.449219 -1 -1 -1 z m 9 0 c -0.550781 0 -1 0.449219 -1 1 s 0.449219 1 1 1 s 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 z m 0 0" fill="#2e3434"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 6 0.015625 c -0.554688 0 -1 0.445313 -1 1 v 3 c 0 0.554687 0.445312 1 1 1 h 1 v 2 h -7 v 2 h 2 v 2 h -1 c -0.554688 0 -1 0.445313 -1 1 v 3 c 0 0.554687 0.445312 1 1 1 h 4 c 0.554688 0 1 -0.445313 1 -1 v -3 c 0 -0.554687 -0.445312 -1 -1 -1 h -1 v -2 h 8 v 2 h -1 c -0.554688 0 -1 0.445313 -1 1 v 3 c 0 0.554687 0.445312 1 1 1 h 4 c 0.554688 0 1 -0.445313 1 -1 v -3 c 0 -0.554687 -0.445312 -1 -1 -1 h -1 v -2 h 2 v -2 h -7 v -2 h 1 c 0.554688 0 1 -0.445313 1 -1 v -3 c 0 -0.554687 -0.445312 -1 -1 -1 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 673 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e3436">
<path d="m 6.5 0 c -1.378906 0 -2.5 1.121094 -2.5 2.5 v 0.5 h -3 c -0.550781 0 -1 0.449219 -1 1 s 0.449219 1 1 1 h 1 v 8 c 0 1.65625 1.34375 3 3 3 h 6 c 1.65625 0 3 -1.34375 3 -3 v -8 h 1 c 0.550781 0 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 h -3.023438 v -0.5 c 0 -1.378906 -1.117187 -2.5 -2.5 -2.5 z m 0 2 h 2.976562 c 0.289063 0 0.5 0.210938 0.5 0.5 v 0.5 h -3.976562 v -0.5 c 0 -0.289062 0.210938 -0.5 0.5 -0.5 z m -2.5 3 h 8 v 8 c 0 0.5625 -0.4375 1 -1 1 h -6 c -0.5625 0 -1 -0.4375 -1 -1 z m 0 0"/>
<path d="m 7 7 v 5 c 0 0.277344 -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 v -5 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 s 0.5 0.222656 0.5 0.5 z m 0 0"/>
<path d="m 10 7 v 5 c 0 0.277344 -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 v -5 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 s 0.5 0.222656 0.5 0.5 z m 0 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1009 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 7.90625 0.09375 c -0.527344 -0.0273438 -1.039062 0.28125 -1.4375 0.96875 l -6.25 11.59375 c -0.535156 0.964844 0.046875 2.34375 1.09375 2.34375 h 13.15625 c 0.980469 0 1.902344 -1.160156 1.21875 -2.34375 l -6.3125 -11.53125 c -0.398438 -0.644531 -0.941406 -1.003906 -1.46875 -1.03125 z m 1.09375 3.90625 v 5 c 0.007812 0.527344 -0.472656 1 -1 1 s -1.007812 -0.472656 -1 -1 v -5 z m -1 7 c 0.550781 0 1 0.449219 1 1 s -0.449219 1 -1 1 s -1 -0.449219 -1 -1 s 0.449219 -1 1 -1 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 649 B

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="#2e3436">
<path d="m 15 10 c 0.265625 0 0.519531 0.105469 0.707031 0.292969 c 0.390625 0.390625 0.390625 1.023437 0 1.414062 l -1.292969 1.292969 l 1.292969 1.292969 c 0.390625 0.390625 0.390625 1.023437 0 1.414062 s -1.023437 0.390625 -1.414062 0 l -1.292969 -1.292969 l -1.292969 1.292969 c -0.390625 0.390625 -1.023437 0.390625 -1.414062 0 s -0.390625 -1.023437 0 -1.414062 l 1.292969 -1.292969 l -1.292969 -1.292969 c -0.390625 -0.390625 -0.390625 -1.023437 0 -1.414062 c 0.1875 -0.1875 0.441406 -0.292969 0.707031 -0.292969 s 0.519531 0.105469 0.707031 0.292969 l 1.292969 1.292969 l 1.292969 -1.292969 c 0.1875 -0.1875 0.441406 -0.292969 0.707031 -0.292969 z m 0 0"/>
<path d="m 6 0 c -0.554688 0 -1 0.445312 -1 1 v 3 c 0 0.554688 0.445312 1 1 1 h 1 v 2 h -7 v 2 h 2 v 2 h -1 c -0.554688 0 -1 0.445312 -1 1 v 3 c 0 0.554688 0.445312 1 1 1 h 4 c 0.554688 0 1 -0.445312 1 -1 v -3 c 0 -0.554688 -0.445312 -1 -1 -1 h -1 v -2 h 12 v -2 h -7 v -2 h 1 c 0.554688 0 1 -0.445312 1 -1 v -3 c 0 -0.554688 -0.445312 -1 -1 -1 z m 0 0" fill-opacity="0.34902"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -2,11 +2,30 @@
<gresources> <gresources>
<gresource prefix="/de/feschber/LanMouse"> <gresource prefix="/de/feschber/LanMouse">
<file compressed="true" preprocess="xml-stripblanks">window.ui</file> <file compressed="true" preprocess="xml-stripblanks">window.ui</file>
<file compressed="true" preprocess="xml-stripblanks">authorization_window.ui</file>
<file compressed="true" preprocess="xml-stripblanks">fingerprint_window.ui</file> <file compressed="true" preprocess="xml-stripblanks">fingerprint_window.ui</file>
<file compressed="true" preprocess="xml-stripblanks">client_row.ui</file> <file compressed="true" preprocess="xml-stripblanks">client_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">key_row.ui</file> <file compressed="true" preprocess="xml-stripblanks">key_row.ui</file>
</gresource> </gresource>
<gresource prefix="/de/feschber/LanMouse/icons"> <gresource prefix="/de/feschber/LanMouse/icons">
<file compressed="true" preprocess="xml-stripblanks">de.feschber.LanMouse.svg</file> <file compressed="true" preprocess="xml-stripblanks">de.feschber.LanMouse.svg</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.
Registered via IconTheme::add_resource_path("/de/feschber/LanMouse/icons").
-->
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/edit-copy-symbolic.svg">icons/scalable/actions/edit-copy-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/edit-delete-symbolic.svg">icons/scalable/actions/edit-delete-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/emblem-ok-symbolic.svg">icons/scalable/actions/emblem-ok-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/list-add-symbolic.svg">icons/scalable/actions/list-add-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/object-rotate-right-symbolic.svg">icons/scalable/actions/object-rotate-right-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/object-select-symbolic.svg">icons/scalable/actions/object-select-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/open-menu-symbolic.svg">icons/scalable/actions/open-menu-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/actions/process-stop-symbolic.svg">icons/scalable/actions/process-stop-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/devices/auth-fingerprint-symbolic.svg">icons/scalable/devices/auth-fingerprint-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/devices/network-wired-symbolic.svg">icons/scalable/devices/network-wired-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/places/user-trash-symbolic.svg">icons/scalable/places/user-trash-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/status/dialog-warning-symbolic.svg">icons/scalable/status/dialog-warning-symbolic.svg</file>
<file compressed="true" preprocess="xml-stripblanks" alias="scalable/status/network-wired-disconnected-symbolic.svg">icons/scalable/status/network-wired-disconnected-symbolic.svg</file>
</gresource> </gresource>
</gresources> </gresources>

View File

@@ -63,7 +63,7 @@
<signal name="clicked" handler="handle_capture" swapped="true"/> <signal name="clicked" handler="handle_capture" swapped="true"/>
<property name="valign">center</property> <property name="valign">center</property>
<style> <style>
<class name="circular"/> <class name="pill"/>
<class name="flat"/> <class name="flat"/>
</style> </style>
</object> </object>
@@ -89,7 +89,7 @@
<property name="valign">center</property> <property name="valign">center</property>
<signal name="clicked" handler="handle_emulation" swapped="true"/> <signal name="clicked" handler="handle_emulation" swapped="true"/>
<style> <style>
<class name="circular"/> <class name="pill"/>
<class name="flat"/> <class name="flat"/>
</style> </style>
</object> </object>

View File

@@ -0,0 +1,19 @@
mod imp;
use glib::Object;
use gtk::{gio, glib, subclass::prelude::ObjectSubclassIsExt};
glib::wrapper! {
pub struct AuthorizationWindow(ObjectSubclass<imp::AuthorizationWindow>)
@extends adw::Window, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable,
gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
}
impl AuthorizationWindow {
pub(crate) fn new(fingerprint: &str) -> Self {
let window: Self = Object::builder().build();
window.imp().set_fingerprint(fingerprint);
window
}
}

View File

@@ -0,0 +1,76 @@
use std::sync::OnceLock;
use adw::prelude::*;
use adw::subclass::prelude::*;
use glib::subclass::InitializingObject;
use gtk::{
Button, CompositeTemplate, Label,
glib::{self, subclass::Signal},
template_callbacks,
};
#[derive(CompositeTemplate, Default)]
#[template(resource = "/de/feschber/LanMouse/authorization_window.ui")]
pub struct AuthorizationWindow {
#[template_child]
pub fingerprint: TemplateChild<Label>,
#[template_child]
pub cancel_button: TemplateChild<Button>,
#[template_child]
pub confirm_button: TemplateChild<Button>,
}
#[glib::object_subclass]
impl ObjectSubclass for AuthorizationWindow {
const NAME: &'static str = "AuthorizationWindow";
const ABSTRACT: bool = false;
type Type = super::AuthorizationWindow;
type ParentType = adw::Window;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
klass.bind_template_callbacks();
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[template_callbacks]
impl AuthorizationWindow {
#[template_callback]
fn handle_confirm(&self, _button: Button) {
let fp = self.fingerprint.text().as_str().trim().to_owned();
self.obj().emit_by_name("confirm-clicked", &[&fp])
}
#[template_callback]
fn handle_cancel(&self, _: Button) {
self.obj().emit_by_name("cancel-clicked", &[])
}
pub(super) fn set_fingerprint(&self, fingerprint: &str) {
self.fingerprint.set_text(fingerprint);
}
}
impl ObjectImpl for AuthorizationWindow {
fn signals() -> &'static [Signal] {
static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new();
SIGNALS.get_or_init(|| {
vec![
Signal::builder("confirm-clicked")
.param_types([String::static_type()])
.build(),
Signal::builder("cancel-clicked").build(),
]
})
}
}
impl WidgetImpl for AuthorizationWindow {}
impl WindowImpl for AuthorizationWindow {}
impl ApplicationWindowImpl for AuthorizationWindow {}
impl AdwWindowImpl for AuthorizationWindow {}

View File

@@ -13,7 +13,7 @@ use super::ClientData;
#[properties(wrapper_type = super::ClientObject)] #[properties(wrapper_type = super::ClientObject)]
pub struct ClientObject { pub struct ClientObject {
#[property(name = "handle", get, set, type = ClientHandle, member = handle)] #[property(name = "handle", get, set, type = ClientHandle, member = handle)]
#[property(name = "hostname", get, set, type = String, member = hostname)] #[property(name = "hostname", get, set, type = Option<String>, member = hostname)]
#[property(name = "port", get, set, type = u32, member = port, maximum = u16::MAX as u32)] #[property(name = "port", get, set, type = u32, member = port, maximum = u16::MAX as u32)]
#[property(name = "active", get, set, type = bool, member = active)] #[property(name = "active", get, set, type = bool, member = active)]
#[property(name = "position", get, set, type = String, member = position)] #[property(name = "position", get, set, type = String, member = position)]

View File

@@ -4,7 +4,7 @@ use adw::prelude::*;
use adw::subclass::prelude::*; use adw::subclass::prelude::*;
use gtk::glib::{self, Object}; use gtk::glib::{self, Object};
use lan_mouse_ipc::DEFAULT_PORT; use lan_mouse_ipc::{DEFAULT_PORT, Position};
use super::ClientObject; use super::ClientObject;
@@ -15,25 +15,32 @@ glib::wrapper! {
} }
impl ClientRow { impl ClientRow {
pub fn new(_client_object: &ClientObject) -> Self { pub fn new(client_object: &ClientObject) -> Self {
Object::builder().build() let client_row: Self = Object::builder().build();
client_row
.imp()
.client_object
.borrow_mut()
.replace(client_object.clone());
client_row
} }
pub fn bind(&self, client_object: &ClientObject) { pub fn bind(&self, client_object: &ClientObject) {
let mut bindings = self.imp().bindings.borrow_mut(); let mut bindings = self.imp().bindings.borrow_mut();
// bind client active to switch state
let active_binding = client_object let active_binding = client_object
.bind_property("active", &self.imp().enable_switch.get(), "state") .bind_property("active", &self.imp().enable_switch.get(), "state")
.bidirectional()
.sync_create() .sync_create()
.build(); .build();
// bind client active to switch position
let switch_position_binding = client_object let switch_position_binding = client_object
.bind_property("active", &self.imp().enable_switch.get(), "active") .bind_property("active", &self.imp().enable_switch.get(), "active")
.bidirectional()
.sync_create() .sync_create()
.build(); .build();
// bind hostname to hostname edit field
let hostname_binding = client_object let hostname_binding = client_object
.bind_property("hostname", &self.imp().hostname.get(), "text") .bind_property("hostname", &self.imp().hostname.get(), "text")
.transform_to(|_, v: Option<String>| { .transform_to(|_, v: Option<String>| {
@@ -43,72 +50,48 @@ impl ClientRow {
Some("".to_string()) Some("".to_string())
} }
}) })
.transform_from(|_, v: String| {
if v.as_str().trim() == "" {
Some(None)
} else {
Some(Some(v))
}
})
.bidirectional()
.sync_create() .sync_create()
.build(); .build();
// bind hostname to title
let title_binding = client_object let title_binding = client_object
.bind_property("hostname", self, "title") .bind_property("hostname", self, "title")
.transform_to(|_, v: Option<String>| { .transform_to(|_, v: Option<String>| v.or(Some("<span font_style=\"italic\" font_weight=\"light\" foreground=\"darkgrey\">no hostname!</span>".to_string())))
if let Some(hostname) = v {
Some(hostname)
} else {
Some("<span font_style=\"italic\" font_weight=\"light\" foreground=\"darkgrey\">no hostname!</span>".to_string())
}
})
.sync_create() .sync_create()
.build(); .build();
// bind port to port edit field
let port_binding = client_object let port_binding = client_object
.bind_property("port", &self.imp().port.get(), "text") .bind_property("port", &self.imp().port.get(), "text")
.transform_from(|_, v: String| {
if v.is_empty() {
Some(DEFAULT_PORT as u32)
} else {
Some(v.parse::<u16>().unwrap_or(DEFAULT_PORT) as u32)
}
})
.transform_to(|_, v: u32| { .transform_to(|_, v: u32| {
if v == 4242 { if v == DEFAULT_PORT as u32 {
Some("".to_string()) Some("".to_string())
} else { } else {
Some(v.to_string()) Some(v.to_string())
} }
}) })
.bidirectional()
.sync_create() .sync_create()
.build(); .build();
// bind port to subtitle
let subtitle_binding = client_object let subtitle_binding = client_object
.bind_property("port", self, "subtitle") .bind_property("port", self, "subtitle")
.sync_create() .sync_create()
.build(); .build();
// bind position to selected position
let position_binding = client_object let position_binding = client_object
.bind_property("position", &self.imp().position.get(), "selected") .bind_property("position", &self.imp().position.get(), "selected")
.transform_from(|_, v: u32| match v {
1 => Some("right"),
2 => Some("top"),
3 => Some("bottom"),
_ => Some("left"),
})
.transform_to(|_, v: String| match v.as_str() { .transform_to(|_, v: String| match v.as_str() {
"right" => Some(1), "right" => Some(1u32),
"top" => Some(2u32), "top" => Some(2u32),
"bottom" => Some(3u32), "bottom" => Some(3u32),
_ => Some(0u32), _ => Some(0u32),
}) })
.bidirectional()
.sync_create() .sync_create()
.build(); .build();
// bind resolving status to spinner visibility
let resolve_binding = client_object let resolve_binding = client_object
.bind_property( .bind_property(
"resolving", "resolving",
@@ -118,6 +101,7 @@ impl ClientRow {
.sync_create() .sync_create()
.build(); .build();
// bind ips to tooltip-text
let ip_binding = client_object let ip_binding = client_object
.bind_property("ips", &self.imp().dns_button.get(), "tooltip-text") .bind_property("ips", &self.imp().dns_button.get(), "tooltip-text")
.transform_to(|_, ips: Vec<String>| { .transform_to(|_, ips: Vec<String>| {
@@ -146,4 +130,24 @@ impl ClientRow {
binding.unbind(); binding.unbind();
} }
} }
pub fn set_active(&self, active: bool) {
self.imp().set_active(active);
}
pub fn set_hostname(&self, hostname: Option<String>) {
self.imp().set_hostname(hostname);
}
pub fn set_port(&self, port: u16) {
self.imp().set_port(port);
}
pub fn set_position(&self, pos: Position) {
self.imp().set_pos(pos);
}
pub fn set_dns_state(&self, resolved: bool) {
self.imp().set_dns_state(resolved);
}
} }

View File

@@ -1,13 +1,16 @@
use std::cell::RefCell; use std::cell::RefCell;
use adw::subclass::prelude::*; use adw::subclass::prelude::*;
use adw::{prelude::*, ActionRow, ComboRow}; use adw::{ActionRow, ComboRow, prelude::*};
use glib::{subclass::InitializingObject, Binding}; use glib::{Binding, subclass::InitializingObject};
use gtk::glib::clone;
use gtk::glib::subclass::Signal; use gtk::glib::subclass::Signal;
use gtk::{glib, Button, CompositeTemplate, Switch}; use gtk::glib::{SignalHandlerId, clone};
use gtk::{Button, CompositeTemplate, Entry, Switch, glib};
use lan_mouse_ipc::Position;
use std::sync::OnceLock; use std::sync::OnceLock;
use crate::client_object::ClientObject;
#[derive(CompositeTemplate, Default)] #[derive(CompositeTemplate, Default)]
#[template(resource = "/de/feschber/LanMouse/client_row.ui")] #[template(resource = "/de/feschber/LanMouse/client_row.ui")]
pub struct ClientRow { pub struct ClientRow {
@@ -28,6 +31,11 @@ pub struct ClientRow {
#[template_child] #[template_child]
pub dns_loading_indicator: TemplateChild<gtk::Spinner>, pub dns_loading_indicator: TemplateChild<gtk::Spinner>,
pub bindings: RefCell<Vec<Binding>>, pub bindings: RefCell<Vec<Binding>>,
hostname_change_handler: RefCell<Option<SignalHandlerId>>,
port_change_handler: RefCell<Option<SignalHandlerId>>,
position_change_handler: RefCell<Option<SignalHandlerId>>,
set_state_handler: RefCell<Option<SignalHandlerId>>,
pub client_object: RefCell<Option<ClientObject>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@@ -59,17 +67,61 @@ impl ObjectImpl for ClientRow {
row.handle_client_delete(button); row.handle_client_delete(button);
} }
)); ));
let handler = self.hostname.connect_changed(clone!(
#[weak(rename_to = row)]
self,
move |entry| {
row.handle_hostname_changed(entry);
}
));
self.hostname_change_handler.replace(Some(handler));
let handler = self.port.connect_changed(clone!(
#[weak(rename_to = row)]
self,
move |entry| {
row.handle_port_changed(entry);
}
));
self.port_change_handler.replace(Some(handler));
let handler = self.position.connect_selected_notify(clone!(
#[weak(rename_to = row)]
self,
move |position| {
row.handle_position_changed(position);
}
));
self.position_change_handler.replace(Some(handler));
let handler = self.enable_switch.connect_state_set(clone!(
#[weak(rename_to = row)]
self,
#[upgrade_or]
glib::Propagation::Proceed,
move |switch, state| {
row.handle_activate_switch(state, switch);
glib::Propagation::Proceed
}
));
self.set_state_handler.replace(Some(handler));
} }
fn signals() -> &'static [glib::subclass::Signal] { fn signals() -> &'static [glib::subclass::Signal] {
static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new(); static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new();
SIGNALS.get_or_init(|| { SIGNALS.get_or_init(|| {
vec![ vec![
Signal::builder("request-dns").build(), Signal::builder("request-activate")
Signal::builder("request-update")
.param_types([bool::static_type()]) .param_types([bool::static_type()])
.build(), .build(),
Signal::builder("request-delete").build(), Signal::builder("request-delete").build(),
Signal::builder("request-dns").build(),
Signal::builder("request-hostname-change")
.param_types([String::static_type()])
.build(),
Signal::builder("request-port-change")
.param_types([u32::static_type()])
.build(),
Signal::builder("request-position-change")
.param_types([u32::static_type()])
.build(),
] ]
}) })
} }
@@ -78,22 +130,97 @@ impl ObjectImpl for ClientRow {
#[gtk::template_callbacks] #[gtk::template_callbacks]
impl ClientRow { impl ClientRow {
#[template_callback] #[template_callback]
fn handle_client_set_state(&self, state: bool, _switch: &Switch) -> bool { fn handle_activate_switch(&self, state: bool, _switch: &Switch) -> bool {
log::debug!("state change -> requesting update"); self.obj().emit_by_name::<()>("request-activate", &[&state]);
self.obj().emit_by_name::<()>("request-update", &[&state]);
true // dont run default handler true // dont run default handler
} }
#[template_callback] #[template_callback]
fn handle_request_dns(&self, _: Button) { fn handle_request_dns(&self, _: &Button) {
self.obj().emit_by_name::<()>("request-dns", &[]); self.obj().emit_by_name::<()>("request-dns", &[]);
} }
#[template_callback] #[template_callback]
fn handle_client_delete(&self, _button: &Button) { fn handle_client_delete(&self, _button: &Button) {
log::debug!("delete button pressed -> requesting delete");
self.obj().emit_by_name::<()>("request-delete", &[]); self.obj().emit_by_name::<()>("request-delete", &[]);
} }
fn handle_port_changed(&self, port_entry: &Entry) {
if let Ok(port) = port_entry.text().parse::<u16>() {
self.obj()
.emit_by_name::<()>("request-port-change", &[&(port as u32)]);
}
}
fn handle_hostname_changed(&self, hostname_entry: &Entry) {
self.obj()
.emit_by_name::<()>("request-hostname-change", &[&hostname_entry.text()]);
}
fn handle_position_changed(&self, position: &ComboRow) {
self.obj()
.emit_by_name("request-position-change", &[&position.selected()])
}
pub(super) fn set_hostname(&self, hostname: Option<String>) {
let position = self.hostname.position();
let handler = self.hostname_change_handler.borrow();
let handler = handler.as_ref().expect("signal handler");
self.hostname.block_signal(handler);
self.client_object
.borrow_mut()
.as_mut()
.expect("client object")
.set_property("hostname", hostname);
self.hostname.unblock_signal(handler);
self.hostname.set_position(position);
}
pub(super) fn set_port(&self, port: u16) {
let position = self.port.position();
let handler = self.port_change_handler.borrow();
let handler = handler.as_ref().expect("signal handler");
self.port.block_signal(handler);
self.client_object
.borrow_mut()
.as_mut()
.expect("client object")
.set_port(port as u32);
self.port.unblock_signal(handler);
self.port.set_position(position);
}
pub(super) fn set_pos(&self, pos: Position) {
let handler = self.position_change_handler.borrow();
let handler = handler.as_ref().expect("signal handler");
self.position.block_signal(handler);
self.client_object
.borrow_mut()
.as_mut()
.expect("client object")
.set_position(pos.to_string());
self.position.unblock_signal(handler);
}
pub(super) fn set_active(&self, active: bool) {
let handler = self.set_state_handler.borrow();
let handler = handler.as_ref().expect("signal handler");
self.enable_switch.block_signal(handler);
self.client_object
.borrow_mut()
.as_mut()
.expect("client object")
.set_active(active);
self.enable_switch.unblock_signal(handler);
}
pub(super) fn set_dns_state(&self, resolved: bool) {
if resolved {
self.dns_button.set_css_classes(&["success"])
} else {
self.dns_button.set_css_classes(&["warning"])
}
}
} }
impl WidgetImpl for ClientRow {} impl WidgetImpl for ClientRow {}

View File

@@ -1,7 +1,7 @@
mod imp; mod imp;
use glib::Object; use glib::Object;
use gtk::{gio, glib}; use gtk::{gio, glib, prelude::ObjectExt, subclass::prelude::ObjectSubclassIsExt};
glib::wrapper! { glib::wrapper! {
pub struct FingerprintWindow(ObjectSubclass<imp::FingerprintWindow>) pub struct FingerprintWindow(ObjectSubclass<imp::FingerprintWindow>)
@@ -11,8 +11,12 @@ glib::wrapper! {
} }
impl FingerprintWindow { impl FingerprintWindow {
pub(crate) fn new() -> Self { pub(crate) fn new(fingerprint: Option<String>) -> Self {
let window: Self = Object::builder().build(); let window: Self = Object::builder().build();
if let Some(fp) = fingerprint {
window.imp().fingerprint.set_property("text", fp);
window.imp().fingerprint.set_property("editable", false);
}
window window
} }
} }

View File

@@ -4,8 +4,9 @@ use adw::prelude::*;
use adw::subclass::prelude::*; use adw::subclass::prelude::*;
use glib::subclass::InitializingObject; use glib::subclass::InitializingObject;
use gtk::{ use gtk::{
Button, CompositeTemplate, Text,
glib::{self, subclass::Signal}, glib::{self, subclass::Signal},
template_callbacks, Button, CompositeTemplate, Text, template_callbacks,
}; };
#[derive(CompositeTemplate, Default)] #[derive(CompositeTemplate, Default)]
@@ -51,9 +52,11 @@ impl ObjectImpl for FingerprintWindow {
fn signals() -> &'static [Signal] { fn signals() -> &'static [Signal] {
static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new(); static SIGNALS: OnceLock<Vec<Signal>> = OnceLock::new();
SIGNALS.get_or_init(|| { SIGNALS.get_or_init(|| {
vec![Signal::builder("confirm-clicked") vec![
.param_types([String::static_type(), String::static_type()]) Signal::builder("confirm-clicked")
.build()] .param_types([String::static_type(), String::static_type()])
.build(),
]
}) })
} }
} }

View File

@@ -8,7 +8,7 @@ use super::KeyObject;
glib::wrapper! { glib::wrapper! {
pub struct KeyRow(ObjectSubclass<imp::KeyRow>) pub struct KeyRow(ObjectSubclass<imp::KeyRow>)
@extends gtk::ListBoxRow, gtk::Widget, adw::PreferencesRow, adw::ExpanderRow, @extends gtk::ListBoxRow, gtk::Widget, adw::PreferencesRow, adw::ActionRow,
@implements gtk::Accessible, gtk::Actionable, gtk::Buildable, gtk::ConstraintTarget; @implements gtk::Accessible, gtk::Actionable, gtk::Buildable, gtk::ConstraintTarget;
} }

View File

@@ -1,11 +1,11 @@
use std::cell::RefCell; use std::cell::RefCell;
use adw::subclass::prelude::*; use adw::subclass::prelude::*;
use adw::{prelude::*, ActionRow}; use adw::{ActionRow, prelude::*};
use glib::{subclass::InitializingObject, Binding}; use glib::{Binding, subclass::InitializingObject};
use gtk::glib::clone; use gtk::glib::clone;
use gtk::glib::subclass::Signal; use gtk::glib::subclass::Signal;
use gtk::{glib, Button, CompositeTemplate}; use gtk::{Button, CompositeTemplate, glib};
use std::sync::OnceLock; use std::sync::OnceLock;
#[derive(CompositeTemplate, Default)] #[derive(CompositeTemplate, Default)]

View File

@@ -1,26 +1,37 @@
mod authorization_window;
mod client_object; mod client_object;
mod client_row; mod client_row;
mod fingerprint_window; mod fingerprint_window;
mod key_object; mod key_object;
mod key_row; mod key_row;
#[cfg(target_os = "macos")]
mod macos_privacy;
#[cfg(target_os = "macos")]
mod macos_status_item;
mod window; mod window;
use std::{env, process, str}; use std::{env, process, str};
use window::Window; use window::Window;
use lan_mouse_ipc::{FrontendEvent, FrontendRequest}; use lan_mouse_ipc::FrontendEvent;
use adw::Application; use adw::Application;
use gtk::{ use gtk::{IconTheme, gdk::Display, glib::clone, prelude::*};
gdk::Display, glib::clone, prelude::*, subclass::prelude::ObjectSubclassIsExt, IconTheme,
};
use gtk::{gio, glib, prelude::ApplicationExt}; use gtk::{gio, glib, prelude::ApplicationExt};
use self::client_object::ClientObject; use self::client_object::ClientObject;
use self::key_object::KeyObject; use self::key_object::KeyObject;
pub fn run() -> glib::ExitCode { use thiserror::Error;
#[derive(Error, Debug)]
pub enum GtkError {
#[error("gtk frontend exited with non zero exit code: {0}")]
NonZeroExitCode(i32),
}
pub fn run() -> Result<(), GtkError> {
log::debug!("running gtk frontend"); log::debug!("running gtk frontend");
#[cfg(windows)] #[cfg(windows)]
let ret = std::thread::Builder::new() let ret = std::thread::Builder::new()
@@ -33,35 +44,127 @@ pub fn run() -> glib::ExitCode {
#[cfg(not(windows))] #[cfg(not(windows))]
let ret = gtk_main(); let ret = gtk_main();
if ret == glib::ExitCode::FAILURE { match ret {
log::error!("frontend exited with failure"); glib::ExitCode::SUCCESS => Ok(()),
} else { e => Err(GtkError::NonZeroExitCode(e.value())),
log::info!("frontend exited successfully");
} }
ret
} }
fn gtk_main() -> glib::ExitCode { fn gtk_main() -> glib::ExitCode {
#[cfg(target_os = "macos")]
{
configure_macos_bundle_environment();
install_macos_gtk_log_filter();
}
gio::resources_register_include!("lan-mouse.gresource").expect("Failed to register resources."); gio::resources_register_include!("lan-mouse.gresource").expect("Failed to register resources.");
let app = Application::builder() let app = Application::builder()
.application_id("de.feschber.LanMouse") .application_id("de.feschber.LanMouse")
.build(); .build();
app.connect_startup(|_| load_icons()); app.connect_startup(|app| {
load_icons();
setup_actions(app);
setup_menu(app);
});
app.connect_activate(build_ui); app.connect_activate(build_ui);
let args: Vec<&'static str> = vec![]; let args: Vec<&'static str> = vec![];
app.run_with_args(&args) app.run_with_args(&args)
} }
#[cfg(target_os = "macos")]
fn install_macos_gtk_log_filter() {
glib::log_set_writer_func(|level, fields| {
if level == glib::LogLevel::Warning && is_gtk_theme_parser_warning(fields) {
return glib::LogWriterOutput::Handled;
}
glib::log_writer_default(level, fields)
});
}
#[cfg(target_os = "macos")]
fn is_gtk_theme_parser_warning(fields: &[glib::LogField<'_>]) -> bool {
let mut domain = None;
let mut message = None;
for field in fields {
match field.key() {
"GLIB_DOMAIN" => domain = field.value_str(),
"MESSAGE" => message = field.value_str(),
_ => {}
}
}
domain == Some("Gtk")
&& message.is_some_and(|message| message.starts_with("Theme parser warning: gtk.css:"))
}
#[cfg(target_os = "macos")]
fn configure_macos_bundle_environment() {
let Ok(exe) = env::current_exe() else {
return;
};
let Some(contents) = exe
.parent()
.and_then(|dir| dir.parent())
.map(std::path::Path::to_owned)
else {
return;
};
let share = contents.join("Resources").join("share");
if !share.exists() {
return;
}
let schemas = share.join("glib-2.0").join("schemas");
if schemas.exists() {
env::set_var("GSETTINGS_SCHEMA_DIR", schemas);
}
env::set_var("XDG_DATA_DIRS", &share);
env::set_var(
"GTK_DATA_PREFIX",
contents.join("Resources").to_string_lossy().as_ref(),
);
}
fn load_icons() { fn load_icons() {
let display = &Display::default().expect("Could not connect to a display."); let display = &Display::default().expect("Could not connect to a display.");
let icon_theme = IconTheme::for_display(display); let icon_theme = IconTheme::for_display(display);
icon_theme.add_resource_path("/de/feschber/LanMouse/icons"); icon_theme.add_resource_path("/de/feschber/LanMouse/icons");
} }
// Add application actions
fn setup_actions(app: &adw::Application) {
// Quit action
// This is important on macOS, where users expect a File->Quit action with a Cmd+Q shortcut.
let quit_action = gio::SimpleAction::new("quit", None);
quit_action.connect_activate({
let app = app.clone();
move |_, _| {
app.quit();
}
});
app.add_action(&quit_action);
}
// Set up a global menu
//
// Currently this is used only on macOS
fn setup_menu(app: &adw::Application) {
let menu = gio::Menu::new();
let file_menu = gio::Menu::new();
file_menu.append(Some("Quit"), Some("app.quit"));
menu.append_submenu(Some("_File"), &file_menu);
app.set_menubar(Some(&menu))
}
fn build_ui(app: &Application) { fn build_ui(app: &Application) {
log::debug!("connecting to lan-mouse-socket"); log::debug!("connecting to lan-mouse-socket");
let (mut frontend_rx, frontend_tx) = match lan_mouse_ipc::connect() { let (mut frontend_rx, frontend_tx) = match lan_mouse_ipc::connect() {
@@ -88,6 +191,41 @@ fn build_ui(app: &Application) {
}); });
let window = Window::new(app, frontend_tx); let window = Window::new(app, frontend_tx);
#[cfg(target_os = "macos")]
{
window.connect_close_request(|window| {
window.set_visible(false);
glib::Propagation::Stop
});
macos_status_item::setup(app, &window);
// First-launch TCC prompts. No-op when already granted.
macos_privacy::fire_initial_prompts();
// Watch the Accessibility grant continuously for the lifetime
// of the process. On a grant, swap the warning row into its
// "relaunch required" state (the daemon subprocess already
// bailed and can't recover without a restart). On a REVOKE,
// quit immediately — an active CGEventTap at
// HeadInsertEventTap can wedge system input if the process
// lingers after losing AX, and forcing the process to exit is
// the only bulletproof way to guarantee the kernel tears the
// tap down.
let window_weak = window.downgrade();
let app_weak = app.downgrade();
macos_privacy::watch_accessibility_state(move |change| match change {
macos_privacy::AccessibilityChange::Granted => {
if let Some(window) = window_weak.upgrade() {
window.present();
window.refresh_capture_emulation_status();
}
}
macos_privacy::AccessibilityChange::Revoked => {
log::warn!("Accessibility revoked — quitting to avoid wedging system input");
if let Some(app) = app_weak.upgrade() {
app.quit();
}
}
});
}
glib::spawn_future_local(clone!( glib::spawn_future_local(clone!(
#[weak] #[weak]
@@ -96,58 +234,58 @@ fn build_ui(app: &Application) {
loop { loop {
let notify = receiver.recv().await.unwrap_or_else(|_| process::exit(1)); let notify = receiver.recv().await.unwrap_or_else(|_| process::exit(1));
match notify { match notify {
FrontendEvent::Changed(handle) => {
window.request(FrontendRequest::GetState(handle));
}
FrontendEvent::Created(handle, client, state) => { FrontendEvent::Created(handle, client, state) => {
window.new_client(handle, client, state); window.new_client(handle, client, state)
}
FrontendEvent::Deleted(client) => {
window.delete_client(client);
} }
FrontendEvent::Deleted(client) => window.delete_client(client),
FrontendEvent::State(handle, config, state) => { FrontendEvent::State(handle, config, state) => {
window.update_client_config(handle, config); window.update_client_config(handle, config);
window.update_client_state(handle, state); window.update_client_state(handle, state);
} }
FrontendEvent::NoSuchClient(_) => {} FrontendEvent::NoSuchClient(_) => {}
FrontendEvent::Error(e) => { FrontendEvent::Error(e) => window.show_toast(e.as_str()),
window.show_toast(e.as_str()); FrontendEvent::Enumerate(clients) => window.update_client_list(clients),
FrontendEvent::PortChanged(port, msg) => window.update_port(port, msg),
FrontendEvent::CaptureStatus(s) => window.set_capture(s.into()),
FrontendEvent::EmulationStatus(s) => window.set_emulation(s.into()),
FrontendEvent::AuthorizedUpdated(keys) => window.set_authorized_keys(keys),
FrontendEvent::PublicKeyFingerprint(fp) => window.set_pk_fp(&fp),
FrontendEvent::ConnectionAttempt { fingerprint } => {
window.request_authorization(&fingerprint);
} }
FrontendEvent::Enumerate(clients) => { FrontendEvent::DeviceConnected {
for (handle, client, state) in clients { fingerprint: _,
if window.client_idx(handle).is_some() { addr,
window.update_client_config(handle, client); } => {
window.update_client_state(handle, state); window.show_toast(format!("device connected: {addr}").as_str());
} else {
window.new_client(handle, client, state);
}
}
} }
FrontendEvent::PortChanged(port, msg) => { FrontendEvent::DeviceEntered {
match msg { fingerprint: _,
None => window.show_toast(format!("port changed: {port}").as_str()), addr,
Some(msg) => window.show_toast(msg.as_str()), pos,
} } => {
window.imp().set_port(port); window.show_toast(format!("device entered: {addr} ({pos})").as_str());
} }
FrontendEvent::CaptureStatus(s) => { FrontendEvent::IncomingDisconnected(addr) => {
window.set_capture(s.into()); window.show_toast(format!("{addr} disconnected").as_str());
} }
FrontendEvent::EmulationStatus(s) => {
window.set_emulation(s.into());
}
FrontendEvent::AuthorizedUpdated(keys) => {
window.set_authorized_keys(keys);
}
FrontendEvent::PublicKeyFingerprint(fp) => {
window.set_pk_fp(&fp);
}
FrontendEvent::IncomingConnected(..) => {}
FrontendEvent::IncomingDisconnected(..) => {}
} }
} }
} }
)); ));
#[cfg(not(target_os = "macos"))]
window.present(); window.present();
// On macOS, default to presenting the main window on every launch
// so the user gets a visible confirmation that the app is running
// — including the post-grant relaunch and normal Dock/Finder/`open`
// launches. Opt out by setting `LAN_MOUSE_HIDDEN=1` in the
// environment (useful for a LaunchAgent / login-item configuration
// where the user wants the app to come up quietly into the menu
// bar only, with no window on boot).
#[cfg(target_os = "macos")]
if env::var_os("LAN_MOUSE_HIDDEN").is_none() {
window.present();
}
} }

View File

@@ -0,0 +1,256 @@
//! Tiny macOS Privacy-pane helpers used by the GUI.
//!
//! On macOS 13+, the Accessibility grant transitively confers the
//! listen-only event-tap privilege that Input Monitoring gates and the
//! synthesize-event privilege that Post Event gates, and the bundle
//! typically isn't even listed in those separate panes. So the single
//! user-facing action for any missing-capture or missing-emulation
//! scenario is "re-toggle Accessibility" — we don't route elsewhere.
use std::ffi::{c_uchar, c_void};
use std::process::Command;
use std::sync::Once;
use gtk::glib;
// Apple declares `AXIsProcessTrusted` as returning `Boolean` (`unsigned char`),
// NOT C's `bool`. Rust's `bool` has a strict bit pattern (0 or 1) so binding
// a `Boolean`-returning function as `-> bool` is technically UB if Apple ever
// returns a non-canonical true value. Keep these as `c_uchar` and normalize.
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> c_uchar;
fn AXIsProcessTrustedWithOptions(options: *const c_void) -> c_uchar;
}
#[link(name = "CoreFoundation", kind = "framework")]
extern "C" {
static kCFAllocatorDefault: *const c_void;
static kCFTypeDictionaryKeyCallBacks: *const c_void;
static kCFTypeDictionaryValueCallBacks: *const c_void;
static kCFBooleanTrue: *const c_void;
fn CFDictionaryCreate(
allocator: *const c_void,
keys: *const *const c_void,
values: *const *const c_void,
num: isize,
key_callbacks: *const c_void,
value_callbacks: *const c_void,
) -> *const c_void;
fn CFRelease(cf: *const c_void);
}
// kAXTrustedCheckOptionPrompt is a CFStringRef exported from ApplicationServices.
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
static kAXTrustedCheckOptionPrompt: *const c_void;
}
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGRequestListenEventAccess() -> c_uchar;
fn CGRequestPostEventAccess() -> c_uchar;
// CFMachPortRef CGEventTapCreate(
// CGEventTapLocation tap, CGEventTapPlacement place,
// CGEventTapOptions options, CGEventMask eventsOfInterest,
// CGEventTapCallBack callback, void *userInfo);
fn CGEventTapCreate(
tap: u32,
place: u32,
options: u32,
events_of_interest: u64,
callback: *const c_void,
user_info: *const c_void,
) -> *const c_void;
}
pub fn accessibility_granted() -> bool {
let raw = unsafe { AXIsProcessTrusted() };
log::debug!("AXIsProcessTrusted() = {raw}");
raw != 0
}
pub enum AccessibilityChange {
/// AX was missing at startup and the user has now granted it.
/// Capture/emulation still need a relaunch to take effect, since
/// the daemon subprocess already bailed.
Granted,
/// AX was granted and the user has now revoked it. Quit immediately
/// — leaving the process alive with an active CGEventTap at
/// HeadInsertEventTap can wedge system input (clicks/keys silently
/// consumed) until the process dies. See
/// macos-cgeventtap-drop-fallthrough-tcc-revoke skill for the
/// underlying event-tap-disable footgun.
Revoked,
}
/// Poll for Accessibility grant/revoke transitions. Starts a 1-second
/// GLib timer that fires `on_change` every time `AXIsProcessTrusted()`
/// flips, and keeps running for the lifetime of the process.
///
/// We rely on polling rather than AXObserver because the AX notification
/// API requires a trusted process to subscribe — the precondition we
/// can't assume. This runs on the GTK main thread (via
/// `timeout_add_seconds_local`).
pub fn watch_accessibility_state<F>(mut on_change: F)
where
F: FnMut(AccessibilityChange) + 'static,
{
let mut last = accessibility_granted();
log::info!("watching Accessibility state (initial = {last})");
glib::timeout_add_seconds_local(1, move || {
let current = accessibility_granted();
if current != last {
log::info!("Accessibility state flip: {last} -> {current}");
on_change(if current {
AccessibilityChange::Granted
} else {
AccessibilityChange::Revoked
});
last = current;
}
glib::ControlFlow::Continue
});
}
pub fn open_accessibility_settings() {
open_url("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility");
}
/// Spawn a fresh instance of the current `.app` bundle via Launch Services
/// after a 1-second delay, so the new instance starts *after* the current
/// process has exited — otherwise Launch Services reactivates the existing
/// process instead of launching a fresh one, and the stale IPC socket
/// would block the new daemon subprocess. The caller is responsible for
/// quitting the current process (e.g. `Application::quit()`) after this.
pub fn relaunch_bundle() {
// Resolve the .app bundle path from the current executable: it lives
// at <bundle>/Contents/MacOS/lan-mouse, so three parents up is the
// bundle root we hand to `open`.
let Ok(exe) = std::env::current_exe() else {
return;
};
let Some(bundle) = exe
.parent()
.and_then(std::path::Path::parent)
.and_then(std::path::Path::parent)
else {
return;
};
// Trailing `&` backgrounds the sleep+open so our shell call returns
// immediately; the spawned shell is adopted by launchd once we exit.
let cmd = format!("(sleep 1 && open {bundle:?}) &");
let _ = Command::new("sh").arg("-c").arg(cmd).spawn();
}
/// Make sure the app appears in System Settings → Privacy → Input Monitoring.
///
/// `CGRequestListenEventAccess()` is *supposed* to register the app in the
/// list (and prompt) on first call, but in practice — particularly after a
/// `tccutil reset ListenEvent <bundle>` — it often silently no-ops and the
/// app never gets added. The reliable way to force registration is to
/// attempt a protected action: create a `CGEventTap`. If permission is
/// missing the call returns null, but the attempt itself causes TCC to add
/// the bundle to the Input Monitoring pane so the user can toggle it on.
/// If permission already exists the tap is created successfully, and we
/// tear it down immediately so it doesn't intercept events.
unsafe fn ensure_listed_in_input_monitoring() {
let req = CGRequestListenEventAccess();
log::debug!("CGRequestListenEventAccess() = {req}");
let cb = input_monitoring_noop_tap_callback as *const c_void;
// Use kCGSessionEventTap (1), NOT kCGHIDEventTap (0). The HID tap sits
// below window-server input and requires Accessibility in addition to
// Input Monitoring, so attempting it when Accessibility isn't granted
// surfaces an Accessibility prompt as a side effect — which is confusing
// on top of the real Accessibility prompt we already fire explicitly.
// The session tap requires only Input Monitoring, so its failure is a
// clean "Input Monitoring missing" signal that TCC uses to list the
// bundle under the Input Monitoring pane.
// kCGHeadInsertEventTap = 0, kCGEventTapOptionListenOnly = 1,
// mask kCGEventKeyDown = 1 << 10.
let tap = CGEventTapCreate(1, 0, 1, 1 << 10, cb, std::ptr::null());
log::debug!("CGEventTapCreate(kCGSessionEventTap) -> {tap:?}");
if !tap.is_null() {
CFRelease(tap);
}
}
extern "C" fn input_monitoring_noop_tap_callback(
_proxy: *const c_void,
_ty: u32,
event: *const c_void,
_refcon: *const c_void,
) -> *const c_void {
// Pass through unchanged. This tap is never added to a run loop, so
// in practice the callback never fires — it exists only so the tap
// can be created (and the attempt is what forces TCC registration).
event
}
fn open_url(url: &str) {
if let Err(e) = Command::new("open").arg(url).spawn() {
log::warn!("failed to open {url}: {e}");
}
}
/// One-shot, at GUI startup: if a permission is missing, fire the system
/// prompt. This is where the familiar first-launch "Lan Mouse.app would
/// like to control this computer" alert comes from. Subsequent clicks on
/// the Reenable button use URL-scheme navigation instead, so we never
/// double up alerts on retries.
///
/// Guarded with a `Once` because GApplication::activate can fire more
/// than once in a process (reactivation, window presentation) and we
/// must not re-pop the TCC alert on each activation — that looks like a
/// bug to the user.
pub fn fire_initial_prompts() {
static FIRED: Once = Once::new();
FIRED.call_once(fire_initial_prompts_inner);
}
fn fire_initial_prompts_inner() {
if !accessibility_granted() {
// When Accessibility isn't granted yet, ONLY fire the Accessibility
// prompt. Do NOT also try to register Input Monitoring or Post Event
// — those paths have been observed to surface a second Accessibility
// dialog on top of the one we fire explicitly (Post Event is part of
// the Accessibility category on modern macOS, and CGEventTap attempts
// can bail on Accessibility before they reach the Input Monitoring
// check). Once the user grants Accessibility and relaunches, this
// branch is skipped and we register the other grants cleanly below.
log::info!("firing first-launch Accessibility prompt");
unsafe {
let key = kAXTrustedCheckOptionPrompt;
let value = kCFBooleanTrue;
let options = CFDictionaryCreate(
kCFAllocatorDefault,
&key as *const _,
&value as *const _,
1,
kCFTypeDictionaryKeyCallBacks,
kCFTypeDictionaryValueCallBacks,
);
AXIsProcessTrustedWithOptions(options);
CFRelease(options);
}
return;
}
// Accessibility is granted. Attempt Input Monitoring registration
// unconditionally — even if preflight returns true — so the bundle gets
// listed in System Settings under its own identity (otherwise launches
// from a parent process that already has Input Monitoring, e.g. Terminal,
// inherit the grant but the bundle is never listed for the user to
// toggle persistently).
log::info!("ensuring Lan Mouse is listed under Input Monitoring");
unsafe {
ensure_listed_in_input_monitoring();
}
// Same for Post Event: now that Accessibility is present, this call is
// safe — it won't surface the generic Accessibility prompt.
log::info!("ensuring Lan Mouse is listed under Accessibility > Post Event");
unsafe {
CGRequestPostEventAccess();
}
}

View File

@@ -0,0 +1,346 @@
#![allow(clashing_extern_declarations)]
use std::{
cell::RefCell,
ffi::{CStr, CString, c_char, c_double, c_uint, c_void},
sync::OnceLock,
};
use adw::prelude::*;
use gtk::{gio, glib};
use crate::window::Window;
type Id = *mut c_void;
type Class = *mut c_void;
type Sel = *mut c_void;
type Bool = i8;
struct StatusItem {
app: glib::WeakRef<adw::Application>,
window: glib::WeakRef<Window>,
_hold: gio::ApplicationHoldGuard,
_delegate: Id,
_status_item: Id,
}
thread_local! {
static STATUS_ITEM: RefCell<Option<StatusItem>> = const { RefCell::new(None) };
}
pub fn setup(app: &adw::Application, window: &Window) {
log::debug!("macos_status_item::setup entered");
STATUS_ITEM.with(|item| {
let already_initialized = item.borrow().is_some();
if already_initialized {
let mut cell = item.borrow_mut();
if let Some(existing) = cell.as_mut() {
existing.app.set(Some(app));
existing.window.set(Some(window));
}
return;
}
unsafe {
let hold = app.hold();
let ns_app = msg_send_id(class(c"NSApplication"), sel(c"sharedApplication"));
assert!(
!ns_app.is_null(),
"NSApplication sharedApplication returned null"
);
msg_send_bool_usize(ns_app, sel(c"setActivationPolicy:"), 1);
let delegate = new_delegate();
let menu = menu(&[
menu_item(c"Open Lan Mouse", c"showLanMouse:"),
separator_item(),
menu_item(c"Quit Lan Mouse", c"quitLanMouse:"),
]);
let status_bar = msg_send_id(class(c"NSStatusBar"), sel(c"systemStatusBar"));
assert!(
!status_bar.is_null(),
"NSStatusBar systemStatusBar returned null"
);
let status_item = msg_send_id_f64(status_bar, sel(c"statusItemWithLength:"), -1.0);
assert!(!status_item.is_null(), "statusItemWithLength returned null");
// Retain so the status item survives autorelease pool drain.
let status_item = msg_send_id(status_item, sel(c"retain"));
let button = msg_send_id(status_item, sel(c"button"));
assert!(!button.is_null(), "NSStatusItem.button was null");
set_button_image(button);
msg_send_void_id(button, sel(c"setToolTip:"), nsstring(c"Lan Mouse"));
msg_send_void_id(status_item, sel(c"setMenu:"), menu);
for item in menu_items(menu) {
msg_send_void_id(item, sel(c"setTarget:"), delegate);
}
install_reopen_handler(delegate);
log::debug!("macos_status_item ready at {status_item:p}");
item.replace(Some(StatusItem {
app: app.downgrade(),
window: window.downgrade(),
_hold: hold,
_delegate: delegate,
_status_item: status_item,
}));
}
});
}
// Prefer a pre-rendered template PNG (black silhouette with alpha) so macOS
// auto-tints the glyph to match the menu bar in light and dark modes.
// Falls back to the full-color icns, then to "LM" text.
unsafe fn set_button_image(button: Id) {
if let Some(image) = load_menubar_template() {
msg_send_void_bool(image, sel(c"setTemplate:"), 1);
msg_send_void_id(button, sel(c"setImage:"), image);
return;
}
if let Some(image) = load_app_icon() {
msg_send_void_id(button, sel(c"setImage:"), image);
return;
}
log::warn!("no menu bar image available; falling back to text title");
msg_send_void_id(button, sel(c"setTitle:"), nsstring(c"LM"));
}
unsafe fn load_menubar_template() -> Option<Id> {
load_resource_image(c"menubar-template", c"png", MENUBAR_ICON_SIZE)
}
unsafe fn load_app_icon() -> Option<Id> {
load_resource_image(c"icon", c"icns", MENUBAR_ICON_SIZE)
}
unsafe fn load_resource_image(name: &CStr, ext: &CStr, size_pt: c_double) -> Option<Id> {
let bundle = msg_send_id(class(c"NSBundle"), sel(c"mainBundle"));
if bundle.is_null() {
return None;
}
let path = msg_send_id_id_id(
bundle,
sel(c"pathForResource:ofType:"),
nsstring(name),
nsstring(ext),
);
if path.is_null() {
return None;
}
let image = msg_send_id_id(
msg_send_id(class(c"NSImage"), sel(c"alloc")),
sel(c"initWithContentsOfFile:"),
path,
);
if image.is_null() {
return None;
}
// Render at menu bar height; 22pt is the full status bar icon height.
msg_send_void_size(image, sel(c"setSize:"), size_pt, size_pt);
Some(image)
}
const MENUBAR_ICON_SIZE: c_double = 22.0;
unsafe fn menu(items: &[Id]) -> Id {
let menu = msg_send_id(msg_send_id(class(c"NSMenu"), sel(c"alloc")), sel(c"init"));
for item in items {
msg_send_void_id(menu, sel(c"addItem:"), *item);
}
menu
}
unsafe fn menu_item(title: &CStr, action: &CStr) -> Id {
msg_send_id_id_sel_id(
msg_send_id(class(c"NSMenuItem"), sel(c"alloc")),
sel(c"initWithTitle:action:keyEquivalent:"),
nsstring(title),
sel(action),
nsstring(c""),
)
}
unsafe fn separator_item() -> Id {
msg_send_id(class(c"NSMenuItem"), sel(c"separatorItem"))
}
unsafe fn menu_items(menu: Id) -> Vec<Id> {
let count = msg_send_usize(menu, sel(c"numberOfItems"));
(0..count)
.map(|idx| msg_send_id_usize(menu, sel(c"itemAtIndex:"), idx))
.collect()
}
unsafe fn new_delegate() -> Id {
let class = delegate_class();
msg_send_id(msg_send_id(class, sel(c"alloc")), sel(c"init"))
}
fn delegate_class() -> Class {
static CLASS: OnceLock<usize> = OnceLock::new();
*CLASS.get_or_init(|| unsafe {
let superclass = class(c"NSObject");
let class_name = CString::new("LanMouseStatusItemDelegate").unwrap();
let class = objc_allocateClassPair(superclass, class_name.as_ptr(), 0);
assert!(!class.is_null(), "failed to allocate status item delegate");
class_addMethod(
class,
sel(c"showLanMouse:"),
show_lan_mouse as *const c_void,
c"v@:@".as_ptr(),
);
class_addMethod(
class,
sel(c"quitLanMouse:"),
quit_lan_mouse as *const c_void,
c"v@:@".as_ptr(),
);
// kAEReopenApplication handler — fires when the user re-launches
// the .app while it's already running (Finder, `open`, Dock).
class_addMethod(
class,
sel(c"handleReopenEvent:withReplyEvent:"),
handle_reopen_event as *const c_void,
c"v@:@@".as_ptr(),
);
objc_registerClassPair(class);
class as usize
}) as Class
}
extern "C" fn show_lan_mouse(_this: Id, _cmd: Sel, _sender: Id) {
present_window();
}
extern "C" fn handle_reopen_event(_this: Id, _cmd: Sel, _event: Id, _reply: Id) {
log::debug!("kAEReopenApplication received — presenting main window");
present_window();
}
fn present_window() {
STATUS_ITEM.with(|item| {
let item = item.borrow();
let Some(item) = item.as_ref() else {
return;
};
if let Some(window) = item.window.upgrade() {
window.present();
}
unsafe {
let ns_app = msg_send_id(class(c"NSApplication"), sel(c"sharedApplication"));
msg_send_void_bool(ns_app, sel(c"activateIgnoringOtherApps:"), 1);
}
});
}
// Register the status-item delegate as the handler for the
// kAEReopenApplication Apple Event ('aevt'/'rapp'). NSApplication
// installs a default handler at -finishLaunching that just delegates to
// applicationShouldHandleReopen:hasVisibleWindows: — which is a no-op
// here because GApplication owns NSApp's delegate. Replacing it lets us
// re-present the window when the user double-clicks the .app while
// we're already running.
unsafe fn install_reopen_handler(delegate: Id) {
const K_CORE_EVENT_CLASS: c_uint = 0x6165_7674; // 'aevt'
const K_AE_REOPEN_APPLICATION: c_uint = 0x7261_7070; // 'rapp'
let manager = msg_send_id(
class(c"NSAppleEventManager"),
sel(c"sharedAppleEventManager"),
);
if manager.is_null() {
log::warn!("NSAppleEventManager unavailable; re-launch will not re-open window");
return;
}
msg_send_void_id_sel_u32_u32(
manager,
sel(c"setEventHandler:andSelector:forEventClass:andEventID:"),
delegate,
sel(c"handleReopenEvent:withReplyEvent:"),
K_CORE_EVENT_CLASS,
K_AE_REOPEN_APPLICATION,
);
}
extern "C" fn quit_lan_mouse(_this: Id, _cmd: Sel, _sender: Id) {
STATUS_ITEM.with(|item| {
if let Some(app) = item.borrow().as_ref().and_then(|item| item.app.upgrade()) {
app.quit();
}
});
}
unsafe fn class(name: &CStr) -> Class {
let class = objc_getClass(name.as_ptr());
assert!(!class.is_null(), "missing Objective-C class {name:?}");
class
}
unsafe fn sel(name: &CStr) -> Sel {
sel_registerName(name.as_ptr())
}
unsafe fn nsstring(value: &CStr) -> Id {
msg_send_id_ptr(
class(c"NSString"),
sel(c"stringWithUTF8String:"),
value.as_ptr(),
)
}
#[link(name = "objc")]
extern "C" {
fn objc_allocateClassPair(superclass: Class, name: *const c_char, extra_bytes: usize) -> Class;
fn objc_getClass(name: *const c_char) -> Class;
fn objc_registerClassPair(class: Class);
fn sel_registerName(name: *const c_char) -> Sel;
fn class_addMethod(class: Class, name: Sel, imp: *const c_void, types: *const c_char) -> Bool;
}
#[link(name = "AppKit", kind = "framework")]
extern "C" {}
#[link(name = "objc")]
extern "C" {
#[link_name = "objc_msgSend"]
fn msg_send_id(receiver: Id, selector: Sel) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_f64(receiver: Id, selector: Sel, value: c_double) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_id_sel_id(receiver: Id, selector: Sel, a: Id, b: Sel, c: Id) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_id_id(receiver: Id, selector: Sel, a: Id, b: Id) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_id(receiver: Id, selector: Sel, a: Id) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_void_size(receiver: Id, selector: Sel, width: c_double, height: c_double);
#[link_name = "objc_msgSend"]
fn msg_send_id_ptr(receiver: Id, selector: Sel, value: *const c_char) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_id_usize(receiver: Id, selector: Sel, value: usize) -> Id;
#[link_name = "objc_msgSend"]
fn msg_send_usize(receiver: Id, selector: Sel) -> usize;
#[link_name = "objc_msgSend"]
fn msg_send_void_bool(receiver: Id, selector: Sel, value: Bool);
#[link_name = "objc_msgSend"]
fn msg_send_void_id(receiver: Id, selector: Sel, value: Id);
#[link_name = "objc_msgSend"]
fn msg_send_bool_usize(receiver: Id, selector: Sel, value: usize) -> Bool;
#[link_name = "objc_msgSend"]
fn msg_send_void_id_sel_u32_u32(
receiver: Id,
selector: Sel,
a: Id,
b: Sel,
c: c_uint,
d: c_uint,
);
}

View File

@@ -4,22 +4,35 @@ use std::collections::HashMap;
use adw::prelude::*; use adw::prelude::*;
use adw::subclass::prelude::*; use adw::subclass::prelude::*;
use glib::{clone, Object}; use glib::{Object, clone};
use gtk::{ use gtk::{
gio, NoSelection, gio,
glib::{self, closure_local}, glib::{self, closure_local},
ListBox, NoSelection,
}; };
use lan_mouse_ipc::{ use lan_mouse_ipc::{
ClientConfig, ClientHandle, ClientState, FrontendRequest, FrontendRequestWriter, Position, ClientConfig, ClientHandle, ClientState, DEFAULT_PORT, FrontendRequest, FrontendRequestWriter,
DEFAULT_PORT, Position,
}; };
use crate::{fingerprint_window::FingerprintWindow, key_object::KeyObject, key_row::KeyRow}; use crate::{
authorization_window::AuthorizationWindow, fingerprint_window::FingerprintWindow,
key_object::KeyObject, key_row::KeyRow,
};
use super::{client_object::ClientObject, client_row::ClientRow}; use super::{client_object::ClientObject, client_row::ClientRow};
#[cfg(target_os = "macos")]
fn set_button_content_label(button: &gtk::Button, label: &str) {
// The Reenable/Grant/Relaunch button wraps its icon+label in an
// AdwButtonContent (see window.ui). Walk into it and swap the label
// rather than GtkButton::set_label, which would replace the content
// widget and drop the icon.
if let Some(content) = button.child().and_downcast::<adw::ButtonContent>() {
content.set_label(label);
}
}
glib::wrapper! { glib::wrapper! {
pub struct Window(ObjectSubclass<imp::Window>) pub struct Window(ObjectSubclass<imp::Window>)
@extends adw::ApplicationWindow, gtk::Window, gtk::Widget, @extends adw::ApplicationWindow, gtk::Window, gtk::Widget,
@@ -28,7 +41,7 @@ glib::wrapper! {
} }
impl Window { impl Window {
pub(crate) fn new(app: &adw::Application, conn: FrontendRequestWriter) -> Self { pub(super) fn new(app: &adw::Application, conn: FrontendRequestWriter) -> Self {
let window: Self = Object::builder().property("application", app).build(); let window: Self = Object::builder().property("application", app).build();
window window
.imp() .imp()
@@ -38,7 +51,7 @@ impl Window {
window window
} }
pub fn clients(&self) -> gio::ListStore { fn clients(&self) -> gio::ListStore {
self.imp() self.imp()
.clients .clients
.borrow() .borrow()
@@ -46,7 +59,7 @@ impl Window {
.expect("Could not get clients") .expect("Could not get clients")
} }
pub fn authorized(&self) -> gio::ListStore { fn authorized(&self) -> gio::ListStore {
self.imp() self.imp()
.authorized .authorized
.borrow() .borrow()
@@ -62,6 +75,14 @@ impl Window {
self.authorized().item(idx).map(|o| o.downcast().unwrap()) self.authorized().item(idx).map(|o| o.downcast().unwrap())
} }
fn row_by_idx(&self, idx: i32) -> Option<ClientRow> {
self.imp()
.client_list
.get()
.row_at_index(idx)
.map(|o| o.downcast().expect("expected ClientRow"))
}
fn setup_authorized(&self) { fn setup_authorized(&self) {
let store = gio::ListStore::new::<KeyObject>(); let store = gio::ListStore::new::<KeyObject>();
self.imp().authorized.replace(Some(store)); self.imp().authorized.replace(Some(store));
@@ -112,16 +133,57 @@ impl Window {
.expect("Expected object of type `ClientObject`."); .expect("Expected object of type `ClientObject`.");
let row = window.create_client_row(client_object); let row = window.create_client_row(client_object);
row.connect_closure( row.connect_closure(
"request-update", "request-hostname-change",
false,
closure_local!(
#[strong]
window,
move |row: ClientRow, hostname: String| {
log::debug!("request-hostname-change");
if let Some(client) = window.client_by_idx(row.index() as u32) {
let hostname = Some(hostname).filter(|s| !s.is_empty());
/* changed in response to FrontendEvent
* -> do not request additional update */
window.request(FrontendRequest::UpdateHostname(
client.handle(),
hostname,
));
}
}
),
);
row.connect_closure(
"request-port-change",
false,
closure_local!(
#[strong]
window,
move |row: ClientRow, port: u32| {
if let Some(client) = window.client_by_idx(row.index() as u32) {
window.request(FrontendRequest::UpdatePort(
client.handle(),
port as u16,
));
}
}
),
);
row.connect_closure(
"request-activate",
false, false,
closure_local!( closure_local!(
#[strong] #[strong]
window, window,
move |row: ClientRow, active: bool| { move |row: ClientRow, active: bool| {
if let Some(client) = window.client_by_idx(row.index() as u32) { if let Some(client) = window.client_by_idx(row.index() as u32) {
window.request_client_activate(&client, active); log::debug!(
window.request_client_update(&client); "request: {} client",
window.request_client_state(&client); if active { "activating" } else { "deactivating" }
);
window.request(FrontendRequest::Activate(
client.handle(),
active,
));
} }
} }
), ),
@@ -134,7 +196,7 @@ impl Window {
window, window,
move |row: ClientRow| { move |row: ClientRow| {
if let Some(client) = window.client_by_idx(row.index() as u32) { if let Some(client) = window.client_by_idx(row.index() as u32) {
window.request_client_delete(&client); window.request(FrontendRequest::Delete(client.handle()));
} }
} }
), ),
@@ -147,9 +209,31 @@ impl Window {
window, window,
move |row: ClientRow| { move |row: ClientRow| {
if let Some(client) = window.client_by_idx(row.index() as u32) { if let Some(client) = window.client_by_idx(row.index() as u32) {
window.request_client_update(&client); window.request(FrontendRequest::ResolveDns(
window.request_dns(&client); client.get_data().handle,
window.request_client_state(&client); ));
}
}
),
);
row.connect_closure(
"request-position-change",
false,
closure_local!(
#[strong]
window,
move |row: ClientRow, pos_idx: u32| {
if let Some(client) = window.client_by_idx(row.index() as u32) {
let position = match pos_idx {
0 => Position::Left,
1 => Position::Right,
2 => Position::Top,
_ => Position::Bottom,
};
window.request(FrontendRequest::UpdatePosition(
client.handle(),
position,
));
} }
} }
), ),
@@ -160,10 +244,14 @@ impl Window {
); );
} }
fn setup_icon(&self) {
self.set_icon_name(Some("de.feschber.LanMouse"));
}
/// workaround for a bug in libadwaita that shows an ugly line beneath /// workaround for a bug in libadwaita that shows an ugly line beneath
/// the last element if a placeholder is set. /// the last element if a placeholder is set.
/// https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6308 /// https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6308
pub fn update_placeholder_visibility(&self) { fn update_placeholder_visibility(&self) {
let visible = self.clients().n_items() == 0; let visible = self.clients().n_items() == 0;
let placeholder = self.imp().client_placeholder.get(); let placeholder = self.imp().client_placeholder.get();
self.imp().client_list.set_placeholder(match visible { self.imp().client_list.set_placeholder(match visible {
@@ -172,7 +260,7 @@ impl Window {
}); });
} }
pub fn update_auth_placeholder_visibility(&self) { fn update_auth_placeholder_visibility(&self) {
let visible = self.authorized().n_items() == 0; let visible = self.authorized().n_items() == 0;
let placeholder = self.imp().authorized_placeholder.get(); let placeholder = self.imp().authorized_placeholder.get();
self.imp().authorized_list.set_placeholder(match visible { self.imp().authorized_list.set_placeholder(match visible {
@@ -181,10 +269,6 @@ impl Window {
}); });
} }
fn setup_icon(&self) {
self.set_icon_name(Some("de.feschber.LanMouse"));
}
fn create_client_row(&self, client_object: &ClientObject) -> ClientRow { fn create_client_row(&self, client_object: &ClientObject) -> ClientRow {
let row = ClientRow::new(client_object); let row = ClientRow::new(client_object);
row.bind(client_object); row.bind(client_object);
@@ -197,24 +281,46 @@ impl Window {
row row
} }
pub fn new_client(&self, handle: ClientHandle, client: ClientConfig, state: ClientState) { pub(super) fn new_client(
&self,
handle: ClientHandle,
client: ClientConfig,
state: ClientState,
) {
let client = ClientObject::new(handle, client, state.clone()); let client = ClientObject::new(handle, client, state.clone());
self.clients().append(&client); self.clients().append(&client);
self.update_placeholder_visibility(); self.update_placeholder_visibility();
self.update_dns_state(handle, !state.ips.is_empty()); self.update_dns_state(handle, !state.ips.is_empty());
} }
pub fn client_idx(&self, handle: ClientHandle) -> Option<usize> { pub(super) fn update_client_list(
self.clients().iter::<ClientObject>().position(|c| { &self,
if let Ok(c) = c { clients: Vec<(ClientHandle, ClientConfig, ClientState)>,
c.handle() == handle ) {
for (handle, client, state) in clients {
if self.client_idx(handle).is_some() {
self.update_client_config(handle, client);
self.update_client_state(handle, state);
} else { } else {
false self.new_client(handle, client, state);
} }
}) }
} }
pub fn delete_client(&self, handle: ClientHandle) { pub(super) fn update_port(&self, port: u16, msg: Option<String>) {
if let Some(msg) = msg {
self.show_toast(msg.as_str());
}
self.imp().set_port(port);
}
fn client_idx(&self, handle: ClientHandle) -> Option<usize> {
self.clients()
.iter::<ClientObject>()
.position(|c| c.ok().map(|c| c.handle() == handle).unwrap_or_default())
}
pub(super) fn delete_client(&self, handle: ClientHandle) {
let Some(idx) = self.client_idx(handle) else { let Some(idx) = self.client_idx(handle) else {
log::warn!("could not find client with handle {handle}"); log::warn!("could not find client with handle {handle}");
return; return;
@@ -226,46 +332,31 @@ impl Window {
} }
} }
pub fn update_client_config(&self, handle: ClientHandle, client: ClientConfig) { pub(super) fn update_client_config(&self, handle: ClientHandle, client: ClientConfig) {
let Some(idx) = self.client_idx(handle) else { let Some(row) = self.row_for_handle(handle) else {
log::warn!("could not find client with handle {}", handle); log::warn!("could not find row for handle {handle}");
return; return;
}; };
let client_object = self.clients().item(idx as u32).unwrap(); row.set_hostname(client.hostname);
let client_object: &ClientObject = client_object.downcast_ref().unwrap(); row.set_port(client.port);
let data = client_object.get_data(); row.set_position(client.pos);
/* only change if it actually has changed, otherwise
* the update signal is triggered */
if data.hostname != client.hostname {
client_object.set_hostname(client.hostname.unwrap_or("".into()));
}
if data.port != client.port as u32 {
client_object.set_port(client.port as u32);
}
if data.position != client.pos.to_string() {
client_object.set_position(client.pos.to_string());
}
} }
pub fn update_client_state(&self, handle: ClientHandle, state: ClientState) { pub(super) fn update_client_state(&self, handle: ClientHandle, state: ClientState) {
let Some(idx) = self.client_idx(handle) else { let Some(row) = self.row_for_handle(handle) else {
log::warn!("could not find client with handle {}", handle); log::warn!("could not find row for handle {handle}");
return;
};
let Some(client_object) = self.client_object_for_handle(handle) else {
log::warn!("could not find row for handle {handle}");
return; return;
}; };
let client_object = self.clients().item(idx as u32).unwrap();
let client_object: &ClientObject = client_object.downcast_ref().unwrap();
let data = client_object.get_data();
if state.active != data.active { /* activation state */
client_object.set_active(state.active); row.set_active(state.active);
log::debug!("set active to {}", state.active);
}
if state.resolving != data.resolving { /* dns state */
client_object.set_resolving(state.resolving); client_object.set_resolving(state.resolving);
log::debug!("resolving {}: {}", data.handle, state.resolving);
}
self.update_dns_state(handle, !state.ips.is_empty()); self.update_dns_state(handle, !state.ips.is_empty());
let ips = state let ips = state
@@ -276,22 +367,23 @@ impl Window {
client_object.set_ips(ips); client_object.set_ips(ips);
} }
pub fn update_dns_state(&self, handle: ClientHandle, resolved: bool) { fn client_object_for_handle(&self, handle: ClientHandle) -> Option<ClientObject> {
let Some(idx) = self.client_idx(handle) else { self.client_idx(handle)
log::warn!("could not find client with handle {}", handle); .and_then(|i| self.client_by_idx(i as u32))
return; }
};
let list_box: ListBox = self.imp().client_list.get(); fn row_for_handle(&self, handle: ClientHandle) -> Option<ClientRow> {
let row = list_box.row_at_index(idx as i32).unwrap(); self.client_idx(handle)
let client_row: ClientRow = row.downcast().expect("expected ClientRow Object"); .and_then(|i| self.row_by_idx(i as i32))
if resolved { }
client_row.imp().dns_button.set_css_classes(&["success"])
} else { fn update_dns_state(&self, handle: ClientHandle, resolved: bool) {
client_row.imp().dns_button.set_css_classes(&["warning"]) if let Some(client_row) = self.row_for_handle(handle) {
client_row.set_dns_state(resolved);
} }
} }
pub fn request_port_change(&self) { fn request_port_change(&self) {
let port = self let port = self
.imp() .imp()
.port_entry .port_entry
@@ -303,56 +395,20 @@ impl Window {
self.request(FrontendRequest::ChangePort(port)); self.request(FrontendRequest::ChangePort(port));
} }
pub fn request_capture(&self) { fn request_capture(&self) {
self.request(FrontendRequest::EnableCapture); self.request(FrontendRequest::EnableCapture);
} }
pub fn request_emulation(&self) { fn request_emulation(&self) {
self.request(FrontendRequest::EnableEmulation); self.request(FrontendRequest::EnableEmulation);
} }
pub fn request_client_state(&self, client: &ClientObject) { fn request_client_create(&self) {
self.request_client_state_for(client.handle());
}
pub fn request_client_state_for(&self, handle: ClientHandle) {
self.request(FrontendRequest::GetState(handle));
}
pub fn request_client_create(&self) {
self.request(FrontendRequest::Create); self.request(FrontendRequest::Create);
} }
pub fn request_dns(&self, client: &ClientObject) { fn open_fingerprint_dialog(&self, fp: Option<String>) {
self.request(FrontendRequest::ResolveDns(client.get_data().handle)); let window = FingerprintWindow::new(fp);
}
pub fn request_client_update(&self, client: &ClientObject) {
let handle = client.handle();
let data = client.get_data();
let position = Position::try_from(data.position.as_str()).expect("invalid position");
let hostname = data.hostname;
let port = data.port as u16;
for event in [
FrontendRequest::UpdateHostname(handle, hostname),
FrontendRequest::UpdatePosition(handle, position),
FrontendRequest::UpdatePort(handle, port),
] {
self.request(event);
}
}
pub fn request_client_activate(&self, client: &ClientObject, active: bool) {
self.request(FrontendRequest::Activate(client.handle(), active));
}
pub fn request_client_delete(&self, client: &ClientObject) {
self.request(FrontendRequest::Delete(client.handle()));
}
pub fn open_fingerprint_dialog(&self) {
let window = FingerprintWindow::new();
window.set_transient_for(Some(self)); window.set_transient_for(Some(self));
window.connect_closure( window.connect_closure(
"confirm-clicked", "confirm-clicked",
@@ -369,15 +425,15 @@ impl Window {
window.present(); window.present();
} }
pub fn request_fingerprint_add(&self, desc: String, fp: String) { fn request_fingerprint_add(&self, desc: String, fp: String) {
self.request(FrontendRequest::AuthorizeKey(desc, fp)); self.request(FrontendRequest::AuthorizeKey(desc, fp));
} }
pub fn request_fingerprint_remove(&self, fp: String) { fn request_fingerprint_remove(&self, fp: String) {
self.request(FrontendRequest::RemoveAuthorizedKey(fp)); self.request(FrontendRequest::RemoveAuthorizedKey(fp));
} }
pub fn request(&self, request: FrontendRequest) { fn request(&self, request: FrontendRequest) {
let mut requester = self.imp().frontend_request_writer.borrow_mut(); let mut requester = self.imp().frontend_request_writer.borrow_mut();
let requester = requester.as_mut().unwrap(); let requester = requester.as_mut().unwrap();
if let Err(e) = requester.request(request) { if let Err(e) = requester.request(request) {
@@ -385,33 +441,84 @@ impl Window {
}; };
} }
pub fn show_toast(&self, msg: &str) { pub(super) fn show_toast(&self, msg: &str) {
let toast = adw::Toast::new(msg); let toast = adw::Toast::new(msg);
self.add_toast(toast);
}
pub(super) fn add_toast(&self, toast: adw::Toast) {
let toast_overlay = &self.imp().toast_overlay; let toast_overlay = &self.imp().toast_overlay;
toast_overlay.add_toast(toast); toast_overlay.add_toast(toast);
} }
pub fn set_capture(&self, active: bool) { pub(super) fn set_capture(&self, active: bool) {
self.imp().capture_active.replace(active); self.imp().capture_active.replace(active);
self.update_capture_emulation_status(); self.update_capture_emulation_status();
} }
pub fn set_emulation(&self, active: bool) { pub(super) fn set_emulation(&self, active: bool) {
self.imp().emulation_active.replace(active); self.imp().emulation_active.replace(active);
self.update_capture_emulation_status(); self.update_capture_emulation_status();
} }
#[cfg(target_os = "macos")]
pub(super) fn refresh_capture_emulation_status(&self) {
self.update_capture_emulation_status();
}
fn update_capture_emulation_status(&self) { fn update_capture_emulation_status(&self) {
let capture = self.imp().capture_active.get(); let capture = self.imp().capture_active.get();
let emulation = self.imp().emulation_active.get(); let emulation = self.imp().emulation_active.get();
self.imp().capture_status_row.set_visible(!capture);
self.imp().emulation_status_row.set_visible(!emulation); #[cfg(target_os = "macos")]
self.imp() {
.capture_emulation_group // On macOS, capture and emulation share the same TCC gate
.set_visible(!capture || !emulation); // (Accessibility). Collapse to a single warning row —
// emulation_status_row stays hidden and capture_status_row
// doubles as the shared status indicator. Its text and
// button mutate based on whether we're waiting for AX or
// waiting for the user to relaunch the app.
let anything_off = !capture || !emulation;
self.imp().emulation_status_row.set_visible(false);
self.imp().capture_status_row.set_visible(anything_off);
self.imp().capture_emulation_group.set_visible(anything_off);
if anything_off {
self.update_macos_warning_row_text();
}
}
#[cfg(not(target_os = "macos"))]
{
self.imp().capture_status_row.set_visible(!capture);
self.imp().emulation_status_row.set_visible(!emulation);
self.imp()
.capture_emulation_group
.set_visible(!capture || !emulation);
}
} }
pub(crate) fn set_authorized_keys(&self, fingerprints: HashMap<String, String>) { #[cfg(target_os = "macos")]
fn update_macos_warning_row_text(&self) {
let row = &self.imp().capture_status_row;
let button = &self.imp().input_capture_button;
if crate::macos_privacy::accessibility_granted() {
// AX granted but capture/emulation still off → the daemon
// subprocess bailed at startup and needs a fresh process to
// re-initialize with the new grant in place.
row.set_title("relaunch required");
row.set_subtitle("Accessibility granted — restart to activate capture and emulation");
set_button_content_label(button, "Relaunch");
} else {
// AX missing → send the user to System Settings.
row.set_title("input capture is disabled");
row.set_subtitle("grant Accessibility permission to enable");
set_button_content_label(button, "Grant");
}
}
pub(super) fn set_authorized_keys(&self, fingerprints: HashMap<String, String>) {
let authorized = self.authorized(); let authorized = self.authorized();
// clear list // clear list
authorized.remove_all(); authorized.remove_all();
@@ -423,7 +530,36 @@ impl Window {
self.update_auth_placeholder_visibility(); self.update_auth_placeholder_visibility();
} }
pub(crate) fn set_pk_fp(&self, fingerprint: &str) { pub(super) fn set_pk_fp(&self, fingerprint: &str) {
self.imp().fingerprint_row.set_subtitle(fingerprint); self.imp().fingerprint_row.set_subtitle(fingerprint);
} }
pub(super) fn request_authorization(&self, fingerprint: &str) {
if let Some(w) = self.imp().authorization_window.borrow_mut().take() {
w.close();
}
let window = AuthorizationWindow::new(fingerprint);
window.set_transient_for(Some(self));
window.connect_closure(
"confirm-clicked",
false,
closure_local!(
#[strong(rename_to = parent)]
self,
move |w: AuthorizationWindow, fp: String| {
w.close();
parent.open_fingerprint_dialog(Some(fp));
}
),
);
window.connect_closure(
"cancel-clicked",
false,
closure_local!(move |w: AuthorizationWindow| {
w.close();
}),
);
window.present();
self.imp().authorization_window.replace(Some(window));
}
} }

View File

@@ -1,12 +1,14 @@
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use adw::subclass::prelude::*; use adw::subclass::prelude::*;
use adw::{prelude::*, ActionRow, PreferencesGroup, ToastOverlay}; use adw::{ActionRow, PreferencesGroup, ToastOverlay, prelude::*};
use glib::subclass::InitializingObject; use glib::subclass::InitializingObject;
use gtk::glib::clone; use gtk::glib::clone;
use gtk::{gdk, gio, glib, Button, CompositeTemplate, Entry, Image, Label, ListBox}; use gtk::{Button, CompositeTemplate, Entry, Image, Label, ListBox, gdk, gio, glib};
use lan_mouse_ipc::{FrontendRequestWriter, DEFAULT_PORT}; use lan_mouse_ipc::{DEFAULT_PORT, FrontendRequestWriter};
use crate::authorization_window::AuthorizationWindow;
#[derive(CompositeTemplate, Default)] #[derive(CompositeTemplate, Default)]
#[template(resource = "/de/feschber/LanMouse/window.ui")] #[template(resource = "/de/feschber/LanMouse/window.ui")]
@@ -49,6 +51,7 @@ pub struct Window {
pub port: Cell<u16>, pub port: Cell<u16>,
pub capture_active: Cell<bool>, pub capture_active: Cell<bool>,
pub emulation_active: Cell<bool>, pub emulation_active: Cell<bool>,
pub authorization_window: RefCell<Option<AuthorizationWindow>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@@ -139,17 +142,38 @@ impl Window {
#[template_callback] #[template_callback]
fn handle_emulation(&self) { fn handle_emulation(&self) {
// On macOS the emulation_status_row is hidden — capture_status_row
// acts as the shared warning (see update_capture_emulation_status).
// This handler still fires for the non-macOS platforms where the
// emulation row is distinct.
self.obj().request_emulation(); self.obj().request_emulation();
} }
#[template_callback] #[template_callback]
fn handle_capture(&self) { fn handle_capture(&self) {
#[cfg(target_os = "macos")]
{
use crate::macos_privacy;
if macos_privacy::accessibility_granted() {
// AX granted but the row is still visible => the daemon
// subprocess bailed before AX was in place and needs a
// fresh process. Quit + relaunch via Launch Services.
log::info!("capture row clicked in relaunch-required state");
macos_privacy::relaunch_bundle();
if let Some(app) = self.obj().application() {
app.quit();
}
return;
}
log::info!("capture row clicked in AX-missing state, opening pane");
macos_privacy::open_accessibility_settings();
}
self.obj().request_capture(); self.obj().request_capture();
} }
#[template_callback] #[template_callback]
fn handle_add_cert_fingerprint(&self, _button: &Button) { fn handle_add_cert_fingerprint(&self, _button: &Button) {
self.obj().open_fingerprint_dialog(); self.obj().open_fingerprint_dialog(None);
} }
pub fn set_port(&self, port: u16) { pub fn set_port(&self, port: u16) {

View File

@@ -12,5 +12,5 @@ log = "0.4.22"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.107" serde_json = "1.0.107"
thiserror = "2.0.0" thiserror = "2.0.0"
tokio = { version = "1.32.0", features = ["net", "io-util", "time"] } tokio = { version = "1.32.0", features = ["macros", "net", "io-util", "time"] }
tokio-stream = { version = "0.1.15", features = ["io-util"] } tokio-stream = { version = "0.1.15", features = ["io-util"] }

View File

@@ -1,7 +1,7 @@
use crate::{ConnectionError, FrontendEvent, FrontendRequest, IpcError}; use crate::{ConnectionError, FrontendEvent, FrontendRequest, IpcError};
use std::{ use std::{
cmp::min, cmp::min,
io::{self, prelude::*, BufReader, LineWriter, Lines}, io::{self, BufReader, LineWriter, Lines, prelude::*},
thread, thread,
time::Duration, time::Duration,
}; };

View File

@@ -1,8 +1,7 @@
use crate::{ConnectionError, FrontendEvent, FrontendRequest, IpcError}; use crate::{ConnectionError, FrontendEvent, FrontendRequest, IpcError};
use std::{ use std::{
cmp::min, cmp::min,
io, task::{Poll, ready},
task::{ready, Poll},
time::Duration, time::Duration,
}; };
@@ -47,7 +46,7 @@ impl Stream for AsyncFrontendEventReader {
} }
impl AsyncFrontendRequestWriter { impl AsyncFrontendRequestWriter {
pub async fn request(&mut self, request: FrontendRequest) -> Result<(), io::Error> { pub async fn request(&mut self, request: FrontendRequest) -> Result<(), IpcError> {
let mut json = serde_json::to_string(&request).unwrap(); let mut json = serde_json::to_string(&request).unwrap();
log::debug!("requesting: {json}"); log::debug!("requesting: {json}");
json.push('\n'); json.push('\n');
@@ -57,8 +56,16 @@ impl AsyncFrontendRequestWriter {
} }
pub async fn connect_async( pub async fn connect_async(
timeout: Option<Duration>,
) -> Result<(AsyncFrontendEventReader, AsyncFrontendRequestWriter), ConnectionError> { ) -> Result<(AsyncFrontendEventReader, AsyncFrontendRequestWriter), ConnectionError> {
let stream = wait_for_service().await?; let stream = if let Some(duration) = timeout {
tokio::select! {
s = wait_for_service() => s?,
_ = tokio::time::sleep(duration) => return Err(ConnectionError::Timeout),
}
} else {
wait_for_service().await?
};
#[cfg(unix)] #[cfg(unix)]
let (rx, tx): (ReadHalf<UnixStream>, WriteHalf<UnixStream>) = tokio::io::split(stream); let (rx, tx): (ReadHalf<UnixStream>, WriteHalf<UnixStream>) = tokio::io::split(stream);
#[cfg(windows)] #[cfg(windows)]

View File

@@ -20,8 +20,8 @@ mod connect;
mod connect_async; mod connect_async;
mod listen; mod listen;
pub use connect::{connect, FrontendEventReader, FrontendRequestWriter}; pub use connect::{FrontendEventReader, FrontendRequestWriter, connect};
pub use connect_async::{connect_async, AsyncFrontendEventReader, AsyncFrontendRequestWriter}; pub use connect_async::{AsyncFrontendEventReader, AsyncFrontendRequestWriter, connect_async};
pub use listen::AsyncFrontendListener; pub use listen::AsyncFrontendListener;
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -30,6 +30,8 @@ pub enum ConnectionError {
SocketPath(#[from] SocketPathError), SocketPath(#[from] SocketPathError),
#[error(transparent)] #[error(transparent)]
Io(#[from] io::Error), Io(#[from] io::Error),
#[error("connection timed out")]
Timeout,
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -57,6 +59,7 @@ pub enum IpcError {
pub const DEFAULT_PORT: u16 = 4242; pub const DEFAULT_PORT: u16 = 4242;
#[derive(Debug, Default, Eq, Hash, PartialEq, Clone, Copy, Serialize, Deserialize)] #[derive(Debug, Default, Eq, Hash, PartialEq, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Position { pub enum Position {
#[default] #[default]
Left, Left,
@@ -177,8 +180,6 @@ pub struct ClientState {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FrontendEvent { pub enum FrontendEvent {
/// client state has changed, new state must be requested via [`FrontendRequest::GetState`]
Changed(ClientHandle),
/// a client was created /// a client was created
Created(ClientHandle, ClientConfig, ClientState), Created(ClientHandle, ClientConfig, ClientState),
/// no such client /// no such client
@@ -201,10 +202,21 @@ pub enum FrontendEvent {
AuthorizedUpdated(HashMap<String, String>), AuthorizedUpdated(HashMap<String, String>),
/// public key fingerprint of this device /// public key fingerprint of this device
PublicKeyFingerprint(String), PublicKeyFingerprint(String),
/// incoming connected /// new device connected
IncomingConnected(String, SocketAddr, Position), DeviceConnected {
addr: SocketAddr,
fingerprint: String,
},
/// incoming device entered the screen
DeviceEntered {
fingerprint: String,
addr: SocketAddr,
pos: Position,
},
/// incoming disconnected /// incoming disconnected
IncomingDisconnected(SocketAddr), IncomingDisconnected(SocketAddr),
/// failed connection attempt (approval for fingerprint required)
ConnectionAttempt { fingerprint: String },
} }
#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)]
@@ -229,8 +241,6 @@ pub enum FrontendRequest {
UpdatePosition(ClientHandle, Position), UpdatePosition(ClientHandle, Position),
/// update fix-ips /// update fix-ips
UpdateFixIps(ClientHandle, Vec<IpAddr>), UpdateFixIps(ClientHandle, Vec<IpAddr>),
/// request the state of the given client
GetState(ClientHandle),
/// request reenabling input capture /// request reenabling input capture
EnableCapture, EnableCapture,
/// request reenabling input emulation /// request reenabling input emulation
@@ -241,6 +251,10 @@ pub enum FrontendRequest {
AuthorizeKey(String, String), AuthorizeKey(String, String),
/// remove fingerprint (fingerprint) /// remove fingerprint (fingerprint)
RemoveAuthorizedKey(String), RemoveAuthorizedKey(String),
/// change the hook command
UpdateEnterHook(u64, Option<String>),
/// save config file
SaveConfiguration,
} }
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] #[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]

View File

@@ -1,4 +1,4 @@
use futures::{stream::SelectAll, Stream, StreamExt}; use futures::{Stream, StreamExt, stream::SelectAll};
#[cfg(unix)] #[cfg(unix)]
use std::path::PathBuf; use std::path::PathBuf;
use std::{ use std::{
@@ -45,7 +45,7 @@ impl AsyncFrontendListener {
let (socket_path, listener) = { let (socket_path, listener) = {
let socket_path = crate::default_socket_path()?; let socket_path = crate::default_socket_path()?;
log::debug!("remove socket: {:?}", socket_path); log::debug!("remove socket: {socket_path:?}");
if socket_path.exists() { if socket_path.exists() {
// try to connect to see if some other instance // try to connect to see if some other instance
// of lan-mouse is already running // of lan-mouse is already running
@@ -63,7 +63,7 @@ impl AsyncFrontendListener {
Ok(ls) => ls, Ok(ls) => ls,
// some other lan-mouse instance has bound the socket in the meantime // some other lan-mouse instance has bound the socket in the meantime
Err(e) if e.kind() == ErrorKind::AddrInUse => { Err(e) if e.kind() == ErrorKind::AddrInUse => {
return Err(IpcListenerCreationError::AlreadyRunning) return Err(IpcListenerCreationError::AlreadyRunning);
} }
Err(e) => return Err(IpcListenerCreationError::Bind(e)), Err(e) => return Err(IpcListenerCreationError::Bind(e)),
}; };
@@ -75,7 +75,7 @@ impl AsyncFrontendListener {
Ok(ls) => ls, Ok(ls) => ls,
// some other lan-mouse instance has bound the socket in the meantime // some other lan-mouse instance has bound the socket in the meantime
Err(e) if e.kind() == ErrorKind::AddrInUse => { Err(e) if e.kind() == ErrorKind::AddrInUse => {
return Err(IpcListenerCreationError::AlreadyRunning) return Err(IpcListenerCreationError::AlreadyRunning);
} }
Err(e) => return Err(IpcListenerCreationError::Bind(e)), Err(e) => return Err(IpcListenerCreationError::Bind(e)),
}; };

View File

@@ -1,18 +1,18 @@
# Nix Flake Usage # Nix Flake Usage
## run ## Run
```bash ```bash
nix run github:feschber/lan-mouse nix run github:feschber/lan-mouse
# with params # With params
nix run github:feschber/lan-mouse -- --help nix run github:feschber/lan-mouse -- --help
``` ```
## home-manager module ## Home-manager module
add input Add input:
```nix ```nix
inputs = { inputs = {
@@ -20,14 +20,27 @@ inputs = {
} }
``` ```
enable lan-mouse Optional: add [our binary cache](https://app.cachix.org/cache/lan-mouse) to allow a faster package install.
```nix
nixConfig = {
extra-substituters = [
"https://lan-mouse.cachix.org/"
];
extra-trusted-public-keys = [
"lan-mouse.cachix.org-1:KlE2AEZUgkzNKM7BIzMQo8w9yJYqUpor1CAUNRY6OyM="
];
};
```
Enable lan-mouse:
``` nix ``` nix
{ {
inputs, inputs,
... ...
}: { }: {
# add the home manager module # Add the Home Manager module
imports = [inputs.lan-mouse.homeManagerModules.default]; imports = [inputs.lan-mouse.homeManagerModules.default];
programs.lan-mouse = { programs.lan-mouse = {

View File

@@ -1,34 +1,40 @@
{ {
stdenv,
rustPlatform, rustPlatform,
lib, lib,
pkgs, pkg-config,
}: let libX11,
cargoToml = builtins.fromTOML (builtins.readFile ../Cargo.toml); gtk4,
libadwaita,
libXtst,
wrapGAppsHook4,
librsvg,
git,
}:
let
cargoToml = fromTOML (builtins.readFile ../Cargo.toml);
pname = cargoToml.package.name; pname = cargoToml.package.name;
version = cargoToml.package.version; version = cargoToml.package.version;
in in
rustPlatform.buildRustPackage { rustPlatform.buildRustPackage {
pname = pname; inherit pname;
version = version; inherit version;
nativeBuildInputs = with pkgs; [ nativeBuildInputs = [
git
pkg-config pkg-config
cmake wrapGAppsHook4
makeWrapper git
buildPackages.gtk4
]; ];
buildInputs = with pkgs; [ buildInputs = [
xorg.libX11
gtk4 gtk4
libadwaita libadwaita
xorg.libXtst librsvg
] ++ lib.optionals stdenv.isDarwin ]
(with darwin.apple_sdk_11_0.frameworks; [ ++ lib.optionals stdenv.isLinux [
CoreGraphics libX11
ApplicationServices libXtst
]); ];
src = builtins.path { src = builtins.path {
name = pname; name = pname;
@@ -40,11 +46,7 @@ rustPlatform.buildRustPackage {
# Set Environment Variables # Set Environment Variables
RUST_BACKTRACE = "full"; RUST_BACKTRACE = "full";
# Needed to enable support for SVG icons in GTK
postInstall = '' postInstall = ''
wrapProgram "$out/bin/lan-mouse" \
--set GDK_PIXBUF_MODULE_FILE ${pkgs.librsvg.out}/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache
install -Dm444 *.desktop -t $out/share/applications install -Dm444 *.desktop -t $out/share/applications
install -Dm444 lan-mouse-gtk/resources/*.svg -t $out/share/icons/hicolor/scalable/apps install -Dm444 lan-mouse-gtk/resources/*.svg -t $out/share/icons/hicolor/scalable/apps
''; '';

View File

@@ -52,7 +52,7 @@ in {
}; };
Service = { Service = {
Type = "simple"; Type = "simple";
ExecStart = "${cfg.package}/bin/lan-mouse --daemon"; ExecStart = "${cfg.package}/bin/lan-mouse daemon";
}; };
Install.WantedBy = [ Install.WantedBy = [
(lib.mkIf config.wayland.windowManager.hyprland.systemd.enable "hyprland-session.target") (lib.mkIf config.wayland.windowManager.hyprland.systemd.enable "hyprland-session.target")
@@ -65,7 +65,7 @@ in {
config = { config = {
ProgramArguments = [ ProgramArguments = [
"${cfg.package}/bin/lan-mouse" "${cfg.package}/bin/lan-mouse"
"--daemon" "daemon"
]; ];
KeepAlive = true; KeepAlive = true;
}; };

136
scripts/copy-macos-dylib.sh Executable file
View File

@@ -0,0 +1,136 @@
#!/bin/sh
set -eu
homebrew_path=""
exec_path="target/debug/bundle/osx/Lan Mouse.app/Contents/MacOS/lan-mouse"
usage() {
cat <<EOF
$0: Copy all Homebrew libraries into the macOS app bundle.
USAGE: $0 [-h] [-b homebrew_path] [exec_path]
OPTIONS:
-h, --help Show this help message and exit
-b Path to Homebrew installation (default: $homebrew_path)
exec_path Path to the main executable in the app bundle
(default: get from `brew --prefix`)
When macOS apps are linked to dynamic libraries (.dylib files),
the fully qualified path to the library is embedded in the binary.
If the libraries come from Homebrew, that means that Homebrew must be present
and the libraries must be installed in the same location on the user's machine.
This script copies all of the Homebrew libraries that an executable links to into the app bundle
and tells all the binaries in the bundle to look for them there.
EOF
}
# Gather command-line arguments
while test $# -gt 0; do
case "$1" in
-h | --help ) usage; exit 0;;
-b | --homebrew ) homebrew_path="$1"; shift 2;;
* ) exec_path="$1"; shift;;
esac
done
if [ -z "$homebrew_path" ]; then
homebrew_path="$(brew --prefix)"
fi
# Path to the .app bundle
bundle_path=$(dirname "$(dirname "$(dirname "$exec_path")")")
# Path to the Frameworks directory
fwks_path="$bundle_path/Contents/Frameworks"
mkdir -p "$fwks_path"
# Path to bundled GTK/GSettings data
resources_path="$bundle_path/Contents/Resources"
share_path="$resources_path/share"
# Copy and fix references for a binary (executable or dylib)
#
# This function will:
# - Copy any referenced dylibs from /opt/homebrew to the Frameworks directory
# - Update the binary to reference the local copy instead
# - Add the Frameworks directory to the binary's RPATH
# - Recursively process the copied dylibs
fix_references() {
local bin="$1"
# Get all Homebrew libraries referenced by the binary
libs=$(otool -L "$bin" | awk -v homebrew="$homebrew_path" '$0 ~ homebrew {print $1}')
echo "$libs" | while IFS= read -r old_path; do
if [ -z "$old_path" ]; then
continue
fi
local base_name="$(basename "$old_path")"
local dest="$fwks_path/$base_name"
if [ ! -e "$dest" ]; then
echo "Copying $old_path -> $dest"
cp -f "$old_path" "$dest"
# Ensure the copied dylib is writable so that xattr -rd /path/to/Lan\ Mouse.app works.
chmod 644 "$dest"
echo "Updating $dest to have install_name of @rpath/$base_name..."
install_name_tool -id "@rpath/$base_name" "$dest"
# Recursively process this dylib
fix_references "$dest"
fi
echo "Updating $bin to reference @rpath/$base_name..."
install_name_tool -change "$old_path" "@rpath/$base_name" "$bin"
done
}
fix_references "$exec_path"
copy_runtime_data() {
mkdir -p "$share_path"
if [ -d "$homebrew_path/share/glib-2.0/schemas" ]; then
mkdir -p "$share_path/glib-2.0"
rm -rf "$share_path/glib-2.0/schemas"
cp -RL "$homebrew_path/share/glib-2.0/schemas" "$share_path/glib-2.0/schemas"
if command -v glib-compile-schemas >/dev/null 2>&1; then
glib-compile-schemas "$share_path/glib-2.0/schemas"
elif [ -x "$homebrew_path/bin/glib-compile-schemas" ]; then
"$homebrew_path/bin/glib-compile-schemas" "$share_path/glib-2.0/schemas"
fi
fi
if [ -d "$homebrew_path/share/gtk-4.0" ]; then
rm -rf "$share_path/gtk-4.0"
cp -RL "$homebrew_path/share/gtk-4.0" "$share_path/gtk-4.0"
fi
if [ -d "$homebrew_path/share/icons/Adwaita" ]; then
mkdir -p "$share_path/icons"
rm -rf "$share_path/icons/Adwaita"
cp -RL "$homebrew_path/share/icons/Adwaita" "$share_path/icons/Adwaita"
fi
}
copy_runtime_data
# cargo-bundle preserves the source path under Contents/Resources (so
# `target/menubar-template.png` lands at `Resources/target/...`). Flatten it
# so NSBundle pathForResource: finds the file at the Resources root.
if [ -f "$resources_path/target/menubar-template.png" ]; then
mv "$resources_path/target/menubar-template.png" "$resources_path/menubar-template.png"
rmdir "$resources_path/target" 2>/dev/null || true
fi
# Ensure the main executable has our Frameworks path in its RPATH
if ! otool -l "$exec_path" | grep -q "@executable_path/../Frameworks"; then
echo "Adding RPATH to $exec_path"
install_name_tool -add_rpath "@executable_path/../Frameworks" "$exec_path"
fi
# Se-sign the .app
codesign --force --deep --sign - "$bundle_path"
echo "Done!"

137
scripts/makeicns.sh Executable file
View File

@@ -0,0 +1,137 @@
#!/bin/sh
set -e
usage() {
cat <<EOF
$0: Make a macOS icns file from an SVG with rsvg-convert, ImageMagick and iconutil.
Follows the Big Sur+ icon template:
- 1024x1024 canvas with a rounded-square (squircle) background
- Icon artwork scaled to fit inside an 824x824 content area, centered
- Transparent padding outside the squircle so the Dock/Finder render it
like other first-party macOS apps.
usage: $0 [SVG [ICNS [ICONSET]]]
ARGUMENTS
SVG The SVG file to convert
Defaults to ./lan-mouse-gtk/resources/de.feschber.LanMouse.svg
ICNS The icns file to create
Defaults to ./target/icon.icns
ICONSET The iconset directory to create
Defaults to ./target/icon.iconset
This is just a temporary directory
EOF
}
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
usage
exit 0
fi
svg="${1:-./lan-mouse-gtk/resources/de.feschber.LanMouse.svg}"
icns="${2:-./target/icon.icns}"
iconset="${3:-./target/icon.iconset}"
set -u
workdir="$(dirname "$iconset")/icon-work"
rm -rf "$iconset" "$workdir"
mkdir -p "$iconset" "$workdir"
# Big Sur+ macOS icon template proportions (in a 1024 canvas):
# canvas = 1024
# squircle = 824 (the white rounded-square background, inset 100px)
# content = 560 (artwork inside the squircle, with generous margin)
# radius = 185 (~22.5% of the squircle, the characteristic curvature)
CANVAS=1024
SQUIRCLE=824
CONTENT=560
RADIUS=185
BG_COLOR="#FFFFFF"
SQUIRCLE_OFFSET=$(( (CANVAS - SQUIRCLE) / 2 ))
CONTENT_OFFSET=$(( (CANVAS - CONTENT) / 2 ))
# 1) Render the SVG to the content size at full fidelity.
# rsvg-convert handles our SVG correctly; ImageMagick sometimes crops it.
rsvg-convert -w "$CONTENT" -h "$CONTENT" "$svg" -o "$workdir/content.png"
# 2) Draw the rounded-square (squircle) background on a transparent canvas.
# The squircle is inset from the canvas edges (transparent padding), so the
# Dock/Finder render it at the same visual size as other first-party apps.
magick -size ${CANVAS}x${CANVAS} xc:none \
-fill "$BG_COLOR" \
-draw "roundrectangle ${SQUIRCLE_OFFSET},${SQUIRCLE_OFFSET} $((CANVAS-SQUIRCLE_OFFSET-1)),$((CANVAS-SQUIRCLE_OFFSET-1)) $RADIUS,$RADIUS" \
"$workdir/background.png"
# 3) Composite the artwork onto the background, centered inside the content area.
magick "$workdir/background.png" \
"$workdir/content.png" -geometry +${CONTENT_OFFSET}+${CONTENT_OFFSET} -composite \
-colorspace sRGB -type TrueColorAlpha PNG32:"$workdir/icon-1024.png"
# 4) Generate each iconset size from the master so all sizes share the same
# squircle proportions and look consistent at every resolution.
for size in 1024 512 256 128 64 32 16; do
magick "$workdir/icon-1024.png" -resize ${size}x${size} \
-colorspace sRGB -type TrueColorAlpha PNG32:"$workdir/${size}.png"
done
cp "$workdir/1024.png" "$iconset"/icon_512x512@2x.png
cp "$workdir/512.png" "$iconset"/icon_512x512.png
cp "$workdir/512.png" "$iconset"/icon_256x256@2x.png
cp "$workdir/256.png" "$iconset"/icon_256x256.png
cp "$workdir/256.png" "$iconset"/icon_128x128@2x.png
cp "$workdir/128.png" "$iconset"/icon_128x128.png
cp "$workdir/64.png" "$iconset"/icon_32x32@2x.png
cp "$workdir/32.png" "$iconset"/icon_32x32.png
cp "$workdir/32.png" "$iconset"/icon_16x16@2x.png
cp "$workdir/16.png" "$iconset"/icon_16x16.png
mkdir -p "$(dirname "$icns")"
# Menu bar template icon: flatten all RGB channels to 0 (black) while keeping
# alpha so the artwork reads as a clean silhouette. NSStatusBarButton tints
# template images to match the menu bar appearance in light and dark modes.
menubar_template="$(dirname "$icns")/menubar-template.png"
rsvg-convert -w 44 -h 44 "$svg" -o "$workdir/menubar-44.png"
magick "$workdir/menubar-44.png" -channel RGB -evaluate set 0 +channel \
"$menubar_template"
if ! iconutil -c icns "$iconset" -o "$icns"; then
if ! command -v perl >/dev/null 2>&1; then
echo "iconutil failed and perl is not available for the fallback icns writer" >&2
exit 1
fi
echo "iconutil rejected the iconset; writing icns directly" >&2
perl - "$icns" "$iconset" <<'PERL'
use strict;
use warnings;
my ($icns, $iconset) = @ARGV;
my @icons = (
[ 'icp4', "$iconset/icon_16x16.png" ],
[ 'ic11', "$iconset/icon_16x16\@2x.png" ],
[ 'icp5', "$iconset/icon_32x32.png" ],
[ 'ic12', "$iconset/icon_32x32\@2x.png" ],
[ 'ic07', "$iconset/icon_128x128.png" ],
[ 'ic13', "$iconset/icon_128x128\@2x.png" ],
[ 'ic08', "$iconset/icon_256x256.png" ],
[ 'ic14', "$iconset/icon_256x256\@2x.png" ],
[ 'ic09', "$iconset/icon_512x512.png" ],
[ 'ic10', "$iconset/icon_512x512\@2x.png" ],
);
my $body = '';
for my $icon (@icons) {
my ($type, $path) = @$icon;
open my $fh, '<:raw', $path or die "$path: $!";
local $/;
my $png = <$fh>;
$body .= $type . pack('N', length($png) + 8) . $png;
}
open my $out, '>:raw', $icns or die "$icns: $!";
print {$out} 'icns' . pack('N', length($body) + 8) . $body;
PERL
fi

View File

@@ -6,7 +6,7 @@ After=graphical-session.target
BindsTo=graphical-session.target BindsTo=graphical-session.target
[Service] [Service]
ExecStart=/usr/bin/lan-mouse --daemon ExecStart=/usr/bin/lan-mouse daemon
Restart=on-failure Restart=on-failure
[Install] [Install]

View File

@@ -8,10 +8,10 @@ use futures::StreamExt;
use input_capture::{ use input_capture::{
CaptureError, CaptureEvent, CaptureHandle, InputCapture, InputCaptureError, Position, CaptureError, CaptureEvent, CaptureHandle, InputCapture, InputCaptureError, Position,
}; };
use input_event::scancode; use input_event::{Event, KeyboardEvent, scancode};
use lan_mouse_proto::ProtoEvent; use lan_mouse_proto::ProtoEvent;
use local_channel::mpsc::{channel, Receiver, Sender}; use local_channel::mpsc::{Receiver, Sender, channel};
use tokio::task::{spawn_local, JoinHandle}; use tokio::task::{JoinHandle, spawn_local};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use crate::connect::LanMouseConnection; use crate::connect::LanMouseConnection;
@@ -49,7 +49,7 @@ pub(crate) enum CaptureType {
EnterOnly, EnterOnly,
} }
#[derive(Clone, Copy, Debug)] #[derive(Clone, Debug)]
enum CaptureRequest { enum CaptureRequest {
/// capture must release the mouse /// capture must release the mouse
Release, Release,
@@ -59,6 +59,8 @@ enum CaptureRequest {
Destroy(CaptureHandle), Destroy(CaptureHandle),
/// reenable input capture /// reenable input capture
Reenable, Reenable,
/// set release bind
SetReleaseBind(Vec<scancode::Linux>),
} }
impl Capture { impl Capture {
@@ -131,6 +133,10 @@ impl Capture {
pub(crate) async fn event(&mut self) -> ICaptureEvent { pub(crate) async fn event(&mut self) -> ICaptureEvent {
self.event_rx.recv().await.expect("channel closed") self.event_rx.recv().await.expect("channel closed")
} }
pub(crate) fn set_release_bind(&mut self, bind: Vec<scancode::Linux>) {
let _ = self.request_tx.send(CaptureRequest::SetReleaseBind(bind));
}
} }
/// debounce a statement `$st`, i.e. the statement is executed only if the /// debounce a statement `$st`, i.e. the statement is executed only if the
@@ -205,6 +211,9 @@ impl CaptureTask {
CaptureRequest::Create(h, p, t) => self.add_capture(h, p, t), CaptureRequest::Create(h, p, t) => self.add_capture(h, p, t),
CaptureRequest::Destroy(h) => self.remove_capture(h), CaptureRequest::Destroy(h) => self.remove_capture(h),
CaptureRequest::Release => { /* nothing to do */ } CaptureRequest::Release => { /* nothing to do */ }
CaptureRequest::SetReleaseBind(bind) => {
self.release_bind.borrow_mut().clone_from(&bind);
}
}, },
_ = self.cancellation_token.cancelled() => return, _ = self.cancellation_token.cancelled() => return,
} }
@@ -295,6 +304,9 @@ impl CaptureTask {
self.remove_capture(h); self.remove_capture(h);
capture.destroy(h).await?; capture.destroy(h).await?;
} }
CaptureRequest::SetReleaseBind(bind) => {
self.release_bind.borrow_mut().clone_from(&bind);
}
}, },
_ = self.cancellation_token.cancelled() => break, _ = self.cancellation_token.cancelled() => break,
} }
@@ -362,7 +374,48 @@ impl CaptureTask {
} }
async fn release_capture(&mut self, capture: &mut InputCapture) -> Result<(), CaptureError> { async fn release_capture(&mut self, capture: &mut InputCapture) -> Result<(), CaptureError> {
self.active_client.take(); // If we have an active client, notify them we're leaving
if let Some(handle) = self.active_client.take() {
// 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
// modifiers) leaves the peer with phantom held modifiers:
// the down events were forwarded while capture was active,
// but the matching up events arrive after the local tap
// flips to passthrough and never reach the peer. The peer
// then runs every subsequent keystroke through those held
// mods until its watchdog times out (1+ s) or our Leave
// arrives — and Leave can be lost over UDP/DTLS.
for key in capture.take_pressed_keys() {
let key_up = ProtoEvent::Input(Event::Keyboard(KeyboardEvent::Key {
time: 0,
key: key as u32,
state: 0,
}));
if let Err(e) = self.conn.send(key_up, handle).await {
log::warn!("failed to send key-up to client {handle}: {e}");
}
}
// Reset the modifier mask too. The peer's input-emulation
// layer keeps a separate XKB-style modifier state that's
// updated by KeyboardEvent::Modifiers, distinct from the
// pressed_keys set drained above. Without this, an
// already-locked CapsLock would survive the release.
let mods_zero = ProtoEvent::Input(Event::Keyboard(KeyboardEvent::Modifiers {
depressed: 0,
latched: 0,
locked: 0,
group: 0,
}));
if let Err(e) = self.conn.send(mods_zero, handle).await {
log::warn!("failed to reset modifiers on client {handle}: {e}");
}
log::info!("sending Leave event to client {handle}");
if let Err(e) = self.conn.send(ProtoEvent::Leave(0), handle).await {
log::warn!("failed to send Leave to client {handle}: {e}");
}
}
capture.release().await capture.release().await
} }
} }

View File

@@ -1,12 +1,16 @@
use crate::config::Config; use crate::config::Config;
use clap::Args;
use futures::StreamExt; use futures::StreamExt;
use input_capture::{self, CaptureError, CaptureEvent, InputCapture, InputCaptureError, Position}; use input_capture::{self, CaptureError, CaptureEvent, InputCapture, InputCaptureError, Position};
use input_event::{Event, KeyboardEvent}; use input_event::{Event, KeyboardEvent};
pub async fn run(config: Config) -> Result<(), InputCaptureError> { #[derive(Args, Clone, Debug, Eq, PartialEq)]
pub struct TestCaptureArgs {}
pub async fn run(config: Config, _args: TestCaptureArgs) -> Result<(), InputCaptureError> {
log::info!("running input capture test"); log::info!("running input capture test");
log::info!("creating input capture"); log::info!("creating input capture");
let backend = config.capture_backend.map(|b| b.into()); let backend = config.capture_backend().map(|b| b.into());
loop { loop {
let mut input_capture = InputCapture::new(backend).await?; let mut input_capture = InputCapture::new(backend).await?;
log::info!("creating clients"); log::info!("creating clients");

View File

@@ -9,12 +9,42 @@ use slab::Slab;
use lan_mouse_ipc::{ClientConfig, ClientHandle, ClientState, Position}; use lan_mouse_ipc::{ClientConfig, ClientHandle, ClientState, Position};
use crate::config::ConfigClient;
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct ClientManager { pub struct ClientManager {
clients: Rc<RefCell<Slab<(ClientConfig, ClientState)>>>, clients: Rc<RefCell<Slab<(ClientConfig, ClientState)>>>,
} }
impl ClientManager { impl ClientManager {
/// get all clients
pub fn clients(&self) -> Vec<(ClientConfig, ClientState)> {
self.clients
.borrow()
.iter()
.map(|(_, c)| c.clone())
.collect::<Vec<_>>()
}
pub fn add_with_config(&self, config_client: ConfigClient) -> ClientHandle {
let config = ClientConfig {
hostname: config_client.hostname,
fix_ips: config_client.ips.into_iter().collect(),
port: config_client.port,
pos: config_client.pos,
cmd: config_client.enter_hook,
};
let state = ClientState {
active: config_client.active,
ips: HashSet::from_iter(config.fix_ips.iter().cloned()),
..Default::default()
};
let handle = self.add_client();
self.set_config(handle, config);
self.set_state(handle, state);
handle
}
/// add a new client to this manager /// add a new client to this manager
pub fn add_client(&self) -> ClientHandle { pub fn add_client(&self) -> ClientHandle {
self.clients.borrow_mut().insert(Default::default()) as ClientHandle self.clients.borrow_mut().insert(Default::default()) as ClientHandle
@@ -199,6 +229,13 @@ impl ClientManager {
} }
} }
/// update the enter hook command of the client
pub(crate) fn set_enter_hook(&self, handle: ClientHandle, enter_hook: Option<String>) {
if let Some((c, _s)) = self.clients.borrow_mut().get_mut(handle as usize) {
c.cmd = enter_hook;
}
}
/// set resolving status of the client /// set resolving status of the client
pub(crate) fn set_resolving(&self, handle: ClientHandle, status: bool) { pub(crate) fn set_resolving(&self, handle: ClientHandle, status: bool) {
if let Some((_, s)) = self.clients.borrow_mut().get_mut(handle as usize) { if let Some((_, s)) = self.clients.borrow_mut().get_mut(handle as usize) {
@@ -214,6 +251,15 @@ impl ClientManager {
.and_then(|(c, _)| c.cmd.clone()) .and_then(|(c, _)| c.cmd.clone())
} }
/// returns all clients that are currently registered
pub(crate) fn registered_clients(&self) -> Vec<ClientHandle> {
self.clients
.borrow()
.iter()
.map(|(h, _)| h as ClientHandle)
.collect()
}
/// returns all clients that are currently active /// returns all clients that are currently active
pub(crate) fn active_clients(&self) -> Vec<ClientHandle> { pub(crate) fn active_clients(&self) -> Vec<ClientHandle> {
self.clients self.clients

View File

@@ -1,16 +1,22 @@
use clap::{Parser, ValueEnum}; use crate::capture_test::TestCaptureArgs;
use crate::emulation_test::TestEmulationArgs;
use clap::{Parser, Subcommand, ValueEnum};
use notify::{EventKind, RecommendedWatcher, Watcher};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::env::{self, VarError}; use std::env::{self, VarError};
use std::fmt::Display; use std::fmt::Display;
use std::fs; use std::fs::{self, File};
use std::io::Write;
use std::net::IpAddr; use std::net::IpAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::{collections::HashSet, io}; use std::{collections::HashSet, io};
use thiserror::Error; use thiserror::Error;
use toml; use toml;
use toml_edit::{self, DocumentMut};
use lan_mouse_ipc::{Position, DEFAULT_PORT}; use lan_mouse_cli::CliArgs;
use lan_mouse_ipc::{DEFAULT_PORT, Position};
use input_event::scancode::{ use input_event::scancode::{
self, self,
@@ -21,34 +27,50 @@ use shadow_rs::shadow;
shadow!(build); shadow!(build);
#[derive(Serialize, Deserialize, Debug)] const CONFIG_FILE_NAME: &str = "config.toml";
pub struct ConfigToml { const CERT_FILE_NAME: &str = "lan-mouse.pem";
pub capture_backend: Option<CaptureBackend>,
pub emulation_backend: Option<EmulationBackend>, fn default_path() -> Result<PathBuf, VarError> {
pub port: Option<u16>, #[cfg(unix)]
pub frontend: Option<Frontend>, let default_path = {
pub release_bind: Option<Vec<scancode::Linux>>, let xdg_config_home =
pub cert_path: Option<PathBuf>, env::var("XDG_CONFIG_HOME").unwrap_or(format!("{}/.config", env::var("HOME")?));
pub left: Option<TomlClient>, format!("{xdg_config_home}/lan-mouse/")
pub right: Option<TomlClient>, };
pub top: Option<TomlClient>,
pub bottom: Option<TomlClient>, #[cfg(not(unix))]
pub authorized_fingerprints: Option<HashMap<String, String>>, let default_path = {
let app_data =
env::var("LOCALAPPDATA").unwrap_or(format!("{}/.config", env::var("USERPROFILE")?));
format!("{app_data}\\lan-mouse\\")
};
Ok(PathBuf::from(default_path))
} }
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct TomlClient { struct ConfigToml {
pub capture_backend: Option<CaptureBackend>, capture_backend: Option<CaptureBackend>,
pub hostname: Option<String>, emulation_backend: Option<EmulationBackend>,
pub host_name: Option<String>, port: Option<u16>,
pub ips: Option<Vec<IpAddr>>, release_bind: Option<Vec<scancode::Linux>>,
pub port: Option<u16>, cert_path: Option<PathBuf>,
pub activate_on_startup: Option<bool>, clients: Option<Vec<TomlClient>>,
pub enter_hook: Option<String>, authorized_fingerprints: Option<HashMap<String, String>>,
}
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
struct TomlClient {
hostname: Option<String>,
host_name: Option<String>,
ips: Option<Vec<IpAddr>>,
port: Option<u16>,
position: Option<Position>,
activate_on_startup: Option<bool>,
enter_hook: Option<String>,
} }
impl ConfigToml { impl ConfigToml {
pub fn new(path: &Path) -> Result<ConfigToml, ConfigError> { fn new(path: &Path) -> Result<ConfigToml, ConfigError> {
let config = fs::read_to_string(path)?; let config = fs::read_to_string(path)?;
Ok(toml::from_str::<_>(&config)?) Ok(toml::from_str::<_>(&config)?)
} }
@@ -56,30 +78,14 @@ impl ConfigToml {
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version=build::CLAP_LONG_VERSION, about, long_about = None)] #[command(author, version=build::CLAP_LONG_VERSION, about, long_about = None)]
struct CliArgs { struct Args {
/// the listen port for lan-mouse /// the listen port for lan-mouse
#[arg(short, long)] #[arg(short, long)]
port: Option<u16>, port: Option<u16>,
/// the frontend to use [cli | gtk]
#[arg(short, long)]
frontend: Option<Frontend>,
/// non-default config file location /// non-default config file location
#[arg(short, long)] #[arg(short, long)]
config: Option<String>, config: Option<PathBuf>,
/// run only the service as a daemon without the frontend
#[arg(short, long)]
daemon: bool,
/// test input capture
#[arg(long)]
test_capture: bool,
/// test input emulation
#[arg(long)]
test_emulation: bool,
/// capture backend override /// capture backend override
#[arg(long)] #[arg(long)]
@@ -92,6 +98,22 @@ struct CliArgs {
/// path to non-default certificate location /// path to non-default certificate location
#[arg(long)] #[arg(long)]
cert_path: Option<PathBuf>, cert_path: Option<PathBuf>,
/// subcommands
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Clone, Debug, Eq, PartialEq)]
pub enum Command {
/// test input emulation
TestEmulation(TestEmulationArgs),
/// test input capture
TestCapture(TestCaptureArgs),
/// Lan Mouse commandline interface
Cli(CliArgs),
/// run in daemon mode
Daemon,
} }
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
@@ -215,50 +237,22 @@ impl Display for EmulationBackend {
} }
} }
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize, ValueEnum)]
pub enum Frontend {
#[serde(rename = "gtk")]
Gtk,
#[serde(rename = "cli")]
Cli,
}
impl Default for Frontend {
fn default() -> Self {
if cfg!(feature = "gtk") {
Self::Gtk
} else {
Self::Cli
}
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct Config { pub struct Config {
/// the path to the configuration file used /// command line arguments
pub path: PathBuf, args: Args,
/// public key fingerprints authorized for connection /// path to the certificate file used
pub authorized_fingerprints: HashMap<String, String>, cert_path: PathBuf,
/// optional input-capture backend override /// path to the config file used
pub capture_backend: Option<CaptureBackend>, config_path: PathBuf,
/// optional input-emulation backend override /// path to config directory (parent of above)
pub emulation_backend: Option<EmulationBackend>, config_dir: PathBuf,
/// the frontend to use /// the (optional) toml config and it's path
pub frontend: Frontend, config_toml: Option<ConfigToml>,
/// the port to use (initially) // filesystem watcher
pub port: u16, watcher: notify::RecommendedWatcher,
/// list of clients // channel for filesystem events
pub clients: Vec<(TomlClient, Position)>, watch_rx: tokio::sync::mpsc::Receiver<Result<notify::Event, notify::Error>>,
/// whether or not to run as a daemon
pub daemon: bool,
/// configured release bind
pub release_bind: Vec<scancode::Linux>,
/// test capture instead of running the app
pub test_capture: bool,
/// test emulation instead of running the app
pub test_emulation: bool,
/// path to the tls certificate to use
pub cert_path: PathBuf,
} }
pub struct ConfigClient { pub struct ConfigClient {
@@ -270,6 +264,52 @@ pub struct ConfigClient {
pub enter_hook: Option<String>, pub enter_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 hostname = toml.hostname;
let ips = HashSet::from_iter(toml.ips.into_iter().flatten());
let port = toml.port.unwrap_or(DEFAULT_PORT);
let pos = toml.position.unwrap_or_default();
Self {
ips,
hostname,
port,
pos,
active,
enter_hook,
}
}
}
impl From<ConfigClient> for TomlClient {
fn from(client: ConfigClient) -> Self {
let hostname = client.hostname;
let host_name = None;
let mut ips = client.ips.into_iter().collect::<Vec<_>>();
ips.sort();
let ips = Some(ips);
let port = if client.port == DEFAULT_PORT {
None
} else {
Some(client.port)
};
let position = Some(client.pos);
let activate_on_startup = if client.active { Some(true) } else { None };
let enter_hook = client.enter_hook;
Self {
hostname,
host_name,
ips,
port,
position,
activate_on_startup,
enter_hook,
}
}
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ConfigError { pub enum ConfigError {
#[error(transparent)] #[error(transparent)]
@@ -278,6 +318,8 @@ pub enum ConfigError {
Io(#[from] io::Error), Io(#[from] io::Error),
#[error(transparent)] #[error(transparent)]
Var(#[from] VarError), Var(#[from] VarError),
#[error(transparent)]
Watcher(#[from] notify::Error),
} }
const DEFAULT_RELEASE_KEYS: [scancode::Linux; 4] = const DEFAULT_RELEASE_KEYS: [scancode::Linux; 4] =
@@ -285,134 +327,234 @@ const DEFAULT_RELEASE_KEYS: [scancode::Linux; 4] =
impl Config { impl Config {
pub fn new() -> Result<Self, ConfigError> { pub fn new() -> Result<Self, ConfigError> {
let args = CliArgs::parse(); let args = Args::parse();
const CONFIG_FILE_NAME: &str = "config.toml";
const CERT_FILE_NAME: &str = "lan-mouse.pem";
#[cfg(unix)]
let config_path = {
let xdg_config_home =
env::var("XDG_CONFIG_HOME").unwrap_or(format!("{}/.config", env::var("HOME")?));
format!("{xdg_config_home}/lan-mouse/")
};
#[cfg(not(unix))]
let config_path = {
let app_data =
env::var("LOCALAPPDATA").unwrap_or(format!("{}/.config", env::var("USERPROFILE")?));
format!("{app_data}\\lan-mouse\\")
};
let config_path = PathBuf::from(config_path);
let config_file = config_path.join(CONFIG_FILE_NAME);
// --config <file> overrules default location // --config <file> overrules default location
let config_file = args.config.map(PathBuf::from).unwrap_or(config_file); let config_path = args
.config
.clone()
.unwrap_or(default_path()?.join(CONFIG_FILE_NAME));
let config_dir = config_path
.parent()
.expect("config directory")
.to_path_buf();
let mut config_toml = match ConfigToml::new(&config_file) { // Ensure the config directory exists and write a default config file
// if none is present. Runs on every Config::new(), regardless of which
// entry path (GUI main, spawned daemon, CLI, test commands) we're on,
// so a fresh Mac never hits "No such file or directory" on config.toml
// and notify::Watcher (which requires the dir to exist on macOS
// FSEvents and some Linux backends) has a concrete path to watch.
fs::create_dir_all(&config_dir)?;
if !config_path.exists() {
let default_toml = toml::to_string_pretty(&ConfigToml::default())
.expect("default ConfigToml serialization cannot fail");
fs::write(&config_path, default_toml)?;
}
let config_toml = match ConfigToml::new(&config_path) {
Err(e) => { Err(e) => {
log::warn!("{config_file:?}: {e}"); log::warn!("{config_path:?}: {e}");
log::warn!("Continuing without config file ..."); log::warn!("Continuing without config file ...");
None None
} }
Ok(c) => Some(c), Ok(c) => Some(c),
}; };
let frontend_arg = args.frontend; // --cert-path <file> overrules default location
let frontend_cfg = config_toml.as_ref().and_then(|c| c.frontend);
let frontend = frontend_arg.or(frontend_cfg).unwrap_or_default();
let port = args
.port
.or(config_toml.as_ref().and_then(|c| c.port))
.unwrap_or(DEFAULT_PORT);
log::debug!("{config_toml:?}");
let release_bind = config_toml
.as_ref()
.and_then(|c| c.release_bind.clone())
.unwrap_or(Vec::from_iter(DEFAULT_RELEASE_KEYS.iter().cloned()));
let capture_backend = args
.capture_backend
.or(config_toml.as_ref().and_then(|c| c.capture_backend));
let emulation_backend = args
.emulation_backend
.or(config_toml.as_ref().and_then(|c| c.emulation_backend));
let cert_path = args let cert_path = args
.cert_path .cert_path
.clone()
.or(config_toml.as_ref().and_then(|c| c.cert_path.clone())) .or(config_toml.as_ref().and_then(|c| c.cert_path.clone()))
.unwrap_or(config_path.join(CERT_FILE_NAME)); .unwrap_or(default_path()?.join(CERT_FILE_NAME));
let authorized_fingerprints = config_toml let (tx, watch_rx) = tokio::sync::mpsc::channel(16);
.as_mut() let watcher = RecommendedWatcher::new(
.and_then(|c| std::mem::take(&mut c.authorized_fingerprints)) move |res| {
.unwrap_or_default(); let _ = tx.blocking_send(res);
},
notify::Config::default(),
)?;
let mut config = Config {
args,
cert_path,
config_path,
config_dir,
config_toml,
watcher,
watch_rx,
};
config.watch()?;
Ok(config)
}
let mut clients: Vec<(TomlClient, Position)> = vec![]; fn watch(&mut self) -> Result<(), notify::Error> {
self.watcher
.watch(&self.config_dir, notify::RecursiveMode::NonRecursive)?;
Ok(())
}
if let Some(config_toml) = config_toml { fn unwatch(&mut self) -> Result<(), notify::Error> {
if let Some(c) = config_toml.right { self.watcher.unwatch(&self.config_dir)?;
clients.push((c, Position::Right)) Ok(())
} }
if let Some(c) = config_toml.left {
clients.push((c, Position::Left)) pub async fn changed(&mut self) -> Result<(), notify::Error> {
} loop {
if let Some(c) = config_toml.top { let event = self.watch_rx.recv().await.expect("channel closed");
clients.push((c, Position::Top)) let event = event.expect("filesystem event");
} if event.paths.contains(&self.config_path)
if let Some(c) = config_toml.bottom { && matches!(
clients.push((c, Position::Bottom)) event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
)
&& self.read_from_disk()?
{
return Ok(());
} }
} }
let daemon = args.daemon;
let test_capture = args.test_capture;
let test_emulation = args.test_emulation;
Ok(Config {
path: config_path,
authorized_fingerprints,
capture_backend,
emulation_backend,
daemon,
frontend,
clients,
port,
release_bind,
test_capture,
test_emulation,
cert_path,
})
} }
pub fn get_clients(&self) -> Vec<ConfigClient> { /// the command to run
self.clients pub fn command(&self) -> Option<Command> {
.iter() self.args.command.clone()
.map(|(c, pos)| { }
let port = c.port.unwrap_or(DEFAULT_PORT);
let ips: HashSet<IpAddr> = if let Some(ips) = c.ips.as_ref() { pub fn config_path(&self) -> &Path {
HashSet::from_iter(ips.iter().cloned()) &self.config_path
} else { }
HashSet::new()
}; /// public key fingerprints authorized for connection
let hostname = match &c.hostname { pub fn authorized_fingerprints(&self) -> HashMap<String, String> {
Some(h) => Some(h.clone()), self.config_toml
None => c.host_name.clone(), .as_ref()
}; .and_then(|c| c.authorized_fingerprints.clone())
let active = c.activate_on_startup.unwrap_or(false); .unwrap_or_default()
let enter_hook = c.enter_hook.clone(); }
ConfigClient {
ips, /// path to certificate
hostname, pub fn cert_path(&self) -> &Path {
port, &self.cert_path
pos: *pos, }
active,
enter_hook, /// optional input-capture backend override
} pub fn capture_backend(&self) -> Option<CaptureBackend> {
}) self.args
.capture_backend
.or(self.config_toml.as_ref().and_then(|c| c.capture_backend))
}
/// optional input-emulation backend override
pub fn emulation_backend(&self) -> Option<EmulationBackend> {
self.args
.emulation_backend
.or(self.config_toml.as_ref().and_then(|c| c.emulation_backend))
}
/// the port to use (initially)
pub fn port(&self) -> u16 {
self.args
.port
.or(self.config_toml.as_ref().and_then(|c| c.port))
.unwrap_or(DEFAULT_PORT)
}
/// list of configured clients
pub fn clients(&self) -> Vec<ConfigClient> {
self.config_toml
.as_ref()
.map(|c| c.clients.clone())
.unwrap_or_default()
.into_iter()
.flatten()
.map(From::<TomlClient>::from)
.collect() .collect()
} }
/// release bind for returning control to the host
pub fn release_bind(&self) -> Vec<scancode::Linux> {
self.config_toml
.as_ref()
.and_then(|c| c.release_bind.clone())
.unwrap_or(Vec::from_iter(DEFAULT_RELEASE_KEYS.iter().cloned()))
}
/// set configured clients
pub fn set_clients(&mut self, clients: Vec<ConfigClient>) {
if clients.is_empty() {
return;
}
if self.config_toml.is_none() {
self.config_toml = Some(Default::default());
}
self.config_toml.as_mut().expect("config").clients =
Some(clients.into_iter().map(|c| c.into()).collect::<Vec<_>>());
}
/// set authorized keys
pub fn set_authorized_keys(&mut self, fingerprints: HashMap<String, String>) {
if self.config_toml.is_none() {
self.config_toml = Some(Default::default());
}
self.config_toml
.as_mut()
.expect("config")
.authorized_fingerprints = Some(fingerprints);
}
pub fn read_from_disk(&mut self) -> Result<bool, io::Error> {
log::info!("reading config from {:?}", &self.config_path);
let current_config = fs::read_to_string(&self.config_path)?;
let current_config = match current_config.parse::<DocumentMut>() {
Ok(c) => c,
Err(e) => {
log::warn!("{:?} {e}", self.config_path());
return Ok(false);
}
};
let mut changed = false;
match toml_edit::de::from_document::<ConfigToml>(current_config) {
Ok(current_config) => {
changed = self
.config_toml
.as_ref()
.is_none_or(|c| c != &current_config);
self.config_toml.replace(current_config);
}
Err(e) => log::warn!("{:?} {e}", self.config_path()),
};
Ok(changed)
}
pub fn write_back(&mut self) -> Result<(), io::Error> {
log::info!("writing config to {:?}", &self.config_path);
/* the new config */
let new_config = self.config_toml.clone().unwrap_or_default();
let new_config = toml_edit::ser::to_string_pretty(&new_config).expect("config");
/*
* TODO merge with current config file to preserve comments
* => eventually we might want to split this up into clients configured
* via the config file and clients managed through the GUI / frontend.
* The latter should be saved to $XDG_DATA_HOME instead of $XDG_CONFIG_HOME,
* and clients configured through .config could be made permanent.
* For now we just override the config file.
*/
let _ = self.unwatch();
/* write new config to file */
if let Some(p) = self.config_path().parent() {
fs::create_dir_all(p)?;
}
{
let mut f = File::create(self.config_path())?;
f.write_all(new_config.as_bytes())?;
f.sync_all()?;
}
let _ = self.watch();
Ok(())
}
} }

View File

@@ -1,7 +1,7 @@
use crate::client::ClientManager; use crate::client::ClientManager;
use lan_mouse_ipc::{ClientHandle, DEFAULT_PORT}; use lan_mouse_ipc::{ClientHandle, DEFAULT_PORT};
use lan_mouse_proto::{ProtoEvent, MAX_EVENT_SIZE}; use lan_mouse_proto::{MAX_EVENT_SIZE, ProtoEvent};
use local_channel::mpsc::{channel, Receiver, Sender}; use local_channel::mpsc::{Receiver, Sender, channel};
use std::{ use std::{
cell::RefCell, cell::RefCell,
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
@@ -15,7 +15,7 @@ use thiserror::Error;
use tokio::{ use tokio::{
net::UdpSocket, net::UdpSocket,
sync::Mutex, sync::Mutex,
task::{spawn_local, JoinSet}, task::{JoinSet, spawn_local},
}; };
use webrtc_dtls::{ use webrtc_dtls::{
config::{Config, ExtendedMasterSecretType}, config::{Config, ExtendedMasterSecretType},
@@ -223,14 +223,18 @@ async fn ping_pong(
) { ) {
loop { loop {
let (buf, len) = ProtoEvent::Ping.into(); let (buf, len) = ProtoEvent::Ping.into();
if let Err(e) = conn.send(&buf[..len]).await {
log::warn!("{addr}: send error `{e}`, closing connection");
let _ = conn.close().await;
break;
}
log::trace!("PING >->->->->- {addr}");
tokio::time::sleep(Duration::from_millis(500)).await; // send 4 pings, at least one must be answered
for _ in 0..4 {
if let Err(e) = conn.send(&buf[..len]).await {
log::warn!("{addr}: send error `{e}`, closing connection");
let _ = conn.close().await;
break;
}
log::trace!("PING >->->->->- {addr}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
if !ping_response.borrow_mut().remove(&addr) { if !ping_response.borrow_mut().remove(&addr) {
log::warn!("{addr} did not respond, closing connection"); log::warn!("{addr} did not respond, closing connection");

View File

@@ -1,9 +1,9 @@
use std::net::IpAddr; use std::{collections::HashMap, net::IpAddr};
use local_channel::mpsc::{channel, Receiver, Sender}; use local_channel::mpsc::{Receiver, Sender, channel};
use tokio::task::{spawn_local, JoinHandle}; use tokio::task::{JoinHandle, spawn_local};
use hickory_resolver::{error::ResolveError, TokioAsyncResolver}; use hickory_resolver::{ResolveError, TokioResolver};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use lan_mouse_ipc::ClientHandle; use lan_mouse_ipc::ClientHandle;
@@ -26,19 +26,21 @@ pub(crate) enum DnsEvent {
} }
struct DnsTask { struct DnsTask {
resolver: TokioAsyncResolver, resolver: TokioResolver,
request_rx: Receiver<DnsRequest>, request_rx: Receiver<DnsRequest>,
event_tx: Sender<DnsEvent>, event_tx: Sender<DnsEvent>,
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
active_tasks: HashMap<ClientHandle, JoinHandle<()>>,
} }
impl DnsResolver { impl DnsResolver {
pub(crate) fn new() -> Result<Self, ResolveError> { pub(crate) fn new() -> Result<Self, ResolveError> {
let resolver = TokioAsyncResolver::tokio_from_system_conf()?; let resolver = TokioResolver::builder_tokio()?.build();
let (request_tx, request_rx) = channel(); let (request_tx, request_rx) = channel();
let (event_tx, event_rx) = channel(); let (event_tx, event_rx) = channel();
let cancellation_token = CancellationToken::new(); let cancellation_token = CancellationToken::new();
let dns_task = DnsTask { let dns_task = DnsTask {
active_tasks: Default::default(),
resolver, resolver,
request_rx, request_rx,
event_tx, event_tx,
@@ -81,6 +83,14 @@ impl DnsTask {
while let Some(dns_request) = self.request_rx.recv().await { while let Some(dns_request) = self.request_rx.recv().await {
let DnsRequest { handle, hostname } = dns_request; let DnsRequest { handle, hostname } = dns_request;
/* abort previous dns task */
let previous_task = self.active_tasks.remove(&handle);
if let Some(task) = previous_task {
if !task.is_finished() {
task.abort();
}
}
self.event_tx self.event_tx
.send(DnsEvent::Resolving(handle)) .send(DnsEvent::Resolving(handle))
.expect("channel closed"); .expect("channel closed");
@@ -90,7 +100,7 @@ impl DnsTask {
let resolver = self.resolver.clone(); let resolver = self.resolver.clone();
let cancellation_token = self.cancellation_token.clone(); let cancellation_token = self.cancellation_token.clone();
tokio::task::spawn_local(async move { let task = tokio::task::spawn_local(async move {
tokio::select! { tokio::select! {
ips = resolver.lookup_ip(&hostname) => { ips = resolver.lookup_ip(&hostname) => {
let ips = ips.map(|ips| ips.iter().collect::<Vec<_>>()); let ips = ips.map(|ips| ips.iter().collect::<Vec<_>>());
@@ -101,6 +111,7 @@ impl DnsTask {
_ = cancellation_token.cancelled() => {}, _ = cancellation_token.cancelled() => {},
} }
}); });
self.active_tasks.insert(handle, task);
} }
} }
} }

View File

@@ -1,9 +1,9 @@
use crate::listen::{LanMouseListener, ListenerCreationError}; use crate::listen::{LanMouseListener, ListenEvent, ListenerCreationError};
use futures::StreamExt; use futures::StreamExt;
use input_emulation::{EmulationHandle, InputEmulation, InputEmulationError}; use input_emulation::{EmulationHandle, InputEmulation, InputEmulationError};
use input_event::Event; use input_event::Event;
use lan_mouse_proto::{Position, ProtoEvent}; use lan_mouse_proto::{Position, ProtoEvent};
use local_channel::mpsc::{channel, Receiver, Sender}; use local_channel::mpsc::{Receiver, Sender, channel};
use std::{ use std::{
cell::Cell, cell::Cell,
collections::HashMap, collections::HashMap,
@@ -13,7 +13,7 @@ use std::{
}; };
use tokio::{ use tokio::{
select, select,
task::{spawn_local, JoinHandle}, task::{JoinHandle, spawn_local},
}; };
/// emulation handling events received from a listener /// emulation handling events received from a listener
@@ -24,8 +24,15 @@ pub(crate) struct Emulation {
} }
pub(crate) enum EmulationEvent { pub(crate) enum EmulationEvent {
/// new connection
Connected { Connected {
addr: SocketAddr,
fingerprint: String,
},
ConnectionAttempt {
fingerprint: String,
},
/// new connection
Entered {
/// address of the connection /// address of the connection
addr: SocketAddr, addr: SocketAddr,
/// position of the connection /// position of the connection
@@ -34,7 +41,9 @@ pub(crate) enum EmulationEvent {
fingerprint: String, fingerprint: String,
}, },
/// connection closed /// connection closed
Disconnected { addr: SocketAddr }, Disconnected {
addr: SocketAddr,
},
/// the port of the listener has changed /// the port of the listener has changed
PortChanged(Result<u16, ListenerCreationError>), PortChanged(Result<u16, ListenerCreationError>),
/// emulation was disabled /// emulation was disabled
@@ -119,33 +128,42 @@ impl ListenTask {
async fn run(mut self) { async fn run(mut self) {
let mut interval = tokio::time::interval(Duration::from_secs(5)); let mut interval = tokio::time::interval(Duration::from_secs(5));
let mut last_response = HashMap::new(); let mut last_response = HashMap::new();
let mut rejected_connections = HashMap::new();
loop { loop {
select! { select! {
e = self.listener.next() => { e = self.listener.next() => {match e {
let (event, addr) = match e { Some(ListenEvent::Msg { event, addr }) => {
Some(e) => e, log::trace!("{event} <-<-<-<-<- {addr}");
None => break, last_response.insert(addr, Instant::now());
}; match event {
log::trace!("{event} <-<-<-<-<- {addr}"); ProtoEvent::Enter(pos) => {
last_response.insert(addr, Instant::now()); if let Some(fingerprint) = self.listener.get_certificate_fingerprint(addr).await {
match event { log::info!("releasing capture: {addr} entered this device");
ProtoEvent::Enter(pos) => { self.event_tx.send(EmulationEvent::ReleaseNotify).expect("channel closed");
if let Some(fingerprint) = self.listener.get_certificate_fingerprint(addr).await { self.listener.reply(addr, ProtoEvent::Ack(0)).await;
log::info!("releasing capture: {addr} entered this device"); self.event_tx.send(EmulationEvent::Entered{addr, pos: to_ipc_pos(pos), fingerprint}).expect("channel closed");
self.event_tx.send(EmulationEvent::ReleaseNotify).expect("channel closed"); }
self.listener.reply(addr, ProtoEvent::Ack(0)).await;
self.event_tx.send(EmulationEvent::Connected{addr, pos: to_ipc_pos(pos), fingerprint}).expect("channel closed");
} }
ProtoEvent::Leave(_) => {
self.emulation_proxy.remove(addr);
self.listener.reply(addr, ProtoEvent::Ack(0)).await;
}
ProtoEvent::Input(event) => self.emulation_proxy.consume(event, addr),
ProtoEvent::Ping => self.listener.reply(addr, ProtoEvent::Pong(self.emulation_proxy.emulation_active.get())).await,
_ => {}
} }
ProtoEvent::Leave(_) => {
self.emulation_proxy.remove(addr);
self.listener.reply(addr, ProtoEvent::Ack(0)).await;
}
ProtoEvent::Input(event) => self.emulation_proxy.consume(event, addr),
ProtoEvent::Ping => self.listener.reply(addr, ProtoEvent::Pong(self.emulation_proxy.emulation_active.get())).await,
_ => {}
} }
} Some(ListenEvent::Accept { addr, fingerprint }) => {
self.event_tx.send(EmulationEvent::Connected { addr, fingerprint }).expect("channel closed");
}
Some(ListenEvent::Rejected { fingerprint }) => {
if rejected_connections.insert(fingerprint.clone(), Instant::now())
.is_none_or(|i| i.elapsed() >= Duration::from_secs(2)) {
self.event_tx.send(EmulationEvent::ConnectionAttempt { fingerprint }).expect("channel closed");
}
}
None => break
}}
event = self.emulation_proxy.event() => { event = self.emulation_proxy.event() => {
self.event_tx.send(event).expect("channel closed"); self.event_tx.send(event).expect("channel closed");
} }

View File

@@ -1,4 +1,5 @@
use crate::config::Config; use crate::config::Config;
use clap::Args;
use input_emulation::{InputEmulation, InputEmulationError}; use input_emulation::{InputEmulation, InputEmulationError};
use input_event::{Event, PointerEvent}; use input_event::{Event, PointerEvent};
use std::f64::consts::PI; use std::f64::consts::PI;
@@ -7,10 +8,20 @@ use std::time::{Duration, Instant};
const FREQUENCY_HZ: f64 = 1.0; const FREQUENCY_HZ: f64 = 1.0;
const RADIUS: f64 = 100.0; const RADIUS: f64 = 100.0;
pub async fn run(config: Config) -> Result<(), InputEmulationError> { #[derive(Args, Clone, Debug, Eq, PartialEq)]
pub struct TestEmulationArgs {
#[arg(long)]
mouse: bool,
#[arg(long)]
keyboard: bool,
#[arg(long)]
scroll: bool,
}
pub async fn run(config: Config, _args: TestEmulationArgs) -> Result<(), InputEmulationError> {
log::info!("running input emulation test"); log::info!("running input emulation test");
let backend = config.emulation_backend.map(|b| b.into()); let backend = config.emulation_backend().map(|b| b.into());
let mut emulation = InputEmulation::new(backend).await?; let mut emulation = InputEmulation::new(backend).await?;
emulation.create(0).await; emulation.create(0).await;

View File

@@ -1,18 +1,18 @@
use futures::{Stream, StreamExt}; use futures::{Stream, StreamExt};
use lan_mouse_proto::{ProtoEvent, MAX_EVENT_SIZE}; use lan_mouse_proto::{MAX_EVENT_SIZE, ProtoEvent};
use local_channel::mpsc::{channel, Receiver, Sender}; use local_channel::mpsc::{Receiver, Sender, channel};
use rustls::pki_types::CertificateDer; use rustls::pki_types::CertificateDer;
use std::{ use std::{
collections::HashMap, collections::{HashMap, VecDeque},
net::SocketAddr, net::SocketAddr,
rc::Rc, rc::Rc,
sync::{Arc, RwLock}, sync::{Arc, Mutex, RwLock},
time::Duration, time::Duration,
}; };
use thiserror::Error; use thiserror::Error;
use tokio::{ use tokio::{
sync::Mutex, sync::Mutex as AsyncMutex,
task::{spawn_local, JoinHandle}, task::{JoinHandle, spawn_local},
}; };
use webrtc_dtls::{ use webrtc_dtls::{
config::{ClientAuthType::RequireAnyClientCert, Config, ExtendedMasterSecretType}, config::{ClientAuthType::RequireAnyClientCert, Config, ExtendedMasterSecretType},
@@ -20,7 +20,7 @@ use webrtc_dtls::{
crypto::Certificate, crypto::Certificate,
listener::listen, listener::listen,
}; };
use webrtc_util::{conn::Listener, Conn, Error}; use webrtc_util::{Conn, Error, conn::Listener};
use crate::crypto; use crate::crypto;
@@ -34,11 +34,25 @@ pub enum ListenerCreationError {
type ArcConn = Arc<dyn Conn + Send + Sync>; type ArcConn = Arc<dyn Conn + Send + Sync>;
pub(crate) enum ListenEvent {
Msg {
event: ProtoEvent,
addr: SocketAddr,
},
Accept {
addr: SocketAddr,
fingerprint: String,
},
Rejected {
fingerprint: String,
},
}
pub(crate) struct LanMouseListener { pub(crate) struct LanMouseListener {
listen_rx: Receiver<(ProtoEvent, SocketAddr)>, listen_rx: Receiver<ListenEvent>,
listen_tx: Sender<(ProtoEvent, SocketAddr)>, listen_tx: Sender<ListenEvent>,
listen_task: JoinHandle<()>, listen_task: JoinHandle<()>,
conns: Rc<Mutex<Vec<(SocketAddr, ArcConn)>>>, conns: Rc<AsyncMutex<Vec<(SocketAddr, ArcConn)>>>,
request_port_change: Sender<u16>, request_port_change: Sender<u16>,
port_changed: Receiver<Result<u16, ListenerCreationError>>, port_changed: Receiver<Result<u16, ListenerCreationError>>,
} }
@@ -58,26 +72,35 @@ impl LanMouseListener {
let (listen_tx, listen_rx) = channel(); let (listen_tx, listen_rx) = channel();
let (request_port_change, mut request_port_change_rx) = channel(); let (request_port_change, mut request_port_change_rx) = channel();
let (port_changed_tx, port_changed) = channel(); let (port_changed_tx, port_changed) = channel();
let connection_attempts: Arc<Mutex<VecDeque<String>>> = Default::default();
let authorized = authorized_keys.clone(); let authorized = authorized_keys.clone();
let verify_peer_certificate: Option<VerifyPeerCertificateFn> = Some(Arc::new( let verify_peer_certificate: Option<VerifyPeerCertificateFn> = {
move |certs: &[Vec<u8>], _chains: &[CertificateDer<'static>]| { let connection_attempts = connection_attempts.clone();
assert!(certs.len() == 1); Some(Arc::new(
let fingerprints = certs move |certs: &[Vec<u8>], _chains: &[CertificateDer<'static>]| {
.iter() assert!(certs.len() == 1);
.map(|c| crypto::generate_fingerprint(c)) let fingerprints = certs
.collect::<Vec<_>>(); .iter()
if authorized .map(|c| crypto::generate_fingerprint(c))
.read() .collect::<Vec<_>>();
.expect("lock") if authorized
.contains_key(&fingerprints[0]) .read()
{ .expect("lock")
Ok(()) .contains_key(&fingerprints[0])
} else { {
Err(webrtc_dtls::Error::ErrVerifyDataMismatch) Ok(())
} } else {
}, let fingerprint = fingerprints.into_iter().next().expect("fingerprint");
)); connection_attempts
.lock()
.expect("lock")
.push_back(fingerprint);
Err(webrtc_dtls::Error::ErrVerifyDataMismatch)
}
},
))
};
let cfg = Config { let cfg = Config {
certificates: vec![cert.clone()], certificates: vec![cert.clone()],
extended_master_secret: ExtendedMasterSecretType::Require, extended_master_secret: ExtendedMasterSecretType::Require,
@@ -89,43 +112,69 @@ impl LanMouseListener {
let listen_addr = SocketAddr::new("0.0.0.0".parse().expect("invalid ip"), port); let listen_addr = SocketAddr::new("0.0.0.0".parse().expect("invalid ip"), port);
let mut listener = listen(listen_addr, cfg.clone()).await?; let mut listener = listen(listen_addr, cfg.clone()).await?;
let conns: Rc<Mutex<Vec<(SocketAddr, ArcConn)>>> = Rc::new(Mutex::new(Vec::new())); let conns: Rc<AsyncMutex<Vec<(SocketAddr, ArcConn)>>> =
Rc::new(AsyncMutex::new(Vec::new()));
let conns_clone = conns.clone(); let conns_clone = conns.clone();
let tx = listen_tx.clone(); let listen_task: JoinHandle<()> = {
let listen_task: JoinHandle<()> = spawn_local(async move { let listen_tx = listen_tx.clone();
loop { let connection_attempts = connection_attempts.clone();
let sleep = tokio::time::sleep(Duration::from_secs(2)); spawn_local(async move {
tokio::select! { loop {
/* workaround for https://github.com/webrtc-rs/webrtc/issues/614 */ let sleep = tokio::time::sleep(Duration::from_secs(2));
_ = sleep => continue, tokio::select! {
c = listener.accept() => match c { /* workaround for https://github.com/webrtc-rs/webrtc/issues/614 */
Ok((conn, addr)) => { _ = sleep => continue,
log::info!("dtls client connected, ip: {addr}"); c = listener.accept() => match c {
let mut conns = conns_clone.lock().await; Ok((conn, addr)) => {
conns.push((addr, conn.clone())); log::info!("dtls client connected, ip: {addr}");
spawn_local(read_loop(conns_clone.clone(), addr, conn, tx.clone())); let mut conns = conns_clone.lock().await;
}, conns.push((addr, conn.clone()));
Err(e) => log::warn!("accept: {e}"), let dtls_conn: &DTLSConn = conn.as_any().downcast_ref().expect("dtls conn");
}, let certs = dtls_conn.connection_state().await.peer_certificates;
port = request_port_change_rx.recv() => { let cert = certs.first().expect("cert");
let port = port.expect("channel closed"); let fingerprint = crypto::generate_fingerprint(cert);
let listen_addr = SocketAddr::new("0.0.0.0".parse().expect("invalid ip"), port); listen_tx.send(ListenEvent::Accept { addr, fingerprint }).expect("channel closed");
match listen(listen_addr, cfg.clone()).await { spawn_local(read_loop(conns_clone.clone(), addr, conn, listen_tx.clone()));
Ok(new_listener) => { },
let _ = listener.close().await;
listener = new_listener;
port_changed_tx.send(Ok(port)).expect("channel closed");
}
Err(e) => { Err(e) => {
log::warn!("unable to change port: {e}"); if let Error::Std(ref e) = e {
port_changed_tx.send(Err(e.into())).expect("channel closed"); if let Some(e) = e.0.downcast_ref::<webrtc_dtls::Error>() {
match e {
webrtc_dtls::Error::ErrVerifyDataMismatch => {
if let Some(fingerprint) = connection_attempts.lock().expect("lock").pop_front() {
listen_tx.send(ListenEvent::Rejected { fingerprint }).expect("channel closed");
}
}
_ => log::warn!("accept: {e}"),
}
} else {
log::warn!("accept: {e:?}");
}
} else {
log::warn!("accept: {e:?}");
}
} }
}; },
}, port = request_port_change_rx.recv() => {
}; let port = port.expect("channel closed");
} let listen_addr = SocketAddr::new("0.0.0.0".parse().expect("invalid ip"), port);
}); match listen(listen_addr, cfg.clone()).await {
Ok(new_listener) => {
let _ = listener.close().await;
listener = new_listener;
port_changed_tx.send(Ok(port)).expect("channel closed");
}
Err(e) => {
log::warn!("unable to change port: {e}");
port_changed_tx.send(Err(e.into())).expect("channel closed");
}
};
},
};
}
})
};
Ok(Self { Ok(Self {
conns, conns,
@@ -186,7 +235,7 @@ impl LanMouseListener {
} }
impl Stream for LanMouseListener { impl Stream for LanMouseListener {
type Item = (ProtoEvent, SocketAddr); type Item = ListenEvent;
fn poll_next( fn poll_next(
mut self: std::pin::Pin<&mut Self>, mut self: std::pin::Pin<&mut Self>,
@@ -197,23 +246,25 @@ impl Stream for LanMouseListener {
} }
async fn read_loop( async fn read_loop(
conns: Rc<Mutex<Vec<(SocketAddr, ArcConn)>>>, conns: Rc<AsyncMutex<Vec<(SocketAddr, ArcConn)>>>,
addr: SocketAddr, addr: SocketAddr,
conn: ArcConn, conn: ArcConn,
dtls_tx: Sender<(ProtoEvent, SocketAddr)>, dtls_tx: Sender<ListenEvent>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let mut b = [0u8; MAX_EVENT_SIZE]; let mut b = [0u8; MAX_EVENT_SIZE];
while conn.recv(&mut b).await.is_ok() { while conn.recv(&mut b).await.is_ok() {
match b.try_into() { match b.try_into() {
Ok(event) => dtls_tx.send((event, addr)).expect("channel closed"), Ok(event) => dtls_tx
.send(ListenEvent::Msg { event, addr })
.expect("channel closed"),
Err(e) => { Err(e) => {
log::warn!("error receiving event: {e}"); log::warn!("error receiving event: {e}");
break; break;
} }
} }
} }
log::info!("dtls client disconnected {:?}", addr); log::info!("dtls client disconnected {addr:?}");
let mut conns = conns.lock().await; let mut conns = conns.lock().await;
let index = conns let index = conns
.iter() .iter()

View File

@@ -3,15 +3,18 @@ use input_capture::InputCaptureError;
use input_emulation::InputEmulationError; use input_emulation::InputEmulationError;
use lan_mouse::{ use lan_mouse::{
capture_test, capture_test,
config::{Config, ConfigError, Frontend}, config::{self, Command, Config, ConfigError},
emulation_test, emulation_test,
service::{Service, ServiceError}, service::{Service, ServiceError},
}; };
use lan_mouse_cli::CliError;
#[cfg(feature = "gtk")]
use lan_mouse_gtk::GtkError;
use lan_mouse_ipc::{IpcError, IpcListenerCreationError}; use lan_mouse_ipc::{IpcError, IpcListenerCreationError};
use std::{ use std::{
future::Future, future::Future,
io, io,
process::{self, Child, Command}, process::{self, Child},
}; };
use thiserror::Error; use thiserror::Error;
use tokio::task::LocalSet; use tokio::task::LocalSet;
@@ -30,6 +33,11 @@ enum LanMouseError {
Capture(#[from] InputCaptureError), Capture(#[from] InputCaptureError),
#[error(transparent)] #[error(transparent)]
Emulation(#[from] InputEmulationError), Emulation(#[from] InputEmulationError),
#[cfg(feature = "gtk")]
#[error(transparent)]
Gtk(#[from] GtkError),
#[error(transparent)]
Cli(#[from] CliError),
} }
fn main() { fn main() {
@@ -44,35 +52,52 @@ fn main() {
} }
fn run() -> Result<(), LanMouseError> { fn run() -> Result<(), LanMouseError> {
// parse config file + cli args let config = config::Config::new()?;
let config = Config::new()?; match config.command() {
if config.test_capture { Some(command) => match command {
run_async(capture_test::run(config))?; Command::TestEmulation(args) => run_async(emulation_test::run(config, args))?,
} else if config.test_emulation { Command::TestCapture(args) => run_async(capture_test::run(config, args))?,
run_async(emulation_test::run(config))?; Command::Cli(cli_args) => run_async(lan_mouse_cli::run(cli_args))?,
} else if config.daemon { Command::Daemon => {
// if daemon is specified we run the service // if daemon is specified we run the service
match run_async(run_service(config)) { match run_async(run_service(config)) {
Err(LanMouseError::Service(ServiceError::IpcListen( Err(LanMouseError::Service(ServiceError::IpcListen(
IpcListenerCreationError::AlreadyRunning, IpcListenerCreationError::AlreadyRunning,
))) => log::info!("service already running!"), ))) => log::info!("service already running!"),
r => r?, r => r?,
} }
} else { }
// otherwise start the service as a child process and },
// run a frontend None => {
let mut service = start_service()?; // otherwise start the service as a child process and
run_frontend(&config)?; // run a frontend
#[cfg(unix)] #[cfg(feature = "gtk")]
{ {
// on unix we give the service a chance to terminate gracefully let mut service = start_service()?;
let pid = service.id() as libc::pid_t; let res = lan_mouse_gtk::run();
unsafe { #[cfg(unix)]
libc::kill(pid, libc::SIGINT); {
// on unix we give the service a chance to terminate gracefully
let pid = service.id() as libc::pid_t;
unsafe {
libc::kill(pid, libc::SIGINT);
}
service.wait()?;
}
service.kill()?;
res?;
}
#[cfg(not(feature = "gtk"))]
{
// run daemon if gtk is diabled
match run_async(run_service(config)) {
Err(LanMouseError::Service(ServiceError::IpcListen(
IpcListenerCreationError::AlreadyRunning,
))) => log::info!("service already running!"),
r => r?,
}
} }
service.wait()?;
} }
service.kill()?;
} }
Ok(()) Ok(())
@@ -94,33 +119,20 @@ where
} }
fn start_service() -> Result<Child, io::Error> { fn start_service() -> Result<Child, io::Error> {
let child = Command::new(std::env::current_exe()?) let child = process::Command::new(std::env::current_exe()?)
.args(std::env::args().skip(1)) .args(std::env::args().skip(1))
.arg("--daemon") .arg("daemon")
.spawn()?; .spawn()?;
Ok(child) Ok(child)
} }
async fn run_service(config: Config) -> Result<(), ServiceError> { async fn run_service(config: Config) -> Result<(), ServiceError> {
log::info!("using config: {:?}", config.path); let release_bind = config.release_bind();
log::info!("Press {:?} to release the mouse", config.release_bind); let config_path = config.config_path().to_owned();
let mut service = Service::new(config).await?; let mut service = Service::new(config).await?;
log::info!("using config: {config_path:?}");
log::info!("Press {release_bind:?} to release the mouse");
service.run().await?; service.run().await?;
log::info!("service exited!"); log::info!("service exited!");
Ok(()) Ok(())
} }
fn run_frontend(config: &Config) -> Result<(), IpcError> {
match config.frontend {
#[cfg(feature = "gtk")]
Frontend::Gtk => {
lan_mouse_gtk::run();
}
#[cfg(not(feature = "gtk"))]
Frontend::Gtk => panic!("gtk frontend requested but feature not enabled!"),
Frontend::Cli => {
lan_mouse_cli::run()?;
}
};
Ok(())
}

View File

@@ -1,7 +1,7 @@
use crate::{ use crate::{
capture::{Capture, CaptureType, ICaptureEvent}, capture::{Capture, CaptureType, ICaptureEvent},
client::ClientManager, client::ClientManager,
config::Config, config::{Config, ConfigClient},
connect::LanMouseConnection, connect::LanMouseConnection,
crypto, crypto,
dns::{DnsEvent, DnsResolver}, dns::{DnsEvent, DnsResolver},
@@ -9,10 +9,10 @@ use crate::{
listen::{LanMouseListener, ListenerCreationError}, listen::{LanMouseListener, ListenerCreationError},
}; };
use futures::StreamExt; use futures::StreamExt;
use hickory_resolver::error::ResolveError; use hickory_resolver::ResolveError;
use lan_mouse_ipc::{ use lan_mouse_ipc::{
AsyncFrontendListener, ClientConfig, ClientHandle, ClientState, FrontendEvent, FrontendRequest, AsyncFrontendListener, ClientHandle, FrontendEvent, FrontendRequest, IpcError,
IpcError, IpcListenerCreationError, Position, Status, IpcListenerCreationError, Position, Status,
}; };
use log; use log;
use std::{ use std::{
@@ -39,6 +39,8 @@ pub enum ServiceError {
} }
pub struct Service { pub struct Service {
/// configuration
config: Config,
/// input capture /// input capture
capture: Capture, capture: Capture,
/// input emulation /// input emulation
@@ -80,48 +82,35 @@ struct Incoming {
impl Service { impl Service {
pub async fn new(config: Config) -> Result<Self, ServiceError> { pub async fn new(config: Config) -> Result<Self, ServiceError> {
let client_manager = ClientManager::default(); let client_manager = ClientManager::default();
for client in config.get_clients() { for client in config.clients() {
let config = ClientConfig { client_manager.add_with_config(client);
hostname: client.hostname,
fix_ips: client.ips.into_iter().collect(),
port: client.port,
pos: client.pos,
cmd: client.enter_hook,
};
let state = ClientState {
active: client.active,
ips: HashSet::from_iter(config.fix_ips.iter().cloned()),
..Default::default()
};
let handle = client_manager.add_client();
client_manager.set_config(handle, config);
client_manager.set_state(handle, state);
} }
// load certificate // load certificate
let cert = crypto::load_or_generate_key_and_cert(&config.cert_path)?; let cert = crypto::load_or_generate_key_and_cert(config.cert_path())?;
let public_key_fingerprint = crypto::certificate_fingerprint(&cert); let public_key_fingerprint = crypto::certificate_fingerprint(&cert);
// create frontend communication adapter, exit if already running // create frontend communication adapter, exit if already running
let frontend_listener = AsyncFrontendListener::new().await?; let frontend_listener = AsyncFrontendListener::new().await?;
let authorized_keys = Arc::new(RwLock::new(config.authorized_fingerprints.clone())); let authorized_keys = Arc::new(RwLock::new(config.authorized_fingerprints()));
// listener + connection // listener + connection
let listener = let listener =
LanMouseListener::new(config.port, cert.clone(), authorized_keys.clone()).await?; LanMouseListener::new(config.port(), cert.clone(), authorized_keys.clone()).await?;
let conn = LanMouseConnection::new(cert.clone(), client_manager.clone()); let conn = LanMouseConnection::new(cert.clone(), client_manager.clone());
// input capture + emulation // input capture + emulation
let capture_backend = config.capture_backend.map(|b| b.into()); let capture_backend = config.capture_backend().map(|b| b.into());
let capture = Capture::new(capture_backend, conn, config.release_bind.clone()); let capture = Capture::new(capture_backend, conn, config.release_bind());
let emulation_backend = config.emulation_backend.map(|b| b.into()); let emulation_backend = config.emulation_backend().map(|b| b.into());
let emulation = Emulation::new(emulation_backend, listener); let emulation = Emulation::new(emulation_backend, listener);
// create dns resolver // create dns resolver
let resolver = DnsResolver::new()?; let resolver = DnsResolver::new()?;
let port = config.port; let port = config.port();
let service = Self { let service = Self {
config,
capture, capture,
emulation, emulation,
frontend_listener, frontend_listener,
@@ -142,11 +131,15 @@ impl Service {
} }
pub async fn run(&mut self) -> Result<(), ServiceError> { pub async fn run(&mut self) -> Result<(), ServiceError> {
for handle in self.client_manager.active_clients() { let active = self.client_manager.active_clients();
for handle in active.iter() {
// small hack: `activate_client()` checks, if the client // small hack: `activate_client()` checks, if the client
// is already active in client_manager and does not create a // is already active in client_manager and does not create a
// capture barrier in that case so we have to deactivate it first // capture barrier in that case so we have to deactivate it first
self.client_manager.deactivate_client(handle); self.client_manager.deactivate_client(*handle);
}
for handle in active {
self.activate_client(handle); self.activate_client(handle);
} }
@@ -157,6 +150,7 @@ impl Service {
event = self.emulation.event() => self.handle_emulation_event(event), event = self.emulation.event() => self.handle_emulation_event(event),
event = self.capture.event() => self.handle_capture_event(event), event = self.capture.event() => self.handle_capture_event(event),
event = self.resolver.event() => self.handle_resolver_event(event), event = self.resolver.event() => self.handle_resolver_event(event),
_ = self.config.changed() => self.handle_config_change(),
r = signal::ctrl_c() => break r.expect("failed to wait for CTRL+C"), r = signal::ctrl_c() => break r.expect("failed to wait for CTRL+C"),
} }
} }
@@ -178,25 +172,100 @@ impl Service {
Err(e) => return log::error!("error receiving request: {e}"), Err(e) => return log::error!("error receiving request: {e}"),
}; };
match request { match request {
FrontendRequest::Activate(handle, active) => self.set_client_active(handle, active), FrontendRequest::Activate(handle, active) => {
FrontendRequest::AuthorizeKey(desc, fp) => self.add_authorized_key(desc, fp), self.set_client_active(handle, active);
self.save_config();
}
FrontendRequest::AuthorizeKey(desc, fp) => {
self.add_authorized_key(desc, fp);
self.save_config();
}
FrontendRequest::ChangePort(port) => self.change_port(port), FrontendRequest::ChangePort(port) => self.change_port(port),
FrontendRequest::Create => self.add_client(), FrontendRequest::Create => {
FrontendRequest::Delete(handle) => self.remove_client(handle), self.add_client();
self.save_config();
}
FrontendRequest::Delete(handle) => {
self.remove_client(handle);
self.save_config();
}
FrontendRequest::EnableCapture => self.capture.reenable(), FrontendRequest::EnableCapture => self.capture.reenable(),
FrontendRequest::EnableEmulation => self.emulation.reenable(), FrontendRequest::EnableEmulation => self.emulation.reenable(),
FrontendRequest::Enumerate() => self.enumerate(), FrontendRequest::Enumerate() => self.enumerate(),
FrontendRequest::GetState(handle) => self.broadcast_client(handle), FrontendRequest::UpdateFixIps(handle, fix_ips) => {
FrontendRequest::UpdateFixIps(handle, fix_ips) => self.update_fix_ips(handle, fix_ips), self.update_fix_ips(handle, fix_ips);
FrontendRequest::UpdateHostname(handle, host) => self.update_hostname(handle, host), self.save_config();
FrontendRequest::UpdatePort(handle, port) => self.update_port(handle, port), }
FrontendRequest::UpdatePosition(handle, pos) => self.update_pos(handle, pos), FrontendRequest::UpdateHostname(handle, host) => {
self.update_hostname(handle, host);
self.save_config();
}
FrontendRequest::UpdatePort(handle, port) => {
self.update_port(handle, port);
self.save_config();
}
FrontendRequest::UpdatePosition(handle, pos) => {
self.update_pos(handle, pos);
self.save_config();
}
FrontendRequest::ResolveDns(handle) => self.resolve(handle), FrontendRequest::ResolveDns(handle) => self.resolve(handle),
FrontendRequest::Sync => self.sync_frontend(), FrontendRequest::Sync => self.sync_frontend(),
FrontendRequest::RemoveAuthorizedKey(key) => self.remove_authorized_key(key), FrontendRequest::RemoveAuthorizedKey(key) => {
self.remove_authorized_key(key);
self.save_config();
}
FrontendRequest::UpdateEnterHook(handle, enter_hook) => {
self.update_enter_hook(handle, enter_hook)
}
FrontendRequest::SaveConfiguration => self.save_config(),
} }
} }
fn save_config(&mut self) {
let clients = self.client_manager.clients();
let clients = clients
.into_iter()
.map(|(c, s)| ConfigClient {
ips: HashSet::from_iter(c.fix_ips),
hostname: c.hostname,
port: c.port,
pos: c.pos,
active: s.active,
enter_hook: c.cmd,
})
.collect();
self.config.set_clients(clients);
let authorized_keys = self.authorized_keys.read().expect("lock").clone();
self.config.set_authorized_keys(authorized_keys);
if let Err(e) = self.config.write_back() {
log::warn!("failed to write config: {e}");
}
}
fn handle_config_change(&mut self) {
for h in self.client_manager.registered_clients() {
self.remove_client(h);
}
for c in self.config.clients() {
let handle = self.client_manager.add_with_config(c);
log::info!("added client {handle}");
let (c, s) = self.client_manager.get_state(handle).unwrap();
if s.active {
self.client_manager.deactivate_client(handle);
self.activate_client(handle);
}
self.notify_frontend(FrontendEvent::Created(handle, c, s));
}
let release_bind = self.config.release_bind();
self.capture.set_release_bind(release_bind);
let authorized_keys = self.config.authorized_fingerprints();
self.authorized_keys
.write()
.unwrap()
.clone_from(&authorized_keys);
self.sync_frontend();
}
async fn handle_frontend_pending(&mut self) { async fn handle_frontend_pending(&mut self) {
while let Some(event) = self.pending_frontend_events.pop_front() { while let Some(event) = self.pending_frontend_events.pop_front() {
self.frontend_listener.broadcast(event).await; self.frontend_listener.broadcast(event).await;
@@ -205,7 +274,10 @@ impl Service {
fn handle_emulation_event(&mut self, event: EmulationEvent) { fn handle_emulation_event(&mut self, event: EmulationEvent) {
match event { match event {
EmulationEvent::Connected { EmulationEvent::ConnectionAttempt { fingerprint } => {
self.notify_frontend(FrontendEvent::ConnectionAttempt { fingerprint });
}
EmulationEvent::Entered {
addr, addr,
pos, pos,
fingerprint, fingerprint,
@@ -213,7 +285,11 @@ impl Service {
// check if already registered // check if already registered
if !self.incoming_conns.contains(&addr) { if !self.incoming_conns.contains(&addr) {
self.add_incoming(addr, pos, fingerprint.clone()); self.add_incoming(addr, pos, fingerprint.clone());
self.notify_frontend(FrontendEvent::IncomingConnected(fingerprint, addr, pos)); self.notify_frontend(FrontendEvent::DeviceEntered {
fingerprint,
addr,
pos,
});
} else { } else {
self.update_incoming(addr, pos, fingerprint); self.update_incoming(addr, pos, fingerprint);
} }
@@ -240,6 +316,9 @@ impl Service {
self.notify_frontend(FrontendEvent::EmulationStatus(self.emulation_status)); self.notify_frontend(FrontendEvent::EmulationStatus(self.emulation_status));
} }
EmulationEvent::ReleaseNotify => self.capture.release(), EmulationEvent::ReleaseNotify => self.capture.release(),
EmulationEvent::Connected { addr, fingerprint } => {
self.notify_frontend(FrontendEvent::DeviceConnected { addr, fingerprint });
}
} }
} }
@@ -283,7 +362,7 @@ impl Service {
handle handle
} }
}; };
self.notify_frontend(FrontendEvent::Changed(handle)); self.broadcast_client(handle);
} }
fn resolve(&self, handle: ClientHandle) { fn resolve(&self, handle: ClientHandle) {
@@ -341,7 +420,11 @@ impl Service {
self.remove_incoming(addr); self.remove_incoming(addr);
self.add_incoming(addr, pos, fingerprint.clone()); self.add_incoming(addr, pos, fingerprint.clone());
self.notify_frontend(FrontendEvent::IncomingDisconnected(addr)); self.notify_frontend(FrontendEvent::IncomingDisconnected(addr));
self.notify_frontend(FrontendEvent::IncomingConnected(fingerprint, addr, pos)); self.notify_frontend(FrontendEvent::DeviceEntered {
fingerprint,
addr,
pos,
});
} }
} }
@@ -399,13 +482,13 @@ impl Service {
log::debug!("deactivating client {handle}"); log::debug!("deactivating client {handle}");
if self.client_manager.deactivate_client(handle) { if self.client_manager.deactivate_client(handle) {
self.capture.destroy(handle); self.capture.destroy(handle);
self.notify_frontend(FrontendEvent::Changed(handle)); self.broadcast_client(handle);
log::info!("deactivated client {handle}"); log::info!("deactivated client {handle}");
} }
} }
fn activate_client(&mut self, handle: ClientHandle) { fn activate_client(&mut self, handle: ClientHandle) {
log::debug!("activating client"); log::debug!("activating client {handle}");
/* resolve dns on activate */ /* resolve dns on activate */
self.resolve(handle); self.resolve(handle);
@@ -425,7 +508,7 @@ impl Service {
if self.client_manager.activate_client(handle) { if self.client_manager.activate_client(handle) {
/* notify capture and frontends */ /* notify capture and frontends */
self.capture.create(handle, pos, CaptureType::Default); self.capture.create(handle, pos, CaptureType::Default);
self.notify_frontend(FrontendEvent::Changed(handle)); self.broadcast_client(handle);
log::info!("activated client {handle} ({pos})"); log::info!("activated client {handle} ({pos})");
} }
} }
@@ -452,19 +535,20 @@ impl Service {
fn update_fix_ips(&mut self, handle: ClientHandle, fix_ips: Vec<IpAddr>) { fn update_fix_ips(&mut self, handle: ClientHandle, fix_ips: Vec<IpAddr>) {
self.client_manager.set_fix_ips(handle, fix_ips); self.client_manager.set_fix_ips(handle, fix_ips);
self.notify_frontend(FrontendEvent::Changed(handle)); self.broadcast_client(handle);
} }
fn update_hostname(&mut self, handle: ClientHandle, hostname: Option<String>) { fn update_hostname(&mut self, handle: ClientHandle, hostname: Option<String>) {
log::info!("hostname changed: {hostname:?}");
if self.client_manager.set_hostname(handle, hostname.clone()) { if self.client_manager.set_hostname(handle, hostname.clone()) {
self.resolve(handle); self.resolve(handle);
} }
self.notify_frontend(FrontendEvent::Changed(handle)); self.broadcast_client(handle);
} }
fn update_port(&mut self, handle: ClientHandle, port: u16) { fn update_port(&mut self, handle: ClientHandle, port: u16) {
self.client_manager.set_port(handle, port); self.client_manager.set_port(handle, port);
self.notify_frontend(FrontendEvent::Changed(handle)); self.broadcast_client(handle);
} }
fn update_pos(&mut self, handle: ClientHandle, pos: Position) { fn update_pos(&mut self, handle: ClientHandle, pos: Position) {
@@ -473,7 +557,12 @@ impl Service {
self.deactivate_client(handle); self.deactivate_client(handle);
self.activate_client(handle); self.activate_client(handle);
} }
self.notify_frontend(FrontendEvent::Changed(handle)); self.broadcast_client(handle);
}
fn update_enter_hook(&mut self, handle: ClientHandle, enter_hook: Option<String>) {
self.client_manager.set_enter_hook(handle, enter_hook);
self.broadcast_client(handle);
} }
fn broadcast_client(&mut self, handle: ClientHandle) { fn broadcast_client(&mut self, handle: ClientHandle) {