Install gliff by default and show when a remote session is active (#14712)

* Install gliff by default

Gliff is the Hyprland remote desktop over SSH. Ship it in the base
package set for new installs, with a migration for existing ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Show a bar indicator while a gliff remote session is active

The machine being controlled should make it obvious that someone is on
it. A first-party remote-session service watches Hyprland's screencast
events, which gliff-server triggers through ext-image-copy-capture, and
probes for the server process, picking up the ssh client address from
its environment. The RemoteSession indicator in the default tray lights
up in the urgent colour while a session runs, and a notification
announces each session start and end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Address review of the remote session indicator

Probe only the current user's gliff-server processes, forward refresh
through the third-party service proxy so cloned indicator widgets can
re-probe, exercise the Hyprland screencast event path in the service
test with a grim capture instead of calling refresh by hand, and
describe the indicator as status only in the manual.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Drop the remote session notifications

The bar indicator is the signal; a toast on every connect and
disconnect is noise on top of it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Tighten remote session detection after review

Hyprland reports a screencast stopped as soon as frames pause for half a
second and says nothing more when the server later exits, so poll every
two seconds while a session is active instead of trusting a stop event.
Match only the screencast event, not its screencastv2 twin, and debounce
the burst around one transition so it costs one probe. Move the probe
into its own script that the service and the test both run, poll for
state in the service test instead of fixed delays, and drop the unused
session count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Fable 5.1 authored and GitHub committed 2026-10-09 17:02:42 +02:00
1 parent 54fc12db76
commit 9d48fc402e
17 files changed
+483 -4

No files matched your search

@@ -41,12 +41,23 @@ ShellRoot {
}
}
QtObject {
id: remoteSessionService
property bool active: false
property var peers: []
property int refreshes: 0
function refresh() {
refreshes++
}
}
QtObject {
id: mockShell
function firstPartyServiceFor(id) {
if (id === "omarchy.notifications") return notificationService
if (id === "omarchy.idle") return idleService
if (id === "omarchy.nightlight") return nightlightService
if (id === "omarchy.remote-session") return remoteSessionService
return null
}
}
@@ -74,6 +85,7 @@ ShellRoot {
QtObject {
id: indicatorHost
property bool revealInactiveIndicators: true
signal refreshRequested()
}
function createIndicator(name) {
@@ -198,6 +210,19 @@ ShellRoot {
root.assertTrue(root.commandCount("omarchy-capture-screenrecording --stop-recording") === 1, "Screen Recording left click stops active recording")
}
var remoteSession = root.createIndicator("RemoteSession")
if (remoteSession) {
remoteSession.moduleName = "RemoteSession"
root.injectBar(remoteSession)
root.assertTrue(remoteSession.active === false, "Remote Session starts inactive without a gliff server")
remoteSessionService.active = true
remoteSessionService.peers = ["10.0.0.5"]
root.assertTrue(remoteSession.active === true, "Remote Session follows the service state")
root.assertTrue(remoteSession.tooltipText === "Remote session from 10.0.0.5", "Remote Session tooltip names the connected peer")
indicatorHost.refreshRequested()
root.assertTrue(remoteSessionService.refreshes === 1, "Remote Session refresh re-probes the service")
}
var stayAwake = root.createIndicator("StayAwake")
if (stayAwake) {
stayAwake.moduleName = "StayAwake"
@@ -0,0 +1,84 @@
import QtQuick
import Quickshell
// Drives the real remote-session service against a stub gliff-server on PATH:
// idle at start, active with the ssh peer once a server runs, idle again after
// it exits. The service is never refreshed by hand: a one-shot grim capture
// raises the same Hyprland screencast event that gliff-server does, so the
// event path is what gets exercised.
ShellRoot {
id: root
property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT")
property string stubPidFile: Quickshell.env("OMARCHY_QML_TEST_STUB_PID")
property var failures: []
property var service: null
function fail(message) {
failures.push(String(message))
}
function assertTrue(condition, message) {
if (!condition) fail(message)
}
function shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'"
}
function writeResult() {
var payload = JSON.stringify({ ok: failures.length === 0, failures: failures })
Quickshell.execDetached(["bash", "-lc", "printf '%s' " + shellQuote(payload) + " > " + shellQuote(resultPath)])
}
function captureFrame() {
Quickshell.execDetached(["grim", "-g", "0,0 1x1", Quickshell.env("OMARCHY_QML_TEST_FRAME")])
}
// Polls `condition` every 100 ms for up to `timeout` ms, then continues with
// `next` either way, recording `message` as a failure on timeout. `tick`,
// when given, runs every 500 ms while waiting.
function waitFor(condition, timeout, message, next, tick) {
var timer = Qt.createQmlObject("import QtQuick; Timer { repeat: true; interval: 100 }", root)
var waited = 0
timer.triggered.connect(function() {
waited += timer.interval
if (tick && waited % 500 === 0) tick()
if (!condition() && waited < timeout) return
timer.stop()
timer.destroy()
root.assertTrue(condition(), message)
next()
})
timer.start()
}
Component.onCompleted: {
var component = Qt.createComponent("file://" + Quickshell.env("OMARCHY_PATH") + "/shell/plugins/services/remote-session/Service.qml")
if (component.status !== Component.Ready) {
fail("remote session service failed to load: " + component.errorString())
writeResult()
return
}
service = component.createObject(root, { shell: null })
if (!service) {
fail("remote session service failed to instantiate: " + component.errorString())
writeResult()
return
}
waitFor(function() { return service.stateLoaded === true }, 3000, "service probes on startup", function() {
root.assertTrue(service.active === false, "service starts idle without a gliff server")
Quickshell.execDetached(["bash", "-c", "SSH_CONNECTION='10.0.0.5 51234 10.0.0.1 22' gliff-server --stdio & echo $! > " + root.shellQuote(root.stubPidFile)])
// Each capture raises a screencast event; the first one after the stub
// is up is what should flip the service, with no refresh() by hand.
waitFor(function() { return service.active === true }, 4000, "a screencast event makes the service report the session while gliff-server runs", function() {
root.assertTrue(JSON.stringify(service.peers) === JSON.stringify(["10.0.0.5"]), "service reports the ssh peer, got " + JSON.stringify(service.peers))
Quickshell.execDetached(["bash", "-c", "kill \"$(cat " + root.shellQuote(root.stubPidFile) + ")\""])
waitFor(function() { return service.active === false }, 5000, "service returns to idle once gliff-server exits", function() {
root.writeResult()
})
}, root.captureFrame)
})
}
}
@@ -0,0 +1,92 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=""
QS_PID=""
cleanup() {
if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then
kill "$QS_PID" 2>/dev/null || true
wait "$QS_PID" 2>/dev/null || true
fi
if [[ -n $TMPDIR && -f $TMPDIR/stub.pid ]]; then
kill "$(<"$TMPDIR/stub.pid")" 2>/dev/null || true
fi
if [[ -n $TMPDIR && -d $TMPDIR ]]; then
rm -rf "$TMPDIR"
fi
}
trap cleanup EXIT
require_compositor "remote session service test"
if ! command -v quickshell >/dev/null 2>&1; then
skip "quickshell not installed; skipping remote session service test"
exit 0
fi
require_command jq
require_command grim
TMPDIR=$(mktemp -d)
result="$TMPDIR/result.json"
log="$TMPDIR/quickshell.log"
config_dir="$TMPDIR/remote-session"
mkdir -p "$config_dir" "$TMPDIR/home" "$TMPDIR/bin"
cp "$SHELL_TEST_DIR/fixtures/remote-session/shell.qml" "$config_dir/shell.qml"
# A stand-in for the real server: the probe matches it by process name, so it
# must stay a script called gliff-server rather than exec into sleep, and it
# takes its sleep down with it when killed. Its ssh peer comes from the
# environment the fixture launches it with. It cannot capture the screen, so
# the fixture uses grim for the screencast event.
cat >"$TMPDIR/bin/gliff-server" <<'SH'
#!/bin/bash
sleep 30 &
trap 'kill $!' TERM EXIT
wait $!
SH
chmod +x "$TMPDIR/bin/gliff-server"
OMARCHY_PATH="$ROOT" \
OMARCHY_QML_TEST_RESULT="$result" \
OMARCHY_QML_TEST_STUB_PID="$TMPDIR/stub.pid" \
OMARCHY_QML_TEST_FRAME="$TMPDIR/frame.png" \
HOME="$TMPDIR/home" \
XDG_CONFIG_HOME="$TMPDIR/home/.config" \
XDG_CACHE_HOME="$TMPDIR/home/.cache" \
XDG_STATE_HOME="$TMPDIR/home/.local/state" \
QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \
QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \
PATH="$TMPDIR/bin:$ROOT/bin:$PATH" \
quickshell -p "$config_dir" --no-color >"$log" 2>&1 &
QS_PID=$!
for _ in {1..150}; do
[[ -s $result ]] && break
if ! kill -0 "$QS_PID" 2>/dev/null; then
sed -n '1,160p' "$log" >&2
fail "remote session quickshell exited before writing result"
fi
sleep 0.1
done
[[ -s $result ]] || {
sed -n '1,160p' "$log" >&2
fail "remote session service test timed out"
}
if ! jq -e '.ok == true' "$result" >/dev/null; then
jq . "$result" >&2
sed -n '1,160p' "$log" >&2
fail "remote session service tracks a gliff-server session"
fi
pass "remote session service tracks a gliff-server session"
[[ -s $TMPDIR/frame.png ]] || fail "the fixture raised screencast events through grim"
pass "the fixture raised screencast events through grim"
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const read = name => fs.readFileSync(path.join(root, name), 'utf8')
const model = requireFromRoot('shell/plugins/services/remote-session/RemoteSessionModel.js')
assert(model.isCaptureEvent('screencast'), 'the screencast event triggers a probe')
assert(!model.isCaptureEvent('screencastv2'), 'the paired screencastv2 event does not double the probe')
assert(!model.isCaptureEvent('openwindow') && !model.isCaptureEvent(''), 'unrelated Hyprland events are ignored')
assertDeepEqual(model.stateFromOutput(''), { active: false, peers: [] }, 'no gliff-server means no session')
assertDeepEqual(model.stateFromOutput('\n'), { active: false, peers: [] }, 'blank output means no session')
assertDeepEqual(model.stateFromOutput('4321 \n'), { active: true, peers: [] }, 'a local server without ssh is a session with no known peer')
assertDeepEqual(model.stateFromOutput('4321 10.0.0.5\n'), { active: true, peers: ['10.0.0.5'] }, 'an ssh-spawned server reports the client address')
assertDeepEqual(model.stateFromOutput('1 10.0.0.5\n2 10.0.0.5\n3 fd00::9\n'), { active: true, peers: ['10.0.0.5', 'fd00::9'] }, 'peers are listed once each')
const service = read('shell/plugins/services/remote-session/Service.qml')
const manifest = JSON.parse(read('shell/plugins/services/remote-session/manifest.json'))
assertEqual(manifest.id, 'omarchy.remote-session', 'service manifest id matches the indicator lookup')
assert(manifest.kinds.includes('service') && manifest.entryPoints.service === 'Service.qml', 'service manifest declares a service entry point')
assert(service.includes('target: Hyprland') && service.includes('isCaptureEvent(event ? event.name : "")'), 'service re-probes on Hyprland screencast events')
assert(service.includes('eventDebounce.restart()'), 'service debounces event bursts into one probe')
assert(service.includes('/shell/plugins/services/remote-session/probe.sh"'), 'service runs the shared probe script')
assert(service.includes('running: root.active') && service.includes('interval: 2000'), 'service polls only while a session is active, since the stop event is not reliable')
assert(!service.includes('omarchy-notification-send'), 'a session is shown by the indicator alone, with no notification')
const indicator = read('shell/plugins/bar/indicators/RemoteSession.qml')
const widget = read('shell/plugins/bar/widgets/Indicators.qml')
const widgetManifest = JSON.parse(read('shell/plugins/bar/widgets/Indicators.manifest.json'))
assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "ScreenRecording", "RemoteSession"/), 'remote session is included in the default indicator tray')
assert(widgetManifest.barWidget.schema.find(field => field.key === 'items').options.some(option => option.value === 'RemoteSession'), 'remote session is configurable alongside the other indicators')
assert(indicator.includes('firstPartyServiceFor("omarchy.remote-session")'), 'indicator reads the remote session service')
assert(indicator.includes('"Remote session from " + peers.join(", ")'), 'active tooltip names the connected peers')
assert(indicator.includes('useActiveColor: true') && indicator.includes('activeColor: Commons.Color.urgent'), 'active remote session uses the theme danger color')
assertEqual(indicator.match(/activeText: "([^"]+)"/)[1], indicator.match(/inactiveText: "([^"]+)"/)[1], 'remote session keeps the same icon in both states')
assert(!indicator.includes('visible:'), 'remote session uses the shared indicator visibility and hover behavior')
assert(indicator.includes('root.remoteSessionService.refresh()'), 'indicator refresh re-probes the service')
const shell = read('shell/shell.qml')
assert((shell.match(/"omarchy\.remote-session"/g) || []).length >= 2, 'third-party bar clones can read the remote session service')
const api = read('shell/services/PluginFirstPartyServiceApi.qml')
assert(api.includes('property bool active: false') && api.includes('property var peers: []'), 'service proxy exposes the remote session state')
assert(api.includes('serviceId === "omarchy.remote-session" && _refresh') && shell.includes('_refresh: function()'), 'service proxy forwards refresh for cloned indicator widgets')
JS
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
mkdir -p "$TMPDIR/bin" "$TMPDIR/proc/100" "$TMPDIR/proc/200"
probe_script="$ROOT/shell/plugins/services/remote-session/probe.sh"
bash -n "$probe_script" || fail "remote session probe is valid bash"
pass "remote session probe is valid bash"
cat >"$TMPDIR/bin/pgrep" <<'SH'
#!/bin/bash
[[ $1 == "-x" && $2 == "-u" && $3 == "$(id -u)" && $4 == "gliff-server" ]] || exit 2
[[ -n ${GLIFF_PIDS:-} ]] || exit 1
printf '%s\n' $GLIFF_PIDS
SH
chmod +x "$TMPDIR/bin/pgrep"
# The probe reads /proc/<pid>/environ; point a copy at fixtures instead.
sed "s|/proc/\$pid/environ|$TMPDIR/proc/\$pid/environ|" "$probe_script" >"$TMPDIR/probe.sh"
grep -q "$TMPDIR/proc" "$TMPDIR/probe.sh" || fail "probe reads the server environment from /proc"
pass "probe reads the server environment from /proc"
probe() {
PATH="$TMPDIR/bin:$PATH" GLIFF_PIDS="${1:-}" bash "$TMPDIR/probe.sh"
}
[[ -z $(probe) ]] || fail "probe prints nothing without a gliff server"
pass "probe prints nothing without a gliff server"
printf 'HOME=/home/x\0SSH_CONNECTION=10.0.0.5 51234 10.0.0.1 22\0TERM=foot\0' >"$TMPDIR/proc/100/environ"
printf 'HOME=/home/x\0TERM=foot\0' >"$TMPDIR/proc/200/environ"
[[ $(probe "100") == "100 10.0.0.5" ]] || fail "probe reports the ssh client address of a spawned server"
pass "probe reports the ssh client address of a spawned server"
[[ $(probe "200") == "200 " ]] || fail "probe reports a local server with an empty peer"
pass "probe reports a local server with an empty peer"
(( $(probe "100 200" | wc -l) == 2 )) || fail "probe lists every server"
pass "probe lists every server"