inital v4

This commit is contained in:
TutorialsGHG
2026-05-25 20:46:00 +02:00
parent fca54607cb
commit 1adec1b88f
42 changed files with 56666 additions and 0 deletions

418
v4/windows_client/client.py Normal file
View File

@@ -0,0 +1,418 @@
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()