Files
community-plugins/wl-screen-mirror/service.luau
T
IlyaandGitHub 4c553d5676 feat: add Wayland Screen Mirror plugin (#86)
* feat: add wl-screen-mirror plugin

* fix: naming

* rename thumbnail

* fix(error): treat user termination as a clean stop
2026-07-23 19:18:08 -04:00

310 lines
8.0 KiB
Luau

--!nonstrict
local MANAGED_TITLE = "Noctalia Screen Mirror"
local dataDir = noctalia.pluginDataDir()
local runFile = dataDir and (dataDir .. "/mirror.run") or nil
local errorFile = dataDir and (dataDir .. "/mirror.stderr") or nil
local commandAvailable = noctalia.commandExists("wl-mirror")
local streamActive = false
local stopRequested = false
local stopMessage = nil
local notifyAfterStop = false
local status = {
phase = "stopped",
running = false,
source = nil,
destination = nil,
available = commandAvailable and runFile ~= nil and errorFile ~= nil,
command_available = commandAvailable,
message = "",
}
local function tr(key, values)
return noctalia.tr(key, values)
end
local function shellQuote(value)
return "'" .. string.gsub(tostring(value), "'", "'\\''") .. "'"
end
local function publishStatus(phase, message)
status.phase = phase
status.running = phase == "running"
status.message = message or ""
status.available = commandAvailable and runFile ~= nil and errorFile ~= nil
status.command_available = commandAvailable
noctalia.state.set("mirror_status", status)
end
local function connected(name)
if name == nil then
return false
end
for _, output in ipairs(noctalia.outputs() or {}) do
if output.name == name then
return true
end
end
return false
end
local function readMirrorError(fallback)
if errorFile == nil then
return fallback
end
local message = noctalia.string.trim(noctalia.readFile(errorFile) or "")
return message ~= "" and message or fallback
end
local function cleanupControlFiles()
if runFile ~= nil then
noctalia.removeFile(runFile)
end
if errorFile ~= nil then
noctalia.removeFile(errorFile)
end
end
-- runStream owns this shell process group, so Noctalia tears down both the
-- wrapper and wl-mirror when the entry unloads. The marker provides normal
-- start/stop control; the parent check also prevents an orphan after a crash.
local function mirrorCommand(source, destination)
local launch = "wl-mirror"
.. " --fullscreen-output "
.. shellQuote(destination)
.. " --fullscreen"
.. " --title "
.. shellQuote(MANAGED_TITLE)
.. " "
.. shellQuote(source)
.. ' >"$ERR" 2>&1 &'
return table.concat({
"RUN=" .. shellQuote(runFile),
"ERR=" .. shellQuote(errorFile),
"OWNER=$PPID",
launch,
"PID=$!",
[[trap 'kill -TERM "$PID" 2>/dev/null || true' HUP INT TERM EXIT]],
"sleep 0.15",
[[if ! kill -0 "$PID" 2>/dev/null; then wait "$PID"; CODE=$?; printf 'failed:%s\n' "$CODE"; exit 0; fi]],
[[printf 'started\n']],
[[while [ -f "$RUN" ] && kill -0 "$OWNER" 2>/dev/null && kill -0 "$PID" 2>/dev/null; do sleep 0.1; done]],
[[if [ ! -f "$RUN" ] || ! kill -0 "$OWNER" 2>/dev/null; then]],
[[kill -TERM "$PID" 2>/dev/null || true]],
[[ATTEMPT=0]],
[[while kill -0 "$PID" 2>/dev/null && [ "$ATTEMPT" -lt 20 ]; do sleep 0.05; ATTEMPT=$((ATTEMPT + 1)); done]],
[[kill -KILL "$PID" 2>/dev/null || true]],
[[wait "$PID" 2>/dev/null || true]],
[[trap - HUP INT TERM EXIT]],
[[printf 'stopped\n']],
[[else]],
[[wait "$PID"; CODE=$?]],
[[trap - HUP INT TERM EXIT]],
[[printf 'exited:%s\n' "$CODE"]],
[[fi]],
}, "\n")
end
local function finishStopped()
local previousSource = status.source
local previousDestination = status.destination
local message = stopMessage or tr("service.mirror_stopped")
local shouldNotify = notifyAfterStop
streamActive = false
stopRequested = false
stopMessage = nil
notifyAfterStop = false
cleanupControlFiles()
status.source = nil
status.destination = nil
publishStatus("stopped", message)
if shouldNotify then
noctalia.notify(
tr("title"),
tr("notification.stopped", {
source = previousSource or "?",
destination = previousDestination or "?",
})
)
end
end
local function finishFailed(fallback)
local message = readMirrorError(fallback)
streamActive = false
stopRequested = false
stopMessage = nil
notifyAfterStop = false
cleanupControlFiles()
status.source = nil
status.destination = nil
publishStatus("error", message)
noctalia.notifyError(tr("title"), message)
end
local function handleMirrorLine(line)
line = noctalia.string.trim(line or "")
if line == "started" then
if not stopRequested then
publishStatus("running", tr("service.mirror_active"))
noctalia.notify(
tr("title"),
tr("notification.started", {
source = status.source or "?",
destination = status.destination or "?",
})
)
end
elseif line == "stopped" then
finishStopped()
elseif string.match(line, "^failed:") ~= nil then
finishFailed(tr("service.mirror_failed"))
elseif string.match(line, "^exited:") ~= nil then
local exitCode = tonumber(string.match(line, "^exited:(%d+)$"))
local userTerminated = exitCode == 0
or exitCode == 129 -- SIGHUP
or exitCode == 130 -- SIGINT
or exitCode == 131 -- SIGQUIT
or exitCode == 137 -- SIGKILL
or exitCode == 143 -- SIGTERM
if userTerminated then
finishStopped()
else
finishFailed(tr("service.mirror_exited"))
end
end
end
local function stopMirror(message, notify)
if status.phase == "stopping" then
return
end
if not streamActive then
status.source = nil
status.destination = nil
publishStatus("stopped", message or tr("service.mirror_stopped"))
return
end
stopRequested = true
stopMessage = message or tr("service.mirror_stopped")
notifyAfterStop = notify == true
publishStatus("stopping", tr("service.stopping"))
if runFile == nil or noctalia.removeFile(runFile) ~= true then
finishFailed(tr("service.data_unavailable"))
end
end
local function startMirror(source, destination)
if not commandAvailable then
local message = tr("service.wl_mirror_missing")
publishStatus("error", message)
noctalia.notifyError(tr("title"), message)
return
end
if runFile == nil or errorFile == nil then
local message = tr("service.data_unavailable")
publishStatus("error", message)
noctalia.notifyError(tr("title"), message)
return
end
if source == nil or destination == nil or source == destination then
local message = tr("service.invalid_pair")
publishStatus("error", message)
noctalia.notifyError(tr("title"), message)
return
end
if not connected(source) or not connected(destination) then
local message = tr("service.monitor_missing")
publishStatus("error", message)
noctalia.notifyError(tr("title"), message)
return
end
if streamActive or status.phase == "running" or status.phase == "starting" then
return
end
local written, writeError = noctalia.writeFile(runFile, source .. "\n" .. destination .. "\n")
if not written then
local message = writeError or tr("service.data_unavailable")
publishStatus("error", message)
noctalia.notifyError(tr("title"), message)
return
end
noctalia.writeFile(errorFile, "")
status.source = source
status.destination = destination
stopRequested = false
stopMessage = nil
notifyAfterStop = false
publishStatus("starting", tr("service.starting"))
streamActive = true
local accepted = noctalia.runStream(mirrorCommand(source, destination), handleMirrorLine)
if not accepted then
streamActive = false
cleanupControlFiles()
status.source = nil
status.destination = nil
local message = tr("service.launch_failed")
publishStatus("error", message)
noctalia.notifyError(tr("title"), message)
end
end
noctalia.state.watch("mirror_request", function(request)
if type(request) ~= "table" then
return
end
if request.action == "start" then
startMirror(request.source, request.destination)
elseif request.action == "stop" then
stopMirror(tr("service.mirror_stopped"), true)
end
end)
function onOutputsChanged()
if
(status.phase == "running" or status.phase == "starting")
and (not connected(status.source) or not connected(status.destination))
then
stopMirror(tr("service.disconnected"), false)
end
end
function onExit(_signal)
if runFile ~= nil then
noctalia.removeFile(runFile)
end
end
cleanupControlFiles()
if not commandAvailable then
publishStatus("error", tr("service.wl_mirror_missing"))
elseif runFile == nil or errorFile == nil then
publishStatus("error", tr("service.data_unavailable"))
else
publishStatus("stopped", tr("service.ready"))
end