feat: add whyoolw/dropwall (#6)

Co-authored-by: whyoolw <whyoolw@gmail.com>
This commit is contained in:
wioletowa
2026-07-17 18:33:21 -04:00
committed by GitHub
co-authored by whyoolw
parent ca07934fe4
commit cc6a1ca59b
9 changed files with 1085 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 whyoolw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+110
View File
@@ -0,0 +1,110 @@
# DropWall
Drag a local image anywhere onto the desktop to set it as the wallpaper in
Noctalia v5.
The image is applied through Noctalia's own wallpaper API, so the regular fill
mode, fill color, transitions, and wallpaper state remain in effect.
## Plugin
| Field | Value |
| --- | --- |
| ID | `whyoolw/dropwall` |
| Entries | Service: `service` (`service.luau`) |
DropWall is a headless service plugin. It does not add a bar widget or panel;
enabling the plugin starts the desktop drop target service.
## Usage
1. Enable `whyoolw/dropwall` from Noctalia's plugin store or with
`noctalia msg plugins enable whyoolw/dropwall`.
2. Drag a local image file onto the bare desktop.
3. Drop it on any monitor to apply it as the wallpaper through Noctalia's
wallpaper settings.
4. Optional: open **Settings -> Plugins -> DropWall** to enable per-monitor
drops, safe copying into the wallpaper directory, notifications, or the
alternate `bottom` layer.
## Features
- Full-desktop drop targets on every connected monitor.
- Optional per-monitor application based on the monitor receiving the drop.
- Optional safe copy into Noctalia's wallpaper directory.
- A subtle dashed highlight while a file is dragged over the desktop.
- Automatic monitor hotplug handling and helper recovery.
## How it works
- A headless service opens one long-lived stream to
`dropwall_supervisor.py`. The supervisor owns a GTK3 + gtk-layer-shell
worker and restarts it after an unexpected exit without consuming extra
Noctalia stream slots.
- The worker keeps one fully transparent layer-shell surface per monitor,
anchored to all edges. A runtime lock, parent-death signals, and pipe
heartbeats prevent orphaned or duplicate workers.
- Dragging a file over the desktop shows a subtle dashed drop highlight.
- On drop, the worker accepts only a local regular file with a supported
extension (`jpg`, `jpeg`, `png`, `webp`, `bmp`, or `gif`). It reports a
percent-encoded path and the monitor's logical geometry to the service.
- The service matches that geometry against `noctalia.outputs()` and applies
the image with `noctalia.setWallpaper()`. Ambiguous per-monitor matches fail
safely instead of changing every output.
- When copying is enabled, the service resolves the current theme's wallpaper
directory for that drop and starts `dropwall_copy.py`. The copier writes a
private hidden temporary file, flushes it, then publishes the complete file
atomically without replacing anything. `photo-1.jpg`, `photo-2.jpg`, and so
on are used for name collisions.
## Requirements
- `python3`
- Python GObject bindings (`python-gobject` / `python3-gi`)
- GTK 3 and the GTK Layer Shell typelib (`gtk3`, `gtk-layer-shell`)
- A compositor with wlr-layer-shell support (niri, Hyprland, sway, …)
Package names vary between distributions. Install the packages that provide
Python 3, PyGObject, GTK 3, and the GTK Layer Shell typelib on your system.
## Settings
| Setting | Default | Meaning |
| --- | --- | --- |
| Per-monitor drop | off | Set only on the monitor you dropped on; off = system behavior |
| Copy into wallpaper directory | off | Create a non-overwriting copy in Noctalia's wallpaper directory before applying |
| Notify on set | on | Notification when applied |
| Drop surface layer | background | Use `bottom` if drops do not register; the service restarts automatically |
## Process, filesystem, and network behavior
DropWall keeps two local Python processes running: a small supervisor and its
GTK worker. It writes a PID lock named `noctalia-dropwall.lock` in
`$XDG_RUNTIME_DIR`. With **Copy into wallpaper directory** enabled, each drop
starts one short-lived Python copier and writes a mode-0600 image to the
directory returned by Noctalia. A completed copy appears atomically and
existing files are never overwritten. The copier watches the exact GTK worker
that accepted the drop and cleans up if the plugin is stopped or reloaded.
Before a copy, the copier removes only
owned `.dropwall-copy-*.tmp` files older than 24 hours that an unclean shutdown
may have left in that directory. With copying disabled, Noctalia keeps
referring to the original file, so moving or deleting that file can break the
wallpaper.
DropWall makes no network requests and never downloads or executes remote
code.
## Notes
- The drop surface accepts pointer input over the bare desktop (that's what
makes Wayland DnD target it). Noctalia desktop widgets live on their own
surfaces and are unaffected.
- Files dragged from browsers as remote URLs (not `file://`) are ignored —
save the image first.
- If the highlight does not appear, switch **Drop surface layer** from
`background` to `bottom`. If startup still fails, check Noctalia's log for
messages prefixed with `dropwall helper:` and verify the dependencies above.
## License
MIT — see [LICENSE](LICENSE).
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Atomically copy one dropped image without replacing an existing file."""
import argparse
import ctypes
import os
import select
import signal
import stat
import sys
import tempfile
import time
import urllib.parse
TEMP_PREFIX = ".dropwall-copy-"
TEMP_SUFFIX = ".tmp"
STALE_SECONDS = 24 * 60 * 60
ACTIVE_TEMP = None
COPY_CHUNK = 1024 * 1024
# Exit immediately if Noctalia closes the process pipe.
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
def arm_parent_death_signal():
"""Ask Linux to terminate this copy if Noctalia disappears."""
parent = os.getppid()
try:
libc = ctypes.CDLL(None, use_errno=True)
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
return
if os.getppid() != parent:
os.kill(os.getpid(), signal.SIGTERM)
except (AttributeError, OSError):
return
def encode_path(path):
return urllib.parse.quote_from_bytes(os.fsencode(path), safe="")
def remove_active_temp():
global ACTIVE_TEMP
if ACTIVE_TEMP:
try:
os.unlink(ACTIVE_TEMP)
except OSError:
pass
ACTIVE_TEMP = None
def terminate(signum, _frame):
remove_active_temp()
os._exit(128 + signum)
class WorkerLease:
"""A pidfd tied to the GTK worker that accepted this drop."""
def __init__(self, pid):
if not hasattr(os, "pidfd_open"):
raise OSError("this Linux/Python build does not support pidfd_open")
self.fd = os.pidfd_open(pid, 0)
self.poller = select.poll()
self.poller.register(self.fd, select.POLLIN | select.POLLHUP | select.POLLERR)
self.check()
def check(self):
if self.poller.poll(0):
raise BrokenPipeError("DropWall worker stopped during the copy")
def close(self):
os.close(self.fd)
def cleanup_stale_temps(directory):
"""Remove only old, owned temporary files left by interrupted copies."""
cutoff = time.time() - STALE_SECONDS
try:
names = os.listdir(directory)
except OSError:
return
for name in names:
if not (name.startswith(TEMP_PREFIX) and name.endswith(TEMP_SUFFIX)):
continue
path = os.path.join(directory, name)
try:
info = os.lstat(path)
if info.st_uid == os.getuid() and stat.S_ISREG(info.st_mode) and info.st_mtime < cutoff:
os.unlink(path)
except OSError:
continue
def is_inside(path, directory):
try:
return os.path.commonpath((path, directory)) == directory
except ValueError:
return False
def publish_unique(temp_path, source, directory, lease):
filename = os.path.basename(source)
stem, suffix = os.path.splitext(filename)
stem = stem or "wallpaper"
for counter in range(10000):
candidate_name = filename if counter == 0 else "%s-%d%s" % (stem, counter, suffix)
candidate = os.path.join(directory, candidate_name)
try:
lease.check()
# The hard link exposes the already-complete inode atomically and
# fails if candidate exists. It can never replace user data.
os.link(temp_path, candidate, follow_symlinks=False)
return candidate
except FileExistsError:
continue
raise FileExistsError("could not allocate a unique destination filename")
def copy_atomic(source, directory, lease):
global ACTIVE_TEMP
real_directory = os.path.realpath(directory)
if not os.path.isdir(real_directory):
raise NotADirectoryError("wallpaper directory does not exist")
source_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NONBLOCK", 0)
source_fd = os.open(source, source_flags)
with os.fdopen(source_fd, "rb") as source_file:
source_info = os.fstat(source_file.fileno())
if not stat.S_ISREG(source_info.st_mode):
raise OSError("the dropped path is not a regular file")
# Resolve the descriptor we actually validated, not a pathname that
# could have been swapped after open().
real_source = os.path.realpath("/proc/self/fd/%d" % source_file.fileno())
if is_inside(real_source, real_directory):
return real_source
cleanup_stale_temps(real_directory)
temp_fd, temp_path = tempfile.mkstemp(
prefix=TEMP_PREFIX,
suffix=TEMP_SUFFIX,
dir=real_directory,
)
ACTIVE_TEMP = temp_path
try:
with os.fdopen(temp_fd, "wb") as temp_file:
while True:
lease.check()
chunk = source_file.read(COPY_CHUNK)
if not chunk:
break
temp_file.write(chunk)
temp_file.flush()
os.fsync(temp_file.fileno())
# Keep copies private even if the source was more permissive.
os.chmod(temp_path, 0o600)
return publish_unique(temp_path, source, real_directory, lease)
finally:
remove_active_temp()
def main():
arm_parent_death_signal()
parser = argparse.ArgumentParser(description="Safely copy one DropWall image")
parser.add_argument("--lease-pid", type=int, required=True)
parser.add_argument("source")
parser.add_argument("directory")
args = parser.parse_args()
for signum in (signal.SIGINT, signal.SIGTERM):
signal.signal(signum, terminate)
lease = None
try:
lease = WorkerLease(args.lease_pid)
destination = copy_atomic(args.source, args.directory, lease)
except Exception as error:
print(str(error).replace("\n", " "), file=sys.stderr, flush=True)
return 1
finally:
if lease is not None:
lease.close()
print("COPIED\t%s" % encode_path(destination), flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""DropWall helper: transparent layer-shell drop targets, one per monitor.
Owned by dropwall_supervisor.py, which keeps one worker attached to the single
stream opened by the DropWall Noctalia service.
Protocol, one line per event on stdout:
READY\t<n> n drop surfaces created
ALIVE heartbeat for stream/orphan detection
DROP\t<x>\t<y>\t<w>\t<h>\t<pid>\t<path> image dropped; path is percent-encoded
INVALID\t<path> unsupported filename extension
MISSING\t<path> dropped path is not a regular file
ERR\t<message> non-fatal problem
"""
import argparse
import ctypes
import fcntl
import os
import signal
import stat
import sys
import threading
import urllib.parse
# Python ignores SIGPIPE by default. Restoring the Unix behavior makes an
# orphaned helper exit on its next heartbeat when Noctalia closes the stream.
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
try:
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
gi.require_version("GtkLayerShell", "0.1")
from gi.repository import Gdk, GLib, Gtk, GtkLayerShell # noqa: E402
except (ImportError, ValueError) as error:
message = str(error).replace("\n", " ")
print("ERR\tGTK dependencies could not be loaded: %s" % message, flush=True)
raise SystemExit(2)
CSS = b"""
window { background-color: rgba(0, 0, 0, 0); }
.dropzone {
background-color: rgba(0, 0, 0, 0);
border: 3px dashed rgba(0, 0, 0, 0);
border-radius: 18px;
margin: 14px;
transition: background-color 150ms ease, border-color 150ms ease;
}
.dropzone.hover {
background-color: rgba(128, 128, 128, 0.16);
border-color: rgba(255, 255, 255, 0.55);
}
"""
LAYERS = {
"background": GtkLayerShell.Layer.BACKGROUND,
"bottom": GtkLayerShell.Layer.BOTTOM,
}
VALID_EXTENSIONS = {"jpg", "jpeg", "png", "webp", "bmp", "gif"}
HEARTBEAT_SECONDS = 5
EMIT_LOCK = threading.Lock()
INSTANCE_LOCK = None
def emit(line):
with EMIT_LOCK:
print(line, flush=True)
def arm_parent_death_signal():
"""Ask Linux to terminate us if the process that spawned us disappears."""
parent = os.getppid()
try:
libc = ctypes.CDLL(None, use_errno=True)
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
return
# Close the small race where the parent dies immediately before prctl.
if os.getppid() != parent:
os.kill(os.getpid(), signal.SIGTERM)
except (AttributeError, OSError):
# The heartbeat/SIGPIPE path remains the portable fallback.
return
def acquire_instance_lock():
"""Keep at most one DropWall target alive in this user session."""
global INSTANCE_LOCK
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
if not runtime_dir:
emit("ERR\tXDG_RUNTIME_DIR is not set")
return False
lock_path = os.path.join(runtime_dir, "noctalia-dropwall.lock")
flags = os.O_RDWR | os.O_CREAT
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(lock_path, flags, 0o600)
info = os.fstat(fd)
if info.st_uid != os.getuid() or not stat.S_ISREG(info.st_mode):
raise PermissionError("unsafe lock file")
os.fchmod(fd, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(fd)
emit("BUSY\tanother DropWall helper is already running")
return False
except OSError as error:
if "fd" in locals():
os.close(fd)
emit("ERR\tcould not acquire the runtime lock: %s" % error)
return False
INSTANCE_LOCK = os.fdopen(fd, "w", encoding="ascii")
INSTANCE_LOCK.write("%d\n" % os.getpid())
INSTANCE_LOCK.flush()
return True
def selection_to_path(data):
"""Extract the first local file path from a drop's selection data."""
uris = list(data.get_uris() or [])
if not uris:
text = data.get_text()
if text:
uris = [part for part in text.split("\n") if part.strip()]
for uri in uris:
uri = uri.strip()
if uri.startswith("file://"):
parsed = urllib.parse.urlsplit(uri)
if parsed.netloc not in ("", "localhost"):
continue
path = os.fsdecode(urllib.parse.unquote_to_bytes(parsed.path))
if uri.startswith("/"):
path = uri
elif not uri.startswith("file://"):
continue
if "\0" not in path and os.path.isabs(path):
return path
return None
def encode_path(path):
"""Encode filesystem bytes as ASCII so filenames cannot forge events."""
return urllib.parse.quote_from_bytes(os.fsencode(path), safe="")
def process_drop(path, geometry):
"""Validate a drop away from the GTK event loop, then report it."""
encoded_path = encode_path(path)
try:
info = os.stat(path)
except (OSError, ValueError):
emit("MISSING\t%s" % encoded_path)
return
if not stat.S_ISREG(info.st_mode):
emit("MISSING\t%s" % encoded_path)
return
extension = os.path.splitext(path)[1].lower().lstrip(".")
if extension not in VALID_EXTENSIONS:
emit("INVALID\t%s" % encoded_path)
return
encoded_path = encode_path(path)
x, y, width, height = geometry
emit("DROP\t%d\t%d\t%d\t%d\t%d\t%s" % (x, y, width, height, os.getpid(), encoded_path))
class DropWindow(Gtk.Window):
def __init__(self, monitor, layer):
super().__init__(type=Gtk.WindowType.TOPLEVEL)
self.monitor = monitor
visual = self.get_screen().get_rgba_visual()
if visual is not None:
self.set_visual(visual)
GtkLayerShell.init_for_window(self)
GtkLayerShell.set_layer(self, layer)
GtkLayerShell.set_monitor(self, monitor)
GtkLayerShell.set_namespace(self, "dropwall")
for edge in (
GtkLayerShell.Edge.LEFT,
GtkLayerShell.Edge.RIGHT,
GtkLayerShell.Edge.TOP,
GtkLayerShell.Edge.BOTTOM,
):
GtkLayerShell.set_anchor(self, edge, True)
# Cover the full output, including space reserved by bars/docks.
GtkLayerShell.set_exclusive_zone(self, -1)
self.zone = Gtk.Box()
self.zone.get_style_context().add_class("dropzone")
self.add(self.zone)
self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY)
self.drag_dest_add_uri_targets()
self.drag_dest_add_text_targets()
self.connect("drag-motion", self.on_drag_motion)
self.connect("drag-leave", self.on_drag_leave)
self.connect("drag-data-received", self.on_drag_data_received)
self.show_all()
def on_drag_motion(self, _widget, _context, _x, _y, _time):
self.zone.get_style_context().add_class("hover")
return False # let the default DestDefaults handler ack the drag
def on_drag_leave(self, _widget, _context, _time):
self.zone.get_style_context().remove_class("hover")
def on_drag_data_received(self, _widget, _context, _x, _y, data, _info, _time):
self.zone.get_style_context().remove_class("hover")
path = selection_to_path(data)
if not path:
emit("ERR\tdrop carried no usable local file path")
return
geo = self.monitor.get_geometry()
geometry = (geo.x, geo.y, geo.width, geo.height)
threading.Thread(
target=process_drop,
args=(path, geometry),
daemon=True,
).start()
class App:
def __init__(self, layer):
self.layer = layer
self.windows = []
self.rebuild_pending = False
provider = Gtk.CssProvider()
provider.load_from_data(CSS)
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
display = Gdk.Display.get_default()
display.connect("monitor-added", self.schedule_rebuild)
display.connect("monitor-removed", self.schedule_rebuild)
self.build_windows()
GLib.timeout_add_seconds(HEARTBEAT_SECONDS, self.heartbeat)
def build_windows(self):
for win in self.windows:
win.destroy()
self.windows = []
display = Gdk.Display.get_default()
for i in range(display.get_n_monitors()):
monitor = display.get_monitor(i)
if monitor is not None:
self.windows.append(DropWindow(monitor, self.layer))
emit("READY\t%d" % len(self.windows))
def heartbeat(self):
emit("ALIVE")
return True
def schedule_rebuild(self, *_args):
# Debounce: hotplug fires added+removed bursts during mode changes.
if self.rebuild_pending:
return
self.rebuild_pending = True
def do_rebuild():
self.rebuild_pending = False
self.build_windows()
return False
GLib.timeout_add(500, do_rebuild)
def main():
parser = argparse.ArgumentParser(description="DropWall layer-shell drop helper")
parser.add_argument("--layer", choices=sorted(LAYERS), default="background")
args = parser.parse_args()
arm_parent_death_signal()
if not acquire_instance_lock():
return 3
try:
if Gdk.Display.get_default() is None:
emit("ERR\tcould not connect to the Wayland display")
return 1
if not GtkLayerShell.is_supported():
emit("ERR\tlayer-shell is not supported by this compositor")
return 1
App(LAYERS[args.layer])
Gtk.main()
return 0
except Exception as error:
emit("ERR\tGTK helper startup failed: %s" % str(error).replace("\n", " "))
return 1
if __name__ == "__main__":
sys.exit(main())
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Keep one DropWall GTK target attached to a single Noctalia stream."""
import argparse
import ctypes
import os
import signal
import subprocess
import sys
import time
# Exit immediately when Noctalia closes runStream's stdout pipe.
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
def arm_parent_death_signal():
"""Ask Linux to terminate us if Noctalia disappears."""
parent = os.getppid()
try:
libc = ctypes.CDLL(None, use_errno=True)
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
return
if os.getppid() != parent:
os.kill(os.getpid(), signal.SIGTERM)
except (AttributeError, OSError):
return
def emit(line):
print(line, flush=True)
def worker_command(args):
helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dropwall_helper.py")
return [sys.executable, "-B", helper, "--layer", args.layer]
def run_worker(command):
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)
assert process.stdout is not None
for line in process.stdout:
emit(line.rstrip("\n"))
return process.wait()
def main():
parser = argparse.ArgumentParser(description="DropWall helper supervisor")
parser.add_argument("--layer", choices=("background", "bottom"), default="background")
args = parser.parse_args()
arm_parent_death_signal()
command = worker_command(args)
lock_retry_seconds = 2
while True:
try:
exit_code = run_worker(command)
except OSError as error:
emit("ERR\tcould not start the GTK helper: %s" % str(error).replace("\n", " "))
exit_code = 127
emit("RESTART\t%d" % exit_code)
if exit_code == 3:
# A collision normally means the previous runtime is still
# shutting down. Back off if it is a genuinely persistent owner.
time.sleep(lock_retry_seconds)
lock_retry_seconds = min(lock_retry_seconds * 2, 60)
else:
lock_retry_seconds = 2
time.sleep(30)
if __name__ == "__main__":
sys.exit(main())
+61
View File
@@ -0,0 +1,61 @@
# DropWall — drag & drop an image anywhere onto the desktop to set it as the
# wallpaper.
#
# A headless [[service]] owns a Python supervisor and a GTK3 + gtk-layer-shell
# worker that keeps one transparent layer-shell surface per monitor as a drop
# target. When an image is dropped, the worker reports the file and monitor
# geometry; the service resolves the output connector and applies it through
# noctalia.setWallpaper(), just like the built-in wallpaper panel.
#
# External requirements: python3, python-gobject, gtk3, gtk-layer-shell.
id = "whyoolw/dropwall"
name = "DropWall"
version = "1.0.0"
plugin_api = 3
author = "whyoolw"
license = "MIT"
dependencies = ["python3", "python-gobject", "gtk3", "gtk-layer-shell"]
tags = ["desktop", "wallpaper"]
icon = "image"
description = "Drag and drop an image onto the desktop to set it as wallpaper using Noctalia's wallpaper settings."
[[setting]]
key = "per_monitor"
type = "bool"
label_key = "settings.per_monitor.label"
description_key = "settings.per_monitor.description"
default = false
[[setting]]
key = "copy_to_wallpaper_dir"
type = "bool"
label_key = "settings.copy_to_wallpaper_dir.label"
description_key = "settings.copy_to_wallpaper_dir.description"
default = false
[[setting]]
key = "notify_on_set"
type = "bool"
label_key = "settings.notify_on_set.label"
description_key = "settings.notify_on_set.description"
default = true
# Which layer-shell layer the invisible drop surface lives on. "background"
# sits next to the wallpaper itself and is the least intrusive; switch to
# "bottom" if drops don't register on your compositor because the wallpaper
# surface swallows them. Changing it restarts the service automatically.
[[setting]]
key = "layer"
type = "select"
label_key = "settings.layer.label"
description_key = "settings.layer.description"
default = "background"
options = [
{ value = "background", label_key = "settings.layer.options.background" },
{ value = "bottom", label_key = "settings.layer.options.bottom" },
]
[[service]]
id = "service"
entry = "service.luau"
+282
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+27
View File
@@ -0,0 +1,27 @@
{
"title": "DropWall",
"settings.per_monitor.label": "Per-monitor drop",
"settings.per_monitor.description": "Set the wallpaper only on the monitor the image was dropped on. When off, apply it to every output.",
"settings.copy_to_wallpaper_dir.label": "Copy into wallpaper directory",
"settings.copy_to_wallpaper_dir.description": "Copy the dropped image into the system wallpaper directory before applying. Existing files are never replaced; a numeric suffix is added when needed.",
"settings.notify_on_set.label": "Notify on set",
"settings.notify_on_set.description": "Show a notification when a dropped image is applied.",
"settings.layer.label": "Drop surface layer",
"settings.layer.description": "Layer-shell layer for the invisible drop surface. \"Background\" is the least intrusive; use \"Bottom\" if drops don't register.",
"settings.layer.options.background": "Background",
"settings.layer.options.bottom": "Bottom",
"notify.set_title": "Wallpaper set",
"notify.invalid_title": "Not an image (jpg, png, webp, bmp, gif)",
"notify.missing_title": "Dropped file not found",
"notify.output_error_title": "Could not identify the target monitor",
"notify.helper_error_title": "DropWall helper error",
"notify.python_missing": "python3 is not available on PATH.",
"notify.plugin_dir_missing": "Noctalia did not provide the plugin directory.",
"notify.helper_file_missing": "The DropWall supervisor file is missing.",
"notify.helper_start_failed": "Noctalia could not open the helper stream.",
"notify.helper_busy": "Another DropWall helper is still running. The plugin will keep retrying.",
"notify.no_outputs": "The helper could not create a drop target for any output.",
"notify.copy_dir_title": "Wallpaper was not copied",
"notify.copy_dir_missing": "No wallpaper directory is configured; the original file will be used.",
"notify.copy_failed": "The safe copy failed; the original file will be used."
}