From cc6a1ca59b704a098eea6eca598ca01324da0654 Mon Sep 17 00:00:00 2001 From: wioletowa Date: Sat, 18 Jul 2026 01:33:21 +0300 Subject: [PATCH] feat: add whyoolw/dropwall (#6) Co-authored-by: whyoolw --- dropwall/LICENSE | 21 +++ dropwall/README.md | 110 ++++++++++++ dropwall/dropwall_copy.py | 196 ++++++++++++++++++++ dropwall/dropwall_helper.py | 305 ++++++++++++++++++++++++++++++++ dropwall/dropwall_supervisor.py | 83 +++++++++ dropwall/plugin.toml | 61 +++++++ dropwall/service.luau | 282 +++++++++++++++++++++++++++++ dropwall/thumbnail.webp | Bin 0 -> 42012 bytes dropwall/translations/en.json | 27 +++ 9 files changed, 1085 insertions(+) create mode 100644 dropwall/LICENSE create mode 100644 dropwall/README.md create mode 100644 dropwall/dropwall_copy.py create mode 100644 dropwall/dropwall_helper.py create mode 100644 dropwall/dropwall_supervisor.py create mode 100644 dropwall/plugin.toml create mode 100644 dropwall/service.luau create mode 100644 dropwall/thumbnail.webp create mode 100644 dropwall/translations/en.json 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 0000000000000000000000000000000000000000..3b86f590fa54269121f6fe629d9db463d67cfebd GIT binary patch literal 42012 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 literal 0 HcmV?d00001 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." +}