diff --git a/Model.js b/Model.js index 9a66346..f40a4e2 100644 --- a/Model.js +++ b/Model.js @@ -69,7 +69,7 @@ function serializeConfig(originalText, patch) { // 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" + + return "-- Managed by the dotjuice.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" + @@ -111,6 +111,18 @@ function parseActiveWorkspaceId(raw) { } } +// Isolation intent is on unless the state file explicitly says otherwise — +// covers a fresh install (no file yet, handled by the caller) and a +// corrupt/unexpected file the same way: default to the safer "isolated" side. +function parseIsolationState(raw) { + try { + var obj = JSON.parse(String(raw || "")) + return { enabled: obj.enabled !== false } + } catch (e) { + return { enabled: true } + } +} + if (typeof module !== "undefined") { module.exports = { escapeTomlString: escapeTomlString, @@ -119,6 +131,7 @@ if (typeof module !== "undefined") { serializeConfig: serializeConfig, isolationLuaContent: isolationLuaContent, parseClients: parseClients, - parseActiveWorkspaceId: parseActiveWorkspaceId + parseActiveWorkspaceId: parseActiveWorkspaceId, + parseIsolationState: parseIsolationState } } diff --git a/README.md b/README.md index b8e0f72..5ba78a4 100644 --- a/README.md +++ b/README.md @@ -48,17 +48,21 @@ omarchy pkg drop hypr-rdp-git # or hypr-rdp - **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. +- **Monitor isolation**: **on by default**, no setup needed. It works by + creating `~/.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. Your choice is remembered in a small state file next to the + toggle flag, and every poll cycle re-applies it — so if the toggle file + is ever deleted or edited by something else, the widget puts it back + within a few seconds rather than silently losing the setting. **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 diff --git a/Service.qml b/Service.qml index b807293..0a55fd6 100644 --- a/Service.qml +++ b/Service.qml @@ -28,8 +28,13 @@ Item { 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) + // Persisted intent (see isolation-state.json below), not raw file + // existence — so a fresh install defaults to isolated without anyone + // clicking anything, and the plugin self-heals if the toggle file is + // ever removed out from under it. + property bool desiredIsolation: true + property bool _isolationStateLoaded: false + readonly property bool isolated: desiredIsolation readonly property bool busy: startStopProc.running || installUnitProc.running || isolationRemoveProc.running || reloadProc.running @@ -79,6 +84,7 @@ Item { if (!whichProc.running) whichProc.running = true if (!statusProc.running) statusProc.running = true if (root.running && !clientsProc.running) clientsProc.running = true + root.reconcileIsolation() } Timer { @@ -201,18 +207,64 @@ Item { } // ---------------------------------------------------------- isolation toggle + // + // Two files, two different jobs, both in toggleDir on purpose: it's + // Omarchy's own toggles directory, guaranteed to already exist on every + // install (default/hypr/toggles.lua ships a placeholder file there), so + // neither FileView below races an async `mkdir -p` the way a + // plugin-private directory would — that race is exactly what silently + // broke first-run bootstrap during testing. + // - .dotjuice.hypr-rdp-isolation-state.json records the user's + // *intent*: on by default so a fresh install behaves the same way as + // one where someone flipped the switch, off only once someone + // actually turns it off. The require_all loader only picks up + // `*.lua` files, so a `.json` file here is invisible to Hyprland. + // - hypr-rdp-isolation.lua is what Hyprland actually reads. + // reconcileIsolation() keeps it in sync with intent on every poll, so + // if anything external deletes or restores it, the plugin puts it + // back the way it's supposed to be within one poll interval. + + readonly property string isolationStatePath: toggleDir + "/.dotjuice.hypr-rdp-isolation-state.json" + + property FileView isolationStateFile: FileView { + path: root.isolationStatePath + watchChanges: true + printErrors: false + onLoaded: { + root.desiredIsolation = Model.parseIsolationState(text()).enabled + root._isolationStateLoaded = true + root.reconcileIsolation() + } + onLoadFailed: { + // Never configured before (fresh install, or the state file was + // removed): bootstrap to isolated-on, matching this plugin's + // out-of-the-box behavior, and persist that choice. + root._isolationStateLoaded = true + root.persistIsolationState(true) + } + onFileChanged: reload() + } + + function persistIsolationState(enabled) { + root.desiredIsolation = enabled + root.isolationStateFile.setText(JSON.stringify({ enabled: enabled })) + root.reconcileIsolation() + } + + property string isolationFileContent: "" property FileView isolationFile: FileView { path: root.isolationTogglePath watchChanges: true printErrors: false onLoaded: { + root.isolationFileContent = text() root.isolationFileExists = true - if (root._isolationDesired === 1) root._isolationDesired = -1 + root.reconcileIsolation() } onLoadFailed: { + root.isolationFileContent = "" root.isolationFileExists = false - if (root._isolationDesired === 0) root._isolationDesired = -1 } onFileChanged: reload() } @@ -237,21 +289,32 @@ Item { function applyReload() { reloadProc.running = true } - function toggleIsolation() { - if (isolationRemoveProc.running || reloadProc.running) return - if (root.isolated) { - root._isolationDesired = 0 + // Brings the toggle-flag file into line with persisted intent. Called on + // every poll tick as well as after any explicit change, so drift from + // outside this plugin (a stray `omarchy refresh`, someone hand-editing + // the toggles directory, etc.) gets corrected automatically. + function reconcileIsolation() { + if (!root._isolationStateLoaded || isolationRemoveProc.running) return + if (root.desiredIsolation) { + var desiredContent = Model.isolationLuaContent(root.isolatedWorkspace, root.outputName) + if (!root.isolationFileExists || root.isolationFileContent !== desiredContent) { + root.isolationFile.setText(desiredContent) + // FileView does not re-emit onLoaded for its own write. + root.isolationFileContent = desiredContent + root.isolationFileExists = true + root.applyReload() + } + } else if (root.isolationFileExists) { root.isolationFileExists = false isolationRemoveProc.running = true - } 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() } } + function toggleIsolation() { + if (isolationRemoveProc.running || reloadProc.running) return + root.persistIsolationState(!root.desiredIsolation) + } + // ---------------------------------------------------------- credentials property FileView configFile: FileView {