419 lines
13 KiB
Python
419 lines
13 KiB
Python
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()
|