Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1adec1b88f |
+167
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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.
Binary file not shown.
@@ -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)
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.30
|
||||||
|
pydantic>=2.8
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"windows": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"download_url": "",
|
||||||
|
"sha256": "",
|
||||||
|
"notes": "",
|
||||||
|
"published_at": "2026-05-21T19:37:28.631621+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -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.
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"server_url": "http://192.168.178.122:8080",
|
||||||
|
"api_token": "replace-with-your-token"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"server_url": "http://192.168.178.122:8080",
|
||||||
|
"api_token": "123456789"
|
||||||
|
}
|
||||||
@@ -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
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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()
|
||||||
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,6 @@
|
|||||||
|
websockets>=12.0
|
||||||
|
requests>=2.32
|
||||||
|
winotify>=1.1
|
||||||
|
pystray>=0.19
|
||||||
|
Pillow>=10.0
|
||||||
|
pyinstaller>=6.0
|
||||||
Reference in New Issue
Block a user