Files
community-plugins/hypr-screen-mirror/service.luau
T
8a3bb49fdb feat: add hypr-screen-mirror (#66)
* feat(hypr-screen-mirror): setup plugin

* feat(hypr-screen-mirror): added socat watch on hyprland socket for monitor change

* feat(hypr-screen-mirror): added hyprctl commands for starting and stopping mirroring

* fix(hypr-screen-mirror): check if a mirror is active from monitor output during monitor update

* doc(hypr-screen-mirror): plugin readme and image

* fix(hypr-screen-mirror): issues from review

* fix(hypr-screen-mirror): separate observed state from mirror actions

---------

Co-authored-by: Lemmy <studio@quadbyte.net>
2026-07-21 23:57:54 -04:00

202 lines
6.2 KiB
Luau

function tr(key)
return noctalia.tr(key)
end
local function shellQuote(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
-- This could be replaced by noctalia.outputs() and noctalia.focusedOutputName() but currently the focusedOutputName() function returns nil when testing so we can't use it
local function updateMonitors()
noctalia.runAsync("hyprctl monitors all -j", function(output)
if output.timedOut or output.exitCode ~= 0 then
noctalia.notify(tr("notification.monitor_list_failed"), output.stderr or tr("notification.timed_out"))
return
end
local ok, msg = pcall(noctalia.json.decode, output.stdout)
if not ok or type(msg) ~= "table" then
noctalia.notify(tr("notification.monitor_list_failed"), msg)
return
end
local monitors = {}
local focusedMonitor = nil
for i, monitor in ipairs(msg) do
monitors[i] = monitor.name
focusedMonitor = monitor.focused and monitor.name or focusedMonitor
end
if #monitors == 0 then
noctalia.notify(tr("notification.monitor_list_failed"), tr("notification.no_monitors_found"))
return
end
-- update monitor data
noctalia.state.set("monitors", monitors)
noctalia.state.set("focusedMonitor", focusedMonitor)
-- update source and target monitor indices if they are out of bounds or not set
local sourceMonitor = tonumber(noctalia.state.get("sourceMonitor")) or 0
local targetMonitor = tonumber(noctalia.state.get("targetMonitor")) or 0
local isMirroring = false
if sourceMonitor >= #monitors then
sourceMonitor = 0
end
if targetMonitor >= #monitors then
targetMonitor = 0
end
-- check if there is an active mirror and set the source and target monitors accordingly
for _, monitor in ipairs(msg) do
if monitor.mirrorOf == "none" then
continue
end
local mirrorId = tonumber(monitor.mirrorOf)
local targetIndex = table.find(monitors, monitor.name)
if mirrorId ~= nil and targetIndex ~= nil then
for _, source in ipairs(msg) do
if tonumber(source.id) == mirrorId then
local sourceIndex = table.find(monitors, source.name)
if sourceIndex ~= nil then
sourceMonitor = sourceIndex - 1
targetMonitor = targetIndex - 1
isMirroring = true
end
break
end
end
end
if isMirroring then
break
end
end
noctalia.state.set("sourceMonitor", sourceMonitor)
noctalia.state.set("targetMonitor", targetMonitor)
noctalia.state.set("isMirroring", isMirroring)
end, 5000)
end
local function hyprlandSocketPath()
local runtimeDir = noctalia.getenv("XDG_RUNTIME_DIR")
local instanceSig = noctalia.getenv("HYPRLAND_INSTANCE_SIGNATURE")
if not runtimeDir or not instanceSig then
return nil
end
return string.format("%s/hypr/%s/.socket2.sock", runtimeDir, instanceSig)
end
local function handleHyprEvent(line)
local event, data = line:match("^([^>]+)>>(.*)$")
-- we can not use the event data itself for the monitor events because hyprland treats a mirror as a monitor remove/add so this would remove a mirrored screen from the list
if event == "monitoradded" then
updateMonitors()
elseif event == "monitorremoved" then
updateMonitors()
elseif event == "focusedmon" then
-- data is MONNAME,WORKSPACENAME
local monitorName = data:match("([^,]+)")
if not monitorName then
return
end
noctalia.state.set("focusedMonitor", monitorName)
end
end
-- Watches the Hyprland socket for monitor events and updates the state accordingly
local function watchMonitors()
local socketPath = hyprlandSocketPath()
if not socketPath then
noctalia.notify(tr("notification.monitor_watch_failed"), tr("notification.hyprland_socket_not_found"))
return
end
local cmd = "socat -U - UNIX-CONNECT:" .. shellQuote(socketPath)
noctalia.runStream(cmd, handleHyprEvent)
end
-- main
updateMonitors()
watchMonitors()
noctalia.state.watch("monitor_refresh", function()
updateMonitors()
end)
local function stopMirroring(targetIndex)
targetIndex = tonumber(targetIndex)
local monitors = noctalia.state.get("monitors") or {}
if not targetIndex then
noctalia.notify(tr("notification.stop_mirroring_failed"), tr("notification.invalid_monitor_selection"))
return
end
local targetMonitor = monitors[targetIndex + 1]
if not targetMonitor then
noctalia.notify(tr("notification.stop_mirroring_failed"), tr("notification.invalid_monitor_selection"))
return
end
local expr = string.format("hl.monitor({ output = %q, mirror = \"\" })", targetMonitor)
local cmd = "hyprctl eval " .. shellQuote(expr)
noctalia.runAsync(cmd, function(output)
if output.timedOut or output.exitCode ~= 0 then
noctalia.notify(tr("notification.stop_mirroring_failed"), output.stderr or tr("notification.timed_out"))
return
end
updateMonitors()
end, 5000)
end
local function startMirroring(sourceIndex, targetIndex)
sourceIndex = tonumber(sourceIndex)
targetIndex = tonumber(targetIndex)
local monitors = noctalia.state.get("monitors") or {}
if not sourceIndex or not targetIndex or sourceIndex == targetIndex then
noctalia.notify(tr("notification.start_mirroring_failed"), tr("notification.invalid_monitor_selection"))
return
end
local sourceMonitor = monitors[sourceIndex + 1]
local targetMonitor = monitors[targetIndex + 1]
if not sourceMonitor or not targetMonitor then
noctalia.notify(tr("notification.start_mirroring_failed"), tr("notification.invalid_monitor_selection"))
return
end
local expr = string.format("hl.monitor({ output = %q, mirror = %q })", targetMonitor, sourceMonitor)
local cmd = "hyprctl eval " .. shellQuote(expr)
noctalia.runAsync(cmd, function(output)
if output.timedOut or output.exitCode ~= 0 then
noctalia.notify(tr("notification.start_mirroring_failed"), output.stderr or tr("notification.timed_out"))
return
end
updateMonitors()
end, 5000)
end
noctalia.state.watch("mirrorRequest", function(request)
if type(request) ~= "table" then
return
end
if request.enabled == true then
startMirroring(request.sourceMonitor, request.targetMonitor)
else
stopMirroring(request.targetMonitor)
end
end)