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"