from __future__ import annotations import asyncio import contextlib import ctypes import json import os import platform import socket import threading import sys import time import uuid from pathlib import Path from typing import Any from urllib.parse import urlparse import requests from websockets.client import connect try: from winotify import Notification, audio except Exception: Notification = None audio = None try: import pystray from PIL import Image, ImageDraw, ImageTk except Exception: pystray = None Image = None ImageDraw = None ImageTk = None try: import tkinter as tk except Exception: tk = None AGENT_VERSION = "4.0.0-alpha" APP_NAME = "NotifyPulseAgent" SPI_SETDESKWALLPAPER = 0x0014 SPIF_UPDATEINIFILE = 0x01 SPIF_SENDCHANGE = 0x02 def appdata_dir() -> Path: base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) path = base / "NotifyPulseV4" path.mkdir(parents=True, exist_ok=True) (path / "cache").mkdir(parents=True, exist_ok=True) return path DATA_DIR = appdata_dir() CLIENT_FILE = DATA_DIR / "client.json" SETTINGS_FILE = DATA_DIR / "settings.json" STOP_EVENT = threading.Event() def load_json(path: Path, fallback: Any) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return fallback def save_json(path: Path, payload: Any) -> None: path.write_text(json.dumps(payload, indent=2), encoding="utf-8") def app_base_dir() -> Path: if getattr(sys, "frozen", False): return Path(sys.executable).resolve().parent return Path(__file__).resolve().parent def load_agent_config() -> dict[str, Any]: cfg_file = app_base_dir() / "agent_config.json" cfg = load_json(cfg_file, {}) return cfg if isinstance(cfg, dict) else {} def load_or_create_client_id() -> str: raw = load_json(CLIENT_FILE, {}) did = raw.get("device_id", "").strip() if did: return did did = f"win_{uuid.uuid4().hex[:12]}" save_json(CLIENT_FILE, {"device_id": did, "created_at": int(time.time())}) return did DEVICE_ID = load_or_create_client_id() _cfg = load_agent_config() SERVER_URL = str( os.getenv("NP4_SERVER_URL", _cfg.get("server_url", "http://127.0.0.1:8080")) ).rstrip("/") API_TOKEN = str(os.getenv("NP4_API_TOKEN", _cfg.get("api_token", ""))) def http_headers() -> dict[str, str]: headers: dict[str, str] = {} if API_TOKEN: headers["X-Api-Token"] = API_TOKEN return headers def ws_url_from_http(base: str) -> str: parsed = urlparse(base) scheme = "wss" if parsed.scheme == "https" else "ws" netloc = parsed.netloc return f"{scheme}://{netloc}/ws/device" def toast(title: str, message: str) -> None: if Notification is None: print(f"[notify fallback] {title}: {message}") return n = Notification(app_id=APP_NAME, title=title, msg=message, duration="short") if audio: n.set_audio(audio.Default, loop=False) n.show() def _load_tray_icon_image(): if Image is None: return None icon_path = app_base_dir() / "icon.ico" if icon_path.exists(): try: return Image.open(icon_path) except Exception: pass png_path = app_base_dir() / "icon.png" if png_path.exists(): try: return Image.open(png_path) except Exception: pass img = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) d = ImageDraw.Draw(img) d.ellipse((8, 8, 56, 56), fill=(79, 142, 247, 255)) d.ellipse((20, 20, 44, 44), fill=(255, 255, 255, 235)) return img def start_tray() -> None: if pystray is None: return def on_quit(icon, _item): STOP_EVENT.set() icon.stop() image = _load_tray_icon_image() if image is None: return icon = pystray.Icon( "NotifyPulseAgentV4", image, f"NotifyPulse Agent V4 ({DEVICE_ID})", menu=pystray.Menu(pystray.MenuItem("Quit", on_quit)), ) t = threading.Thread(target=icon.run, daemon=True) t.start() def set_wallpaper(path: str) -> bool: try: ok = ctypes.windll.user32.SystemParametersInfoW( SPI_SETDESKWALLPAPER, 0, str(path), SPIF_UPDATEINIFILE | SPIF_SENDCHANGE ) return bool(ok) except Exception: return False def _download_to_cache(url: str) -> Path: ext = Path(urlparse(url).path).suffix.lower() or ".jpg" out = DATA_DIR / "cache" / f"wallpaper_{int(time.time())}{ext}" r = requests.get(url, headers=http_headers(), timeout=30) r.raise_for_status() out.write_bytes(r.content) return out def _prepare_overlay_image(pil_img, sw: int, sh: int, stretch: bool): if stretch: return pil_img.resize((sw, sh), Image.Resampling.LANCZOS) img_w, img_h = pil_img.size scale = min(sw / img_w, sh / img_h) nw, nh = int(img_w * scale), int(img_h * scale) resized = pil_img.resize((nw, nh), Image.Resampling.LANCZOS) canvas = Image.new("RGB", (sw, sh), "black") canvas.paste(resized, ((sw - nw) // 2, (sh - nh) // 2)) return canvas def show_overlay(path: str, duration_ms: int, opacity: float = 0.4, stretch: bool = False) -> bool: if tk is None or Image is None or ImageTk is None: return False try: pil_img = Image.open(path) except Exception: return False def _run(): try: root = tk.Tk() root.withdraw() sw, sh = root.winfo_screenwidth(), root.winfo_screenheight() win = tk.Toplevel(root) win.attributes("-topmost", True) win.attributes("-alpha", max(0.05, min(1.0, float(opacity)))) win.overrideredirect(True) win.geometry(f"{sw}x{sh}+0+0") prepared = _prepare_overlay_image(pil_img, sw, sh, stretch) tk_img = ImageTk.PhotoImage(prepared) label = tk.Label(win, image=tk_img, bg="black") label.image = tk_img label.pack(fill="both", expand=True) win.after(max(500, int(duration_ms)), root.destroy) root.mainloop() except Exception: pass threading.Thread(target=_run, daemon=True).start() return True def version_tuple(v: str) -> tuple[int, ...]: nums: list[int] = [] for part in v.replace("-", ".").split("."): if part.isdigit(): nums.append(int(part)) else: break return tuple(nums or [0]) async def send_event(ws, name: str, data: dict[str, Any]) -> None: await ws.send(json.dumps({"type": "event", "name": name, "data": data})) async def send_ack(ws, command_id: str, status: str = "ok", detail: str = "") -> None: await ws.send( json.dumps( { "type": "ack", "command_id": command_id, "status": status, "detail": detail, } ) ) def load_local_settings() -> dict[str, Any]: return load_json(SETTINGS_FILE, {}) def save_local_settings(settings: dict[str, Any]) -> None: save_json(SETTINGS_FILE, settings) async def apply_command(ws, msg: dict[str, Any]) -> None: cid = str(msg.get("id", "")) action = str(msg.get("action", "")) data = msg.get("data", {}) if isinstance(msg.get("data"), dict) else {} try: if action == "notify": title = str(data.get("title", "NotifyPulse")) message = str(data.get("message", "")) toast(title, message) await send_ack(ws, cid, "ok", "notification shown") return if action == "wallpaper_set": path = str(data.get("path", "")).strip() url = str(data.get("url", "")).strip() if url: local = _download_to_cache(url) ok = set_wallpaper(str(local)) elif path: ok = set_wallpaper(path) else: await send_ack(ws, cid, "error", "missing wallpaper path/url") return await send_ack(ws, cid, "ok" if ok else "error", "wallpaper applied" if ok else "wallpaper failed") return if action == "overlay_show": path = str(data.get("path", "")).strip() url = str(data.get("url", "")).strip() duration_ms = int(data.get("duration_ms", 6000)) opacity = float(data.get("opacity", 0.4)) stretch = bool(data.get("stretch", False)) if url: local = _download_to_cache(url) ok = show_overlay(str(local), duration_ms, opacity=opacity, stretch=stretch) elif path: ok = show_overlay(path, duration_ms, opacity=opacity, stretch=stretch) else: await send_ack(ws, cid, "error", "missing overlay path/url") return await send_ack(ws, cid, "ok" if ok else "error", "overlay shown" if ok else "overlay failed") return if action == "sync_settings": settings = data.get("settings", {}) if isinstance(settings, dict): save_local_settings(settings) await send_ack(ws, cid, "ok", "settings synced") return if action == "ping": await send_ack(ws, cid, "ok", "pong") return await send_ack(ws, cid, "unsupported", f"unknown action: {action}") except Exception as exc: await send_ack(ws, cid, "error", str(exc)) async def heartbeat_loop(ws) -> None: while not STOP_EVENT.is_set(): await ws.send(json.dumps({"type": "heartbeat", "time": int(time.time())})) await asyncio.sleep(15) def check_update_manifest() -> tuple[bool, dict[str, Any]]: try: r = requests.get( f"{SERVER_URL}/api/v4/update/manifest/windows", headers=http_headers(), timeout=10, ) r.raise_for_status() manifest = r.json() remote_v = str(manifest.get("version", "0.0.0")) return version_tuple(remote_v) > version_tuple(AGENT_VERSION), manifest except Exception: return False, {} async def run_client() -> None: ws_url = ws_url_from_http(SERVER_URL) backoff_s = 2 while not STOP_EVENT.is_set(): try: async with connect(ws_url, ping_interval=20, ping_timeout=20, open_timeout=15) as ws: hello = { "type": "hello", "token": API_TOKEN, "payload": { "device_id": DEVICE_ID, "platform": "windows", "version": AGENT_VERSION, "hostname": socket.gethostname(), "capabilities": ["notify", "wallpaper_set", "overlay_show", "sync_settings"], }, } await ws.send(json.dumps(hello)) heartbeat_task = asyncio.create_task(heartbeat_loop(ws)) try: update_available, manifest = check_update_manifest() if update_available: await send_event( ws, "update_available", { "current_version": AGENT_VERSION, "target_version": manifest.get("version", ""), "download_url": manifest.get("download_url", ""), }, ) while True: raw = await ws.recv() msg = json.loads(raw) mtype = msg.get("type", "") if mtype == "welcome": incoming = msg.get("settings") if isinstance(incoming, dict): save_local_settings(incoming) elif mtype == "command": await apply_command(ws, msg) elif mtype == "heartbeat_ack": pass finally: heartbeat_task.cancel() with contextlib.suppress(Exception): await heartbeat_task backoff_s = 2 except Exception as exc: print(f"[agent] disconnected: {exc}") await asyncio.sleep(backoff_s) backoff_s = min(backoff_s * 2, 30) def main() -> None: print(f"{APP_NAME} {AGENT_VERSION}") print(f"Server: {SERVER_URL}") print(f"Device: {DEVICE_ID}") print(f"Host: {platform.node()}") start_tray() toast("NotifyPulse Agent", "Agent started and connecting to server") asyncio.run(run_client()) if __name__ == "__main__": main()