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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.bak
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
124
Model.js
Normal 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
308
Panel.qml
Normal 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
86
README.md
Normal 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
327
Service.qml
Normal 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
42
manifest.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user