From 9d48fc402edf51ae089a7c8c1f7b1a8611744bd8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 9 Oct 2026 17:02:42 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5.1 --- install/omarchy-base.packages | 1 + manual/05-the-top-bar.md | 2 +- migrations/1791542175.sh | 3 + shell/plugins/README.md | 1 + .../plugins/bar/indicators/RemoteSession.qml | 26 ++++++ .../bar/widgets/Indicators.manifest.json | 5 + shell/plugins/bar/widgets/Indicators.qml | 2 +- .../remote-session/RemoteSessionModel.js | 31 +++++++ .../services/remote-session/Service.qml | 79 ++++++++++++++++ .../services/remote-session/manifest.json | 14 +++ .../plugins/services/remote-session/probe.sh | 11 +++ shell/services/PluginFirstPartyServiceApi.qml | 7 ++ shell/shell.qml | 16 +++- .../fixtures/indicator-contract/shell.qml | 25 +++++ .../shell.d/fixtures/remote-session/shell.qml | 84 +++++++++++++++++ test/shell.d/remote-session-service-test.sh | 92 +++++++++++++++++++ test/shell.d/remote-session-test.sh | 88 ++++++++++++++++++ 17 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 migrations/1791542175.sh create mode 100644 shell/plugins/bar/indicators/RemoteSession.qml create mode 100644 shell/plugins/services/remote-session/RemoteSessionModel.js create mode 100644 shell/plugins/services/remote-session/Service.qml create mode 100644 shell/plugins/services/remote-session/manifest.json create mode 100755 shell/plugins/services/remote-session/probe.sh create mode 100644 test/shell.d/fixtures/remote-session/shell.qml create mode 100644 test/shell.d/remote-session-service-test.sh create mode 100644 test/shell.d/remote-session-test.sh diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 35fd3feb..69cd36c7 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -43,6 +43,7 @@ fontconfig foot fzf git +gliff gnome-keyring gnome-themes-extra grim diff --git a/manual/05-the-top-bar.md b/manual/05-the-top-bar.md index 41646560..9f7b4e79 100644 --- a/manual/05-the-top-bar.md +++ b/manual/05-the-top-bar.md @@ -73,7 +73,7 @@ Removing either service takes its widget back off the bar. ## Indicators -The little cluster in the center is the indicators widget. These are status glyphs for modes you've turned on: do not disturb, night light, a queued [reminder](09-reminders.md), an active screen recording, stay awake, and [dictation](11-text-extraction-dictation.md). They light up when the mode is active and otherwise stay out of the way — hover to the left of the clock (above it on a vertical bar) to peek at the inactive ones. Clicking an indicator toggles that mode. +The little cluster in the center is the indicators widget. These are status glyphs for modes you've turned on: do not disturb, night light, a queued [reminder](09-reminders.md), an active screen recording, stay awake, and [dictation](11-text-extraction-dictation.md). They light up when the mode is active and otherwise stay out of the way — hover to the left of the clock (above it on a vertical bar) to peek at the inactive ones. Clicking an indicator toggles that mode. One more is status only: a remote session lights up red while someone is connected to this machine through gliff. If you'd rather they were always visible, set `alwaysShow` to `true` on the widget. And if you only care about some of them, list the ones you want in `items`: `["Dnd", "Reminder", "NightLight"]`. You can have more than one indicators widget, so different sections can show different subsets. diff --git a/migrations/1791542175.sh b/migrations/1791542175.sh new file mode 100644 index 00000000..40bc4ba4 --- /dev/null +++ b/migrations/1791542175.sh @@ -0,0 +1,3 @@ +echo "Install Gliff, the Hyprland remote desktop over SSH" + +omarchy-pkg-add gliff diff --git a/shell/plugins/README.md b/shell/plugins/README.md index 092dd20c..4acd9ee2 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -34,6 +34,7 @@ User-installed plugins live alongside these conceptually but on disk under | Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` | | Idle | `omarchy.idle` | `service` | `services/idle/Service.qml` | | Night light | `omarchy.nightlight` | `service` | `services/nightlight/Service.qml` | +| Remote session | `omarchy.remote-session` | `service` | `services/remote-session/Service.qml` | | Lock screen | `omarchy.lock` | `service` | `lock/Service.qml` | | OSD | `omarchy.osd` | `panel` | `osd/Osd.qml` | | Polkit agent | `omarchy.polkit` | `service` | `polkit/PolkitAgent.qml` | diff --git a/shell/plugins/bar/indicators/RemoteSession.qml b/shell/plugins/bar/indicators/RemoteSession.qml new file mode 100644 index 00000000..0f0eef7d --- /dev/null +++ b/shell/plugins/bar/indicators/RemoteSession.qml @@ -0,0 +1,26 @@ +import QtQuick +import qs.Commons as Commons +import qs.Ui + +BarIndicator { + id: root + + readonly property var remoteSessionService: bar?.shell?.firstPartyServiceFor("omarchy.remote-session") + readonly property var peers: remoteSessionService && Array.isArray(remoteSessionService.peers) ? remoteSessionService.peers : [] + + active: remoteSessionService ? remoteSessionService.active : false + activeText: "󰢹" + inactiveText: "󰢹" + activeTooltipText: peers.length > 0 ? "Remote session from " + peers.join(", ") : "Remote session" + inactiveTooltipText: "Remote Session" + useActiveColor: true + activeColor: Commons.Color.urgent + + Connections { + target: root.indicatorHost + ignoreUnknownSignals: true + function onRefreshRequested() { + if (root.remoteSessionService) root.remoteSessionService.refresh() + } + } +} diff --git a/shell/plugins/bar/widgets/Indicators.manifest.json b/shell/plugins/bar/widgets/Indicators.manifest.json index 9e9ee40b..a2447aae 100644 --- a/shell/plugins/bar/widgets/Indicators.manifest.json +++ b/shell/plugins/bar/widgets/Indicators.manifest.json @@ -36,6 +36,11 @@ "label": "Screen recording", "description": "GPU screen recorder status" }, + { + "value": "RemoteSession", + "label": "Remote session", + "description": "Warning while someone is connected through gliff" + }, { "value": "Reminder", "label": "Reminder", diff --git a/shell/plugins/bar/widgets/Indicators.qml b/shell/plugins/bar/widgets/Indicators.qml index d5679267..7d4fe564 100644 --- a/shell/plugins/bar/widgets/Indicators.qml +++ b/shell/plugins/bar/widgets/Indicators.qml @@ -8,7 +8,7 @@ BarWidget { id: root moduleName: "omarchy.indicators" - readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] + readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "ScreenRecording", "RemoteSession", "Reminder", "NightLight", "Dnd", "StayAwake" ] readonly property var indicatorEntries: indicatorEntriesFromSettings(settings) property var activeIndicatorIds: [] property var indicatorActiveStates: ({}) diff --git a/shell/plugins/services/remote-session/RemoteSessionModel.js b/shell/plugins/services/remote-session/RemoteSessionModel.js new file mode 100644 index 00000000..39a8ccac --- /dev/null +++ b/shell/plugins/services/remote-session/RemoteSessionModel.js @@ -0,0 +1,31 @@ +// Hyprland emits this whenever a screencopy session starts or stops, which +// covers gliff-server (ext-image-copy-capture) as well as screen recorders +// and browser screen shares, so the event is only a cue to re-probe. The +// matching screencastv2 event always accompanies it and is left alone so one +// transition costs one probe. +function isCaptureEvent(name) { + return String(name || "") === "screencast" +} + +// Parses the probe output: one " [peer]" line per running gliff-server, +// where peer is the ssh client address when the server was spawned over ssh. +function stateFromOutput(text) { + var lines = String(text || "").split("\n") + var active = false + var peers = [] + for (var i = 0; i < lines.length; i++) { + var parts = lines[i].trim().split(/\s+/) + if (parts[0] === "") continue + active = true + var peer = parts[1] || "" + if (peer !== "" && peers.indexOf(peer) === -1) peers.push(peer) + } + return { active: active, peers: peers } +} + +if (typeof module !== "undefined") { + module.exports = { + isCaptureEvent: isCaptureEvent, + stateFromOutput: stateFromOutput + } +} diff --git a/shell/plugins/services/remote-session/Service.qml b/shell/plugins/services/remote-session/Service.qml new file mode 100644 index 00000000..5d3d734f --- /dev/null +++ b/shell/plugins/services/remote-session/Service.qml @@ -0,0 +1,79 @@ +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import "RemoteSessionModel.js" as RemoteSessionModel + +// Watches for gliff-server, the Hyprland remote desktop, so the machine being +// controlled can see that someone is on it: the RemoteSession bar indicator +// reads `active` and `peers`. +Item { + id: root + + // Injected by omarchy-shell (the first-party service loader). + property var shell: null + + property bool stateLoaded: false + property bool active: false + property var peers: [] + + property bool refreshPending: false + + function refresh() { + if (statusProbe.running) { + root.refreshPending = true + return + } + root.refreshPending = false + statusProbe.running = true + } + + function applyProbe(text) { + var state = RemoteSessionModel.stateFromOutput(text) + root.peers = state.peers + root.active = state.active + root.stateLoaded = true + } + + Component.onCompleted: refresh() + + // gliff-server captures the screen through ext-image-copy-capture, so a + // session start surfaces as a Hyprland screencast event. The probe decides + // whether it was gliff or another screencopy client, and a short debounce + // folds the event bursts Hyprland sends around one transition into one probe. + Connections { + target: Hyprland + function onRawEvent(event) { + if (RemoteSessionModel.isCaptureEvent(event ? event.name : "")) eventDebounce.restart() + } + } + + Timer { + id: eventDebounce + interval: 150 + repeat: false + onTriggered: root.refresh() + } + + // The end of a session often has no event: Hyprland reports the screencast + // stopped as soon as frames pause for half a second, and says nothing more + // when the server later exits. So poll while a session is active. + Timer { + interval: 2000 + repeat: true + running: root.active + onTriggered: root.refresh() + } + + Process { + id: statusProbe + command: ["bash", Quickshell.env("OMARCHY_PATH") + "/shell/plugins/services/remote-session/probe.sh"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.applyProbe(text) + } + onExited: function() { + if (root.refreshPending) Qt.callLater(root.refresh) + } + } +} diff --git a/shell/plugins/services/remote-session/manifest.json b/shell/plugins/services/remote-session/manifest.json new file mode 100644 index 00000000..cf38ad31 --- /dev/null +++ b/shell/plugins/services/remote-session/manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "omarchy.remote-session", + "name": "Remote Session", + "version": "1.0.0", + "author": "Omarchy", + "description": "Detects a gliff remote-desktop session on this machine for the bar indicator.", + "kinds": [ + "service" + ], + "entryPoints": { + "service": "Service.qml" + } +} diff --git a/shell/plugins/services/remote-session/probe.sh b/shell/plugins/services/remote-session/probe.sh new file mode 100755 index 00000000..41e48bae --- /dev/null +++ b/shell/plugins/services/remote-session/probe.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Lists this user's running gliff-server processes, one " [peer]" line +# each. The peer is the ssh client address when the server was spawned over +# ssh (the real path); a local --listen server has none. Another account's +# server captures its own desktop, not this one, so other users are skipped. + +for pid in $(pgrep -x -u "$(id -u)" gliff-server); do + peer=$(tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=\([^ ]*\).*/\1/p') + printf '%s %s\n' "$pid" "$peer" +done diff --git a/shell/services/PluginFirstPartyServiceApi.qml b/shell/services/PluginFirstPartyServiceApi.qml index 1f052629..e39dfd54 100644 --- a/shell/services/PluginFirstPartyServiceApi.qml +++ b/shell/services/PluginFirstPartyServiceApi.qml @@ -11,6 +11,8 @@ QtObject { property bool doNotDisturb: false property var activePlayer: null property var sourcePlayers: [] + property bool active: false + property var peers: [] property var _setIdleEnabled: null property var _setNightlight: null @@ -18,6 +20,7 @@ QtObject { property var _runAction: null property var _playerKey: null property var _selectPlayer: null + property var _refresh: null function setIdleEnabled(value) { if (serviceId === "omarchy.idle" && _setIdleEnabled) _setIdleEnabled(!!value) @@ -43,4 +46,8 @@ QtObject { function selectPlayer(playerId) { if (serviceId === "omarchy.media" && _selectPlayer) _selectPlayer(String(playerId || "")) } + + function refresh() { + if (serviceId === "omarchy.remote-session" && _refresh) _refresh() + } } diff --git a/shell/shell.qml b/shell/shell.qml index ea6e6de8..b89831ed 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -456,7 +456,7 @@ ShellRoot { function pluginFirstPartyServiceFor(cacheKey, pluginId, requestedId) { var id = String(requestedId || "") - var allowed = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"] + var allowed = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications", "omarchy.remote-session"] if (allowed.indexOf(id) === -1) return null var proxyKey = cacheKey + "::" + id if (_pluginFirstPartyServiceApis[proxyKey]) return _pluginFirstPartyServiceApis[proxyKey] @@ -490,6 +490,10 @@ ShellRoot { _selectPlayer: function(playerKey) { var target = service() if (target && typeof target.selectPlayer === "function") target.selectPlayer(playerKey) + }, + _refresh: function() { + var target = service() + if (target && typeof target.refresh === "function") target.refresh() } }) if (!api) return null @@ -513,6 +517,14 @@ ShellRoot { var target = service() return target && Array.isArray(target.sourcePlayers) ? target.sourcePlayers : [] }) + api.active = Qt.binding(function() { + var target = service() + return target ? target.active === true : false + }) + api.peers = Qt.binding(function() { + var target = service() + return target && Array.isArray(target.peers) ? target.peers : [] + }) var next = ({}) for (var existing in _pluginFirstPartyServiceApis) next[existing] = _pluginFirstPartyServiceApis[existing] next[proxyKey] = api @@ -588,7 +600,7 @@ ShellRoot { // property, even though the resulting proxy is otherwise acyclic. var firstPartyServices = ({}) if (barCapabilities) { - var serviceIds = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"] + var serviceIds = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications", "omarchy.remote-session"] for (var i = 0; i < serviceIds.length; i++) { var serviceId = serviceIds[i] firstPartyServices[serviceId] = shell.pluginFirstPartyServiceFor(cacheKey, key, serviceId) diff --git a/test/shell.d/fixtures/indicator-contract/shell.qml b/test/shell.d/fixtures/indicator-contract/shell.qml index 4b3f04c3..79b19e22 100644 --- a/test/shell.d/fixtures/indicator-contract/shell.qml +++ b/test/shell.d/fixtures/indicator-contract/shell.qml @@ -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" diff --git a/test/shell.d/fixtures/remote-session/shell.qml b/test/shell.d/fixtures/remote-session/shell.qml new file mode 100644 index 00000000..9b090f06 --- /dev/null +++ b/test/shell.d/fixtures/remote-session/shell.qml @@ -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) + }) + } +} diff --git a/test/shell.d/remote-session-service-test.sh b/test/shell.d/remote-session-service-test.sh new file mode 100644 index 00000000..b3abefbe --- /dev/null +++ b/test/shell.d/remote-session-service-test.sh @@ -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" + diff --git a/test/shell.d/remote-session-test.sh b/test/shell.d/remote-session-test.sh new file mode 100644 index 00000000..ea856eb6 --- /dev/null +++ b/test/shell.d/remote-session-test.sh @@ -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//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"