359 lines
11 KiB
Python
Executable File
359 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import socket
|
|
import json
|
|
import subprocess
|
|
import threading
|
|
from sys import exit
|
|
from time import sleep
|
|
from os import environ, getuid
|
|
from pathlib import Path
|
|
from watchdog.observers import Observer
|
|
from watchdog.events import FileSystemEventHandler
|
|
|
|
NORMAL_WALLPAPER_DIR = Path("~/.local/share/wallpaper/current").expanduser()
|
|
BLURRED_WALLPAPER_DIR = Path("~/.local/share/wallpaper/blurred").expanduser()
|
|
|
|
|
|
def getFirstFile(dir: Path, pattern: str = "*") -> Path | None:
|
|
'''`find $dir -type f | head -n 1`'''
|
|
return next(dir.glob(pattern), None)
|
|
|
|
|
|
def getNiriSocket():
|
|
return environ['NIRI_SOCKET']
|
|
|
|
|
|
def _log(msg: str):
|
|
print(msg)
|
|
|
|
# logFIle = Path("/tmp/niri-autoblur.log")
|
|
# try:
|
|
# with logFIle.open("a") as f:
|
|
# f.write(msg + "\n")
|
|
# except Exception:
|
|
# pass
|
|
|
|
pass
|
|
|
|
|
|
def swwwLoadImg(namespace: str, wallpaper: Path):
|
|
cmd = [
|
|
"swww",
|
|
"img",
|
|
"-n",
|
|
namespace,
|
|
str(wallpaper),
|
|
"--transition-type",
|
|
"fade",
|
|
"--transition-duration",
|
|
"0.5",
|
|
]
|
|
_log(f"[SWWW] {" ".join(cmd)}")
|
|
ret = 0
|
|
try:
|
|
ret = subprocess.run(cmd, check=True).returncode
|
|
except Exception as e:
|
|
_log(f"[SWWW] failed to set wallpaper: {e}")
|
|
return False
|
|
|
|
if ret != 0:
|
|
_log(f"[SWWW] failed to set wallpaper, exit code: {ret}")
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def swwwStartDaemon(namespace: str):
|
|
# Check if daemon is already running
|
|
cmd = ["pgrep", "-f", f"swww daemon -n {namespace}"], "-u", str(getuid())
|
|
try:
|
|
output = subprocess.check_output(cmd, text=True)
|
|
pids = output.strip().splitlines()
|
|
if pids:
|
|
_log(f"[SWWW] daemon already running with PIDs: {', '.join(pids)}")
|
|
return True
|
|
except subprocess.CalledProcessError:
|
|
# pgrep returns non-zero exit code if no process is found
|
|
pass
|
|
except Exception as e:
|
|
_log(f"[SWWW] failed to check if daemon is running: {e}")
|
|
pass
|
|
|
|
try:
|
|
subprocess.Popen(["swww-daemon", "-n", namespace], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
_log(f"[SWWW] daemon started for namespace: {namespace}")
|
|
return True
|
|
except Exception as e:
|
|
_log(f"[SWWW] failed to start daemon non-blockingly: {e}")
|
|
return False
|
|
|
|
|
|
class AutoBlur:
|
|
_interval: float
|
|
_normalDir: Path
|
|
_blurredDir: Path
|
|
_isBlurred = threading.Event()
|
|
_thread: threading.Thread | None = None
|
|
_lastWallpaer: Path | None = None
|
|
_isFirst = True
|
|
|
|
def __init__(self, normalDir, blurredDir, interval=0.2):
|
|
self._interval = interval
|
|
self._normalDir = normalDir
|
|
self._blurredDir = blurredDir
|
|
|
|
# Niri will send "WindowsChanged" event on connect, so no need to init here
|
|
# init state
|
|
# self.setBlurred(AutoBlur.initIsBlurred())
|
|
|
|
# Start watching dirs
|
|
self.addWatchDir()
|
|
|
|
class WatchdogHandler(FileSystemEventHandler):
|
|
_callback = None
|
|
|
|
def __init__(self, callback):
|
|
if callback is None:
|
|
raise ValueError("callback cannot be None")
|
|
super().__init__()
|
|
self._callback = callback
|
|
|
|
def on_created(self, event):
|
|
if not event.is_directory:
|
|
src_path = str(event.src_path)
|
|
path = Path(src_path)
|
|
_log(f"[Watchdog] file created: {path}")
|
|
self._callback(path) # type: ignore
|
|
|
|
def on_moved(self, event):
|
|
if not event.is_directory:
|
|
dest_path = str(event.dest_path)
|
|
path = Path(dest_path)
|
|
_log(f"[Watchdog] file moved to: {path}")
|
|
self._callback(path) # type: ignore
|
|
|
|
def addWatchDir(self):
|
|
normalHandler = self.WatchdogHandler(self._onNormalDirEvent)
|
|
blurredHandler = self.WatchdogHandler(self._onBlurredDirEvent)
|
|
observer = Observer()
|
|
observer.schedule(normalHandler, str(self._normalDir), recursive=False)
|
|
observer.schedule(blurredHandler, str(self._blurredDir), recursive=False)
|
|
observer.start()
|
|
_log(f"[Watchdog] watching dirs: {self._normalDir}, {self._blurredDir}")
|
|
|
|
def _onNormalDirEvent(self, path: Path):
|
|
if not self._isBlurred.is_set():
|
|
self._apply(path)
|
|
|
|
def _onBlurredDirEvent(self, path: Path):
|
|
if self._isBlurred.is_set():
|
|
self._apply(path)
|
|
|
|
@staticmethod
|
|
def initIsBlurred() -> bool:
|
|
'''[ $(niri msg focused-window | wc -l) -gt 1 ]'''
|
|
cmd = ["niri", "msg", "focused-window"]
|
|
try:
|
|
output = subprocess.check_output(cmd, text=True)
|
|
lines = output.strip().splitlines()
|
|
return len(lines) > 1
|
|
except Exception as e:
|
|
_log(f"[initIsBlurred] failed to check focused window, assuming none: {e}")
|
|
return False
|
|
|
|
def setBlurred(self, isBlurred: bool) -> None:
|
|
# Cache state, avoid starting thread unnecessarily
|
|
if not self._isFirst and self._isBlurred.is_set() == isBlurred:
|
|
_log("[AutoBlur] state unchanged")
|
|
return
|
|
self._isFirst = False
|
|
|
|
if isBlurred:
|
|
self._isBlurred.set()
|
|
_log("[AutoBlur] set to blurred")
|
|
else:
|
|
self._isBlurred.clear()
|
|
_log("[AutoBlur] set to normal")
|
|
|
|
if self._thread is None or not self._thread.is_alive():
|
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
self._thread.start()
|
|
|
|
def _run(self) -> None:
|
|
'''Wait until wallpapers are ready & apply the correct one according to the current state'''
|
|
while True:
|
|
if self._isBlurred.is_set():
|
|
wallpaper = getFirstFile(self._blurredDir)
|
|
else:
|
|
wallpaper = getFirstFile(self._normalDir)
|
|
|
|
if wallpaper is not None and wallpaper.exists():
|
|
if self._apply(wallpaper):
|
|
break
|
|
|
|
sleep(self._interval)
|
|
|
|
def _apply(self, wallpaper: Path) -> bool:
|
|
if wallpaper == self._lastWallpaer:
|
|
return True
|
|
|
|
if not swwwLoadImg("background", wallpaper):
|
|
return False
|
|
|
|
self._lastWallpaer = wallpaper
|
|
return True
|
|
|
|
|
|
autoBlurInst = AutoBlur(NORMAL_WALLPAPER_DIR, BLURRED_WALLPAPER_DIR)
|
|
|
|
|
|
def handleEvent(event_name, payload):
|
|
if event_name == "WindowFocusChanged":
|
|
_log(f"[EventHandler] WindowFocusChanged event received")
|
|
id = payload.get("id", "")
|
|
if isinstance(id, int):
|
|
_log(f"[EventHandler] focused window id: {id}")
|
|
autoBlurInst.setBlurred(True)
|
|
elif isinstance(id, str) and id == "None":
|
|
_log("[EventHandler] no focused window")
|
|
autoBlurInst.setBlurred(False)
|
|
else:
|
|
_log(f"[EventHandler] unknown id: {id}, assuming no focused window")
|
|
autoBlurInst.setBlurred(False)
|
|
elif event_name == "WindowsChanged":
|
|
_log(f"[EventHandler] WindowsChanged event received")
|
|
windows = payload.get("windows", [])
|
|
for window in windows:
|
|
if window.get("is_focused", False):
|
|
_log(f"[EventHandler] found focused window")
|
|
autoBlurInst.setBlurred(True)
|
|
return
|
|
_log("[EventHandler] no focused window found")
|
|
autoBlurInst.setBlurred(False)
|
|
elif event_name == "WindowOpenedOrChanged":
|
|
_log(f"[EventHandler] WindowOpenedOrChanged event received")
|
|
window = payload.get("window", {})
|
|
if window.get("is_focused", False):
|
|
_log(f"[EventHandler] opened/changed focused window")
|
|
autoBlurInst.setBlurred(True)
|
|
else:
|
|
_log(f"[EventHandler] unhandled event: {event_name}")
|
|
|
|
|
|
def printEvent(eventName, payload):
|
|
_log(f"[EventHandler] event: {eventName}, payload:\n{json.dumps(payload, indent=2, ensure_ascii=False)}")
|
|
|
|
|
|
def connectNiri(niriSocket: str, handler) -> bool:
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
try:
|
|
sock.connect(niriSocket)
|
|
except Exception as e:
|
|
sock.close()
|
|
_log(f"[Socket] failed to connect to {niriSocket}: {e}")
|
|
return False
|
|
|
|
f = sock.makefile("rwb")
|
|
|
|
try:
|
|
f.write(b'"EventStream"\n')
|
|
f.flush()
|
|
|
|
first = f.readline()
|
|
if not first:
|
|
# raise RuntimeError("connection closed by server before handshake")
|
|
_log("[Socket] connection closed by server before handshake")
|
|
return False
|
|
try:
|
|
resp = json.loads(first.decode())
|
|
except Exception:
|
|
resp = first.decode().strip()
|
|
_log(f"[Socket] handshake response: {resp}")
|
|
|
|
while True:
|
|
line = f.readline()
|
|
if not line:
|
|
_log("[Socket] socket closed by server")
|
|
break
|
|
s = line.decode().strip()
|
|
if s == "":
|
|
continue
|
|
try:
|
|
obj = json.loads(s)
|
|
except Exception as e:
|
|
_log(f"[Socket] failed to parse line as JSON: {s}, error: {e}")
|
|
continue
|
|
|
|
keys = list(obj.keys())
|
|
if keys:
|
|
event_name = keys[0]
|
|
payload = obj[event_name]
|
|
else:
|
|
event_name = "<unknown>"
|
|
payload = obj
|
|
handler(event_name, payload)
|
|
finally:
|
|
try:
|
|
f.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
sock.close()
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# connectNiri(getNiriSocket(), printEvent)
|
|
# exit(0)
|
|
|
|
desktop = environ.get("XDG_CURRENT_DESKTOP", "")
|
|
if desktop == "niri":
|
|
_log("[Main] running in Niri")
|
|
_log("[Main] starting swww daemons")
|
|
if not swwwStartDaemon("background"):
|
|
exit(1)
|
|
if not swwwStartDaemon("backdrop"):
|
|
exit(1)
|
|
sleep(1) # give some time to start
|
|
|
|
_log("[Main] loading initial wallpapers")
|
|
# Init wallpaper for backdrop
|
|
blurred = getFirstFile(BLURRED_WALLPAPER_DIR)
|
|
if blurred:
|
|
swwwLoadImg("backdrop", blurred)
|
|
# Init wallpaper for background
|
|
normal = getFirstFile(NORMAL_WALLPAPER_DIR)
|
|
if normal:
|
|
swwwLoadImg("background", normal)
|
|
|
|
# Connect to Niri socket
|
|
_log(f"[Main] connecting to Niri socket")
|
|
niri_socket = getNiriSocket()
|
|
if not niri_socket:
|
|
_log("[Main] NIRI_SOCKET environment variable is not set.")
|
|
exit(1)
|
|
|
|
if not connectNiri(niri_socket, handleEvent):
|
|
exit(1)
|
|
elif desktop == "Hyprland":
|
|
_log("[Main] running in Hyprland")
|
|
_log("[Main] starting swww daemon")
|
|
if not swwwStartDaemon("background"):
|
|
exit(1)
|
|
sleep(1) # similarly
|
|
|
|
_log("[Main] loading initial wallpaper")
|
|
normal = getFirstFile(NORMAL_WALLPAPER_DIR)
|
|
if normal:
|
|
swwwLoadImg("background", normal)
|
|
|
|
# Wait indefinitely
|
|
while True:
|
|
sleep(3600)
|
|
else:
|
|
_log(f"[Main] unsupported desktop environment: {desktop}")
|
|
exit(1)
|