From 9e4c51d6792b2ab49608c3c9c6a89beb1ed18a8d Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 13:21:04 +1300 Subject: [PATCH 01/14] GNOME can be the main computer, through the input capture portal Fedora (GNOME) could be controlled but never push its own cursor onto another computer: capture needed wlr-layer-shell, which GNOME doesn't have. Where it's missing, capture now goes through the InputCapture desktop portal: pointer barriers along the stretches of edge that lead to another computer, then everything over libei (the reis crate) while the cursor is away. GNOME asks each time MouseTail starts; if the answer is no, it doesn't ask again until the next start. GNOME's barriers sit on the edge's own line, so anything resting exactly on a left or top edge set them off: - Positions injected through uinput (while being controlled) aim for the middle of the pixel, so another computer's cursor at x = 0 no longer fires the barrier dozens of times a second. - The pointer comes back a pixel clear of a left or top barrier: exactly on one, it fired again as soon as it moved, sending the cursor straight back. - Where GNOME ends a capture itself (Super+Shift+Escape), the first push at that spot only lets the pointer go. Capture starts off the async workers, as it can now wait a long time for someone to answer the dialog. --- Cargo.lock | 27 + README.md | 2 +- crates/core/src/layout.rs | 50 +- crates/mousetail/Cargo.toml | 3 + crates/mousetail/src/node.rs | 11 +- .../mousetail/src/platform/linux/capture.rs | 17 +- crates/mousetail/src/platform/linux/mod.rs | 1 + crates/mousetail/src/platform/linux/portal.rs | 716 ++++++++++++++++++ crates/mousetail/src/platform/linux/uinput.rs | 46 +- docs/DESIGN.md | 2 +- website/index.html | 3 +- 11 files changed, 860 insertions(+), 18 deletions(-) create mode 100644 crates/mousetail/src/platform/linux/portal.rs diff --git a/Cargo.lock b/Cargo.lock index ac34162..4e02834 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -742,6 +742,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "futures-sink" version = "0.3.34" @@ -761,6 +772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", @@ -1152,6 +1164,7 @@ dependencies = [ "cpal", "dispatch2", "evdev", + "futures-util", "hostname", "libc", "mousetail-core", @@ -1165,6 +1178,7 @@ dependencies = [ "objc2-metal", "objc2-quartz-core", "pipewire", + "reis", "serde", "serde_json", "sha2", @@ -1853,6 +1867,19 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reis" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b967ec6489a42067a20724f11987bb96178ea796873cbe1afd25fb93ee6f85f" +dependencies = [ + "enumflags2", + "futures-util", + "log", + "rustix", + "tokio", +] + [[package]] name = "ring" version = "0.17.14" diff --git a/README.md b/README.md index 17a6b6a..f0e05ad 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Linux → Linux, and each can be both at different times. | **Linux: Hyprland** (incl. [Omarchy](https://omarchy.org)) | Yes | Yes | | **Linux: Sway, river, niri and other wlroots desktops** | Yes, untested | Yes, untested | | **Linux: KDE Plasma** | Yes, untested | Yes, after `enable-input.sh`, untested | -| **Linux: GNOME** | Not yet | Yes, after `enable-input.sh`, untested | +| **Linux: GNOME** | Yes; GNOME asks each time MouseTail starts | Yes, after `enable-input.sh`, untested | | **Windows** | Planned | Planned | Clipboard works everywhere except GNOME (which doesn't let background apps use the diff --git a/crates/core/src/layout.rs b/crates/core/src/layout.rs index 7b349fe..dcd8e61 100644 --- a/crates/core/src/layout.rs +++ b/crates/core/src/layout.rs @@ -315,17 +315,32 @@ impl Layout { /// Stretches of display edge where the cursor passes to another computer, for drawing in /// the arrangement view. Each is (start, end) in layout coordinates. pub fn crossing_edges(&self) -> Vec<(Point, Point)> { - self.edges_where(|from, to| from != to) + let edges = self.edges_where(|from, to| from != to); + edges.into_iter().map(|(_, _, a, b)| (a, b)).collect() } /// Stretches of machine `a`'s display edges that lead to machine `b`. pub fn edges_between(&self, a: usize, b: usize) -> Vec<(Point, Point)> { - self.edges_where(|from, to| from == a && to == b) + let edges = self.edges_where(|from, to| from == a && to == b); + edges.into_iter().map(|(_, _, a, b)| (a, b)).collect() + } + + /// Stretches of `machine`'s display edges that lead to another computer, with the display + /// (its index) and side each is on: where a capture backend that sets barriers puts them. + pub fn exit_edges(&self, machine: usize) -> Vec<(usize, Side, Point, Point)> { + let edges = self.edges_where(|from, to| from == machine && to != machine); + edges + .into_iter() + .map(|(d, side, a, b)| (d.display, side, a, b)) + .collect() } /// Stretches of display edge leading from one machine to another, for which `leads` /// (from machine, to machine) holds. - fn edges_where(&self, leads: impl Fn(usize, usize) -> bool) -> Vec<(Point, Point)> { + fn edges_where( + &self, + leads: impl Fn(usize, usize) -> bool, + ) -> Vec<(DisplayRef, Side, Point, Point)> { const STEP: f64 = 2.0; let mut out = vec![]; for from in self.displays().collect::>() { @@ -351,7 +366,7 @@ impl Layout { match (crosses, run) { (true, None) => run = Some(v), (false, Some(start)) => { - out.push((at(start), at(v.min(hi)))); + out.push((from, side, at(start), at(v.min(hi)))); run = None; } _ => {} @@ -642,6 +657,33 @@ mod tests { assert!(edges.contains(&(Point::new(0.0, 0.0), Point::new(0.0, 596.0)))); } + #[test] + fn exit_edges_say_which_display_and_side() { + let l = real_desk(); + assert_eq!( + l.exit_edges(0), + vec![ + (0, Side::Left, Point::new(0.0, 0.0), Point::new(0.0, 596.0)), + ( + 1, + Side::Left, + Point::new(-587.0, -484.0), + Point::new(-587.0, 0.0) + ), + ] + ); + // The iMac's right edge leads to the Mac all the way down: the Dell, then the MacBook. + assert_eq!( + l.exit_edges(1), + vec![( + 0, + Side::Right, + Point::new(-587.0, -484.0), + Point::new(-587.0, 596.0) + )] + ); + } + #[test] fn locate_and_nearest() { let l = desk(); diff --git a/crates/mousetail/Cargo.toml b/crates/mousetail/Cargo.toml index 799dc64..c557b26 100644 --- a/crates/mousetail/Cargo.toml +++ b/crates/mousetail/Cargo.toml @@ -45,3 +45,6 @@ wl-clipboard-rs = "0.9" pipewire = { version = "0.10", features = ["v0_3_49"] } xkbcommon = "0.9" zbus = { version = "5", default-features = false, features = ["tokio"] } +# libei, which carries input captured through the desktop portal (GNOME). +reis = { version = "0.7", features = ["tokio"] } +futures-util = "0.3" diff --git a/crates/mousetail/src/node.rs b/crates/mousetail/src/node.rs index 0f914b8..85127b2 100644 --- a/crates/mousetail/src/node.rs +++ b/crates/mousetail/src/node.rs @@ -480,7 +480,15 @@ impl Node { async fn start_capture(self: Arc, actions: mpsc::UnboundedSender) { let mut prompt = true; loop { - match platform::Capture::start(self.controller.clone(), actions.clone(), prompt) { + // Not on an async worker: it can wait a long time (on GNOME, for someone to answer + // the permission dialog). + let (controller, actions) = (self.controller.clone(), actions.clone()); + let started = tokio::task::spawn_blocking(move || { + platform::Capture::start(controller, actions, prompt) + }) + .await + .unwrap_or_else(|e| Err(anyhow::anyhow!("starting capture: {e}"))); + match started { Ok(c) => { let _ = self.capture.set(c); *self.capture_error.lock().unwrap() = None; @@ -1450,6 +1458,7 @@ impl Node { self.apply_actions(actions); } Message::Enter { x, y } if self.target.get().is_some() => { + debug!("cursor ← {id}"); // Being controlled: our own cursor comes home if it's off on another computer // (perhaps this one: its own mouse took the cursor back, or we both crossed at // once), and our capture stands down until they leave. diff --git a/crates/mousetail/src/platform/linux/capture.rs b/crates/mousetail/src/platform/linux/capture.rs index 22e607d..b098e56 100644 --- a/crates/mousetail/src/platform/linux/capture.rs +++ b/crates/mousetail/src/platform/linux/capture.rs @@ -7,6 +7,8 @@ //! against the edge". That feeds the same `Controller` the Mac uses; when it decides to //! cross we lock the pointer to the strip, take keyboard focus and inhibit compositor //! shortcuts, and forward everything until it brings the cursor home. +//! +//! Without layer-shell (GNOME), the desktop portal does the watching instead (`portal`). use std::collections::HashSet; use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; @@ -50,7 +52,7 @@ use wayland_protocols_wlr::layer_shell::v1::client::{ use crate::platform::Edge; -enum Cmd { +pub(super) enum Cmd { Apply(Action), Edges(Vec), HideCursor, @@ -83,7 +85,7 @@ impl Capture { let (ready_tx, ready_rx) = mpsc::channel(); thread::Builder::new() .name("capture".into()) - .spawn(move || match Grabber::connect(controller, actions) { + .spawn(move || match Grabber::connect(&controller, &actions) { Ok((grabber, queue)) => { let _ = ready_tx.send(Ok(())); if let Err(e) = grabber.run(queue, rx, wake_rx) { @@ -96,7 +98,8 @@ impl Capture { } } Err(e) => { - let _ = ready_tx.send(Err(e)); + debug!("no layer-shell capture ({e:#}), so the desktop portal"); + super::portal::run(controller, actions, rx, wake_rx, ready_tx); } })?; ready_rx.recv().context("capture thread died")??; @@ -204,8 +207,8 @@ struct Globals { impl Grabber { fn connect( - controller: Arc>, - actions: UnboundedSender, + controller: &Arc>, + actions: &UnboundedSender, ) -> anyhow::Result<(Self, EventQueue)> { let conn = Connection::connect_to_env().context("connecting to the Wayland compositor")?; // Gather globals on a throwaway queue, then build the real state. @@ -262,8 +265,8 @@ impl Grabber { buttons: HashSet::new(), grab: None, scroll: PendingScroll::default(), - controller, - actions, + controller: controller.clone(), + actions: actions.clone(), }; Ok((grabber, queue)) } diff --git a/crates/mousetail/src/platform/linux/mod.rs b/crates/mousetail/src/platform/linux/mod.rs index 93d26f4..86601f7 100644 --- a/crates/mousetail/src/platform/linux/mod.rs +++ b/crates/mousetail/src/platform/linux/mod.rs @@ -10,6 +10,7 @@ mod keystate; pub mod mpris; mod outputs; pub mod player; +mod portal; pub mod ripple; mod uinput; diff --git a/crates/mousetail/src/platform/linux/portal.rs b/crates/mousetail/src/platform/linux/portal.rs new file mode 100644 index 0000000..e86f5c3 --- /dev/null +++ b/crates/mousetail/src/platform/linux/portal.rs @@ -0,0 +1,716 @@ +//! Linux as the main computer where there's no `wlr-layer-shell` (GNOME), through the +//! InputCapture desktop portal. +//! +//! We set pointer barriers along the stretches of edge that lead to other computers. When the +//! pointer pushes through one, the desktop holds it there and sends us everything (relative +//! motion, buttons, scrolling, keys) over libei until we let it go, saying where the pointer +//! comes back. That feeds the same `Controller` as everywhere else. Barriers can only change +//! while capture is off, so a new arrangement waits until the cursor is home. +//! +//! GNOME asks the person each time MouseTail starts (this version of the portal can't remember +//! the answer). If they say no, we don't ask again until the next start. + +use std::collections::{HashMap, HashSet}; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::os::unix::net::UnixStream; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, mpsc}; + +use anyhow::{Context, anyhow, bail}; +use futures_util::StreamExt; +use mousetail_core::controller::{Action, Controller, Input}; +use mousetail_core::keys::ev; +use mousetail_core::layout::{Point, Side}; +use mousetail_core::proto::Scroll; +use reis::ei::{self, button::ButtonState, handshake::ContextType, keyboard::KeyState}; +use reis::event::{DeviceCapability, EiEvent}; +use reis::tokio::EiConvertEventStream; +use tokio::io::unix::AsyncFd; +use tokio::sync::mpsc::UnboundedSender; +use tracing::{debug, warn}; +use zbus::zvariant::{self, OwnedObjectPath, OwnedValue, Structure, Value}; +use zbus::{Connection, Message, Proxy}; + +use super::capture::Cmd; + +const DESKTOP: &str = "org.freedesktop.portal.Desktop"; +const DESKTOP_PATH: &str = "/org/freedesktop/portal/desktop"; +const INPUT_CAPTURE: &str = "org.freedesktop.portal.InputCapture"; +/// Keyboard and pointer. +const CAPABILITIES: u32 = 1 | 2; + +/// Once the person has been asked, why capture can't start this time MouseTail runs: the +/// retries mustn't put the question up again every couple of seconds. +static GAVE_UP: OnceLock = OnceLock::new(); + +type Options<'a> = HashMap<&'a str, Value<'a>>; +type Results = HashMap; + +/// Capture through the portal on this thread until it stops. `ready` hears once it's running +/// (the person allowed it), or why not. +pub(super) fn run( + controller: Arc>, + actions: UnboundedSender, + cmds: mpsc::Receiver, + wake: OwnedFd, + ready: mpsc::Sender>, +) { + if let Some(why) = GAVE_UP.get() { + let _ = ready.send(Err(anyhow!("{why}"))); + return; + } + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(e) => { + let _ = ready.send(Err(e.into())); + return; + } + }; + runtime.block_on(async move { + let (mut portal, events) = match Portal::open(controller, actions).await { + Ok(opened) => opened, + Err(e) => { + let _ = ready.send(Err(e)); + return; + } + }; + let _ = ready.send(Ok(())); + if let Err(e) = portal.run(events, cmds, wake).await { + warn!("input capture stopped: {e:#}"); + } + // Nothing can bring the cursor back from here on, so it comes home now. + portal.go_home().await; + }); +} + +/// A stretch of one of our edges that leads to another computer, in local coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Barrier { + side: Side, + /// Start, and end (exclusive), along the edge. + from: Point, + to: Point, +} + +impl Barrier { + /// x1, y1, x2, y2 as the portal wants them: on the edge, both ends inclusive. + fn position(&self) -> (i32, i32, i32, i32) { + let (x, y) = (self.from.x.round() as i32, self.from.y.round() as i32); + match self.side { + Side::Left | Side::Right => (x, y, x, self.to.y.round() as i32 - 1), + Side::Above | Side::Below => (x, y, self.to.x.round() as i32 - 1, y), + } + } + + /// Where the pointer is held after pushing through at `p` (just inside the screen), and + /// which way it was going. + fn crossing(&self, p: Point) -> (Point, f64, f64) { + let along = |v: f64, lo: f64, hi: f64| v.min(hi - 1.0).max(lo); + let (from, to) = (self.from, self.to); + match self.side { + Side::Left => (Point::new(from.x, along(p.y, from.y, to.y)), -1.0, 0.0), + Side::Right => (Point::new(from.x - 1.0, along(p.y, from.y, to.y)), 1.0, 0.0), + Side::Above => (Point::new(along(p.x, from.x, to.x), from.y), 0.0, -1.0), + Side::Below => (Point::new(along(p.x, from.x, to.x), from.y - 1.0), 0.0, 1.0), + } + } + + fn distance(&self, p: Point) -> f64 { + let (q, ..) = self.crossing(p); + (q.x - p.x).hypot(q.y - p.y) + } +} + +struct Portal { + conn: Connection, + portal: Proxy<'static>, + session: OwnedObjectPath, + ei: ei::Context, + controller: Arc>, + actions: UnboundedSender, + /// Barrier `n` (they count from 1) is `barriers[n - 1]`. + barriers: Vec, + /// The barriers need setting again once the cursor is home. + stale: bool, + /// The capture under way, by its activation id. + active: Option, + /// The cursor is on another computer. + grabbed: bool, + /// Where the pointer is held meanwhile. + held_at: Point, + /// Where the desktop last ended a capture itself, leaving the pointer on the barrier. + left_on: Option, + buttons: HashSet, +} + +impl Portal { + async fn open( + controller: Arc>, + actions: UnboundedSender, + ) -> anyhow::Result<(Self, EiConvertEventStream)> { + let conn = Connection::session() + .await + .context("connecting to the session bus")?; + let portal = Proxy::new(&conn, DESKTOP, DESKTOP_PATH, INPUT_CAPTURE).await?; + // Missing where the desktop can't do it, or isn't up yet (so trying again is fine). + portal.get_property::("version").await.context( + "this desktop supports neither wlr-layer-shell nor the input capture portal, so it \ + can't be the main computer yet", + )?; + + let token = next_token(); + let options = Options::from([ + ("handle_token", Value::from(token.as_str())), + ("session_handle_token", Value::from("mousetail")), + ("capabilities", Value::from(CAPABILITIES)), + ]); + // GNOME asks the person first, so this can take a while. + let answer = request(&conn, &portal, "CreateSession", &token, &("", options)).await?; + let Some(session) = answer + .ok() + .and_then(|mut r| take_path(&mut r, "session_handle")) + else { + return Err(give_up(anyhow!( + "input capture wasn't allowed (GNOME asks each time MouseTail starts)" + ))); + }; + let connect = async { + let fd: zvariant::OwnedFd = portal + .call("ConnectToEIS", &(&session, Options::new())) + .await?; + let ei = ei::Context::new(UnixStream::from(OwnedFd::from(fd)))?; + let (_, events) = ei + .handshake_tokio("MouseTail", ContextType::Receiver) + .await?; + anyhow::Ok((ei, events)) + }; + let (ei, events) = connect + .await + .context("connecting to the desktop's input") + .map_err(give_up)?; + debug!("input capture session {session}"); + let portal = Self { + conn, + portal, + session, + ei, + controller, + actions, + barriers: vec![], + stale: false, + active: None, + grabbed: false, + held_at: Point::default(), + left_on: None, + buttons: HashSet::new(), + }; + Ok((portal, events)) + } + + async fn run( + &mut self, + mut events: EiConvertEventStream, + cmds: mpsc::Receiver, + wake: OwnedFd, + ) -> anyhow::Result<()> { + let wake = AsyncFd::new(wake)?; + let mut activated = self.portal.receive_signal("Activated").await?; + let mut deactivated = self.portal.receive_signal("Deactivated").await?; + let mut zones_changed = self.portal.receive_signal("ZonesChanged").await?; + let mut disabled = self.portal.receive_signal("Disabled").await?; + let session = Proxy::new( + &self.conn, + DESKTOP, + self.session.clone(), + "org.freedesktop.portal.Session", + ) + .await?; + let mut closed = session.receive_signal("Closed").await?; + loop { + tokio::select! { + event = events.next() => match event { + Some(event) => self.on_ei(event.context("input from the desktop")?).await?, + None => bail!("the desktop closed the input connection"), + }, + Some(m) = activated.next() => if let Some(o) = self.ours(&m) { + self.activated(o).await + }, + Some(m) = deactivated.next() => if let Some(o) = self.ours(&m) { + self.deactivated(o).await + }, + // The monitors changed and the desktop switched capture off: set the barriers + // up again (and again once the arrangement follows). + Some(m) = zones_changed.next() => if self.ours(&m).is_some() { + self.stale = true; + self.rearm().await + }, + Some(m) = disabled.next() => if self.ours(&m).is_some() { + self.desktop_ended().await + }, + Some(_) = closed.next() => bail!("the desktop ended input capture"), + ready = wake.readable() => { + let mut ready = ready?; + let mut buf = [0u8; 64]; + while unsafe { libc::read(wake.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len()) } + > 0 + {} + ready.clear_ready(); + while let Ok(cmd) = cmds.try_recv() { + self.command(cmd).await; + } + } + } + } + } + + /// The options of a signal about our session (other apps' come too). + fn ours(&self, m: &Message) -> Option { + let (session, options): (OwnedObjectPath, Results) = m.body().deserialize().ok()?; + (session == self.session).then_some(options) + } + + async fn command(&mut self, cmd: Cmd) { + match cmd { + Cmd::Apply(Action::Release { warp }) => self.release(Some(warp)).await, + // Grabs follow crossings, which happen here; and the portal can't choose the + // pointer's image. + Cmd::Apply(_) | Cmd::HideCursor => {} + // The barriers follow the arrangement those edges came from. + Cmd::Edges(_) => self.rearm().await, + } + } + + // ------------------------------------------------------------------------- barriers + + /// Where barriers go: wherever the arrangement leads off our screens. + fn wanted(&self) -> Vec { + let controller = self + .controller + .lock() + .unwrap_or_else(PoisonError::into_inner); + controller + .layout() + .exit_edges(0) + .into_iter() + .map(|(_, side, from, to)| Barrier { side, from, to }) + .collect() + } + + /// Put the barriers where they're wanted, unless they're there already. + async fn rearm(&mut self) { + if self.grabbed { + // Changing them now would end the capture under way. + self.stale = true; + return; + } + let stale = std::mem::take(&mut self.stale); + let wanted = self.wanted(); + if wanted == self.barriers && !stale { + return; + } + if let Err(e) = self.set_barriers(wanted).await { + warn!("couldn't watch the screen edges: {e:#}"); + } + } + + async fn set_barriers(&mut self, wanted: Vec) -> anyhow::Result<()> { + // They only change while capture is off. (This fails if it's off already.) + let _ = self.call("Disable", Options::new()).await; + self.barriers.clear(); + if wanted.is_empty() { + return Ok(()); + } + let token = next_token(); + let mut zones = self + .request("GetZones", &token, &(&self.session, handle(&token))) + .await?; + let zone_set: u32 = take(&mut zones, "zone_set").context("no zone set")?; + debug!( + "screens {:?}, barriers {wanted:?}", + take::>(&mut zones, "zones") + ); + let list: Vec = wanted + .iter() + .zip(1u32..) + .map(|(b, id)| { + Options::from([ + ("barrier_id", Value::from(id)), + ("position", Value::from(Structure::from(b.position()))), + ]) + }) + .collect(); + let token = next_token(); + let body = (&self.session, handle(&token), list, zone_set); + let mut set = self.request("SetPointerBarriers", &token, &body).await?; + let failed: Vec = take(&mut set, "failed_barriers").unwrap_or_default(); + if !failed.is_empty() { + warn!("the desktop wouldn't watch edges {failed:?} of {wanted:?}"); + } + self.call("Enable", Options::new()) + .await + .context("turning capture on")?; + self.barriers = wanted; + Ok(()) + } + + /// Arm the barriers again (it fails harmlessly if they're armed already). + async fn enable(&self) { + if self.barriers.is_empty() { + return; + } + if let Err(e) = self.call("Enable", Options::new()).await { + debug!("turning capture back on: {e}"); + } + } + + // ------------------------------------------------------------------------- capture + + /// The pointer pushed through a barrier and the desktop is holding it there. + async fn activated(&mut self, mut o: Results) { + let id = take::(&mut o, "activation_id"); + let at = take::<(f64, f64)>(&mut o, "cursor_position").map(|(x, y)| Point::new(x, y)); + let barrier = take::(&mut o, "barrier_id"); + debug!("capture {id:?} at {at:?} through barrier {barrier:?}"); + self.active = Some(id.unwrap_or_default()); + self.buttons.clear(); + let barrier = barrier + .and_then(|b| self.barriers.get((b as usize).checked_sub(1)?)) + .or_else(|| { + let p = at?; + (self.barriers.iter()).min_by(|a, b| a.distance(p).total_cmp(&b.distance(p))) + }) + .copied(); + let (Some(barrier), Some(at)) = (barrier, at) else { + return self.release(None).await; + }; + let (at, dx, dy) = barrier.crossing(at); + if let Some(p) = self.left_on.take() + && (p.x - at.x).abs() < 2.0 + && (p.y - at.y).abs() < 2.0 + { + // The pointer moving off where the desktop left it, not pushing on through. + return self.release(Some(at)).await; + } + self.held_at = at; + self.feed(Input::Motion { + at, + dx, + dy, + dragging: false, + }) + .await; + if !self.grabbed { + // Not crossing after all (being controlled, or the computer there is asleep): + // give the pointer straight back. + self.release(Some(at)).await; + } + } + + async fn deactivated(&mut self, mut o: Results) { + let id = take::(&mut o, "activation_id"); + if self.active.is_none() || id.is_some_and(|id| Some(id) != self.active) { + // One we released, or an old one. + return; + } + // The desktop ended it (its own escape shortcut, say): the cursor comes home, and the + // barriers go back on if that turned them off. + debug!("the desktop ended capture {id:?}"); + self.desktop_ended().await; + } + + async fn desktop_ended(&mut self) { + if self.active.take().is_some() { + // It leaves the pointer where it held it, on the barrier. + self.left_on = Some(self.held_at); + } + self.go_home().await; + self.enable().await; + } + + /// Let the pointer go, at `warp` (where the controller says it came home). + async fn release(&mut self, warp: Option) { + self.grabbed = false; + if let Some(id) = self.active.take() { + let mut options = Options::from([("activation_id", Value::from(id))]); + if let Some(p) = warp.map(|p| clear_of(&self.barriers, p)) { + debug!("capture {id} over, the pointer back at {p:?}"); + options.insert("cursor_position", Value::from(Structure::from((p.x, p.y)))); + } + if let Err(e) = self.call("Release", options).await { + debug!("letting the pointer go: {e}"); + } + } + if self.stale { + self.rearm().await; + } + } + + async fn go_home(&mut self) { + let actions = self + .controller + .lock() + .unwrap_or_else(PoisonError::into_inner) + .release(); + self.apply(actions).await; + } + + async fn feed(&mut self, input: Input) { + let actions = self + .controller + .lock() + .unwrap_or_else(PoisonError::into_inner) + .handle(input) + .actions; + self.apply(actions).await; + } + + async fn apply(&mut self, actions: Vec) { + for action in actions { + match action { + Action::Grab => self.grabbed = true, + Action::Release { warp } => self.release(Some(warp)).await, + other => { + let _ = self.actions.send(other); + } + } + } + } + + async fn on_ei(&mut self, event: EiEvent) -> anyhow::Result<()> { + match event { + EiEvent::SeatAdded(e) => { + e.seat.bind_capabilities( + DeviceCapability::Pointer + | DeviceCapability::Button + | DeviceCapability::Scroll + | DeviceCapability::Keyboard, + ); + self.ei.flush().context("answering the desktop")?; + } + EiEvent::Disconnected(e) => { + bail!("the desktop disconnected ({:?})", e.reason) + } + EiEvent::DeviceAdded(e) => debug!("captured device {:?}", e.device.name()), + EiEvent::DeviceRemoved(e) => debug!("captured device {:?} gone", e.device.name()), + _ if !self.grabbed => {} + EiEvent::PointerMotion(e) => { + let dragging = self.buttons.iter().any(|b| ev::is_button(*b)); + self.feed(Input::Motion { + at: self.held_at, + dx: e.dx.into(), + dy: e.dy.into(), + dragging, + }) + .await; + } + EiEvent::Button(e) => { + let (code, down) = (e.button as u16, e.state == ButtonState::Press); + if down { + self.buttons.insert(code); + } else { + self.buttons.remove(&code); + } + self.feed(Input::Button { code, down }).await; + } + // Wheels come in whole notches of 120; trackpads smoothly. + EiEvent::ScrollDiscrete(e) => { + let (x, y) = (e.discrete_dx, e.discrete_dy); + let notches = (x % 120 == 0 && y % 120 == 0).then_some((x / 120, y / 120)); + self.feed(Input::Scroll(Scroll { + dx: x as f64 / 8.0, + dy: y as f64 / 8.0, + notches, + })) + .await; + } + EiEvent::ScrollDelta(e) => { + self.feed(Input::Scroll(Scroll { + dx: e.dx.into(), + dy: e.dy.into(), + notches: None, + })) + .await; + } + EiEvent::KeyboardKey(e) => { + let down = e.state == KeyState::Press; + self.feed(Input::Key { + code: e.key as u16, + down, + }) + .await; + } + _ => {} + } + Ok(()) + } + + // ------------------------------------------------------------------------- D-Bus + + /// Call a portal method on our session that answers straight away. + async fn call(&self, method: &str, options: Options<'_>) -> zbus::Result<()> { + self.portal.call(method, &(&self.session, options)).await + } + + /// Call one that answers later, through a request (and say no if the desktop did). + async fn request(&self, method: &str, token: &str, body: &B) -> anyhow::Result + where + B: serde::Serialize + zvariant::DynamicType, + { + request(&self.conn, &self.portal, method, token, body) + .await? + .map_err(|code| anyhow!("{method} failed ({code})")) + } +} + +/// Call a portal method that answers through a Request object, and wait for the answer: the +/// results, or the portal's code for why not (1: the person said no). +async fn request( + conn: &Connection, + portal: &Proxy<'_>, + method: &str, + token: &str, + body: &B, +) -> anyhow::Result> +where + B: serde::Serialize + zvariant::DynamicType, +{ + let me = conn.unique_name().context("no name on the session bus")?; + let me = me.trim_start_matches(':').replace('.', "_"); + // Listening before asking, so even an instant answer is heard. + let path = format!("{DESKTOP_PATH}/request/{me}/{token}"); + let request = Proxy::new(conn, DESKTOP, path, "org.freedesktop.portal.Request").await?; + let mut answers = request.receive_signal("Response").await?; + let _: OwnedObjectPath = portal + .call(method, body) + .await + .with_context(|| format!("asking the desktop portal to {method}"))?; + let answer = answers + .next() + .await + .context("the desktop portal went away")?; + let (code, results): (u32, Results) = answer.body().deserialize()?; + Ok(if code == 0 { Ok(results) } else { Err(code) }) +} + +/// `p`, moved off any left or top barrier it's on. The desktop puts the pointer back on a +/// whole pixel, and one exactly on a barrier sets it off again as soon as it moves sideways, +/// even away from the edge. (Right and bottom ones sit just past the last pixel.) +fn clear_of(barriers: &[Barrier], mut p: Point) -> Point { + for b in barriers { + match b.side { + Side::Left if p.x < b.from.x + 1.0 && (b.from.y..b.to.y).contains(&p.y) => { + p.x = b.from.x + 1.0 + } + Side::Above if p.y < b.from.y + 1.0 && (b.from.x..b.to.x).contains(&p.x) => { + p.y = b.from.y + 1.0 + } + _ => {} + } + } + p +} + +fn next_token() -> String { + static NEXT: AtomicU32 = AtomicU32::new(0); + format!("mousetail{}", NEXT.fetch_add(1, Ordering::Relaxed)) +} + +fn handle(token: &str) -> Options<'_> { + Options::from([("handle_token", Value::from(token))]) +} + +fn take>(o: &mut Results, key: &str) -> Option { + T::try_from(o.remove(key)?).ok() +} + +/// An object path, which some portals send as a string. +fn take_path(o: &mut Results, key: &str) -> Option { + let v = o.remove(key)?; + match v.try_clone().ok().map(OwnedObjectPath::try_from) { + Some(Ok(path)) => Some(path), + _ => OwnedObjectPath::try_from(String::try_from(v).ok()?).ok(), + } +} + +/// Don't ask the person again this run (see `GAVE_UP`). +fn give_up(e: anyhow::Error) -> anyhow::Error { + let _ = GAVE_UP.set(format!("{e:#}")); + e +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn barriers_sit_on_the_edge_with_inclusive_ends() { + // The portal's own example: two 1920×1080 screens side by side. + let left = Barrier { + side: Side::Left, + from: Point::new(0.0, 0.0), + to: Point::new(0.0, 1080.0), + }; + assert_eq!(left.position(), (0, 0, 0, 1079)); + let right = Barrier { + side: Side::Right, + from: Point::new(3840.0, 0.0), + to: Point::new(3840.0, 1080.0), + }; + assert_eq!(right.position(), (3840, 0, 3840, 1079)); + let below = Barrier { + side: Side::Below, + from: Point::new(1920.0, 1080.0), + to: Point::new(3840.0, 1080.0), + }; + assert_eq!(below.position(), (1920, 1080, 3839, 1080)); + } + + #[test] + fn a_push_through_is_held_just_inside_heading_out() { + let right = Barrier { + side: Side::Right, + from: Point::new(3840.0, 100.0), + to: Point::new(3840.0, 500.0), + }; + // Pushed well past the edge, a little above the stretch: held on it, inside. + let (at, dx, dy) = right.crossing(Point::new(3852.5, 90.0)); + assert_eq!((at, dx, dy), (Point::new(3839.0, 100.0), 1.0, 0.0)); + let above = Barrier { + side: Side::Above, + from: Point::new(0.0, 0.0), + to: Point::new(1920.0, 0.0), + }; + let (at, dx, dy) = above.crossing(Point::new(700.0, -6.0)); + assert_eq!((at, dx, dy), (Point::new(700.0, 0.0), 0.0, -1.0)); + } + + #[test] + fn the_pointer_goes_back_clear_of_left_and_top_barriers() { + let barriers = [ + Barrier { + side: Side::Left, + from: Point::new(0.0, 420.0), + to: Point::new(0.0, 1500.0), + }, + Barrier { + side: Side::Above, + from: Point::new(1520.0, 318.0), + to: Point::new(3440.0, 318.0), + }, + Barrier { + side: Side::Right, + from: Point::new(3760.0, 462.0), + to: Point::new(3760.0, 1542.0), + }, + ]; + let back = |x, y| clear_of(&barriers, Point::new(x, y)); + assert_eq!(back(0.0, 794.0), Point::new(1.0, 794.0)); + assert_eq!(back(2000.0, 318.0), Point::new(2000.0, 319.0)); + // Off the end of the barrier, or already clear: left alone. + assert_eq!(back(0.0, 1600.0), Point::new(0.0, 1600.0)); + assert_eq!(back(3759.0, 800.0), Point::new(3759.0, 800.0)); + } +} diff --git a/crates/mousetail/src/platform/linux/uinput.rs b/crates/mousetail/src/platform/linux/uinput.rs index 65dd60a..7c468f2 100644 --- a/crates/mousetail/src/platform/linux/uinput.rs +++ b/crates/mousetail/src/platform/linux/uinput.rs @@ -99,9 +99,10 @@ impl Uinput { } Cmd::Motion(x, y) => { let b = self.bounds; - let sx = (((x - b.x) / b.w.max(1.0)).clamp(0.0, 1.0) * RANGE as f64).round() as i32; - let sy = (((y - b.y) / b.h.max(1.0)).clamp(0.0, 1.0) * RANGE as f64).round() as i32; - self.emit(&[(EV_ABS, ABS_X, sx), (EV_ABS, ABS_Y, sy)]) + self.emit(&[ + (EV_ABS, ABS_X, axis(x - b.x, b.w)), + (EV_ABS, ABS_Y, axis(y - b.y, b.h)), + ]) } Cmd::Button(code, down) | Cmd::Key(code, down) => { let held = if ev::is_button(code) { @@ -169,3 +170,42 @@ impl Uinput { } } } + +/// Where `v` points along a desktop `len` points wide lands on an absolute axis: the middle of +/// its pixel. Exactly on a screen's edge is where GNOME puts the barriers that tell this +/// computer's own mouse is pushing off it (see `portal`), and one landing there sets them off. +/// The desktop reads the axis back as `value * len / (RANGE + 1)`. +fn axis(v: f64, len: f64) -> i32 { + let len = len.max(1.0); + let pixel = v.clamp(0.0, len - 1.0).floor(); + ((pixel + 0.5) / len * (RANGE + 1) as f64) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positions_land_inside_pixels_never_on_an_edge() { + let read_back = |value: i32, len: f64| value as f64 * len / (RANGE + 1) as f64; + for len in [1080.0, 1920.0, 3760.0, 5120.0] { + for v in [ + -2.0, + 0.0, + 0.2, + 317.99, + 318.0, + 1051.875, + len - 1.0, + len + 3.0, + ] { + let p = read_back(axis(v, len), len); + let pixel = v.clamp(0.0, len - 1.0).floor(); + assert!( + p > pixel + 0.25 && p < pixel + 0.75, + "{v} on {len}: read back as {p}" + ); + } + } + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7d4980c..f194e35 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -72,7 +72,7 @@ that `Enter`, brings its own cursor home. |---|---|---|---| | macOS | Quartz event tap over the whole screen | HID-level `CGEventPost` (drags, click counts, modifier flags, pixel scrolling); wakes the display | CoreGraphics | | Linux, layer-shell compositors (Hyprland, Sway, KDE…) | 1-px overlay strips on edges that lead somewhere; relative-pointer motion while resting on one; pointer lock + exclusive keyboard + shortcuts inhibitor while remote | `zwlr_virtual_pointer` + `zwp_virtual_keyboard` (no root), else uinput | `xdg-output` | -| Linux, GNOME | not yet (needs the InputCapture portal + libei) | uinput absolute pointer + keyboard (after `enable-input.sh` grants `/dev/uinput` via udev `uaccess`) | `xdg-output` | +| Linux, GNOME | InputCapture portal: pointer barriers on the stretches of edge that lead somewhere, then everything over libei while remote. GNOME asks each time MouseTail starts. Barriers sit on screen edges, so injected positions and returning pointers keep off them (the middle of the pixel; a pixel in) | uinput absolute pointer + keyboard (after `enable-input.sh` grants `/dev/uinput` via udev `uaccess`) | `xdg-output` | Key codes travel as evdev codes. The receiving side translates for its platform: a Mac controlling Linux gets the Command remap (below); Linux controlling a Mac is positional (Super diff --git a/website/index.html b/website/index.html index 234b615..993110b 100644 --- a/website/index.html +++ b/website/index.html @@ -276,7 +276,7 @@

