--!nonstrict -- Headless [[service]]: the single poller behind the OSTT recorder widget. -- -- ostt writes $XDG_RUNTIME_DIR/ostt/recording.pid while the microphone is -- capturing (and keeps the process alive through transcription). This service -- polls that pidfile (liveness-checked — ostt leaves a stale file behind when -- it is killed) plus a wrapper-written exit-code file, derives a phase, and -- publishes it to the plugin state channel: -- -- status = { phase = "idle"|"starting"|"recording"|"transcribing", -- pid = number?, elapsed = seconds } -- -- The recorder widget writes an `intent` record for every action it takes so -- the service can distinguish widget-started sessions (notify on completion, -- report failures) from external ones (`ostt launch` hotkeys — stay silent, -- ostt's own popup already reports those): -- -- intent = { action = "start"|"stop"|"cancel", pid = number?, -- mode = "paste"|"clipboard", notify = boolean } | false -- -- Time comes from the poll shell (`date +%s`), not the Luau VM, so elapsed -- math never depends on os.* availability in the sandbox. local function shq(s) return "'" .. (s:gsub("'", "'\\''")) .. "'" end local runtime = noctalia.getenv("XDG_RUNTIME_DIR") local user = noctalia.getenv("USER") or "unknown" local osttRuntimeDir = runtime and (runtime .. "/ostt") or ("/tmp/ostt-" .. user) local pidFile = osttRuntimeDir .. "/recording.pid" local exitFile = (runtime or "/tmp") .. "/noctalia-ostt-exit" -- Emits one line: " " local pollCmd = table.concat({ "t=$(date +%s)", "p=$(cat " .. shq(pidFile) .. " 2>/dev/null)", 'if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then :; else p=-; fi', "e=$(cat " .. shq(exitFile) .. " 2>/dev/null)", '[ -n "$e" ] || e=-', 'echo "$t $p $e"', }, "; ") local STARTING_GRACE = 6 -- seconds to wait for the pidfile after a start intent local phase = "idle" local since = nil -- epoch when the live recorder was first observed local startingSince = nil -- epoch when the start intent was first observed local polling = false local function getIntent() local v = noctalia.state.get("intent") return type(v) == "table" and v or nil end local function clearIntent() noctalia.state.set("intent", false) end local function publish(newPhase, pid, elapsed) phase = newPhase noctalia.state.set("status", { phase = newPhase, pid = pid, elapsed = elapsed or 0 }) -- Poll faster while a session is in flight so stop/cancel feel immediate. noctalia.setUpdateInterval(newPhase == "idle" and 1000 or 500) end -- Session ended (pid gone). Notify only for widget-started sessions where the -- wrapper reported an exit code; external sessions have neither intent nor -- exit file and stay silent. local function finishSession(exitCode) local intent = getIntent() clearIntent() noctalia.runAsync("rm -f " .. shq(exitFile)) if not intent or intent.action == "cancel" then return end if exitCode == "0" then if intent.notify then local key = intent.mode == "paste" and "notify.done_paste" or "notify.done_clipboard" noctalia.notify(noctalia.tr("title"), noctalia.tr(key)) end elseif exitCode ~= nil then noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notify.failed", { code = exitCode })) end end local function handlePoll(t, pid, exitCode) local intent = getIntent() if pid then startingSince = nil -- Cancel raced the startup: the recorder appeared after the widget gave -- up on it. Kill it instead of adopting it. if intent and intent.action == "cancel" then noctalia.runAsync("kill -TERM " .. pid .. " 2>/dev/null; rm -f " .. shq(pidFile)) clearIntent() publish("idle") return end since = since or t local stopping = intent and intent.action == "stop" and intent.pid == pid publish(stopping and "transcribing" or "recording", pid, t - since) return end -- No live recorder. local wasActive = phase == "recording" or phase == "transcribing" since = nil if wasActive then startingSince = nil finishSession(exitCode) publish("idle") return end if intent and intent.action == "start" then if exitCode then -- The wrapped command exited before the pidfile ever appeared -- (e.g. no audio device, ostt misconfigured). startingSince = nil finishSession(exitCode) publish("idle") else startingSince = startingSince or t if t - startingSince > STARTING_GRACE then startingSince = nil clearIntent() noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notify.start_timeout")) publish("idle") else publish("starting") end end return end -- A cancel that raced startup: keep it briefly so a late-appearing recorder -- still gets killed, then expire it so it can never hit a future session. if intent and intent.action == "cancel" then startingSince = startingSince or t if t - startingSince > STARTING_GRACE then startingSince = nil clearIntent() end publish("idle") return end startingSince = nil if phase ~= "idle" then publish("idle") end end local function doPoll() if polling then return end polling = true local started = noctalia.runAsync(pollCmd, function(res) polling = false if res.exitCode ~= 0 then return end local t, pid, exitCode = string.match(res.stdout, "(%d+)%s+(%S+)%s+(%S+)") if not t then return end handlePoll(tonumber(t), pid ~= "-" and tonumber(pid) or nil, exitCode ~= "-" and exitCode or nil) end) if not started then polling = false end end -- React to widget actions immediately instead of waiting for the next tick. noctalia.state.watch("intent", function() doPoll() end) noctalia.setUpdateInterval(1000) function update() doPoll() end