1 Commits
V3 ... v4

Author SHA1 Message Date
TutorialsGHG
1adec1b88f inital v4 2026-05-25 20:46:00 +02:00
42 changed files with 56666 additions and 0 deletions

167
v4/README.md Normal file
View File

@@ -0,0 +1,167 @@
# NotifyPulse V4 (Server + Clients)
This V4 scaffold splits NotifyPulse into:
- Linux-hosted server (`v4/server`) for API, Web UI, PWA, settings, command routing.
- Windows agent (`v4/windows_client`) built as `.exe`, connected to server by WebSocket.
- Shared command protocol with ack/event flow.
V3 stays untouched in the repo root (`notifier.py`).
## Architecture
1. Server is the source of truth for settings, usecases, and command dispatch.
2. Windows client registers itself and executes device-local actions:
- toast notifications
- wallpaper changes
- (overlay hook included, implementation placeholder)
3. PWA/web clients connect to the server and use the same API.
4. Updates are centralized with server-hosted update manifests:
- `GET /api/v4/update/manifest/windows`
- Client checks and reports update availability.
## Quick start (Linux server)
```bash
cd v4/server
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8080
```
Server defaults:
- Web UI: `http://<server>:8080/`
- PWA: `http://<server>:8080/pwa/`
- Device WebSocket: `ws://<server>:8080/ws/device`
## Deploy on Linux (systemd)
Example target folder: `/opt/notifypulse`.
1. Copy repo to server and install deps:
```bash
sudo mkdir -p /opt/notifypulse
sudo chown -R $USER:$USER /opt/notifypulse
cd /opt/notifypulse
python3 -m venv v4/server/.venv
source v4/server/.venv/bin/activate
pip install -r v4/server/requirements.txt
```
2. Create env file:
```bash
cat > /opt/notifypulse/v4/server/.env <<'EOF'
NP4_HOST=0.0.0.0
NP4_PORT=8080
NP4_API_TOKEN=replace-with-long-random-token
NP4_DATA_DIR=/opt/notifypulse/v4_data
EOF
```
3. Create systemd service `/etc/systemd/system/notifypulse-v4.service`:
```ini
[Unit]
Description=NotifyPulse V4 Server
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/notifypulse/v4/server
EnvironmentFile=/opt/notifypulse/v4/server/.env
ExecStart=/opt/notifypulse/v4/server/.venv/bin/uvicorn app.main:app --host ${NP4_HOST} --port ${NP4_PORT}
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
4. Enable service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now notifypulse-v4
sudo systemctl status notifypulse-v4
```
## Optional: Nginx reverse proxy + HTTPS
Minimal Nginx vhost:
```nginx
server {
listen 80;
server_name your.domain.tld;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws/device {
proxy_pass http://127.0.0.1:8080/ws/device;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
Then add TLS (recommended) with Certbot.
## Quick start (Windows client)
```powershell
cd v4\windows_client
py -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
$env:NP4_SERVER_URL="http://<server>:8080"
$env:NP4_API_TOKEN="<optional token>"
python client.py
```
## Build Windows `.exe`
```powershell
cd v4\windows_client
build.bat
```
Output: `dist\NotifyPulseAgent-V4.exe`
## Easy update model
1. Publish new Windows agent builds on your server/CDN.
2. Update the manifest in server data:
- `v4_data/update_manifest.json` (auto-created on first boot)
3. Client periodically checks `GET /api/v4/update/manifest/windows`.
4. If newer version is detected, client emits `update_available` event.
You can later switch this to silent self-update (download + staged swap + restart), but this scaffold keeps it safe and transparent first.
## iOS note
PWA is the practical path. iOS sideloading native apps is possible but adds signing/provisioning/distribution complexity and is not "easy update" compared to PWA.
## Troubleshooting
- `Desktop test -> Wallpaper` does nothing:
- Ensure an active usecase exists.
- Add files under `v4_data/usecases/<usecase_id>/wallpapers/`.
- `Desktop test -> Overlay` does nothing:
- Add files under `v4_data/usecases/<usecase_id>/overlay/`.
- Rebuild/update the Windows agent so it includes overlay support.
- PWA wallpaper is identical for lock/home:
- Add at least 2 images under `v4_data/usecases/<usecase_id>/mobile/`.
- With only one image, both slots intentionally use that single image.

10
v4/server/.env.example Normal file
View File

@@ -0,0 +1,10 @@
NP4_HOST=0.0.0.0
NP4_PORT=8080
# Optional: set this to protect write/dispatch endpoints + device websocket auth
NP4_API_TOKEN=
# Optional overrides
# NP4_DATA_DIR=./v4_data
# NP4_UI_DIR=../../ui
# NP4_PWA_DIR=../../pwa

View File

@@ -0,0 +1,2 @@
"""NotifyPulse V4 server package."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

29
v4/server/app/config.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import annotations
import os
from pathlib import Path
class Config:
host: str = os.getenv("NP4_HOST", "0.0.0.0")
port: int = int(os.getenv("NP4_PORT", "8080"))
api_token: str = os.getenv("NP4_API_TOKEN", "").strip()
data_dir: Path = Path(os.getenv("NP4_DATA_DIR", "./v4_data")).resolve()
ui_dir: Path = Path(
os.getenv(
"NP4_UI_DIR",
str((Path(__file__).resolve().parents[3] / "ui").resolve()),
)
)
pwa_dir: Path = Path(
os.getenv(
"NP4_PWA_DIR",
str((Path(__file__).resolve().parents[3] / "pwa").resolve()),
)
)
@classmethod
def ensure_dirs(cls) -> None:
cls.data_dir.mkdir(parents=True, exist_ok=True)

View File

@@ -0,0 +1,144 @@
from __future__ import annotations
import asyncio
from collections import deque
from datetime import datetime, timezone
from typing import Any
from fastapi import WebSocket
from .models import ConnectedDevice, DeviceHello, Platform
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class ConnectionManager:
def __init__(self) -> None:
self._lock = asyncio.Lock()
self._sockets: dict[str, WebSocket] = {}
self._meta: dict[str, ConnectedDevice] = {}
self._events: deque[dict[str, Any]] = deque(maxlen=500)
async def connect(self, hello: DeviceHello, websocket: WebSocket) -> None:
now = _utc_now_iso()
async with self._lock:
self._sockets[hello.device_id] = websocket
self._meta[hello.device_id] = ConnectedDevice(
device_id=hello.device_id,
platform=hello.platform,
version=hello.version,
hostname=hello.hostname,
capabilities=hello.capabilities,
connected_at=now,
last_seen_at=now,
)
self._events.appendleft(
{
"time": now,
"event": "device_connected",
"device_id": hello.device_id,
"platform": hello.platform.value,
"version": hello.version,
}
)
async def disconnect(self, device_id: str) -> None:
now = _utc_now_iso()
async with self._lock:
had = device_id in self._sockets
self._sockets.pop(device_id, None)
if device_id in self._meta:
self._meta[device_id].last_seen_at = now
if had:
self._events.appendleft(
{
"time": now,
"event": "device_disconnected",
"device_id": device_id,
}
)
async def touch(self, device_id: str) -> None:
async with self._lock:
meta = self._meta.get(device_id)
if meta:
meta.last_seen_at = _utc_now_iso()
async def list_devices(self) -> list[ConnectedDevice]:
async with self._lock:
return [self._meta[did] for did in self._sockets.keys() if did in self._meta]
async def get_logs(self) -> list[dict[str, Any]]:
async with self._lock:
return list(self._events)
async def add_event(self, payload: dict[str, Any]) -> None:
async with self._lock:
payload = dict(payload)
payload.setdefault("time", _utc_now_iso())
self._events.appendleft(payload)
async def dispatch_command(
self,
command_id: str,
action: str,
data: dict[str, Any],
target_scope: str,
device_ids: list[str],
) -> tuple[list[str], list[str]]:
async with self._lock:
targets: list[tuple[str, WebSocket, ConnectedDevice]] = []
skipped: list[str] = []
for device_id, ws in self._sockets.items():
meta = self._meta.get(device_id)
if not meta:
continue
allowed = False
if target_scope == "all":
allowed = True
elif target_scope == "windows":
allowed = meta.platform == Platform.windows
elif target_scope == "pwa":
allowed = meta.platform == Platform.pwa
elif target_scope == "web":
allowed = meta.platform == Platform.web
elif target_scope == "device_ids":
allowed = device_id in device_ids
if allowed:
targets.append((device_id, ws, meta))
else:
skipped.append(device_id)
delivered: list[str] = []
for device_id, ws, _meta in targets:
try:
await ws.send_json(
{
"type": "command",
"id": command_id,
"action": action,
"data": data,
"sent_at": _utc_now_iso(),
}
)
delivered.append(device_id)
except Exception:
skipped.append(device_id)
await self.disconnect(device_id)
await self.add_event(
{
"event": "command_dispatched",
"command_id": command_id,
"action": action,
"target_scope": target_scope,
"delivered_count": len(delivered),
"delivered_to": delivered,
}
)
return delivered, skipped

862
v4/server/app/main.py Normal file
View File

@@ -0,0 +1,862 @@
from __future__ import annotations
import base64
import mimetypes
import random
import re
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, Header, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel
from .config import Config
from .connection_manager import ConnectionManager
from .models import DeviceHello, DispatchRequest, DispatchResult, UpdateManifest
from .store import store
app = FastAPI(title="NotifyPulse V4 Server", version="4.0.0-alpha")
manager = ConnectionManager()
_pwa_clients: dict[str, dict[str, Any]] = {}
_pending_wallpapers: dict[str, dict[str, str]] = {}
_pwa_overlay: dict[str, Any] = {"active": False, "image": None, "until_ms": 0}
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
ALL_BLOCKS = [
{"id": "notifications", "name": "Notifications", "description": "Server-dispatched notifications", "icon": "🔔"},
{"id": "wallpaper", "name": "Wallpaper", "description": "Wallpaper command dispatch", "icon": "🖼️"},
{"id": "overlay", "name": "Overlay", "description": "Overlay command dispatch", "icon": ""},
{"id": "timer", "name": "Timer", "description": "Scheduled triggers (server-side in V4)", "icon": "⏱️"},
{"id": "mobile_wallpaper", "name": "Mobile Wallpaper", "description": "PWA wallpaper receiver", "icon": "📱"},
]
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def require_token(x_api_token: str = Header(default="")) -> None:
# Optional auth: if NP4_API_TOKEN is empty, routes are open.
if Config.api_token and x_api_token != Config.api_token:
raise HTTPException(status_code=401, detail="invalid API token")
def _active_usecase_id() -> str:
return str(store.get_settings().get("active_usecase", ""))
def _active_usecase() -> dict[str, Any] | None:
uid = _active_usecase_id()
for uc in store.get_usecases():
if uc.get("id") == uid:
return uc
return None
def _set_active_usecase(uid: str) -> None:
store.update_settings({"active_usecase": uid})
def _parse_entry(line: str) -> dict[str, Any]:
parts = [p.strip() for p in line.split("|", 1)]
text = parts[0]
weight = None
trigger_time = None
if len(parts) > 1:
rhs = parts[1]
m_pct = re.match(r"^(\d+(?:\.\d+)?)\s*%$", rhs)
m_time = re.match(r"^([01]?\d|2[0-3]):([0-5]\d)$", rhs)
if m_pct:
weight = float(m_pct.group(1))
elif m_time:
trigger_time = f"{int(m_time.group(1)):02d}:{m_time.group(2)}"
return {"text": text, "weight": weight, "trigger_time": trigger_time}
def _img_to_datauri(path: Path) -> str | None:
try:
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
data = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{data}"
except Exception:
return None
def _legacy_log_item(msg: str, level: str = "info") -> dict[str, Any]:
return {"time": utc_now_iso(), "msg": msg, "level": level}
def _event_to_legacy_log(event: dict[str, Any]) -> dict[str, Any]:
if "msg" in event:
return {
"time": event.get("time", utc_now_iso()),
"msg": str(event.get("msg", "")),
"level": str(event.get("level", "info")),
}
et = str(event.get("event", "event"))
level = "info"
msg = et
if et == "device_connected":
msg = f"Client connected: {event.get('device_id', '?')} ({event.get('platform', 'unknown')})"
elif et == "device_disconnected":
msg = f"Client disconnected: {event.get('device_id', '?')}"
elif et == "command_dispatched":
msg = f"Dispatch {event.get('action', '?')} -> {event.get('delivered_count', 0)} client(s)"
elif et == "command_ack":
st = str(event.get("status", "ok"))
detail = str(event.get("detail", "")).strip()
msg = f"Ack {event.get('command_id', '?')} from {event.get('device_id', '?')}: {st}"
if detail:
msg += f" ({detail})"
if st in {"error", "unsupported"}:
level = "warn" if st == "unsupported" else "error"
elif et == "client_event":
msg = f"Client event {event.get('name', '?')} from {event.get('device_id', '?')}"
elif et == "socket_error":
msg = f"Socket error ({event.get('device_id', '?')}): {event.get('error', '')}"
level = "error"
elif et == "unknown_message":
msg = f"Unknown message from {event.get('device_id', '?')}"
level = "warn"
return {"time": event.get("time", utc_now_iso()), "msg": msg, "level": level}
async def _append_log(msg: str, level: str = "info") -> None:
await manager.add_event(
{
"event": "legacy_log",
"msg": msg,
"level": level,
"time": utc_now_iso(),
}
)
def _ensure_usecase_dirs(uid: str) -> None:
base = Config.data_dir / "usecases" / uid
base.mkdir(parents=True, exist_ok=True)
for sub in ("wallpapers", "overlay", "mobile"):
(base / sub).mkdir(parents=True, exist_ok=True)
def _list_images(folder: Path) -> list[Path]:
if not folder.exists():
return []
return [
p
for p in folder.iterdir()
if p.is_file() and p.suffix.lower() in _IMAGE_EXTS
]
def _pick_usecase_image(bucket: str) -> Path | None:
uc = _active_usecase()
if not uc:
return None
uid = str(uc.get("id", "")).strip()
if not uid:
return None
files = _list_images(Config.data_dir / "usecases" / uid / bucket)
if not files:
return None
return random.choice(files)
def _pick_mobile_wallpapers() -> tuple[Path, Path] | None:
uc = _active_usecase()
if not uc:
return None
uid = str(uc.get("id", "")).strip()
if not uid:
return None
files = _list_images(Config.data_dir / "usecases" / uid / "mobile")
if not files:
return None
lock_pref = [p for p in files if re.search(r"(lock|locks|screenlock)", p.stem, re.IGNORECASE)]
bg_pref = [p for p in files if re.search(r"(home|bg|wall|back)", p.stem, re.IGNORECASE)]
if lock_pref and bg_pref:
lock = random.choice(lock_pref)
bg_choices = [p for p in bg_pref if p != lock] or bg_pref
bg = random.choice(bg_choices)
return lock, bg
if len(files) >= 2:
a, b = random.sample(files, 2)
return a, b
return files[0], files[0]
def _build_asset_url(request: Request, uid: str, bucket: str, filename: str) -> str:
base = str(request.base_url).rstrip("/")
return f"{base}/api/v4/assets/usecases/{uid}/{bucket}/{filename}"
def _queue_mobile_payload(payload: dict[str, str]) -> int:
active_clients = [cid for cid, meta in _pwa_clients.items() if (time.time() - meta.get("last_seen", 0)) <= 90]
if active_clients:
for cid in active_clients:
_pending_wallpapers[cid] = dict(payload)
return len(active_clients)
_pending_wallpapers["__broadcast__"] = dict(payload)
return 0
def _pick_mobile_wallpaper_payload() -> dict[str, str] | None:
uc = _active_usecase()
if not uc:
return None
uid = str(uc.get("id", "")).strip()
if not uid:
return None
chosen = _pick_mobile_wallpapers()
if not chosen:
return None
lock_uri = _img_to_datauri(chosen[0])
bg_uri = _img_to_datauri(chosen[1])
if not lock_uri or not bg_uri:
return None
return {"lockscreen": lock_uri, "background": bg_uri}
@app.get("/api/v4/health")
async def health() -> dict[str, Any]:
return {
"ok": True,
"service": "notifypulse-v4-server",
"time": utc_now_iso(),
}
@app.get("/api/v4/state")
async def api_state() -> dict[str, Any]:
devices = [d.model_dump() for d in await manager.list_devices()]
logs = await manager.get_logs()
return {
"version": app.version,
"settings": store.get_settings(),
"usecases": store.get_usecases(),
"connected_devices": devices,
"events": logs,
}
@app.get("/api/v4/settings")
async def get_settings() -> dict[str, Any]:
return store.get_settings()
@app.put("/api/v4/settings", dependencies=[Depends(require_token)])
async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
return store.update_settings(payload)
@app.get("/api/v4/usecases")
async def get_usecases() -> dict[str, Any]:
return {"usecases": store.get_usecases()}
class UsecasesPayload(BaseModel):
usecases: list[dict[str, Any]]
@app.put("/api/v4/usecases", dependencies=[Depends(require_token)])
async def put_usecases(payload: UsecasesPayload) -> dict[str, Any]:
updated = store.set_usecases(payload.usecases)
await manager.dispatch_command(
command_id=str(uuid.uuid4()),
action="sync_settings",
data={"settings": store.get_settings(), "usecases": updated},
target_scope="all",
device_ids=[],
)
return {"ok": True, "usecases": updated}
@app.post("/api/v4/dispatch", dependencies=[Depends(require_token)])
async def dispatch(payload: DispatchRequest) -> DispatchResult:
command_id = str(uuid.uuid4())
delivered, skipped = await manager.dispatch_command(
command_id=command_id,
action=payload.action,
data=payload.data,
target_scope=payload.target_scope,
device_ids=payload.device_ids,
)
return DispatchResult(command_id=command_id, delivered_to=delivered, skipped=skipped)
@app.get("/api/v4/update/manifest/{platform}")
async def get_update_manifest(platform: str) -> UpdateManifest:
return store.get_update_manifest(platform)
@app.put("/api/v4/update/manifest/{platform}", dependencies=[Depends(require_token)])
async def put_update_manifest(platform: str, manifest: UpdateManifest) -> dict[str, Any]:
store.set_update_manifest(platform, manifest)
return {"ok": True}
@app.websocket("/ws/device")
async def device_socket(websocket: WebSocket) -> None:
await websocket.accept()
device_id = ""
try:
first = await websocket.receive_json()
if first.get("type") != "hello":
await websocket.send_json({"type": "error", "error": "first frame must be hello"})
await websocket.close(code=1008)
return
token = first.get("token", "")
if Config.api_token and token != Config.api_token:
await websocket.send_json({"type": "error", "error": "invalid token"})
await websocket.close(code=1008)
return
hello = DeviceHello.model_validate(first.get("payload", {}))
device_id = hello.device_id
await manager.connect(hello, websocket)
await websocket.send_json(
{
"type": "welcome",
"server_time": utc_now_iso(),
"settings": store.get_settings(),
}
)
while True:
msg = await websocket.receive_json()
mtype = msg.get("type")
if mtype == "heartbeat":
await manager.touch(device_id)
await websocket.send_json({"type": "heartbeat_ack", "time": utc_now_iso()})
elif mtype == "ack":
await manager.add_event(
{
"event": "command_ack",
"device_id": device_id,
"command_id": msg.get("command_id", ""),
"status": msg.get("status", "ok"),
"detail": msg.get("detail", ""),
}
)
elif mtype == "event":
await manager.add_event(
{
"event": "client_event",
"device_id": device_id,
"name": msg.get("name", ""),
"data": msg.get("data", {}),
}
)
else:
await manager.add_event(
{
"event": "unknown_message",
"device_id": device_id,
"payload": msg,
}
)
except WebSocketDisconnect:
pass
except Exception as exc:
await manager.add_event(
{
"event": "socket_error",
"device_id": device_id,
"error": str(exc),
}
)
finally:
if device_id:
await manager.disconnect(device_id)
# ----------------------------
# Legacy compatibility routes
# ----------------------------
@app.get("/api/usecases")
async def legacy_get_usecases() -> dict[str, Any]:
return {
"usecases": store.get_usecases(),
"active": _active_usecase_id(),
"blocks": ALL_BLOCKS,
}
@app.post("/api/usecases")
async def legacy_create_usecase(payload: dict[str, Any]) -> dict[str, Any]:
name = str(payload.get("name", "")).strip()
if not name:
return {"ok": False, "error": "name required"}
uid = re.sub(r"[^a-z0-9_]", "_", name.lower()) + f"_{random.randint(100,999)}"
uc = {
"id": uid,
"name": name,
"color": payload.get("color", "#4f8ef7"),
"group": str(payload.get("group", "")).strip(),
"blocks": payload.get("blocks", ["notifications"]),
"min_interval": int(payload.get("min_interval", 10)),
"max_interval": int(payload.get("max_interval", 30)),
"notifications": payload.get("notifications", []),
"dashboard_layout": payload.get("dashboard_layout", {}),
}
usecases = store.get_usecases()
usecases.append(uc)
store.set_usecases(usecases)
_ensure_usecase_dirs(uid)
await _append_log(f"Usecase created: {name}")
return {"ok": True, "usecase": uc}
@app.put("/api/usecases/{uid}")
async def legacy_update_usecase(uid: str, payload: dict[str, Any]) -> dict[str, Any]:
usecases = store.get_usecases()
found = None
for uc in usecases:
if uc.get("id") == uid:
uc.update({k: v for k, v in payload.items() if k != "id"})
found = uc
break
if not found:
return {"ok": False, "error": "not found"}
store.set_usecases(usecases)
_ensure_usecase_dirs(uid)
await _append_log(f"Usecase updated: {found.get('name', uid)}")
return {"ok": True, "usecase": found}
@app.delete("/api/usecases/{uid}")
async def legacy_delete_usecase(uid: str) -> dict[str, Any]:
usecases = store.get_usecases()
after = [u for u in usecases if u.get("id") != uid]
if len(after) == len(usecases):
return {"ok": False, "error": "not found"}
store.set_usecases(after)
if _active_usecase_id() == uid:
_set_active_usecase(after[0]["id"] if after else "")
await _append_log(f"Usecase deleted: {uid}", "warn")
return {"ok": True}
@app.post("/api/usecases/{uid}/activate")
async def legacy_activate_usecase(uid: str) -> dict[str, Any]:
usecases = store.get_usecases()
exists = any(u.get("id") == uid for u in usecases)
if not exists:
return {"ok": False, "error": "not found"}
_set_active_usecase(uid)
uc = next((u for u in usecases if u.get("id") == uid), {})
await _append_log(f"Switched to usecase '{uc.get('name', uid)}'")
return {"ok": True, "active": uid}
@app.post("/api/usecases/{uid}/open_folder")
async def legacy_open_usecase_folder(uid: str) -> dict[str, Any]:
_ensure_usecase_dirs(uid)
d = Config.data_dir / "usecases" / uid
return {"ok": True, "path": str(d)}
@app.get("/api/state")
async def legacy_state() -> dict[str, Any]:
settings = store.get_settings()
uc = _active_usecase()
logs_raw = await manager.get_logs()
logs = [_event_to_legacy_log(e) for e in logs_raw[:100]]
connected_devices = [d.model_dump() for d in await manager.list_devices()]
entries: list[dict[str, Any]] = []
if uc:
entries = [_parse_entry(x) for x in uc.get("notifications", []) if str(x).strip()]
min_interval = int((uc or {}).get("min_interval", 10))
next_fire_at = int(time.time()) + (min_interval * 60)
cutoff = time.time() - 60
stale = [k for k, v in _pwa_clients.items() if v.get("last_seen", 0) < cutoff]
for k in stale:
_pwa_clients.pop(k, None)
return {
"app_name": (uc or {}).get("name") or settings.get("app_name", "NotifyPulse"),
"settings_app_name": settings.get("app_name", "NotifyPulse"),
"version": app.version,
"paused": bool(settings.get("paused", False)),
"next_fire_at": next_fire_at,
"entries": entries,
"log": logs,
"pwa_clients": len(_pwa_clients),
"connected_devices": connected_devices,
"settings": settings,
"active_usecase": _active_usecase_id(),
"usecase": uc,
}
@app.post("/api/pause")
async def legacy_pause() -> dict[str, Any]:
settings = store.get_settings()
paused = not bool(settings.get("paused", False))
store.update_settings({"paused": paused})
return {"paused": paused}
@app.post("/api/fire_now")
async def legacy_fire_now(request: Request) -> dict[str, Any]:
uc = _active_usecase()
if not uc:
return {"ok": False, "error": "no active usecase"}
entries = [str(x) for x in uc.get("notifications", []) if str(x).strip()]
if not entries:
return {"ok": False, "error": "No entries"}
entry = random.choice(entries).split("|", 1)[0].strip()
tl = entry.lower()
if tl == "change.wallpaper":
img = _pick_usecase_image("wallpapers")
if not img:
await _append_log("Fire now: wallpaper missing", "warn")
return {"ok": False, "error": "No wallpaper images found"}
uid = str(uc.get("id", ""))
url = _build_asset_url(request, uid, "wallpapers", img.name)
res = await dispatch(
DispatchRequest(
action="wallpaper_set",
data={"url": url},
target_scope="windows",
)
)
await _append_log(f"Fire now: wallpaper -> {img.name}")
return {
"ok": True,
"fired": entry,
"action": "wallpaper_set",
"asset_url": url,
"command_id": res.command_id,
}
if tl.startswith("show.overlay"):
sec = 6
m = re.match(r"^show\.overlay\.(\d+)$", tl)
if m:
sec = max(1, min(60, int(m.group(1))))
img = _pick_usecase_image("overlay")
if not img:
await _append_log("Fire now: overlay missing", "warn")
return {"ok": False, "error": "No overlay images found"}
_pwa_overlay.update(
{
"active": True,
"image": _img_to_datauri(img),
"until_ms": int(time.time() * 1000) + (sec * 1000),
}
)
overlay_url = _build_asset_url(request, str(uc.get("id", "")), "overlay", img.name)
settings = store.get_settings()
opacity = float(settings.get("overlay_opacity", 0.4))
stretch = bool(settings.get("overlay_stretch", False))
res = await dispatch(
DispatchRequest(
action="overlay_show",
data={
"url": overlay_url,
"duration_ms": sec * 1000,
"opacity": opacity,
"stretch": stretch,
},
target_scope="windows",
)
)
await _append_log(f"Fire now: overlay -> {img.name} ({sec}s)")
return {
"ok": True,
"fired": entry,
"action": "overlay_show",
"duration_sec": sec,
"command_id": res.command_id,
}
if tl == "change.wallpaper.mobile":
payload = _pick_mobile_wallpaper_payload()
if not payload:
await _append_log("Fire now: mobile wallpaper missing", "warn")
return {"ok": False, "error": "No mobile images found"}
queued = _queue_mobile_payload(payload)
await _append_log(f"Fire now: mobile wallpaper queued -> {queued} client(s)")
return {"ok": True, "fired": entry}
cmd = DispatchRequest(
action="notify",
data={"title": uc.get("name", "NotifyPulse"), "message": entry},
target_scope="windows",
)
res = await dispatch(cmd)
await _append_log(f"Fire now notification -> {entry}")
return {"ok": True, "fired": entry, "command_id": res.command_id}
@app.get("/api/settings")
async def legacy_get_settings() -> dict[str, Any]:
return store.get_settings()
@app.post("/api/settings")
async def legacy_post_settings(payload: dict[str, Any]) -> dict[str, Any]:
store.update_settings(payload)
return {"ok": True}
@app.get("/api/log")
async def legacy_log(limit: int = 100) -> list[dict[str, Any]]:
logs = await manager.get_logs()
adapted = [_event_to_legacy_log(e) for e in logs]
return adapted[: min(limit, 200)]
@app.post("/api/test_notification")
async def legacy_test_notification(payload: dict[str, Any] | None = None) -> dict[str, Any]:
p = payload or {}
cmd = DispatchRequest(
action="notify",
data={"title": "NotifyPulse", "message": p.get("message", "Test from Web UI")},
target_scope="windows",
)
res = await dispatch(cmd)
if not res.delivered_to:
await _append_log("Desktop test notification: no Windows clients connected", "warn")
return {"ok": False, "error": "No Windows clients connected"}
await _append_log("Desktop test notification sent")
return {"ok": True, "command_id": res.command_id}
@app.post("/api/test_wallpaper")
async def legacy_test_wallpaper(request: Request) -> dict[str, Any]:
uc = _active_usecase()
if not uc:
return {"ok": False, "error": "No active usecase"}
img = _pick_usecase_image("wallpapers")
if not img:
await _append_log("Desktop test wallpaper: no wallpaper images found", "warn")
return {"ok": False, "error": "No images in wallpapers folder"}
uid = str(uc.get("id", ""))
url = _build_asset_url(request, uid, "wallpapers", img.name)
res = await dispatch(
DispatchRequest(
action="wallpaper_set",
data={"url": url},
target_scope="windows",
)
)
if not res.delivered_to:
await _append_log("Desktop test wallpaper: no Windows clients connected", "warn")
return {"ok": False, "error": "No Windows clients connected"}
await _append_log(f"Desktop test wallpaper -> {img.name}")
return {"ok": True, "command_id": res.command_id}
@app.post("/api/test_overlay")
async def legacy_test_overlay(request: Request) -> dict[str, Any]:
uc = _active_usecase()
if not uc:
return {"ok": False, "error": "No active usecase"}
img = _pick_usecase_image("overlay")
if not img:
await _append_log("Desktop test overlay: no overlay images found", "warn")
return {"ok": False, "error": "No images in overlay folder"}
uid = str(uc.get("id", ""))
url = _build_asset_url(request, uid, "overlay", img.name)
settings = store.get_settings()
dur_ms = int(float(settings.get("overlay_duration", 6)) * 1000)
opacity = float(settings.get("overlay_opacity", 0.4))
stretch = bool(settings.get("overlay_stretch", False))
_pwa_overlay.update(
{
"active": True,
"image": _img_to_datauri(img),
"until_ms": int(time.time() * 1000) + dur_ms,
}
)
res = await dispatch(
DispatchRequest(
action="overlay_show",
data={
"url": url,
"duration_ms": dur_ms,
"opacity": opacity,
"stretch": stretch,
},
target_scope="windows",
)
)
if not res.delivered_to:
await _append_log("Desktop test overlay: no Windows clients connected", "warn")
return {"ok": False, "error": "No Windows clients connected"}
await _append_log(f"Desktop test overlay -> {img.name}")
return {"ok": True, "command_id": res.command_id}
@app.post("/api/test_mobile_wallpaper")
async def legacy_test_mobile() -> dict[str, Any]:
payload = _pick_mobile_wallpaper_payload()
if not payload:
await _append_log("Mobile test wallpaper: no mobile images found", "warn")
return {"ok": False, "error": "no mobile wallpaper found for active usecase"}
queued = _queue_mobile_payload(payload)
await _append_log(f"Mobile test wallpaper queued -> {queued} client(s)")
return {"ok": True}
@app.post("/api/pwa/ping")
async def legacy_pwa_ping(payload: dict[str, Any]) -> dict[str, Any]:
cid = str(payload.get("client_id", "")).strip()
if not cid:
return {"error": "no client_id"}
_pwa_clients[cid] = {"last_seen": time.time()}
return {"ok": True, "clients": len(_pwa_clients)}
@app.get("/api/pwa/wallpaper")
async def legacy_pwa_wallpaper(client_id: str = "") -> dict[str, Any]:
cid = client_id.strip()
if not cid:
return {"pending": False}
cmd = _pending_wallpapers.pop(cid, None) or _pending_wallpapers.pop("__broadcast__", None)
if not cmd:
return {"pending": False}
return {"pending": True, **cmd}
@app.get("/api/pwa/app_name")
async def legacy_pwa_app_name() -> dict[str, Any]:
settings = store.get_settings()
return {
"app_name": (_active_usecase() or {}).get("name") or settings.get("app_name", "NotifyPulse"),
"settings_app_name": settings.get("app_name", "NotifyPulse"),
"active": _active_usecase_id(),
"usecases": [
{
"id": u.get("id", ""),
"name": u.get("name", ""),
"color": u.get("color", "#4f8ef7"),
"group": u.get("group", ""),
"blocks": u.get("blocks", []),
"min_interval": u.get("min_interval", 10),
"max_interval": u.get("max_interval", 30),
}
for u in store.get_usecases()
],
"version": app.version,
"blocks": ALL_BLOCKS,
"pwa_bg_blur": settings.get("pwa_bg_blur", 18),
"pwa_bg_opacity": settings.get("pwa_bg_opacity", 0.72),
}
@app.get("/api/pwa/splash_image")
async def legacy_pwa_splash() -> dict[str, Any]:
splash_dir = Config.data_dir / "splash"
candidates: list[Path] = []
if splash_dir.exists():
candidates = [
p
for p in splash_dir.iterdir()
if p.is_file() and p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}
]
if not candidates:
for name in ("main.png", "main.jpg", "main.jpeg", "main.webp"):
p = Config.data_dir / name
if p.exists():
candidates = [p]
break
if not candidates:
return {"image": "", "has_custom": False}
chosen = random.choice(candidates)
return {"image": _img_to_datauri(chosen) or "", "has_custom": True}
@app.get("/api/pwa/overlay")
async def legacy_pwa_overlay() -> dict[str, Any]:
if not _pwa_overlay.get("active"):
return {"active": False, "image": None}
remaining = int(_pwa_overlay.get("until_ms", 0) - (time.time() * 1000))
if remaining <= 0:
_pwa_overlay.update({"active": False, "image": None, "until_ms": 0})
return {"active": False, "image": None}
return {"active": True, "image": _pwa_overlay.get("image"), "remaining_ms": remaining}
@app.post("/api/pwa/trigger_wallpaper")
async def legacy_pwa_trigger_wallpaper() -> dict[str, Any]:
payload = _pick_mobile_wallpaper_payload()
if not payload:
await _append_log("PWA wallpaper request: no mobile images found", "warn")
return {"ok": False, "error": "no mobile wallpaper found for active usecase"}
queued = _queue_mobile_payload(payload)
await _append_log(f"PWA wallpaper queued -> {queued} client(s)")
return {"ok": True}
@app.post("/api/pwa/activate_usecase")
async def legacy_pwa_activate_usecase(payload: dict[str, Any]) -> dict[str, Any]:
uid = str(payload.get("usecase_id", "")).strip()
usecases = store.get_usecases()
uc = next((u for u in usecases if u.get("id") == uid), None)
if not uc:
return {"ok": False, "error": "not found"}
_set_active_usecase(uid)
return {"ok": True, "name": uc.get("name", "")}
@app.get("/api/v4/assets/usecases/{uid}/{bucket}/{filename}")
async def usecase_asset(uid: str, bucket: str, filename: str):
if bucket not in {"wallpapers", "overlay", "mobile"}:
raise HTTPException(status_code=404, detail="invalid asset bucket")
if "/" in filename or "\\" in filename:
raise HTTPException(status_code=400, detail="invalid filename")
path = (Config.data_dir / "usecases" / uid / bucket / filename).resolve()
root = (Config.data_dir / "usecases" / uid / bucket).resolve()
if root not in path.parents and path != root:
raise HTTPException(status_code=400, detail="invalid asset path")
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail="asset not found")
return FileResponse(path)
def _safe_file(path: Path) -> FileResponse | JSONResponse:
if path.exists() and path.is_file():
return FileResponse(path)
return JSONResponse(
{"error": "file not found", "path": str(path)},
status_code=404,
)
@app.get("/")
async def web_ui_index():
return _safe_file(Config.ui_dir / "index.html")
@app.get("/pwa")
@app.get("/pwa/")
async def pwa_index():
return _safe_file(Config.pwa_dir / "index.html")
@app.get("/pwa/{filename:path}")
async def pwa_assets(filename: str):
return _safe_file(Config.pwa_dir / filename)

58
v4/server/app/models.py Normal file
View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Literal
from pydantic import BaseModel, Field
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class Platform(str, Enum):
windows = "windows"
pwa = "pwa"
web = "web"
unknown = "unknown"
class DeviceHello(BaseModel):
device_id: str = Field(min_length=3, max_length=128)
platform: Platform = Platform.unknown
version: str = "0.0.0"
hostname: str = ""
capabilities: list[str] = Field(default_factory=list)
class ConnectedDevice(BaseModel):
device_id: str
platform: Platform
version: str
hostname: str = ""
capabilities: list[str] = Field(default_factory=list)
connected_at: str = Field(default_factory=utc_now_iso)
last_seen_at: str = Field(default_factory=utc_now_iso)
class DispatchRequest(BaseModel):
action: Literal["notify", "wallpaper_set", "overlay_show", "sync_settings", "ping"]
data: dict[str, Any] = Field(default_factory=dict)
target_scope: Literal["all", "windows", "pwa", "web", "device_ids"] = "all"
device_ids: list[str] = Field(default_factory=list)
class DispatchResult(BaseModel):
command_id: str
delivered_to: list[str] = Field(default_factory=list)
skipped: list[str] = Field(default_factory=list)
class UpdateManifest(BaseModel):
version: str = "0.0.0"
download_url: str = ""
sha256: str = ""
notes: str = ""
published_at: str = Field(default_factory=utc_now_iso)

112
v4/server/app/store.py Normal file
View File

@@ -0,0 +1,112 @@
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Any
from .config import Config
from .models import UpdateManifest
DEFAULT_SETTINGS: dict[str, Any] = {
"app_name": "NotifyPulse",
"active_usecase": "",
"paused": False,
"hotkey": "F13",
"startup_toast": True,
"notify_sound": True,
"auto_open_browser": True,
"minimize_to_tray": True,
"run_on_startup": False,
"confirm_delete": True,
"entry_display_mode": "percent",
"notification_duration": 5,
"overlay_opacity": 0.4,
"overlay_duration": 6,
"overlay_stretch": False,
"overlay_monitor": 0,
"wallpaper_fit": "fill",
"pwa_bg_blur": 18,
"pwa_bg_opacity": 0.72,
"log_max_entries": 100,
"webui_refresh_ms": 1000,
}
DEFAULT_USECASES: list[dict[str, Any]] = []
DEFAULT_UPDATE_MANIFEST: dict[str, Any] = {
"windows": UpdateManifest().model_dump(),
}
class JsonStore:
def __init__(self, data_dir: Path):
self._data_dir = data_dir
self._lock = threading.RLock()
self._settings_file = data_dir / "settings.json"
self._usecases_file = data_dir / "usecases.json"
self._update_file = data_dir / "update_manifest.json"
self._init_defaults()
def _init_defaults(self) -> None:
with self._lock:
self._ensure_file(self._settings_file, DEFAULT_SETTINGS)
self._ensure_file(self._usecases_file, DEFAULT_USECASES)
self._ensure_file(self._update_file, DEFAULT_UPDATE_MANIFEST)
@staticmethod
def _write_json(path: Path, payload: Any) -> None:
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def _ensure_file(self, path: Path, payload: Any) -> None:
if not path.exists():
self._write_json(path, payload)
@staticmethod
def _read_json(path: Path, fallback: Any) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return fallback
def get_settings(self) -> dict[str, Any]:
with self._lock:
raw = self._read_json(self._settings_file, {})
return {**DEFAULT_SETTINGS, **raw}
def update_settings(self, patch: dict[str, Any]) -> dict[str, Any]:
with self._lock:
current = self.get_settings()
current.update(patch)
self._write_json(self._settings_file, current)
return current
def get_usecases(self) -> list[dict[str, Any]]:
with self._lock:
raw = self._read_json(self._usecases_file, DEFAULT_USECASES)
return raw if isinstance(raw, list) else []
def set_usecases(self, payload: list[dict[str, Any]]) -> list[dict[str, Any]]:
with self._lock:
self._write_json(self._usecases_file, payload)
return payload
def get_update_manifest(self, platform: str) -> UpdateManifest:
with self._lock:
raw = self._read_json(self._update_file, DEFAULT_UPDATE_MANIFEST)
platform_raw = raw.get(platform) if isinstance(raw, dict) else None
if not isinstance(platform_raw, dict):
return UpdateManifest()
return UpdateManifest.model_validate(platform_raw)
def set_update_manifest(self, platform: str, manifest: UpdateManifest) -> None:
with self._lock:
raw = self._read_json(self._update_file, DEFAULT_UPDATE_MANIFEST)
if not isinstance(raw, dict):
raw = {}
raw[platform] = manifest.model_dump()
self._write_json(self._update_file, raw)
Config.ensure_dirs()
store = JsonStore(Config.data_dir)

View File

@@ -0,0 +1,3 @@
fastapi>=0.115
uvicorn[standard]>=0.30
pydantic>=2.8

View File

@@ -0,0 +1,12 @@
{
"app_name": "NotifyPulse",
"active_usecase": "",
"paused": false,
"notification_duration": 5,
"overlay_opacity": 0.4,
"overlay_duration": 6,
"wallpaper_fit": "fill",
"pwa_bg_blur": 18,
"pwa_bg_opacity": 0.72,
"webui_refresh_ms": 1000
}

View File

@@ -0,0 +1,9 @@
{
"windows": {
"version": "0.0.0",
"download_url": "",
"sha256": "",
"notes": "",
"published_at": "2026-05-21T19:37:28.631621+00:00"
}
}

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,39 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['client.py'],
pathex=[],
binaries=[],
datas=[('agent_config.example.json', '.')],
hiddenimports=['websockets', 'requests', 'winotify', 'pystray', 'PIL'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='NotifyPulseAgent-V4',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['icon.ico'],
)

Binary file not shown.

View File

@@ -0,0 +1,4 @@
{
"server_url": "http://192.168.178.122:8080",
"api_token": "replace-with-your-token"
}

View File

@@ -0,0 +1,4 @@
{
"server_url": "http://192.168.178.122:8080",
"api_token": "123456789"
}

View File

@@ -0,0 +1,46 @@
@echo off
setlocal
echo ============================================
echo NotifyPulse Agent V4 - Build
echo ============================================
echo.
where python >nul 2>&1
if errorlevel 1 (
echo ERROR: Python not found in PATH
pause & exit /b 1
)
set "PY=python"
where py >nul 2>&1
if not errorlevel 1 set "PY=py -3"
echo [1/2] Installing dependencies...
%PY% -m pip install -r requirements.txt --quiet
if errorlevel 1 ( echo FAILED & pause & exit /b 1 )
echo [2/2] Building executable...
set "ICON_ARG="
if exist "icon.ico" set "ICON_ARG=--icon icon.ico"
%PY% -m PyInstaller --noconfirm --onefile --windowed ^
--name "NotifyPulseAgent-V4" ^
%ICON_ARG% ^
--hidden-import=websockets ^
--hidden-import=requests ^
--hidden-import=winotify ^
--hidden-import=pystray ^
--hidden-import=PIL ^
--add-data "agent_config.example.json;." ^
client.py
if errorlevel 1 (
echo BUILD FAILED
pause & exit /b 1
)
echo.
echo ============================================
echo SUCCESS: dist\NotifyPulseAgent-V4.exe
echo ============================================
pause

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,279 @@
This file lists modules PyInstaller was not able to find. This does not
necessarily mean these modules are required for running your program. Both
Python's standard library and 3rd-party Python packages often conditionally
import optional modules, some of which may be available only on certain
platforms.
Types of import:
* top-level: imported at the top-level - look at these first
* conditional: imported within an if-statement
* delayed: imported within a function
* optional: imported within a try-except-statement
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib._local (optional), subprocess (delayed, conditional, optional), setuptools._distutils.util (delayed, conditional, optional), netrc (delayed, optional), getpass (delayed, optional), setuptools._vendor.backports.tarfile (optional), setuptools._distutils.archive_util (optional), http.server (delayed, optional)
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib._local (optional), subprocess (delayed, conditional, optional), setuptools._vendor.backports.tarfile (optional), setuptools._distutils.archive_util (optional)
missing module named 'collections.abc' - imported by logging (top-level), inspect (top-level), typing (top-level), importlib.resources.readers (top-level), selectors (top-level), tracemalloc (top-level), traceback (top-level), asyncio.base_events (top-level), http.client (top-level), asyncio.coroutines (top-level), setuptools (top-level), setuptools._distutils.filelist (top-level), setuptools._distutils.util (top-level), setuptools._vendor.jaraco.functools (top-level), setuptools._vendor.more_itertools.more (top-level), setuptools._distutils._modified (top-level), setuptools._distutils.compat (top-level), setuptools._distutils.spawn (top-level), typing_extensions (top-level), setuptools._distutils.compilers.C.base (top-level), setuptools._distutils.fancy_getopt (top-level), setuptools._reqs (top-level), setuptools._vendor.jaraco.context (top-level), setuptools.discovery (top-level), setuptools.dist (top-level), setuptools._vendor.importlib_metadata (top-level), setuptools._vendor.importlib_metadata._meta (top-level), setuptools._distutils.command.bdist (top-level), setuptools._distutils.core (top-level), setuptools._distutils.cmd (top-level), setuptools._distutils.dist (top-level), configparser (top-level), setuptools._distutils.extension (top-level), setuptools.config.setupcfg (top-level), setuptools.config.expand (top-level), setuptools.config.pyprojecttoml (top-level), setuptools.config._apply_pyprojecttoml (top-level), setuptools.extension (top-level), tomllib._parser (top-level), setuptools._vendor.tomli._parser (conditional), setuptools.wheel (top-level), setuptools.command.egg_info (top-level), setuptools.command.bdist_egg (top-level), setuptools.command.sdist (top-level), setuptools._distutils.command.build (top-level), setuptools._distutils.command.sdist (top-level), setuptools.glob (top-level), setuptools.command._requirestxt (top-level), setuptools.command.bdist_wheel (top-level), requests.compat (top-level), websockets.imports (top-level), websockets.asyncio.client (top-level), websockets.client (top-level), websockets.datastructures (top-level), websockets.frames (top-level), websockets.extensions.base (top-level), websockets.http11 (top-level), websockets.headers (top-level), websockets.protocol (top-level), websockets.streams (top-level), websockets.extensions.permessage_deflate (top-level), websockets.asyncio.connection (top-level), websockets.asyncio.messages (top-level), websockets.asyncio.server (top-level), websockets.server (top-level), werkzeug.wrappers.request (top-level), werkzeug.datastructures.accept (top-level), werkzeug.datastructures.structures (top-level), markupsafe (top-level), werkzeug.datastructures.cache_control (top-level), werkzeug.datastructures.mixins (top-level), werkzeug.datastructures.auth (top-level), werkzeug.datastructures.csp (top-level), werkzeug.datastructures.etag (top-level), werkzeug.datastructures.file_storage (top-level), werkzeug.datastructures.headers (top-level), werkzeug.datastructures.range (top-level), werkzeug.middleware.shared_data (top-level), PIL.Image (top-level), PIL._typing (top-level), numpy._typing._array_like (top-level), numpy._typing._nested_sequence (conditional), numpy._typing._shape (top-level), numpy._typing._dtype_like (top-level), numpy.lib._function_base_impl (top-level), numpy.lib._npyio_impl (top-level), yaml.constructor (top-level), numpy.random._common (top-level), numpy.random._generator (top-level), numpy.random.bit_generator (top-level), numpy.random.mtrand (top-level), numpy.polynomial._polybase (top-level), xml.etree.ElementTree (top-level), PIL.TiffImagePlugin (top-level), PIL.ImageOps (top-level), PIL.ImagePalette (top-level), PIL.GimpGradientFile (conditional), PIL.ImageFilter (top-level), PIL.ImageQt (conditional), PIL.ImageMath (conditional), PIL.ImageSequence (conditional), PIL.PngImagePlugin (conditional), PIL.ImageDraw (top-level), PIL._imagingft (top-level), websockets.legacy.auth (top-level), websockets.legacy.server (top-level), websockets.legacy.protocol (top-level), websockets.legacy.framing (top-level), websockets.legacy.client (top-level), websockets.sync.client (top-level), websockets.sync.connection (top-level), websockets.sync.server (top-level), PIL.Jpeg2KImagePlugin (conditional), setuptools._distutils.command.build_ext (top-level), _pyrepl.types (top-level), _pyrepl.readline (top-level), setuptools._distutils.compilers.C.msvc (top-level)
missing module named posix - imported by os (conditional, optional), posixpath (optional), shutil (conditional), importlib._bootstrap_external (conditional), _pyrepl.unix_console (delayed, optional)
missing module named resource - imported by posix (top-level)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
missing module named annotationlib - imported by typing_extensions (conditional)
missing module named _posixsubprocess - imported by subprocess (conditional), multiprocessing.util (delayed)
missing module named fcntl - imported by subprocess (optional), _pyrepl.unix_console (top-level)
missing module named _typeshed - imported by setuptools._distutils.dist (conditional), setuptools.command.bdist_egg (conditional), setuptools.glob (conditional), setuptools._vendor.wheel.wheelfile (conditional), setuptools.compat.py311 (conditional), numpy.random.bit_generator (top-level)
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
missing module named _scproxy - imported by urllib.request (conditional)
missing module named termios - imported by getpass (optional), tty (top-level), _pyrepl.pager (delayed, optional), werkzeug._reloader (delayed, optional), _pyrepl.unix_console (top-level), _pyrepl.fancy_termios (top-level), _pyrepl.unix_eventqueue (top-level)
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.Value - imported by multiprocessing (top-level), werkzeug.debug (top-level)
missing module named usercustomize - imported by site (delayed, optional)
missing module named sitecustomize - imported by site (delayed, optional)
missing module named _curses - imported by curses (top-level), curses.has_key (top-level), _pyrepl.curses (optional)
missing module named readline - imported by code (delayed, conditional, optional), cmd (delayed, conditional, optional), pdb (delayed, conditional, optional), rlcompleter (optional), websockets.cli (delayed, optional), site (delayed, optional)
missing module named _manylinux - imported by packaging._manylinux (delayed, optional), setuptools._vendor.packaging._manylinux (delayed, optional)
missing module named setuptools._vendor.backports.zstd - imported by setuptools._vendor.backports (top-level), urllib3.util.request (conditional, optional), urllib3.response (conditional, optional)
missing module named importlib_resources - imported by setuptools._vendor.jaraco.text (optional)
missing module named trove_classifiers - imported by setuptools.config._validate_pyproject.formats (optional)
missing module named pyimod02_importers - imported by C:\Users\timoh\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed)
missing module named '_typeshed.wsgi' - imported by werkzeug._internal (conditional), werkzeug.exceptions (conditional), werkzeug.http (conditional), werkzeug.wsgi (conditional), werkzeug.utils (conditional), werkzeug.wrappers.response (conditional), werkzeug.test (conditional), werkzeug.datastructures.headers (conditional), werkzeug.formparser (conditional), werkzeug.wrappers.request (conditional), werkzeug.serving (conditional), werkzeug.debug (conditional), werkzeug.middleware.shared_data (conditional), werkzeug.routing.exceptions (conditional), werkzeug.routing.map (conditional)
missing module named 'watchdog.observers' - imported by werkzeug._reloader (delayed)
missing module named 'watchdog.events' - imported by werkzeug._reloader (delayed)
missing module named watchdog - imported by werkzeug._reloader (delayed)
missing module named cryptography - imported by urllib3.contrib.pyopenssl (top-level), requests (conditional, optional), werkzeug.serving (delayed, optional)
missing module named 'cryptography.x509' - imported by urllib3.contrib.pyopenssl (delayed, optional), werkzeug.serving (delayed, conditional, optional)
missing module named 'cryptography.hazmat' - imported by werkzeug.serving (delayed, conditional, optional)
missing module named 'python_socks.sync' - imported by websockets.sync.client (optional)
missing module named python_socks - imported by websockets.asyncio.client (optional), websockets.sync.client (optional)
missing module named 'python_socks.async_' - imported by websockets.asyncio.client (optional)
missing module named _dummy_thread - imported by numpy._core.arrayprint (optional)
missing module named 'numpy_distutils.cpuinfo' - imported by numpy.f2py.diagnose (delayed, conditional, optional)
missing module named 'numpy_distutils.fcompiler' - imported by numpy.f2py.diagnose (delayed, conditional, optional)
missing module named 'numpy_distutils.command' - imported by numpy.f2py.diagnose (delayed, conditional, optional)
missing module named numpy_distutils - imported by numpy.f2py.diagnose (delayed, optional)
missing module named psutil - imported by numpy.testing._private.utils (delayed, optional)
missing module named win32pdh - imported by numpy.testing._private.utils (delayed, conditional)
missing module named numpy.random.RandomState - imported by numpy.random (top-level), numpy.random._generator (top-level)
missing module named pyodide_js - imported by threadpoolctl (delayed, optional)
missing module named numpy._core.zeros - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.vstack - imported by numpy._core (top-level), numpy.lib._shape_base_impl (top-level), numpy (conditional)
missing module named numpy._core.void - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.vecmat - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.vecdot - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.ushort - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.unsignedinteger - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.ulonglong - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.ulong - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.uintp - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.uintc - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.uint64 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
missing module named numpy._core.uint32 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
missing module named numpy._core.uint16 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
missing module named numpy._core.uint - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.ubyte - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.trunc - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.true_divide - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.transpose - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.lib._function_base_impl (top-level), numpy (conditional)
missing module named numpy._core.trace - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.timedelta64 - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.tensordot - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.tanh - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.tan - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.swapaxes - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.sum - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.subtract - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.str_ - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.square - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.sqrt - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.fft._pocketfft (top-level)
missing module named numpy._core.spacing - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.sort - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.sinh - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.single - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.signedinteger - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.signbit - imported by numpy._core (delayed), numpy.testing._private.utils (delayed), numpy (conditional)
missing module named numpy._core.sign - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.short - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.rint - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.right_shift - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.result_type - imported by numpy._core (delayed), numpy.testing._private.utils (delayed), numpy (conditional), numpy.fft._pocketfft (top-level)
missing module named numpy._core.remainder - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.reciprocal - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.fft._pocketfft (top-level)
missing module named numpy._core.radians - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.rad2deg - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.prod - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.power - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.positive - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.pi - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.outer - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.ones - imported by numpy._core (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional)
missing module named numpy._core.object_ - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.testing._private.utils (delayed), numpy (conditional)
missing module named numpy._core.number - imported by numpy._core (delayed), numpy.testing._private.utils (delayed), numpy (conditional)
missing module named numpy._core.not_equal - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.nextafter - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.newaxis - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.negative - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.ndarray - imported by numpy._core (top-level), numpy.testing._private.utils (top-level), numpy.lib._utils_impl (top-level), numpy (conditional)
missing module named numpy._core.multiply - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.moveaxis - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.modf - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.mod - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.minimum - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.maximum - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.max - imported by numpy._core (delayed), numpy.testing._private.utils (delayed), numpy (conditional)
missing module named numpy._core.matvec - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.matrix_transpose - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.matmul - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.longlong - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.longdouble - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.long - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.logical_xor - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.logical_or - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.logical_not - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.logical_and - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.logaddexp2 - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.logaddexp - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.log10 - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.log2 - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.log1p - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.log - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.linspace - imported by numpy._core (top-level), numpy.lib._index_tricks_impl (top-level), numpy (conditional)
missing module named numpy._core.less_equal - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.less - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.left_shift - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.ldexp - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.lcm - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.isscalar - imported by numpy._core (delayed), numpy.testing._private.utils (delayed), numpy.lib._polynomial_impl (top-level), numpy (conditional)
missing module named numpy._core.isnat - imported by numpy._core (top-level), numpy.testing._private.utils (top-level), numpy (conditional)
missing module named numpy._core.isnan - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.testing._private.utils (delayed), numpy (conditional)
missing module named numpy._core.isfinite - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.intp - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.testing._private.utils (top-level), numpy (conditional), numpy._array_api_info (top-level)
missing module named numpy._core.integer - imported by numpy._core (conditional), numpy (conditional), numpy.fft._helper (top-level)
missing module named numpy._core.intc - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.int64 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
missing module named numpy._core.int32 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
missing module named numpy._core.int16 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
missing module named numpy._core.int8 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
missing module named numpy._core.inf - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.testing._private.utils (delayed), numpy (conditional)
missing module named numpy._core.inexact - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.iinfo - imported by numpy._core (top-level), numpy.lib._twodim_base_impl (top-level), numpy (conditional)
missing module named numpy._core.hypot - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.hstack - imported by numpy._core (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional)
missing module named numpy._core.heaviside - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.half - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.greater_equal - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.greater - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.gcd - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.frompyfunc - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.frexp - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.fmod - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.fmin - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.fmax - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.floor_divide - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.floor - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.floating - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.float_power - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.float32 - imported by numpy._core (top-level), numpy.testing._private.utils (top-level), numpy (conditional), numpy._array_api_info (top-level)
missing module named numpy._core.float16 - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.finfo - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional)
missing module named numpy._core.fabs - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.expm1 - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.exp2 - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.exp - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.euler_gamma - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.errstate - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.testing._private.utils (delayed), numpy (conditional)
missing module named numpy._core.equal - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.empty_like - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional), numpy.fft._pocketfft (top-level)
missing module named numpy._core.empty - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.testing._private.utils (top-level), numpy (conditional), numpy.fft._helper (top-level)
missing module named numpy._core.e - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.double - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.dot - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional)
missing module named numpy._core.divmod - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.divide - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.diagonal - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.degrees - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.deg2rad - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.datetime64 - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.csingle - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.cross - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.count_nonzero - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.cosh - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.cos - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.copysign - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.conjugate - imported by numpy._core (conditional), numpy (conditional), numpy.fft._pocketfft (top-level)
missing module named numpy._core.conj - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.complexfloating - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.complex64 - imported by numpy._core (conditional), numpy (conditional), numpy._array_api_info (top-level)
missing module named numpy._core.clongdouble - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.character - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.ceil - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.cdouble - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.cbrt - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.bytes_ - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.byte - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.bool_ - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.bitwise_xor - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.bitwise_or - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.bitwise_count - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.bitwise_and - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.atleast_3d - imported by numpy._core (top-level), numpy.lib._shape_base_impl (top-level), numpy (conditional)
missing module named numpy._core.atleast_2d - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.atleast_1d - imported by numpy._core (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional)
missing module named numpy._core.asarray - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.lib._array_utils_impl (top-level), numpy (conditional), numpy.fft._helper (top-level), numpy.fft._pocketfft (top-level)
missing module named numpy._core.asanyarray - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.array_repr - imported by numpy._core (top-level), numpy.testing._private.utils (top-level), numpy (conditional)
missing module named numpy._core.array2string - imported by numpy._core (delayed), numpy.testing._private.utils (delayed), numpy (conditional)
missing module named numpy._core.array - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.testing._private.utils (top-level), numpy.lib._polynomial_impl (top-level), numpy (conditional)
missing module named numpy._core.argsort - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.arctanh - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.arctan2 - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.arctan - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.arcsinh - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.arcsin - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.arccosh - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.arccos - imported by numpy._core (conditional), numpy (conditional)
missing module named numpy._core.arange - imported by numpy._core (top-level), numpy.testing._private.utils (top-level), numpy (conditional), numpy.fft._helper (top-level)
missing module named numpy._core.amin - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.amax - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._core.all - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy.testing._private.utils (delayed), numpy (conditional)
missing module named numpy._core.add - imported by numpy._core (top-level), numpy.linalg._linalg (top-level), numpy (conditional)
missing module named numpy._distributor_init_local - imported by numpy (optional), numpy._distributor_init (optional)
missing module named olefile - imported by PIL.FpxImagePlugin (top-level), PIL.MicImagePlugin (top-level)
missing module named defusedxml - imported by PIL.Image (optional)
missing module named 'gi.repository' - imported by pystray._appindicator (top-level), pystray._util.gtk (top-level), pystray._util.notify_dbus (top-level), pystray._gtk (top-level)
missing module named gi - imported by pystray._appindicator (top-level), pystray._util.gtk (top-level), pystray._util.notify_dbus (top-level), pystray._gtk (top-level)
runtime module named six.moves - imported by pystray._base (top-level), pystray._win32 (top-level), pystray._xorg (top-level)
missing module named StringIO - imported by six (conditional)
missing module named 'Xlib.XK' - imported by pystray._xorg (top-level)
missing module named 'Xlib.threaded' - imported by pystray._xorg (top-level)
missing module named Xlib - imported by pystray._xorg (top-level)
missing module named PyObjCTools - imported by pystray._darwin (top-level)
missing module named objc - imported by pystray._darwin (top-level)
missing module named Foundation - imported by pystray._darwin (top-level)
missing module named AppKit - imported by pystray._darwin (top-level)
missing module named simplejson - imported by requests.compat (conditional, optional)
missing module named dummy_threading - imported by requests.cookies (optional)
missing module named compression - imported by urllib3.util.request (conditional, optional), urllib3.response (conditional, optional)
missing module named 'h2.events' - imported by urllib3.http2.connection (top-level)
missing module named 'h2.connection' - imported by urllib3.http2.connection (top-level)
missing module named h2 - imported by urllib3.http2.connection (top-level)
missing module named brotli - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named brotlicffi - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named socks - imported by urllib3.contrib.socks (optional)
missing module named 'OpenSSL.crypto' - imported by urllib3.contrib.pyopenssl (delayed, conditional)
missing module named OpenSSL - imported by urllib3.contrib.pyopenssl (top-level)
missing module named chardet - imported by requests (optional)
missing module named 'pyodide.ffi' - imported by urllib3.contrib.emscripten.fetch (delayed, optional)
missing module named pyodide - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named js - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named vms_lib - imported by platform (delayed, optional)
missing module named 'java.lang' - imported by platform (delayed, optional)
missing module named java - imported by platform (delayed)
missing module named asyncio.DefaultEventLoopPolicy - imported by asyncio (delayed, conditional), asyncio.events (delayed, conditional)

File diff suppressed because it is too large Load Diff

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()

Binary file not shown.

BIN
v4/windows_client/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
v4/windows_client/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -0,0 +1,6 @@
websockets>=12.0
requests>=2.32
winotify>=1.1
pystray>=0.19
Pillow>=10.0
pyinstaller>=6.0