Skip to content

Offer this computer's gliff-server to a machine too old to control - #108

Open
avillagran wants to merge 41 commits into
omacom:mainfrom
avillagran:feat/update-old-server
Open

avillagran wants to merge 41 commits into
omacom:mainfrom
avillagran:feat/update-old-server

Conversation

@avillagran

Copy link
Copy Markdown

Stacked PR. This builds on #106 (the mouse button) and #99 (installing
for the user on a remote). Only 31f1e89 is new here, on top of 2bac362, a
merge of the two. Once those land, the diff shrinks to that commit.

The problem

gliff-server --control is newer than the packaged server. Pressing the
mouse button for a machine on that server starts ssh, which dies with
unexpected argument '--control', and the control retries for ever with
no hint why. The window's normal connection to that machine still works,
which makes it look like the button is broken.

What this adds

Before driving a machine, gliff asks its gliff-server --help over ssh
whether it knows --control.

  • If it does, or the machine does not answer in 10 seconds, nothing changes:
    the control starts and shows its own connection errors.
  • If it does not, a dialog says the machine runs an incompatible version and
    offers to install this computer's server for that user, in ~/.local/bin.
    Cancelling leaves the machine as it was and says it needs a newer server.
  • After installing, gliff asks again. If an older server still comes first
    on the PATH over ssh, it says so instead of retrying for ever.

The prompt for a machine too old to control

Details

  • The server is uploaded as ~/.local/bin/.gliff-server.upload. Before it
    replaces anything, the same script as Offer to install gliff for the user on a remote that lacks it #99 checks its size, runs it there and
    requires --control in its help, so a binary that cannot run on that
    machine leaves it as it was. The upload is always removed.
  • Only a file gliff installed is replaced, judged by the hash in
    user-install.json, which is updated pending first and complete last, as
    in Offer to install gliff for the user on a remote that lacks it #99.
  • The server comes from next to the running gliff, or from the PATH, when
    the machine has the same architecture. For another one it comes from
    $XDG_DATA_HOME/gliff/servers/<arch>/gliff-server, and the error says so
    if there is none: a binary from this computer could not run there. gliff
    does not build or download one.
  • The check is in the window only. gliff --control from the command line
    still fails as before.

Testing

  • scripts/check.sh passes.
  • From an x86_64 laptop, in a throwaway HOME holding gliff 0.4.1 and its
    manifest, with real clicks: the prompt appears; Cancel leaves the server
    untouched and shows the note; Install This Version replaces it, updates the
    manifest and the control starts.
  • From an aarch64 laptop to an x86_64 desktop, the upload itself over real ssh,
    in a throwaway HOME copied from that machine's 0.4.1: --control
    missing, installed, then present. The dialog was not clicked on this path.
  • A probe of a real 0.4.1, a current server and an unreachable host gives
    no, yes and no answer.
  • A unit test runs the script with a fake HOME: a file gliff installed is
    replaced, somebody else's is not. Another covers the architecture lookup.

- The manifest is written atomically before and after each file, with
  the hash of the file being written as pending. An install that stops part
  way leaves only files gliff recognises as its own and is marked
  unfinished, so the next attempt finishes it even though gliff-server may
  already be on PATH.
- Selecting the machine's tab while gliff is being installed no longer
  starts a connection, which made the install's reconnect stale and could
  offer a second install over the first.
- The desktop file's Exec path is quoted and escaped by the desktop entry
  rules, so a home directory with spaces, quotes or % still launches.
The client offers to install gliff only while ssh cannot find
gliff-server. With gliff-server written second, an install that failed
on the desktop file or the icon left a working server on PATH, so the
offer never came back and the rest stayed missing. It is now written
last, so until everything else is in place the next connection offers
the install again, and the script finishes it over its own files.

A file that cannot be written now reports which one and why, instead of
the temporary file's name, and an unreadable manifest says how to
recover.
gliff --stdio shows the gliff-server whose protocol arrives on stdin and
leaves on stdout. Another machine runs that server and starts this over
ssh with the server's stdout as its stdin, to use this screen as one of
its own; only that machine needs ssh access to this one.

- stdout is kept for the protocol and fd 1 points at stderr, so nothing
  that prints can corrupt the stream.
- Started by ssh outside the desktop session, it finds the user's
  Hyprland session the way gliff-server does.
- The window shows only that session and remembers nothing; it closes
  when the stream ends, since only the other machine can start it again.
- --fullscreen opens the window fullscreen; the header no longer warns
  when the window starts that way.
