Files
community-plugins/special-workspaces/service.luau
T
Jetsadakorn MaliwanandGitHub 0d4f1277c4 feat: Add Hyprland special workspaces plugin (#116)
* feat: Add Hyprland special workspaces plugin

* fix: Nest translation keys under settings object
2026-07-26 23:06:15 -04:00

195 lines
5.7 KiB
Luau

-- Publishes populated or active Hyprland special workspaces to the plugin state
-- channel.
-- Hyprland's event socket triggers snapshots after relevant changes.
local STATE_KEY = "special_workspaces"
local SEPARATOR = "__NOCTALIA_SPECIAL_WORKSPACES_MONITORS__"
local STREAM_REFRESH_MARKER = "__NOCTALIA_SPECIAL_WORKSPACES_STREAM_REFRESH__"
local SNAPSHOT_ATTEMPTS = 3
local loggedSnapshotError = false
local refreshInFlight = false
local refreshPending = false
local function logSnapshotError(message)
if not loggedSnapshotError then
noctalia.log("Special workspaces: " .. message .. "; retaining last state")
loggedSnapshotError = true
end
end
local function shellQuote(value)
return "'" .. string.gsub(value, "'", "'\"'\"'") .. "'"
end
local function specialName(value)
if type(value) ~= "string" then
return nil
end
local name = string.match(value, "^special:(.+)$")
if name and name ~= "" then
return name
end
return nil
end
local function snapshotFromJson(clientsJson, monitorsJson)
local clients, clientErr = noctalia.json.decode(clientsJson)
local monitors, monitorErr = noctalia.json.decode(monitorsJson)
if type(clients) ~= "table" or type(monitors) ~= "table" then
return nil, "invalid Hyprland JSON" .. (clientErr and (": " .. clientErr) or (monitorErr and (": " .. monitorErr) or ""))
end
local workspaces = {}
for _, client in ipairs(clients) do
local workspace = client.workspace
local name = workspace and specialName(workspace.name)
if name then
local item = workspaces[name]
if not item then
item = { name = name, windowCount = 0, active = false }
workspaces[name] = item
end
item.windowCount = item.windowCount + 1
end
end
for _, monitor in ipairs(monitors) do
local special = monitor.specialWorkspace
local name = special and specialName(special.name)
if name then
local item = workspaces[name]
if not item then
item = { name = name, windowCount = 0, active = false }
workspaces[name] = item
end
item.active = true
end
end
local result = {}
for _, item in pairs(workspaces) do
table.insert(result, item)
end
table.sort(result, function(a, b) return a.name < b.name end)
return result
end
local function publishSnapshot(clientsJson, monitorsJson)
local state, err = snapshotFromJson(clientsJson, monitorsJson)
if not state then
-- Keep the last valid state during temporary compositor command failures.
logSnapshotError("snapshot failed (" .. err .. ")")
return false
end
loggedSnapshotError = false
noctalia.state.set(STATE_KEY, state)
return true
end
local function refresh()
if refreshInFlight then
refreshPending = true
return
end
refreshInFlight = true
local command = "attempt=1; while [ \"$attempt\" -le " .. SNAPSHOT_ATTEMPTS
.. " ]; do clients=$(hyprctl -j clients) && monitors=$(hyprctl -j monitors)"
.. " && { printf '%s\\n" .. SEPARATOR .. "\\n%s\\n' \"$clients\" \"$monitors\"; exit 0; };"
.. " attempt=$((attempt + 1)); [ \"$attempt\" -le " .. SNAPSHOT_ATTEMPTS
.. " ] && sleep 1; done; exit 1"
local started = noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
logSnapshotError("hyprctl failed")
else
local markerStart, markerEnd = string.find(result.stdout, "\n" .. SEPARATOR .. "\n", 1, true)
if not markerStart then
logSnapshotError("incomplete hyprctl snapshot")
else
publishSnapshot(string.sub(result.stdout, 1, markerStart - 1), string.sub(result.stdout, markerEnd + 1))
end
end
refreshInFlight = false
if refreshPending then
refreshPending = false
refresh()
end
end)
if not started then
refreshInFlight = false
logSnapshotError("could not start hyprctl snapshot")
end
end
local refreshEvents = {
activespecial = true,
activespecialv2 = true,
openwindow = true,
closewindow = true,
movewindow = true,
movewindowv2 = true,
workspace = true,
workspacev2 = true,
focusedmon = true,
focusedmonv2 = true,
createworkspace = true,
createworkspacev2 = true,
destroyworkspace = true,
destroyworkspacev2 = true,
moveworkspace = true,
moveworkspacev2 = true,
renameworkspace = true,
monitoradded = true,
monitoraddedv2 = true,
monitorremoved = true,
monitorremovedv2 = true,
}
local function startEventStream()
if not noctalia.commandExists("socat") then
noctalia.log("Special workspaces: socat is unavailable; event updates disabled")
return
end
local runtimeDir = noctalia.getenv("XDG_RUNTIME_DIR")
local signature = noctalia.getenv("HYPRLAND_INSTANCE_SIGNATURE")
if not runtimeDir or not signature then
noctalia.log("Special workspaces: Hyprland environment is unavailable; event updates disabled")
return
end
local socket = runtimeDir .. "/hypr/" .. signature .. "/.socket2.sock"
-- Keep a single helper alive across temporary socket failures. Noctalia stops
-- the complete stream command automatically when this service exits.
local command = "while true; do while [ ! -S " .. shellQuote(socket)
.. " ]; do sleep 5; done; printf '" .. STREAM_REFRESH_MARKER
.. "\\n'; socat -u UNIX-CONNECT:" .. shellQuote(socket)
.. " - 2>&1; sleep 5; done"
local started = noctalia.runStream(command, function(line)
if line == STREAM_REFRESH_MARKER then
refresh()
return
end
local event = string.match(line, "^([%a%d_]+)>>")
if event then
if refreshEvents[event] then
refresh()
end
end
end)
if not started then
noctalia.log("Special workspaces: could not start socat event stream")
end
end
refresh()
startEventStream()
function onOutputsChanged()
refresh()
end