* feat: add wl-screen-mirror plugin * fix: naming * rename thumbnail * fix(error): treat user termination as a clean stop
310 lines
8.0 KiB
Luau
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
|