Files
omarchy-rdp-service/Service.qml
Johan a90363eeee Default monitor isolation on and make it self-healing
Fresh installs previously started with isolation off until someone
noticed and flipped the switch by hand, since the toggle read raw
file-existence with no persisted intent behind it. Now a small state
file (colocated in Omarchy's toggles dir, which is guaranteed to exist,
avoiding an mkdir-p race a plugin-private directory would hit) records
the user's actual choice, defaulting to isolated-on the first time the
widget ever loads. Every poll reconciles the toggle-flag file against
that intent and against the current isolated-workspace setting, so
external drift (or a workspace-number change) gets corrected within
one poll interval instead of silently sticking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MLfSWFEMyY85ZDWEYicdaF
2026-09-08 16:19:00 +01:00

391 lines
13 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import "Model.js" as Model
Item {
id: root
property var settings: ({})
readonly property string homeDir: Quickshell.env("HOME")
readonly property string serviceUnitPath: homeDir + "/.config/systemd/user/hypr-rdp.service"
readonly property string rdpConfigDir: homeDir + "/.config/hypr-rdp"
readonly property string rdpConfigPath: rdpConfigDir + "/config.toml"
readonly property string toggleDir: homeDir + "/.local/state/omarchy/toggles/hypr"
readonly property string isolationTogglePath: toggleDir + "/hypr-rdp-isolation.lua"
readonly property string outputName: "hypr-rdp"
readonly property int pollIntervalSec: intSetting("pollIntervalSec", 5, 2, 60)
readonly property int isolatedWorkspace: intSetting("isolatedWorkspace", 11, 1, 99)
property bool installed: false
property bool serviceUnitExists: false
property bool running: false
// Optimistic desired state so the switch reacts instantly; -1 means
// "just follow reality", 0/1 mean "still catching up to a click".
property int _desired: -1
readonly property bool active: _desired === -1 ? running : (_desired === 1)
property bool isolationFileExists: false
// Persisted intent (see isolation-state.json below), not raw file
// existence — so a fresh install defaults to isolated without anyone
// clicking anything, and the plugin self-heals if the toggle file is
// ever removed out from under it.
property bool desiredIsolation: true
property bool _isolationStateLoaded: false
readonly property bool isolated: desiredIsolation
readonly property bool busy: startStopProc.running || installUnitProc.running
|| isolationRemoveProc.running || reloadProc.running
property string statusText: "Checking…"
property string lastError: ""
property string actionStatus: ""
property string username: ""
property string password: ""
property string bindAddress: ""
property string rawConfigText: ""
property bool configLoaded: false
property var windowsOnIsolated: []
property int previousWorkspace: -1
property bool peeking: false
function setting(name, fallback) {
var v = settings ? settings[name] : undefined
return (v === undefined || v === null) ? fallback : v
}
function intSetting(name, fallback, min, max) {
var n = parseInt(String(setting(name, fallback)), 10)
if (!isFinite(n)) n = fallback
if (n < min) n = min
if (n > max) n = max
return n
}
function clearActionStatus() { root.actionStatus = "" }
Timer {
id: actionStatusTimer
interval: 3000
repeat: false
onTriggered: root.clearActionStatus()
}
function flashStatus(message) {
root.actionStatus = message
actionStatusTimer.restart()
}
function refresh() {
if (!whichProc.running) whichProc.running = true
if (!statusProc.running) statusProc.running = true
if (root.running && !clientsProc.running) clientsProc.running = true
root.reconcileIsolation()
}
Timer {
interval: root.pollIntervalSec * 1000
running: true
repeat: true
onTriggered: root.refresh()
}
Component.onCompleted: {
unitDirProc.running = true
isolationDirProc.running = true
configDirProc.running = true
root.refresh()
}
// ---------------------------------------------------------- install
Process {
id: whichProc
command: ["which", "hypr-rdp"]
onExited: function(code) { root.installed = (code === 0) }
}
function installHyprRdp() {
Quickshell.execDetached(["omarchy", "launch", "terminal", "bash", "-lc",
"omarchy pkg aur add hypr-rdp-git; echo; read -p 'Press Enter to close...' _"])
}
// ---------------------------------------------------------- service unit
property FileView serviceUnitFile: FileView {
path: root.serviceUnitPath
watchChanges: true
printErrors: false
onLoaded: root.serviceUnitExists = true
onLoadFailed: root.serviceUnitExists = false
onFileChanged: reload()
}
readonly property string serviceUnitContent:
"[Unit]\n" +
"Description=Native RDP server for Hyprland\n" +
"Documentation=https://github.com/MuNeNICK/hypr-rdp\n" +
"PartOf=graphical-session.target\n" +
"Requires=graphical-session.target\n" +
"After=graphical-session.target\n" +
"ConditionEnvironment=WAYLAND_DISPLAY\n" +
"\n" +
"[Service]\n" +
"Type=simple\n" +
"ExecStart=/usr/bin/hypr-rdp\n" +
"Slice=session.slice\n" +
"Restart=on-failure\n" +
"\n" +
"[Install]\n" +
"WantedBy=graphical-session.target\n"
Process { id: unitDirProc; command: ["mkdir", "-p", root.homeDir + "/.config/systemd/user"] }
Process {
id: installUnitProc
command: []
onExited: function(code) {
if (code !== 0) root.flashStatus("Failed to enable hypr-rdp.service")
root.refresh()
}
}
function ensureServiceUnit(thenStart) {
if (root.serviceUnitExists) { thenStart(); return }
root.serviceUnitFile.setText(root.serviceUnitContent)
root.serviceUnitExists = true
installUnitProc.command = ["bash", "-lc",
"systemctl --user daemon-reload && systemctl --user enable hypr-rdp.service"]
installUnitProc.running = true
thenStart()
}
// ---------------------------------------------------------- status/start/stop
Process {
id: statusProc
command: ["systemctl", "--user", "is-active", "hypr-rdp.service"]
onExited: function(code) {
root.running = (code === 0)
if (root._desired !== -1 && root.running === (root._desired === 1)) root._desired = -1
root.statusText = !root.installed ? "Not installed" : (root.running ? "Running" : "Stopped")
if (root.running && !clientsProc.running) clientsProc.running = true
if (!root.running) root.windowsOnIsolated = []
}
}
Process {
id: startStopProc
command: []
onExited: function(code) {
if (code !== 0) {
var wasStarting = root._desired === 1
root._desired = -1
root.flashStatus(wasStarting ? "hypr-rdp failed to start" : "hypr-rdp failed to stop")
}
root.refresh()
}
}
function toggleService() {
if (!root.installed || startStopProc.running) return
if (root.active) {
root._desired = 0
startStopProc.command = ["systemctl", "--user", "stop", "hypr-rdp.service"]
startStopProc.running = true
} else {
root._desired = 1
ensureServiceUnit(function() {
startStopProc.command = ["systemctl", "--user", "start", "hypr-rdp.service"]
startStopProc.running = true
})
}
}
// ---------------------------------------------------------- isolation toggle
//
// Two files, two different jobs, both in toggleDir on purpose: it's
// Omarchy's own toggles directory, guaranteed to already exist on every
// install (default/hypr/toggles.lua ships a placeholder file there), so
// neither FileView below races an async `mkdir -p` the way a
// plugin-private directory would — that race is exactly what silently
// broke first-run bootstrap during testing.
// - .dotjuice.hypr-rdp-isolation-state.json records the user's
// *intent*: on by default so a fresh install behaves the same way as
// one where someone flipped the switch, off only once someone
// actually turns it off. The require_all loader only picks up
// `*.lua` files, so a `.json` file here is invisible to Hyprland.
// - hypr-rdp-isolation.lua is what Hyprland actually reads.
// reconcileIsolation() keeps it in sync with intent on every poll, so
// if anything external deletes or restores it, the plugin puts it
// back the way it's supposed to be within one poll interval.
readonly property string isolationStatePath: toggleDir + "/.dotjuice.hypr-rdp-isolation-state.json"
property FileView isolationStateFile: FileView {
path: root.isolationStatePath
watchChanges: true
printErrors: false
onLoaded: {
root.desiredIsolation = Model.parseIsolationState(text()).enabled
root._isolationStateLoaded = true
root.reconcileIsolation()
}
onLoadFailed: {
// Never configured before (fresh install, or the state file was
// removed): bootstrap to isolated-on, matching this plugin's
// out-of-the-box behavior, and persist that choice.
root._isolationStateLoaded = true
root.persistIsolationState(true)
}
onFileChanged: reload()
}
function persistIsolationState(enabled) {
root.desiredIsolation = enabled
root.isolationStateFile.setText(JSON.stringify({ enabled: enabled }))
root.reconcileIsolation()
}
property string isolationFileContent: ""
property FileView isolationFile: FileView {
path: root.isolationTogglePath
watchChanges: true
printErrors: false
onLoaded: {
root.isolationFileContent = text()
root.isolationFileExists = true
root.reconcileIsolation()
}
onLoadFailed: {
root.isolationFileContent = ""
root.isolationFileExists = false
}
onFileChanged: reload()
}
Process { id: isolationDirProc; command: ["mkdir", "-p", root.toggleDir] }
Process {
id: isolationRemoveProc
command: ["rm", "-f", root.isolationTogglePath]
onExited: function(code) { root.applyReload() }
}
Process {
id: reloadProc
command: ["bash", "-lc", "hyprctl reload >/dev/null 2>&1; hyprctl configerrors"]
stdout: StdioCollector { id: reloadOut; waitForEnd: true }
onExited: function(code) {
var text = String(reloadOut.text || "").trim()
root.lastError = (text !== "" && text.toLowerCase() !== "ok") ? ("Hyprland config error: " + text) : ""
}
}
function applyReload() { reloadProc.running = true }
// Brings the toggle-flag file into line with persisted intent. Called on
// every poll tick as well as after any explicit change, so drift from
// outside this plugin (a stray `omarchy refresh`, someone hand-editing
// the toggles directory, etc.) gets corrected automatically.
function reconcileIsolation() {
if (!root._isolationStateLoaded || isolationRemoveProc.running) return
if (root.desiredIsolation) {
var desiredContent = Model.isolationLuaContent(root.isolatedWorkspace, root.outputName)
if (!root.isolationFileExists || root.isolationFileContent !== desiredContent) {
root.isolationFile.setText(desiredContent)
// FileView does not re-emit onLoaded for its own write.
root.isolationFileContent = desiredContent
root.isolationFileExists = true
root.applyReload()
}
} else if (root.isolationFileExists) {
root.isolationFileExists = false
isolationRemoveProc.running = true
}
}
function toggleIsolation() {
if (isolationRemoveProc.running || reloadProc.running) return
root.persistIsolationState(!root.desiredIsolation)
}
// ---------------------------------------------------------- credentials
property FileView configFile: FileView {
path: root.rdpConfigPath
watchChanges: true
printErrors: false
onLoaded: {
root.rawConfigText = text()
var parsed = Model.parseConfig(root.rawConfigText)
root.username = parsed.username
root.password = parsed.password
root.bindAddress = parsed.bind
root.configLoaded = true
}
onLoadFailed: root.configLoaded = false
onFileChanged: reload()
}
Process { id: configDirProc; command: ["mkdir", "-p", root.rdpConfigDir] }
function saveCredentials(newUsername, newPassword, newBind) {
var patch = { username: newUsername, password: newPassword, bind: newBind }
var text = Model.serializeConfig(root.rawConfigText, patch)
root.configFile.setText(text)
root.rawConfigText = text
root.username = newUsername
root.password = newPassword
root.bindAddress = newBind
root.flashStatus(root.running ? "Saved — restart hypr-rdp to apply" : "Saved")
}
// ---------------------------------------------------------- isolated workspace peek
Process {
id: activeWorkspaceProc
command: []
stdout: StdioCollector { id: activeWsOut; waitForEnd: true }
onExited: function(code) {
var id = Model.parseActiveWorkspaceId(activeWsOut.text)
if (id >= 0) root.previousWorkspace = id
dispatchProc.command = ["hyprctl", "dispatch", "workspace", String(root.isolatedWorkspace)]
dispatchProc.running = true
root.peeking = true
}
}
Process { id: dispatchProc; command: [] }
function peekIsolatedWorkspace() {
if (root.peeking || activeWorkspaceProc.running) return
activeWorkspaceProc.command = ["hyprctl", "-j", "activeworkspace"]
activeWorkspaceProc.running = true
}
function returnFromPeek() {
if (!root.peeking || root.previousWorkspace < 0) return
dispatchProc.command = ["hyprctl", "dispatch", "workspace", String(root.previousWorkspace)]
dispatchProc.running = true
root.peeking = false
}
// ---------------------------------------------------------- windows on the isolated workspace
Process {
id: clientsProc
command: ["hyprctl", "-j", "clients"]
stdout: StdioCollector {
id: clientsOut
waitForEnd: true
onStreamFinished: root.windowsOnIsolated = Model.parseClients(text, root.isolatedWorkspace)
}
}
}