Files
community-plugins/gslapper/service.luau
T
RAMAandGitHub 132c9fe00b Add gSlapper video wallpaper plugin (#152)
* feat: add gSlapper wallpaper plugin

* docs: clarify gSlapper installation and testing

* docs: state Noctalia v5 requirement

* docs: use generated plugin thumbnail

* fix: keep gSlapper videos playing by default
2026-07-29 21:03:53 -04:00

1700 lines
58 KiB
Luau

--!nonstrict
local assignments = {}
local runtime = {}
local generations = {}
local pending = {}
local capabilities = { video = false, checking = true }
local previousConnectors = {}
local lastHandledNonce = 0
local commandBatches = {}
local activeRequests = {}
local restartSettingsSignature = ""
local NOTIFICATION_TITLE = "gSlapper"
local IMAGE_EXTENSIONS = { jpg = true, jpeg = true, png = true, webp = true, jxl = true, bmp = true }
local VIDEO_EXTENSIONS = { mp4 = true, mkv = true, webm = true, mov = true, avi = true, m4v = true, gif = true }
local LIBRARY_BATCH_SIZE = 6
local THUMBNAIL_LIMIT = 2
local libraryGeneration = 0
local libraryVersion = 0
local libraryTasks = {}
local librarySources = {}
local libraryErrors = {}
local libraryProcessed = 0
local libraryBytes = 0
local libraryConsumedBytes = 0
local libraryInvalidUtf8 = 0
local thumbnailQueue = {}
local thumbnailActive = 0
local thumbnailReady = 0
local thumbnailDone = 0
local thumbnailTotal = 0
local thumbnailsAvailable = false
local libraryRootSignature = ""
local REQUIRED_FLAGS = {
"--fork",
"--ipc-socket",
"--no-save-state",
"--auto-pause",
"--auto-stop",
"--fps-cap",
"--gst-options",
"--transition-type",
"--transition-duration",
}
local VIDEO_CHANGE_RESTART_ERROR = "ERROR: cannot update path (use --auto-stop for video changes)"
local function copyTable(value)
local result = {}
for key, item in pairs(value or {}) do
result[key] = item
end
return result
end
local function copyList(values)
local result = {}
for index, value in ipairs(values or {}) do
result[index] = value
end
return result
end
local function validateShellValue(value)
if type(value) ~= "string" or value:find("[\n\r%z]") then
return nil, "value contains a forbidden control character"
end
return value
end
local function shellQuote(value)
local valid, message = validateShellValue(value)
if not valid then
return nil, message
end
return "'" .. value:gsub("'", "'\\''") .. "'"
end
local function trimTrailingSlash(path)
if type(path) ~= "string" then
return nil
end
path = path:gsub("/+$", "")
return path == "" and "/" or path
end
local function mediaRoots()
local image = trimTrailingSlash(noctalia.wallpaperDirectory())
local configured = noctalia.getConfig("video_directory")
local video = trimTrailingSlash(type(configured) == "string" and noctalia.expandPath(configured) or nil)
return image, video
end
local function validMediaRoot(path)
return type(path) == "string" and path:sub(1, 1) == "/" and validateShellValue(path)
end
local function classifyMedia(name)
if type(name) ~= "string" then
return nil
end
local extension = name:match("%.([^./]+)$")
extension = extension and extension:lower() or nil
if extension == "gif" then
return "video"
elseif extension and IMAGE_EXTENSIONS[extension] then
return "image"
elseif extension and VIDEO_EXTENSIONS[extension] then
return "video"
end
return nil
end
local function fnv1a32(value)
-- ponytail: 32-bit FNV keeps cache names short; use a wider digest if a real collision appears.
local hash = 2166136261
for index = 1, #value do
hash = bit32.bxor(hash, value:byte(index))
local low = (hash % 65536) * 16777619
local high = (math.floor(hash / 65536) * 16777619) % 65536
hash = (low + high * 65536) % 4294967296
end
return string.format("%08x", hash)
end
local function thumbnailPath(path, size, mtime)
local directory = noctalia.pluginDataDir()
if not directory then
return nil
end
return directory .. "/thumb-" .. fnv1a32("v3:" .. path .. ":" .. tostring(size) .. ":" .. tostring(mtime)) .. ".jpg"
end
local function publishLibraryStatus(phase, errorMessage)
noctalia.state.set("library_status", {
phase = phase,
generation = libraryGeneration,
version = libraryVersion,
processed = libraryProcessed,
bytes = libraryBytes,
consumed_bytes = libraryConsumedBytes,
skipped_invalid_utf8 = libraryInvalidUtf8,
thumbnails_ready = thumbnailReady,
thumbnails_done = thumbnailDone,
thumbnails_total = thumbnailTotal,
error = errorMessage,
})
end
local function nextNulField(raw, offset)
local boundary = raw:find("\0", offset, true)
if not boundary then
return nil, offset
end
return raw:sub(offset, boundary - 1), boundary + 1
end
local function isWithinRoot(path, root)
return path == root or path:sub(1, #root + 1) == root .. "/"
end
local function acceptsMedia(rootKind, kind, name)
if rootKind == "mixed" or rootKind == "video" then
return rootKind == "mixed" or kind == "video"
end
return kind == "image" or name:lower():match("%.gif$") ~= nil
end
local startThumbnailJobs
local function finishLibraryIndex(generation)
if generation ~= libraryGeneration then
return
end
libraryVersion = libraryVersion + 1
local imageRoot, videoRoot = mediaRoots()
noctalia.state.set("library", {
version = libraryVersion,
roots = { image = imageRoot, video = videoRoot },
sources = librarySources,
})
thumbnailTotal = thumbnailReady + #thumbnailQueue
thumbnailDone = thumbnailReady
if thumbnailTotal == thumbnailReady then
publishLibraryStatus("ready", #libraryErrors > 0 and table.concat(libraryErrors, "; ") or nil)
else
publishLibraryStatus("thumbnails", #libraryErrors > 0 and table.concat(libraryErrors, "; ") or nil)
startThumbnailJobs(generation)
end
end
local scheduleLibraryBatch
local function processLibraryBatch(generation)
if generation ~= libraryGeneration then
return
end
local remaining = LIBRARY_BATCH_SIZE
while remaining > 0 and #libraryTasks > 0 do
local task = libraryTasks[1]
local previousOffset = task.offset
local fileType
fileType, task.offset = nextNulField(task.raw, task.offset)
if not fileType then
table.remove(libraryTasks, 1)
else
local size
local mtime
local path
size, task.offset = nextNulField(task.raw, task.offset)
mtime, task.offset = nextNulField(task.raw, task.offset)
path, task.offset = nextNulField(task.raw, task.offset)
libraryConsumedBytes = libraryConsumedBytes + task.offset - previousOffset
remaining = remaining - 1
libraryProcessed = libraryProcessed + 1
if path and utf8.len(path) == nil then
libraryInvalidUtf8 = libraryInvalidUtf8 + 1
elseif path and validateShellValue(path) and isWithinRoot(path, task.root) then
local name = path:match("([^/]+)$")
if fileType == "f" then
local kind = classifyMedia(name)
if kind and acceptsMedia(task.rootKind, kind, name) then
local numericSize = tonumber(size) or 0
local numericMtime = tonumber(mtime) or 0
local thumbnail = thumbnailPath(path, numericSize, numericMtime)
local entry = { path = path, thumbnail = thumbnail }
if thumbnail and noctalia.fileExists(thumbnail) then
thumbnailReady = thumbnailReady + 1
elseif thumbnail and thumbnailsAvailable then
table.insert(thumbnailQueue, entry)
end
end
end
end
end
end
if #libraryTasks == 0 then
finishLibraryIndex(generation)
else
publishLibraryStatus("scanning")
scheduleLibraryBatch(generation)
end
end
scheduleLibraryBatch = function(generation)
local started = noctalia.runAsync(":", function()
processLibraryBatch(generation)
end, 2000)
if not started and generation == libraryGeneration then
publishLibraryStatus("error", "Noctalia command capacity is busy")
end
end
startThumbnailJobs = function(generation)
while generation == libraryGeneration and thumbnailActive < THUMBNAIL_LIMIT and #thumbnailQueue > 0 do
local entry = table.remove(thumbnailQueue, 1)
thumbnailActive = thumbnailActive + 1
local temporary = entry.thumbnail .. ".tmp-" .. tostring(generation)
local command = "gst-launch-1.0 -q filesrc location=" .. shellQuote(entry.path)
.. " ! decodebin ! videoconvert ! videoscale"
.. " ! video/x-raw,width=224,height=126,pixel-aspect-ratio=1/1"
.. " ! jpegenc quality=80 snapshot=true ! filesink location=" .. shellQuote(temporary)
local started = noctalia.runAsync(command, function(result)
if generation ~= libraryGeneration then
noctalia.removeFile(temporary)
return
end
thumbnailActive = thumbnailActive - 1
if result.exitCode == 0 and noctalia.fileExists(temporary)
and noctalia.renameFile(temporary, entry.thumbnail) then
thumbnailReady = thumbnailReady + 1
else
noctalia.removeFile(temporary)
end
thumbnailDone = thumbnailDone + 1
if #thumbnailQueue == 0 and thumbnailActive == 0 then
publishLibraryStatus("ready", #libraryErrors > 0 and table.concat(libraryErrors, "; ") or nil)
elseif thumbnailDone % 8 == 0 then
publishLibraryStatus("thumbnails", #libraryErrors > 0 and table.concat(libraryErrors, "; ") or nil)
end
startThumbnailJobs(generation)
end, 15000)
if not started then
thumbnailActive = thumbnailActive - 1
thumbnailDone = thumbnailDone + 1
if #thumbnailQueue == 0 and thumbnailActive == 0 then
publishLibraryStatus("ready", "could not start thumbnail generation")
end
end
end
end
local function startLibraryIndex()
local imageRoot, videoRoot = mediaRoots()
local signature = tostring(imageRoot or "") .. "\0" .. tostring(videoRoot or "")
libraryRootSignature = signature
libraryGeneration = libraryGeneration + 1
local generation = libraryGeneration
libraryTasks = {}
librarySources = {}
libraryErrors = {}
libraryProcessed = 0
libraryBytes = 0
libraryConsumedBytes = 0
libraryInvalidUtf8 = 0
thumbnailQueue = {}
thumbnailActive = 0
thumbnailReady = 0
thumbnailDone = 0
thumbnailTotal = 0
thumbnailsAvailable = noctalia.commandExists("gst-launch-1.0")
publishLibraryStatus("scanning")
local roots = {}
if imageRoot and videoRoot == imageRoot then
if validMediaRoot(imageRoot) then
table.insert(roots, { root = imageRoot, rootKind = "mixed" })
else
table.insert(libraryErrors, "Wallpaper directory must be an absolute path without control characters")
end
else
if imageRoot then
if validMediaRoot(imageRoot) then
table.insert(roots, { root = imageRoot, rootKind = "image" })
else
table.insert(libraryErrors, "Wallpaper directory must be an absolute path without control characters")
end
end
if videoRoot then
if validMediaRoot(videoRoot) then
table.insert(roots, { root = videoRoot, rootKind = "video" })
else
table.insert(libraryErrors, "Video directory must be an absolute path without control characters")
end
end
end
if #roots == 0 then
publishLibraryStatus("error", #libraryErrors > 0 and table.concat(libraryErrors, "; ")
or "No wallpaper directories are configured")
return
end
local pendingRoots = #roots
for _, root in ipairs(roots) do
local quoted = shellQuote(root.root)
local command = "find -P " .. quoted .. " -mindepth 1 -printf '%y\\0%s\\0%T@\\0%p\\0'"
local started = noctalia.runAsync(command, function(result)
if generation ~= libraryGeneration then
return
end
pendingRoots = pendingRoots - 1
if result.exitCode == 0 then
local raw = result.stdout or ""
local indexPath = noctalia.pluginDataDir() .. "/library-" .. root.rootKind .. ".index"
noctalia.writeFile(indexPath, raw)
libraryBytes = libraryBytes + #raw
table.insert(librarySources, {
root = root.root,
rootKind = root.rootKind,
path = indexPath,
})
table.insert(libraryTasks, {
root = root.root,
rootKind = root.rootKind,
raw = raw,
offset = 1,
})
else
table.insert(libraryErrors, result.stderr ~= "" and result.stderr or (root.root .. " is unreadable"))
end
if pendingRoots == 0 then
scheduleLibraryBatch(generation)
end
end, 30000)
if not started then
pendingRoots = pendingRoots - 1
table.insert(libraryErrors, "could not scan " .. root.root)
end
end
if pendingRoots == 0 then
scheduleLibraryBatch(generation)
end
end
local function runtimeDirectory()
local base = noctalia.getenv("XDG_RUNTIME_DIR")
if not validateShellValue(base) or base == "" or base:sub(1, 1) ~= "/" then
return nil, "XDG_RUNTIME_DIR is unavailable"
end
return base .. "/noctalia-gslapper"
end
local function socketPathFor(connector, directory)
if type(connector) ~= "string" or connector == "" or type(directory) ~= "string" then
return nil
end
local safeConnector = connector:gsub("[^A-Za-z0-9_.-]", "_")
return directory .. "/" .. safeConnector .. ".sock"
end
local function isOwnedSocket(path, directory)
if type(path) ~= "string" or type(directory) ~= "string" then
return false
end
local prefix = directory .. "/"
if path:sub(1, #prefix) ~= prefix then
return false
end
local filename = path:sub(#prefix + 1)
return filename ~= "" and not filename:find("/", 1, true) and filename:match("^[A-Za-z0-9_.-]+%.sock$") ~= nil
end
local function processMatchNeedles(socketPath)
return { "gslapper", "--ipc-socket", socketPath }
end
local function hiddenFlags(behavior)
if behavior == "auto-pause" then
return { "--auto-pause" }
elseif behavior == "auto-stop" then
return { "--auto-stop" }
end
return {}
end
local function trim(value)
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
end
local function parseIpcResponse(command, raw)
raw = trim(raw or "")
if command == "query" then
local status, kind, path = raw:match("^STATUS: (%S+) (%S+) (.+)$")
if (status == "playing" or status == "paused") and (kind == "image" or kind == "video") then
return { ok = true, status = status, kind = kind, path = path, raw = raw }
end
elseif raw == "OK" or raw:sub(1, 4) == "OK: " then
return { ok = true, raw = raw }
elseif command == "change" and raw == VIDEO_CHANGE_RESTART_ERROR then
return { ok = false, restart = true, error = raw, raw = raw }
end
return { ok = false, error = raw ~= "" and raw or "gSlapper returned no response", raw = raw }
end
local COMMAND_ACTIONS = {
["assign-image"] = true,
["assign-video"] = true,
["pause"] = true,
["resume"] = true,
["restore"] = true,
["restore-all"] = true,
["self-test"] = true,
}
local function validateCommand(command)
if type(command) ~= "table" or type(command.nonce) ~= "number" or command.nonce % 1 ~= 0 then
return false, "command nonce must be an integer"
end
if not COMMAND_ACTIONS[command.action] then
return false, "unknown command action"
end
if type(command.target) ~= "string" or command.target == "" then
return false, "command target is required"
end
if command.action == "assign-image" or command.action == "assign-video" then
if type(command.path) ~= "string" or command.path:sub(1, 1) ~= "/" or command.path == "/"
or not validateShellValue(command.path) then
return false, "assignment path must be absolute"
end
end
return true
end
local function summarizeResults(results)
local summary = { total = 0, successful = 0, failed = 0, pending = 0 }
for _, result in pairs(results or {}) do
summary.total = summary.total + 1
if result.pending then
summary.pending = summary.pending + 1
elseif result.ok then
summary.successful = summary.successful + 1
else
summary.failed = summary.failed + 1
end
end
return summary
end
local function targetsFor(target, connectors)
if target ~= "*" then
return { target }
end
local targets = copyList(connectors)
table.sort(targets)
return targets
end
local function commandTargets(action, target, connectors, durable)
local targets = targetsFor(target, connectors)
if (action ~= "pause" and action ~= "resume") or target ~= "*" then
return targets
end
local videos = {}
for _, connector in ipairs(targets) do
if durable[connector] and durable[connector].kind == "video" then
table.insert(videos, connector)
end
end
return videos
end
local function detectedConnectors()
local connectors = {}
for _, output in ipairs(noctalia.outputs()) do
if type(output.name) == "string" and output.name ~= "" then
table.insert(connectors, output.name)
end
end
table.sort(connectors)
return connectors
end
local function connectorDetected(connector)
if type(connector) ~= "string" or connector == "" then
return false
end
for _, detected in ipairs(detectedConnectors()) do
if connector == detected then
return true
end
end
return false
end
local function connectorBusy(connector)
return pending[connector] ~= nil or (runtime[connector] and runtime[connector].status == "starting")
end
local function nextGeneration(connector, store)
store = store or generations
store[connector] = (store[connector] or 0) + 1
return store[connector]
end
local function isCurrent(connector, generation, store)
store = store or generations
return store[connector] == generation
end
local function generationGuardedSet(store, generationStore, connector, generation, value)
if not isCurrent(connector, generation, generationStore) then
return false
end
store[connector] = value
return true
end
local function connectorSet(connectors)
local result = {}
for _, connector in ipairs(connectors) do
result[connector] = true
end
return result
end
local function ownedConnectors(durable, liveState)
local seen = {}
local connectors = {}
for connector in pairs(durable or {}) do
seen[connector] = true
table.insert(connectors, connector)
end
for connector in pairs(liveState or {}) do
if not seen[connector] then
table.insert(connectors, connector)
end
end
table.sort(connectors)
return connectors
end
local function reconcileOutputs(previousConnectors, currentConnectors, durable, live)
local previous = connectorSet(previousConnectors)
local current = connectorSet(currentConnectors)
local stops = {}
local restores = {}
for connector in pairs(previous) do
if not current[connector] then
live[connector] = nil
table.insert(stops, connector)
end
end
for connector in pairs(current) do
local assignment = durable[connector]
if not previous[connector] and assignment and assignment.kind == "video" then
table.insert(restores, connector)
end
end
table.sort(stops)
table.sort(restores)
return stops, restores
end
local function applyAssignmentBatch(target, connectors, assignment, durable, apply)
local results = {}
for _, connector in ipairs(targetsFor(target, connectors)) do
local ok, message = apply(connector, assignment)
results[connector] = { ok = ok, error = ok and nil or message }
if ok then
durable[connector] = copyTable(assignment)
end
end
return results
end
local function validAbsolutePath(path)
return type(path) == "string" and path:sub(1, 1) == "/" and path ~= "/"
end
local function validateAssignment(connector, record)
if type(connector) ~= "string" or connector == "" or type(record) ~= "table" then
return nil
end
if record.kind ~= "image" and record.kind ~= "video" then
return nil
end
if not validAbsolutePath(record.path) then
return nil
end
if record.desired_playback ~= "playing" and record.desired_playback ~= "paused" then
return nil
end
if record.preview_path ~= nil and not validAbsolutePath(record.preview_path) then
return nil
end
return {
kind = record.kind,
path = record.path,
preview_path = record.preview_path,
desired_playback = record.desired_playback,
}
end
local function assignmentsPath()
local directory = noctalia.pluginDataDir()
if not directory then
return nil
end
return directory .. "/assignments.json"
end
local function loadAssignments()
local path = assignmentsPath()
if not path then
return {}
end
local contents = noctalia.readFile(path)
if not contents then
return {}
end
local decoded, decodeError = noctalia.json.decode(contents)
if type(decoded) ~= "table" or type(decoded.assignments) ~= "table" then
noctalia.log("Ignoring invalid assignments file: " .. tostring(decodeError or "invalid shape"))
return {}
end
local loaded = {}
for connector, record in pairs(decoded.assignments) do
local valid = validateAssignment(connector, record)
if valid then
loaded[connector] = valid
end
end
return loaded
end
local function saveAssignments()
local path = assignmentsPath()
if not path then
return false, "plugin data directory is unavailable"
end
-- ponytail: assignments are rewritten as one small JSON object; split storage
-- only if real configurations make write size measurable.
local encoded, encodeError = noctalia.json.encode({ version = 1, assignments = assignments }, true)
if not encoded then
return false, encodeError or "could not encode assignments"
end
local temporaryPath = path .. ".tmp"
local written, writeError = noctalia.writeFile(temporaryPath, encoded)
if not written then
return false, writeError or "could not write assignments"
end
local renamed, renameError = noctalia.renameFile(temporaryPath, path)
if not renamed then
noctalia.removeFile(temporaryPath)
return false, renameError or "could not replace assignments"
end
return true
end
local function publishRuntime()
noctalia.state.set("runtime", runtime)
end
local function publishAssignments()
noctalia.state.set("assignments", assignments)
end
local function publishBatch(batch)
local record = {
nonce = batch.nonce,
action = batch.action,
results = batch.results,
summary = summarizeResults(batch.results),
}
noctalia.state.set("command_result", record)
end
local function completeRequest(connector, ok, message)
local request = activeRequests[connector]
if not request then
return
end
activeRequests[connector] = nil
local batch = commandBatches[request.nonce]
if not batch then
return
end
batch.results[connector] = { ok = ok, error = ok and nil or message }
publishBatch(batch)
if summarizeResults(batch.results).pending == 0 then
commandBatches[request.nonce] = nil
end
end
local function setPending(connector, operation)
pending[connector] = operation
noctalia.setUpdateInterval(100)
end
local function clearPending(connector, operation)
if operation == nil or pending[connector] == operation then
pending[connector] = nil
end
if next(pending) == nil then
noctalia.setUpdateInterval(1000)
end
end
local function validateMediaPath(path)
local valid, message = validateShellValue(path)
if not valid or path:sub(1, 1) ~= "/" or path == "/" then
return nil, message or "media path must be absolute"
end
local info, infoError = noctalia.fileInfo(path)
if not info or info.isDir then
return nil, infoError or "media path is not a file"
end
return path
end
local function ipcCommand(socketPath, command, callback)
if not isOwnedSocket(socketPath, runtimeDirectory()) then
callback({ ok = false, error = "refusing IPC outside the plugin runtime directory" })
return false
end
if not validateShellValue(command) then
callback({ ok = false, error = "IPC command contains a forbidden control character" })
return false
end
local quotedCommand = shellQuote(command)
local quotedSocket = shellQuote(socketPath)
local shellCommand = "printf '%s\\n' " .. quotedCommand .. " | socat -T 2 - UNIX-CONNECT:" .. quotedSocket
local started = noctalia.runAsync(shellCommand, function(result)
if result.exitCode ~= 0 then
callback({
ok = false,
error = trim(result.stderr ~= "" and result.stderr or "gSlapper IPC failed"),
notConnectable = not result.timedOut,
raw = result.stdout,
})
return
end
callback(parseIpcResponse(command:match("^(%S+)") or command, result.stdout))
end, 2000)
if not started then
callback({ ok = false, retryable = true, error = "Noctalia command capacity is busy" })
end
return started
end
local function normalizedSettings()
local scale = noctalia.getConfig("scale")
if scale ~= "fill" and scale ~= "stretch" and scale ~= "original" and scale ~= "panscan" then
scale = "fill"
end
if scale == "panscan" then
scale = "panscan=1.0"
end
local hidden = noctalia.getConfig("hidden_behavior")
if hidden ~= "none" and hidden ~= "auto-pause" and hidden ~= "auto-stop" then
hidden = "none"
end
local fps = tostring(noctalia.getConfig("fps_cap") or "30")
if fps ~= "30" and fps ~= "60" and fps ~= "100" then
fps = "30"
end
local duration = tonumber(noctalia.getConfig("fade_duration")) or 0.5
duration = math.max(0.1, math.min(5.0, duration))
return {
scale = scale,
hidden = hidden,
loop = noctalia.getConfig("loop") ~= false,
fps = fps,
fade = noctalia.getConfig("fade") == true,
fadeDuration = duration,
gstOptions = tostring(noctalia.getConfig("gst_options") or ""),
}
end
local function buildLaunchCommand(connector, path, socketPath)
local directory = runtimeDirectory()
if not directory or not isOwnedSocket(socketPath, directory) then
return nil, "invalid plugin socket path"
end
local settings = normalizedSettings()
if not validateShellValue(settings.gstOptions) then
return nil, "GStreamer options contain a forbidden control character"
end
local gst = settings.scale .. " no-audio"
if settings.loop then
gst = gst .. " loop"
end
if settings.gstOptions ~= "" then
gst = gst .. " " .. settings.gstOptions
end
local arguments = {
"gslapper",
"--fork",
"--ipc-socket",
socketPath,
"--no-save-state",
}
for _, flag in ipairs(hiddenFlags(settings.hidden)) do
table.insert(arguments, flag)
end
table.insert(arguments, "--gst-options")
table.insert(arguments, gst)
table.insert(arguments, "--fps-cap")
table.insert(arguments, settings.fps)
table.insert(arguments, "--transition-type")
table.insert(arguments, settings.fade and "fade" or "none")
table.insert(arguments, "--transition-duration")
table.insert(arguments, tostring(settings.fadeDuration))
table.insert(arguments, connector)
table.insert(arguments, path)
local quoted = {}
for index, argument in ipairs(arguments) do
local value, message = shellQuote(argument)
if not value then
return nil, message
end
quoted[index] = value
end
return table.concat(quoted, " ")
end
local function setConnectorError(connector, generation, message)
if not isCurrent(connector, generation) then
return
end
clearPending(connector)
runtime[connector] = { status = "error", error = message }
noctalia.setWallpaperEnabled(connector, true)
publishRuntime()
completeRequest(connector, false, message)
end
local function setLiveError(connector, generation, message)
if not isCurrent(connector, generation) then
return
end
clearPending(connector)
local live = runtime[connector] or {}
runtime[connector] = {
status = "error",
error = message,
socket_path = live.socket_path,
}
publishRuntime()
completeRequest(connector, false, message)
end
local stopOwned
local function commitVideo(connector, generation, assignment, query)
if not isCurrent(connector, generation) then
return
end
if not query.ok or query.kind ~= "video" or query.path ~= assignment.path then
setConnectorError(connector, generation, query.error or "gSlapper reported unexpected media")
return
end
if assignment.desired_playback == "paused" and query.status ~= "paused" then
local live = runtime[connector]
ipcCommand(live.socket_path, "pause", function(paused)
if not isCurrent(connector, generation) then
return
end
if not paused.ok then
setConnectorError(connector, generation, paused.error)
return
end
ipcCommand(live.socket_path, "query", function(pausedQuery)
commitVideo(connector, generation, assignment, pausedQuery)
end)
end)
return
end
local previous = assignments[connector]
assignments[connector] = copyTable(assignment)
local saved, saveError = saveAssignments()
if not saved then
assignments[connector] = previous
setConnectorError(connector, generation, saveError)
stopOwned(connector, generation)
return
end
clearPending(connector)
noctalia.setWallpaperEnabled(connector, false)
runtime[connector] = {
status = query.status,
error = nil,
socket_path = runtime[connector] and runtime[connector].socket_path,
}
publishAssignments()
publishRuntime()
completeRequest(connector, true)
end
local function awaitVideo(connector, generation, assignment, socketPath)
local operation = {
kind = "ready",
generation = generation,
assignment = assignment,
socketPath = socketPath,
deadline = noctalia.nowMs() + 5000,
inFlight = false,
}
setPending(connector, operation)
end
local function launchVideo(connector, generation, assignment)
if not isCurrent(connector, generation) then
return
end
local directory, directoryError = runtimeDirectory()
if not directory then
setConnectorError(connector, generation, directoryError)
return
end
local made, makeError = noctalia.mkdirAll(directory)
if not made then
setConnectorError(connector, generation, makeError or "could not create runtime directory")
return
end
local socketPath = socketPathFor(connector, directory)
local command, commandError = buildLaunchCommand(connector, assignment.path, socketPath)
if not command then
setConnectorError(connector, generation, commandError)
return
end
runtime[connector] = { status = "starting", socket_path = socketPath }
publishRuntime()
local started = noctalia.runAsync(command, function(result)
if not isCurrent(connector, generation) then
return
end
if result.exitCode ~= 0 then
setConnectorError(connector, generation, trim(result.stderr ~= "" and result.stderr or "gSlapper failed to start"))
return
end
awaitVideo(connector, generation, assignment, socketPath)
end, 5000)
if not started then
setConnectorError(connector, generation, "Noctalia command capacity is busy")
end
end
local function regexEscape(value)
return (value:gsub("([^%w])", "%%%1"))
end
local function processFallback(connector, generation, socketPath, after)
local directory = runtimeDirectory()
if not directory or not isOwnedSocket(socketPath, directory) then
setConnectorError(connector, generation, "refusing process fallback for an unowned socket")
return
end
local needles = processMatchNeedles(socketPath)
local started = noctalia.processMatches(function(matched)
if not isCurrent(connector, generation) then
return
end
if not matched then
if after then
after()
end
return
end
local pattern = "[g]slapper.*--ipc-socket[ =]" .. regexEscape(socketPath)
local command = "pkill -TERM -f -- " .. shellQuote(pattern)
local killStarted = noctalia.runAsync(command, function()
if isCurrent(connector, generation) and after then
after()
end
end, 2000)
if not killStarted and after then
after()
end
end, needles[1], needles[2], needles[3])
if not started and after then
after()
end
end
stopOwned = function(connector, generation, after)
if not isCurrent(connector, generation) then
return
end
local directory = runtimeDirectory()
local socketPath = directory and socketPathFor(connector, directory)
if not socketPath or not noctalia.fileExists(socketPath) then
if socketPath then
processFallback(connector, generation, socketPath, after)
elseif after then
after()
end
return
end
ipcCommand(socketPath, "stop", function()
if not isCurrent(connector, generation) then
return
end
setPending(connector, {
kind = "stop",
generation = generation,
socketPath = socketPath,
deadline = noctalia.nowMs() + 2000,
after = after,
inFlight = false,
})
end)
end
local function changeVideo(connector, generation, assignment, socketPath)
ipcCommand(socketPath, "change " .. assignment.path, function(change)
if not isCurrent(connector, generation) then
return
end
if change.ok then
awaitVideo(connector, generation, assignment, socketPath)
elseif change.restart then
stopOwned(connector, generation, function()
launchVideo(connector, generation, assignment)
end)
else
setLiveError(connector, generation, change.error)
end
end)
end
local function assignVideo(connector, path, previewPath, desiredPlayback)
if not connectorDetected(connector) then
return false, "output is not connected"
end
if connectorBusy(connector) then
return false, "output switch is already in progress"
end
local validPath, pathError = validateMediaPath(path)
if not validPath then
return false, pathError
end
if previewPath ~= nil and not validateMediaPath(previewPath) then
previewPath = nil
end
if not capabilities.video then
return false, capabilities.error or "video support is unavailable"
end
local generation = nextGeneration(connector)
local assignment = {
kind = "video",
path = validPath,
preview_path = previewPath,
desired_playback = desiredPlayback == "paused" and "paused" or "playing",
}
if previewPath then
noctalia.setWallpaper(connector, previewPath)
end
local directory = runtimeDirectory()
local socketPath = directory and socketPathFor(connector, directory)
if socketPath and noctalia.fileExists(socketPath) then
runtime[connector] = { status = "starting", socket_path = socketPath }
publishRuntime()
ipcCommand(socketPath, "query", function(query)
if not isCurrent(connector, generation) then
return
end
if query.ok and query.kind == "video" and query.path == assignment.path then
commitVideo(connector, generation, assignment, query)
elseif query.ok then
changeVideo(connector, generation, assignment, socketPath)
elseif query.notConnectable then
if isOwnedSocket(socketPath, directory) then
noctalia.removeFile(socketPath)
end
processFallback(connector, generation, socketPath, function()
launchVideo(connector, generation, assignment)
end)
else
setLiveError(connector, generation, query.error)
end
end)
else
if socketPath then
processFallback(connector, generation, socketPath, function()
launchVideo(connector, generation, assignment)
end)
else
launchVideo(connector, generation, assignment)
end
end
return true
end
local function assignImage(connector, path)
if not connectorDetected(connector) then
return false, "output is not connected"
end
if connectorBusy(connector) then
return false, "output switch is already in progress"
end
local validPath, pathError = validateMediaPath(path)
if not validPath then
return false, pathError
end
local generation = nextGeneration(connector)
local previous = assignments[connector]
noctalia.setWallpaper(connector, validPath)
assignments[connector] = {
kind = "image",
path = validPath,
desired_playback = "playing",
}
local saved, saveError = saveAssignments()
if not saved then
assignments[connector] = previous
return false, saveError
end
noctalia.setWallpaperEnabled(connector, true)
runtime[connector] = { status = "static" }
publishAssignments()
publishRuntime()
stopOwned(connector, generation)
completeRequest(connector, true)
return true
end
local function setPlayback(connector, desired)
local assignment = assignments[connector]
local live = runtime[connector]
if not assignment or assignment.kind ~= "video" or not live or not live.socket_path then
return false, "output has no active video"
end
if connectorBusy(connector) then
return false, "output switch is already in progress"
end
local generation = nextGeneration(connector)
local previousDesired = assignment.desired_playback
live.status = "starting"
live.error = nil
publishRuntime()
ipcCommand(live.socket_path, desired == "paused" and "pause" or "resume", function(result)
if not isCurrent(connector, generation) or not result.ok then
if isCurrent(connector, generation) and not result.ok then
setLiveError(connector, generation, result.error)
end
return
end
ipcCommand(live.socket_path, "query", function(query)
if not isCurrent(connector, generation) then
return
end
if not query.ok or query.status ~= desired then
setLiveError(connector, generation, query.error or "playback state did not change")
return
end
assignment.desired_playback = desired
local saved, saveError = saveAssignments()
if not saved then
assignment.desired_playback = previousDesired
setLiveError(connector, generation, saveError)
return
end
runtime[connector].status = query.status
publishAssignments()
publishRuntime()
completeRequest(connector, true)
end)
end)
return true
end
local function restoreConnector(connector)
if not connectorDetected(connector) then
return false, "output is not connected"
end
if connectorBusy(connector) then
return false, "output switch is already in progress"
end
local generation = nextGeneration(connector)
local previous = assignments[connector]
assignments[connector] = nil
local saved, saveError = saveAssignments()
if not saved then
assignments[connector] = previous
return false, saveError
end
noctalia.setWallpaperEnabled(connector, true)
runtime[connector] = { status = "static" }
publishAssignments()
publishRuntime()
stopOwned(connector, generation, function()
completeRequest(connector, true)
end)
return true
end
local function restoreAssignment(connector)
local assignment = assignments[connector]
if not assignment then
return
end
if assignment.kind == "image" then
runtime[connector] = { status = "static" }
noctalia.setWallpaperEnabled(connector, true)
publishRuntime()
elseif capabilities.video then
assignVideo(connector, assignment.path, assignment.preview_path, assignment.desired_playback)
else
runtime[connector] = { status = "error", error = capabilities.error or "video support is unavailable" }
noctalia.setWallpaperEnabled(connector, true)
publishRuntime()
end
end
local function restoreConnectedAssignments()
previousConnectors = detectedConnectors()
noctalia.state.set("outputs", previousConnectors)
for _, connector in ipairs(previousConnectors) do
restoreAssignment(connector)
end
end
local function settingsSignature()
local settings = normalizedSettings()
return table.concat({
settings.scale,
settings.hidden,
tostring(settings.loop),
settings.fps,
tostring(settings.fade),
tostring(settings.fadeDuration),
settings.gstOptions,
}, "\0")
end
local function restartActiveVideos()
for _, connector in ipairs(detectedConnectors()) do
local assignment = assignments[connector]
local live = runtime[connector]
if assignment and assignment.kind == "video" and live and not connectorBusy(connector) then
local generation = nextGeneration(connector)
live.status = "starting"
live.error = nil
publishRuntime()
stopOwned(connector, generation, function()
launchVideo(connector, generation, copyTable(assignment))
end)
end
end
end
local function reconcileDetectedOutputs()
local current = detectedConnectors()
local stops, restores = reconcileOutputs(previousConnectors, current, assignments, runtime)
previousConnectors = current
noctalia.state.set("outputs", current)
for _, connector in ipairs(stops) do
completeRequest(connector, false, "output disconnected")
local generation = nextGeneration(connector)
noctalia.setWallpaperEnabled(connector, true)
stopOwned(connector, generation)
end
for _, connector in ipairs(restores) do
restoreAssignment(connector)
end
publishRuntime()
end
local capabilitiesReady
local function probeCapabilities()
if not noctalia.commandExists("gslapper") then
capabilities = { video = false, checking = false, error = "gSlapper is not installed" }
noctalia.state.set("capabilities", capabilities)
if capabilitiesReady then
capabilitiesReady()
end
return
end
if not noctalia.commandExists("socat") then
capabilities = { video = false, checking = false, error = "socat is not installed" }
noctalia.state.set("capabilities", capabilities)
if capabilitiesReady then
capabilitiesReady()
end
return
end
noctalia.state.set("capabilities", capabilities)
local started = noctalia.runAsync("gslapper --help", function(result)
local missing = {}
local help = result.stdout .. result.stderr
for _, flag in ipairs(REQUIRED_FLAGS) do
if not help:find(flag, 1, true) then
table.insert(missing, flag)
end
end
if result.exitCode == 0 and #missing == 0 then
capabilities = { video = true, checking = false, tested_version = "1.5.2" }
else
capabilities = {
video = false,
checking = false,
error = "gSlapper is missing required IPC options: " .. table.concat(missing, ", "),
}
end
noctalia.state.set("capabilities", capabilities)
if capabilitiesReady then
capabilitiesReady()
end
end, 2000)
if not started then
capabilities = { video = false, checking = false, error = "could not run the gSlapper compatibility check" }
noctalia.state.set("capabilities", capabilities)
if capabilitiesReady then
capabilitiesReady()
end
end
end
local function runModelChecks()
local connectors = { "eDP-1", "DP-1", "DP-3" }
local durable = {
["eDP-1"] = { kind = "image", path = "/wallpapers/still.jpg", desired_playback = "playing" },
["DP-1"] = { kind = "video", path = "/wallpapers/motion.mp4", desired_playback = "paused" },
}
assert(durable["eDP-1"].path ~= durable["DP-1"].path, "outputs lost independent assignments")
local targets = targetsFor("*", connectors)
assert(table.concat(targets, ",") == "DP-1,DP-3,eDP-1", "All target was not sorted")
local testGenerations = {}
local testRuntime = {}
local stale = nextGeneration("DP-1", testGenerations)
local current = nextGeneration("DP-1", testGenerations)
assert(not generationGuardedSet(testRuntime, testGenerations, "DP-1", stale, "stale"), "stale generation mutated state")
assert(generationGuardedSet(testRuntime, testGenerations, "DP-1", current, "ready"), "current generation did not mutate state")
local live = { ["DP-1"] = { status = "playing" } }
local stops = reconcileOutputs(connectors, { "eDP-1", "DP-3" }, durable, live)
assert(durable["DP-1"] ~= nil and live["DP-1"] == nil and stops[1] == "DP-1", "disconnect did not retain durable state")
local _, restores = reconcileOutputs({ "eDP-1", "DP-3" }, connectors, durable, live)
assert(restores[1] == "DP-1", "reconnect did not schedule retained video")
local batchDurable = {}
local results = applyAssignmentBatch("*", connectors, durable["DP-1"], batchDurable, function(connector)
if connector == "DP-1" then
return false, "fixture failure"
end
return true
end)
assert(not results["DP-1"].ok, "fixture failure was not reported")
assert(batchDurable["eDP-1"] ~= nil and batchDurable["DP-3"] ~= nil, "partial failure erased successful targets")
assert(shellQuote("/tmp/a b's.mp4") == "'/tmp/a b'\\''s.mp4'", "apostrophe quoting failed")
assert(validateShellValue("line\nbreak") == nil and validateShellValue("nul\0byte") == nil, "control character accepted")
assert(validMediaRoot("/wallpapers") and not validMediaRoot("wallpapers")
and not validMediaRoot("/wall\npapers"), "media root validation failed")
local testDirectory = "/run/user/1000/noctalia-gslapper"
local socketPath = socketPathFor("../DP/1", testDirectory)
assert(socketPath == testDirectory .. "/.._DP_1.sock", "connector escaped runtime directory")
assert(isOwnedSocket(socketPath, testDirectory) and not isOwnedSocket("/tmp/DP-1.sock", testDirectory), "socket ownership failed")
local needles = processMatchNeedles(socketPath)
assert(needles[1] == "gslapper" and needles[3] == socketPath, "process match omitted exact socket")
assert(#hiddenFlags("none") == 0 and #hiddenFlags("auto-pause") == 1 and #hiddenFlags("auto-stop") == 1, "hidden flags conflict")
local query = parseIpcResponse("query", "STATUS: paused video /tmp/a b.mp4\n")
assert(query.ok and query.status == "paused" and query.path == "/tmp/a b.mp4", "query response parsing failed")
assert(parseIpcResponse("pause", "OK\n").ok, "success response parsing failed")
assert(parseIpcResponse("change", VIDEO_CHANGE_RESTART_ERROR .. "\n").restart, "video-change recovery parsing failed")
assert(validateCommand({ nonce = 1, action = "assign-video", target = "*", path = "/tmp/a.mp4" }), "valid command rejected")
assert(not validateCommand({ nonce = 1, action = "unknown", target = "*" }), "unknown command accepted")
assert(not validateCommand({ nonce = 1, action = "assign-video", target = "*", path = "relative.mp4" }), "relative path accepted")
local summary = summarizeResults({
["DP-1"] = { ok = true },
["DP-3"] = { ok = false, error = "fixture" },
["eDP-1"] = { pending = true },
})
assert(summary.total == 3 and summary.successful == 1 and summary.failed == 1 and summary.pending == 1, "batch summary failed")
local newOutputDurable = { ["DP-1"] = durable["DP-1"] }
local _, newOutputRestores = reconcileOutputs({ "DP-1" }, { "DP-1", "DP-3" }, newOutputDurable, {})
assert(#newOutputRestores == 0, "unassigned new output received a video")
local allThenOne = {}
applyAssignmentBatch("*", connectors, durable["DP-1"], allThenOne, function()
return true
end)
allThenOne["DP-1"] = copyTable(durable["eDP-1"])
assert(allThenOne["DP-3"].kind == "video" and allThenOne["eDP-1"].kind == "video", "individual override changed other outputs")
local pauseTargets = commandTargets("pause", "*", connectors, {
["DP-1"] = { kind = "video" },
["DP-3"] = { kind = "image" },
["eDP-1"] = { kind = "video" },
})
assert(table.concat(pauseTargets, ",") == "DP-1,eDP-1", "All pause targeted non-video outputs")
assert(table.concat(ownedConnectors({ ["DP-1"] = {} }, { ["DP-3"] = {} }), ",") == "DP-1,DP-3", "teardown omitted durable output")
end
local function publishSelfTest(report)
local directory = noctalia.pluginDataDir()
if directory then
local encoded = noctalia.json.encode(report, true)
if encoded then
noctalia.writeFile(directory .. "/selftest.json", encoded)
end
end
noctalia.state.set("self_test", report)
end
local function runSelfTest(quiet)
local ok, message = pcall(runModelChecks)
local report = {
passed = ok,
checks = 22,
error = not ok and tostring(message) or nil,
}
if not quiet then
publishSelfTest(report)
if ok then
noctalia.notify(NOTIFICATION_TITLE, "Self-test passed.")
else
noctalia.notifyError(NOTIFICATION_TITLE, report.error)
error(report.error)
end
end
return report
end
local function handleCommand(command)
local valid, validationError = validateCommand(command)
if not valid or command.nonce <= lastHandledNonce then
return false, validationError or "command nonce was already handled"
end
lastHandledNonce = command.nonce
if command.action == "self-test" then
local report = runSelfTest(true)
noctalia.state.set("command_result", {
nonce = command.nonce,
action = command.action,
results = { self_test = { ok = report.passed, error = report.error } },
summary = { total = 1, successful = report.passed and 1 or 0, failed = report.passed and 0 or 1, pending = 0 },
})
publishSelfTest(report)
return report.passed, report.error
end
local connectors = detectedConnectors()
local target = command.action == "restore-all" and "*" or command.target
local targets = commandTargets(command.action, target, connectors, assignments)
local batch = { nonce = command.nonce, action = command.action, results = {} }
commandBatches[command.nonce] = batch
for _, connector in ipairs(targets) do
if activeRequests[connector] or connectorBusy(connector) then
batch.results[connector] = { ok = false, error = "output switch is already in progress" }
else
batch.results[connector] = { pending = true }
activeRequests[connector] = { nonce = command.nonce, action = command.action }
local ok, message
if command.action == "assign-image" then
ok, message = assignImage(connector, command.path)
elseif command.action == "assign-video" then
ok, message = assignVideo(connector, command.path, command.preview_path)
elseif command.action == "pause" then
ok, message = setPlayback(connector, "paused")
elseif command.action == "resume" then
ok, message = setPlayback(connector, "playing")
else
ok, message = restoreConnector(connector)
end
if not ok then
activeRequests[connector] = nil
batch.results[connector] = { ok = false, error = message }
end
end
end
publishBatch(batch)
if summarizeResults(batch.results).pending == 0 then
commandBatches[command.nonce] = nil
end
return true
end
function onIpc(event, _payload)
if event == "self-test" then
runSelfTest(false)
end
end
function onOutputsChanged()
reconcileDetectedOutputs()
end
function onConfigChanged()
local currentSignature = settingsSignature()
if restartSettingsSignature ~= "" and currentSignature ~= restartSettingsSignature then
restartActiveVideos()
end
restartSettingsSignature = currentSignature
local imageRoot, videoRoot = mediaRoots()
local rootsSignature = tostring(imageRoot or "") .. "\0" .. tostring(videoRoot or "")
if noctalia.pluginDataDir() and rootsSignature ~= libraryRootSignature then
startLibraryIndex()
end
end
function onExit()
local directory = runtimeDirectory()
if not directory then
return
end
for _, connector in ipairs(ownedConnectors(assignments, runtime)) do
local live = runtime[connector] or {}
noctalia.setWallpaperEnabled(connector, true)
local socketPath = live.socket_path or socketPathFor(connector, directory)
if isOwnedSocket(socketPath, directory) and noctalia.fileExists(socketPath) then
local command = "printf '%s\\n' 'stop' | socat -T 2 - UNIX-CONNECT:" .. shellQuote(socketPath)
noctalia.runAsync(command)
end
end
end
function update()
local now = noctalia.nowMs()
for connector, operation in pairs(pending) do
if not isCurrent(connector, operation.generation) then
clearPending(connector, operation)
elseif operation.kind == "ready" and not operation.inFlight then
operation.inFlight = true
ipcCommand(operation.socketPath, "query", function(query)
if pending[connector] ~= operation or not isCurrent(connector, operation.generation) then
return
end
operation.inFlight = false
if query.ok and query.kind == "video" and query.path == operation.assignment.path then
commitVideo(connector, operation.generation, operation.assignment, query)
elseif noctalia.nowMs() >= operation.deadline then
setConnectorError(connector, operation.generation, query.error or "gSlapper readiness timed out")
stopOwned(connector, operation.generation)
end
end)
elseif operation.kind == "stop" then
if not noctalia.fileExists(operation.socketPath) then
clearPending(connector, operation)
if operation.after then
operation.after()
end
elseif now >= operation.deadline and not operation.inFlight then
operation.inFlight = true
ipcCommand(operation.socketPath, "query", function(query)
if pending[connector] ~= operation or not isCurrent(connector, operation.generation) then
return
end
clearPending(connector, operation)
local directory = runtimeDirectory()
if query.notConnectable and directory and isOwnedSocket(operation.socketPath, directory) then
noctalia.removeFile(operation.socketPath)
end
processFallback(connector, operation.generation, operation.socketPath, operation.after)
end)
end
end
end
end
noctalia.setUpdateInterval(1000)
assignments = loadAssignments()
publishAssignments()
capabilitiesReady = restoreConnectedAssignments
if type(noctalia.commandExists) == "function" then
restartSettingsSignature = settingsSignature()
noctalia.state.watch("command", handleCommand)
probeCapabilities()
if noctalia.pluginDataDir() then
startLibraryIndex()
end
end
return {
assignments = assignments,
runtime = runtime,
detectedConnectors = detectedConnectors,
targetsFor = targetsFor,
commandTargets = commandTargets,
nextGeneration = nextGeneration,
isCurrent = isCurrent,
reconcileOutputs = reconcileOutputs,
ownedConnectors = ownedConnectors,
validateAssignment = validateAssignment,
saveAssignments = saveAssignments,
validateShellValue = validateShellValue,
shellQuote = shellQuote,
socketPathFor = socketPathFor,
isOwnedSocket = isOwnedSocket,
processMatchNeedles = processMatchNeedles,
hiddenFlags = hiddenFlags,
parseIpcResponse = parseIpcResponse,
validateCommand = validateCommand,
summarizeResults = summarizeResults,
buildLaunchCommand = buildLaunchCommand,
assignVideo = assignVideo,
assignImage = assignImage,
setPlayback = setPlayback,
handleCommand = handleCommand,
runSelfTest = runSelfTest,
classifyMedia = classifyMedia,
fnv1a32 = fnv1a32,
validMediaRoot = validMediaRoot,
requiredFlags = REQUIRED_FLAGS,
}