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