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