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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MLfSWFEMyY85ZDWEYicdaF
This commit is contained in:
2026-09-08 15:57:18 +01:00
commit 82d76b726e
7 changed files with 909 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.bak

21
LICENSE Normal file
View File

@@ -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.

124
Model.js Normal file
View File

@@ -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
}
}

308
Panel.qml Normal file
View File

@@ -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
}
}
}
}
}

86
README.md Normal file
View File

@@ -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 <n>`) 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).

327
Service.qml Normal file
View File

@@ -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)
}
}
}

42
manifest.json Normal file
View File

@@ -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
}
]
}
}