Skip to content

macOS: QoL bundle (LSUIElement, TCC flow, quit-unfreezable, display wake) + UI polish#434

Open
jondkinney wants to merge 76 commits intofeschber:mainfrom
jondkinney:split/07-macos-qol
Open

macOS: QoL bundle (LSUIElement, TCC flow, quit-unfreezable, display wake) + UI polish#434
jondkinney wants to merge 76 commits intofeschber:mainfrom
jondkinney:split/07-macos-qol

Conversation

@jondkinney
Copy link
Copy Markdown
Contributor

@jondkinney jondkinney commented May 6, 2026

Bundle of macOS-specific quality-of-life fixes (TCC flow, AX-revoke handling, display wake, Cmd+W) plus general UI polish.

Review-only focused diff (just this PR's commits, vs. split/06-mdns): jondkinney/lan-mouse@split/06-mdns...split/07-macos-qol

Summary

  • macOS: TCC.db watcher with fresh-subprocess AX probe; quit when AX revoked mid-session; quit-unfreezable backstop; Cmd+W close-to-menubar; emulation wakes the display when synthesizing input; capture refreshes display bounds on system wake
  • UI: title-case headings, modal dialog sizing, popup polish, GtkScrolledWindow content wrap, Hostname/Port row split

Test plan

  • Revoke Accessibility mid-session → daemon exits cleanly
  • Lid-close + lid-open clamshell transitions; cursor bounds stay current
  • Resize window; UI adapts cleanly

Related PRs

This PR is part of an effort to split #418 into focused, independently-reviewable pieces.

The split stack (each builds on the previous; review in order):

  1. feat(capture): wall-press auto-release + Bounds protocol foundation #420 — wall-press auto-release + Bounds protocol foundation
  2. feat(cursor-sync): CursorPos protocol + cursor warps without prior Bounds #429 — CursorPos protocol + cursor warps without prior Bounds
  3. feat(capture): suppress cross-machine crossings while host screen is locked #430 — host-lock crossing suppression
  4. feat: peer version exchange via Hello proto event #431 — peer version exchange
  5. fix: hostname resolution via OS resolver + multi-homed DTLS listener #432 — multi-homed DTLS listener + OS-resolver DNS
  6. feat(discovery): mDNS-SD primary-IP hints for service-order-aware dialing #433 — mDNS-SD primary-IP hints
  7. macOS: QoL bundle (LSUIElement, TCC flow, quit-unfreezable, display wake) + UI polish #434 — macOS QoL bundle + UI polish
  8. feat: per-pair scroll & mouse-sensitivity controls + scroll/version fixes #435 — per-pair scroll/sensitivity + scroll & version-exchange fixes
  9. feat(gui): cross-platform GUI singleton via dedicated socket #436 — cross-platform GUI singleton

Plus:


CleanShot 2026-05-07 at 09 45 01@2x

jondkinney and others added 11 commits May 6, 2026 16:01
Adds a host-side fallback that releases capture when the user
sweeps the cursor against the host-adjacent edge of the guest
and keeps pushing past a configurable threshold. Solves the
"two locked screens" case where the peer's capture backend
can't fire CaptureBegin (and therefore can't send Leave back),
leaving the host stuck capturing indefinitely until the
release-bind chord is pressed.

Algorithm lives in InputCapture::poll_next so every backend
(macOS, libei, layer-shell, x11, windows, dummy) gets it for
free — they only need to emit standard motion events through
the existing Stream interface, which they already do. The
wrapper tracks:

  virtual_pos: signed position along the entry axis, clamped at
    0 from below. No upper clamp — the wrapper can't know the
    guest's far-edge extent without protocol-level cooperation,
    and any proxy is wrong for some user's setup.
  wall_pressure: motion that overshoots the host-adjacent edge
    and would have driven virtual_pos negative. Fires
    CaptureEvent::AutoRelease when the threshold is reached;
    the capture loop then runs the same teardown path as the
    release-bind chord.

State resets on Begin (entry to capture), AutoRelease (we
self-released), and external release (chord, peer Leave,
connection error, EnterOnly fallback).

Surface:
- New FrontendRequest::SetReleaseThreshold + FrontendEvent::
  ReleaseThreshold IPC pair.
- New release_threshold_px field on the daemon config (0 = off,
  serialized to config.toml).
- New AdwPreferencesGroup with a 0–500px slider in the GTK
  window. Default 0 (disabled) so existing users see no
  behavior change until they opt in.
- New CaptureEvent::AutoRelease variant + handling in
  src/capture.rs's handle_capture_event (short-circuit to
  release_capture, which already synthesizes key-ups and sends
  Leave to the peer).

Known limitation: the wrapper has no way to know where the
guest's cursor actually is (the guest doesn't tell us). On
re-entry into a peer mid-session, virtual_pos resets to 0 but
the guest's cursor may still be in the middle of its screen
from the prior session, causing the threshold to fire from
the wrong reference point. A protocol-level Bounds event +
cursor-warp on Enter is needed for full correctness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a new ProtoEvent variant carrying the receiving device's
display geometry (in pixels). Sent by the emulation side
right after acknowledging an Enter so the capturing peer can
model the guest cursor's position along the entry axis.

