Files
community-plugins/screen-toolkit/service.luau
T

1001 lines
35 KiB
Luau

--!nonstrict
-- Screen Toolkit — headless [[service]].
--
-- Owns every tool pipeline (color pick, OCR, QR, palette, lens, annotate,
-- measure, recording, sharing) plus state persistence. The bar [[widget]], the
-- control-center [[shortcut]], and the main [[panel]] are thin clients: they
-- read the published state and drive this service by writing to the plugin's
-- "command" state channel (or by IPC). A capture can be started even with no
-- UI entry placed.
--
-- Region selection shells out to `slurp` (which draws its own crosshair), then
-- `grim` captures the geometry. No custom overlay is possible in the v5 plugin
-- API, so the legacy in-shell region selector is replaced by slurp.
--
-- Recording runs `wl-screenrec` (preferred) or `wf-recorder`. The raw output
-- lands in /tmp as MP4; GIF is produced on save via record.sh convert-gif.
local PANEL_ID = "alexander/screen-toolkit:panel"
local FULL_PANEL_ID = "alexander/screen-toolkit:panel-full"
local LEGACY_PANEL_ID = "alexander/screen-toolkit:panel-legacy"
local RESULT_PANEL_ID = "alexander/screen-toolkit:result"
local SCRIPT_CAPTURE = noctalia.pluginDir() .. "/scripts/capture.sh"
local SCRIPT_OCR = noctalia.pluginDir() .. "/scripts/ocr.sh"
local SCRIPT_LENS = noctalia.pluginDir() .. "/scripts/lens-upload.sh"
local SCRIPT_RECORD = noctalia.pluginDir() .. "/scripts/record.sh"
local SCRIPT_SHARE = noctalia.pluginDir() .. "/scripts/share-upload.sh"
local SCRIPT_PICKER = noctalia.pluginDir() .. "/scripts/color-picker.sh"
local COLOR_PNG = "/tmp/screen-toolkit-colorpicker.png"
local QR_PNG = "/tmp/screen-toolkit-qr.png"
local ANNOTATE_PNG = "/tmp/screen-toolkit-annotate.png"
local MAX_HISTORY = 8
-- ── Small helpers ──────────────────────────────────────────────────────────
local function cfg(key)
return noctalia.getConfig(key)
end
local function log(msg)
noctalia.log(`screen-toolkit: {msg}`)
end
local function trim(s)
return (tostring(s):gsub("^%s+", ""):gsub("%s+$", ""))
end
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", [["'"']]) .. "'"
end
local function copyFileUri(path)
local escaped = tostring(path):gsub(" ", "%%20"):gsub("'", "%%27"):gsub('"', "%%22")
noctalia.copyToClipboard(`file://{escaped}`, "text/uri-list")
end
-- Whether the cursor is excluded from recordings. Grim screenshots exclude the
-- cursor by default, so screenshots do not need a pointer-moving workaround.
-- This also keeps color picking and slurp interaction reliable on Hyprland.
local cursorHidden = true
local CAPTURE_DELAY = 0.15
-- Grim excludes the cursor unless `-c` is passed. Do not move the
-- pointer: that breaks color picking and is compositor-specific. Recorder
-- backends receive their own cursor flag in buildRecorderCommand below.
local function hideCursorForCapture(cmd, cb)
noctalia.runAsync(`sleep {CAPTURE_DELAY}; {cmd}`, cb)
end
local function grimCursorFlag()
return if cursorHidden then "" else "-c"
end
local function captureEnv()
return if cursorHidden then "" else "SCREEN_TOOLKIT_CAPTURE_CURSOR=1 "
end
local function publish(key, value)
noctalia.state.set(key, value)
end
-- `noctalia.outputs()` reports logical geometry + a scale; grim wants physical
-- pixels, so multiply every axis by the output scale.
local function focusedGeometry()
local outputs = noctalia.outputs()
for _, o in ipairs(outputs) do
if o.focused then
local scale = o.scale or 1
return string.format(
"%d,%d %dx%d",
math.floor(o.x * scale),
math.floor(o.y * scale),
math.round(o.width * scale),
math.round(o.height * scale)
)
end
end
if outputs and #outputs > 0 then
local o = outputs[1]
local scale = o.scale or 1
return string.format(
"%d,%d %dx%d",
math.floor(o.x * scale),
math.floor(o.y * scale),
math.round(o.width * scale),
math.round(o.height * scale)
)
end
return nil
end
local function parseGeometry(geom)
local gx, gy, gw, gh = geom:match("^(%d+),(%d+)%s+(%d+)x(%d+)$")
if not gx then return nil end
return tonumber(gx), tonumber(gy), tonumber(gw), tonumber(gh)
end
local function screenshotDir()
local p = noctalia.expandPath(cfg("screenshot-path") or "")
if p == "" then p = noctalia.expandPath("~/Pictures/Screenshots") end
return p
end
local function videoDir()
local p = noctalia.expandPath(cfg("video-path") or "")
if p == "" then p = noctalia.expandPath("~/Videos") end
return p
end
local function buildFilename(tool, ext)
local stem = noctalia.formatTime(cfg("filename-format") or "")
if stem == nil or stem == "" then
stem = tool .. "-" .. noctalia.formatTime("%Y-%m-%d_%H-%M-%S")
end
return stem .. ext
end
-- ── Region selection (slurp) ───────────────────────────────────────────────
local slurping = false
local ocrRunning = false
local ocrSerial = 0
-- Draws the slurp crosshair after a short delay so the just-closed panel's
-- close animation is not captured. Invokes callback(geom) or nothing on cancel.
local function slurpGeometry(callback)
if slurping then
noctalia.notify(noctalia.tr("panel.running"))
return
end
slurping = true
-- slurp blocks for as long as the user is dragging the selection. Give it a
-- generous timeout; the default runAsync one cancels a slow/long drag.
noctalia.runAsync("sleep 0.3 && slurp -f '%x,%y %wx%h'", function(res)
slurping = false
if res.exitCode ~= 0 then return end -- cancelled
local geom = trim(res.stdout or "")
if geom == "" then return end
callback(geom)
end, 600000)
end
local function grimRegion(geom, dest, cb)
hideCursorForCapture(`grim {grimCursorFlag()} -g {shellQuote(geom)} {shellQuote(dest)}`, cb)
end
-- ── Color conversions ──────────────────────────────────────────────────────
local function clamp(n)
return math.max(0, math.min(255, n))
end
local function rgbToHsv(r, g, b)
local rn, gn, bn = r / 255, g / 255, b / 255
local max, min = math.max(rn, gn, bn), math.min(rn, gn, bn)
local d = max - min
local h, sat, val = 0, 0, max
if d ~= 0 then
sat = d / max
if max == rn then
h = ((gn - bn) / d + (gn < bn and 6 or 0)) % 6
elseif max == gn then
h = (bn - rn) / d + 2
else
h = (rn - gn) / d + 4
end
h = math.round(h * 60)
end
return h, math.round(sat * 100), math.round(val * 100)
end
local function rgbToHsl(r, g, b)
local rn, gn, bn = r / 255, g / 255, b / 255
local max, min = math.max(rn, gn, bn), math.min(rn, gn, bn)
local d = max - min
local h, l = 0, (max + min) / 2
local s = if d == 0 then 0 else d / (1 - math.abs(2 * l - 1))
if d ~= 0 then
if max == rn then
h = ((gn - bn) / d + (gn < bn and 6 or 0)) % 6
elseif max == gn then
h = (bn - rn) / d + 2
else
h = (rn - gn) / d + 4
end
h = h / 6
end
return math.floor(h * 360 + 0.5), math.round(s * 100), math.round(l * 100)
end
-- ── Persistence (survives restarts; plugin data dir is update-safe) ────────
local DATA_DIR = noctalia.pluginDataDir()
local RESULTS_FILE = DATA_DIR .. "/results.json"
local HISTORY_FILE = DATA_DIR .. "/color-history.json"
local function loadState()
local raw = noctalia.readFile(RESULTS_FILE)
if raw then
local d = noctalia.json.decode(raw)
if type(d) == "table" then
if d.activeTool then publish("activeTool", d.activeTool) end
if d.colorResult then publish("colorResult", d.colorResult) end
if d.ocrResult then publish("ocrResult", d.ocrResult) end
if d.qrResult then publish("qrResult", d.qrResult) end
if d.paletteColors then publish("paletteColors", d.paletteColors) end
end
end
local hraw = noctalia.readFile(HISTORY_FILE)
if hraw then
local d = noctalia.json.decode(hraw)
if type(d) == "table" then publish("colorHistory", d) end
end
end
local function saveResults()
local d = {
activeTool = noctalia.state.get("activeTool"),
colorResult = noctalia.state.get("colorResult"),
ocrResult = noctalia.state.get("ocrResult"),
qrResult = noctalia.state.get("qrResult"),
paletteColors = noctalia.state.get("paletteColors"),
}
local enc = noctalia.json.encode(d)
if enc then noctalia.writeFile(RESULTS_FILE, enc) end
local h = noctalia.state.get("colorHistory") or {}
local henc = noctalia.json.encode(h)
if henc then noctalia.writeFile(HISTORY_FILE, henc) end
end
local function pushHistory(hex)
hex = hex:upper()
local history = noctalia.state.get("colorHistory") or {}
for i, c in ipairs(history) do
if c == hex then
table.remove(history, i)
break
end
end
table.insert(history, 1, hex)
while #history > MAX_HISTORY do
table.remove(history)
end
publish("colorHistory", history)
end
-- ── Tools: color picker ────────────────────────────────────────────────────
local function runColorPicker()
-- hyprpicker waits for an interactive pointer selection. Keep it on the
-- long-lived stream API; captured runAsync callbacks can time out while the
-- user is still choosing a color.
noctalia.runStream(`sleep {CAPTURE_DELAY}; {captureEnv()}{SCRIPT_PICKER} {shellQuote(COLOR_PNG)}`, function(line)
local r, g, b = tostring(line):match("^(%d+)%s+(%d+)%s+(%d+)$")
if not r then return end
r, g, b = clamp(tonumber(r)), clamp(tonumber(g)), clamp(tonumber(b))
local hex = string.format("#%02X%02X%02X", r, g, b)
local rgb = string.format("rgb(%d, %d, %d)", r, g, b)
local h, s, v = rgbToHsv(r, g, b)
local hh, ss, ll = rgbToHsl(r, g, b)
local hsv = string.format("hsv(%d, %d%%, %d%%)", h, s, v)
local hsl = string.format("hsl(%d, %d%%, %d%%)", hh, ss, ll)
noctalia.copyToClipboard(hex, "text/plain;charset=utf-8")
pushHistory(hex)
publish("colorResult", {
hex = hex, rgb = rgb, hsv = hsv, hsl = hsl,
capturePath = COLOR_PNG, cacheBust = os.time(),
})
publish("activeTool", "colorpicker")
saveResults()
noctalia.togglePanel(RESULT_PANEL_ID)
end)
end
-- ── Tools: OCR ─────────────────────────────────────────────────────────────
local function ocrParams(w, h)
local area = w * h
local upscale = ""
if h < 30 then
upscale = "-resize 400%"
elseif area < 50000 or w < 200 then
upscale = "-resize 200%"
end
local ratio = w / math.max(h, 1)
local psm = "3"
if ratio > 8 then
psm = "7"
elseif area < 60000 then
psm = "6"
elseif h < 40 then
psm = "7"
end
return upscale, psm
end
local OCR_EXIT_KEYS = {
[2] = "ocr.exit_args",
[3] = "ocr.exit_capture",
[4] = "ocr.exit_process",
}
local function runOcr()
if ocrRunning then
noctalia.notify(noctalia.tr("panel.running"))
return
end
slurpGeometry(function(geom)
ocrRunning = true
ocrSerial = ocrSerial + 1
local ocrPath = "/tmp/screen-toolkit-ocr-" .. tostring(os.time()) .. "-" .. tostring(ocrSerial) .. ".png"
local gx, gy, gw, gh = parseGeometry(geom)
if not gx then
ocrRunning = false
return
end
local lang = cfg("selected-ocr-lang") or "eng"
local upscale, psm = ocrParams(gw, gh)
local cmd = captureEnv() .. SCRIPT_OCR .. " " .. gx .. " " .. gy .. " " .. gw .. " " .. gh
.. " " .. shellQuote(lang) .. " " .. shellQuote(upscale) .. " " .. psm
.. " " .. shellQuote(ocrPath)
hideCursorForCapture(cmd, function(res)
ocrRunning = false
if res.exitCode == 1 then
noctalia.notifyError(noctalia.tr("messages.missing_dep", { dep = trim(res.stdout or "") }))
return
elseif res.exitCode ~= 0 then
noctalia.notifyError(noctalia.tr(OCR_EXIT_KEYS[res.exitCode] or "ocr.exit_unknown", { code = tostring(res.exitCode) }))
return
end
local text = trim(res.stdout or "")
if text == "" then
noctalia.notifyError(noctalia.tr("messages.no_text"))
return
end
noctalia.copyToClipboard(text, "text/plain;charset=utf-8")
publish("ocrResult", {
text = text,
capturePath = ocrPath,
cacheBust = tostring(os.time()) .. "-" .. tostring(ocrSerial),
gw = tonumber(gw),
gh = tonumber(gh),
})
publish("activeTool", "ocr")
saveResults()
noctalia.togglePanel(RESULT_PANEL_ID)
end)
end)
end
local function ocrSearch(payload)
local result = noctalia.state.get("ocrResult")
local text = if type(payload) == "table" and type(payload.text) == "string"
then payload.text
else type(result) == "table" and result.text or nil
if not text or text == "" then return end
local url = cfg("search-engine-url")
if url == nil or url == "" then
url = "https://www.google.com/search?q="
end
local encoded = noctalia.string.urlEncode(text)
if url:find("{text}", 1, true) then
url = url:gsub("{text}", function() return encoded end)
else
url = url .. encoded
end
noctalia.runAsync("xdg-open " .. shellQuote(url))
end
local function ocrTranslate(payload)
local result = noctalia.state.get("ocrResult")
local text = if type(payload) == "table" and type(payload.text) == "string"
then payload.text
else type(result) == "table" and result.text or nil
if not text or text == "" then return end
if not noctalia.commandExists("trans") then
noctalia.notifyError(noctalia.tr("messages.missing_dep", { dep = "translate-shell" }))
return
end
publish("translateResult", nil)
local targetLang = if type(payload) == "table" then payload.lang else payload
local lang = if type(targetLang) == "string" and targetLang ~= "" then targetLang else "en"
noctalia.runAsync(`trans -brief -to {shellQuote(lang)} {shellQuote(text)}`, function(res)
if res.exitCode ~= 0 then
publish("translateResult", noctalia.tr("messages.translate_failed"))
return
end
publish("translateResult", trim(res.stdout or ""))
end)
end
-- ── Tools: QR / palette / lens ─────────────────────────────────────────────
local function runQr()
slurpGeometry(function(geom)
hideCursorForCapture(captureEnv() .. SCRIPT_CAPTURE .. " qr " .. shellQuote(geom), function(res)
if res.exitCode ~= 0 or trim(res.stdout or "") == "" then
noctalia.notifyError(noctalia.tr("messages.no_qr"))
return
end
local text = trim(res.stdout or "")
noctalia.copyToClipboard(text, "text/plain;charset=utf-8")
publish("qrResult", { text = text, capturePath = QR_PNG })
publish("activeTool", "qr")
saveResults()
noctalia.togglePanel(RESULT_PANEL_ID)
end)
end)
end
local function runPalette()
slurpGeometry(function(geom)
hideCursorForCapture(captureEnv() .. SCRIPT_CAPTURE .. " palette " .. shellQuote(geom), function(res)
if res.exitCode ~= 0 then
noctalia.notifyError(noctalia.tr("messages.palette_failed"))
return
end
local colors = {}
for line in (res.stdout or ""):gmatch("[^\n]+") do
local c = trim(line)
if c:match("^#%x%x%x%x%x%x$") then
colors[#colors + 1] = c:upper()
end
end
local seen, out = {}, {}
for _, c in ipairs(colors) do
if not seen[c] then
seen[c] = true
out[#out + 1] = c
end
end
if #out == 0 then
noctalia.notifyError(noctalia.tr("messages.palette_failed"))
return
end
noctalia.copyToClipboard(table.concat(out, "\n"), "text/plain;charset=utf-8")
publish("paletteColors", out)
publish("activeTool", "palette")
saveResults()
noctalia.togglePanel(RESULT_PANEL_ID)
end)
end)
end
local function runLens()
slurpGeometry(function(geom)
local gx, gy, gw, gh = parseGeometry(geom)
if not gx then return end
noctalia.runAsync(captureEnv() .. SCRIPT_LENS .. " " .. gx .. " " .. gy .. " " .. gw .. " " .. gh, function(res)
if res.exitCode ~= 0 then
noctalia.notifyError(noctalia.tr("messages.lens_failed"))
end
end)
end)
end
-- ── Tools: measure ─────────────────────────────────────────────────────────
local function runMeasure()
slurpGeometry(function(geom)
local gx, gy, gw, gh = parseGeometry(geom)
if not gx or not gw or not gh then return end
local text = string.format("%d x %d px", gw, gh)
noctalia.copyToClipboard(text, "text/plain;charset=utf-8")
publish("measureResult", { width = gw, height = gh, text = text })
noctalia.notify(noctalia.tr("messages.measure_result", { width = tostring(gw), height = tostring(gh) }))
end)
end
-- ── Tools: annotate (external editor handoff) ──────────────────────────────
local function openAnnotator(file)
publish("activeTool", "annotate")
publish("annotateResult", { capturePath = file })
if noctalia.commandExists("swappy") then
noctalia.runAsync(`swappy -f {shellQuote(file)}`)
elseif noctalia.commandExists("satty") then
local destDir = screenshotDir()
local out = `{destDir}/{buildFilename("annotate", ".png")}`
noctalia.runAsync(`satty --filename {shellQuote(file)} --output-filename {shellQuote(out)}`)
elseif noctalia.commandExists("gimp") then
noctalia.runAsync(`gimp {shellQuote(file)}`)
else
-- No annotation editor installed: hand the capture over via the clipboard
-- so the tool still does something useful.
copyFileUri(file)
noctalia.notify(noctalia.tr("messages.no_annotator"))
return
end
end
local function annotateRegion()
slurpGeometry(function(geom)
grimRegion(geom, ANNOTATE_PNG, function(res)
if res.exitCode ~= 0 then
noctalia.notifyError(noctalia.tr("messages.capture_failed"))
return
end
openAnnotator(ANNOTATE_PNG)
end)
end)
end
local function annotateFullscreen()
local out = noctalia.focusedOutputName()
local cmd
-- Give transient UI a moment to clear before the fullscreen grab.
if out and out ~= "" then
cmd = `sleep 0.2 && grim {grimCursorFlag()} -o {shellQuote(out)} {shellQuote(ANNOTATE_PNG)}`
else
cmd = `sleep 0.2 && grim {grimCursorFlag()} {shellQuote(ANNOTATE_PNG)}`
end
hideCursorForCapture(cmd, function(res)
if res.exitCode ~= 0 then
noctalia.notifyError(noctalia.tr("messages.capture_failed"))
return
end
openAnnotator(ANNOTATE_PNG)
end)
end
local function annotateWindow()
if not noctalia.commandExists("hyprctl") then
noctalia.notifyError(noctalia.tr("messages.missing_dep", { dep = "hyprctl" }))
return
end
noctalia.runAsync(`sleep {CAPTURE_DELAY}; {captureEnv()}{SCRIPT_CAPTURE} annotate-window`, function(res)
if res.exitCode ~= 0 then
noctalia.notifyError(noctalia.tr("messages.capture_failed"))
return
end
openAnnotator(ANNOTATE_PNG)
end)
end
-- ── Recording ──────────────────────────────────────────────────────────────
-- Recorder availability. `gpu-screen-recorder` (NVENC) is the best choice on
-- NVIDIA GPUs but can only capture monitors/windows — it cannot record an
-- arbitrary region — so fullscreen capture prefers it while region capture
-- falls back to wl-screenrec / wf-recorder.
local gsrAvailable = false
local wlScreenrecAvailable = false
local wfRecorderAvailable = false
local lastRecorderLog = ""
local recordState = "idle" -- idle | recording | converting | ready
local recordFormat = "mp4"
local rawPath = ""
local recordPath = ""
local recordStartedAt = nil
local function refreshRecorders()
gsrAvailable = noctalia.commandExists("gpu-screen-recorder")
wlScreenrecAvailable = noctalia.commandExists("wl-screenrec")
wfRecorderAvailable = noctalia.commandExists("wf-recorder")
local key = string.format(
"%s|%s|%s", tostring(gsrAvailable), tostring(wlScreenrecAvailable), tostring(wfRecorderAvailable)
)
if key ~= lastRecorderLog then
lastRecorderLog = key
log("recorders: " .. key)
end
end
local function bestFullscreenRecorder()
if gsrAvailable then return "gpu-screen-recorder" end
if wlScreenrecAvailable then return "wl-screenrec" end
if wfRecorderAvailable then return "wf-recorder" end
return ""
end
local function bestRegionRecorder()
if wlScreenrecAvailable then return "wl-screenrec" end
if wfRecorderAvailable then return "wf-recorder" end
return ""
end
local function setRecordState(next)
if recordState == next then return end
recordState = next
publish("recordState", recordState)
publish("recording", recordState == "recording")
publish("recordPath", recordPath)
publish("recordFormat", recordFormat)
publish("recordStartedAt", if recordState == "recording" then recordStartedAt else nil)
end
local overrideAudioOut = nil
local overrideAudioIn = nil
local function getAudioOut()
if overrideAudioOut ~= nil then return overrideAudioOut end
return cfg("record-audio-out") == true
end
local function getAudioIn()
if overrideAudioIn ~= nil then return overrideAudioIn end
return cfg("record-audio-in") == true
end
local function gsrAudioFlags()
local audioOut = getAudioOut()
local audioIn = getAudioIn()
if audioOut and audioIn then
return '-a "default_output|default_input" -ac aac'
elseif audioOut then
return "-a default_output -ac aac"
elseif audioIn then
return "-a default_input -ac aac"
end
return ""
end
local function buildRecorderCommand(bin, geom, out)
local audioOut, audioIn = getAudioOut(), getAudioIn()
local isRegion = geom and geom ~= "" and geom ~= "screen"
local fps = cfg("record-fps") or 60
local codec = cfg("record-codec") or "h264"
if bin == "gpu-screen-recorder" then
local cursor = cursorHidden and "no" or "yes"
local target = "-w screen"
if isRegion then
local gx, gy, gw, gh = tostring(geom):match("^(%d+),(%d+) (%d+)x(%d+)$")
if gx then target = string.format("-w region -region %sx%s+%s+%s", gw, gh, gx, gy) end
end
return string.format("gpu-screen-recorder %s -f %d -k %s -bm qp -ffmpeg-opts qp=25 -cursor %s -cr limited %s -v no -o %s", target, fps, codec, cursor, gsrAudioFlags(), shellQuote(out))
end
local isWl = bin == "wl-screenrec"
local parts = { bin }
if isRegion then table.insert(parts, "-g " .. shellQuote(geom)) end
table.insert(parts, "-f " .. shellQuote(out))
if isWl then
table.insert(parts, "-m " .. tostring(fps))
local wlCodec = codec == "h264" and "avc" or codec
table.insert(parts, "--codec " .. wlCodec)
if cursorHidden then table.insert(parts, "--no-cursor") end
else
table.insert(parts, "-r " .. tostring(fps))
end
if audioOut and audioIn then
table.insert(parts, isWl and "--audio" or "-a")
elseif audioOut then
table.insert(parts, isWl and "--audio --audio-device '$(pactl get-default-sink 2>/dev/null).monitor'" or "-a -C '$(pactl get-default-sink 2>/dev/null).monitor'")
elseif audioIn then
table.insert(parts, isWl and "--audio --audio-device '$(pactl get-default-source 2>/dev/null)'" or "-a -C '$(pactl get-default-source 2>/dev/null)'")
end
return table.concat(parts, " ")
end
local function startRecord(geom, format, bin)
refreshRecorders()
if bin == "" then
noctalia.notifyError(noctalia.tr("messages.no_recorder"))
return
end
-- clean up old thumb so it doesn't stay stuck in preview
if recordState ~= "idle" then return end
rawPath = `/tmp/screen-toolkit-record-{os.time()}.mp4`
recordFormat = if format == "gif" then "gif" else "mp4"
publish("recordFormat", recordFormat)
recordPath = ""
local cmd = buildRecorderCommand(bin, geom, rawPath)
log(`starting recording: {cmd}`)
if cmd == "" then
rawPath = ""
setRecordState("idle")
noctalia.notifyError(noctalia.tr("messages.recording_failed"))
return
end
-- Launch detached (no callback): the recorder keeps running after runAsync
-- returns, exactly like the built-in screen recorder does. A tracked
-- (callback) runAsync owns the command's process group and tears it down
-- when the foreground shell exits, killing a backgrounded recorder. The stop
-- path signals it by matching its unique output path in the command line.
if not noctalia.runAsync(cmd) then
rawPath = ""
setRecordState("idle")
noctalia.notifyError(noctalia.tr("messages.recording_failed"))
return
end
recordStartedAt = os.time()
setRecordState("recording")
noctalia.notify(noctalia.tr("plugin_name"), noctalia.tr("messages.recording_started"))
end
local function waitForFileStable(path, callback, attempts, previousSize)
attempts = attempts or 60
noctalia.runAsync(`stat -c %s {shellQuote(path)} 2>/dev/null`, function(res)
local size = trim(res.stdout or "")
local numSize = tonumber(size)
if res.exitCode == 0 and numSize and numSize > 0 then
if previousSize == numSize then
callback(true)
return
end
noctalia.runAsync("sleep 0.3", function()
waitForFileStable(path, callback, attempts - 1, numSize)
end)
return
end
if attempts <= 0 then
callback(numSize ~= nil and numSize > 0)
return
end
noctalia.runAsync("sleep 0.2", function()
waitForFileStable(path, callback, attempts - 1, previousSize)
end)
end)
end
local function parseRecordInfo(raw)
local d, wh, size = tostring(raw):match("([%d%.]+)|(%d+x%d+)|(%d+)")
local w, h = 0, 0
if wh then
local ww, hh = wh:match("(%d+)x(%d+)")
w, h = tonumber(ww) or 0, tonumber(hh) or 0
end
return {
duration = tonumber(d) or 0,
width = w,
height = h,
size = tonumber(size) or 0,
}
end
local function finalizeRecord(format, copyToClipboard)
if rawPath == "" then return end
local destDir = videoDir()
local ext
if format == "gif" then ext = ".gif" elseif format == "mov" then ext = ".mov" else ext = ".mp4" end
local dest = destDir .. "/" .. buildFilename("record", ext)
setRecordState("converting")
local cmd
if format == "gif" then
cmd = SCRIPT_RECORD .. " convert-gif " .. shellQuote(rawPath) .. " " .. shellQuote(dest)
.. " " .. tostring(cfg("gif-max-seconds") or 0)
elseif format == "mov" then
cmd = SCRIPT_RECORD .. " convert-mov " .. shellQuote(rawPath) .. " " .. shellQuote(dest)
else
cmd = SCRIPT_RECORD .. " convert-mp4 " .. shellQuote(rawPath) .. " " .. shellQuote(dest)
end
noctalia.runAsync(cmd, function(res)
if res.exitCode ~= 0 then
setRecordState("idle")
noctalia.notifyError(noctalia.tr("messages.recording_failed"))
return
end
recordPath = dest
rawPath = ""
setRecordState("idle")
if copyToClipboard then
copyFileUri(dest)
noctalia.notify(noctalia.tr("panel.copied_to_clipboard"), dest)
else
noctalia.notify(noctalia.tr("messages.saved_to", { path = dest }))
end
end, 600000)
end
local function recordStop()
if recordState ~= "recording" then return end
setRecordState("converting")
noctalia.notify(noctalia.tr("plugin_name"), noctalia.tr("messages.recording_stopped"))
-- rawPath is unique per recording, so pkill scopes to our recorder only and
-- never touches the built-in screen recorder's process. The transient pkill
-- wrapper matching itself is harmless (it still signals the recorder first).
-- use [/]tmp regex trick so pkill doesn't match and kill its own shell process
local stopCommand = `pkill -INT -f {rawPath}`
noctalia.runAsync(stopCommand, function()
waitForFileStable(rawPath, function(ok)
if not ok then
setRecordState("idle")
noctalia.notifyError(noctalia.tr("messages.recording_failed"))
return
end
local skipConfirm = cfg("record-skip-confirmation") == true
local toClipboard = cfg("record-copy-to-clipboard") == true
if toClipboard then
finalizeRecord(recordFormat, true)
elseif skipConfirm then
finalizeRecord(recordFormat, false)
else
-- Extract a mid-frame thumbnail so the result panel can preview the
-- recording, then probe it for size / duration / resolution.
noctalia.runAsync(SCRIPT_RECORD .. " thumb " .. shellQuote(rawPath), function()
local probe = string.format(
"D=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 %s 2>/dev/null); "
.. "WH=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 %s 2>/dev/null); "
.. "S=$(stat -c %%s %s 2>/dev/null); printf '%%s|%%s|%%s' \"$D\" \"$WH\" \"$S\"",
shellQuote(rawPath), shellQuote(rawPath), shellQuote(rawPath)
)
noctalia.runAsync(probe, function(res)
publish("recordInfo", parseRecordInfo(res.stdout or ""))
setRecordState("ready")
noctalia.togglePanel(RESULT_PANEL_ID)
end)
end)
end
end)
end)
end
local function recordDiscard()
if rawPath ~= "" then
noctalia.runAsync(`rm -f {shellQuote(rawPath)}`)
rawPath = ""
end
-- clean up old thumb so it doesn't stay stuck in preview
setRecordState("idle")
end
-- A recording that finished but was never saved is superseded the moment the
-- user runs any other capture tool; drop its temp file so the result panel
-- can't get stuck showing the stale "save recording" card.
local function discardPendingRecording()
if recordState ~= "ready" then return end
recordDiscard()
end
local SHARE_EXIT_KEYS = {
[1] = "messages.share_bad_args",
[2] = "messages.share_file_not_found",
[3] = "messages.share_missing_dep",
[4] = "messages.share_request_failed",
[5] = "messages.share_invalid_response",
[6] = "messages.share_file_too_large",
}
-- ── Sharing ────────────────────────────────────────────────────────────────
local function shareFile(file)
if file == nil or file == "" then
noctalia.notifyError(noctalia.tr("messages.share_bad_args"))
return
end
local apiKey = cfg("x02-api-key") or ""
local expiry = cfg("x02-expiry") or "7d"
noctalia.runAsync(
SCRIPT_SHARE .. " " .. shellQuote(file) .. " " .. shellQuote(apiKey) .. " " .. shellQuote(expiry),
function(res)
if res.exitCode ~= 0 then
local msg = SHARE_EXIT_KEYS[res.exitCode]
noctalia.notifyError(if msg then noctalia.tr(msg) else noctalia.tr("messages.share_unknown_error"))
return
end
local url = trim(res.stdout or "")
if url == "" then
noctalia.notifyError(noctalia.tr("messages.share_invalid_response"))
return
end
noctalia.copyToClipboard(url, "text/plain;charset=utf-8")
noctalia.notify(noctalia.tr("panel.share_url"), url)
end
)
end
-- ── Result housekeeping ────────────────────────────────────────────────────
local function clearResult()
publish("activeTool", nil)
publish("colorResult", nil)
publish("ocrResult", nil)
publish("translateResult", nil)
publish("qrResult", nil)
publish("paletteColors", nil)
publish("annotateResult", nil)
saveResults()
end
local function clearHistory()
publish("colorHistory", {})
saveResults()
end
local function setCursorHidden(payload)
cursorHidden = payload ~= "false"
publish("cursorHidden", cursorHidden)
log(`cursor hidden: {tostring(cursorHidden)}`)
end
-- ── Dispatch ───────────────────────────────────────────────────────────────
local function runRecordRegion(format)
refreshRecorders()
local bin = bestRegionRecorder()
if bin == "" then noctalia.notifyError(noctalia.tr("messages.no_recorder")) return end
slurpGeometry(function(geom) startRecord(geom, format, bin) end)
end
local function runRecordFullscreen(format)
refreshRecorders()
local bin = bestFullscreenRecorder()
if bin == "" then noctalia.notifyError(noctalia.tr("messages.no_recorder")) return end
startRecord("screen", format, bin)
end
local HANDLERS = {
colorPicker = runColorPicker, ocr = runOcr, qr = runQr, palette = runPalette, lens = runLens, measure = runMeasure,
annotate = annotateRegion, annotateFullscreen = annotateFullscreen, annotateWindow = annotateWindow,
record = function() runRecordRegion("gif") end, recordMp4 = function() runRecordRegion("mp4") end,
recordFullscreen = function() runRecordFullscreen("gif") end, recordFullscreenMp4 = function() runRecordFullscreen("mp4") end,
recordStop = recordStop,
recordSave = function(payload) finalizeRecord((type(payload) == "table" and payload.format or nil) or recordFormat, false) end,
recordCopy = function() finalizeRecord(recordFormat, true) end, recordDiscard = recordDiscard,
ocrSearch = ocrSearch, ocrTranslate = ocrTranslate, share = shareFile, clearResult = clearResult, clearHistory = clearHistory, setCursorHidden = setCursorHidden,
setAudioOut = function(payload) overrideAudioOut = (type(payload) == "table" and payload.value or payload) == true end,
setAudioIn = function(payload) overrideAudioIn = (type(payload) == "table" and payload.value or payload) == true end,
toggle = function()
local mode = cfg("panel-mode")
local id = mode == "full" and FULL_PANEL_ID or (mode == "legacy" and LEGACY_PANEL_ID or PANEL_ID)
noctalia.togglePanel(id)
end,
}
-- Tools that begin a brand-new capture. Starting one discards any recording
-- that finished but was never saved, so the result panel shows the new tool's
-- result instead of the stale "save recording" card.
local CAPTURE_STARTS = {
colorPicker = true, ocr = true, qr = true, palette = true, lens = true,
measure = true, annotate = true, annotateFullscreen = true, annotateWindow = true,
record = true, recordMp4 = true, recordFullscreen = true, recordFullscreenMp4 = true,
}
local function dispatch(event, payload)
if event == nil then return end
local handler = HANDLERS[event]
if handler then
if CAPTURE_STARTS[event] then discardPendingRecording() end
log(`ipc '{event}'`)
handler(payload)
else
log(`unknown command '{event}'`)
end
end
-- UI entries drive the service through this channel; the CLI uses onIpc.
noctalia.state.watch("command", function(cmd)
if type(cmd) == "table" then
dispatch(cmd.action, cmd.payload)
elseif type(cmd) == "string" then
dispatch(cmd)
end
end)
function onIpc(event, payload)
dispatch(event, payload)
end
-- ── Boot ───────────────────────────────────────────────────────────────────
local function boot()
noctalia.mkdirAll(DATA_DIR)
loadState()
cursorHidden = cfg("hide-cursor") ~= false
publish("cursorHidden", cursorHidden)
refreshRecorders()
end
-- Services stay alive while plugin settings change. Keep capture and recorder
-- flags in sync without requiring the user to restart Noctalia.
function onConfigChanged()
local nextCursorHidden = cfg("hide-cursor") ~= false
if nextCursorHidden ~= cursorHidden then
cursorHidden = nextCursorHidden
publish("cursorHidden", cursorHidden)
end
refreshRecorders()
end
boot()