feat: add whyoolw/dropwall (#6)
Co-authored-by: whyoolw <whyoolw@gmail.com>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
--!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
|
||||
Reference in New Issue
Block a user