A headless output always went where Hyprland found room. --position puts
it right, left, above or below this machine's screens, and --align lines
it up with their top or left edges (start), their middles, or their
bottom or right edges (end). The position is worked out from the other
screens' layout each time the output's mode is set, so a resize keeps
it in place beside them.
The header gets a button that adds a headless output here and shows it
on the selected machine with `gliff --stdio --fullscreen` over ssh, and
a settings button for which side of this computer's screens it stands
on and how it lines up. A gliff there without `--stdio` still shows it
through a relay in place of its ssh, with Hyprland making its window
fullscreen. Stopping, the other window closing or this window closing
gives the screen back.
The position comes from the output's logical size, which resize
computed with the requested scale. When Hyprland applies a rounded
scale instead, place the output again at that scale, so it still meets
the other screens on the chosen side and lines up as asked.
The window closes when that stream ends, so a machine opened from its
address bar would close with it. A --stdio window shows neither the +
nor the address bar, and connecting from the bar does nothing there.
A --stdio window that closes on a connection error or an incompatible
server now makes gliff exit with a failure, with the reason as its last
stderr line, so the machine that started it can say why. A window the
user closes, or a stream the other side ends, still exits successfully.
Showing another machine forgets the lent screen's tab as the selection,
and selecting that tab again did not bring it back, so it lost its
highlight and the screen buttons asked for a tab once the screen
stopped. Selecting a tab whose screen is in use now makes it the
selected one, as starting the screen does.
Hyprland places a screen whose position is `auto`, as Omarchy's default
configuration does, after every screen with a fixed one. A fixed
position for the headless output therefore pushed such a screen aside:
on a laptop asked for `--position right`, the laptop screen moved right
of the new output. When placing the output moves another screen, the
placement goes to Hyprland's `auto-right`, `auto-center-right` and
related positions instead. They line up at the start or the center of a
side, so `--align end` is centered there, with a warning.
gliff 0.4.1 opens the GPU decoder whenever VA-API lists H.264, and NVIDIA's
VA-API driver does, but every picture then fails to export, so the screen
lent to such a machine stayed black. That gliff cannot switch to the CPU on
its own, so the relay asks for it with GLIFF_VIDEO=cpu when every render
node belongs to the nvidia driver, unless GLIFF_VIDEO is already set.
A new crate puts a one-pixel layer surface along chosen outer edges of the
layout. When the pointer touches one it locks the pointer there, hides it,
takes the keyboard and inhibits shortcuts, and reports relative motion,
buttons, the wheel, keys, modifiers and the keymap until told to release.
Releasing puts the pointer back just inside that edge, through a new
hypr-ipc move_cursor that speaks both config formats.
--control SIDE skips the picture: after the usual Hello it binds a virtual
keyboard and pointer, with the client's keymap, to the screen on SIDE of the
layout, answers with that screen's size, and injects the client's keys,
motion, buttons and wheel until the client goes. The handshake and the
setup error report are the session's own.
gliff --control SIDE:HOST, given once for each machine, runs with no window:
it keeps a gliff-server --control connection to each machine, reconnecting
with backoff, and arms that side's edge only while the machine answers.
Pushing the pointer past an armed edge carries it onto that machine's
screen, entering at the facing edge where it left this one. Moving back
across that edge, or the release hotkey, returns it at the matching point;
a held button keeps the pointer there. Keys and buttons still held are let
go on the other machine on every return. The release hotkey is matched by
keysym in the local keymap, so it means the same keys in any layout.
# Conflicts:
#	crates/gliff/src/main.rs
A mouse button beside the screen buttons drives the selected machine with
this computer's pointer and keyboard across the edge on its side: the same
side the screen settings keep, asked for the first time as they are, without
the alignment, which only a screen in use needs. Each driven machine runs
its control on a thread of its own, so one machine on each side can be
driven at once; a second machine on a side already taken is refused.

The machine's tab stays selected without its session, says how to cross
over or that it is connecting, and its dot shows whether the machine
answers. The tab's button or the mouse button, now a stop button, ends the
control, which gives the pointer back. A machine is not driven and used as
a screen at once. Closing the window ends every control.

