Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <computer> left` on Linux. Both
your desk: **Arrange Displays…** on a Mac, in the Omarchy bar or in GNOME's top bar, or `mousetail place <computer> 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
Expand Down Expand Up @@ -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 |
Expand Down
50 changes: 46 additions & 4 deletions crates/core/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>() {
Expand All @@ -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;
}
_ => {}
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions crates/mousetail/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
13 changes: 12 additions & 1 deletion crates/mousetail/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,17 @@ impl Node {
async fn start_capture(self: Arc<Self>, actions: mpsc::UnboundedSender<Action>) {
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;
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 10 additions & 7 deletions crates/mousetail/src/platform/linux/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Edge>),
HideCursor,
Expand Down Expand Up @@ -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) {
Expand All @@ -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")??;
Expand Down Expand Up @@ -204,8 +207,8 @@ struct Globals {

impl Grabber {
fn connect(
controller: Arc<Mutex<Controller>>,
actions: UnboundedSender<Action>,
controller: &Arc<Mutex<Controller>>,
actions: &UnboundedSender<Action>,
) -> anyhow::Result<(Self, EventQueue<Grabber>)> {
let conn = Connection::connect_to_env().context("connecting to the Wayland compositor")?;
// Gather globals on a throwaway queue, then build the real state.
Expand Down Expand Up @@ -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))
}
Expand Down
1 change: 1 addition & 0 deletions crates/mousetail/src/platform/linux/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod keystate;
pub mod mpris;
mod outputs;
pub mod player;
mod portal;
pub mod ripple;
mod uinput;

Expand Down
Loading