commit 82d76b726ecc47d01099bb3892d42d3fe48a067a Author: Johan Date: Tue Sep 8 15:57:18 2026 +0100 Initial commit: hypr-rdp Control bar widget Toggles the hypr-rdp systemd --user service, isolates its headless monitor's workspace via Omarchy's toggle-flag convention, shows what's running on that workspace, and manages hypr-rdp's connection credentials. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MLfSWFEMyY85ZDWEYicdaF diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..751553b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.bak diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d890ba1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jandieman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Model.js b/Model.js new file mode 100644 index 0000000..9a66346 --- /dev/null +++ b/Model.js @@ -0,0 +1,124 @@ +// Pure helpers for the hypr-rdp plugin: TOML-ish config patching and +// hyprctl JSON parsing. No QML/Quickshell APIs here so this stays testable +// with plain node/qjs. + +function escapeTomlString(value) { + return String(value === undefined || value === null ? "" : value) + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") +} + +function unescapeTomlString(value) { + return String(value || "") + .replace(/\\"/g, "\"") + .replace(/\\\\/g, "\\") +} + +// Reads only the keys we ever show/edit (bind, username, password) from a +// hypr-rdp config.toml. Everything else in the file is left completely +// alone by serializeConfig below, comments included. +function parseConfig(text) { + var result = { bind: "", username: "", password: "" } + var lines = String(text || "").split("\n") + var re = /^\s*(bind|username|password)\s*=\s*"((?:[^"\\]|\\.)*)"\s*$/ + for (var i = 0; i < lines.length; i++) { + var m = lines[i].match(re) + if (m) result[m[1]] = unescapeTomlString(m[2]) + } + return result +} + +// Line-level patch: replaces an existing `key = "..."` line in place, +// appends a new one if the key was never set, and never touches any other +// line (comments, capture_mode, bitrate, etc. survive untouched). +function serializeConfig(originalText, patch) { + var lines = String(originalText || "").split("\n") + var keys = ["bind", "username", "password"] + var seen = {} + + for (var i = 0; i < lines.length; i++) { + for (var k = 0; k < keys.length; k++) { + var key = keys[k] + if (patch[key] === undefined) continue + var re = new RegExp("^\\s*" + key + "\\s*=") + if (re.test(lines[i])) { + lines[i] = key + " = \"" + escapeTomlString(patch[key]) + "\"" + seen[key] = true + } + } + } + + var toAppend = [] + for (var k2 = 0; k2 < keys.length; k2++) { + var key2 = keys[k2] + if (patch[key2] !== undefined && !seen[key2]) { + toAppend.push(key2 + " = \"" + escapeTomlString(patch[key2]) + "\"") + } + } + + var out = lines.join("\n") + if (toAppend.length > 0) { + if (out.length > 0 && out.charAt(out.length - 1) !== "\n") out += "\n" + out += toAppend.join("\n") + "\n" + } + return out +} + +// Content for the toggle flag file under +// ~/.local/state/omarchy/toggles/hypr/. Its mere presence pins the +// hypr-rdp headless output's workspace so Hyprland's normal +// auto-assignment never reuses it; deleting the file removes the pin. +function isolationLuaContent(workspaceId, outputName) { + return "-- Managed by the jandieman.hypr-rdp bar widget's isolation\n" + + "-- toggle. Do not hand-edit -- this file is overwritten or removed\n" + + "-- automatically when the toggle is flipped.\n" + + "--\n" + + "-- Keeps the hypr-rdp headless monitor's workspace from being reused\n" + + "-- by normal window/workspace auto-assignment, so it never silently\n" + + "-- steals one of your visible workspaces while RDP is active.\n" + + "hl.workspace_rule({ workspace = \"" + workspaceId + "\", monitor = \"" + + outputName + "\", default = true, persistent = true })\n" +} + +function parseClients(raw, workspaceId) { + var clients + try { + clients = raw ? JSON.parse(String(raw)) : [] + } catch (e) { + clients = [] + } + if (!Array.isArray(clients)) return [] + + var result = [] + for (var i = 0; i < clients.length; i++) { + var c = clients[i] + if (c && c.workspace && c.workspace.id === workspaceId) { + result.push({ + title: String(c.title || c.initialTitle || "Untitled"), + cls: String(c["class"] || c.initialClass || "") + }) + } + } + return result +} + +function parseActiveWorkspaceId(raw) { + try { + var obj = JSON.parse(String(raw || "")) + return typeof obj.id === "number" ? obj.id : -1 + } catch (e) { + return -1 + } +} + +if (typeof module !== "undefined") { + module.exports = { + escapeTomlString: escapeTomlString, + unescapeTomlString: unescapeTomlString, + parseConfig: parseConfig, + serializeConfig: serializeConfig, + isolationLuaContent: isolationLuaContent, + parseClients: parseClients, + parseActiveWorkspaceId: parseActiveWorkspaceId + } +} diff --git a/Panel.qml b/Panel.qml new file mode 100644 index 0000000..141c130 --- /dev/null +++ b/Panel.qml @@ -0,0 +1,308 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Ui + +Panel { + id: root + moduleName: "jandieman.hypr-rdp" + ipcTarget: "jandieman.hypr-rdp" + + readonly property color fg: root.bar ? root.bar.foreground : Color.foreground + readonly property color dim: Qt.darker(fg, 1.45) + readonly property color good: "#22c55e" + readonly property color bad: "#ef4444" + readonly property string fontFamily: root.bar ? root.bar.fontFamily : "JetBrainsMono Nerd Font" + + property string editUsername: "" + property string editPassword: "" + property string editBind: "" + property bool showPassword: false + + function syncEditFields() { + editUsername = service.username + editPassword = service.password + editBind = service.bindAddress + } + + function triggerPress(button) { + if (button === Qt.MiddleButton) { service.refresh(); return } + if (opened) close() + else { open(); service.refresh() } + } + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + Service { + id: service + settings: root.settings + } + + onOpenedChanged: if (opened) syncEditFields() + + WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: "RDP" + fixedWidth: root.bar && root.bar.vertical ? -1 : Style.space(30) + fixedHeight: root.bar && root.bar.vertical ? Style.space(26) : -1 + foreground: !service.installed ? root.dim : (service.active ? root.good : root.dim) + tooltipText: !service.installed ? "hypr-rdp: not installed" : ("hypr-rdp: " + service.statusText) + onPressed: function(b) { root.triggerPress(b) } + } + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.opened + focusTarget: keyCatcher + contentWidth: panel.fittedContentWidth(Style.space(400)) + contentHeight: panel.fittedContentHeight(contentColumn.implicitHeight) + + PanelKeyCatcher { + id: keyCatcher + anchors.fill: parent + onCloseRequested: root.close() + + ColumnLayout { + id: contentColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Style.space(10) + + // ── Header ── + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: "hypr-rdp" + color: root.fg + font.family: root.fontFamily + font.pixelSize: Style.font.title + font.bold: true + Layout.fillWidth: true + textFormat: Text.PlainText + } + + Text { + text: service.statusText + color: !service.installed ? root.dim : (service.active ? root.good : root.dim) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + textFormat: Text.PlainText + } + + Button { + text: "Refresh" + foreground: root.fg + fontFamily: root.fontFamily + fontSize: Style.font.caption + horizontalPadding: Style.spacing.controlPaddingX + verticalPadding: Style.spacing.controlPaddingY + onClicked: service.refresh() + } + } + + Text { + visible: service.actionStatus !== "" + Layout.fillWidth: true + text: service.actionStatus + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + textFormat: Text.PlainText + } + + Text { + visible: service.lastError !== "" + Layout.fillWidth: true + text: service.lastError + color: root.bad + wrapMode: Text.WordWrap + font.family: root.fontFamily + font.pixelSize: Style.font.caption + textFormat: Text.PlainText + } + + PanelSeparator { Layout.fillWidth: true; foreground: root.fg } + + // ── Not installed ── + ColumnLayout { + visible: !service.installed + Layout.fillWidth: true + spacing: Style.space(8) + + Text { + Layout.fillWidth: true + text: "hypr-rdp isn't installed on this machine." + color: root.dim + wrapMode: Text.WordWrap + font.family: root.fontFamily + font.pixelSize: Style.font.body + textFormat: Text.PlainText + } + + Button { + text: "Install hypr-rdp (opens a terminal)" + foreground: root.fg + fontFamily: root.fontFamily + fontSize: Style.font.body + horizontalPadding: Style.spacing.controlPaddingX + verticalPadding: Style.spacing.controlPaddingY + onClicked: service.installHyprRdp() + } + } + + // ── Installed: full UI ── + ColumnLayout { + visible: service.installed + Layout.fillWidth: true + spacing: Style.space(12) + + // Service + PanelSectionHeader { text: "SERVICE"; foreground: root.fg } + + Toggle { + Layout.fillWidth: true + label: "RDP server" + description: service.bindAddress !== "" ? ("Listening on " + service.bindAddress) : "systemd --user service" + checked: service.active + foreground: root.fg + onClicked: service.toggleService() + } + + // Isolation + PanelSectionHeader { text: "MONITOR ISOLATION"; foreground: root.fg } + + Toggle { + Layout.fillWidth: true + label: "Isolate RDP monitor" + description: "Keeps it off workspace " + service.isolatedWorkspace + + " only, out of normal auto-assignment. Turn off to let Hyprland treat it" + + " like a real second display. Takes effect next time hypr-rdp (re)starts." + checked: service.isolated + foreground: root.fg + onClicked: service.toggleIsolation() + } + + // Workspace view + PanelSectionHeader { text: "WORKSPACE " + service.isolatedWorkspace; foreground: root.fg } + + Text { + visible: service.windowsOnIsolated.length === 0 + Layout.fillWidth: true + text: service.running ? "No windows" : "hypr-rdp isn't running" + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + textFormat: Text.PlainText + } + + ColumnLayout { + visible: service.windowsOnIsolated.length > 0 + Layout.fillWidth: true + spacing: Style.space(4) + + Repeater { + model: service.windowsOnIsolated + delegate: Text { + required property var modelData + Layout.fillWidth: true + text: modelData.cls !== "" ? (modelData.cls + " — " + modelData.title) : modelData.title + color: root.fg + elide: Text.ElideRight + font.family: root.fontFamily + font.pixelSize: Style.font.caption + textFormat: Text.PlainText + } + } + } + + Button { + visible: service.running + text: service.peeking ? "Back to your workspace" : "Peek at RDP workspace" + foreground: root.fg + fontFamily: root.fontFamily + fontSize: Style.font.caption + horizontalPadding: Style.spacing.controlPaddingX + verticalPadding: Style.spacing.controlPaddingY + onClicked: service.peeking ? service.returnFromPeek() : service.peekIsolatedWorkspace() + } + + // Credentials + PanelSectionHeader { text: "CREDENTIALS"; foreground: root.fg } + + TextField { + Layout.fillWidth: true + placeholderText: "Username" + text: root.editUsername + foreground: root.fg + onTextEdited: root.editUsername = text + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.space(6) + + TextField { + Layout.fillWidth: true + placeholderText: "Password" + text: root.editPassword + password: !root.showPassword + foreground: root.fg + onTextEdited: root.editPassword = text + } + + Button { + text: root.showPassword ? "Hide" : "Show" + foreground: root.fg + fontFamily: root.fontFamily + fontSize: Style.font.caption + horizontalPadding: Style.spacing.controlPaddingX + verticalPadding: Style.spacing.controlPaddingY + onClicked: root.showPassword = !root.showPassword + } + } + + TextField { + Layout.fillWidth: true + placeholderText: "Bind address (e.g. 0.0.0.0:3389)" + text: root.editBind + foreground: root.fg + onTextEdited: root.editBind = text + } + + Button { + text: "Save credentials" + foreground: root.fg + fontFamily: root.fontFamily + fontSize: Style.font.body + horizontalPadding: Style.spacing.controlPaddingX + verticalPadding: Style.spacing.controlPaddingY + onClicked: service.saveCredentials(root.editUsername, root.editPassword, root.editBind) + } + } + + // ── Footer ── + Text { + Layout.fillWidth: true + text: "esc closes" + color: Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.3) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + horizontalAlignment: Text.AlignHCenter + textFormat: Text.PlainText + } + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0214fd --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# hypr-rdp Control + +Bar widget for [hypr-rdp](https://github.com/MuNeNICK/hypr-rdp) — start/stop +it, keep its headless monitor from colliding with your normal workspaces, +and manage its credentials, all from the Omarchy bar. + +## Requirements + +- [Omarchy](https://omarchy.org) on Hyprland +- [hypr-rdp](https://github.com/MuNeNICK/hypr-rdp) itself — the widget can + install it for you (see below), or you can install it first via + `yay -S hypr-rdp-git` / `omarchy pkg aur add hypr-rdp-git` + +## Install + +1. Copy (or clone) this repository into + `~/.config/omarchy/plugins/jandieman.hypr-rdp/` +2. `omarchy plugin enable jandieman.hypr-rdp --section right` + (or any bar section you prefer) + +The widget appears immediately; no restart of Hyprland or the shell needed. + +## Remove + +``` +omarchy plugin disable jandieman.hypr-rdp +omarchy plugin remove jandieman.hypr-rdp +``` + +Removing the plugin does **not** touch `hypr-rdp` itself, its +`systemd --user` service, or its config — it only removes the bar widget. +If you also want those gone: + +``` +systemctl --user disable --now hypr-rdp.service +rm ~/.config/systemd/user/hypr-rdp.service +rm -f ~/.local/state/omarchy/toggles/hypr/hypr-rdp-isolation.lua +hyprctl reload +omarchy pkg drop hypr-rdp-git # or hypr-rdp +``` + +## What it does + +- **Install**: if `hypr-rdp` isn't on `PATH`, offers a button that opens a + terminal running `omarchy pkg aur add hypr-rdp-git` (kept interactive + since AUR builds need a `sudo`/`makepkg` prompt — this plugin never runs + privileged commands itself). +- **Service**: start/stop via a `systemd --user` unit + (`~/.config/systemd/user/hypr-rdp.service`). Created automatically on + first start if it doesn't already exist; never overwritten after that. +- **Monitor isolation**: toggling this creates or removes + `~/.local/state/omarchy/toggles/hypr/hypr-rdp-isolation.lua` (Omarchy's + standard toggle-flag convention — the same one `omarchy toggle + window-gaps` uses), which pins an isolated workspace (default 11) to the + `hypr-rdp` headless output via `hl.workspace_rule`, then runs `hyprctl + reload` + `hyprctl configerrors`. On: Hyprland's normal workspace + auto-assignment never reuses that workspace, so starting an RDP session + can't silently steal one of your visible workspaces. Off: the headless + output behaves like a normal display. Workspace-to-monitor binding is + config-file-only in Hyprland (no live `hyprctl keyword` for it), so a + flip takes effect the next time hypr-rdp (re)starts, not mid-session. + **If you already have your own `hl.workspace_rule` pinning a workspace to + the `hypr-rdp` output in `monitors.lua` or elsewhere, remove it first** — + a leftover static rule will keep isolation on regardless of what the + widget's toggle shows. +- **Workspace view**: read-only list of windows on the isolated workspace, + plus a Peek/Back button that temporarily swaps it onto whichever monitor + you're on (`hyprctl dispatch workspace `) and back. +- **Credentials**: reads/writes `bind`, `username`, `password` in + `~/.config/hypr-rdp/config.toml`, leaving every other key and comment in + that file untouched. hypr-rdp doesn't hot-reload its config, so a saved + credential change needs a service restart to take effect. + +Settings (poll interval, isolated workspace number) are in the plugin's +bar-widget settings panel, backed by `manifest.json`'s schema. + +## Permissions / capabilities + +This plugin never calls `sudo` or `pkexec` itself. The one privileged path +is the optional Install button, which opens a visible terminal running +`omarchy pkg aur add hypr-rdp-git` — you see and approve any password +prompt yourself, the same as running that command by hand. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/Service.qml b/Service.qml new file mode 100644 index 0000000..b807293 --- /dev/null +++ b/Service.qml @@ -0,0 +1,327 @@ +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 + property int _isolationDesired: -1 + readonly property bool isolated: _isolationDesired === -1 ? isolationFileExists : (_isolationDesired === 1) + + 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 + } + + 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 + + property FileView isolationFile: FileView { + path: root.isolationTogglePath + watchChanges: true + printErrors: false + onLoaded: { + root.isolationFileExists = true + if (root._isolationDesired === 1) root._isolationDesired = -1 + } + onLoadFailed: { + root.isolationFileExists = false + if (root._isolationDesired === 0) root._isolationDesired = -1 + } + 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 } + + function toggleIsolation() { + if (isolationRemoveProc.running || reloadProc.running) return + if (root.isolated) { + root._isolationDesired = 0 + root.isolationFileExists = false + isolationRemoveProc.running = true + } else { + root._isolationDesired = 1 + root.isolationFile.setText(Model.isolationLuaContent(root.isolatedWorkspace, root.outputName)) + // FileView does not re-emit onLoaded for its own write. + root.isolationFileExists = true + root.applyReload() + } + } + + // ---------------------------------------------------------- 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) + } + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..f1cbed8 --- /dev/null +++ b/manifest.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "id": "jandieman.hypr-rdp", + "name": "hypr-rdp Control", + "version": "1.0.0", + "author": "jandieman", + "description": "Install, start/stop, and configure hypr-rdp from the bar. Keeps its headless monitor isolated to a dedicated workspace by default, with a toggle to let it behave like a normal display, plus a read-only view of what's on that workspace.", + "kinds": ["bar-widget"], + "entryPoints": { + "barWidget": "Panel.qml" + }, + "barWidget": { + "displayName": "hypr-rdp", + "description": "Toggle the hypr-rdp RDP server and manage its isolated workspace.", + "category": "System", + "allowMultiple": false, + "defaults": { + "pollIntervalSec": 5, + "isolatedWorkspace": 11 + }, + "schema": [ + { + "key": "pollIntervalSec", + "type": "integer", + "label": "Poll interval (seconds)", + "min": 2, + "max": 60, + "step": 1, + "defaultValue": 5 + }, + { + "key": "isolatedWorkspace", + "type": "integer", + "label": "Isolated workspace number", + "min": 1, + "max": 99, + "step": 1, + "defaultValue": 11 + } + ] + } +}