283 lines
9.2 KiB
Luau
283 lines
9.2 KiB
Luau
--!nonstrict
|
|
-- DropWall service: owns one long-lived helper supervisor stream and applies
|
|
-- dropped images as wallpapers.
|
|
--
|
|
-- The supervisor owns a GTK3 + gtk-layer-shell worker with one transparent
|
|
-- surface per monitor. Paths are percent-encoded on its stdout protocol, then
|
|
-- decoded here before going through noctalia.setWallpaper().
|
|
|
|
-- Same whitelist as the host wallpaper scanner (wallpaper.cpp).
|
|
local VALID_EXT = { jpg = true, jpeg = true, png = true, webp = true, bmp = true, gif = true }
|
|
local HELPER_ERROR_COOLDOWN = 300
|
|
local helperErrorNotified = false
|
|
local lastHelperErrorAt = 0
|
|
local busyLogged = false
|
|
local busyCount = 0
|
|
local copyInProgress = false
|
|
local pendingCopy = nil
|
|
local helperStreamStarted = false
|
|
|
|
local function cfg(key)
|
|
return noctalia.getConfig(key)
|
|
end
|
|
|
|
local function basename(path)
|
|
return path:match("([^/]+)$") or path
|
|
end
|
|
|
|
local function shellQuote(s)
|
|
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
|
|
end
|
|
|
|
local function now()
|
|
return tonumber(noctalia.formatTime("%s")) or 0
|
|
end
|
|
|
|
local function reportHelperError(message)
|
|
noctalia.log("dropwall helper: " .. message)
|
|
local stamp = now()
|
|
if not helperErrorNotified or (stamp > 0 and stamp - lastHelperErrorAt >= HELPER_ERROR_COOLDOWN) then
|
|
helperErrorNotified = true
|
|
lastHelperErrorAt = stamp
|
|
noctalia.notifyError(noctalia.tr("notify.helper_error_title"), message)
|
|
end
|
|
end
|
|
|
|
local function decodePath(value)
|
|
if type(value) ~= "string" or value == "" then
|
|
return nil
|
|
end
|
|
local decoded = noctalia.string.urlDecode(value)
|
|
if type(decoded) ~= "string" or decoded == "" or decoded:find("%z") then
|
|
return nil
|
|
end
|
|
return decoded
|
|
end
|
|
|
|
-- Resolve the connector of the monitor the image was dropped on by matching
|
|
-- the helper-reported logical geometry against the host's output list.
|
|
local function findConnector(x, y, w, h)
|
|
local outputs = noctalia.outputs()
|
|
local exact = nil
|
|
local exactCount = 0
|
|
for i = 1, #outputs do
|
|
local o = outputs[i]
|
|
if o.x == x and o.y == y and o.width == w and o.height == h then
|
|
exact = o.name
|
|
exactCount = exactCount + 1
|
|
end
|
|
end
|
|
|
|
-- Mirrored outputs may share identical geometry. Refuse an ambiguous match
|
|
-- instead of choosing one arbitrarily.
|
|
if exactCount == 1 then
|
|
return exact
|
|
elseif exactCount > 1 then
|
|
return nil
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
local function applyWallpaper(path, connector)
|
|
if cfg("per_monitor") then
|
|
if not connector then
|
|
noctalia.notifyError(noctalia.tr("notify.output_error_title"), basename(path))
|
|
return
|
|
end
|
|
noctalia.setWallpaper(connector, path)
|
|
else
|
|
-- The host applies its normal all-output wallpaper behavior.
|
|
noctalia.setWallpaper(path)
|
|
end
|
|
if cfg("notify_on_set") then
|
|
noctalia.notify(noctalia.tr("notify.set_title"), basename(path))
|
|
end
|
|
end
|
|
|
|
local function applyOriginalAfterCopyError(path, connector, message)
|
|
noctalia.log("dropwall copy: " .. message)
|
|
noctalia.notifyError(noctalia.tr("notify.copy_dir_title"), noctalia.tr("notify.copy_failed"))
|
|
applyWallpaper(path, connector)
|
|
end
|
|
|
|
local function copyAndApply(path, connector, leasePid)
|
|
if copyInProgress then
|
|
-- Bound copy concurrency to one process and retain only the latest queued
|
|
-- drop. It will be copied as soon as the active one finishes.
|
|
pendingCopy = { path = path, connector = connector, leasePid = leasePid }
|
|
noctalia.log("dropwall copy: queued latest drop while a copy is active")
|
|
return
|
|
end
|
|
|
|
-- This is theme-mode dependent, so resolve it for every drop rather than
|
|
-- pinning the directory when the service starts.
|
|
local wallpaperDir = noctalia.wallpaperDirectory()
|
|
if type(wallpaperDir) ~= "string" or wallpaperDir == "" then
|
|
noctalia.notifyError(noctalia.tr("notify.copy_dir_title"), noctalia.tr("notify.copy_dir_missing"))
|
|
applyWallpaper(path, connector)
|
|
return
|
|
end
|
|
|
|
local pluginDir = noctalia.pluginDir()
|
|
if type(pluginDir) ~= "string" or pluginDir == "" then
|
|
applyOriginalAfterCopyError(path, connector, "plugin directory is unavailable")
|
|
return
|
|
end
|
|
local copier = pluginDir .. "/dropwall_copy.py"
|
|
if not noctalia.fileExists(copier) then
|
|
applyOriginalAfterCopyError(path, connector, "copy helper is missing")
|
|
return
|
|
end
|
|
|
|
local cmd = "exec python3 -B " .. shellQuote(copier)
|
|
.. " --lease-pid " .. shellQuote(leasePid)
|
|
.. " " .. shellQuote(path) .. " " .. shellQuote(wallpaperDir)
|
|
copyInProgress = true
|
|
local accepted = noctalia.runAsync(cmd, function(result)
|
|
copyInProgress = false
|
|
if type(result) ~= "table" or result.exitCode ~= 0 or result.timedOut or result.stdoutTruncated then
|
|
local detail = type(result) == "table" and noctalia.string.trim(result.stderr or "") or "no result"
|
|
applyOriginalAfterCopyError(path, connector, detail ~= "" and detail or "copy process failed")
|
|
else
|
|
local output = noctalia.string.trim(result.stdout or "")
|
|
local encoded = output:match("^COPIED\t([^\t\r\n]+)$")
|
|
local copied = decodePath(encoded)
|
|
local copiedInfo = copied and noctalia.fileInfo(copied) or nil
|
|
if not copied or type(copiedInfo) ~= "table" or copiedInfo.isDir then
|
|
applyOriginalAfterCopyError(path, connector, "copy helper returned an invalid destination")
|
|
else
|
|
applyWallpaper(copied, connector)
|
|
end
|
|
end
|
|
|
|
local pending = pendingCopy
|
|
pendingCopy = nil
|
|
if pending then
|
|
copyAndApply(pending.path, pending.connector, pending.leasePid)
|
|
end
|
|
end, 60000)
|
|
|
|
if not accepted then
|
|
copyInProgress = false
|
|
applyOriginalAfterCopyError(path, connector, "Noctalia rejected the copy process")
|
|
end
|
|
end
|
|
|
|
local function handleDrop(path, connector, leasePid)
|
|
local ext = path:match("%.(%w+)$")
|
|
if not ext or not VALID_EXT[ext:lower()] then
|
|
noctalia.notifyError(noctalia.tr("notify.invalid_title"), basename(path))
|
|
return
|
|
end
|
|
if not noctalia.fileExists(path) then
|
|
noctalia.notifyError(noctalia.tr("notify.missing_title"), path)
|
|
return
|
|
end
|
|
local info = noctalia.fileInfo(path)
|
|
if type(info) ~= "table" or info.isDir then
|
|
noctalia.notifyError(noctalia.tr("notify.missing_title"), path)
|
|
return
|
|
end
|
|
|
|
if cfg("per_monitor") and not connector then
|
|
noctalia.notifyError(noctalia.tr("notify.output_error_title"), basename(path))
|
|
return
|
|
end
|
|
|
|
if cfg("copy_to_wallpaper_dir") then
|
|
copyAndApply(path, connector, leasePid)
|
|
else
|
|
applyWallpaper(path, connector)
|
|
end
|
|
end
|
|
|
|
local function onHelperLine(line)
|
|
if line:sub(1, 5) == "DROP\t" then
|
|
local x, y, w, h, pid, encoded = line:match("^DROP\t(-?%d+)\t(-?%d+)\t(%d+)\t(%d+)\t(%d+)\t([^\t]+)$")
|
|
local path = decodePath(encoded)
|
|
local leasePid = tonumber(pid)
|
|
if path and leasePid and leasePid > 1 then
|
|
handleDrop(path, findConnector(tonumber(x), tonumber(y), tonumber(w), tonumber(h)), leasePid)
|
|
else
|
|
noctalia.log("dropwall: malformed DROP line: " .. line)
|
|
end
|
|
elseif line:sub(1, 8) == "INVALID\t" then
|
|
local path = decodePath(line:sub(9))
|
|
noctalia.notifyError(noctalia.tr("notify.invalid_title"), path and basename(path) or "")
|
|
elseif line:sub(1, 8) == "MISSING\t" then
|
|
local path = decodePath(line:sub(9))
|
|
noctalia.notifyError(noctalia.tr("notify.missing_title"), path or "")
|
|
elseif line:sub(1, 4) == "ERR\t" then
|
|
reportHelperError(line:sub(5))
|
|
elseif line:sub(1, 5) == "BUSY\t" then
|
|
busyCount = busyCount + 1
|
|
if not busyLogged then
|
|
noctalia.log("dropwall helper: " .. line:sub(6))
|
|
busyLogged = true
|
|
end
|
|
if busyCount == 3 then
|
|
reportHelperError(noctalia.tr("notify.helper_busy"))
|
|
end
|
|
elseif line:sub(1, 6) == "READY\t" then
|
|
local count = tonumber(line:sub(7)) or 0
|
|
if count > 0 then
|
|
helperErrorNotified = false
|
|
busyLogged = false
|
|
busyCount = 0
|
|
else
|
|
reportHelperError(noctalia.tr("notify.no_outputs"))
|
|
end
|
|
elseif line:sub(1, 8) == "RESTART\t" then
|
|
local exitCode = line:sub(9)
|
|
if exitCode ~= "3" then
|
|
noctalia.log("dropwall: helper exited (code " .. exitCode .. "); supervisor will restart it")
|
|
end
|
|
elseif line ~= "ALIVE" and line ~= "" then
|
|
noctalia.log("dropwall helper output: " .. line)
|
|
end
|
|
end
|
|
|
|
local function startHelper()
|
|
if not noctalia.commandExists("python3") then
|
|
reportHelperError(noctalia.tr("notify.python_missing"))
|
|
return
|
|
end
|
|
|
|
local layer = tostring(cfg("layer") or "background")
|
|
if layer ~= "background" and layer ~= "bottom" then
|
|
layer = "background"
|
|
end
|
|
|
|
local pluginDir = noctalia.pluginDir()
|
|
if type(pluginDir) ~= "string" or pluginDir == "" then
|
|
reportHelperError(noctalia.tr("notify.plugin_dir_missing"))
|
|
return
|
|
end
|
|
|
|
local script = pluginDir .. "/dropwall_supervisor.py"
|
|
if not noctalia.fileExists(script) then
|
|
reportHelperError(noctalia.tr("notify.helper_file_missing"))
|
|
return
|
|
end
|
|
local cmd = "exec python3 -B " .. shellQuote(script) .. " --layer " .. shellQuote(layer)
|
|
cmd = cmd .. " 2>&1"
|
|
if noctalia.runStream(cmd, onHelperLine) then
|
|
helperStreamStarted = true
|
|
else
|
|
reportHelperError(noctalia.tr("notify.helper_start_failed"))
|
|
end
|
|
end
|
|
|
|
-- Boot.
|
|
startHelper()
|
|
|
|
-- If python3 was missing or the initial stream launch was rejected, retry
|
|
-- without ever opening more than the one long-lived supervisor stream.
|
|
function update()
|
|
noctalia.setUpdateInterval(60000)
|
|
if not helperStreamStarted then
|
|
startHelper()
|
|
end
|
|
end
|