Wire format: 1-byte EventType discriminator (Bounds = 11)
followed by big-endian u32 width and big-endian u32 height
— 9 bytes total, well under MAX_EVENT_SIZE (21).

This commit only adds the protocol wiring. Senders and the
host-side cache come in subsequent commits. Old peers that
don't recognize EventType=11 will skip the datagram per the
forward-compat fix in the previous commit, so deployment is
incremental: the emulation side can start sending Bounds
without breaking older capturing peers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `display_bounds(pos)` and `warp_cursor(pos)` to the
InputEmulation trait and implement them across every backend:

  - macOS: CGDisplay APIs for bounds, CGWarpMouseCursorPosition for warp
  - x11: RandR for bounds, XWarpPointer for warp
  - wlroots: wl_output extents + virtual_pointer.motion_absolute
  - libei: region walking + ei_pointer.emit_motion_absolute
  - Windows: GetSystemMetrics + SetCursorPos
  - xdg_desktop_portal: no-op fallback (the protocol exposes neither
    bounds nor a warp primitive)

These are the prerequisites for the protocol-based wall-press
auto-release: emulation hosts now have a common API to report their
display extents to peers and to warp the cursor on Enter so the
host's modeled virtual_pos = 0 matches the guest's actual cursor.
Wire the new emulation-side capabilities into the daemon's
listener task. When a peer's Enter arrives:

  1. Reply Ack (existing behavior).
  2. Reply Bounds(width, height) using the cached display
     geometry from the active emulation backend.
  3. Warp the local cursor to the entry edge of the displayed
     position (0 for Left, width-1 for Right, etc., centered
     along the orthogonal axis).

The warp is the structural fix for the "cursor jumps back to
where it was" symptom: previously, on re-entry into a peer
mid-session, the cursor stayed wherever the prior capture
session left it, breaking the host's wall-press model
(virtual_pos=0 in the host's mind didn't match the guest's
actual cursor column). With the warp, the host's model is
synchronized with the guest's reality on every Enter.

EmulationProxy gains:
  - Cached display_bounds (Rc<Cell<Option<(u32, u32)>>>),
    refreshed each time the underlying InputEmulation is
    (re)created. Read by the listener task.
  - warp_cursor(x, y) fire-and-forget. Drops if emulation
    isn't currently active (no live backend to receive it).

ProxyRequest::Warp(x, y) carries the request to EmulationTask,
which dispatches to InputEmulation::warp_cursor.

If the active backend doesn't implement display_bounds — every
non-macOS backend right now — the listener skips the Bounds
reply and the warp call. The capturing peer falls back to its
existing "no upper clamp / virtual_pos = 0 on Begin" heuristic,
which is degraded but functional. Adding display_bounds /
warp_cursor to other backends unlocks correct behavior
incrementally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
InputCapture now keeps a per-position HashMap of peer display
geometry, populated when ProtoEvent::Bounds arrives from the
peer (handled in src/capture.rs's recv arm). track_wall_press
uses the cached entry-axis extent as the upper clamp for
virtual_pos:

  self.virtual_pos = proposed.clamp(0.0, peer_extent);

Eliminates the runaway-virtual_pos bug from the heuristic
fallback: when the user obliviously over-pushes their physical
mouse past the guest's actual far edge, the modeled position
clamps at the real width instead of climbing fictionally to
infinity. Now the user's "walk back" cost is bounded by the
guest's actual screen width.

When the peer hasn't sent Bounds yet (older peer running
without the protocol extension, or in the brief pre-Ack
window of a fresh connection), peer_extent returns INFINITY
and the model degrades to the prior heuristic.

Cache lifecycle:
  - Insert on ProtoEvent::Bounds.
  - Drop on CaptureRequest::Destroy(handle) so re-adding the
    same peer later starts fresh.

Combined with the previous commit (emulation warps cursor on
Enter), the host's virtual_pos = 0 at Begin now matches the
guest's actual cursor at column 0 (or width-1, etc.) on every
re-entry. The "cursor was in the middle, 200px back fires
release prematurely" bug is fixed structurally rather than
papered over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The label "Auto-Release" reads as a global app preference; the
description's "forwarded mouse capture" was ambiguous about which
machine does the forwarding. Rename the group to
"Outgoing Auto-Release" so the scope mirrors the surrounding
"Outgoing Connections" / "Incoming Connections" groups, and lead
the description with "When this machine is capturing input for a
peer …" so a user scanning the window can tell at a glance that
this setting only matters when the local machine is the host.
GtkScale's default behavior treats a vertical scroll event as
+/- increment, which means the threshold creeps any time the
user is scrolling the window and the cursor passes over the
slider — easy to do given the slider sits in the middle of the
preferences pane.

