diff --git a/wl-screen-mirror/README.md b/wl-screen-mirror/README.md new file mode 100644 index 0000000..cf34b9c --- /dev/null +++ b/wl-screen-mirror/README.md @@ -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. diff --git a/wl-screen-mirror/panel.luau b/wl-screen-mirror/panel.luau new file mode 100644 index 0000000..fecff90 --- /dev/null +++ b/wl-screen-mirror/panel.luau @@ -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 diff --git a/wl-screen-mirror/plugin.toml b/wl-screen-mirror/plugin.toml new file mode 100644 index 0000000..45c4bfd --- /dev/null +++ b/wl-screen-mirror/plugin.toml @@ -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" diff --git a/wl-screen-mirror/service.luau b/wl-screen-mirror/service.luau new file mode 100644 index 0000000..e5649db --- /dev/null +++ b/wl-screen-mirror/service.luau @@ -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 diff --git a/wl-screen-mirror/thumbnail.webp b/wl-screen-mirror/thumbnail.webp new file mode 100644 index 0000000..1eaea00 Binary files /dev/null and b/wl-screen-mirror/thumbnail.webp differ diff --git a/wl-screen-mirror/translations/en.json b/wl-screen-mirror/translations/en.json new file mode 100644 index 0000000..f7947a6 --- /dev/null +++ b/wl-screen-mirror/translations/en.json @@ -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" + } +} diff --git a/wl-screen-mirror/widget.luau b/wl-screen-mirror/widget.luau new file mode 100644 index 0000000..7d14142 --- /dev/null +++ b/wl-screen-mirror/widget.luau @@ -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()