What works where.

Mouse and keyboard - Control other computers from hereYes1YesYesYesNot yetPlanned + Control other computers from hereYes1YesYesYesYes9Planned Be controlled from another computerYesYesYesOne-time setup2One-time setup2Planned More than two computersEach beside the one you sit at, and hop straight between themYesYesYesYesYesPlanned Multiple displays on either sidePlug one in or unplug it and MouseTail keeps upYesYesYesYesYesPlanned @@ -318,6 +318,7 @@

What works where.

  • Run enable-wake.sh once. Needs NetworkManager and a network card that can be woken.
  • Omarchy only. Plain Hyprland uses the command line.
  • If your desktop doesn't start systemd's graphical session (Hyprland or Sway without uwsm, say), add one line to its config. The installer shows you which.
  • +
  • GNOME asks each time MouseTail starts: turn on Allow Remote Input Capturing, then Share.
  • Linux means Wayland desktops with systemd and PipeWire. Keys are sent by position, so give both computers the same keyboard layout. Using something else? Tell us how it goes

    From 1d90eedb6e9a2b8e145d99fff5240704ca42c9bf Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 13:35:22 +1300 Subject: [PATCH 02/14] Design for a GNOME Shell extension like the Omarchy bar plugin --- .../2026-10-02-gnome-extension-design.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-02-gnome-extension-design.md diff --git a/docs/superpowers/specs/2026-10-02-gnome-extension-design.md b/docs/superpowers/specs/2026-10-02-gnome-extension-design.md new file mode 100644 index 0000000..bc113b7 --- /dev/null +++ b/docs/superpowers/specs/2026-10-02-gnome-extension-design.md @@ -0,0 +1,127 @@ +# GNOME Shell extension — design + +2026-10-02 · Agreed in conversation; this records it for review before planning. + +## Goal + +People on GNOME get what Omarchy gets from its bar plugin: an icon in the top bar whose menu +shows MouseTail's state and has every control the Omarchy panel has, plus Arrange Displays. +The installer works out which desktop it's on and puts the matching integration in place; the +uninstaller takes away whichever is there. + +Success: on this Fedora 44 / GNOME 50 machine, a fresh `install.sh` from a release folder +leaves the extension installed and enabled for the next login; after logging in, every control +below works against the running daemon; `uninstall.sh` removes the extension again. + +## Not in scope + +- GNOME versions other than 50: only 50 can be tested here (add others once tried). +- Daemon changes or new IPC: the extension only uses existing `mousetail` commands. +- A keyboard shortcut for Arrange Displays (Omarchy's IPC hook has no GNOME equivalent yet). +- Publishing on extensions.gnome.org. + +## Files + +`integrations/gnome/mousetail@galen.green/` (UUID on the project's domain, galen.green): + +| File | Role | +|---|---| +| `metadata.json` | uuid, name, description, `shell-version: ["50"]`, url; `version-name` set by packaging | +| `extension.js` | the `Extension`: the top-bar indicator and its menu, `mousetail watch`, the command queue | +| `arrange.js` | the Arrange Displays dialog | +| `status.js` | pure logic, no GNOME imports: the summary line, each computer's status line, the arrange view's fit and transform | +| `stylesheet.css` | menu and dialog styling | +| `icons/mousetail-symbolic.svg` | the logo (from the Omarchy plugin's `icon.svg`) as a symbolic icon, so the top bar tints it | +| `test/status.test.js` | a runnable check of `status.js` | + +MIT, like the rest of the repo. + +## Talking to MouseTail + +Exactly as the Omarchy plugin does, through `~/.local/bin/mousetail`: + +- **Status:** `mousetail watch` under `Gio.Subprocess`, one JSON line per change (the status, or + `{"running": false}`). When it exits: not running, and start it again 3 s later. Killed in + `disable()`. +- **Commands,** one at a time in a queue: `set on|off`, + `pause|resume|unpair `, `update`, and `systemctl --user + enable|disable|start|stop|is-enabled mousetail`. A command's stderr (less a leading + `mousetail: `) or `update`'s stdout becomes the menu's message line. +- **Pairing:** `mousetail pair ` with stdin open; the typed code goes to stdin; a + `Paired with` line ends it. A wrong code ends the command, so stderr or a failed exit shows + as the error and the row offers **Pair…** again for a fresh code (the Omarchy panel leaves a + dead field there instead). +- **Arrange:** `mousetail layout` (machines with displays and offsets, and `crossings`), and + `mousetail place-at ` (answers with the snapped `offset`). +- **Fix Firewall…:** runs `~/.local/share/mousetail/enable-firewall.sh` in a terminal (it asks + for a password): `xdg-terminal-exec`, else Ptyxis, Console (`kgx`), GNOME Terminal. + +## The menu + +Same order and wording as the Omarchy panel: + +1. **Header:** the logo, "MouseTail", and the summary: Not running / Pairing with X / In use + from X / Using X / Connected to X, Y / Not connected. +2. **Problems** (only when there are any): the capture error; "Other computers can't control + this one yet. Run this once: ~/.local/share/mousetail/enable-input.sh"; the firewall note with + a **Fix Firewall…** button. +3. **Pairing code** (while this computer shows one): the digits, large and spaced, and "Type + this on X to connect it." A newly shown code opens the menu, as on Omarchy. +4. **Computers:** each paired or connected one, with its name (dimmed when offline or paused) + and status line (Found on your network / Paused / Offline / Using this computer now / + Connected · its sound plays here …). **Pair…** for unpaired ones found on the network; + **Pause/Resume** and **Forget** for paired ones. Pairing shows a code field under the row + (Enter sends, Escape or Cancel stops). "Looking for other computers on your network…" when + there are none. **Arrange Displays…** once one is paired. +5. **Settings** (switches): Sound follows you, Share clipboard, Ripple when crossing, Start at + login (read with `systemctl --user is-enabled` each time the menu opens), Update + automatically. +6. **Footer:** the last message, **Check for Updates**, **Stop MouseTail** / **Start + MouseTail**, and the version. When not running: "MouseTail isn't running." and Start. + +## Arrange Displays + +A modal dialog (GNOME's `ModalDialog`: the screen dims, a card in the middle, Escape closes), +looking like the Omarchy overlay: MouseTail's own colours (#080808 card, #111212 canvas, +#f2f1ec text, #ffeba7 glow) rather than the theme's; "Arrange Displays" and the same help text; +every machine's displays drawn to scale and fitted with a 40 px margin; this computer graphite, +others warm, offline ones dim; a strip on each computer's main display; "This computer" or the +name, and the display name or "Offline". Other computers' tiles drag; dropping one calls +`place-at` and the tile glides (220 ms) to where it snapped. Glowing lines mark the crossings, +hidden while dragging. The layout reloads every 2 s while nothing is being dragged. Footer: +the legend (This computer · Other computers · Cursor crosses here) and **Done**. + +## Install, update, uninstall + +- **Which desktop:** Omarchy when `~/.config/omarchy` exists (as now). GNOME when + `$XDG_CURRENT_DESKTOP` names GNOME or `gnome-shell` is running for this user (so it also works + from SSH or a TTY). Neither: no integration, as now. +- **Installing on GNOME:** copy the extension to + `${XDG_DATA_HOME:-~/.local/share}/gnome-shell/extensions/mousetail@galen.green` (replacing an + old copy), then `gnome-extensions enable`; for a new install the running shell hasn't seen it + and refuses, so add the UUID to `org.gnome.shell enabled-extensions` (and take it out of + `disabled-extensions`) through `gjs` and `Gio.Settings`. Say the icon appears after the next + login (GNOME on Wayland only loads new extensions then). If all extensions are switched off + (`disable-user-extensions`), say that instead. +- **Uninstalling:** if the extension is there: `gnome-extensions disable` (or take the UUID out + of `enabled-extensions`), then delete it. The Omarchy plugin is removed as now. +- **Updating:** the updater replaces an installed extension from the release's + `gnome-extension/` folder, like the Omarchy plugin; GNOME runs the new copy from the next login. +- **Packaging:** the Linux tarball gains `gnome-extension/mousetail@galen.green`, its + `version-name` set to MouseTail's version. + +## Testing + +- `test/status.test.js`: the summary for each state, the status lines, and the fit maths, run + with Node in podman (no Node on the host). `node --check` on every module. +- The installer, end to end on this machine: package a release folder in the build container, + run its `install.sh`, check the files and `enabled-extensions`; run `uninstall.sh`, check + they've gone. +- The UI in a real GNOME Shell: a nested one (`gnome-shell --devkit`) if `mutter-devkit` gets + installed, otherwise after logging out and in. Check each menu item and the arrange drag + against the running daemon and the Omarchy laptop. + +## Docs + +README (the installer's note, arranging from GNOME's top bar, the project layout table), the +website table's GNOME cells (status, pause, arrange), and DESIGN.md's front-end note. From ab7c8211aadbc7cf21661fd3a095c3efc82e6ebe Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 13:54:05 +1300 Subject: [PATCH 03/14] Plan for the GNOME Shell extension --- .../plans/2026-10-02-gnome-extension.md | 1587 +++++++++++++++++ 1 file changed, 1587 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-02-gnome-extension.md diff --git a/docs/superpowers/plans/2026-10-02-gnome-extension.md b/docs/superpowers/plans/2026-10-02-gnome-extension.md new file mode 100644 index 0000000..d2aa0a3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-gnome-extension.md @@ -0,0 +1,1587 @@ +# GNOME Shell extension — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A GNOME Shell 50 extension that gives GNOME what the Omarchy bar plugin gives Omarchy, installed and removed by MouseTail's own installer and uninstaller. + +**Architecture:** An ESM GNOME Shell extension in `integrations/gnome/mousetail@galen.green/`. A +`PanelMenu.Button` streams status from `mousetail watch` and runs every action as a `mousetail` or +`systemctl --user` command, exactly like the Omarchy plugin. A `ModalDialog` subclass draws +Arrange Displays. The logic that needs no GNOME (summary lines, the arrange view's maths) lives in +`status.js` so Node can test it. The installer picks the integration by desktop. + +**Tech Stack:** GJS (ESM), St, Clutter, Gio.Subprocess; GNOME Shell 50.5 APIs (`PanelMenu`, +`PopupMenu`, `ModalDialog`); bash; Rust (one updater hunk); Node 22 (tests, in podman). + +**Spec:** `docs/superpowers/specs/2026-10-02-gnome-extension-design.md` + +## Global Constraints + +- `metadata.json`: `"uuid": "mousetail@galen.green"`, `"shell-version": ["50"]`. +- Extension files live in `integrations/gnome/mousetail@galen.green/`; tests in `integrations/gnome/test/` (never installed). +- Only existing commands through `~/.local/bin/mousetail` (`watch`, `set`, `pause`, `resume`, `unpair`, `pair`, `update`, `layout`, `place-at`) and `systemctl --user … mousetail`. No daemon changes. +- MIT, like the repo; no GPL template header. +- Wording follows the Omarchy panel (`integrations/omarchy/nz.galengreen.mousetail/Widget.qml`) exactly, except where GNOME needs otherwise (no tooltips; pairing offers Pair… again after a wrong code). +- Arrange Displays uses MouseTail's colours: card `#080808`, canvas `#111212`, text `#f2f1ec`, glow `#ffeba7`; tiles graphite (`#383939`→`#292a2a`) for this computer, warm (`#4a4535`→`#353226`) for others, dim (`#232424`→`#1b1c1c`) offline; glide 220 ms. +- GNOME 50 APIs: `St.BoxLayout` takes `orientation: Clutter.Orientation.VERTICAL` (not `vertical`). +- Every `GObject.registerClass` gets an explicit `GTypeName` starting `MouseTail`, so it can't clash with another extension's class. +- Installer: Omarchy when `$config_home/omarchy` exists; GNOME when `$XDG_CURRENT_DESKTOP` contains `GNOME` or `gnome-shell` runs for this user. +- Commit as the global git user; no attribution lines in commit messages. + +## Review Focus + +- MouseTail not installed, or its daemon stopped and started: the icon says "Not running" and recovers within ~3 s of it starting, without errors repeating in the log (Task 2, step 7). +- The extension disabled while commands are still running (the screen locks, say): their replies must not touch destroyed widgets (Task 2, step 8). +- A status update arriving while a pairing code is half typed must leave the field and its text alone (Task 3, step 6). +- Arrange Displays closed while `layout` or `place-at` is still running: no errors when the reply comes (Task 4, step 6). +- A newly shown pairing code opens the menu once, not again on every later update (Task 3, step 7). + +--- + +### Task 1: Status logic (`status.js`) and its test + +**Files:** +- Create: `integrations/gnome/mousetail@galen.green/status.js` +- Test: `integrations/gnome/test/status.test.js` + +**Interfaces:** +- Produces (all pure, no imports): `parse(line) → status | null`; `nameOf(status, id) → string`; `shownPeers(status) → peer[]`; `summary(status) → string`; `peerDetail(status, peer) → string`; `setting(status, key) → bool`; `placed(layout) → machine[]`; `offsetOf(machine) → {x, y}`; `fit(machines, width, height, margin = 40) → {scale, x, y, bx, by}`; `viewX(view, x)`, `viewY(view, y) → number`; `dropAt(machine, view, dx, dy) → {x, y}` (rounded); `crossingBar(view, [a, b], thickness = 3) → {x, y, width, height}`. + +- [ ] **Step 1: Write the failing test** + +`integrations/gnome/test/status.test.js`: + +```js +// The top-bar menu's logic, outside GNOME: node --test integrations/gnome/test/ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +import * as Status from '../mousetail@galen.green/status.js'; + +const omarchy = {id: 'o', name: 'omarchy', paired: true, connected: true, paused: false, sound: 'here'}; +const running = {running: true, peers: [omarchy]}; + +test('reads watch lines, ignoring junk', () => { + assert.equal(Status.parse('not json'), null); + assert.deepEqual(Status.parse('{"running":false}'), {running: false}); + assert.deepEqual(Status.parse(JSON.stringify(running)), running); +}); + +test('the summary says what MouseTail is doing', () => { + assert.equal(Status.summary({running: false}), 'Not running'); + assert.equal(Status.summary(running), 'Connected to omarchy'); + assert.equal(Status.summary({...running, controlling: 'o'}), 'Using omarchy'); + assert.equal(Status.summary({...running, controlled_by: 'o'}), 'In use from omarchy'); + assert.equal(Status.summary({...running, pairing_code: {name: 'mac', code: '1234'}}), 'Pairing with mac'); + assert.equal(Status.summary({...running, pairing_code: {code: '1234'}}), 'Pairing with another computer'); + assert.equal(Status.summary({...running, peers: [{...omarchy, paused: true}]}), 'Not connected'); +}); + +test('each computer says how it is', () => { + const detail = p => Status.peerDetail(running, p); + assert.equal(detail(omarchy), 'Connected · its sound plays here'); + assert.equal(detail({...omarchy, sound: 'there'}), 'Connected · plays your sound'); + assert.equal(detail({...omarchy, connected: false}), 'Offline'); + assert.equal(detail({...omarchy, paused: true, connected: false}), 'Paused · Offline'); + assert.equal(detail({...omarchy, paired: false}), 'Found on your network'); + assert.equal(Status.peerDetail({...running, controlled_by: 'o'}, omarchy), 'Using this computer now'); + assert.equal(Status.nameOf(running, 'nobody'), 'nobody'); +}); + +test('only paired or found computers are listed', () => { + const gone = {id: 'g', name: 'gone', paired: false, connected: false}; + assert.deepEqual(Status.shownPeers({running: true, peers: [omarchy, gone]}), [omarchy]); +}); + +test('settings read as the daemon defaults them', () => { + assert.equal(Status.setting({running: true}, 'clipboard'), true); + assert.equal(Status.setting({settings: {}}, 'clipboard'), false); + assert.equal(Status.setting({settings: {clipboard: true, audio: false}}, 'audio'), false); + assert.equal(Status.setting({settings: {}}, 'ripple'), true); +}); + +test('the arrange view fits everything in, centred, with a margin', () => { + const screen = {rect: {x: 0, y: 0, w: 1000, h: 500}}; + const machines = [ + {id: 'f', this: true, displays: [screen]}, + {id: 'o', offset: {x: 1000, y: 0}, displays: [screen]}, + ]; + const view = Status.fit(machines, 480, 400); + // 2000 × 500 points into 400 × 320 pixels: the width decides. + assert.equal(view.scale, 0.2); + assert.equal(view.x, 40); + assert.equal(view.y, 150); + assert.equal(Status.viewX(view, 1000), 240); + assert.equal(Status.viewY(view, 500), 250); + assert.deepEqual(Status.dropAt(machines[1], view, 20, -10), {x: 1100, y: -50}); + assert.deepEqual(Status.crossingBar(view, [{x: 1000, y: 0}, {x: 1000, y: 500}]), + {x: 238.5, y: 148.5, width: 3, height: 103}); + assert.deepEqual(Status.placed({machines: [...machines, {id: 'x', displays: [screen]}]}), machines); + assert.deepEqual(Status.fit([], 480, 400), {scale: 0.1, x: 0, y: 0, bx: 0, by: 0}); +}); +``` + +- [ ] **Step 2: Run it to see it fail** + +Run: `podman run --rm --security-opt label=disable -v "$PWD":/src -w /src docker.io/library/node:22-alpine node --test integrations/gnome/test/` +Expected: FAIL, `Cannot find module '…/mousetail@galen.green/status.js'`. + +- [ ] **Step 3: Write `status.js`** + +```js +// What the top-bar menu says about MouseTail, worked out from `mousetail watch`, and the +// arrange view's maths. No GNOME imports, so Node can check it (../test/status.test.js). + +/** A `mousetail watch` line: the status (`{running: false}` while the daemon is down), or null + * for anything unreadable, which is ignored as the Omarchy panel does. */ +export function parse(line) { + let data; + try { + data = JSON.parse(line); + } catch { + return null; + } + return data?.running === true ? data : {running: false}; +} + +export function nameOf(status, id) { + if (!id) + return ''; + return (status.peers ?? []).find(p => p.id === id)?.name ?? id; +} + +/** The computers worth listing: paired ones, and unpaired ones found on the network. */ +export function shownPeers(status) { + return (status.peers ?? []).filter(p => p.paired || p.connected); +} + +/** One line on what MouseTail is doing, under the menu's title. */ +export function summary(status) { + if (!status.running) + return 'Not running'; + if (status.pairing_code) + return `Pairing with ${status.pairing_code.name || 'another computer'}`; + if (status.controlled_by) + return `In use from ${nameOf(status, status.controlled_by)}`; + if (status.controlling) + return `Using ${nameOf(status, status.controlling)}`; + const connected = shownPeers(status).filter(p => p.paired && p.connected && !p.paused); + if (connected.length > 0) + return `Connected to ${connected.map(p => p.name).join(', ')}`; + return 'Not connected'; +} + +/** How a computer is doing, under its name. */ +export function peerDetail(status, p) { + if (!p.paired) + return p.connected ? 'Found on your network' : 'Not paired'; + if (p.paused) + return p.connected ? 'Paused' : 'Paused · Offline'; + if (!p.connected) + return 'Offline'; + if (status.controlled_by === p.id) + return 'Using this computer now'; + const parts = ['Connected']; + if (p.sound === 'here') + parts.push('its sound plays here'); + if (p.sound === 'there') + parts.push('plays your sound'); + return parts.join(' · '); +} + +/** A setting's switch. Missing settings read as on, but a clipboard setting must say so. */ +export function setting(status, key) { + const settings = status.settings; + if (!settings) + return true; + return key === 'clipboard' ? settings.clipboard === true : settings[key] !== false; +} + +/** What the arrange view draws: this computer, and the others that have a place. */ +export function placed(layout) { + return layout?.machines?.filter(m => m.this || m.offset) ?? []; +} + +export function offsetOf(m) { + return m.offset ?? {x: 0, y: 0}; +} + +/** Fits every machine's displays into `width` × `height` view pixels with `margin` round them: + * the scale, and where the layout's top-left (`bx`, `by`) lands (`x`, `y`). */ +export function fit(machines, width, height, margin = 40) { + const rects = machines.flatMap(m => { + const o = offsetOf(m); + return m.displays.map(d => ({x: d.rect.x + o.x, y: d.rect.y + o.y, w: d.rect.w, h: d.rect.h})); + }); + if (rects.length === 0 || width <= 0 || height <= 0) + return {scale: 0.1, x: 0, y: 0, bx: 0, by: 0}; + const x0 = Math.min(...rects.map(r => r.x)); + const y0 = Math.min(...rects.map(r => r.y)); + const x1 = Math.max(...rects.map(r => r.x + r.w)); + const y1 = Math.max(...rects.map(r => r.y + r.h)); + const scale = Math.max(0.01, + Math.min((width - 2 * margin) / (x1 - x0), (height - 2 * margin) / (y1 - y0))); + return { + scale, + x: (width - (x1 - x0) * scale) / 2, + y: (height - (y1 - y0) * scale) / 2, + bx: x0, + by: y0, + }; +} + +export function viewX(view, x) { + return view.x + (x - view.bx) * view.scale; +} + +export function viewY(view, y) { + return view.y + (y - view.by) * view.scale; +} + +/** Where machine `m`, dragged `dx`, `dy` view pixels, asks to go (for `mousetail place-at`). */ +export function dropAt(m, view, dx, dy) { + const o = offsetOf(m); + return {x: Math.round(o.x + dx / view.scale), y: Math.round(o.y + dy / view.scale)}; +} + +/** The bar that draws a crossing edge, in view pixels, `thickness` across. */ +export function crossingBar(view, [a, b], thickness = 3) { + const x0 = viewX(view, Math.min(a.x, b.x)); + const y0 = viewY(view, Math.min(a.y, b.y)); + const x1 = viewX(view, Math.max(a.x, b.x)); + const y1 = viewY(view, Math.max(a.y, b.y)); + return { + x: x0 - thickness / 2, + y: y0 - thickness / 2, + width: x1 - x0 + thickness, + height: y1 - y0 + thickness, + }; +} +``` + +- [ ] **Step 4: Run the test to see it pass** + +Run: `podman run --rm --security-opt label=disable -v "$PWD":/src -w /src docker.io/library/node:22-alpine node --test integrations/gnome/test/` +Expected: PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add integrations/gnome/mousetail@galen.green/status.js integrations/gnome/test/status.test.js +git commit -m "GNOME extension: what the menu says, and the arrange view's maths" +``` + +--- + +### Task 2: The extension, its icon and the menu's frame + +The indicator, `mousetail watch`, the command queue, the header, settings and footer. Computers, +problems and the pairing code come in Task 3; Arrange Displays in Task 4 (its menu item +appears here but opens nothing until then). + +**Files:** +- Create: `integrations/gnome/mousetail@galen.green/metadata.json` +- Create: `integrations/gnome/mousetail@galen.green/extension.js` +- Create: `integrations/gnome/mousetail@galen.green/stylesheet.css` +- Create: `integrations/gnome/mousetail@galen.green/icons/mousetail-symbolic.svg` (copy of `integrations/omarchy/nz.galengreen.mousetail/icon.svg`) +- Scratch (not committed): `$SCRATCH/devshell.sh`, `$SCRATCH/shell.sh` + +**Interfaces:** +- Consumes: `Status.parse`, `Status.summary`, `Status.setting` (Task 1). +- Produces (inside `extension.js`, used by Tasks 3–4): `run(argv, cancellable, done(ok, out, err))`; `readLines(stream, cancellable, onLine, onEnd)`; `said(text)`; `note(text, styleClass)`; `row(...children)`; `button(label, onClick)`; `iconButton(iconName, accessibleName, onClick, styleClass)`; `Indicator` with `_status`, `_message`, `_cancellable`, `_update()`, `_refill(section, key, items)`, `_command(argv)`, sections `_problems`, `_code`, `_computers`, item `_arrange`; constants `BINARY`, `HELPERS`. + +- [ ] **Step 1: Write `metadata.json`** + +```json +{ + "uuid": "mousetail@galen.green", + "name": "MouseTail", + "description": "Share one keyboard, mouse, clipboard and sound between this computer and the ones beside it. Shows what's connected and any pairing code.", + "url": "https://github.com/galengreen/mousetail", + "shell-version": ["50"], + "version-name": "0.2.7" +} +``` + +- [ ] **Step 2: The icon** + +```bash +mkdir -p integrations/gnome/mousetail@galen.green/icons +cp integrations/omarchy/nz.galengreen.mousetail/icon.svg integrations/gnome/mousetail@galen.green/icons/mousetail-symbolic.svg +``` + +A `-symbolic.svg` file is tinted by the shell like its own icons, whatever its fill. + +- [ ] **Step 3: Write `extension.js`** + +```js +// MouseTail in GNOME's top bar: the MouseTail icon, and a menu with what the Omarchy bar +// plugin's panel has: who's connected (pair, pause, forget), Arrange Displays (arrange.js), any +// pairing code, the settings, and updates. Status streams from `mousetail watch`, one JSON line +// per change; everything else is the `mousetail` command. + +import Clutter from 'gi://Clutter'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import Pango from 'gi://Pango'; +import St from 'gi://St'; + +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; + +import * as Status from './status.js'; + +const BINARY = GLib.build_filenamev([GLib.get_home_dir(), '.local', 'bin', 'mousetail']); +const HELPERS = GLib.build_filenamev([GLib.get_user_data_dir(), 'mousetail']); + +/** Run `argv`, then `done(ok, stdout, stderr)`, unless `cancellable` was cancelled first. */ +function run(argv, cancellable, done) { + let proc; + try { + proc = Gio.Subprocess.new(argv, + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); + } catch (e) { + done(false, '', e.message); + return; + } + proc.communicate_utf8_async(null, cancellable, (p, result) => { + if (cancellable.is_cancelled()) + return; + let out = '', err = ''; + try { + [, out, err] = p.communicate_utf8_finish(result); + } catch (e) { + err = e.message; + } + done(p.get_successful(), out ?? '', err ?? ''); + }); +} + +/** Call `onLine` with each line `stream` gives, then `onEnd`, unless cancelled. */ +function readLines(stream, cancellable, onLine, onEnd = () => {}) { + const lines = new Gio.DataInputStream({base_stream: stream, close_base_stream: true}); + const next = () => lines.read_line_async(GLib.PRIORITY_DEFAULT, cancellable, (s, result) => { + if (cancellable.is_cancelled()) + return; + let line = null; + try { + [line] = s.read_line_finish_utf8(result); + } catch { + // The process has gone. + } + if (line === null) { + onEnd(); + return; + } + onLine(line); + next(); + }); + next(); +} + +/** What a command said on stderr, as the panel shows it. */ +function said(text) { + return text.trim().replace(/^mousetail: /, ''); +} + +/** Wrapped text, as wide as the menu. Dimmed through opacity, so it suits light and dark. */ +function note(text, styleClass = 'mousetail-note') { + const label = new St.Label({text, style_class: styleClass, x_expand: true}); + label.clutter_text.line_wrap = true; + label.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; + label.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + if (styleClass === 'mousetail-note') + label.opacity = 165; + return label; +} + +/** A menu row holding other things (buttons, an entry), which isn't clickable itself: clicking + * it, or a button in it, leaves the menu open. */ +function row(...children) { + const item = new PopupMenu.PopupBaseMenuItem({reactive: false, can_focus: false}); + children.forEach(child => item.add_child(child)); + return item; +} + +function button(label, onClick) { + const b = new St.Button({ + label, + style_class: 'button mousetail-button', + can_focus: true, + y_align: Clutter.ActorAlign.CENTER, + }); + b.connect('clicked', onClick); + return b; +} + +function iconButton(iconName, accessibleName, onClick, styleClass = '') { + const b = new St.Button({ + style_class: `button mousetail-icon-button ${styleClass}`, + can_focus: true, + accessible_name: accessibleName, + y_align: Clutter.ActorAlign.CENTER, + child: new St.Icon({icon_name: iconName, icon_size: 16}), + }); + b.connect('clicked', onClick); + return b; +} + +// A switch that leaves the menu open, like the Omarchy panel's. +const SettingItem = GObject.registerClass({GTypeName: 'MouseTailSettingItem'}, +class SettingItem extends PopupMenu.PopupSwitchMenuItem { + activate() { + this.toggle(); + } +}); + +const Indicator = GObject.registerClass({GTypeName: 'MouseTailIndicator'}, +class Indicator extends PanelMenu.Button { + _init(path) { + super._init(0.5, 'MouseTail'); + this._logo = Gio.icon_new_for_string(`${path}/icons/mousetail-symbolic.svg`); + this.add_child(new St.Icon({gicon: this._logo, style_class: 'system-status-icon'})); + this.menu.box.add_style_class_name('mousetail-menu'); + + this._status = {running: false}; + this._message = ''; + this._atLogin = true; + this._queue = []; + this._busy = false; + this._shown = new Map(); + this._cancellable = new Gio.Cancellable(); + + this._build(); + this._update(); + this.menu.connect('open-state-changed', (_menu, open) => { + if (open) + this._opened(); + }); + this.connect('destroy', () => this._stop()); + this._watch(); + } + + _build() { + const menu = this.menu; + + const title = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + title.add_child(new St.Label({text: 'MouseTail', style_class: 'mousetail-title'})); + this._summary = new St.Label({style_class: 'mousetail-summary'}); + this._summary.opacity = 165; + title.add_child(this._summary); + menu.addMenuItem(row( + new St.Icon({gicon: this._logo, icon_size: 32, style_class: 'mousetail-logo'}), + title)); + + // Anything stopping MouseTail doing its job here; the pairing code (Task 3). + this._problems = new PopupMenu.PopupMenuSection(); + menu.addMenuItem(this._problems); + this._code = new PopupMenu.PopupMenuSection(); + menu.addMenuItem(this._code); + + this._computersHeading = new PopupMenu.PopupSeparatorMenuItem('Computers'); + menu.addMenuItem(this._computersHeading); + this._computers = new PopupMenu.PopupMenuSection(); + menu.addMenuItem(this._computers); + this._arrange = new PopupMenu.PopupMenuItem('Arrange Displays…'); + this._arrange.connect('activate', () => this._openArrange()); + menu.addMenuItem(this._arrange); + + this._settingsHeading = new PopupMenu.PopupSeparatorMenuItem('Settings'); + menu.addMenuItem(this._settingsHeading); + this._switches = [ + ['Sound follows you', 'audio'], + ['Share clipboard', 'clipboard'], + ['Ripple when crossing', 'ripple'], + ['Start at login', null], + ['Update automatically', 'updates'], + ].map(([label, key]) => { + const item = new SettingItem(label, true); + item.connect('toggled', (_item, on) => { + if (this._syncing) + return; + if (key) + this._command([BINARY, 'set', key, on ? 'on' : 'off']); + else + this._setAtLogin(on); + }); + menu.addMenuItem(item); + return {item, key}; + }); + + this._notRunning = row(note("MouseTail isn't running.")); + menu.addMenuItem(this._notRunning); + + menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this._messageLabel = note(''); + this._messageRow = row(this._messageLabel); + menu.addMenuItem(this._messageRow); + this._updatesButton = button('Check for Updates', () => this._checkForUpdates()); + this._startStop = button('Stop MouseTail', () => this._command( + ['systemctl', '--user', this._status.running ? 'stop' : 'start', 'mousetail'])); + const actions = new St.BoxLayout({style_class: 'mousetail-actions', x_expand: true}); + actions.add_child(this._updatesButton); + actions.add_child(this._startStop); + menu.addMenuItem(row(actions)); + this._versionLabel = note(''); + this._versionRow = row(this._versionLabel); + menu.addMenuItem(this._versionRow); + } + + /** Make the menu say what the status says. */ + _update() { + const st = this._status; + const running = st.running === true; + this._summary.text = Status.summary(st); + + this._computersHeading.visible = running; + this._arrange.visible = running && Status.shownPeers(st).some(p => p.paired); + this._settingsHeading.visible = running; + this._syncing = true; + for (const {item, key} of this._switches) { + item.visible = running; + item.setToggleState(key ? Status.setting(st, key) : this._atLogin); + } + this._syncing = false; + + this._notRunning.visible = !running; + this._messageLabel.text = this._message; + this._messageRow.visible = this._message !== ''; + this._updatesButton.visible = running; + this._startStop.label = running ? 'Stop MouseTail' : 'Start MouseTail'; + this._versionLabel.text = st.version ? `MouseTail ${st.version}` : ''; + this._versionRow.visible = !!st.version; + } + + /** Fill `section` with `items()` when what it shows (`key`) has changed, so an unrelated + * update doesn't sweep away a field being typed in. A falsy key empties it. */ + _refill(section, key, items) { + const shows = JSON.stringify(key || null); + if (this._shown.get(section) === shows) + return; + this._shown.set(section, shows); + section.removeAll(); + if (key) + items().forEach(item => section.addMenuItem(item)); + } + + _opened() { + this._message = ''; + run(['systemctl', '--user', 'is-enabled', 'mousetail'], this._cancellable, (_ok, out) => { + this._atLogin = out.trim() === 'enabled'; + this._update(); + }); + this._update(); + } + + _openArrange() { + // Task 4. + } + + // -------------------------------------------------------------- commands + + /** Run a command once those before it have finished; what it says goes in the menu. */ + _command(argv) { + this._queue.push(argv); + if (!this._busy) + this._next(); + } + + _next() { + const argv = this._queue.shift(); + this._busy = argv !== undefined; + if (!this._busy) + return; + run(argv, this._cancellable, (_ok, _out, err) => { + if (said(err) !== '') { + this._message = said(err); + this._update(); + } + this._next(); + }); + } + + _setAtLogin(on) { + this._atLogin = on; + this._command(['systemctl', '--user', on ? 'enable' : 'disable', 'mousetail']); + } + + _checkForUpdates() { + this._message = 'Checking for updates…'; + this._updatesButton.reactive = false; + this._update(); + run([BINARY, 'update'], this._cancellable, (_ok, out, err) => { + this._updatesButton.reactive = true; + this._message = said(err) || out.trim(); + this._update(); + }); + } + + // -------------------------------------------------------------- status + + _watch() { + let proc; + try { + proc = Gio.Subprocess.new([BINARY, 'watch'], + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE); + } catch { + // Not installed (yet). + this._watchAgain(); + return; + } + this._watcher = proc; + readLines(proc.get_stdout_pipe(), this._cancellable, line => { + const status = Status.parse(line); + if (status) + this._setStatus(status); + }); + proc.wait_async(this._cancellable, (p, result) => { + try { + p.wait_finish(result); + } catch { + return; // Cancelled: the extension is going away. + } + this._watcher = null; + this._setStatus({running: false}); + this._watchAgain(); + }); + } + + _watchAgain() { + this._watchTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 3, () => { + this._watchTimer = 0; + this._watch(); + return GLib.SOURCE_REMOVE; + }); + } + + _setStatus(status) { + this._status = status; + this._update(); + } + + _stop() { + this._cancellable.cancel(); + this._watcher?.force_exit(); + if (this._watchTimer) + GLib.source_remove(this._watchTimer); + } +}); + +export default class MouseTailExtension extends Extension { + enable() { + this._indicator = new Indicator(this.path); + Main.panel.addToStatusArea(this.uuid, this._indicator); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } +} +``` + +- [ ] **Step 4: Write `stylesheet.css`** + +```css +/* The top-bar menu, as wide as the Omarchy panel. */ +.mousetail-menu { width: 22em; } +.mousetail-title { font-weight: bold; font-size: 1.15em; } +.mousetail-summary, +.mousetail-note { font-size: 0.9em; } +.mousetail-urgent { font-size: 0.9em; color: #f66151; } +.mousetail-logo { margin-right: 6px; } +.mousetail-actions { spacing: 6px; } +.mousetail-button { padding: 4px 12px; font-size: 0.9em; } +.mousetail-icon-button { padding: 5px; border-radius: 99px; } +``` + +- [ ] **Step 5: Syntax check, and a nested shell to try it in** + +Run: `podman run --rm --security-opt label=disable -v "$PWD":/src -w /src docker.io/library/node:22-alpine sh -c 'for f in integrations/gnome/mousetail@galen.green/*.js; do node --check "$f" || exit 1; done && echo SYNTAX-OK'` +Expected: `SYNTAX-OK`. + +Write `$SCRATCH/devshell.sh` (scratchpad; not committed). It starts GNOME Shell nested in a +window, with a throwaway home (own dconf, own extensions, so nothing reaches the real session), +this extension, and a helper extension that turns on unsafe mode so the shell can be driven over +D-Bus: + +```bash +#!/bin/bash +# Nested GNOME Shell for trying the extension: dev/devshell.sh, then shell.sh ''. +set -euo pipefail +repo=/home/ming/Documents/projects/MouseTail +root=${SCRATCH:?}/devhome +rm -rf "$root" +mkdir -p "$root/.local/share/gnome-shell/extensions/unsafe@dev" "$root/.local/bin" "$root/.config" "$root/.cache" +ln -s "$repo/integrations/gnome/mousetail@galen.green" "$root/.local/share/gnome-shell/extensions/" +ln -s "$HOME/.local/bin/mousetail" "$root/.local/bin/mousetail" +cat > "$root/.local/share/gnome-shell/extensions/unsafe@dev/metadata.json" <<'JSON' +{"uuid": "unsafe@dev", "name": "unsafe", "description": "dev only", "shell-version": ["50"]} +JSON +cat > "$root/.local/share/gnome-shell/extensions/unsafe@dev/extension.js" <<'JS' +export default class { enable() { global.context.unsafe_mode = true; } disable() {} } +JS +export HOME=$root XDG_CONFIG_HOME=$root/.config XDG_DATA_HOME=$root/.local/share XDG_CACHE_HOME=$root/.cache +exec dbus-run-session -- bash -c " + echo \"\$DBUS_SESSION_BUS_ADDRESS\" > '$root/bus' + gsettings set org.gnome.shell enabled-extensions \"['mousetail@galen.green', 'unsafe@dev']\" + exec gnome-shell --devkit --wayland" +``` + +and `$SCRATCH/shell.sh`, which runs JavaScript in it (with `shot` to screenshot it to a PNG): + +```bash +#!/bin/bash +# shell.sh '' runs it in the nested shell; shell.sh shot FILE.png screenshots it. +root=${SCRATCH:?}/devhome +export DBUS_SESSION_BUS_ADDRESS=$(cat "$root/bus") +code=$1 +if [[ $1 == shot ]]; then + code="(async () => { const f = Gio.File.new_for_path('$2'); const s = f.replace(null, false, 0, null); + await new Shell.Screenshot().screenshot(false, s); s.close(null); return 'saved'; })()" +fi +gdbus call --session --dest org.gnome.Shell --object-path /org/gnome/Shell --method org.gnome.Shell.Eval "$code" +``` + +Run (background): `SCRATCH=$SCRATCH bash $SCRATCH/devshell.sh > $SCRATCH/devshell.log 2>&1` +Expected: a window with a nested GNOME Shell; `$SCRATCH/devshell.log` has no `JS ERROR` naming `mousetail@galen.green`. + +- [ ] **Step 6: Look at it** + +Run: `bash $SCRATCH/shell.sh "Main.panel.statusArea['mousetail@galen.green'].menu.open(); 'ok'"` then `bash $SCRATCH/shell.sh shot $SCRATCH/menu.png`, and view the PNG. +Expected: the MouseTail icon in the nested top bar; the menu shows the logo, "MouseTail", the summary ("Connected to omarchy" with the daemon running), Computers and Settings headings, the five switches reflecting `mousetail status`, Check for Updates / Stop MouseTail, and "MouseTail 0.2.7". + +Toggle a switch from the shell (`…menu._getMenuItems()` or the `_switches` list via `statusArea[…]._switches[2].item.toggle()`), wait a second, check `mousetail status` shows the setting changed, and toggle it back. + +- [ ] **Step 7: Review focus: not installed, stopped and started** + +Run: stop the daemon (`pkill -x mousetail` for a dev run, or `systemctl --user stop mousetail`), wait 4 s, screenshot the open menu; start it again, wait 4 s, screenshot again. +Expected: first "Not running" with "MouseTail isn't running." and Start MouseTail; then back to the full menu. `devshell.log` shows no repeating errors. + +- [ ] **Step 8: Review focus: disabled with commands still running** + +Run: `bash $SCRATCH/shell.sh "const i = Main.panel.statusArea['mousetail@galen.green']; i._command(['sleep', '2']); i._command(['true']); Main.extensionManager.disableExtension('mousetail@galen.green'); 'ok'"`, wait 3 s, then `…enableExtension('mousetail@galen.green')…`. +Expected: no `JS ERROR` in `devshell.log` after the sleep finishes; the icon comes back working. + +- [ ] **Step 9: Commit** + +```bash +git add integrations/gnome/mousetail@galen.green +git commit -m "GNOME extension: MouseTail in the top bar, with its settings" +``` + +--- + +### Task 3: Problems, the pairing code, computers and pairing + +**Files:** +- Modify: `integrations/gnome/mousetail@galen.green/extension.js` (`_update`, `_opened`, `_setStatus`, `_stop`; new `_problemItems`, `_codeItems`, `_computerItems`, `_peerItems`, `_codeEntryItems`, `_startPairing`, `_sendCode`, `_cancelPairing`, `_fixFirewall`) +- Modify: `integrations/gnome/mousetail@galen.green/stylesheet.css` + +**Interfaces:** +- Consumes: Task 2's helpers and `Indicator` members; `Status.shownPeers`, `Status.peerDetail`. +- Produces: `Indicator._pairing = {id, proc, error, said, typed, paired} | null`; `Indicator._codeEntry`. + +- [ ] **Step 1: Fill the sections in `_update`** + +In `_update()`, after `this._summary.text = …;`, add: + +```js + this._refill(this._problems, + running && [st.capture_error, st.can_be_controlled, st.firewall, this._fixingFirewall], + () => this._problemItems()); + this._refill(this._code, running && st.pairing_code, () => this._codeItems()); + const peers = running ? Status.shownPeers(st) : []; + const pairing = this._pairing && [this._pairing.id, !!this._pairing.proc, this._pairing.error]; + this._refill(this._computers, + running && [peers.map(p => [p.id, p.name, p.paired, p.connected, p.paused, Status.peerDetail(st, p)]), pairing], + () => this._computerItems(peers)); + if (this._focusEntry) { + // Just shown: focus it, so the code can just be typed. + if (this.menu.isOpen) + this._focusEntry.grab_key_focus(); + this._focusEntry = null; + } +``` + +and change the `_arrange` line to use `peers`: `this._arrange.visible = running && peers.some(p => p.paired);` + +- [ ] **Step 2: Problems and the code** + +Add to `Indicator`: + +```js + // -------------------------------------------------------------- sections + + _problemItems() { + const st = this._status; + const items = []; + if (st.capture_error) + items.push(row(note(st.capture_error, 'mousetail-urgent'))); + if (st.can_be_controlled === false) { + const script = `${HELPERS.replace(GLib.get_home_dir(), '~')}/enable-input.sh`; + items.push(row(note(`Other computers can't control this one yet. Run this once: ${script}`))); + } + if (st.firewall) { + items.push(row(note("This computer's firewall stops other computers reaching it, so connecting can be slow or fail."))); + const fix = button('Fix Firewall…', () => this._fixFirewall()); + fix.reactive = !this._fixingFirewall; + items.push(row(fix)); + } + return items; + } + + _codeItems() { + const code = this._status.pairing_code; + return [ + new PopupMenu.PopupSeparatorMenuItem('Pairing code'), + row(new St.Label({text: code.code.split('').join(' '), style_class: 'mousetail-code'})), + row(note(`Type this on ${code.name || 'your other computer'} to connect it.`)), + ]; + } + + _computerItems(peers) { + if (peers.length === 0) + return [row(note('Looking for other computers on your network…'))]; + return peers.flatMap(p => this._peerItems(p)); + } + + _peerItems(p) { + const pairing = this._pairing?.id === p.id ? this._pairing : null; + const text = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + const name = new St.Label({text: p.name, style_class: 'mousetail-peer'}); + if (!p.connected || p.paused) + name.opacity = 140; + text.add_child(name); + const detail = new St.Label({text: Status.peerDetail(this._status, p), style_class: 'mousetail-peer-detail'}); + detail.opacity = 165; + text.add_child(detail); + + const controls = [text]; + if (!p.paired && p.connected && !pairing?.proc) + controls.push(button('Pair…', () => this._startPairing(p))); + if (p.paired) { + controls.push(iconButton( + p.paused ? 'media-playback-start-symbolic' : 'media-playback-pause-symbolic', + p.paused ? `Resume ${p.name}` : `Pause ${p.name} without forgetting it`, + () => this._command([BINARY, p.paused ? 'resume' : 'pause', p.id]))); + controls.push(iconButton('window-close-symbolic', `Forget ${p.name}`, + () => this._command([BINARY, 'unpair', p.id]), 'mousetail-forget')); + } + const items = [row(...controls)]; + if (pairing?.proc) + items.push(...this._codeEntryItems(p, pairing)); + if (pairing?.error) + items.push(row(note(pairing.error, 'mousetail-urgent'))); + return items; + } + + /** Pairing: the other computer shows a code to type here. */ + _codeEntryItems(p, pairing) { + const entry = new St.Entry({ + hint_text: 'Code', + text: pairing.typed, + can_focus: true, + x_expand: true, + style_class: 'mousetail-code-entry', + }); + entry.clutter_text.max_length = 8; + entry.clutter_text.connect('text-changed', () => (pairing.typed = entry.get_text())); + entry.clutter_text.connect('activate', () => { + this._sendCode(entry.get_text()); + entry.set_text(''); + }); + entry.clutter_text.connect('key-press-event', (_actor, event) => { + if (event.get_key_symbol() !== Clutter.KEY_Escape) + return Clutter.EVENT_PROPAGATE; + this._cancelPairing(); + return Clutter.EVENT_STOP; + }); + entry.connect('destroy', () => { + if (this._codeEntry === entry) + this._codeEntry = null; + }); + this._codeEntry = entry; + this._focusEntry = entry; + return [ + row(note(`Type the code showing on ${p.name}:`)), + row(entry, button('Cancel', () => this._cancelPairing())), + ]; + } +``` + +- [ ] **Step 3: Pairing, and the firewall fix** + +Add to `Indicator`: + +```js + // -------------------------------------------------------------- pairing + + /** `mousetail pair` asks the other computer to show a code, then reads it from stdin. A + * wrong code ends it, so the row offers Pair… again, for a fresh code. */ + _startPairing(p) { + this._cancelPairing(false); + const pairing = {id: p.id, proc: null, error: '', said: '', typed: '', paired: false}; + this._pairing = pairing; + try { + pairing.proc = Gio.Subprocess.new([BINARY, 'pair', p.id], + Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE | + Gio.SubprocessFlags.STDERR_PIPE); + } catch (e) { + pairing.error = e.message; + this._update(); + return; + } + let waiting = 2; // its exit, and the end of what it says on stderr + const finished = () => { + if (--waiting > 0 || this._pairing !== pairing) + return; + if (pairing.paired) { + this._pairing = null; + } else { + pairing.proc = null; + pairing.error = said(pairing.said) || "That didn't work. Try again."; + } + this._update(); + }; + readLines(pairing.proc.get_stdout_pipe(), this._cancellable, line => { + if (line.includes('Paired with')) { + pairing.paired = true; + this._message = line.slice(line.indexOf('Paired with')).trim(); + } + }); + readLines(pairing.proc.get_stderr_pipe(), this._cancellable, + line => (pairing.said += `${line}\n`), finished); + pairing.proc.wait_async(this._cancellable, (proc, result) => { + try { + proc.wait_finish(result); + } catch { + return; // Cancelled: the extension is going away. + } + finished(); + }); + this._update(); + } + + _sendCode(code) { + code = code.trim(); + const pairing = this._pairing; + if (code === '' || !pairing?.proc) + return; + pairing.error = ''; + try { + const stdin = pairing.proc.get_stdin_pipe(); + stdin.write_all(new TextEncoder().encode(`${code}\n`), null); + stdin.flush(null); + } catch (e) { + pairing.error = e.message; + this._update(); + } + } + + _cancelPairing(update = true) { + const pairing = this._pairing; + this._pairing = null; + pairing?.proc?.force_exit(); + if (update) + this._update(); + } + + /** enable-firewall.sh asks for a password, so it runs in a terminal: the first there is. */ + _fixFirewall() { + const script = `${GLib.shell_quote(`${HELPERS}/enable-firewall.sh`)}; read -rp 'Press Enter to close. '`; + const terminal = 'command -v xdg-terminal-exec >/dev/null && exec xdg-terminal-exec bash -c "$0"; ' + + 'for t in ptyxis kgx gnome-terminal; do command -v "$t" >/dev/null && exec "$t" -- bash -c "$0"; done'; + this._fixingFirewall = true; + this._update(); + run(['bash', '-c', terminal, script], this._cancellable, () => { + this._fixingFirewall = false; + this._update(); + }); + } +``` + +- [ ] **Step 4: Open on a new code; focus the field on opening; stop pairing on the way out** + +Replace `_setStatus`: + +```js + _setStatus(status) { + const hadCode = !!this._status.pairing_code; + this._status = status; + this._update(); + // A new pairing code is the one thing worth interrupting for. + if (!hadCode && status.pairing_code && !this.menu.isOpen) + this.menu.open(); + } +``` + +At the end of `_opened()` add `this._codeEntry?.grab_key_focus();`. In `_stop()` add +`this._pairing?.proc?.force_exit();` after cancelling. + +In `stylesheet.css` add: + +```css +.mousetail-code { font-size: 2.2em; font-weight: 600; } +.mousetail-peer { font-weight: 500; } +.mousetail-peer-detail { font-size: 0.85em; } +.mousetail-code-entry { min-width: 6em; } +.mousetail-forget:hover { color: #f66151; } +``` + +- [ ] **Step 5: Syntax check and look** + +Run the Task 2 step 5 syntax check; restart the nested shell; open the menu and screenshot. +Expected: Computers lists omarchy, "Connected · its sound plays here", pause and forget buttons; +Arrange Displays… shows. Press pause from the shell (`…_computers` row's pause button, or +`i._command([BINARY, 'pause', ''])`), screenshot: "Paused", name dimmed, play icon; resume it. + +- [ ] **Step 6: Review focus: an update mid-typing** + +Run in the nested shell: start pairing against a stand-in (`i._pairing = {id: '', proc: Gio.Subprocess.new(['sleep', '60'], Gio.SubprocessFlags.STDIN_PIPE), error: '', said: '', typed: '', paired: false}; i._update();`), set the entry's text to `12`, then send a status that changes nothing about the computers (`i._setStatus({...i._status, version: '9.9.9'})`). +Expected: the entry still exists and still says `12` (`i._codeEntry.get_text() === '12'`). Then `i._cancelPairing()`. + +- [ ] **Step 7: Review focus: a new code opens the menu once** + +Run: close the menu; `i._setStatus({...i._status, pairing_code: {peer: 'x', name: 'mac', code: '4821'}})`; check `i.menu.isOpen`; close it; send the same status again; check it stays closed; clear the code. +Expected: `true`, then `false`; the screenshot shows "4 8 2 1" and "Type this on mac to connect it." + +- [ ] **Step 8: Commit** + +```bash +git add integrations/gnome/mousetail@galen.green +git commit -m "GNOME extension: computers, pairing, the pairing code and problems" +``` + +--- + +### Task 4: Arrange Displays + +**Files:** +- Create: `integrations/gnome/mousetail@galen.green/arrange.js` +- Modify: `integrations/gnome/mousetail@galen.green/extension.js` (import; `_openArrange`; `_stop`) +- Modify: `integrations/gnome/mousetail@galen.green/stylesheet.css` + +**Interfaces:** +- Consumes: `Status.placed`, `Status.offsetOf`, `Status.fit`, `Status.viewX`, `Status.viewY`, `Status.dropAt`, `Status.crossingBar`; `run` (passed in). +- Produces: `export const ArrangeDialog`, constructed as `new ArrangeDialog(binary, run)` where `run(argv, done(ok, out, err))`; a `ModalDialog` (`open()`, `close()`, destroyed on close). + +- [ ] **Step 1: Write `arrange.js`** + +```js +// Arrange Displays: drag the other computers to where they sit on your desk, like the Mac's +// Arrange Displays and the Omarchy panel's, in MouseTail's own colours (stylesheet.css) rather +// than the theme's. Drops snap to the nearest edge; glowing edges are where the cursor crosses. + +import Clutter from 'gi://Clutter'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import Pango from 'gi://Pango'; +import St from 'gi://St'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as ModalDialog from 'resource:///org/gnome/shell/ui/modalDialog.js'; + +import * as Status from './status.js'; + +/** How long a dropped computer takes to glide to where it snapped. */ +const GLIDE_MS = 220; +/** Room the title, help, legend and button take beside the canvas. */ +const CHROME = {width: 48, height: 210}; + +export const ArrangeDialog = GObject.registerClass({GTypeName: 'MouseTailArrangeDialog'}, +class ArrangeDialog extends ModalDialog.ModalDialog { + /** `run(argv, done)` runs a command, then `done(ok, stdout, stderr)`. */ + _init(binary, run) { + super._init({styleClass: 'mousetail-arrange'}); + this._binary = binary; + this._run = run; + this._layout = null; + this._view = null; + this._groups = new Map(); // machine id → its tiles + this._drag = null; + this._loading = false; + this._closed = false; + + // About 70% of the screen, as on Omarchy, but no bigger than 900 × 620. + const monitor = Main.layoutManager.currentMonitor; + const width = Math.round(Math.min(monitor.width * 0.7, 900) - CHROME.width); + const height = Math.round(Math.min(monitor.height * 0.7, 620) - CHROME.height); + + this.contentLayout.add_child(new St.Label({text: 'Arrange Displays', style_class: 'mousetail-arrange-title'})); + const help = new St.Label({ + text: 'Drag each computer to where it sits on your desk. Push the cursor off a highlighted edge to move to the other computer.', + style_class: 'mousetail-arrange-help', + }); + help.clutter_text.line_wrap = true; + this.contentLayout.add_child(help); + + this._canvas = new St.Widget({style_class: 'mousetail-arrange-canvas', width, height, clip_to_allocation: true}); + this._wait = new St.Label({text: 'Loading…', style_class: 'mousetail-arrange-help'}); + this._wait.add_constraint(new Clutter.AlignConstraint({source: this._canvas, align_axis: Clutter.AlignAxis.BOTH, factor: 0.5})); + this._canvas.add_child(this._wait); + this.contentLayout.add_child(this._canvas); + + const legend = new St.BoxLayout({style_class: 'mousetail-arrange-legend'}); + for (const [label, swatch] of [['This computer', 'this'], ['Other computers', 'other'], ['Cursor crosses here', 'crossing']]) { + legend.add_child(new St.Widget({style_class: `mousetail-swatch mousetail-swatch-${swatch}`, y_align: Clutter.ActorAlign.CENTER})); + legend.add_child(new St.Label({text: label, style_class: 'mousetail-arrange-legend-label', y_align: Clutter.ActorAlign.CENTER})); + } + this.contentLayout.add_child(legend); + + this.setButtons([{label: 'Done', action: () => this.close(), key: Clutter.KEY_Escape}]); + + // Keep up with the other computer being moved from there, or connecting. + this._timer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => { + this._refresh(); + return GLib.SOURCE_CONTINUE; + }); + this.connect('destroy', () => { + this._closed = true; + GLib.source_remove(this._timer); + this._drag?.grab.dismiss(); + }); + this._refresh(); + } + + _refresh() { + if (this._drag || this._loading) + return; + this._loading = true; + this._run([this._binary, 'layout'], (_ok, out) => { + this._loading = false; + if (this._closed || this._drag) + return; + let layout = null; + try { + layout = JSON.parse(out); + } catch { + return; + } + if (layout?.machines) + this._show(layout); + }); + } + + /** Draw `layout`: machines kept from one load to the next, so a moved one glides. */ + _show(layout) { + this._layout = layout; + this._wait.visible = false; + const shown = Status.placed(layout); + const view = Status.fit(shown, this._canvas.width, this._canvas.height); + this._view = view; + + const ids = new Set(shown.map(m => m.id)); + for (const [id, group] of this._groups) { + if (!ids.has(id)) { + group.destroy(); + this._groups.delete(id); + } + } + for (const m of shown) { + let group = this._groups.get(m.id); + const fresh = !group; + if (fresh) { + group = new St.Widget(); + this._canvas.add_child(group); + this._groups.set(m.id, group); + } + this._drawTiles(group, m, view); + const o = Status.offsetOf(m); + const x = Status.viewX(view, o.x); + const y = Status.viewY(view, o.y); + if (fresh) + group.set_position(x, y); + else + group.ease({x, y, duration: GLIDE_MS, mode: Clutter.AnimationMode.EASE_OUT_CUBIC}); + } + + // Where the cursor crosses, above the tiles. + this._crossings?.destroy(); + this._crossings = new St.Widget(); + for (const edge of layout.crossings ?? []) { + const bar = Status.crossingBar(view, edge); + this._crossings.add_child(new St.Widget({style_class: 'mousetail-crossing', ...bar})); + } + this._canvas.add_child(this._crossings); + } + + _drawTiles(group, m, view) { + group.destroy_all_children(); + const kind = m.this ? 'this' : m.connected && !m.paused ? 'other' : 'offline'; + for (const d of m.displays) { + const width = d.rect.w * view.scale; + const height = d.rect.h * view.scale; + const tile = new St.Widget({ + style_class: `mousetail-tile mousetail-tile-${kind}`, + layout_manager: new Clutter.BinLayout(), + x: d.rect.x * view.scale, + y: d.rect.y * view.scale, + width, + height, + reactive: !m.this, + }); + // A strip marks each computer's main display, as macOS does. + if (d.primary) + tile.add_child(new St.Widget({style_class: 'mousetail-tile-main', y_align: Clutter.ActorAlign.START})); + const labels = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + for (const [text, styleClass] of [ + [m.this ? 'This computer' : m.name, 'mousetail-tile-name'], + [m.connected || m.this ? d.name : 'Offline', 'mousetail-tile-display'], + ]) { + const label = new St.Label({text, style_class: styleClass, width: Math.max(0, width - 12)}); + label.clutter_text.ellipsize = Pango.EllipsizeMode.END; + labels.add_child(label); + } + tile.add_child(labels); + if (!m.this) + this._draggable(tile, group, m); + group.add_child(tile); + } + } + + _draggable(tile, group, m) { + tile.connect('button-press-event', (_actor, event) => { + if (event.get_button() !== Clutter.BUTTON_PRIMARY || this._drag) + return Clutter.EVENT_PROPAGATE; + const [x, y] = event.get_coords(); + group.remove_all_transitions(); + this._drag = {m, group, x, y, dx: 0, dy: 0, moved: false, view: this._view, + from: {x: group.x, y: group.y}, grab: global.stage.grab(tile)}; + return Clutter.EVENT_STOP; + }); + tile.connect('motion-event', (_actor, event) => { + const drag = this._drag; + if (drag?.group !== group) + return Clutter.EVENT_PROPAGATE; + const [x, y] = event.get_coords(); + drag.dx = x - drag.x; + drag.dy = y - drag.y; + if (!drag.moved) { + if (Math.abs(drag.dx) + Math.abs(drag.dy) < 3) + return Clutter.EVENT_STOP; + // Picked up: it glows, like the logo's tail, above the rest. + drag.moved = true; + this._canvas.set_child_above_sibling(group, null); + group.get_children().forEach(t => t.add_style_class_name('mousetail-tile-lifted')); + this._crossings?.hide(); + } + group.set_position(drag.from.x + drag.dx, drag.from.y + drag.dy); + return Clutter.EVENT_STOP; + }); + tile.connect('button-release-event', (_actor, event) => { + const drag = this._drag; + if (drag?.group !== group || event.get_button() !== Clutter.BUTTON_PRIMARY) + return Clutter.EVENT_PROPAGATE; + drag.grab.dismiss(); + if (drag.moved) + this._drop(drag); + else + this._drag = null; + return Clutter.EVENT_STOP; + }); + } + + /** Ask MouseTail to put it there; it answers with where it snapped, and the tile glides + * there. */ + _drop(drag) { + const at = Status.dropAt(drag.m, drag.view, drag.dx, drag.dy); + this._run([this._binary, 'place-at', drag.m.id, String(at.x), String(at.y)], (_ok, out) => { + if (this._closed) + return; + let offset = null; + try { + offset = JSON.parse(out).offset ?? null; + } catch { + // Back where it was. + } + this._drag = null; + const machines = this._layout.machines.map(m => (offset && m.id === drag.m.id ? {...m, offset} : m)); + this._show({machines, crossings: []}); + this._refresh(); + }); + } +}); +``` + +- [ ] **Step 2: Open it from the menu** + +In `extension.js`, add `import {ArrangeDialog} from './arrange.js';` after the `Status` import, +and replace `_openArrange`: + +```js + _openArrange() { + this._arrangeDialog?.close(); + const dialog = new ArrangeDialog(BINARY, (argv, done) => run(argv, this._cancellable, done)); + dialog.connect('destroy', () => { + if (this._arrangeDialog === dialog) + this._arrangeDialog = null; + }); + this._arrangeDialog = dialog; + dialog.open(); + } +``` + +In `_stop()` add `this._arrangeDialog?.close();`. + +- [ ] **Step 3: Its styles** + +Append to `stylesheet.css`: + +```css +/* Arrange Displays, in MouseTail's own colours. */ +.modal-dialog.mousetail-arrange { + background-color: #080808; + border: 1px solid rgba(242, 241, 236, 0.09); + color: #f2f1ec; +} +.modal-dialog.mousetail-arrange .modal-dialog-content-box { max-width: 900px; spacing: 8px; } +.mousetail-arrange-title { font-size: 1.4em; font-weight: 600; color: #f2f1ec; } +.mousetail-arrange-help { font-size: 0.9em; color: rgba(242, 241, 236, 0.64); } +.mousetail-arrange-canvas { + background-color: #111212; + border: 1px solid rgba(242, 241, 236, 0.09); + border-radius: 12px; +} +.mousetail-tile { + border-radius: 5px; + border: 1px solid rgba(255, 255, 255, 0.12); + background-gradient-direction: vertical; +} +.mousetail-tile-this { background-gradient-start: #383939; background-gradient-end: #292a2a; color: rgba(242, 241, 236, 0.64); } +.mousetail-tile-other { background-gradient-start: #4a4535; background-gradient-end: #353226; border-color: rgba(255, 255, 255, 0.18); color: #f2f1ec; } +.mousetail-tile-offline { background-gradient-start: #232424; background-gradient-end: #1b1c1c; border-color: rgba(255, 255, 255, 0.08); color: rgba(242, 241, 236, 0.42); } +.mousetail-tile-lifted { border: 2px solid #ffeba7; box-shadow: 0 0 6px 3px rgba(255, 224, 110, 0.4); } +.mousetail-tile-main { height: 4px; margin: 1px; border-radius: 4px; background-color: rgba(242, 241, 236, 0.45); } +.mousetail-tile-name { font-size: 0.85em; font-weight: 600; text-align: center; } +.mousetail-tile-display { font-size: 0.75em; text-align: center; } +.mousetail-crossing { background-color: #ffeba7; border-radius: 2px; box-shadow: 0 0 6px 2px rgba(255, 224, 110, 0.4); } +.mousetail-arrange-legend { spacing: 6px; } +.mousetail-arrange-legend-label { font-size: 0.8em; color: rgba(242, 241, 236, 0.64); margin-right: 12px; } +.mousetail-swatch { width: 14px; height: 10px; border-radius: 2px; border: 1px solid rgba(255, 255, 255, 0.15); } +.mousetail-swatch-this { background-color: #383939; } +.mousetail-swatch-other { background-color: #4a4535; } +.mousetail-swatch-crossing { height: 3px; border-width: 0; background-color: #ffeba7; } +.modal-dialog.mousetail-arrange .modal-dialog-button { background-color: #ffeba7; color: #080808; } +.modal-dialog.mousetail-arrange .modal-dialog-button:hover { background-color: #fff2c4; } +``` + +- [ ] **Step 4: Look at it** + +Run the syntax check; restart the nested shell; `bash $SCRATCH/shell.sh "Main.panel.statusArea['mousetail@galen.green']._openArrange(); 'ok'"`; wait 2 s; screenshot. +Expected: the dimmed screen, the dark card, "Arrange Displays" and its help, this computer's two +displays in graphite with "This computer" and the display names, omarchy's in warm, a strip on +each main display, a glowing crossing line where they meet, the legend, and Done. + +- [ ] **Step 5: Drag it** + +Drive a drag from the shell, moving omarchy's tile 80 px down and back (emit the press/motion/release through `ArrangeDialog._drop` directly: `d._drop({m: d._layout.machines.find(m => !m.this), view: d._view, dx: 0, dy: 80})`), screenshot after 1 s, then `mousetail layout`. +Expected: the tile glides to where it snapped; `mousetail layout` shows omarchy's new offset; +then put it back with `mousetail place-at omarchy -80 461` and check the tile glides back +within 2 s (the refresh). Finally, by hand in the nested window: drag the tile with the mouse; +it lifts with a glow, follows, and snaps on release. + +- [ ] **Step 6: Review focus: closed with a command running** + +Run: `d._run(['sleep', '2'], () => d._show(d._layout)); d.close();`, wait 3 s. +Expected: no `JS ERROR` in `devshell.log` (the callback returns on `_closed` … the `layout` +refresh too). Also Escape closes the dialog. + +- [ ] **Step 7: Commit** + +```bash +git add integrations/gnome/mousetail@galen.green +git commit -m "GNOME extension: Arrange Displays" +``` + +--- + +### Task 5: Package, install, update and uninstall it + +**Files:** +- Modify: `scripts/package-linux.sh` +- Modify: `scripts/install-linux.sh` +- Modify: `scripts/uninstall-linux.sh` +- Modify: `crates/mousetail/src/update.rs` +- Modify: `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: the extension directory (Tasks 2–4). +- Produces: release tarball folder `gnome-extension/mousetail@galen.green`; installed copy at `${XDG_DATA_HOME:-~/.local/share}/gnome-shell/extensions/mousetail@galen.green`. + +- [ ] **Step 1: Package it** + +In `scripts/package-linux.sh`, change the `mkdir` line and add after the Omarchy plugin's lines: + +```bash +rm -rf "$out" && mkdir -p "$out/omarchy-plugin" "$out/gnome-extension" +``` + +```bash +cp -r integrations/gnome/mousetail@galen.green "$out/gnome-extension/" +sed -i.bak "s/\"version-name\": \"[^\"]*\"/\"version-name\": \"$version\"/" "$out/gnome-extension/mousetail@galen.green/metadata.json" +rm "$out/gnome-extension/mousetail@galen.green/metadata.json.bak" +``` + +(the `cp` goes before the version line, the `sed` after `version=…`). Update its header comment: +"…install/uninstall scripts, the Omarchy bar plugin and the GNOME extension…". + +- [ ] **Step 2: Install it on GNOME** + +In `scripts/install-linux.sh`: after `omarchy=$config_home/omarchy` add + +```bash +gnome_uuid=mousetail@galen.green +gnome_extensions=${XDG_DATA_HOME:-$HOME/.local/share}/gnome-shell/extensions +``` + +in the release branch add `gnome_src=$here/gnome-extension/$gnome_uuid`, in the source branch +`gnome_src=$repo/integrations/gnome/$gnome_uuid`; after `say() {…}` add + +```bash +# GNOME: the desktop says so, or (run from SSH or a TTY) its shell is running for this user. +on_gnome() { + [[ ${XDG_CURRENT_DESKTOP:-} == *GNOME* ]] || pgrep -u "$(id -u)" -x gnome-shell >/dev/null 2>&1 +} +``` + +and after the Omarchy bar block: + +```bash +if on_gnome && [[ -d $gnome_src ]]; then + say "Adding MouseTail to GNOME's top bar" + rm -rf "${gnome_extensions:?}/$gnome_uuid" + mkdir -p "$gnome_extensions" + cp -r "$gnome_src" "$gnome_extensions/$gnome_uuid" + # GNOME only finds a new extension when you log in (on Wayland it can't reload), so if it + # won't switch this one on now, put it on the list for the next login. + if ! gnome-extensions enable "$gnome_uuid" 2>/dev/null; then + gjs -c " + const {Gio} = imports.gi; + const shell = new Gio.Settings({schema_id: 'org.gnome.shell'}); + const on = shell.get_strv('enabled-extensions'); + if (!on.includes('$gnome_uuid')) + shell.set_strv('enabled-extensions', [...on, '$gnome_uuid']); + shell.set_strv('disabled-extensions', shell.get_strv('disabled-extensions').filter(u => u !== '$gnome_uuid')); + Gio.Settings.sync();" 2>/dev/null || true + echo " It shows in the top bar after you next log in." + fi + if [[ $(gsettings get org.gnome.shell disable-user-extensions 2>/dev/null) == true ]]; then + echo " GNOME's extensions are switched off: turn them on in the Extensions app to see it." + fi +fi +``` + +Update the header comment ("…and on Omarchy or GNOME adds a status icon to the bar."). + +- [ ] **Step 3: Uninstall it** + +In `scripts/uninstall-linux.sh`, after the Omarchy block: + +```bash +gnome_uuid=mousetail@galen.green +gnome_extension=${XDG_DATA_HOME:-$HOME/.local/share}/gnome-shell/extensions/$gnome_uuid +if [[ -d $gnome_extension ]]; then + gnome-extensions disable "$gnome_uuid" 2>/dev/null || gjs -c " + const {Gio} = imports.gi; + const shell = new Gio.Settings({schema_id: 'org.gnome.shell'}); + shell.set_strv('enabled-extensions', shell.get_strv('enabled-extensions').filter(u => u !== '$gnome_uuid')); + Gio.Settings.sync();" 2>/dev/null + rm -rf "$gnome_extension" +fi +``` + +- [ ] **Step 4: Update it** + +In `crates/mousetail/src/update.rs`: add `const GNOME_EXTENSION: &str = "mousetail@galen.green";` +beside `PLUGIN_ID`, and in `install()` after the Omarchy plugin's `if`: + +```rust + let data = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share") + }); + let extension = data.join("gnome-shell/extensions").join(GNOME_EXTENSION); + let new_extension = staged.dir.join("gnome-extension").join(GNOME_EXTENSION); + if extension.is_dir() && new_extension.is_dir() { + replace_dir(&new_extension, &extension).context("updating the GNOME extension")?; + } +``` + +Update `install()`'s doc comment and the module doc to name the GNOME extension too. + +Run: `podman run --rm --security-opt label=disable -v "$PWD":/src -v mousetail-cargo:/cargo localhost/mousetail-build:44 sh -c 'cargo fmt --all --check && cargo clippy --locked -p mousetail --tests -- -D warnings && cargo test --locked -p mousetail'` +Expected: clean, all tests pass. + +- [ ] **Step 5: CI runs the extension's test** + +In `.github/workflows/ci.yml`, linux job, after `cargo test --locked -p mousetail-core`: + +```yaml + - run: node --test integrations/gnome/test/ +``` + +- [ ] **Step 6: End to end on this machine** + +Stop any dev daemon; build a release folder in the container +(`podman run --rm --security-opt label=disable -v "$PWD":/src -v mousetail-cargo:/cargo localhost/mousetail-build:44 scripts/package-linux.sh`); run +`dist/mousetail-linux-x86_64/install.sh` on the host. +Expected: "Adding MouseTail to GNOME's top bar" and "It shows in the top bar after you next log +in."; `~/.local/share/gnome-shell/extensions/mousetail@galen.green/metadata.json` has +`"version-name": "0.2.7"`; `gsettings get org.gnome.shell enabled-extensions` includes it; +`systemctl --user is-active mousetail` is `active`; no Omarchy plugin installed. +Then run `~/.local/share/mousetail/uninstall.sh`: the extension directory and its +`enabled-extensions` entry are gone. Then install again (so the user keeps MouseTail). + +- [ ] **Step 7: Commit** + +```bash +git add scripts/package-linux.sh scripts/install-linux.sh scripts/uninstall-linux.sh crates/mousetail/src/update.rs .github/workflows/ci.yml +git commit -m "Linux: install, update and remove the GNOME extension, like the Omarchy plugin" +``` + +--- + +### Task 6: Docs + +**Files:** +- Modify: `README.md`, `website/index.html`, `docs/DESIGN.md` + +- [ ] **Step 1: README** + +- Install paragraph: "…and, on Omarchy, adds an icon to the bar." → "…and adds an icon to the bar on Omarchy, or to the top bar on GNOME (from your next login)." +- Pair paragraph: "**Arrange Displays…** on a Mac or in the Omarchy bar" → "**Arrange Displays…** on a Mac, in the Omarchy bar or in GNOME's top bar". +- Project layout table: add `| \`integrations/gnome\` | GNOME Shell extension (and its test) |` under the Omarchy row. + +- [ ] **Step 2: Website table (GNOME column)** + +"Pause a computer without forgetting it": `Command line` → `Top bar`; "Status at a glance": +`Command line` → `Top bar`; "Arrange your computers": `Command line` → `Drag to arrange`. + +- [ ] **Step 3: DESIGN.md** + +In the Linux daemon paragraph, after the Omarchy bar plugin, add: "On GNOME, a Shell extension +(`integrations/gnome`) does the same from the top bar." + +- [ ] **Step 4: Commit** + +```bash +git add README.md website/index.html docs/DESIGN.md +git commit -m "Docs: the GNOME extension" +``` From 2e00d65e97449b52ae7dd4b2629e0cff5a3eee1d Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 14:24:32 +1300 Subject: [PATCH 04/14] GNOME extension: what the menu says, and the arrange view's maths --- .../gnome/mousetail@galen.green/status.js | 128 ++++++++++++++++++ integrations/gnome/test/status.test.js | 67 +++++++++ 2 files changed, 195 insertions(+) create mode 100644 integrations/gnome/mousetail@galen.green/status.js create mode 100644 integrations/gnome/test/status.test.js diff --git a/integrations/gnome/mousetail@galen.green/status.js b/integrations/gnome/mousetail@galen.green/status.js new file mode 100644 index 0000000..3ebcd5e --- /dev/null +++ b/integrations/gnome/mousetail@galen.green/status.js @@ -0,0 +1,128 @@ +// What the top-bar menu says about MouseTail, worked out from `mousetail watch`, and the +// arrange view's maths. No GNOME imports, so Node can check it (../test/status.test.js). + +/** A `mousetail watch` line: the status (`{running: false}` while the daemon is down), or null + * for anything unreadable, which is ignored as the Omarchy panel does. */ +export function parse(line) { + let data; + try { + data = JSON.parse(line); + } catch { + return null; + } + return data?.running === true ? data : {running: false}; +} + +export function nameOf(status, id) { + if (!id) + return ''; + return (status.peers ?? []).find(p => p.id === id)?.name ?? id; +} + +/** The computers worth listing: paired ones, and unpaired ones found on the network. */ +export function shownPeers(status) { + return (status.peers ?? []).filter(p => p.paired || p.connected); +} + +/** One line on what MouseTail is doing, under the menu's title. */ +export function summary(status) { + if (!status.running) + return 'Not running'; + if (status.pairing_code) + return `Pairing with ${status.pairing_code.name || 'another computer'}`; + if (status.controlled_by) + return `In use from ${nameOf(status, status.controlled_by)}`; + if (status.controlling) + return `Using ${nameOf(status, status.controlling)}`; + const connected = shownPeers(status).filter(p => p.paired && p.connected && !p.paused); + if (connected.length > 0) + return `Connected to ${connected.map(p => p.name).join(', ')}`; + return 'Not connected'; +} + +/** How a computer is doing, under its name. */ +export function peerDetail(status, p) { + if (!p.paired) + return p.connected ? 'Found on your network' : 'Not paired'; + if (p.paused) + return p.connected ? 'Paused' : 'Paused · Offline'; + if (!p.connected) + return 'Offline'; + if (status.controlled_by === p.id) + return 'Using this computer now'; + const parts = ['Connected']; + if (p.sound === 'here') + parts.push('its sound plays here'); + if (p.sound === 'there') + parts.push('plays your sound'); + return parts.join(' · '); +} + +/** A setting's switch. Missing settings read as on, but a clipboard setting must say so. */ +export function setting(status, key) { + const settings = status.settings; + if (!settings) + return true; + return key === 'clipboard' ? settings.clipboard === true : settings[key] !== false; +} + +/** What the arrange view draws: this computer, and the others that have a place. */ +export function placed(layout) { + return layout?.machines?.filter(m => m.this || m.offset) ?? []; +} + +export function offsetOf(m) { + return m.offset ?? {x: 0, y: 0}; +} + +/** Fits every machine's displays into `width` × `height` view pixels with `margin` round them: + * the scale, and where the layout's top-left (`bx`, `by`) lands (`x`, `y`). */ +export function fit(machines, width, height, margin = 40) { + const rects = machines.flatMap(m => { + const o = offsetOf(m); + return m.displays.map(d => ({x: d.rect.x + o.x, y: d.rect.y + o.y, w: d.rect.w, h: d.rect.h})); + }); + if (rects.length === 0 || width <= 0 || height <= 0) + return {scale: 0.1, x: 0, y: 0, bx: 0, by: 0}; + const x0 = Math.min(...rects.map(r => r.x)); + const y0 = Math.min(...rects.map(r => r.y)); + const x1 = Math.max(...rects.map(r => r.x + r.w)); + const y1 = Math.max(...rects.map(r => r.y + r.h)); + const scale = Math.max(0.01, + Math.min((width - 2 * margin) / (x1 - x0), (height - 2 * margin) / (y1 - y0))); + return { + scale, + x: (width - (x1 - x0) * scale) / 2, + y: (height - (y1 - y0) * scale) / 2, + bx: x0, + by: y0, + }; +} + +export function viewX(view, x) { + return view.x + (x - view.bx) * view.scale; +} + +export function viewY(view, y) { + return view.y + (y - view.by) * view.scale; +} + +/** Where machine `m`, dragged `dx`, `dy` view pixels, asks to go (for `mousetail place-at`). */ +export function dropAt(m, view, dx, dy) { + const o = offsetOf(m); + return {x: Math.round(o.x + dx / view.scale), y: Math.round(o.y + dy / view.scale)}; +} + +/** The bar that draws a crossing edge, in view pixels, `thickness` across. */ +export function crossingBar(view, [a, b], thickness = 3) { + const x0 = viewX(view, Math.min(a.x, b.x)); + const y0 = viewY(view, Math.min(a.y, b.y)); + const x1 = viewX(view, Math.max(a.x, b.x)); + const y1 = viewY(view, Math.max(a.y, b.y)); + return { + x: x0 - thickness / 2, + y: y0 - thickness / 2, + width: x1 - x0 + thickness, + height: y1 - y0 + thickness, + }; +} diff --git a/integrations/gnome/test/status.test.js b/integrations/gnome/test/status.test.js new file mode 100644 index 0000000..23440ae --- /dev/null +++ b/integrations/gnome/test/status.test.js @@ -0,0 +1,67 @@ +// The top-bar menu's logic, outside GNOME: node --test integrations/gnome/test/ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +import * as Status from '../mousetail@galen.green/status.js'; + +const omarchy = {id: 'o', name: 'omarchy', paired: true, connected: true, paused: false, sound: 'here'}; +const running = {running: true, peers: [omarchy]}; + +test('reads watch lines, ignoring junk', () => { + assert.equal(Status.parse('not json'), null); + assert.deepEqual(Status.parse('{"running":false}'), {running: false}); + assert.deepEqual(Status.parse(JSON.stringify(running)), running); +}); + +test('the summary says what MouseTail is doing', () => { + assert.equal(Status.summary({running: false}), 'Not running'); + assert.equal(Status.summary(running), 'Connected to omarchy'); + assert.equal(Status.summary({...running, controlling: 'o'}), 'Using omarchy'); + assert.equal(Status.summary({...running, controlled_by: 'o'}), 'In use from omarchy'); + assert.equal(Status.summary({...running, pairing_code: {name: 'mac', code: '1234'}}), 'Pairing with mac'); + assert.equal(Status.summary({...running, pairing_code: {code: '1234'}}), 'Pairing with another computer'); + assert.equal(Status.summary({...running, peers: [{...omarchy, paused: true}]}), 'Not connected'); +}); + +test('each computer says how it is', () => { + const detail = p => Status.peerDetail(running, p); + assert.equal(detail(omarchy), 'Connected · its sound plays here'); + assert.equal(detail({...omarchy, sound: 'there'}), 'Connected · plays your sound'); + assert.equal(detail({...omarchy, connected: false}), 'Offline'); + assert.equal(detail({...omarchy, paused: true, connected: false}), 'Paused · Offline'); + assert.equal(detail({...omarchy, paired: false}), 'Found on your network'); + assert.equal(Status.peerDetail({...running, controlled_by: 'o'}, omarchy), 'Using this computer now'); + assert.equal(Status.nameOf(running, 'nobody'), 'nobody'); +}); + +test('only paired or found computers are listed', () => { + const gone = {id: 'g', name: 'gone', paired: false, connected: false}; + assert.deepEqual(Status.shownPeers({running: true, peers: [omarchy, gone]}), [omarchy]); +}); + +test('settings read as the daemon defaults them', () => { + assert.equal(Status.setting({running: true}, 'clipboard'), true); + assert.equal(Status.setting({settings: {}}, 'clipboard'), false); + assert.equal(Status.setting({settings: {clipboard: true, audio: false}}, 'audio'), false); + assert.equal(Status.setting({settings: {}}, 'ripple'), true); +}); + +test('the arrange view fits everything in, centred, with a margin', () => { + const screen = {rect: {x: 0, y: 0, w: 1000, h: 500}}; + const machines = [ + {id: 'f', this: true, displays: [screen]}, + {id: 'o', offset: {x: 1000, y: 0}, displays: [screen]}, + ]; + const view = Status.fit(machines, 480, 400); + // 2000 × 500 points into 400 × 320 pixels: the width decides. + assert.equal(view.scale, 0.2); + assert.equal(view.x, 40); + assert.equal(view.y, 150); + assert.equal(Status.viewX(view, 1000), 240); + assert.equal(Status.viewY(view, 500), 250); + assert.deepEqual(Status.dropAt(machines[1], view, 20, -10), {x: 1100, y: -50}); + assert.deepEqual(Status.crossingBar(view, [{x: 1000, y: 0}, {x: 1000, y: 500}]), + {x: 238.5, y: 148.5, width: 3, height: 103}); + assert.deepEqual(Status.placed({machines: [...machines, {id: 'x', displays: [screen]}]}), machines); + assert.deepEqual(Status.fit([], 480, 400), {scale: 0.1, x: 0, y: 0, bx: 0, by: 0}); +}); From 4440d5155c6f7f21cc780ec858fe2ca2845c48ae Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 14:32:44 +1300 Subject: [PATCH 05/14] GNOME extension: MouseTail in the top bar, with its settings --- .../gnome/mousetail@galen.green/extension.js | 372 ++++++++++++++++++ .../icons/mousetail-symbolic.svg | 16 + .../gnome/mousetail@galen.green/metadata.json | 8 + .../mousetail@galen.green/stylesheet.css | 10 + 4 files changed, 406 insertions(+) create mode 100644 integrations/gnome/mousetail@galen.green/extension.js create mode 100644 integrations/gnome/mousetail@galen.green/icons/mousetail-symbolic.svg create mode 100644 integrations/gnome/mousetail@galen.green/metadata.json create mode 100644 integrations/gnome/mousetail@galen.green/stylesheet.css diff --git a/integrations/gnome/mousetail@galen.green/extension.js b/integrations/gnome/mousetail@galen.green/extension.js new file mode 100644 index 0000000..21b55bf --- /dev/null +++ b/integrations/gnome/mousetail@galen.green/extension.js @@ -0,0 +1,372 @@ +// MouseTail in GNOME's top bar: the MouseTail icon, and a menu with what the Omarchy bar +// plugin's panel has: who's connected (pair, pause, forget), Arrange Displays (arrange.js), any +// pairing code, the settings, and updates. Status streams from `mousetail watch`, one JSON line +// per change; everything else is the `mousetail` command. + +import Clutter from 'gi://Clutter'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import Pango from 'gi://Pango'; +import St from 'gi://St'; + +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; + +import * as Status from './status.js'; + +const BINARY = GLib.build_filenamev([GLib.get_home_dir(), '.local', 'bin', 'mousetail']); +const HELPERS = GLib.build_filenamev([GLib.get_user_data_dir(), 'mousetail']); + +/** Run `argv`, then `done(ok, stdout, stderr)`, unless `cancellable` was cancelled first. */ +function run(argv, cancellable, done) { + let proc; + try { + proc = Gio.Subprocess.new(argv, + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); + } catch (e) { + done(false, '', e.message); + return; + } + proc.communicate_utf8_async(null, cancellable, (p, result) => { + if (cancellable.is_cancelled()) + return; + let out = '', err = ''; + try { + [, out, err] = p.communicate_utf8_finish(result); + } catch (e) { + err = e.message; + } + done(p.get_successful(), out ?? '', err ?? ''); + }); +} + +/** Call `onLine` with each line `stream` gives, then `onEnd`, unless cancelled. */ +function readLines(stream, cancellable, onLine, onEnd = () => {}) { + const lines = new Gio.DataInputStream({base_stream: stream, close_base_stream: true}); + const next = () => lines.read_line_async(GLib.PRIORITY_DEFAULT, cancellable, (s, result) => { + if (cancellable.is_cancelled()) + return; + let line = null; + try { + [line] = s.read_line_finish_utf8(result); + } catch { + // The process has gone. + } + if (line === null) { + onEnd(); + return; + } + onLine(line); + next(); + }); + next(); +} + +/** What a command said on stderr, as the panel shows it. */ +function said(text) { + return text.trim().replace(/^mousetail: /, ''); +} + +/** Wrapped text, as wide as the menu. Dimmed through opacity, so it suits light and dark. */ +function note(text, styleClass = 'mousetail-note') { + const label = new St.Label({text, style_class: styleClass, x_expand: true}); + label.clutter_text.line_wrap = true; + label.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; + label.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + if (styleClass === 'mousetail-note') + label.opacity = 165; + return label; +} + +/** A menu row holding other things (buttons, an entry), which isn't clickable itself: clicking + * it, or a button in it, leaves the menu open. Reactive, though, as GNOME greys out rows that + * aren't; and not highlighted on hover. */ +function row(...children) { + const item = new PopupMenu.PopupBaseMenuItem({activate: false, hover: false, can_focus: false}); + item.track_hover = false; + children.forEach(child => item.add_child(child)); + return item; +} + +function button(label, onClick) { + const b = new St.Button({ + label, + style_class: 'button mousetail-button', + can_focus: true, + y_align: Clutter.ActorAlign.CENTER, + }); + b.connect('clicked', onClick); + return b; +} + +function iconButton(iconName, accessibleName, onClick, styleClass = '') { + const b = new St.Button({ + style_class: `button mousetail-icon-button ${styleClass}`, + can_focus: true, + accessible_name: accessibleName, + y_align: Clutter.ActorAlign.CENTER, + child: new St.Icon({icon_name: iconName, icon_size: 16}), + }); + b.connect('clicked', onClick); + return b; +} + +// A switch that leaves the menu open, like the Omarchy panel's. +const SettingItem = GObject.registerClass({GTypeName: 'MouseTailSettingItem'}, +class SettingItem extends PopupMenu.PopupSwitchMenuItem { + activate() { + this.toggle(); + } +}); + +const Indicator = GObject.registerClass({GTypeName: 'MouseTailIndicator'}, +class Indicator extends PanelMenu.Button { + _init(path) { + super._init(0.5, 'MouseTail'); + this._logo = Gio.icon_new_for_string(`${path}/icons/mousetail-symbolic.svg`); + this.add_child(new St.Icon({gicon: this._logo, style_class: 'system-status-icon'})); + this.menu.box.add_style_class_name('mousetail-menu'); + + this._status = {running: false}; + this._message = ''; + this._atLogin = true; + this._queue = []; + this._busy = false; + this._shown = new Map(); + this._cancellable = new Gio.Cancellable(); + + this._build(); + this._update(); + this.menu.connect('open-state-changed', (_menu, open) => { + if (open) + this._opened(); + }); + this.connect('destroy', () => this._stop()); + this._watch(); + } + + _build() { + const menu = this.menu; + + const title = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + title.add_child(new St.Label({text: 'MouseTail', style_class: 'mousetail-title'})); + this._summary = new St.Label({style_class: 'mousetail-summary'}); + this._summary.opacity = 165; + title.add_child(this._summary); + menu.addMenuItem(row( + new St.Icon({gicon: this._logo, icon_size: 32, style_class: 'mousetail-logo'}), + title)); + + // Anything stopping MouseTail doing its job here; the pairing code (Task 3). + this._problems = new PopupMenu.PopupMenuSection(); + menu.addMenuItem(this._problems); + this._code = new PopupMenu.PopupMenuSection(); + menu.addMenuItem(this._code); + + this._computersHeading = new PopupMenu.PopupSeparatorMenuItem('Computers'); + menu.addMenuItem(this._computersHeading); + this._computers = new PopupMenu.PopupMenuSection(); + menu.addMenuItem(this._computers); + this._arrange = new PopupMenu.PopupMenuItem('Arrange Displays…'); + this._arrange.connect('activate', () => this._openArrange()); + menu.addMenuItem(this._arrange); + + this._settingsHeading = new PopupMenu.PopupSeparatorMenuItem('Settings'); + menu.addMenuItem(this._settingsHeading); + this._switches = [ + ['Sound follows you', 'audio'], + ['Share clipboard', 'clipboard'], + ['Ripple when crossing', 'ripple'], + ['Start at login', null], + ['Update automatically', 'updates'], + ].map(([label, key]) => { + const item = new SettingItem(label, true); + item.connect('toggled', (_item, on) => { + if (this._syncing) + return; + if (key) + this._command([BINARY, 'set', key, on ? 'on' : 'off']); + else + this._setAtLogin(on); + }); + menu.addMenuItem(item); + return {item, key}; + }); + + this._notRunning = row(note("MouseTail isn't running.")); + menu.addMenuItem(this._notRunning); + + menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this._messageLabel = note(''); + this._messageRow = row(this._messageLabel); + menu.addMenuItem(this._messageRow); + this._updatesButton = button('Check for Updates', () => this._checkForUpdates()); + this._startStop = button('Stop MouseTail', () => this._command( + ['systemctl', '--user', this._status.running ? 'stop' : 'start', 'mousetail'])); + const actions = new St.BoxLayout({style_class: 'mousetail-actions', x_expand: true}); + actions.add_child(this._updatesButton); + actions.add_child(this._startStop); + menu.addMenuItem(row(actions)); + this._versionLabel = note(''); + this._versionRow = row(this._versionLabel); + menu.addMenuItem(this._versionRow); + } + + /** Make the menu say what the status says. */ + _update() { + const st = this._status; + const running = st.running === true; + this._summary.text = Status.summary(st); + + this._computersHeading.visible = running; + this._arrange.visible = running && Status.shownPeers(st).some(p => p.paired); + this._settingsHeading.visible = running; + this._syncing = true; + for (const {item, key} of this._switches) { + item.visible = running; + item.setToggleState(key ? Status.setting(st, key) : this._atLogin); + } + this._syncing = false; + + this._notRunning.visible = !running; + this._messageLabel.text = this._message; + this._messageRow.visible = this._message !== ''; + this._updatesButton.visible = running; + this._startStop.label = running ? 'Stop MouseTail' : 'Start MouseTail'; + this._versionLabel.text = st.version ? `MouseTail ${st.version}` : ''; + this._versionRow.visible = !!st.version; + } + + /** Fill `section` with `items()` when what it shows (`key`) has changed, so an unrelated + * update doesn't sweep away a field being typed in. A falsy key empties it. */ + _refill(section, key, items) { + const shows = JSON.stringify(key || null); + if (this._shown.get(section) === shows) + return; + this._shown.set(section, shows); + section.removeAll(); + if (key) + items().forEach(item => section.addMenuItem(item)); + } + + _opened() { + this._message = ''; + run(['systemctl', '--user', 'is-enabled', 'mousetail'], this._cancellable, (_ok, out) => { + this._atLogin = out.trim() === 'enabled'; + this._update(); + }); + this._update(); + } + + _openArrange() { + // Task 4. + } + + // -------------------------------------------------------------- commands + + /** Run a command once those before it have finished; what it says goes in the menu. */ + _command(argv) { + this._queue.push(argv); + if (!this._busy) + this._next(); + } + + _next() { + const argv = this._queue.shift(); + this._busy = argv !== undefined; + if (!this._busy) + return; + run(argv, this._cancellable, (_ok, _out, err) => { + if (said(err) !== '') { + this._message = said(err); + this._update(); + } + this._next(); + }); + } + + _setAtLogin(on) { + this._atLogin = on; + this._command(['systemctl', '--user', on ? 'enable' : 'disable', 'mousetail']); + } + + _checkForUpdates() { + this._message = 'Checking for updates…'; + this._updatesButton.reactive = false; + this._update(); + run([BINARY, 'update'], this._cancellable, (_ok, out, err) => { + this._updatesButton.reactive = true; + this._message = said(err) || out.trim(); + this._update(); + }); + } + + // -------------------------------------------------------------- status + + _watch() { + let proc; + try { + proc = Gio.Subprocess.new([BINARY, 'watch'], + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE); + } catch { + // Not installed (yet). + this._watchAgain(); + return; + } + this._watcher = proc; + readLines(proc.get_stdout_pipe(), this._cancellable, line => { + const status = Status.parse(line); + if (status) + this._setStatus(status); + }); + proc.wait_async(this._cancellable, (p, result) => { + try { + p.wait_finish(result); + } catch { + return; // Cancelled: the extension is going away. + } + this._watcher = null; + this._setStatus({running: false}); + this._watchAgain(); + }); + } + + _watchAgain() { + this._watchTimer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 3, () => { + this._watchTimer = 0; + this._watch(); + return GLib.SOURCE_REMOVE; + }); + } + + _setStatus(status) { + this._status = status; + this._update(); + } + + _stop() { + this._cancellable.cancel(); + this._watcher?.force_exit(); + if (this._watchTimer) + GLib.source_remove(this._watchTimer); + } +}); + +export default class MouseTailExtension extends Extension { + enable() { + this._indicator = new Indicator(this.path); + Main.panel.addToStatusArea(this.uuid, this._indicator); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } +} diff --git a/integrations/gnome/mousetail@galen.green/icons/mousetail-symbolic.svg b/integrations/gnome/mousetail@galen.green/icons/mousetail-symbolic.svg new file mode 100644 index 0000000..67ede95 --- /dev/null +++ b/integrations/gnome/mousetail@galen.green/icons/mousetail-symbolic.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/integrations/gnome/mousetail@galen.green/metadata.json b/integrations/gnome/mousetail@galen.green/metadata.json new file mode 100644 index 0000000..00a02c0 --- /dev/null +++ b/integrations/gnome/mousetail@galen.green/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "mousetail@galen.green", + "name": "MouseTail", + "description": "Share one keyboard, mouse, clipboard and sound between this computer and the ones beside it. Shows what's connected and any pairing code.", + "url": "https://github.com/galengreen/mousetail", + "shell-version": ["50"], + "version-name": "0.2.7" +} diff --git a/integrations/gnome/mousetail@galen.green/stylesheet.css b/integrations/gnome/mousetail@galen.green/stylesheet.css new file mode 100644 index 0000000..f475265 --- /dev/null +++ b/integrations/gnome/mousetail@galen.green/stylesheet.css @@ -0,0 +1,10 @@ +/* The top-bar menu, as wide as the Omarchy panel. */ +.mousetail-menu { width: 22em; } +.mousetail-title { font-weight: bold; font-size: 1.15em; } +.mousetail-summary, +.mousetail-note { font-size: 0.9em; } +.mousetail-urgent { font-size: 0.9em; color: #f66151; } +.mousetail-logo { margin-right: 6px; } +.mousetail-actions { spacing: 6px; } +.mousetail-button { padding: 4px 12px; font-size: 0.9em; } +.mousetail-icon-button { padding: 5px; border-radius: 99px; } From 86aa5cab0d2546c06f0b5d90168a735abb7bda87 Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 14:37:23 +1300 Subject: [PATCH 06/14] GNOME extension: computers, pairing, the pairing code and problems --- .../gnome/mousetail@galen.green/extension.js | 210 +++++++++++++++++- .../mousetail@galen.green/stylesheet.css | 5 + 2 files changed, 214 insertions(+), 1 deletion(-) diff --git a/integrations/gnome/mousetail@galen.green/extension.js b/integrations/gnome/mousetail@galen.green/extension.js index 21b55bf..4658df8 100644 --- a/integrations/gnome/mousetail@galen.green/extension.js +++ b/integrations/gnome/mousetail@galen.green/extension.js @@ -224,9 +224,24 @@ class Indicator extends PanelMenu.Button { const st = this._status; const running = st.running === true; this._summary.text = Status.summary(st); + this._refill(this._problems, + running && [st.capture_error, st.can_be_controlled, st.firewall, this._fixingFirewall], + () => this._problemItems()); + this._refill(this._code, running && st.pairing_code, () => this._codeItems()); + const peers = running ? Status.shownPeers(st) : []; + const pairing = this._pairing && [this._pairing.id, !!this._pairing.proc, this._pairing.error]; + this._refill(this._computers, + running && [peers.map(p => [p.id, p.name, p.paired, p.connected, p.paused, Status.peerDetail(st, p)]), pairing], + () => this._computerItems(peers)); + if (this._focusEntry) { + // Just shown: focus it, so the code can just be typed. + if (this.menu.isOpen) + this._focusEntry.grab_key_focus(); + this._focusEntry = null; + } this._computersHeading.visible = running; - this._arrange.visible = running && Status.shownPeers(st).some(p => p.paired); + this._arrange.visible = running && peers.some(p => p.paired); this._settingsHeading.visible = running; this._syncing = true; for (const {item, key} of this._switches) { @@ -263,12 +278,200 @@ class Indicator extends PanelMenu.Button { this._update(); }); this._update(); + this._codeEntry?.grab_key_focus(); } _openArrange() { // Task 4. } + // -------------------------------------------------------------- sections + + _problemItems() { + const st = this._status; + const items = []; + if (st.capture_error) + items.push(row(note(st.capture_error, 'mousetail-urgent'))); + if (st.can_be_controlled === false) { + const script = `${HELPERS.replace(GLib.get_home_dir(), '~')}/enable-input.sh`; + items.push(row(note(`Other computers can't control this one yet. Run this once: ${script}`))); + } + if (st.firewall) { + items.push(row(note("This computer's firewall stops other computers reaching it, so connecting can be slow or fail."))); + const fix = button('Fix Firewall…', () => this._fixFirewall()); + fix.reactive = !this._fixingFirewall; + items.push(row(fix)); + } + return items; + } + + _codeItems() { + const code = this._status.pairing_code; + return [ + new PopupMenu.PopupSeparatorMenuItem('Pairing code'), + row(new St.Label({text: code.code.split('').join(' '), style_class: 'mousetail-code'})), + row(note(`Type this on ${code.name || 'your other computer'} to connect it.`)), + ]; + } + + _computerItems(peers) { + if (peers.length === 0) + return [row(note('Looking for other computers on your network…'))]; + return peers.flatMap(p => this._peerItems(p)); + } + + _peerItems(p) { + const pairing = this._pairing?.id === p.id ? this._pairing : null; + const text = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + const name = new St.Label({text: p.name, style_class: 'mousetail-peer'}); + if (!p.connected || p.paused) + name.opacity = 140; + text.add_child(name); + const detail = new St.Label({text: Status.peerDetail(this._status, p), style_class: 'mousetail-peer-detail'}); + detail.opacity = 165; + text.add_child(detail); + + const controls = [text]; + if (!p.paired && p.connected && !pairing?.proc) + controls.push(button('Pair…', () => this._startPairing(p))); + if (p.paired) { + controls.push(iconButton( + p.paused ? 'media-playback-start-symbolic' : 'media-playback-pause-symbolic', + p.paused ? `Resume ${p.name}` : `Pause ${p.name} without forgetting it`, + () => this._command([BINARY, p.paused ? 'resume' : 'pause', p.id]))); + controls.push(iconButton('window-close-symbolic', `Forget ${p.name}`, + () => this._command([BINARY, 'unpair', p.id]), 'mousetail-forget')); + } + const items = [row(...controls)]; + if (pairing?.proc) + items.push(...this._codeEntryItems(p, pairing)); + if (pairing?.error) + items.push(row(note(pairing.error, 'mousetail-urgent'))); + return items; + } + + /** Pairing: the other computer shows a code to type here. */ + _codeEntryItems(p, pairing) { + const entry = new St.Entry({ + hint_text: 'Code', + text: pairing.typed, + can_focus: true, + x_expand: true, + style_class: 'mousetail-code-entry', + }); + entry.clutter_text.max_length = 8; + entry.clutter_text.connect('text-changed', () => (pairing.typed = entry.get_text())); + entry.clutter_text.connect('activate', () => { + this._sendCode(entry.get_text()); + entry.set_text(''); + }); + entry.clutter_text.connect('key-press-event', (_actor, event) => { + if (event.get_key_symbol() !== Clutter.KEY_Escape) + return Clutter.EVENT_PROPAGATE; + this._cancelPairing(); + return Clutter.EVENT_STOP; + }); + entry.connect('destroy', () => { + if (this._codeEntry === entry) + this._codeEntry = null; + }); + this._codeEntry = entry; + this._focusEntry = entry; + return [ + row(note(`Type the code showing on ${p.name}:`)), + row(entry, button('Cancel', () => this._cancelPairing())), + ]; + } + + // -------------------------------------------------------------- pairing + + /** `mousetail pair` asks the other computer to show a code, then reads it from stdin. A + * wrong code ends it, so the row offers Pair… again, for a fresh code. */ + _startPairing(p) { + this._cancelPairing(false); + const pairing = {id: p.id, proc: null, error: '', said: '', typed: '', paired: false}; + this._pairing = pairing; + try { + pairing.proc = Gio.Subprocess.new([BINARY, 'pair', p.id], + Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE | + Gio.SubprocessFlags.STDERR_PIPE); + } catch (e) { + pairing.error = e.message; + this._update(); + return; + } + let waiting = 2; // its exit, and the end of what it says on stderr + const finished = () => { + if (--waiting > 0 || this._pairing !== pairing) + return; + if (pairing.paired) { + this._pairing = null; + } else { + pairing.proc = null; + pairing.error = said(pairing.said) || "That didn't work. Try again."; + } + this._update(); + }; + readLines(pairing.proc.get_stdout_pipe(), this._cancellable, line => { + if (line.includes('Paired with')) { + pairing.paired = true; + this._message = line.slice(line.indexOf('Paired with')).trim(); + } + }); + readLines(pairing.proc.get_stderr_pipe(), this._cancellable, + line => (pairing.said += `${line}\n`), finished); + pairing.proc.wait_async(this._cancellable, (proc, result) => { + try { + proc.wait_finish(result); + } catch { + return; // Cancelled: the extension is going away. + } + finished(); + }); + this._update(); + } + + _sendCode(code) { + code = code.trim(); + const pairing = this._pairing; + if (code === '' || !pairing?.proc) + return; + pairing.error = ''; + try { + const stdin = pairing.proc.get_stdin_pipe(); + stdin.write_all(new TextEncoder().encode(`${code}\n`), null); + stdin.flush(null); + } catch (e) { + pairing.error = e.message; + this._update(); + } + } + + _cancelPairing(update = true) { + const pairing = this._pairing; + this._pairing = null; + pairing?.proc?.force_exit(); + if (update) + this._update(); + } + + /** enable-firewall.sh asks for a password, so it runs in a terminal: the first there is. */ + _fixFirewall() { + const script = `${GLib.shell_quote(`${HELPERS}/enable-firewall.sh`)}; read -rp 'Press Enter to close. '`; + const terminal = 'command -v xdg-terminal-exec >/dev/null && exec xdg-terminal-exec bash -c "$0"; ' + + 'for t in ptyxis kgx gnome-terminal; do command -v "$t" >/dev/null && exec "$t" -- bash -c "$0"; done'; + this._fixingFirewall = true; + this._update(); + run(['bash', '-c', terminal, script], this._cancellable, () => { + this._fixingFirewall = false; + this._update(); + }); + } + // -------------------------------------------------------------- commands /** Run a command once those before it have finished; what it says goes in the menu. */ @@ -347,12 +550,17 @@ class Indicator extends PanelMenu.Button { } _setStatus(status) { + const hadCode = !!this._status.pairing_code; this._status = status; this._update(); + // A new pairing code is the one thing worth interrupting for. + if (!hadCode && status.pairing_code && !this.menu.isOpen) + this.menu.open(); } _stop() { this._cancellable.cancel(); + this._pairing?.proc?.force_exit(); this._watcher?.force_exit(); if (this._watchTimer) GLib.source_remove(this._watchTimer); diff --git a/integrations/gnome/mousetail@galen.green/stylesheet.css b/integrations/gnome/mousetail@galen.green/stylesheet.css index f475265..6feb3e1 100644 --- a/integrations/gnome/mousetail@galen.green/stylesheet.css +++ b/integrations/gnome/mousetail@galen.green/stylesheet.css @@ -8,3 +8,8 @@ .mousetail-actions { spacing: 6px; } .mousetail-button { padding: 4px 12px; font-size: 0.9em; } .mousetail-icon-button { padding: 5px; border-radius: 99px; } +.mousetail-code { font-size: 2.2em; font-weight: 600; } +.mousetail-peer { font-weight: 500; } +.mousetail-peer-detail { font-size: 0.85em; } +.mousetail-code-entry { min-width: 6em; } +.mousetail-forget:hover { color: #f66151; } From 3102a245e2bd1569317d7f34ff572c729546deac Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 14:44:28 +1300 Subject: [PATCH 07/14] GNOME extension: Arrange Displays --- .../gnome/mousetail@galen.green/arrange.js | 259 ++++++++++++++++++ .../gnome/mousetail@galen.green/extension.js | 11 +- .../mousetail@galen.green/stylesheet.css | 35 +++ 3 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 integrations/gnome/mousetail@galen.green/arrange.js diff --git a/integrations/gnome/mousetail@galen.green/arrange.js b/integrations/gnome/mousetail@galen.green/arrange.js new file mode 100644 index 0000000..3103572 --- /dev/null +++ b/integrations/gnome/mousetail@galen.green/arrange.js @@ -0,0 +1,259 @@ +// Arrange Displays: drag the other computers to where they sit on your desk, like the Mac's +// Arrange Displays and the Omarchy panel's, in MouseTail's own colours (stylesheet.css) rather +// than the theme's. Drops snap to the nearest edge; glowing edges are where the cursor crosses. + +import Clutter from 'gi://Clutter'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import Pango from 'gi://Pango'; +import St from 'gi://St'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as ModalDialog from 'resource:///org/gnome/shell/ui/modalDialog.js'; + +import * as Status from './status.js'; + +/** How long a dropped computer takes to glide to where it snapped. */ +const GLIDE_MS = 220; +/** Room the title, help, legend and button take beside the canvas. */ +const CHROME = {width: 48, height: 210}; + +export const ArrangeDialog = GObject.registerClass({GTypeName: 'MouseTailArrangeDialog'}, +class ArrangeDialog extends ModalDialog.ModalDialog { + /** `run(argv, done)` runs a command, then `done(ok, stdout, stderr)`. */ + _init(binary, run) { + super._init({styleClass: 'mousetail-arrange'}); + this._binary = binary; + this._run = run; + this._layout = null; + this._view = null; + this._groups = new Map(); // machine id → its tiles + this._drag = null; + this._loading = false; + this._closed = false; + + // About 70% of the screen, as on Omarchy, but no bigger than 900 × 620. + const monitor = Main.layoutManager.currentMonitor; + const width = Math.round(Math.min(monitor.width * 0.7, 900) - CHROME.width); + const height = Math.round(Math.min(monitor.height * 0.7, 620) - CHROME.height); + + this.contentLayout.add_child(new St.Label({text: 'Arrange Displays', style_class: 'mousetail-arrange-title'})); + const help = new St.Label({ + text: 'Drag each computer to where it sits on your desk. Push the cursor off a highlighted edge to move to the other computer.', + style_class: 'mousetail-arrange-help', + }); + help.clutter_text.line_wrap = true; + this.contentLayout.add_child(help); + + this._canvas = new St.Widget({style_class: 'mousetail-arrange-canvas', width, height, clip_to_allocation: true}); + this._wait = new St.Label({text: 'Loading…', style_class: 'mousetail-arrange-help'}); + this._wait.add_constraint(new Clutter.AlignConstraint({source: this._canvas, align_axis: Clutter.AlignAxis.BOTH, factor: 0.5})); + this._canvas.add_child(this._wait); + this.contentLayout.add_child(this._canvas); + + const legend = new St.BoxLayout({style_class: 'mousetail-arrange-legend'}); + for (const [label, swatch] of [['This computer', 'this'], ['Other computers', 'other'], ['Cursor crosses here', 'crossing']]) { + legend.add_child(new St.Widget({style_class: `mousetail-swatch mousetail-swatch-${swatch}`, y_align: Clutter.ActorAlign.CENTER})); + legend.add_child(new St.Label({text: label, style_class: 'mousetail-arrange-legend-label', y_align: Clutter.ActorAlign.CENTER})); + } + this.contentLayout.add_child(legend); + + this.setButtons([{label: 'Done', action: () => this.close(), key: Clutter.KEY_Escape}]); + + // Keep up with the other computer being moved from there, or connecting. + this._timer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => { + this._refresh(); + return GLib.SOURCE_CONTINUE; + }); + this.connect('destroy', () => { + this._closed = true; + GLib.source_remove(this._timer); + this._drag?.grab.dismiss(); + }); + this._refresh(); + } + + _refresh() { + if (this._drag || this._loading) + return; + this._loading = true; + this._run([this._binary, 'layout'], (_ok, out) => { + this._loading = false; + if (this._closed || this._drag) + return; + let layout = null; + try { + layout = JSON.parse(out); + } catch { + return; + } + if (layout?.machines) + this._show(layout); + }); + } + + /** Draw `layout`: machines kept from one load to the next, so a moved one glides. */ + _show(layout) { + this._layout = layout; + this._wait.visible = false; + const shown = Status.placed(layout); + const view = Status.fit(shown, this._canvas.width, this._canvas.height); + this._view = view; + + const ids = new Set(shown.map(m => m.id)); + for (const [id, group] of this._groups) { + if (!ids.has(id)) { + group.destroy(); + this._groups.delete(id); + } + } + for (const m of shown) { + let group = this._groups.get(m.id); + const fresh = !group; + if (fresh) { + group = new St.Widget(); + this._canvas.add_child(group); + this._groups.set(m.id, group); + } + this._drawTiles(group, m, view); + const o = Status.offsetOf(m); + const x = Status.viewX(view, o.x); + const y = Status.viewY(view, o.y); + if (fresh) + group.set_position(x, y); + else + group.ease({x, y, duration: GLIDE_MS, mode: Clutter.AnimationMode.EASE_OUT_CUBIC}); + } + + // Where the cursor crosses, above the tiles. + this._crossings?.destroy(); + this._crossings = new St.Widget(); + for (const edge of layout.crossings ?? []) { + const bar = Status.crossingBar(view, edge); + this._crossings.add_child(new St.Widget({style_class: 'mousetail-crossing', ...bar})); + } + this._canvas.add_child(this._crossings); + } + + _drawTiles(group, m, view) { + group.destroy_all_children(); + const kind = m.this ? 'this' : m.connected && !m.paused ? 'other' : 'offline'; + for (const d of m.displays) { + const width = d.rect.w * view.scale; + const height = d.rect.h * view.scale; + const tile = new St.Widget({ + style_class: `mousetail-tile mousetail-tile-${kind}`, + layout_manager: new Clutter.BinLayout(), + x: d.rect.x * view.scale, + y: d.rect.y * view.scale, + width, + height, + reactive: !m.this, + }); + // A strip marks each computer's main display, as macOS does. + if (d.primary) + tile.add_child(new St.Widget({ + style_class: 'mousetail-tile-main', + x_expand: true, + y_expand: true, + y_align: Clutter.ActorAlign.START, + })); + const labels = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + for (const [text, styleClass] of [ + [m.this ? 'This computer' : m.name, 'mousetail-tile-name'], + [m.connected || m.this ? d.name : 'Offline', 'mousetail-tile-display'], + ]) { + const label = new St.Label({text, style_class: styleClass, width: Math.max(0, width - 12)}); + label.clutter_text.ellipsize = Pango.EllipsizeMode.END; + labels.add_child(label); + } + tile.add_child(labels); + if (!m.this) + this._draggable(tile, group, m); + group.add_child(tile); + } + } + + // Through the `event` signal: GNOME 50 no longer sends a tile `button-press-event` or + // `button-release-event`. + _draggable(tile, group, m) { + tile.connect('event', (_actor, event) => { + switch (event.type()) { + case Clutter.EventType.BUTTON_PRESS: + return this._press(event, tile, group, m); + case Clutter.EventType.MOTION: + return this._move(event, group); + case Clutter.EventType.BUTTON_RELEASE: + return this._release(event, group); + default: + return Clutter.EVENT_PROPAGATE; + } + }); + } + + _press(event, tile, group, m) { + if (event.get_button() !== Clutter.BUTTON_PRIMARY || this._drag) + return Clutter.EVENT_PROPAGATE; + const [x, y] = event.get_coords(); + group.remove_all_transitions(); + this._drag = {m, group, x, y, dx: 0, dy: 0, moved: false, view: this._view, + from: {x: group.x, y: group.y}, grab: global.stage.grab(tile)}; + return Clutter.EVENT_STOP; + } + + _move(event, group) { + const drag = this._drag; + if (drag?.group !== group) + return Clutter.EVENT_PROPAGATE; + const [x, y] = event.get_coords(); + drag.dx = x - drag.x; + drag.dy = y - drag.y; + if (!drag.moved) { + if (Math.abs(drag.dx) + Math.abs(drag.dy) < 3) + return Clutter.EVENT_STOP; + // Picked up: it glows, like the logo's tail, above the rest. + drag.moved = true; + this._canvas.set_child_above_sibling(group, null); + group.get_children().forEach(t => t.add_style_class_name('mousetail-tile-lifted')); + this._crossings?.hide(); + } + group.set_position(drag.from.x + drag.dx, drag.from.y + drag.dy); + return Clutter.EVENT_STOP; + } + + _release(event, group) { + const drag = this._drag; + if (drag?.group !== group || event.get_button() !== Clutter.BUTTON_PRIMARY) + return Clutter.EVENT_PROPAGATE; + drag.grab.dismiss(); + if (drag.moved) + this._drop(drag); + else + this._drag = null; + return Clutter.EVENT_STOP; + } + + /** Ask MouseTail to put it there; it answers with where it snapped, and the tile glides + * there. */ + _drop(drag) { + const at = Status.dropAt(drag.m, drag.view, drag.dx, drag.dy); + this._run([this._binary, 'place-at', drag.m.id, String(at.x), String(at.y)], (_ok, out) => { + if (this._closed) + return; + let offset = null; + try { + offset = JSON.parse(out).offset ?? null; + } catch { + // Back where it was. + } + this._drag = null; + const machines = this._layout.machines.map(m => (offset && m.id === drag.m.id ? {...m, offset} : m)); + this._show({machines, crossings: []}); + this._refresh(); + }); + } +}); diff --git a/integrations/gnome/mousetail@galen.green/extension.js b/integrations/gnome/mousetail@galen.green/extension.js index 4658df8..03cca5b 100644 --- a/integrations/gnome/mousetail@galen.green/extension.js +++ b/integrations/gnome/mousetail@galen.green/extension.js @@ -16,6 +16,7 @@ import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; import * as Status from './status.js'; +import {ArrangeDialog} from './arrange.js'; const BINARY = GLib.build_filenamev([GLib.get_home_dir(), '.local', 'bin', 'mousetail']); const HELPERS = GLib.build_filenamev([GLib.get_user_data_dir(), 'mousetail']); @@ -282,7 +283,14 @@ class Indicator extends PanelMenu.Button { } _openArrange() { - // Task 4. + this._arrangeDialog?.close(); + const dialog = new ArrangeDialog(BINARY, (argv, done) => run(argv, this._cancellable, done)); + dialog.connect('destroy', () => { + if (this._arrangeDialog === dialog) + this._arrangeDialog = null; + }); + this._arrangeDialog = dialog; + dialog.open(); } // -------------------------------------------------------------- sections @@ -562,6 +570,7 @@ class Indicator extends PanelMenu.Button { this._cancellable.cancel(); this._pairing?.proc?.force_exit(); this._watcher?.force_exit(); + this._arrangeDialog?.close(); if (this._watchTimer) GLib.source_remove(this._watchTimer); } diff --git a/integrations/gnome/mousetail@galen.green/stylesheet.css b/integrations/gnome/mousetail@galen.green/stylesheet.css index 6feb3e1..d3ab4e4 100644 --- a/integrations/gnome/mousetail@galen.green/stylesheet.css +++ b/integrations/gnome/mousetail@galen.green/stylesheet.css @@ -13,3 +13,38 @@ .mousetail-peer-detail { font-size: 0.85em; } .mousetail-code-entry { min-width: 6em; } .mousetail-forget:hover { color: #f66151; } +/* Arrange Displays, in MouseTail's own colours. */ +.modal-dialog.mousetail-arrange { + background-color: #080808; + border: 1px solid rgba(242, 241, 236, 0.09); + color: #f2f1ec; +} +.modal-dialog.mousetail-arrange .modal-dialog-content-box { max-width: 900px; spacing: 8px; } +.mousetail-arrange-title { font-size: 1.4em; font-weight: 600; color: #f2f1ec; } +.mousetail-arrange-help { font-size: 0.9em; color: rgba(242, 241, 236, 0.64); } +.mousetail-arrange-canvas { + background-color: #111212; + border: 1px solid rgba(242, 241, 236, 0.09); + border-radius: 12px; +} +.mousetail-tile { + border-radius: 5px; + border: 1px solid rgba(255, 255, 255, 0.12); + background-gradient-direction: vertical; +} +.mousetail-tile-this { background-gradient-start: #383939; background-gradient-end: #292a2a; color: rgba(242, 241, 236, 0.64); } +.mousetail-tile-other { background-gradient-start: #4a4535; background-gradient-end: #353226; border-color: rgba(255, 255, 255, 0.18); color: #f2f1ec; } +.mousetail-tile-offline { background-gradient-start: #232424; background-gradient-end: #1b1c1c; border-color: rgba(255, 255, 255, 0.08); color: rgba(242, 241, 236, 0.42); } +.mousetail-tile-lifted { border: 2px solid #ffeba7; box-shadow: 0 0 8px 1px rgba(255, 224, 110, 0.35); } +.mousetail-tile-main { height: 4px; margin: 1px; border-radius: 4px; background-color: rgba(242, 241, 236, 0.45); } +.mousetail-tile-name { font-size: 0.85em; font-weight: 600; text-align: center; } +.mousetail-tile-display { font-size: 0.75em; text-align: center; } +.mousetail-crossing { background-color: #ffeba7; border-radius: 2px; box-shadow: 0 0 6px 2px rgba(255, 224, 110, 0.4); } +.mousetail-arrange-legend { spacing: 6px; } +.mousetail-arrange-legend-label { font-size: 0.8em; color: rgba(242, 241, 236, 0.64); margin-right: 12px; } +.mousetail-swatch { width: 14px; height: 10px; border-radius: 2px; border: 1px solid rgba(255, 255, 255, 0.15); } +.mousetail-swatch-this { background-color: #383939; } +.mousetail-swatch-other { background-color: #4a4535; } +.mousetail-swatch-crossing { height: 3px; border-width: 0; background-color: #ffeba7; } +.modal-dialog.mousetail-arrange .modal-dialog-button { background-color: #ffeba7; color: #080808; } +.modal-dialog.mousetail-arrange .modal-dialog-button:hover { background-color: #fff2c4; } From 99f7b9776b765748341f1816782c967a18c2a2bc Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 14:57:54 +1300 Subject: [PATCH 08/14] Stopping MouseTail doesn't wait for GNOME's permission dialog Starting capture waits until someone answers the dialog, and it waited in Tokio's blocking pool, which the runtime waits for when it shuts down: stopping the service while the dialog was up hung until systemd killed it. It waits on its own thread now. --- crates/mousetail/src/node.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/mousetail/src/node.rs b/crates/mousetail/src/node.rs index 85127b2..f2a62d0 100644 --- a/crates/mousetail/src/node.rs +++ b/crates/mousetail/src/node.rs @@ -480,14 +480,16 @@ impl Node { async fn start_capture(self: Arc, actions: mpsc::UnboundedSender) { let mut prompt = true; loop { - // Not on an async worker: it can wait a long time (on GNOME, for someone to answer - // the permission dialog). + // On its own thread: it can wait as long as someone takes to answer GNOME's + // permission dialog, and quitting waits for blocking tasks but not for threads. let (controller, actions) = (self.controller.clone(), actions.clone()); - let started = tokio::task::spawn_blocking(move || { - platform::Capture::start(controller, actions, prompt) - }) - .await - .unwrap_or_else(|e| Err(anyhow::anyhow!("starting capture: {e}"))); + let (done, started) = oneshot::channel(); + std::thread::spawn(move || { + let _ = done.send(platform::Capture::start(controller, actions, prompt)); + }); + let started = started + .await + .unwrap_or_else(|_| Err(anyhow::anyhow!("starting capture stopped"))); match started { Ok(c) => { let _ = self.capture.set(c); From 8c6cd027aeac1ec20467e4233cfcb0fca29d8f57 Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 14:57:54 +1300 Subject: [PATCH 09/14] Linux: install, update and remove the GNOME extension, like the Omarchy plugin --- .github/workflows/ci.yml | 1 + crates/mousetail/src/update.rs | 19 +++++++++++++++--- scripts/install-linux.sh | 36 ++++++++++++++++++++++++++++++++-- scripts/package-linux.sh | 11 +++++++---- scripts/uninstall-linux.sh | 11 +++++++++++ 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20ab6ce..67787b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: - run: cargo fmt --all --check - run: cargo clippy --locked -p mousetail -p mousetail-core --tests -- -D warnings - run: cargo test --locked -p mousetail-core + - run: node --test integrations/gnome/test/*.test.js macos: runs-on: macos-15 diff --git a/crates/mousetail/src/update.rs b/crates/mousetail/src/update.rs index f01943a..64988f3 100644 --- a/crates/mousetail/src/update.rs +++ b/crates/mousetail/src/update.rs @@ -3,8 +3,9 @@ //! Every few hours, soon after starting, and whenever another computer advertises a newer //! release, this checks the latest release's `latest.json`. A newer release is downloaded, //! its signature checked against the release key, unpacked, and test-run; then, once nobody -//! is using another computer through this one, the binary and the Omarchy bar plugin are -//! swapped in and MouseTail restarts itself. The previous binary is kept alongside. +//! is using another computer through this one, the binary, the Omarchy bar plugin and the +//! GNOME extension are swapped in and MouseTail restarts itself. The previous binary is kept +//! alongside. //! //! Only installs made by the installer (`~/.local/bin/mousetail`) update themselves; //! development builds and packaged installs are left alone. @@ -23,6 +24,7 @@ use tracing::{info, warn}; use crate::node::Node; const PLUGIN_ID: &str = "nz.galengreen.mousetail"; +const GNOME_EXTENSION: &str = "mousetail@galen.green"; /// How often to look for a new release. const INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); /// Checks prompted by other computers are spaced at least this far apart. @@ -273,7 +275,8 @@ async fn prepare() -> anyhow::Result> { .await? } -/// Swap in the new binary (keeping the old one) and the Omarchy plugin. Returns the binary. +/// Swap in the new binary (keeping the old one), the Omarchy plugin and the GNOME extension. +/// Returns the binary. fn install(staged: &Staged) -> anyhow::Result { let binary = installed_binary().context("the installed binary has gone")?; let state = state_dir(); @@ -290,6 +293,16 @@ fn install(staged: &Staged) -> anyhow::Result { if plugin.is_dir() && new_plugin.is_dir() { replace_dir(&new_plugin, &plugin).context("updating the Omarchy bar plugin")?; } + let data = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share") + }); + let extension = data.join("gnome-shell/extensions").join(GNOME_EXTENSION); + let new_extension = staged.dir.join("gnome-extension").join(GNOME_EXTENSION); + if extension.is_dir() && new_extension.is_dir() { + replace_dir(&new_extension, &extension).context("updating the GNOME extension")?; + } let helpers = helpers_dir(); for name in [ "enable-input.sh", diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh index 73aeea3..873022b 100755 --- a/scripts/install-linux.sh +++ b/scripts/install-linux.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Install MouseTail for the current user on Linux (Wayland: Hyprland / Omarchy). No sudo. +# Install MouseTail for the current user on Linux (Wayland: Hyprland / Omarchy, GNOME…). No sudo. # # ./install.sh from a release download (uses the included binary) # scripts/install-linux.sh from a source checkout (builds it; needs Rust) # # Installs ~/.local/bin/mousetail, runs it as a systemd user service that starts with your -# desktop, and on Omarchy adds a status icon to the bar. +# desktop, and on Omarchy or GNOME adds a status icon to the bar. set -euo pipefail here=$(cd "$(dirname "$0")" && pwd) bin_dir=$HOME/.local/bin @@ -13,16 +13,24 @@ config_home=${XDG_CONFIG_HOME:-$HOME/.config} unit_dir=$config_home/systemd/user plugin_id=nz.galengreen.mousetail omarchy=$config_home/omarchy +gnome_uuid=mousetail@galen.green +gnome_extensions=${XDG_DATA_HOME:-$HOME/.local/share}/gnome-shell/extensions # Helper scripts (enable-input, enable-firewall, enable-wake, uninstall) live here, since a `curl | sh` install # deletes its download when it's done. share=${XDG_DATA_HOME:-$HOME/.local/share}/mousetail say() { printf '\033[1m==> %s\033[0m\n' "$*"; } +# GNOME: the desktop says so, or (run from SSH or a TTY) its shell is running for this user. +on_gnome() { + [[ ${XDG_CURRENT_DESKTOP:-} == *GNOME* ]] || pgrep -u "$(id -u)" -x gnome-shell >/dev/null 2>&1 +} + if [[ -x $here/mousetail ]]; then # Release download: everything is alongside this script. binary=$here/mousetail plugin_src=$here/omarchy-plugin/$plugin_id + gnome_src=$here/gnome-extension/$gnome_uuid helpers=$here helper_suffix=.sh else @@ -38,6 +46,7 @@ else (cd "$repo" && "$cargo" build --release --quiet -p mousetail) binary=$repo/target/release/mousetail plugin_src=$repo/integrations/omarchy/$plugin_id + gnome_src=$repo/integrations/gnome/$gnome_uuid helpers=$repo/scripts helper_suffix=-linux.sh fi @@ -117,6 +126,29 @@ if [[ -d $omarchy ]]; then fi fi +if on_gnome && [[ -d $gnome_src ]]; then + say "Adding MouseTail to GNOME's top bar" + rm -rf "${gnome_extensions:?}/$gnome_uuid" + mkdir -p "$gnome_extensions" + cp -r "$gnome_src" "$gnome_extensions/$gnome_uuid" + # GNOME only finds a new extension when you log in (on Wayland it can't reload), so if it + # won't switch this one on now, put it on the list for the next login. + if ! gnome-extensions enable "$gnome_uuid" 2>/dev/null; then + gjs -c " + const {Gio} = imports.gi; + const shell = new Gio.Settings({schema_id: 'org.gnome.shell'}); + const on = shell.get_strv('enabled-extensions'); + if (!on.includes('$gnome_uuid')) + shell.set_strv('enabled-extensions', [...on, '$gnome_uuid']); + shell.set_strv('disabled-extensions', shell.get_strv('disabled-extensions').filter(u => u !== '$gnome_uuid')); + Gio.Settings.sync();" 2>/dev/null || true + echo " It shows in the top bar after you next log in." + fi + if [[ $(gsettings get org.gnome.shell disable-user-extensions 2>/dev/null) == true ]]; then + echo " GNOME's extensions are switched off: turn them on in the Extensions app to see it." + fi +fi + # On desktops without Wayland's virtual-input protocols, being controlled needs uinput access. sleep 3 if ! "$bin_dir/mousetail" status 2>/dev/null | grep -q "can be controlled"; then diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index f7ecde2..24f3bfa 100755 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -1,7 +1,7 @@ #!/bin/bash # Build the Linux release tarball: dist/mousetail-linux-.tar.gz -# It holds the binary, install/uninstall scripts and the Omarchy bar plugin; no Rust needed -# to install it. +# It holds the binary, install/uninstall scripts, the Omarchy bar plugin and the GNOME +# extension; no Rust needed to install it. set -euo pipefail cd "$(dirname "$0")/.." arch=$(uname -m) @@ -9,7 +9,7 @@ name=mousetail-linux-$arch out=dist/$name cargo build --release --locked -p mousetail -rm -rf "$out" && mkdir -p "$out/omarchy-plugin" +rm -rf "$out" && mkdir -p "$out/omarchy-plugin" "$out/gnome-extension" cp target/release/mousetail "$out/" strip "$out/mousetail" 2>/dev/null || true cp scripts/install-linux.sh "$out/install.sh" @@ -18,10 +18,13 @@ cp scripts/enable-wake-linux.sh "$out/enable-wake.sh" cp scripts/enable-input-linux.sh "$out/enable-input.sh" cp scripts/enable-firewall-linux.sh "$out/enable-firewall.sh" cp -r integrations/omarchy/nz.galengreen.mousetail "$out/omarchy-plugin/" -# The plugin's version follows MouseTail's. +cp -r integrations/gnome/mousetail@galen.green "$out/gnome-extension/" +# The plugin's and the extension's versions follow MouseTail's. version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) sed -i.bak "s/\"version\": \"[^\"]*\"/\"version\": \"$version\"/" "$out/omarchy-plugin/nz.galengreen.mousetail/manifest.json" rm "$out/omarchy-plugin/nz.galengreen.mousetail/manifest.json.bak" +sed -i.bak "s/\"version-name\": \"[^\"]*\"/\"version-name\": \"$version\"/" "$out/gnome-extension/mousetail@galen.green/metadata.json" +rm "$out/gnome-extension/mousetail@galen.green/metadata.json.bak" cp LICENSE "$out/" cat > "$out/README.txt" <<'TXT' MouseTail for Linux (Wayland) diff --git a/scripts/uninstall-linux.sh b/scripts/uninstall-linux.sh index 8cad34a..3df7520 100755 --- a/scripts/uninstall-linux.sh +++ b/scripts/uninstall-linux.sh @@ -20,6 +20,17 @@ if [[ -d $omarchy ]]; then fi fi +gnome_uuid=mousetail@galen.green +gnome_extension=${XDG_DATA_HOME:-$HOME/.local/share}/gnome-shell/extensions/$gnome_uuid +if [[ -d $gnome_extension ]]; then + gnome-extensions disable "$gnome_uuid" 2>/dev/null || gjs -c " + const {Gio} = imports.gi; + const shell = new Gio.Settings({schema_id: 'org.gnome.shell'}); + shell.set_strv('enabled-extensions', shell.get_strv('enabled-extensions').filter(u => u !== '$gnome_uuid')); + Gio.Settings.sync();" 2>/dev/null + rm -rf "$gnome_extension" +fi + rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/mousetail" rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/mousetail" [[ ${1:-} == --forget ]] && rm -rf "$config_home/mousetail" From 5217f02a7c8c548dca4b0a231e67927557872622 Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 14:59:49 +1300 Subject: [PATCH 10/14] Docs: the GNOME extension --- README.md | 8 +++++--- docs/DESIGN.md | 5 +++-- website/index.html | 6 +++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f0e05ad..e4e164c 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,16 @@ curl -fsSL https://mousetail.galen.green/install.sh | sh ``` This installs `~/.local/bin/mousetail`, runs it as a systemd user service that starts with your -desktop and, on Omarchy, adds an icon to the bar. On GNOME or KDE the installer will ask you -to run `~/.local/share/mousetail/enable-input.sh` once (it needs your password) so other +desktop and adds an icon to the bar on Omarchy, or to the top bar on GNOME (from your next +login). On GNOME or KDE the installer will ask you to run +`~/.local/share/mousetail/enable-input.sh` once (it needs your password) so other computers can control this one. `~/.local/share/mousetail/uninstall.sh` removes everything. To let your Mac wake this computer from sleep, run `~/.local/share/mousetail/enable-wake.sh` (asks for your password once). **Pair:** on a Mac, click the mouse in the menu bar, then **Pair…** next to the other computer; on Linux, run `mousetail pair`. Type the code the other computer shows. Then put it where it sits on -your desk: **Arrange Displays…** on a Mac or in the Omarchy bar, or `mousetail place left` on Linux. Both +your desk: **Arrange Displays…** on a Mac, in the Omarchy bar or in GNOME's top bar, or `mousetail place left` on Linux. Both computers share one arrangement, so you only do this once. **Updates** install themselves. MouseTail checks for a new release every few hours (and @@ -151,6 +152,7 @@ Releases are built by GitHub Actions when a `v*` tag is pushed. | `crates/mousetail` | The daemon and CLI, with macOS and Linux backends | | `apps/macos` | SwiftUI menu bar app | | `integrations/omarchy` | Omarchy bar plugin | +| `integrations/gnome` | GNOME Shell extension (and its test) | | `website` | [mousetail.galen.green](https://mousetail.galen.green/) | | `docs/DESIGN.md` | Architecture and the reasoning behind it | | `research` | The feasibility experiments done before building, and their results | diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f194e35..566118a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -50,7 +50,8 @@ planned; the platform layer has room for it. serves the CLI and the Omarchy bar plugin, so every front end sees the same thing. - **Linux daemon** `mousetail run`: headless, runs as a systemd user service started with the Hyprland session. No window. Status via an Omarchy shell bar plugin (Omarchy 4 replaced - Waybar with its Quickshell shell); pairing and events via desktop notifications. + Waybar with its Quickshell shell); pairing and events via desktop notifications. On GNOME, + a Shell extension (`integrations/gnome`) does the same from the top bar. Feasibility of every piece below is recorded in [`research/RESULTS.md`](research/RESULTS.md). ### Roles @@ -338,7 +339,7 @@ practice the iMac dials the Mac and the Omarchy install needs no firewall change and installs by relaunching the app, but holds the install until nobody is using another computer through this Mac. - **Linux:** the daemon does the same (`crates/mousetail/src/update.rs`): verify, unpack, test-run - the new binary's `--version`, wait until idle, swap the binary and bar plugin by renaming, + the new binary's `--version`, wait until idle, swap the binary, bar plugin and GNOME extension by renaming, keep the old binary in `~/.local/state/mousetail/mousetail.previous`, then `exec` the new one so the systemd service carries straight on. Only installer-made installs update themselves. - Discovery advertises each computer's version (TXT `app`); seeing a newer one prompts a check diff --git a/website/index.html b/website/index.html index 993110b..3775a58 100644 --- a/website/index.html +++ b/website/index.html @@ -299,9 +299,9 @@

    What works where.

    Setting up Finds your computers and the fastest linkYesYesYesYesYesPlanned Pair with a four-digit codeYesYesYesYesYesPlanned - Pause a computer without forgetting itNothing crosses until you resume it, from either computerMenu buttonOmarchy bar7Command lineCommand lineCommand linePlanned - Status at a glancemousetail watch can feed any status barMenu barOmarchy bar7Command lineCommand lineCommand linePlanned - Arrange your computersBoth computers share one arrangementDrag to arrangeDrag to arrange7Command lineCommand lineCommand linePlanned + Pause a computer without forgetting itNothing crosses until you resume it, from either computerMenu buttonOmarchy bar7Command lineCommand lineTop barPlanned + Status at a glancemousetail watch can feed any status barMenu barOmarchy bar7Command lineCommand lineTop barPlanned + Arrange your computersBoth computers share one arrangementDrag to arrangeDrag to arrange7Command lineCommand lineDrag to arrangePlanned InstallNo sudo needed on LinuxApp downloadOne commandOne commandOne commandOne commandPlanned ProcessorsApple Silicon, Intelx86-64, ARM64x86-64, ARM64x86-64, ARM64x86-64, ARM64Planned Starts at loginMenu optionYes8Yes8YesYesPlanned From a6e386a4ae5c17f4892f3c19dc2a03c0bb16d192 Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 15:37:25 +1300 Subject: [PATCH 11/14] Updating MouseTail copies the GNOME extension's icons folder too replace_dir copied only files, and the extension has icons/, so every update on a GNOME computer with the extension would have failed after swapping the binary. --- crates/mousetail/src/update.rs | 54 ++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/mousetail/src/update.rs b/crates/mousetail/src/update.rs index 64988f3..a66f571 100644 --- a/crates/mousetail/src/update.rs +++ b/crates/mousetail/src/update.rs @@ -335,17 +335,28 @@ fn replace_dir(from: &Path, to: &Path) -> anyhow::Result<()> { let old = to.with_file_name(format!(".{name}.old")); let _ = std::fs::remove_dir_all(&temp); let _ = std::fs::remove_dir_all(&old); - std::fs::create_dir_all(&temp)?; - for entry in std::fs::read_dir(from)? { - let entry = entry?; - std::fs::copy(entry.path(), temp.join(entry.file_name()))?; - } + copy_dir(from, &temp)?; std::fs::rename(to, &old)?; std::fs::rename(&temp, to)?; let _ = std::fs::remove_dir_all(&old); Ok(()) } +/// Copy a folder with everything in it (the GNOME extension has `icons/`). +fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let to = to.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir(&entry.path(), &to)?; + } else { + std::fs::copy(entry.path(), to)?; + } + } + Ok(()) +} + /// Become the new version: same process, so the user service carries on as if nothing happened. /// Only returns if that failed. fn restart(binary: &Path) -> anyhow::Result<()> { @@ -392,3 +403,36 @@ fn run_ok(cmd: &mut Command) -> anyhow::Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replacing_a_folder_brings_its_subfolders_and_drops_what_went() { + let root = + std::env::temp_dir().join(format!("mousetail-replace-dir-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let (new, installed) = (root.join("new"), root.join("installed")); + std::fs::create_dir_all(new.join("icons")).unwrap(); + std::fs::write(new.join("extension.js"), "new").unwrap(); + std::fs::write(new.join("icons/mousetail-symbolic.svg"), "").unwrap(); + std::fs::create_dir_all(&installed).unwrap(); + std::fs::write(installed.join("extension.js"), "old").unwrap(); + std::fs::write(installed.join("gone.js"), "old").unwrap(); + + let replaced = replace_dir(&new, &installed); + let read = |path: &str| std::fs::read_to_string(installed.join(path)).ok(); + let (js, icon, gone) = ( + read("extension.js"), + read("icons/mousetail-symbolic.svg"), + read("gone.js"), + ); + let _ = std::fs::remove_dir_all(&root); + + replaced.unwrap(); + assert_eq!(js.as_deref(), Some("new")); + assert_eq!(icon.as_deref(), Some("")); + assert_eq!(gone, None); + } +} From 56351c5ce44b502fbe422b3f113fad116d3faae9 Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 15:37:25 +1300 Subject: [PATCH 12/14] GNOME extension: Escape in the code field cancels pairing GNOME's menu manager takes Escape first, to close the menu, so the field never saw it: pairing carried on with the menu closed. Now the menu catches it first, and Escape cancels pairing and keeps the menu open, as on Omarchy. --- .../gnome/mousetail@galen.green/extension.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/integrations/gnome/mousetail@galen.green/extension.js b/integrations/gnome/mousetail@galen.green/extension.js index 03cca5b..3d51bfa 100644 --- a/integrations/gnome/mousetail@galen.green/extension.js +++ b/integrations/gnome/mousetail@galen.green/extension.js @@ -145,6 +145,17 @@ class Indicator extends PanelMenu.Button { if (open) this._opened(); }); + // Escape in the code field stops pairing, as on Omarchy. The panel's menu manager + // takes Escape before the field sees it, to close the menu, so this goes first: + // connected before the menu joins the panel. + this.menu.actor.connect('captured-event', (_actor, event) => { + if (event.type() !== Clutter.EventType.KEY_PRESS || + event.get_key_symbol() !== Clutter.KEY_Escape || + !this._codeEntry?.clutter_text.has_key_focus()) + return Clutter.EVENT_PROPAGATE; + this._cancelPairing(); + return Clutter.EVENT_STOP; + }); this.connect('destroy', () => this._stop()); this._watch(); } @@ -377,12 +388,6 @@ class Indicator extends PanelMenu.Button { this._sendCode(entry.get_text()); entry.set_text(''); }); - entry.clutter_text.connect('key-press-event', (_actor, event) => { - if (event.get_key_symbol() !== Clutter.KEY_Escape) - return Clutter.EVENT_PROPAGATE; - this._cancelPairing(); - return Clutter.EVENT_STOP; - }); entry.connect('destroy', () => { if (this._codeEntry === entry) this._codeEntry = null; From 7901154add7381dc7f0a3ac0026c65a1136034a6 Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 15:37:25 +1300 Subject: [PATCH 13/14] GNOME capture starts over if the desktop ends it When the desktop ended input capture after start-up (the portal restarting, say), capture stopped silently and MouseTail still said it could share. Now MouseTail exits so systemd starts it again, as when it loses layer-shell, and GNOME asks again. --- crates/mousetail/src/platform/linux/portal.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/mousetail/src/platform/linux/portal.rs b/crates/mousetail/src/platform/linux/portal.rs index e86f5c3..d27f31d 100644 --- a/crates/mousetail/src/platform/linux/portal.rs +++ b/crates/mousetail/src/platform/linux/portal.rs @@ -79,10 +79,13 @@ pub(super) fn run( }; let _ = ready.send(Ok(())); if let Err(e) = portal.run(events, cmds, wake).await { - warn!("input capture stopped: {e:#}"); + tracing::error!("input capture stopped: {e:#}"); } - // Nothing can bring the cursor back from here on, so it comes home now. + // The desktop ended capture (or the portal went away). Nothing can bring the cursor + // back or see the edges from here on, so it comes home and MouseTail starts over, as + // when it loses layer-shell; GNOME asks again. portal.go_home().await; + std::process::exit(1); }); } From 848e1794fdf6a67265830a07a8dcf87e9533e029 Mon Sep 17 00:00:00 2001 From: ming Date: Fri, 2 Oct 2026 15:37:25 +1300 Subject: [PATCH 14/14] Installer: GNOME's top-bar menu needs GNOME 50 Older GNOME doesn't load the extension, so the installer no longer adds it there or promises an icon after the next login, and the docs say GNOME 50. --- README.md | 2 +- scripts/install-linux.sh | 8 +++++++- website/index.html | 7 ++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e4e164c..a50bae2 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ curl -fsSL https://mousetail.galen.green/install.sh | sh ``` This installs `~/.local/bin/mousetail`, runs it as a systemd user service that starts with your -desktop and adds an icon to the bar on Omarchy, or to the top bar on GNOME (from your next +desktop and adds an icon to the bar on Omarchy, or to the top bar on GNOME 50 (from your next login). On GNOME or KDE the installer will ask you to run `~/.local/share/mousetail/enable-input.sh` once (it needs your password) so other computers can control this one. `~/.local/share/mousetail/uninstall.sh` removes everything. To diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh index 873022b..04121a9 100755 --- a/scripts/install-linux.sh +++ b/scripts/install-linux.sh @@ -126,7 +126,13 @@ if [[ -d $omarchy ]]; then fi fi -if on_gnome && [[ -d $gnome_src ]]; then +# GNOME only runs an extension made for its version. +gnome_version=$(gnome-shell --version 2>/dev/null | grep -oE '[0-9]+' | head -1 || true) +gnome_supported=$(grep -so '"shell-version": *\[[^]]*' "$gnome_src/metadata.json" | grep -oE '[0-9]+' | paste -sd/ || true) +if on_gnome && [[ -n $gnome_version && -n $gnome_supported && /$gnome_supported/ != */$gnome_version/* ]]; then + echo + echo "MouseTail's top-bar menu needs GNOME $gnome_supported (this is GNOME $gnome_version), so it isn't added." +elif on_gnome && [[ -d $gnome_src ]]; then say "Adding MouseTail to GNOME's top bar" rm -rf "${gnome_extensions:?}/$gnome_uuid" mkdir -p "$gnome_extensions" diff --git a/website/index.html b/website/index.html index 3775a58..b99051b 100644 --- a/website/index.html +++ b/website/index.html @@ -299,9 +299,9 @@

    What works where.

    Setting up Finds your computers and the fastest linkYesYesYesYesYesPlanned Pair with a four-digit codeYesYesYesYesYesPlanned - Pause a computer without forgetting itNothing crosses until you resume it, from either computerMenu buttonOmarchy bar7Command lineCommand lineTop barPlanned - Status at a glancemousetail watch can feed any status barMenu barOmarchy bar7Command lineCommand lineTop barPlanned - Arrange your computersBoth computers share one arrangementDrag to arrangeDrag to arrange7Command lineCommand lineDrag to arrangePlanned + Pause a computer without forgetting itNothing crosses until you resume it, from either computerMenu buttonOmarchy bar7Command lineCommand lineTop bar10Planned + Status at a glancemousetail watch can feed any status barMenu barOmarchy bar7Command lineCommand lineTop bar10Planned + Arrange your computersBoth computers share one arrangementDrag to arrangeDrag to arrange7Command lineCommand lineDrag to arrange10Planned InstallNo sudo needed on LinuxApp downloadOne commandOne commandOne commandOne commandPlanned ProcessorsApple Silicon, Intelx86-64, ARM64x86-64, ARM64x86-64, ARM64x86-64, ARM64Planned Starts at loginMenu optionYes8Yes8YesYesPlanned @@ -319,6 +319,7 @@

    What works where.

  • Omarchy only. Plain Hyprland uses the command line.
  • If your desktop doesn't start systemd's graphical session (Hyprland or Sway without uwsm, say), add one line to its config. The installer shows you which.
  • GNOME asks each time MouseTail starts: turn on Allow Remote Input Capturing, then Share.
  • +
  • GNOME 50. Older versions use the command line.
  • Linux means Wayland desktops with systemd and PipeWire. Keys are sent by position, so give both computers the same keyboard layout. Using something else? Tell us how it goes