Add an EventControllerScroll to the slider in CAPTURE phase that
returns Propagation::Stop unconditionally. The scale's own scroll
controller never sees the event, so the value doesn't change.

Trade-off: scrolling doesn't pass through to the parent
GtkScrolledWindow while the cursor is on the slider — the wheel
becomes inert there. Acceptable: prior behavior was actively
destructive (silent state corruption); this is just "no scroll
in this small region." If users start complaining about the gap,
the next step is to forward dy to the ancestor scrolled window's
vadjustment manually before returning Stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old text described the mechanism ("releases capture automatically once
the cursor pushes past the host-adjacent edge") without explaining
when the user would actually need it. With the new peer-Leave deadline
gate (34605a7), wall-press only fires when the peer can't deliver a
Leave — i.e. when the peer's screen is locked or its capture backend
is otherwise suppressed. New text leads with that framing and trims
two sentences to two.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture-phase scroll handler used to return Propagation::Stop to
suppress GtkScale's default scroll-to-adjust behavior, but Stop also
killed propagation to the parent — so the main window wouldn't
scroll when the cursor was over the slider. Frustrating because the
slider sits in the middle of the preferences pane and "I just want
to scroll past this" is the common interaction.

Same capture-phase handler now walks up to the ancestor
ScrolledWindow and bumps its vadjustment by `dy * step_increment`
(or 40px when step_increment is unset). Mimics what native scroll
passthrough would have done — slider value stays fixed, parent
scrolls smoothly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windows clippy flags `loop { let Some(...) = get_msg() else { break } }`
as while-let-loop. Rewrite to `while let Some(msg) = get_msg() { … }`.
The inner `break` for `RequestType::Exit` still breaks the surrounding
while-let, so semantics are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Proxy trait import needed by the wlroots backend's
`output.id()` call (introduced when the emulation side started
binding wl_output for display_bounds), and applies cargo fmt for
this split's own files.
jondkinney and others added 10 commits May 7, 2026 00:50
`test windows-latest`'s only failure was the post-job cache-save
step in `Swatinem/rust-cache@v2` (job step 27); `cargo test`
itself (step 12) succeeded with all tests passing. CI rerun on
upstream requires admin rights, so an empty commit is the only
way to retrigger from this side.
Before: when crossing machines, the guest's cursor jumped to the
midpoint of the entry edge — a ~100 px Y-jump on typical
displays — because the guest snapped to a hardcoded
(0, h/2) / (w/2, 0) point on Enter. Visually discontinuous and
hard to follow when the user is mid-task.

After: the host's capture backend snapshots the screen-space cursor
position at the instant of the edge crossing (CGEvent.location()
on macOS — the only backend that can report this today; others
emit None and the guest falls back to the prior midpoint warp).
The capture loop scales those host coords against the cached peer
geometry and sends them as a new ProtoEvent::MotionAbsolute right
after Enter. The guest handles MotionAbsolute by warping the
cursor to (x, y), overriding the entry-edge midpoint so the user
sees visual continuity across the boundary.

Layered choices:

- New ProtoEvent::MotionAbsolute { x, y } primitive rather than
  bolting an offset onto Enter — gives a reusable
  position-setting building block for future features (snap to
  point on app launch, multi-monitor handoff, follow-host-cursor
  modes) without inventing more event variants.
- Pixel coordinates in the receiver's screen space, not normalized
  floats — host already caches peer bounds (Bounds proto event)
  for the wall-press upper clamp, so it can do the scaling and
  the guest just calls warp_cursor directly. Guest's
  warp_cursor primitive already takes pixels.
- Backwards compatibility: peers running the previous protocol
  don't recognize MotionAbsolute and skip it via the forward-
  compat decode-tolerance fix from earlier in this branch. Old
  hosts paired with new guests fall through to the entry-edge
  midpoint (current behavior); new hosts paired with old guests
  ignore MotionAbsolute and the cursor stays at the edge midpoint
  too — neither pair regresses.

Capture backend coverage in this commit: macOS only (the
CGEventTap callback has cg_ev.location() at the moment of edge
crossing). Other backends (libei, x11, layer_shell, windows,
dummy) emit Begin { cursor: None } and don't send MotionAbsolute,
so the guest falls back to the midpoint warp on Enter. Adding
cursor-position reporting to those backends is a per-backend
follow-up.

InputCapture trait grew display_bounds() (default impl returns
None; macOS implements via CGDisplay::active_displays) and a
peer_warp_target(pos, cursor) helper that combines the host's
own bounds, the cached peer bounds, and the cursor position into
a target point on the peer's screen. peer_warp_target returns
None when either bounds is unavailable, in which case the capture
loop just doesn't emit MotionAbsolute.
The cross-axis cursor preservation introduced in 6c1bd88 was macOS-only;
the layer-shell capture backend (Wayland/Hyprland and similar wlroots
compositors) emitted Begin { cursor: None }, so transitions where Linux
was the host fell back to the entry-edge midpoint warp on the guest —
the same 300–400 px Y-jump the macOS path was fixed to avoid.

Read surface_x / surface_y from wl_pointer::Enter and translate to
compositor screen-space using the layer-surface's anchor edge: surfaces
here are 1 px on the on-axis dimension and span the cross-axis, so the
surface-local cross-axis coord is the screen offset directly. To support
multi-output setups, store the output's compositor position+size on the
Window when it's created, and add a display_bounds() override that
returns the union rectangle of all active outputs (mirrors the macOS
impl so MotionAbsolute scaling stays consistent).

Effect: Linux→peer transitions where Linux is the source now preserve
cross-axis cursor position the same way macOS→peer transitions already
do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Counterpart to 6c1bd88's Enter-time cross-axis preservation. When the
host releases capture (release-bind chord, auto-release threshold, peer
destroyed), the visible cursor reappears at whatever point capture
started — typically the entry-edge midpoint or wherever the guest
chose to warp to. The user perceives this as a 100–400 px Y-jump even
though Mac→Linux→Mac round-trip "should" feel continuous, because
nothing in the release path tells the host where the guest's cursor
visually was at the moment of release.

Track a virtual_cursor (f64, f64) in the wrapper that mirrors the
guest's screen-space cursor: seeded on Begin from the
peer_warp_target / entry-edge midpoint (whatever the guest will
actually do on Enter), accumulated against every Motion event we
forward, clamped to peer bounds. On release, project it back to host
screen-space with host_warp_target_on_release — symmetric inverse of
peer_warp_target — and pass that as a new Option<(i32, i32)>
parameter on the Capture::release trait method. macOS threads the
target through ProducerEvent::Release and warps before show_cursor()
so the visible cursor reappears at the matching host point. Other
backends ignore the parameter (they don't hide/manage the system
cursor on the way out).

This is a no-op when peer_bounds or display_bounds is unavailable —
fallback is the previous behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-sufficient counterpart to MotionAbsolute. Carries the host's
cursor as a normalized fraction (0..1) of the host's own screen
plus the entry side from the receiver's frame. The receiver
scales nx/ny against its own display bounds and pins the on-axis
dimension to the matching edge.

The point: MotionAbsolute requires the host to know the peer's
geometry (cached via a prior `Bounds` event), which doesn't exist
on the very first crossing — `Bounds` is only sent in response to
`Enter`, so the host can't include MotionAbsolute on the same
crossing that asks for the bounds it needs. CursorPos sidesteps
the round-trip dependency entirely; the receiver does the
scaling locally with its own bounds.

Wire format adds f32 codec impl alongside existing u8/u32/i32/f64.
Old peers don't know the new EventType tag and skip the event via
the proto forward-compat decode-tolerance path; they continue to
warp to the entry-edge midpoint as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to peer_warp_target for the bounds-free CursorPos path.
Normalizes the host's screen-space cursor against the host's own
display bounds — no peer geometry consulted, so a return value
of Some is independent of whether the peer has sent Bounds yet.

The capture loop will emit this fraction as ProtoEvent::CursorPos
right after Enter so the guest can warp on the very first
crossing instead of falling through to the entry-edge midpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After Enter, also send a ProtoEvent::CursorPos carrying the host's
cursor as a normalized fraction of the host's own screen and the
entry side. Both events are emitted; the receiver tolerates either
and the second warp wins.

Why both: MotionAbsolute is more precise when peer_bounds is
already cached (uses peer pixel coords directly), but it can't
fire on the first crossing because the cache is populated by
Bounds — which only arrives in response to Enter. CursorPos has
no such dependency: it's self-contained, so the first crossing
warps correctly. Emitting both keeps backwards-compat with old
guests that only know MotionAbsolute (they get correct warps from
the second crossing onward, same as before) while letting new
guests warp on every crossing including the first.

Old guests skip the unknown CursorPos tag via the proto forward-
compat decode-tolerance path. Order on the wire is MotionAbsolute
then CursorPos so a new guest that handles both ends up with the
CursorPos warp — accurate against the peer's *current* display
bounds, robust to mid-session display reconfiguration on the
receiver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Receiver-side counterpart to the capture change: scale the
host-normalized fraction by our own display bounds, pin the
on-axis dimension to the entry edge matching the side the host
is on, and warp the local cursor.

This is what makes the very first arch->macOS crossing seamless.
The previous code path required arch (host) to have cached macOS
(peer) bounds before sending MotionAbsolute, but those bounds
arrive in response to Enter — too late for the same crossing.
CursorPos lets the receiver do the scaling itself, so no prior
round-trip is needed.

Falls through silently when display_bounds is unavailable on the
receiver (rare; occurs for backends without a geometry-query
trait impl). In that case the entry-edge midpoint warp from the
preceding Enter remains the user-visible result, same as before
this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`host_normalized_cursor` and `peer_warp_target` previously did
`nx = cx / host_w`, assuming cursor coordinates fall in
`[0, host_w]`. On macOS specifically (and X11 generally), the global
pointer-coordinate system is anchored at the primary display's
top-left, so a secondary monitor positioned LEFT of (or ABOVE) the
primary gives cursor x ∈ `[-w, 0)` for points on it. The
`clamp(0.0, 1.0)` silently masked this — every point on a
left-attached external normalized to nx = 0 ("left edge"), and the
receiver warped to the wrong column on every crossing from that
display.

Add `Capture::display_origin()` returning `(xmin, ymin)` with a
`(0, 0)` default for backends whose primary IS the origin
(Windows, most X11/Wayland setups), and macOS overrides to walk
`CGDisplay::active_displays()` for the actual leftmost/topmost
corner. `host_normalized_cursor` and `peer_warp_target` now subtract
the origin before normalizing, so off-primary cursor positions
produce correct fractions.

The wall-press auto-release model's `initial_virtual_cursor`
estimate inherits the fix automatically since it routes through
`peer_warp_target`. No protocol change — `CursorPos` payload stays
the same; only the host-side normalization that produces
`(nx, ny)` is corrected.
jondkinney and others added 25 commits May 7, 2026 00:50
- src/connect.rs: insert blank line in `should_attempt` doc-comment
  before the "Otherwise returns false" continuation. Clippy's
  `doc_lazy_continuation` (Rust 1.94+) treats text immediately after
  a list item without a blank line or indent as continuing the last
  bullet, which it isn't.
- src/discovery.rs: cargo fmt collapsed a wrapped `let instance = …`
  line onto one line at column-fit.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- discovery.rs: drop the unused `peer_primary_ip` method. It was
  kept as a "canonical lookup entry point" with `#[allow(dead_code)]`,
  but the dialer reads `primary_cache` directly via the shared
  `Rc<RefCell>` in `connect.rs` and nothing else calls it.

- discovery.rs: fix the `refresh()` doc — said "call from the
  if-watch supervisor" but it's actually driven by the service's
  periodic tick.

- service.rs: set `MissedTickBehavior::Skip` on
  `discovery_refresh_tick` (30s). The default `Burst` would
  replay backlog ticks back-to-back when resuming from a long
  suspend, each triggering a redundant interface enumeration and
  TXT republish.
- Drop the slider's bespoke margins so Auto-release matches the inner
  padding of the surrounding preference groups.
- Capitalize the first letter of every visible title, subtitle,
  tooltip, and placeholder across the GTK resources and runtime
  strings.
- On macOS, follow the main window's visibility: Regular activation
  policy when shown (Dock icon present), Accessory when hidden so the
  app reads as a menu-bar-only background helper.
Compositors that tile the window (Hyprland and similar) honor min-size
hints by clipping the window's bottom rather than shrinking it, leaving
the lower portion of the layout — including the bottom of "Incoming
Connections" — rendered off-screen. Using default-width/default-height
suggests an initial size while letting the compositor resize freely,
so scrolling reaches all content. Also drops propagate-natural-height
on the scrolled window and adds margin-bottom to the inner content for
breathing room.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Capitalize section row titles ("Hostname & Port",
  "Certificate Fingerprint") and group titles ("Auto-Release",
  "Release Threshold", "Scroll Direction") so the case matches
  their headings.
- Rename "Connections" → "Outgoing Connections" to mirror the
  "Incoming Connections" group and remove ambiguity.
- Replace the Auto-Release description with one that names the
  operand (forwarded mouse capture) and the typical use case
  (smooth release between lock screens).
- Rewrite the Scroll Direction description so it's clear forwarded
  scroll events bypass OS-level preferences and the toggle below
  controls direction.
- Bump AdwClamp max-size 600 → 800 so SHA-256 fingerprint values
  fit on one line without wrapping.
…lider

Bumps the inner content GtkBox spacing 12 → 24 so the headings of each
AdwPreferencesGroup (General, Auto-Release, Outgoing Connections, …)
sit visually as distinct sections rather than running together. Also
adds margin-top: 12 on the release-threshold scale so it doesn't crowd
the row above it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… view

The collapsed AdwExpanderRow had a single row whose title/subtitle were
"Hostname" / "Port" with both entry fields side-by-side as suffix — so
the labels stacked vertically while the inputs sat horizontally, making
it unclear which input belonged to which label. Splitting into two
AdwActionRows pairs each label with its own entry. The Hostname row
also gains a subtitle "IP address or DNS hostname" and a placeholder
of "192.168.1.42" so users know a literal IP is acceptable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the AdwExpanderRow bound hostname → title and port → subtitle,
which rendered the address as two stacked lines in the collapsed state.
Replace the two property bindings with notify-handlers on hostname/port
that recompute the title as "hostname:port" (or the existing "no hostname!"
markup when hostname is empty), and clear the subtitle so the row is a
single line. SignalHandlerIds are tracked alongside Bindings so unbind()
can disconnect them when the row is recycled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 700px default cut off below Outgoing Connections on a typical
macOS display, sending the user straight to the scrollbar to find
Incoming Connections / Scroll Direction. Bump default-height to
1400 so every preference group fits on first paint on the displays
this is most likely to launch on (≥ 1440 px tall — most modern
external displays and the built-in Liquid Retina XDR panels). On
shorter displays the GtkScrolledWindow still kicks in, so the
larger default doesn't strand small-screen users.

Width unchanged; the AdwClamp's 800px ceiling already governs
content width independently.
GtkWindow's built-in `window.close` action fires `close-request`,
which on macOS is already hooked to set_visible(false). The
window's connect_hide handler flips the activation policy to
Accessory, so the same gesture also drops the Dock icon and the
menu bar — the app becomes a menu-bar-only background helper and
focus returns to whatever the user does next.

Add the accelerator wiring (Cmd+W → window.close) and the matching
File → Close Window menu entry so the shortcut is discoverable.
Wired only on macOS so we don't shadow the native Ctrl+W on Linux
and Windows.
Two layers of belt-and-suspenders for the intermittent macOS quit
freeze:

1. Bound the daemon-child cleanup to 3 seconds. The GUI's
   service.wait() was previously unbounded, so a wedged daemon
   (CGEventTap stuck on a TCC change, blocked syscall, IPC peer
   timeout) would freeze the parent process indefinitely. After
   SIGINT we now poll try_wait() with a 3s deadline and fall
   through to SIGKILL if the child hasn't exited.

2. Process-level force-exit backstop on every quit path. A
   dedicated std::thread sleeps for 5 seconds outside the GTK main
   loop and calls process::exit(0). Normal cleanup completes well
   under 5s and the OS reaps the sleeping thread on exit; a wedged
   GTK loop or stuck cleanup gets force-quit instead of hanging.
   Wired through every macOS quit entry point: Cmd+Q via the File
   menu (and the Lan Mouse > Quit app menu macOS auto-attaches),
   the menu bar status item's Quit, and the Accessibility-revoked
   auto-quit. Idempotent — calling it twice doesn't spawn two
   backstop threads.

Linux/Windows already exit cleanly so we don't add the backstop
there.
The CGEventTap callback's TapDisabledByUserInput branch fires for both
AX permission revocation (fatal — tap can't be recreated) and secure
input mode while typing in a password field (recoverable — OS will
re-enable on field exit). Distinguish via AXIsProcessTrusted at the
moment of the disable: if AX is gone, exit the daemon process so the
GUI's IPC connection drops and Layer B (the GUI's request_quit_with_
backstop on receiver-recv Err) propagates the shutdown to the parent.

Doesn't blindly exit on every TapDisabledByUserInput because secure-
input password fields would terminate the daemon every time the user
typed any password anywhere. The AX check distinguishes correctly for
the toggle-off case; the remove-from-list case is handled by the
TCC.db watcher in src/macos_tcc_watch.rs.
Catches the macOS "user removed Lan Mouse from System Settings →
Privacy & Security → Accessibility entirely (vs toggled off)" case,
where AXIsProcessTrusted in already-running processes keeps reporting
cached-true for an unbounded window — the existing 1Hz polling
watcher in lan-mouse-gtk/src/macos_privacy.rs::watch_accessibility_state
never fires, the daemon's CGEventTap stays half-installed, and the
user's mouse/keyboard wedge until they force-quit.

The fix has two pieces:

- New `lan-mouse ax-probe` subcommand (src/macos_tcc_probe.rs) calls
  AXIsProcessTrusted in a fresh subprocess and exits 0/1. A fresh
  process consults the current TCC state without inheriting the
  parent's cached trust.

- New tokio task `macos_tcc_watch::spawn` polls the system-level
  /Library/Application Support/com.apple.TCC/TCC.db mtime every 2s.
  On change, spawns the binary with `ax-probe` to confirm; if the
  probe returns false, calls process::exit(0) on the daemon. The
  existing GUI IPC-drop watcher (Layer B) then propagates the exit
  to the parent process.

Two TCC.db files exist on modern macOS — only the system path
(/Library/...) holds Accessibility state for application bundles;
~/Library/... is for per-user prompts (calendar, contacts, etc) and
does not move on AX changes. Confirmed empirically that the user-
level path's mtime stays stale through AX add/remove/toggle, so we
watch only the system path.

Cost is negligible: one stat() per 2s on a kernel-cached inode (~µs)
plus one subprocess spawn per actual TCC change (rare, only on
explicit user action in System Settings).
Polish pass across the GTK UI for consistent typography:

Title-case multi-word row/group titles to match the existing pattern
(Hostname & Port, Certificate Fingerprint, Release Threshold,
Outgoing/Incoming Connections):
- "Input capture is disabled" → "Input Capture Disabled"
- "Input emulation is disabled" → "Input Emulation Disabled"
- "relaunch required" → "Relaunch Required"
- "Natural scrolling" → "Natural Scrolling"
- "Delete this client" → "Delete This Client"
- "no hostname!" → "No Hostname"
- "No connections!" → "No Connections"
- "No devices registered" → "No Devices Registered"
- "SHA-256 fingerprint" → "SHA-256 Fingerprint" (both modal dialogs)

Add terminal periods to subtitle/description sentences that were
running without one:
- "Required for outgoing and incoming connections."
- "Required for incoming connections."
- "Pixels of wall-press past the entry edge before auto-release fires."
- "Add a new client via the + button."
- "Authorize a new device via the "Authorize" button."
- "Invert the direction of forwarded scroll events."
- "Accessibility granted — restart to activate capture and emulation."
- "Grant Accessibility permission to enable."
- "You can find it under the `General` section…"

Add a subtitle to the Position row in the expanded client view so
the dropdown's purpose is clear without prior knowledge of the KVM
layout: "Where this device sits relative to your screen."

State placeholders ("No connections!" and "No devices registered")
move to title case to align with the rest of the row titles in their
respective groups, and lose their trailing exclamation since they're
state labels rather than alerts.

Toast event-log strings ("device connected: …", "{addr} disconnected")
intentionally stay in sentence case — they're event-log style, not
labels.
Both the "Add Certificate Fingerprint" dialog and the "Unauthorized
Device" prompt were previously wider than the main window — 880 px
and (effectively) auto-grown past 600 px respectively — so when they
appeared as transients of the main window they extended past the
parent's edges, which looked sloppy.

Set both to 560 px wide so they sit visibly inset (~20 px gap on each
side, since the main window opens at 600 px and macOS centers
transient modals over their parent). The Add-Certificate dialog's
inner AdwClamp moves 770 → 800 to match the main window's clamp
maximum; the window-width constraint dominates anyway, so the clamp
change is just consistency.

The Unauthorized-Device prompt also gets a saner default height
(180 → 320) since at 180 px it auto-grew when populated with the
64-char SHA-256 fingerprint and the result varied by font. Fixed
height looks cleaner.
The "Add Certificate Fingerprint" dialog's Confirm button used to be
clickable with empty Description and/or empty SHA-256 Fingerprint
fields, which submitted a useless authorization that the daemon then
had to reject. Disable Confirm while either field is empty (after
trim) and recompute on every keystroke via GtkEditable's `changed`
signal. Defensive guard inside the click handler also drops empty
submits in case the keyboard accelerator races the sensitivity
update.

When AuthorizationWindow chains into this dialog with the fingerprint
pre-filled (from a peer's incoming connection request), Confirm stays
disabled until the user adds a description, which is the right
behavior — saving an empty description doesn't help anyone.
The Add-Certificate-Fingerprint and Authorize dialogs both wrapped
their inputs in AdwPreferencesGroup + AdwActionRow + GtkText (or
GtkLabel for the read-only fingerprint). Each wrapper layer adds
~10–12 px of internal padding, so a "12 px outer margin" was really
40+ px of perceived margin from the modal edge — defeating the
point of trying to tighten the dialogs.

Drop both wrappers in favor of a flat hierarchy:

  GtkBox(margin: 12)
    GtkLabel(heading style)   ← "Description" / "SHA-256 Fingerprint"
    GtkEntry                  ← editable on the cert dialog,
                                editable=False on the auth one

The entries now sit at the true 12 px outer margin. Field
hierarchy still reads correctly (heading above input) without
the libadwaita-supplied styling that was contributing the bulk
of the unwanted padding.

Pulls the auth dialog from a custom outer GtkBox + bottom button
bar into the same AdwToolbarView + AdwHeaderBar + AdwClamp shell
the cert dialog uses, with both Cancel and Authorize buttons in a
centered horizontal row matching Confirm's pill style. Result:
the two popups read as siblings instead of unrelated designs.

Switches the impl-side TemplateChild types accordingly:
  fingerprint_window: Text  → Entry
  authorization_window: Label → Entry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both modals previously hardcoded 560 px which clipped on narrow
parent windows — particularly when the user had tiled the main
window into a narrow split under Hyprland and similar tilers, in
which case the popup ran past the parent's right edge or got
clipped by the compositor.

Wire popup_w = clamp(parent_w − 40, 280, 460):

  * Lower the XML default-width to 460 (the new cap) and
    width-request to 280 (the new floor) on both popups.
  * In open_fingerprint_dialog and request_authorization, read
    the parent's allocated width and call set_default_width()
    with the clamped value before present(). When the parent's
    width isn't yet known (very first launch, hidden window),
    fall through to the XML default.

The 40 px gap is enough that the popup visibly nests inside the
parent rather than feeling like a same-sized overlay, but doesn't
crowd the modal's own content. Uses the XML width-request as the
hard minimum so the runtime clamp can't accidentally shrink the
popup below a usable size when the parent is itself very narrow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Several small fixes that compound into the popup actually feeling
like it has 12 px of space at the top and bottom rather than the
~50 px that the previous scaffolding produced:

  * resizable=False on the AdwWindow so the modal sizes to its
    natural content height instead of inheriting a larger
    default-height that left empty space below the buttons.

  * Drop the explicit height-request and default-height now that
    the modal is autosizing — they were both forcing a 320–380 px
    minimum that was usually larger than content.

  * Add the `flat` style class to the AdwHeaderBar to remove the
    libadwaita-default shadow/separator below it, which read as
    extra padding under the title.

  * valign=start on the AdwClamp so the inner box pins to the
    top of the content area on compositors that don't fully honor
    resizable=False (Hyprland's mod+drag, etc.). Without this the
    AdwClamp was vertically centering its child and putting empty
    space both above and below.

  * margin-top: 12 on the Description field group so there's a
    visual divider between the explanatory body labels and the
    field section. SHA-256 group keeps the standard 18 px parent
    spacing — both groups read as siblings within the same
    section rather than the start of a new one.

Applied symmetrically to fingerprint_window.ui and the
restructured authorization_window.ui.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire an EventControllerKey on each modal that swallows Escape and
calls close(). Catches Escape regardless of which child has focus
(entry, button, or window itself) so the user doesn't have to
fish for the X button or click outside.

Uses GTK 4.0+ EventControllerKey with Propagation::Stop on the
match path so the key event doesn't propagate further; non-Escape
keys continue with Propagation::Proceed and reach their normal
handlers (typing into the entries, button accelerators, etc).

AuthorizationWindow gains a constructed() override since it
didn't previously need one — the controller has to be added on
each instance, not the class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CGEventPost-synthesized events are processed correctly by macOS
(focused apps see them) but DO NOT trigger display wake — the kernel
power-manager only treats real USB/Bluetooth HID interrupts as
wake-worthy. Symptom: when the Mac is the guest and its display has
gone to sleep, input forwarded from the host arrives but the screen
stays black until the user physically taps the Mac's keyboard.

Fix: call `IOPMAssertionDeclareUserActivity(reason,
kIOPMUserActiveLocal, &id)` from IOKit at the top of `consume()`.
This is Apple's documented "treat this as real user input for
power-management purposes" signal — it wakes the display and resets
the idle timer. The system coalesces calls within a 5-second window
(returns the same IOPMAssertionID), so calling on every event is
essentially free; we stash the most recent ID in a `Cell` and pass
it back as in/out so the system can do the right thing.

Verified: with macOS as guest and the Mac's display asleep, a
single mouse click forwarded from the Linux host now wakes the
display.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the accidentally-tracked `.claude/scheduled_tasks.lock`
introduced one commit ago and prevents future agent-runtime state
from being committed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CGDisplayRegisterReconfigurationCallback covers monitor plug/unplug
and resolution changes during normal operation, but in clamshell-
disconnect → lid-open transitions the callback either doesn't fire
(no actual reconfigure event reaches us during sleep) or fires too
early to be useful, leaving lan-mouse's cached `self.bounds` matching
whatever the display layout was when the lid closed. Symptom: after
opening the lid on a Mac that had been clamshelled to an external
monitor and then disconnected, the cursor behaves as if constrained
to the now-absent external display's resolution.

Subscribe to the IOKit power-management notifications via
`IORegisterForSystemPower` and attach the resulting CFRunLoopSource
to the same run loop that owns the event tap. On
`kIOMessageSystemHasPoweredOn` (post-wake), post the same
`ProducerEvent::DisplayReconfigured` the existing handler consumes,
so `update_bounds()` runs against the live display set. Sleep-pending
messages get acked with `IOAllowPowerChange` so we don't stall the
kernel's 30-second client-wait timeout.

Cleanup paths added: deregister + destroy notification port + drop
the boxed refcon when the run loop exits.
Pure formatting follow-ups from a code-review pass:
- input-emulation/macos.rs: move CoreFoundation imports up next to
  their core_graphics/core-foundation siblings, and let rustfmt
  collapse the IOPMAssertionDeclareUserActivity unsafe-block call
  onto a single line.
- lan-mouse-gtk/client_row.rs: rustfmt collapse of the port-title
  notify_local closure.

No behavior change.
The Lan Mouse window previously couldn't scroll its preference
groups when the window height was reduced below the natural
content height — content was simply clipped, with no way to
reach the lower groups. AdwStatusPage doesn't include built-in
scrolling.

Wrap the AdwStatusPage in a GtkScrolledWindow inside the
existing AdwToastOverlay, with vertical scroll on demand and
horizontal scroll disabled (we use AdwClamp for horizontal
sizing). propagate-natural-height keeps the window's preferred
size identical when content fits, so existing layout behavior
on tall windows is unchanged.

Effect: when the user resizes the window shorter than the
natural content height (or has a small display), all preference
groups remain reachable via vertical scroll.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant