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/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..a50bae2 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 @@ -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 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 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/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..f2a62d0 100644 --- a/crates/mousetail/src/node.rs +++ b/crates/mousetail/src/node.rs @@ -480,7 +480,17 @@ 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) { + // 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 (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); *self.capture_error.lock().unwrap() = None; @@ -1450,6 +1460,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..d27f31d --- /dev/null +++ b/crates/mousetail/src/platform/linux/portal.rs @@ -0,0 +1,719 @@ +//! 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 { + tracing::error!("input capture stopped: {e:#}"); + } + // 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); + }); +} + +/// 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/crates/mousetail/src/update.rs b/crates/mousetail/src/update.rs index f01943a..a66f571 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", @@ -322,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<()> { @@ -379,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); + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7d4980c..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 @@ -72,7 +73,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 @@ -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/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" +``` 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. 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 new file mode 100644 index 0000000..3d51bfa --- /dev/null +++ b/integrations/gnome/mousetail@galen.green/extension.js @@ -0,0 +1,594 @@ +// 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'; +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']); + +/** 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(); + }); + // 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(); + } + + _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._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 && peers.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(); + this._codeEntry?.grab_key_focus(); + } + + _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(); + } + + // -------------------------------------------------------------- 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.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. */ + _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) { + 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(); + this._arrangeDialog?.close(); + 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/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/mousetail@galen.green/stylesheet.css b/integrations/gnome/mousetail@galen.green/stylesheet.css new file mode 100644 index 0000000..d3ab4e4 --- /dev/null +++ b/integrations/gnome/mousetail@galen.green/stylesheet.css @@ -0,0 +1,50 @@ +/* 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; } +.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; } +/* 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; } 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}); +}); diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh index 73aeea3..04121a9 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,35 @@ if [[ -d $omarchy ]]; then fi fi +# 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" + 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" diff --git a/website/index.html b/website/index.html index 234b615..b99051b 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 @@ -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 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 @@ -318,6 +318,8 @@

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.
  • +
  • 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