The control loop now stops on a signal from its owner and reports when a
machine connects, drops and why, and when it ends.
While a machine is driven or its screen is in use, the window has nothing
to show, so a button beside the screen buttons hides it. A tray icon (a
StatusNotifierItem over the session bus, which Omarchy's bar and Waybar
show) stands in for it, and a click on the icon brings the window back.
Without a tray to come back from, the window stays. The window also comes
back by itself once nothing runs in the background any more.
A speaker button, shown while the selected machine is driven, moves that
machine's sound to this one: over ssh, pw-record takes what its default
output plays, before its volume, as raw 48 kHz stereo, and pw-play plays it
here. The other machine's output is muted meanwhile and unmuted however the
stream ends. It is muted only once the capture has woken the output,
because WirePlumber restores a suspended output's saved mute as it wakes,
and once more a second later. The choice is kept per machine and applies
the next time it is driven. Stopping the control, or the window, stops the
sound too.
A compiled remote_install.py was committed by mistake with the
recoverable install. Remove it and ignore __pycache__ directories.
The client works out the pointer's extent from the size in HelloAck, but
it got the mode as Hyprland reports it, unrotated, while the server bound
the pointer to the rotated screen. On a portrait screen the pointer could
not reach its bottom. Swap the size for a 90 or 270 degree transform, as
the video session does.
The pointer returned to the edge's screen as it stood when control
started. A screen moved or rescaled meanwhile put it somewhere else,
possibly on the edge again. Read the layout when releasing, and keep the
old one only when it cannot be read.
The script unmuted whichever output was the default when the stream
ended, and read the mute of the default rather than of the output it
muted. If a headset became the default meanwhile, the speakers stayed
muted and the headset was unmuted. Keep the default output at the start
and use that one throughout.
A sound stopped and started again, or stopped with its control and
started with the next one, ends after the new sound began. Its end
cleared the machine's sound by name, which dropped the new one and killed
its ssh. Each sound now has an id, and an end from another run is
ignored.
If the tray's connection failed, its thread ended and the icon went with
it, but the window stayed hidden, leaving no way to stop what ran. A
closed channel from the tray now shows the window, as a click does.
The other machine's keyboard started from a fresh state and followed only
the keys typed into it, so a second layout selected here, or Caps Lock or
Num Lock already on, did not apply there. The client now sends its
layout in effect and locked modifiers, by name, on entering and whenever
they change while captured, in a new KeyboardLocks message. The control
server lists the keyboard-locks feature and applies them to its virtual
keyboard; a server that does not list it is not sent them, and the video
session ignores the message.
Hotkey::parse accepts names such as exclam, but matching looked only at
each key's unshifted symbol, so shift+exclam was accepted and never
matched. A key now matches by its unshifted symbol or by the one it
produces with the modifiers held.
A link whose network went away without closing the connection stayed
up: nothing read had a deadline, so its edge kept taking the pointer to
a machine that was gone, and input queued for a write that never ended.
The link now pings every 2 seconds and is dropped after 6 seconds
without hearing from the other machine, or when a write takes that long;
it then disarms its edge and reconnects as after any drop.
An edge's screen and rectangle were chosen once, so a screen added
farther out left the edge on what was now an inner side, and a moved or
rescaled screen kept the old coordinates. hypr-wl's Outputs now binds
and forgets outputs that come and go, and hypr-edge reads the layout
again after each output's burst, when one goes away, and when the
pointer is given back: an edge whose outermost screen changed is put on
the new one once Wayland has announced it, or after the pointer it holds
is given back. Whether an edge is armed is now kept apart from its
surface, so a surface the compositor closes with its screen is given
again, and events from an edge's previous surface are ignored.
Keymaps and locks reach a link by separate ways, so a layout selected in
a keymap that had just gained it could arrive first, be dropped by a
server still on the old keymap, and not be sent again. The link now
writes the last locks again right after each keymap.
An edge whose screen changed while it held the pointer moved only when
the pointer was given back by a release. When the compositor ended the
capture instead, by leaving the edge or unlocking the pointer, the edge
stayed on the old screen until the next layout change. Losing the
capture now reads the layout again, as a release does.
The Omarchy package has no --control yet, so a machine on it failed the
connection and retried for ever, with no hint why. Ask the machine before
driving it, and offer to install this computer's server for that user. The
upload has to run there before it replaces gliff's own file.
avillagran added a commit to avillagran/gliff that referenced this pull request Oct 30, 2026
@greptile-apps

greptile-apps Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 3/5

[High impact] The PR adds user-triggered remote input control and screen sharing across SSH links.

The PR still needs fixes for concurrent uploads and control starting after a machine is forgotten before merging.

Findings

  1. P1 Updates overwrite each other's uploads ▶
  2. P1 Forgotten machines start control later ▶
  3. P2 Held modifiers disappear across edges ▶
  4. P2 Failed uploads leave files behind ▶

Summary

