diff --git a/dropwall/LICENSE b/dropwall/LICENSE new file mode 100644 index 0000000..12baeb1 --- /dev/null +++ b/dropwall/LICENSE @@ -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. diff --git a/dropwall/README.md b/dropwall/README.md new file mode 100644 index 0000000..e441232 --- /dev/null +++ b/dropwall/README.md @@ -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). diff --git a/dropwall/dropwall_copy.py b/dropwall/dropwall_copy.py new file mode 100644 index 0000000..5328b82 --- /dev/null +++ b/dropwall/dropwall_copy.py @@ -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()) diff --git a/dropwall/dropwall_helper.py b/dropwall/dropwall_helper.py new file mode 100644 index 0000000..7b8c99e --- /dev/null +++ b/dropwall/dropwall_helper.py @@ -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 drop surfaces created + ALIVE heartbeat for stream/orphan detection + DROP\t\t\t\t\t\t image dropped; path is percent-encoded + INVALID\t unsupported filename extension + MISSING\t dropped path is not a regular file + ERR\t 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()) diff --git a/dropwall/dropwall_supervisor.py b/dropwall/dropwall_supervisor.py new file mode 100644 index 0000000..0e12f18 --- /dev/null +++ b/dropwall/dropwall_supervisor.py @@ -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()) diff --git a/dropwall/plugin.toml b/dropwall/plugin.toml new file mode 100644 index 0000000..a974882 --- /dev/null +++ b/dropwall/plugin.toml @@ -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" diff --git a/dropwall/service.luau b/dropwall/service.luau new file mode 100644 index 0000000..b2aef6f --- /dev/null +++ b/dropwall/service.luau @@ -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 diff --git a/dropwall/thumbnail.webp b/dropwall/thumbnail.webp new file mode 100644 index 0000000..3b86f59 Binary files /dev/null and b/dropwall/thumbnail.webp differ diff --git a/dropwall/translations/en.json b/dropwall/translations/en.json new file mode 100644 index 0000000..18025db --- /dev/null +++ b/dropwall/translations/en.json @@ -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." +}