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
391 lines
13 KiB
QML
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)
|
|
}
|
|
}
|
|
}
|