The update lets window-based control use ~/.local/bin/gliff-server when an older server comes first on PATH.

  • A pointer crossing a connected screen edge can control another machine.
  • The window offers a compatible server when a remote machine needs one.
  • A remote machine's screen can appear beside the local screens.
  • A controlled machine's sound can play on the local computer.
  • The window can stay active in the system tray while hidden.

No new findings were accepted.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Check configured server] --> B{Supports control?}
  B -->|Yes or no answer| C[Use configured server]
  B -->|No| D[Check user-installed server]
  D -->|Supported| E[Use user-installed server]
  D -->|Not confirmed| F[Offer update]
  F -->|Accepted| G[Install and check again]
  F -->|Cancelled| H[Show newer-server note]
Loading

Reviews (3) · Last reviewed commit: "Drive a machine with the user install wh..." · Reviewed by Greptile

and digest(target) in owned_hashes(manifest, SERVER))
if not ours:
raise Refused("conflict", f"~/{SERVER} already exists and was not installed by gliff")
data = UPLOAD.read_bytes()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Updates overwrite each other's uploads

If two gliff clients update the same remote user, both write .gliff-server.upload. The second upload can truncate or replace that file after the first check_upload() succeeds but before adopt() reads it. The first installer can then replace the working server with incomplete or unchecked bytes.

Give each attempt its own upload file and install the same bytes that passed the checks.

Comment thread crates/gliff/src/monitor.rs Outdated
Comment on lines +658 to +661
let ready = c.server_ready(&name).await;
c.checking.borrow_mut().remove(&name);
if ready && !c.control_refused(&name, &settings) {
c.drive(name, &settings);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Forgotten machines start control later

start_control() calls drive() after the check without checking whether the user stopped and forgot the machine meanwhile. During the probe, the user can stop the normal session and then forget its tab because checking does not count as running. The delayed start still runs, but tab_names() does not include driven machines, so the tab and its stop button stay gone.

Cancel the pending attempt when the machine is stopped or forgotten, and check that it is still current before calling drive().

Comment on lines +495 to +504
self.active = Some(Active {
side,
x,
y,
keys: BTreeSet::new(),
buttons: BTreeSet::new(),
locks: None,
});
self.send(side, ClientMsg::PointerMotion { x, y });
self.send_locks();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Held modifiers disappear across edges

Modifiers held before crossing the edge never reach the remote keyboard. The keyboard's Enter event is ignored, enter() starts with an empty set of held keys, and the Modifiers handler forwards only layout and locked modifiers. Holding Shift while crossing and then typing sends lowercase letters until Shift is released and pressed again.

Carry the held keys across entry and release them with the rest of the remote keys.

Comment on lines +139 to +148
Ok(Ok((_, stderr))) if !process.is_successful() => {
let stderr = stderr.as_deref().unwrap_or("");
let why = stderr.lines().rev().find(|l| !l.trim().is_empty());
return Outcome::Failed(why.unwrap_or("The upload failed").trim().to_string());
}
Ok(Ok(_)) => {}
Ok(Err(e)) => return Outcome::Failed(format!("ssh failed: {e}")),
Err(_) => {
process.force_exit();
return Outcome::Failed("The upload timed out".into());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed uploads leave files behind

Cleanup runs only inside adopt(), but upload errors and timeouts return before that script runs. Missing python3 also leaves the completed .gliff-server.upload behind. The remote user keeps a partial or complete server binary until another update overwrites it or they remove it.

Add cleanup for these failure paths.

The stream arrives in bursts, and over WiFi one is sometimes 70 ms late,
which left the 30 ms player with nothing to play.
With a machine on each side the screen between them is narrow enough for a
fast return to reach the other edge and leave again.
Installing this computer's gliff-server in ~/.local/bin on a machine whose
system package is older did not help when that machine's ssh PATH puts
/usr/bin first: `gliff-server` still ran the old one, and the update was
offered again every time.

The installer now reports whether `gliff-server` on that machine's PATH is
the one it wrote, both for a fresh install and for an update. The control
flow tries the user install by its own path when the one on PATH cannot be
controlled, before offering the update and again after it, so an older
package first on PATH no longer blocks control, and a machine already
updated is driven without asking again.
@avillagran

Copy link
Copy Markdown
Author

Pushed 732ea68: the update did not help on a machine whose system package is older and comes first on its ssh PATH, as gliff-server there still ran the old one and the update was offered again every time. The installer now reports whether gliff-server on that machine's PATH is the one it wrote, and the control flow tries the user install by its own path when the one on PATH cannot be controlled, before offering the update and again after it. Covered by a unit test with an older package first on PATH, and checked on a machine in that state: the first start installs and drives with ~/.local/bin/gliff-server, later starts drive it without asking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant