Files

186 lines
5.8 KiB
Luau

--!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: "<epoch> <live-pid|-> <exit-code|->"
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