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