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
This commit is contained in:
Ilya
2026-07-23 19:18:08 -04:00
committed by GitHub
parent 46ecd621f6
commit 4c553d5676
7 changed files with 853 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# Wayland Screen Mirror
This plugin allows you to easily toggle screen mirroring in Wayland via `wl-mirror`.
## Plugin
| Field | Value |
| --- | --- |
| ID | `elijaharch/wl-screen-mirror` |
| Entries | Bar widget: `mirror`; panel: `controls`; service: `mirror-service` |
## Requirements
- [`wl-mirror`](https://github.com/Ferdi265/wl-mirror) available on `PATH`
## Usage
1. Add the `mirror` widget to your bar
2. Open the panel and select the source and destination displays
3. Click on **Start mirroring**
```sh
noctalia msg panel-toggle elijaharch/wl-screen-mirror:controls
```
The service stops mirroring automatically if either selected output disconnects.
## Notes
- The plugin launches
`wl-mirror --fullscreen-output DESTINATION --fullscreen SOURCE`.
- A private marker and the latest `wl-mirror` error output are written under the
plugin data directory. No user content is stored.
- The managed process is terminated when mirroring stops, the plugin reloads,
or Noctalia exits. Other `wl-mirror` processes are not affected.
- The plugin makes no network requests.
+360
View File
@@ -0,0 +1,360 @@
--!nonstrict
local opened = false
local requestSerial = 0
local outputs = {}
local destinationOutputs = {}
local sourceName = nil
local destinationName = nil
local sourceIndex = -1
local destinationIndex = -1
local render
local status = noctalia.state.get("mirror_status")
or {
phase = "stopped",
running = false,
available = noctalia.commandExists("wl-mirror"),
command_available = noctalia.commandExists("wl-mirror"),
message = "",
}
local function tr(key, values)
return noctalia.tr(key, values)
end
local function outputLabel(output)
local description = output.description or ""
if description == "" or description == output.name then
return output.name
end
return output.name .. " — " .. description
end
local function findOutputIndex(items, name)
for index, output in ipairs(items) do
if output.name == name then
return index - 1
end
end
return -1
end
local function hasOutput(name)
return name ~= nil and findOutputIndex(outputs, name) >= 0
end
local function rebuildDestinationOutputs()
destinationOutputs = {}
for _, output in ipairs(outputs) do
if output.name ~= sourceName then
table.insert(destinationOutputs, output)
end
end
if findOutputIndex(destinationOutputs, destinationName) < 0 then
destinationName = destinationOutputs[1] and destinationOutputs[1].name or nil
end
destinationIndex = findOutputIndex(destinationOutputs, destinationName)
end
local function refreshOutputs()
local refreshed = noctalia.outputs() or {}
outputs = {}
for _, output in ipairs(refreshed) do
if type(output.name) == "string" and output.name ~= "" then
table.insert(outputs, output)
end
end
table.sort(outputs, function(left, right)
return left.name < right.name
end)
if (status.phase == "running" or status.phase == "starting") and hasOutput(status.source) then
sourceName = status.source
end
if not hasOutput(sourceName) then
sourceName = outputs[1] and outputs[1].name or nil
end
if
(status.phase == "running" or status.phase == "starting")
and status.destination ~= sourceName
and hasOutput(status.destination)
then
destinationName = status.destination
end
sourceIndex = findOutputIndex(outputs, sourceName)
rebuildDestinationOutputs()
end
local function labels(items)
local result = {}
for _, output in ipairs(items) do
table.insert(result, outputLabel(output))
end
return result
end
local function phaseDetails()
local phase = status.phase or "stopped"
if phase == "running" then
return tr("panel.status.running_title"), tr("panel.status.running_message"), "primary", "screen-share"
elseif phase == "starting" then
return tr("panel.status.starting_title"), tr("panel.status.starting_message"), "on_surface_variant", "loader-2"
elseif phase == "stopping" then
return tr("panel.status.stopping_title"), tr("panel.status.stopping_message"), "on_surface_variant", "loader-2"
elseif phase == "error" then
local message = status.message
if message == nil or message == "" then
message = tr("panel.status.error_message")
end
return tr("panel.status.error_title"), message, "error", "alert-circle"
end
local message = status.message
if message == nil or message == "" then
message = tr("panel.status.ready_message")
end
return tr("panel.status.ready_title"), message, "on_surface_variant", "devices"
end
local function section(title, control)
return ui.column({ gap = 7 }, {
ui.label({
text = title,
fontWeight = "medium",
color = "on_surface_variant",
}),
control,
})
end
render = function()
local phase = status.phase or "stopped"
local running = phase == "running"
local busy = phase == "starting" or phase == "stopping"
local available = status.available ~= false
local validPair = sourceName ~= nil and destinationName ~= nil and sourceName ~= destinationName
local canToggle = not busy and (running or (available and validPair))
local stateTitle, stateMessage, stateColor, stateGlyph = phaseDetails()
local actionText = tr("panel.action.start")
local actionGlyph = "player-play"
local actionVariant = "primary"
if running then
actionText = tr("panel.action.stop")
actionGlyph = "player-stop"
actionVariant = "destructive"
elseif phase == "starting" then
actionText = tr("panel.action.starting")
actionGlyph = "loader-2"
elseif phase == "stopping" then
actionText = tr("panel.action.stopping")
actionGlyph = "loader-2"
end
local hint = nil
if not available then
local message = status.message
if status.command_available == false then
message = tr("panel.wl_mirror_missing")
elseif message == nil or message == "" then
message = tr("panel.status.error_message")
end
hint = ui.label({
text = message,
color = "error",
maxLines = 2,
})
elseif #outputs < 2 then
hint = ui.label({
text = tr("panel.two_monitors_required"),
color = "error",
maxLines = 2,
})
end
local children = {
ui.row({ align = "center", justify = "space_between", gap = 8 }, {
ui.row({ align = "center", gap = 9, flexGrow = 1 }, {
ui.glyph({ name = "screen-share", size = 18, color = "primary" }),
ui.label({
text = tr("title"),
fontSize = 16,
fontWeight = "bold",
color = "on_surface",
}),
}),
ui.button({
glyph = "close",
variant = "ghost",
tooltip = tr("panel.close"),
onClick = "onCloseClicked",
}),
}),
section(
tr("panel.source_monitor"),
ui.select({
options = labels(outputs),
selectedIndex = sourceIndex,
placeholder = tr("panel.no_monitor"),
controlSize = "md",
enabled = not busy and not running and #outputs > 0,
onChange = "onSourceChanged",
})
),
section(
tr("panel.destination_monitor"),
ui.select({
options = labels(destinationOutputs),
selectedIndex = destinationIndex,
placeholder = tr("panel.choose_another"),
controlSize = "md",
enabled = not busy and not running and #destinationOutputs > 0,
onChange = "onDestinationChanged",
})
),
ui.row({
align = "center",
gap = 9,
fill = "surface_variant/0.45",
radius = 9,
padding = 10,
}, {
ui.glyph({ name = stateGlyph, size = 17, color = stateColor }),
ui.column({ gap = 2, flexGrow = 1 }, {
ui.label({ text = stateTitle, fontWeight = "medium", color = stateColor }),
ui.label({
text = stateMessage,
color = "on_surface_variant",
maxLines = 2,
}),
}),
}),
}
if hint ~= nil then
table.insert(children, hint)
end
table.insert(
children,
ui.button({
text = actionText,
glyph = actionGlyph,
variant = actionVariant,
controlSize = "lg",
enabled = canToggle,
onClick = "onToggleMirroring",
})
)
panel.render(ui.column({ flexGrow = 1, gap = 14 }, children))
end
noctalia.state.watch("mirror_status", function(value)
if type(value) ~= "table" then
return
end
status = value
if status.phase == "running" or status.phase == "starting" then
sourceName = status.source or sourceName
destinationName = status.destination or destinationName
end
if opened then
refreshOutputs()
render()
end
end)
function onOpen(_context)
opened = true
status = noctalia.state.get("mirror_status") or status
refreshOutputs()
render()
end
function onClose()
opened = false
end
function onSourceChanged(index, _text)
local selected = outputs[(tonumber(index) or -1) + 1]
if selected == nil then
return
end
sourceName = selected.name
sourceIndex = tonumber(index) or -1
rebuildDestinationOutputs()
render()
end
function onDestinationChanged(index, _text)
local selected = destinationOutputs[(tonumber(index) or -1) + 1]
if selected == nil or selected.name == sourceName then
return
end
destinationName = selected.name
destinationIndex = tonumber(index) or -1
render()
end
function onToggleMirroring()
local phase = status.phase or "stopped"
if phase == "running" then
status.phase = "stopping"
status.message = tr("panel.status.stopping_message")
render()
requestSerial += 1
noctalia.state.set("mirror_request", {
action = "stop",
serial = requestSerial,
})
return
end
if phase == "starting" or phase == "stopping" then
return
end
if sourceName == nil or destinationName == nil or sourceName == destinationName then
noctalia.notifyError(tr("title"), tr("panel.invalid_pair"))
return
end
status.phase = "starting"
status.source = sourceName
status.destination = destinationName
status.message = tr("panel.status.starting_message")
render()
requestSerial += 1
noctalia.state.set("mirror_request", {
action = "start",
source = sourceName,
destination = destinationName,
serial = requestSerial,
})
end
function onCloseClicked()
panel.close()
end
+27
View File
@@ -0,0 +1,27 @@
id = "elijaharch/wl-screen-mirror"
name = "Wayland Screen Mirror"
version = "1.0.0"
plugin_api = 3
author = "elijaharch"
license = "MIT"
dependencies = ["wl-mirror"]
tags = ["bar", "panel", "service", "utility", "video"]
icon = "screen-share"
description = "Mirror one Wayland output to another with wl-mirror."
[[widget]]
id = "mirror"
entry = "widget.luau"
[[panel]]
id = "controls"
entry = "panel.luau"
width = 420
height = 390
placement = "attached"
position = "auto"
open_near_click = true
[[service]]
id = "mirror-service"
entry = "service.luau"
+309
View File
@@ -0,0 +1,309 @@
--!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
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+57
View File
@@ -0,0 +1,57 @@
{
"notification": {
"started": "Mirroring {source} to {destination}.",
"stopped": "Stopped mirroring {source} to {destination}."
},
"panel": {
"action": {
"start": "Start mirroring",
"starting": "Starting…",
"stop": "Stop mirroring",
"stopping": "Stopping…"
},
"choose_another": "Choose another monitor",
"close": "Close",
"destination_monitor": "Destination monitor",
"invalid_pair": "Choose two different connected monitors.",
"no_monitor": "No monitor available",
"source_monitor": "Source monitor",
"status": {
"error_message": "Screen mirroring failed.",
"error_title": "Error",
"ready_message": "Choose two different monitors.",
"ready_title": "Ready",
"running_message": "Screen mirroring is active.",
"running_title": "Mirroring",
"starting_message": "Launching wl-mirror…",
"starting_title": "Starting",
"stopping_message": "Stopping wl-mirror…",
"stopping_title": "Stopping"
},
"two_monitors_required": "Connect at least two monitors to start mirroring.",
"wl_mirror_missing": "wl-mirror was not found in PATH."
},
"service": {
"data_unavailable": "The plugin data directory is unavailable.",
"disconnected": "A selected monitor was disconnected.",
"invalid_pair": "Choose two different connected monitors.",
"launch_failed": "Noctalia could not launch wl-mirror.",
"mirror_active": "Screen mirroring is active.",
"mirror_exited": "wl-mirror exited unexpectedly.",
"mirror_failed": "wl-mirror failed to start.",
"mirror_stopped": "Screen mirroring stopped.",
"monitor_missing": "One of the selected monitors is no longer connected.",
"ready": "Choose two different monitors.",
"starting": "Launching wl-mirror…",
"stopping": "Stopping wl-mirror…",
"wl_mirror_missing": "wl-mirror was not found in PATH."
},
"title": "Screen Mirror",
"widget": {
"error": "Screen mirror error",
"ready": "Choose displays and start screen mirroring",
"running": "Mirroring {source} to {destination}",
"starting": "Starting screen mirroring",
"stopping": "Stopping screen mirroring"
}
}
+64
View File
@@ -0,0 +1,64 @@
--!nonstrict
local PANEL_ID = "elijaharch/wl-screen-mirror:controls"
local status = noctalia.state.get("mirror_status") or {
phase = "stopped",
running = false,
}
local function tr(key, values)
return noctalia.tr(key, values)
end
local function render()
local phase = status.phase or "stopped"
barWidget.setText("")
if phase == "running" then
barWidget.setGlyph("screen-share")
barWidget.setGlyphColor("primary")
barWidget.setTooltip(tr("widget.running", {
source = status.source or "?",
destination = status.destination or "?",
}))
elseif phase == "starting" then
barWidget.setGlyph("loader-2")
barWidget.setGlyphColor("on_surface_variant")
barWidget.setTooltip(tr("widget.starting"))
elseif phase == "stopping" then
barWidget.setGlyph("loader-2")
barWidget.setGlyphColor("on_surface_variant")
barWidget.setTooltip(tr("widget.stopping"))
elseif phase == "error" then
barWidget.setGlyph("screen-share-off")
barWidget.setGlyphColor("error")
barWidget.setTooltip(status.message ~= nil and status.message ~= "" and status.message or tr("widget.error"))
else
barWidget.setGlyph("screen-share")
barWidget.setGlyphColor("on_surface")
barWidget.setTooltip(tr("widget.ready"))
end
end
noctalia.state.watch("mirror_status", function(value)
if type(value) == "table" then
status = value
render()
end
end)
function update()
local current = noctalia.state.get("mirror_status")
if type(current) == "table" then
status = current
end
render()
end
function onClick()
noctalia.togglePanel(PANEL_ID)
end
noctalia.setUpdateInterval(1000)
render()