Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1adec1b88f |
167
v4/README.md
Normal file
167
v4/README.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# NotifyPulse V4 (Server + Clients)
|
||||||
|
|
||||||
|
This V4 scaffold splits NotifyPulse into:
|
||||||
|
|
||||||
|
- Linux-hosted server (`v4/server`) for API, Web UI, PWA, settings, command routing.
|
||||||
|
- Windows agent (`v4/windows_client`) built as `.exe`, connected to server by WebSocket.
|
||||||
|
- Shared command protocol with ack/event flow.
|
||||||
|
|
||||||
|
V3 stays untouched in the repo root (`notifier.py`).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
1. Server is the source of truth for settings, usecases, and command dispatch.
|
||||||
|
2. Windows client registers itself and executes device-local actions:
|
||||||
|
- toast notifications
|
||||||
|
- wallpaper changes
|
||||||
|
- (overlay hook included, implementation placeholder)
|
||||||
|
3. PWA/web clients connect to the server and use the same API.
|
||||||
|
4. Updates are centralized with server-hosted update manifests:
|
||||||
|
- `GET /api/v4/update/manifest/windows`
|
||||||
|
- Client checks and reports update availability.
|
||||||
|
|
||||||
|
## Quick start (Linux server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd v4/server
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Server defaults:
|
||||||
|
|
||||||
|
- Web UI: `http://<server>:8080/`
|
||||||
|
- PWA: `http://<server>:8080/pwa/`
|
||||||
|
- Device WebSocket: `ws://<server>:8080/ws/device`
|
||||||
|
|
||||||
|
## Deploy on Linux (systemd)
|
||||||
|
|
||||||
|
Example target folder: `/opt/notifypulse`.
|
||||||
|
|
||||||
|
1. Copy repo to server and install deps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/notifypulse
|
||||||
|
sudo chown -R $USER:$USER /opt/notifypulse
|
||||||
|
cd /opt/notifypulse
|
||||||
|
python3 -m venv v4/server/.venv
|
||||||
|
source v4/server/.venv/bin/activate
|
||||||
|
pip install -r v4/server/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create env file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /opt/notifypulse/v4/server/.env <<'EOF'
|
||||||
|
NP4_HOST=0.0.0.0
|
||||||
|
NP4_PORT=8080
|
||||||
|
NP4_API_TOKEN=replace-with-long-random-token
|
||||||
|
NP4_DATA_DIR=/opt/notifypulse/v4_data
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create systemd service `/etc/systemd/system/notifypulse-v4.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=NotifyPulse V4 Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/opt/notifypulse/v4/server
|
||||||
|
EnvironmentFile=/opt/notifypulse/v4/server/.env
|
||||||
|
ExecStart=/opt/notifypulse/v4/server/.venv/bin/uvicorn app.main:app --host ${NP4_HOST} --port ${NP4_PORT}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Enable service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now notifypulse-v4
|
||||||
|
sudo systemctl status notifypulse-v4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional: Nginx reverse proxy + HTTPS
|
||||||
|
|
||||||
|
Minimal Nginx vhost:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your.domain.tld;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws/device {
|
||||||
|
proxy_pass http://127.0.0.1:8080/ws/device;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add TLS (recommended) with Certbot.
|
||||||
|
|
||||||
|
## Quick start (Windows client)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd v4\windows_client
|
||||||
|
py -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
$env:NP4_SERVER_URL="http://<server>:8080"
|
||||||
|
$env:NP4_API_TOKEN="<optional token>"
|
||||||
|
python client.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Windows `.exe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd v4\windows_client
|
||||||
|
build.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `dist\NotifyPulseAgent-V4.exe`
|
||||||
|
|
||||||
|
## Easy update model
|
||||||
|
|
||||||
|
1. Publish new Windows agent builds on your server/CDN.
|
||||||
|
2. Update the manifest in server data:
|
||||||
|
- `v4_data/update_manifest.json` (auto-created on first boot)
|
||||||
|
3. Client periodically checks `GET /api/v4/update/manifest/windows`.
|
||||||
|
4. If newer version is detected, client emits `update_available` event.
|
||||||
|
|
||||||
|
You can later switch this to silent self-update (download + staged swap + restart), but this scaffold keeps it safe and transparent first.
|
||||||
|
|
||||||
|
## iOS note
|
||||||
|
|
||||||
|
PWA is the practical path. iOS sideloading native apps is possible but adds signing/provisioning/distribution complexity and is not "easy update" compared to PWA.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- `Desktop test -> Wallpaper` does nothing:
|
||||||
|
- Ensure an active usecase exists.
|
||||||
|
- Add files under `v4_data/usecases/<usecase_id>/wallpapers/`.
|
||||||
|
- `Desktop test -> Overlay` does nothing:
|
||||||
|
- Add files under `v4_data/usecases/<usecase_id>/overlay/`.
|
||||||
|
- Rebuild/update the Windows agent so it includes overlay support.
|
||||||
|
- PWA wallpaper is identical for lock/home:
|
||||||
|
- Add at least 2 images under `v4_data/usecases/<usecase_id>/mobile/`.
|
||||||
|
- With only one image, both slots intentionally use that single image.
|
||||||
10
v4/server/.env.example
Normal file
10
v4/server/.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
NP4_HOST=0.0.0.0
|
||||||
|
NP4_PORT=8080
|
||||||
|
# Optional: set this to protect write/dispatch endpoints + device websocket auth
|
||||||
|
NP4_API_TOKEN=
|
||||||
|
|
||||||
|
# Optional overrides
|
||||||
|
# NP4_DATA_DIR=./v4_data
|
||||||
|
# NP4_UI_DIR=../../ui
|
||||||
|
# NP4_PWA_DIR=../../pwa
|
||||||
|
|
||||||
2
v4/server/app/__init__.py
Normal file
2
v4/server/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""NotifyPulse V4 server package."""
|
||||||
|
|
||||||
BIN
v4/server/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
v4/server/app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
v4/server/app/__pycache__/config.cpython-313.pyc
Normal file
BIN
v4/server/app/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
v4/server/app/__pycache__/connection_manager.cpython-313.pyc
Normal file
BIN
v4/server/app/__pycache__/connection_manager.cpython-313.pyc
Normal file
Binary file not shown.
BIN
v4/server/app/__pycache__/main.cpython-313.pyc
Normal file
BIN
v4/server/app/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
v4/server/app/__pycache__/models.cpython-313.pyc
Normal file
BIN
v4/server/app/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
v4/server/app/__pycache__/store.cpython-313.pyc
Normal file
BIN
v4/server/app/__pycache__/store.cpython-313.pyc
Normal file
Binary file not shown.
29
v4/server/app/config.py
Normal file
29
v4/server/app/config.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
host: str = os.getenv("NP4_HOST", "0.0.0.0")
|
||||||
|
port: int = int(os.getenv("NP4_PORT", "8080"))
|
||||||
|
api_token: str = os.getenv("NP4_API_TOKEN", "").strip()
|
||||||
|
|
||||||
|
data_dir: Path = Path(os.getenv("NP4_DATA_DIR", "./v4_data")).resolve()
|
||||||
|
ui_dir: Path = Path(
|
||||||
|
os.getenv(
|
||||||
|
"NP4_UI_DIR",
|
||||||
|
str((Path(__file__).resolve().parents[3] / "ui").resolve()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pwa_dir: Path = Path(
|
||||||
|
os.getenv(
|
||||||
|
"NP4_PWA_DIR",
|
||||||
|
str((Path(__file__).resolve().parents[3] / "pwa").resolve()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def ensure_dirs(cls) -> None:
|
||||||
|
cls.data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
144
v4/server/app/connection_manager.py
Normal file
144
v4/server/app/connection_manager.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from .models import ConnectedDevice, DeviceHello, Platform
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._sockets: dict[str, WebSocket] = {}
|
||||||
|
self._meta: dict[str, ConnectedDevice] = {}
|
||||||
|
self._events: deque[dict[str, Any]] = deque(maxlen=500)
|
||||||
|
|
||||||
|
async def connect(self, hello: DeviceHello, websocket: WebSocket) -> None:
|
||||||
|
now = _utc_now_iso()
|
||||||
|
async with self._lock:
|
||||||
|
self._sockets[hello.device_id] = websocket
|
||||||
|
self._meta[hello.device_id] = ConnectedDevice(
|
||||||
|
device_id=hello.device_id,
|
||||||
|
platform=hello.platform,
|
||||||
|
version=hello.version,
|
||||||
|
hostname=hello.hostname,
|
||||||
|
capabilities=hello.capabilities,
|
||||||
|
connected_at=now,
|
||||||
|
last_seen_at=now,
|
||||||
|
)
|
||||||
|
self._events.appendleft(
|
||||||
|
{
|
||||||
|
"time": now,
|
||||||
|
"event": "device_connected",
|
||||||
|
"device_id": hello.device_id,
|
||||||
|
"platform": hello.platform.value,
|
||||||
|
"version": hello.version,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def disconnect(self, device_id: str) -> None:
|
||||||
|
now = _utc_now_iso()
|
||||||
|
async with self._lock:
|
||||||
|
had = device_id in self._sockets
|
||||||
|
self._sockets.pop(device_id, None)
|
||||||
|
if device_id in self._meta:
|
||||||
|
self._meta[device_id].last_seen_at = now
|
||||||
|
if had:
|
||||||
|
self._events.appendleft(
|
||||||
|
{
|
||||||
|
"time": now,
|
||||||
|
"event": "device_disconnected",
|
||||||
|
"device_id": device_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def touch(self, device_id: str) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
meta = self._meta.get(device_id)
|
||||||
|
if meta:
|
||||||
|
meta.last_seen_at = _utc_now_iso()
|
||||||
|
|
||||||
|
async def list_devices(self) -> list[ConnectedDevice]:
|
||||||
|
async with self._lock:
|
||||||
|
return [self._meta[did] for did in self._sockets.keys() if did in self._meta]
|
||||||
|
|
||||||
|
async def get_logs(self) -> list[dict[str, Any]]:
|
||||||
|
async with self._lock:
|
||||||
|
return list(self._events)
|
||||||
|
|
||||||
|
async def add_event(self, payload: dict[str, Any]) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
payload = dict(payload)
|
||||||
|
payload.setdefault("time", _utc_now_iso())
|
||||||
|
self._events.appendleft(payload)
|
||||||
|
|
||||||
|
async def dispatch_command(
|
||||||
|
self,
|
||||||
|
command_id: str,
|
||||||
|
action: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
target_scope: str,
|
||||||
|
device_ids: list[str],
|
||||||
|
) -> tuple[list[str], list[str]]:
|
||||||
|
async with self._lock:
|
||||||
|
targets: list[tuple[str, WebSocket, ConnectedDevice]] = []
|
||||||
|
skipped: list[str] = []
|
||||||
|
|
||||||
|
for device_id, ws in self._sockets.items():
|
||||||
|
meta = self._meta.get(device_id)
|
||||||
|
if not meta:
|
||||||
|
continue
|
||||||
|
|
||||||
|
allowed = False
|
||||||
|
if target_scope == "all":
|
||||||
|
allowed = True
|
||||||
|
elif target_scope == "windows":
|
||||||
|
allowed = meta.platform == Platform.windows
|
||||||
|
elif target_scope == "pwa":
|
||||||
|
allowed = meta.platform == Platform.pwa
|
||||||
|
elif target_scope == "web":
|
||||||
|
allowed = meta.platform == Platform.web
|
||||||
|
elif target_scope == "device_ids":
|
||||||
|
allowed = device_id in device_ids
|
||||||
|
|
||||||
|
if allowed:
|
||||||
|
targets.append((device_id, ws, meta))
|
||||||
|
else:
|
||||||
|
skipped.append(device_id)
|
||||||
|
|
||||||
|
delivered: list[str] = []
|
||||||
|
for device_id, ws, _meta in targets:
|
||||||
|
try:
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"id": command_id,
|
||||||
|
"action": action,
|
||||||
|
"data": data,
|
||||||
|
"sent_at": _utc_now_iso(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
delivered.append(device_id)
|
||||||
|
except Exception:
|
||||||
|
skipped.append(device_id)
|
||||||
|
await self.disconnect(device_id)
|
||||||
|
|
||||||
|
await self.add_event(
|
||||||
|
{
|
||||||
|
"event": "command_dispatched",
|
||||||
|
"command_id": command_id,
|
||||||
|
"action": action,
|
||||||
|
"target_scope": target_scope,
|
||||||
|
"delivered_count": len(delivered),
|
||||||
|
"delivered_to": delivered,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return delivered, skipped
|
||||||
862
v4/server/app/main.py
Normal file
862
v4/server/app/main.py
Normal file
@@ -0,0 +1,862 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import mimetypes
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, Header, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .connection_manager import ConnectionManager
|
||||||
|
from .models import DeviceHello, DispatchRequest, DispatchResult, UpdateManifest
|
||||||
|
from .store import store
|
||||||
|
|
||||||
|
app = FastAPI(title="NotifyPulse V4 Server", version="4.0.0-alpha")
|
||||||
|
manager = ConnectionManager()
|
||||||
|
_pwa_clients: dict[str, dict[str, Any]] = {}
|
||||||
|
_pending_wallpapers: dict[str, dict[str, str]] = {}
|
||||||
|
_pwa_overlay: dict[str, Any] = {"active": False, "image": None, "until_ms": 0}
|
||||||
|
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||||
|
|
||||||
|
ALL_BLOCKS = [
|
||||||
|
{"id": "notifications", "name": "Notifications", "description": "Server-dispatched notifications", "icon": "🔔"},
|
||||||
|
{"id": "wallpaper", "name": "Wallpaper", "description": "Wallpaper command dispatch", "icon": "🖼️"},
|
||||||
|
{"id": "overlay", "name": "Overlay", "description": "Overlay command dispatch", "icon": "✨"},
|
||||||
|
{"id": "timer", "name": "Timer", "description": "Scheduled triggers (server-side in V4)", "icon": "⏱️"},
|
||||||
|
{"id": "mobile_wallpaper", "name": "Mobile Wallpaper", "description": "PWA wallpaper receiver", "icon": "📱"},
|
||||||
|
]
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def require_token(x_api_token: str = Header(default="")) -> None:
|
||||||
|
# Optional auth: if NP4_API_TOKEN is empty, routes are open.
|
||||||
|
if Config.api_token and x_api_token != Config.api_token:
|
||||||
|
raise HTTPException(status_code=401, detail="invalid API token")
|
||||||
|
|
||||||
|
|
||||||
|
def _active_usecase_id() -> str:
|
||||||
|
return str(store.get_settings().get("active_usecase", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _active_usecase() -> dict[str, Any] | None:
|
||||||
|
uid = _active_usecase_id()
|
||||||
|
for uc in store.get_usecases():
|
||||||
|
if uc.get("id") == uid:
|
||||||
|
return uc
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _set_active_usecase(uid: str) -> None:
|
||||||
|
store.update_settings({"active_usecase": uid})
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_entry(line: str) -> dict[str, Any]:
|
||||||
|
parts = [p.strip() for p in line.split("|", 1)]
|
||||||
|
text = parts[0]
|
||||||
|
weight = None
|
||||||
|
trigger_time = None
|
||||||
|
if len(parts) > 1:
|
||||||
|
rhs = parts[1]
|
||||||
|
m_pct = re.match(r"^(\d+(?:\.\d+)?)\s*%$", rhs)
|
||||||
|
m_time = re.match(r"^([01]?\d|2[0-3]):([0-5]\d)$", rhs)
|
||||||
|
if m_pct:
|
||||||
|
weight = float(m_pct.group(1))
|
||||||
|
elif m_time:
|
||||||
|
trigger_time = f"{int(m_time.group(1)):02d}:{m_time.group(2)}"
|
||||||
|
return {"text": text, "weight": weight, "trigger_time": trigger_time}
|
||||||
|
|
||||||
|
|
||||||
|
def _img_to_datauri(path: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
||||||
|
data = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||||
|
return f"data:{mime};base64,{data}"
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_log_item(msg: str, level: str = "info") -> dict[str, Any]:
|
||||||
|
return {"time": utc_now_iso(), "msg": msg, "level": level}
|
||||||
|
|
||||||
|
|
||||||
|
def _event_to_legacy_log(event: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if "msg" in event:
|
||||||
|
return {
|
||||||
|
"time": event.get("time", utc_now_iso()),
|
||||||
|
"msg": str(event.get("msg", "")),
|
||||||
|
"level": str(event.get("level", "info")),
|
||||||
|
}
|
||||||
|
|
||||||
|
et = str(event.get("event", "event"))
|
||||||
|
level = "info"
|
||||||
|
msg = et
|
||||||
|
if et == "device_connected":
|
||||||
|
msg = f"Client connected: {event.get('device_id', '?')} ({event.get('platform', 'unknown')})"
|
||||||
|
elif et == "device_disconnected":
|
||||||
|
msg = f"Client disconnected: {event.get('device_id', '?')}"
|
||||||
|
elif et == "command_dispatched":
|
||||||
|
msg = f"Dispatch {event.get('action', '?')} -> {event.get('delivered_count', 0)} client(s)"
|
||||||
|
elif et == "command_ack":
|
||||||
|
st = str(event.get("status", "ok"))
|
||||||
|
detail = str(event.get("detail", "")).strip()
|
||||||
|
msg = f"Ack {event.get('command_id', '?')} from {event.get('device_id', '?')}: {st}"
|
||||||
|
if detail:
|
||||||
|
msg += f" ({detail})"
|
||||||
|
if st in {"error", "unsupported"}:
|
||||||
|
level = "warn" if st == "unsupported" else "error"
|
||||||
|
elif et == "client_event":
|
||||||
|
msg = f"Client event {event.get('name', '?')} from {event.get('device_id', '?')}"
|
||||||
|
elif et == "socket_error":
|
||||||
|
msg = f"Socket error ({event.get('device_id', '?')}): {event.get('error', '')}"
|
||||||
|
level = "error"
|
||||||
|
elif et == "unknown_message":
|
||||||
|
msg = f"Unknown message from {event.get('device_id', '?')}"
|
||||||
|
level = "warn"
|
||||||
|
return {"time": event.get("time", utc_now_iso()), "msg": msg, "level": level}
|
||||||
|
|
||||||
|
|
||||||
|
async def _append_log(msg: str, level: str = "info") -> None:
|
||||||
|
await manager.add_event(
|
||||||
|
{
|
||||||
|
"event": "legacy_log",
|
||||||
|
"msg": msg,
|
||||||
|
"level": level,
|
||||||
|
"time": utc_now_iso(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_usecase_dirs(uid: str) -> None:
|
||||||
|
base = Config.data_dir / "usecases" / uid
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
for sub in ("wallpapers", "overlay", "mobile"):
|
||||||
|
(base / sub).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_images(folder: Path) -> list[Path]:
|
||||||
|
if not folder.exists():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
p
|
||||||
|
for p in folder.iterdir()
|
||||||
|
if p.is_file() and p.suffix.lower() in _IMAGE_EXTS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_usecase_image(bucket: str) -> Path | None:
|
||||||
|
uc = _active_usecase()
|
||||||
|
if not uc:
|
||||||
|
return None
|
||||||
|
uid = str(uc.get("id", "")).strip()
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
files = _list_images(Config.data_dir / "usecases" / uid / bucket)
|
||||||
|
if not files:
|
||||||
|
return None
|
||||||
|
return random.choice(files)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_mobile_wallpapers() -> tuple[Path, Path] | None:
|
||||||
|
uc = _active_usecase()
|
||||||
|
if not uc:
|
||||||
|
return None
|
||||||
|
uid = str(uc.get("id", "")).strip()
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
files = _list_images(Config.data_dir / "usecases" / uid / "mobile")
|
||||||
|
if not files:
|
||||||
|
return None
|
||||||
|
|
||||||
|
lock_pref = [p for p in files if re.search(r"(lock|locks|screenlock)", p.stem, re.IGNORECASE)]
|
||||||
|
bg_pref = [p for p in files if re.search(r"(home|bg|wall|back)", p.stem, re.IGNORECASE)]
|
||||||
|
if lock_pref and bg_pref:
|
||||||
|
lock = random.choice(lock_pref)
|
||||||
|
bg_choices = [p for p in bg_pref if p != lock] or bg_pref
|
||||||
|
bg = random.choice(bg_choices)
|
||||||
|
return lock, bg
|
||||||
|
|
||||||
|
if len(files) >= 2:
|
||||||
|
a, b = random.sample(files, 2)
|
||||||
|
return a, b
|
||||||
|
return files[0], files[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_asset_url(request: Request, uid: str, bucket: str, filename: str) -> str:
|
||||||
|
base = str(request.base_url).rstrip("/")
|
||||||
|
return f"{base}/api/v4/assets/usecases/{uid}/{bucket}/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_mobile_payload(payload: dict[str, str]) -> int:
|
||||||
|
active_clients = [cid for cid, meta in _pwa_clients.items() if (time.time() - meta.get("last_seen", 0)) <= 90]
|
||||||
|
if active_clients:
|
||||||
|
for cid in active_clients:
|
||||||
|
_pending_wallpapers[cid] = dict(payload)
|
||||||
|
return len(active_clients)
|
||||||
|
_pending_wallpapers["__broadcast__"] = dict(payload)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_mobile_wallpaper_payload() -> dict[str, str] | None:
|
||||||
|
uc = _active_usecase()
|
||||||
|
if not uc:
|
||||||
|
return None
|
||||||
|
uid = str(uc.get("id", "")).strip()
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
chosen = _pick_mobile_wallpapers()
|
||||||
|
if not chosen:
|
||||||
|
return None
|
||||||
|
lock_uri = _img_to_datauri(chosen[0])
|
||||||
|
bg_uri = _img_to_datauri(chosen[1])
|
||||||
|
if not lock_uri or not bg_uri:
|
||||||
|
return None
|
||||||
|
return {"lockscreen": lock_uri, "background": bg_uri}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v4/health")
|
||||||
|
async def health() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"service": "notifypulse-v4-server",
|
||||||
|
"time": utc_now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v4/state")
|
||||||
|
async def api_state() -> dict[str, Any]:
|
||||||
|
devices = [d.model_dump() for d in await manager.list_devices()]
|
||||||
|
logs = await manager.get_logs()
|
||||||
|
return {
|
||||||
|
"version": app.version,
|
||||||
|
"settings": store.get_settings(),
|
||||||
|
"usecases": store.get_usecases(),
|
||||||
|
"connected_devices": devices,
|
||||||
|
"events": logs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v4/settings")
|
||||||
|
async def get_settings() -> dict[str, Any]:
|
||||||
|
return store.get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v4/settings", dependencies=[Depends(require_token)])
|
||||||
|
async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return store.update_settings(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v4/usecases")
|
||||||
|
async def get_usecases() -> dict[str, Any]:
|
||||||
|
return {"usecases": store.get_usecases()}
|
||||||
|
|
||||||
|
|
||||||
|
class UsecasesPayload(BaseModel):
|
||||||
|
usecases: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v4/usecases", dependencies=[Depends(require_token)])
|
||||||
|
async def put_usecases(payload: UsecasesPayload) -> dict[str, Any]:
|
||||||
|
updated = store.set_usecases(payload.usecases)
|
||||||
|
await manager.dispatch_command(
|
||||||
|
command_id=str(uuid.uuid4()),
|
||||||
|
action="sync_settings",
|
||||||
|
data={"settings": store.get_settings(), "usecases": updated},
|
||||||
|
target_scope="all",
|
||||||
|
device_ids=[],
|
||||||
|
)
|
||||||
|
return {"ok": True, "usecases": updated}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v4/dispatch", dependencies=[Depends(require_token)])
|
||||||
|
async def dispatch(payload: DispatchRequest) -> DispatchResult:
|
||||||
|
command_id = str(uuid.uuid4())
|
||||||
|
delivered, skipped = await manager.dispatch_command(
|
||||||
|
command_id=command_id,
|
||||||
|
action=payload.action,
|
||||||
|
data=payload.data,
|
||||||
|
target_scope=payload.target_scope,
|
||||||
|
device_ids=payload.device_ids,
|
||||||
|
)
|
||||||
|
return DispatchResult(command_id=command_id, delivered_to=delivered, skipped=skipped)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v4/update/manifest/{platform}")
|
||||||
|
async def get_update_manifest(platform: str) -> UpdateManifest:
|
||||||
|
return store.get_update_manifest(platform)
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v4/update/manifest/{platform}", dependencies=[Depends(require_token)])
|
||||||
|
async def put_update_manifest(platform: str, manifest: UpdateManifest) -> dict[str, Any]:
|
||||||
|
store.set_update_manifest(platform, manifest)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/device")
|
||||||
|
async def device_socket(websocket: WebSocket) -> None:
|
||||||
|
await websocket.accept()
|
||||||
|
device_id = ""
|
||||||
|
try:
|
||||||
|
first = await websocket.receive_json()
|
||||||
|
if first.get("type") != "hello":
|
||||||
|
await websocket.send_json({"type": "error", "error": "first frame must be hello"})
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
|
token = first.get("token", "")
|
||||||
|
if Config.api_token and token != Config.api_token:
|
||||||
|
await websocket.send_json({"type": "error", "error": "invalid token"})
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
|
hello = DeviceHello.model_validate(first.get("payload", {}))
|
||||||
|
device_id = hello.device_id
|
||||||
|
await manager.connect(hello, websocket)
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "welcome",
|
||||||
|
"server_time": utc_now_iso(),
|
||||||
|
"settings": store.get_settings(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
msg = await websocket.receive_json()
|
||||||
|
mtype = msg.get("type")
|
||||||
|
if mtype == "heartbeat":
|
||||||
|
await manager.touch(device_id)
|
||||||
|
await websocket.send_json({"type": "heartbeat_ack", "time": utc_now_iso()})
|
||||||
|
elif mtype == "ack":
|
||||||
|
await manager.add_event(
|
||||||
|
{
|
||||||
|
"event": "command_ack",
|
||||||
|
"device_id": device_id,
|
||||||
|
"command_id": msg.get("command_id", ""),
|
||||||
|
"status": msg.get("status", "ok"),
|
||||||
|
"detail": msg.get("detail", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif mtype == "event":
|
||||||
|
await manager.add_event(
|
||||||
|
{
|
||||||
|
"event": "client_event",
|
||||||
|
"device_id": device_id,
|
||||||
|
"name": msg.get("name", ""),
|
||||||
|
"data": msg.get("data", {}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await manager.add_event(
|
||||||
|
{
|
||||||
|
"event": "unknown_message",
|
||||||
|
"device_id": device_id,
|
||||||
|
"payload": msg,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
await manager.add_event(
|
||||||
|
{
|
||||||
|
"event": "socket_error",
|
||||||
|
"device_id": device_id,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if device_id:
|
||||||
|
await manager.disconnect(device_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Legacy compatibility routes
|
||||||
|
# ----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/usecases")
|
||||||
|
async def legacy_get_usecases() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"usecases": store.get_usecases(),
|
||||||
|
"active": _active_usecase_id(),
|
||||||
|
"blocks": ALL_BLOCKS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/usecases")
|
||||||
|
async def legacy_create_usecase(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
name = str(payload.get("name", "")).strip()
|
||||||
|
if not name:
|
||||||
|
return {"ok": False, "error": "name required"}
|
||||||
|
uid = re.sub(r"[^a-z0-9_]", "_", name.lower()) + f"_{random.randint(100,999)}"
|
||||||
|
uc = {
|
||||||
|
"id": uid,
|
||||||
|
"name": name,
|
||||||
|
"color": payload.get("color", "#4f8ef7"),
|
||||||
|
"group": str(payload.get("group", "")).strip(),
|
||||||
|
"blocks": payload.get("blocks", ["notifications"]),
|
||||||
|
"min_interval": int(payload.get("min_interval", 10)),
|
||||||
|
"max_interval": int(payload.get("max_interval", 30)),
|
||||||
|
"notifications": payload.get("notifications", []),
|
||||||
|
"dashboard_layout": payload.get("dashboard_layout", {}),
|
||||||
|
}
|
||||||
|
usecases = store.get_usecases()
|
||||||
|
usecases.append(uc)
|
||||||
|
store.set_usecases(usecases)
|
||||||
|
_ensure_usecase_dirs(uid)
|
||||||
|
await _append_log(f"Usecase created: {name}")
|
||||||
|
return {"ok": True, "usecase": uc}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/usecases/{uid}")
|
||||||
|
async def legacy_update_usecase(uid: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
usecases = store.get_usecases()
|
||||||
|
found = None
|
||||||
|
for uc in usecases:
|
||||||
|
if uc.get("id") == uid:
|
||||||
|
uc.update({k: v for k, v in payload.items() if k != "id"})
|
||||||
|
found = uc
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
return {"ok": False, "error": "not found"}
|
||||||
|
store.set_usecases(usecases)
|
||||||
|
_ensure_usecase_dirs(uid)
|
||||||
|
await _append_log(f"Usecase updated: {found.get('name', uid)}")
|
||||||
|
return {"ok": True, "usecase": found}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/usecases/{uid}")
|
||||||
|
async def legacy_delete_usecase(uid: str) -> dict[str, Any]:
|
||||||
|
usecases = store.get_usecases()
|
||||||
|
after = [u for u in usecases if u.get("id") != uid]
|
||||||
|
if len(after) == len(usecases):
|
||||||
|
return {"ok": False, "error": "not found"}
|
||||||
|
store.set_usecases(after)
|
||||||
|
if _active_usecase_id() == uid:
|
||||||
|
_set_active_usecase(after[0]["id"] if after else "")
|
||||||
|
await _append_log(f"Usecase deleted: {uid}", "warn")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/usecases/{uid}/activate")
|
||||||
|
async def legacy_activate_usecase(uid: str) -> dict[str, Any]:
|
||||||
|
usecases = store.get_usecases()
|
||||||
|
exists = any(u.get("id") == uid for u in usecases)
|
||||||
|
if not exists:
|
||||||
|
return {"ok": False, "error": "not found"}
|
||||||
|
_set_active_usecase(uid)
|
||||||
|
uc = next((u for u in usecases if u.get("id") == uid), {})
|
||||||
|
await _append_log(f"Switched to usecase '{uc.get('name', uid)}'")
|
||||||
|
return {"ok": True, "active": uid}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/usecases/{uid}/open_folder")
|
||||||
|
async def legacy_open_usecase_folder(uid: str) -> dict[str, Any]:
|
||||||
|
_ensure_usecase_dirs(uid)
|
||||||
|
d = Config.data_dir / "usecases" / uid
|
||||||
|
return {"ok": True, "path": str(d)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/state")
|
||||||
|
async def legacy_state() -> dict[str, Any]:
|
||||||
|
settings = store.get_settings()
|
||||||
|
uc = _active_usecase()
|
||||||
|
logs_raw = await manager.get_logs()
|
||||||
|
logs = [_event_to_legacy_log(e) for e in logs_raw[:100]]
|
||||||
|
connected_devices = [d.model_dump() for d in await manager.list_devices()]
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
if uc:
|
||||||
|
entries = [_parse_entry(x) for x in uc.get("notifications", []) if str(x).strip()]
|
||||||
|
min_interval = int((uc or {}).get("min_interval", 10))
|
||||||
|
next_fire_at = int(time.time()) + (min_interval * 60)
|
||||||
|
|
||||||
|
cutoff = time.time() - 60
|
||||||
|
stale = [k for k, v in _pwa_clients.items() if v.get("last_seen", 0) < cutoff]
|
||||||
|
for k in stale:
|
||||||
|
_pwa_clients.pop(k, None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"app_name": (uc or {}).get("name") or settings.get("app_name", "NotifyPulse"),
|
||||||
|
"settings_app_name": settings.get("app_name", "NotifyPulse"),
|
||||||
|
"version": app.version,
|
||||||
|
"paused": bool(settings.get("paused", False)),
|
||||||
|
"next_fire_at": next_fire_at,
|
||||||
|
"entries": entries,
|
||||||
|
"log": logs,
|
||||||
|
"pwa_clients": len(_pwa_clients),
|
||||||
|
"connected_devices": connected_devices,
|
||||||
|
"settings": settings,
|
||||||
|
"active_usecase": _active_usecase_id(),
|
||||||
|
"usecase": uc,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/pause")
|
||||||
|
async def legacy_pause() -> dict[str, Any]:
|
||||||
|
settings = store.get_settings()
|
||||||
|
paused = not bool(settings.get("paused", False))
|
||||||
|
store.update_settings({"paused": paused})
|
||||||
|
return {"paused": paused}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/fire_now")
|
||||||
|
async def legacy_fire_now(request: Request) -> dict[str, Any]:
|
||||||
|
uc = _active_usecase()
|
||||||
|
if not uc:
|
||||||
|
return {"ok": False, "error": "no active usecase"}
|
||||||
|
entries = [str(x) for x in uc.get("notifications", []) if str(x).strip()]
|
||||||
|
if not entries:
|
||||||
|
return {"ok": False, "error": "No entries"}
|
||||||
|
entry = random.choice(entries).split("|", 1)[0].strip()
|
||||||
|
tl = entry.lower()
|
||||||
|
|
||||||
|
if tl == "change.wallpaper":
|
||||||
|
img = _pick_usecase_image("wallpapers")
|
||||||
|
if not img:
|
||||||
|
await _append_log("Fire now: wallpaper missing", "warn")
|
||||||
|
return {"ok": False, "error": "No wallpaper images found"}
|
||||||
|
uid = str(uc.get("id", ""))
|
||||||
|
url = _build_asset_url(request, uid, "wallpapers", img.name)
|
||||||
|
res = await dispatch(
|
||||||
|
DispatchRequest(
|
||||||
|
action="wallpaper_set",
|
||||||
|
data={"url": url},
|
||||||
|
target_scope="windows",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await _append_log(f"Fire now: wallpaper -> {img.name}")
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"fired": entry,
|
||||||
|
"action": "wallpaper_set",
|
||||||
|
"asset_url": url,
|
||||||
|
"command_id": res.command_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if tl.startswith("show.overlay"):
|
||||||
|
sec = 6
|
||||||
|
m = re.match(r"^show\.overlay\.(\d+)$", tl)
|
||||||
|
if m:
|
||||||
|
sec = max(1, min(60, int(m.group(1))))
|
||||||
|
img = _pick_usecase_image("overlay")
|
||||||
|
if not img:
|
||||||
|
await _append_log("Fire now: overlay missing", "warn")
|
||||||
|
return {"ok": False, "error": "No overlay images found"}
|
||||||
|
_pwa_overlay.update(
|
||||||
|
{
|
||||||
|
"active": True,
|
||||||
|
"image": _img_to_datauri(img),
|
||||||
|
"until_ms": int(time.time() * 1000) + (sec * 1000),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
overlay_url = _build_asset_url(request, str(uc.get("id", "")), "overlay", img.name)
|
||||||
|
settings = store.get_settings()
|
||||||
|
opacity = float(settings.get("overlay_opacity", 0.4))
|
||||||
|
stretch = bool(settings.get("overlay_stretch", False))
|
||||||
|
res = await dispatch(
|
||||||
|
DispatchRequest(
|
||||||
|
action="overlay_show",
|
||||||
|
data={
|
||||||
|
"url": overlay_url,
|
||||||
|
"duration_ms": sec * 1000,
|
||||||
|
"opacity": opacity,
|
||||||
|
"stretch": stretch,
|
||||||
|
},
|
||||||
|
target_scope="windows",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await _append_log(f"Fire now: overlay -> {img.name} ({sec}s)")
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"fired": entry,
|
||||||
|
"action": "overlay_show",
|
||||||
|
"duration_sec": sec,
|
||||||
|
"command_id": res.command_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if tl == "change.wallpaper.mobile":
|
||||||
|
payload = _pick_mobile_wallpaper_payload()
|
||||||
|
if not payload:
|
||||||
|
await _append_log("Fire now: mobile wallpaper missing", "warn")
|
||||||
|
return {"ok": False, "error": "No mobile images found"}
|
||||||
|
queued = _queue_mobile_payload(payload)
|
||||||
|
await _append_log(f"Fire now: mobile wallpaper queued -> {queued} client(s)")
|
||||||
|
return {"ok": True, "fired": entry}
|
||||||
|
|
||||||
|
cmd = DispatchRequest(
|
||||||
|
action="notify",
|
||||||
|
data={"title": uc.get("name", "NotifyPulse"), "message": entry},
|
||||||
|
target_scope="windows",
|
||||||
|
)
|
||||||
|
res = await dispatch(cmd)
|
||||||
|
await _append_log(f"Fire now notification -> {entry}")
|
||||||
|
return {"ok": True, "fired": entry, "command_id": res.command_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/settings")
|
||||||
|
async def legacy_get_settings() -> dict[str, Any]:
|
||||||
|
return store.get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/settings")
|
||||||
|
async def legacy_post_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
store.update_settings(payload)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/log")
|
||||||
|
async def legacy_log(limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
logs = await manager.get_logs()
|
||||||
|
adapted = [_event_to_legacy_log(e) for e in logs]
|
||||||
|
return adapted[: min(limit, 200)]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/test_notification")
|
||||||
|
async def legacy_test_notification(payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
p = payload or {}
|
||||||
|
cmd = DispatchRequest(
|
||||||
|
action="notify",
|
||||||
|
data={"title": "NotifyPulse", "message": p.get("message", "Test from Web UI")},
|
||||||
|
target_scope="windows",
|
||||||
|
)
|
||||||
|
res = await dispatch(cmd)
|
||||||
|
if not res.delivered_to:
|
||||||
|
await _append_log("Desktop test notification: no Windows clients connected", "warn")
|
||||||
|
return {"ok": False, "error": "No Windows clients connected"}
|
||||||
|
await _append_log("Desktop test notification sent")
|
||||||
|
return {"ok": True, "command_id": res.command_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/test_wallpaper")
|
||||||
|
async def legacy_test_wallpaper(request: Request) -> dict[str, Any]:
|
||||||
|
uc = _active_usecase()
|
||||||
|
if not uc:
|
||||||
|
return {"ok": False, "error": "No active usecase"}
|
||||||
|
img = _pick_usecase_image("wallpapers")
|
||||||
|
if not img:
|
||||||
|
await _append_log("Desktop test wallpaper: no wallpaper images found", "warn")
|
||||||
|
return {"ok": False, "error": "No images in wallpapers folder"}
|
||||||
|
uid = str(uc.get("id", ""))
|
||||||
|
url = _build_asset_url(request, uid, "wallpapers", img.name)
|
||||||
|
res = await dispatch(
|
||||||
|
DispatchRequest(
|
||||||
|
action="wallpaper_set",
|
||||||
|
data={"url": url},
|
||||||
|
target_scope="windows",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not res.delivered_to:
|
||||||
|
await _append_log("Desktop test wallpaper: no Windows clients connected", "warn")
|
||||||
|
return {"ok": False, "error": "No Windows clients connected"}
|
||||||
|
await _append_log(f"Desktop test wallpaper -> {img.name}")
|
||||||
|
return {"ok": True, "command_id": res.command_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/test_overlay")
|
||||||
|
async def legacy_test_overlay(request: Request) -> dict[str, Any]:
|
||||||
|
uc = _active_usecase()
|
||||||
|
if not uc:
|
||||||
|
return {"ok": False, "error": "No active usecase"}
|
||||||
|
img = _pick_usecase_image("overlay")
|
||||||
|
if not img:
|
||||||
|
await _append_log("Desktop test overlay: no overlay images found", "warn")
|
||||||
|
return {"ok": False, "error": "No images in overlay folder"}
|
||||||
|
uid = str(uc.get("id", ""))
|
||||||
|
url = _build_asset_url(request, uid, "overlay", img.name)
|
||||||
|
settings = store.get_settings()
|
||||||
|
dur_ms = int(float(settings.get("overlay_duration", 6)) * 1000)
|
||||||
|
opacity = float(settings.get("overlay_opacity", 0.4))
|
||||||
|
stretch = bool(settings.get("overlay_stretch", False))
|
||||||
|
_pwa_overlay.update(
|
||||||
|
{
|
||||||
|
"active": True,
|
||||||
|
"image": _img_to_datauri(img),
|
||||||
|
"until_ms": int(time.time() * 1000) + dur_ms,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
res = await dispatch(
|
||||||
|
DispatchRequest(
|
||||||
|
action="overlay_show",
|
||||||
|
data={
|
||||||
|
"url": url,
|
||||||
|
"duration_ms": dur_ms,
|
||||||
|
"opacity": opacity,
|
||||||
|
"stretch": stretch,
|
||||||
|
},
|
||||||
|
target_scope="windows",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not res.delivered_to:
|
||||||
|
await _append_log("Desktop test overlay: no Windows clients connected", "warn")
|
||||||
|
return {"ok": False, "error": "No Windows clients connected"}
|
||||||
|
await _append_log(f"Desktop test overlay -> {img.name}")
|
||||||
|
return {"ok": True, "command_id": res.command_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/test_mobile_wallpaper")
|
||||||
|
async def legacy_test_mobile() -> dict[str, Any]:
|
||||||
|
payload = _pick_mobile_wallpaper_payload()
|
||||||
|
if not payload:
|
||||||
|
await _append_log("Mobile test wallpaper: no mobile images found", "warn")
|
||||||
|
return {"ok": False, "error": "no mobile wallpaper found for active usecase"}
|
||||||
|
queued = _queue_mobile_payload(payload)
|
||||||
|
await _append_log(f"Mobile test wallpaper queued -> {queued} client(s)")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/pwa/ping")
|
||||||
|
async def legacy_pwa_ping(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
cid = str(payload.get("client_id", "")).strip()
|
||||||
|
if not cid:
|
||||||
|
return {"error": "no client_id"}
|
||||||
|
_pwa_clients[cid] = {"last_seen": time.time()}
|
||||||
|
return {"ok": True, "clients": len(_pwa_clients)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/pwa/wallpaper")
|
||||||
|
async def legacy_pwa_wallpaper(client_id: str = "") -> dict[str, Any]:
|
||||||
|
cid = client_id.strip()
|
||||||
|
if not cid:
|
||||||
|
return {"pending": False}
|
||||||
|
cmd = _pending_wallpapers.pop(cid, None) or _pending_wallpapers.pop("__broadcast__", None)
|
||||||
|
if not cmd:
|
||||||
|
return {"pending": False}
|
||||||
|
return {"pending": True, **cmd}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/pwa/app_name")
|
||||||
|
async def legacy_pwa_app_name() -> dict[str, Any]:
|
||||||
|
settings = store.get_settings()
|
||||||
|
return {
|
||||||
|
"app_name": (_active_usecase() or {}).get("name") or settings.get("app_name", "NotifyPulse"),
|
||||||
|
"settings_app_name": settings.get("app_name", "NotifyPulse"),
|
||||||
|
"active": _active_usecase_id(),
|
||||||
|
"usecases": [
|
||||||
|
{
|
||||||
|
"id": u.get("id", ""),
|
||||||
|
"name": u.get("name", ""),
|
||||||
|
"color": u.get("color", "#4f8ef7"),
|
||||||
|
"group": u.get("group", ""),
|
||||||
|
"blocks": u.get("blocks", []),
|
||||||
|
"min_interval": u.get("min_interval", 10),
|
||||||
|
"max_interval": u.get("max_interval", 30),
|
||||||
|
}
|
||||||
|
for u in store.get_usecases()
|
||||||
|
],
|
||||||
|
"version": app.version,
|
||||||
|
"blocks": ALL_BLOCKS,
|
||||||
|
"pwa_bg_blur": settings.get("pwa_bg_blur", 18),
|
||||||
|
"pwa_bg_opacity": settings.get("pwa_bg_opacity", 0.72),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/pwa/splash_image")
|
||||||
|
async def legacy_pwa_splash() -> dict[str, Any]:
|
||||||
|
splash_dir = Config.data_dir / "splash"
|
||||||
|
candidates: list[Path] = []
|
||||||
|
if splash_dir.exists():
|
||||||
|
candidates = [
|
||||||
|
p
|
||||||
|
for p in splash_dir.iterdir()
|
||||||
|
if p.is_file() and p.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp"}
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
for name in ("main.png", "main.jpg", "main.jpeg", "main.webp"):
|
||||||
|
p = Config.data_dir / name
|
||||||
|
if p.exists():
|
||||||
|
candidates = [p]
|
||||||
|
break
|
||||||
|
if not candidates:
|
||||||
|
return {"image": "", "has_custom": False}
|
||||||
|
chosen = random.choice(candidates)
|
||||||
|
return {"image": _img_to_datauri(chosen) or "", "has_custom": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/pwa/overlay")
|
||||||
|
async def legacy_pwa_overlay() -> dict[str, Any]:
|
||||||
|
if not _pwa_overlay.get("active"):
|
||||||
|
return {"active": False, "image": None}
|
||||||
|
remaining = int(_pwa_overlay.get("until_ms", 0) - (time.time() * 1000))
|
||||||
|
if remaining <= 0:
|
||||||
|
_pwa_overlay.update({"active": False, "image": None, "until_ms": 0})
|
||||||
|
return {"active": False, "image": None}
|
||||||
|
return {"active": True, "image": _pwa_overlay.get("image"), "remaining_ms": remaining}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/pwa/trigger_wallpaper")
|
||||||
|
async def legacy_pwa_trigger_wallpaper() -> dict[str, Any]:
|
||||||
|
payload = _pick_mobile_wallpaper_payload()
|
||||||
|
if not payload:
|
||||||
|
await _append_log("PWA wallpaper request: no mobile images found", "warn")
|
||||||
|
return {"ok": False, "error": "no mobile wallpaper found for active usecase"}
|
||||||
|
queued = _queue_mobile_payload(payload)
|
||||||
|
await _append_log(f"PWA wallpaper queued -> {queued} client(s)")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/pwa/activate_usecase")
|
||||||
|
async def legacy_pwa_activate_usecase(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
uid = str(payload.get("usecase_id", "")).strip()
|
||||||
|
usecases = store.get_usecases()
|
||||||
|
uc = next((u for u in usecases if u.get("id") == uid), None)
|
||||||
|
if not uc:
|
||||||
|
return {"ok": False, "error": "not found"}
|
||||||
|
_set_active_usecase(uid)
|
||||||
|
return {"ok": True, "name": uc.get("name", "")}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v4/assets/usecases/{uid}/{bucket}/{filename}")
|
||||||
|
async def usecase_asset(uid: str, bucket: str, filename: str):
|
||||||
|
if bucket not in {"wallpapers", "overlay", "mobile"}:
|
||||||
|
raise HTTPException(status_code=404, detail="invalid asset bucket")
|
||||||
|
if "/" in filename or "\\" in filename:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid filename")
|
||||||
|
path = (Config.data_dir / "usecases" / uid / bucket / filename).resolve()
|
||||||
|
root = (Config.data_dir / "usecases" / uid / bucket).resolve()
|
||||||
|
if root not in path.parents and path != root:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid asset path")
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="asset not found")
|
||||||
|
return FileResponse(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_file(path: Path) -> FileResponse | JSONResponse:
|
||||||
|
if path.exists() and path.is_file():
|
||||||
|
return FileResponse(path)
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "file not found", "path": str(path)},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def web_ui_index():
|
||||||
|
return _safe_file(Config.ui_dir / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/pwa")
|
||||||
|
@app.get("/pwa/")
|
||||||
|
async def pwa_index():
|
||||||
|
return _safe_file(Config.pwa_dir / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/pwa/{filename:path}")
|
||||||
|
async def pwa_assets(filename: str):
|
||||||
|
return _safe_file(Config.pwa_dir / filename)
|
||||||
58
v4/server/app/models.py
Normal file
58
v4/server/app/models.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class Platform(str, Enum):
|
||||||
|
windows = "windows"
|
||||||
|
pwa = "pwa"
|
||||||
|
web = "web"
|
||||||
|
unknown = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceHello(BaseModel):
|
||||||
|
device_id: str = Field(min_length=3, max_length=128)
|
||||||
|
platform: Platform = Platform.unknown
|
||||||
|
version: str = "0.0.0"
|
||||||
|
hostname: str = ""
|
||||||
|
capabilities: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectedDevice(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
platform: Platform
|
||||||
|
version: str
|
||||||
|
hostname: str = ""
|
||||||
|
capabilities: list[str] = Field(default_factory=list)
|
||||||
|
connected_at: str = Field(default_factory=utc_now_iso)
|
||||||
|
last_seen_at: str = Field(default_factory=utc_now_iso)
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchRequest(BaseModel):
|
||||||
|
action: Literal["notify", "wallpaper_set", "overlay_show", "sync_settings", "ping"]
|
||||||
|
data: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
target_scope: Literal["all", "windows", "pwa", "web", "device_ids"] = "all"
|
||||||
|
device_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchResult(BaseModel):
|
||||||
|
command_id: str
|
||||||
|
delivered_to: list[str] = Field(default_factory=list)
|
||||||
|
skipped: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateManifest(BaseModel):
|
||||||
|
version: str = "0.0.0"
|
||||||
|
download_url: str = ""
|
||||||
|
sha256: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
published_at: str = Field(default_factory=utc_now_iso)
|
||||||
|
|
||||||
112
v4/server/app/store.py
Normal file
112
v4/server/app/store.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .models import UpdateManifest
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS: dict[str, Any] = {
|
||||||
|
"app_name": "NotifyPulse",
|
||||||
|
"active_usecase": "",
|
||||||
|
"paused": False,
|
||||||
|
"hotkey": "F13",
|
||||||
|
"startup_toast": True,
|
||||||
|
"notify_sound": True,
|
||||||
|
"auto_open_browser": True,
|
||||||
|
"minimize_to_tray": True,
|
||||||
|
"run_on_startup": False,
|
||||||
|
"confirm_delete": True,
|
||||||
|
"entry_display_mode": "percent",
|
||||||
|
"notification_duration": 5,
|
||||||
|
"overlay_opacity": 0.4,
|
||||||
|
"overlay_duration": 6,
|
||||||
|
"overlay_stretch": False,
|
||||||
|
"overlay_monitor": 0,
|
||||||
|
"wallpaper_fit": "fill",
|
||||||
|
"pwa_bg_blur": 18,
|
||||||
|
"pwa_bg_opacity": 0.72,
|
||||||
|
"log_max_entries": 100,
|
||||||
|
"webui_refresh_ms": 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_USECASES: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
DEFAULT_UPDATE_MANIFEST: dict[str, Any] = {
|
||||||
|
"windows": UpdateManifest().model_dump(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JsonStore:
|
||||||
|
def __init__(self, data_dir: Path):
|
||||||
|
self._data_dir = data_dir
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._settings_file = data_dir / "settings.json"
|
||||||
|
self._usecases_file = data_dir / "usecases.json"
|
||||||
|
self._update_file = data_dir / "update_manifest.json"
|
||||||
|
self._init_defaults()
|
||||||
|
|
||||||
|
def _init_defaults(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._ensure_file(self._settings_file, DEFAULT_SETTINGS)
|
||||||
|
self._ensure_file(self._usecases_file, DEFAULT_USECASES)
|
||||||
|
self._ensure_file(self._update_file, DEFAULT_UPDATE_MANIFEST)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_json(path: Path, payload: Any) -> None:
|
||||||
|
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
def _ensure_file(self, path: Path, payload: Any) -> None:
|
||||||
|
if not path.exists():
|
||||||
|
self._write_json(path, payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_json(path: Path, fallback: Any) -> Any:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
def get_settings(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
raw = self._read_json(self._settings_file, {})
|
||||||
|
return {**DEFAULT_SETTINGS, **raw}
|
||||||
|
|
||||||
|
def update_settings(self, patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
current = self.get_settings()
|
||||||
|
current.update(patch)
|
||||||
|
self._write_json(self._settings_file, current)
|
||||||
|
return current
|
||||||
|
|
||||||
|
def get_usecases(self) -> list[dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
raw = self._read_json(self._usecases_file, DEFAULT_USECASES)
|
||||||
|
return raw if isinstance(raw, list) else []
|
||||||
|
|
||||||
|
def set_usecases(self, payload: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
self._write_json(self._usecases_file, payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def get_update_manifest(self, platform: str) -> UpdateManifest:
|
||||||
|
with self._lock:
|
||||||
|
raw = self._read_json(self._update_file, DEFAULT_UPDATE_MANIFEST)
|
||||||
|
platform_raw = raw.get(platform) if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(platform_raw, dict):
|
||||||
|
return UpdateManifest()
|
||||||
|
return UpdateManifest.model_validate(platform_raw)
|
||||||
|
|
||||||
|
def set_update_manifest(self, platform: str, manifest: UpdateManifest) -> None:
|
||||||
|
with self._lock:
|
||||||
|
raw = self._read_json(self._update_file, DEFAULT_UPDATE_MANIFEST)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raw = {}
|
||||||
|
raw[platform] = manifest.model_dump()
|
||||||
|
self._write_json(self._update_file, raw)
|
||||||
|
|
||||||
|
|
||||||
|
Config.ensure_dirs()
|
||||||
|
store = JsonStore(Config.data_dir)
|
||||||
3
v4/server/requirements.txt
Normal file
3
v4/server/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.30
|
||||||
|
pydantic>=2.8
|
||||||
12
v4/server/v4_data/settings.json
Normal file
12
v4/server/v4_data/settings.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"app_name": "NotifyPulse",
|
||||||
|
"active_usecase": "",
|
||||||
|
"paused": false,
|
||||||
|
"notification_duration": 5,
|
||||||
|
"overlay_opacity": 0.4,
|
||||||
|
"overlay_duration": 6,
|
||||||
|
"wallpaper_fit": "fill",
|
||||||
|
"pwa_bg_blur": 18,
|
||||||
|
"pwa_bg_opacity": 0.72,
|
||||||
|
"webui_refresh_ms": 1000
|
||||||
|
}
|
||||||
9
v4/server/v4_data/update_manifest.json
Normal file
9
v4/server/v4_data/update_manifest.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"windows": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"download_url": "",
|
||||||
|
"sha256": "",
|
||||||
|
"notes": "",
|
||||||
|
"published_at": "2026-05-21T19:37:28.631621+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
v4/server/v4_data/usecases.json
Normal file
1
v4/server/v4_data/usecases.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
39
v4/windows_client/NotifyPulseAgent-V4.spec
Normal file
39
v4/windows_client/NotifyPulseAgent-V4.spec
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['client.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[('agent_config.example.json', '.')],
|
||||||
|
hiddenimports=['websockets', 'requests', 'winotify', 'pystray', 'PIL'],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='NotifyPulseAgent-V4',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon=['icon.ico'],
|
||||||
|
)
|
||||||
BIN
v4/windows_client/__pycache__/client.cpython-313.pyc
Normal file
BIN
v4/windows_client/__pycache__/client.cpython-313.pyc
Normal file
Binary file not shown.
4
v4/windows_client/agent_config.example.json
Normal file
4
v4/windows_client/agent_config.example.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"server_url": "http://192.168.178.122:8080",
|
||||||
|
"api_token": "replace-with-your-token"
|
||||||
|
}
|
||||||
4
v4/windows_client/agent_config.json
Normal file
4
v4/windows_client/agent_config.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"server_url": "http://192.168.178.122:8080",
|
||||||
|
"api_token": "123456789"
|
||||||
|
}
|
||||||
46
v4/windows_client/build.bat
Normal file
46
v4/windows_client/build.bat
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
echo ============================================
|
||||||
|
echo NotifyPulse Agent V4 - Build
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
where python >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR: Python not found in PATH
|
||||||
|
pause & exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "PY=python"
|
||||||
|
where py >nul 2>&1
|
||||||
|
if not errorlevel 1 set "PY=py -3"
|
||||||
|
|
||||||
|
echo [1/2] Installing dependencies...
|
||||||
|
%PY% -m pip install -r requirements.txt --quiet
|
||||||
|
if errorlevel 1 ( echo FAILED & pause & exit /b 1 )
|
||||||
|
|
||||||
|
echo [2/2] Building executable...
|
||||||
|
set "ICON_ARG="
|
||||||
|
if exist "icon.ico" set "ICON_ARG=--icon icon.ico"
|
||||||
|
|
||||||
|
%PY% -m PyInstaller --noconfirm --onefile --windowed ^
|
||||||
|
--name "NotifyPulseAgent-V4" ^
|
||||||
|
%ICON_ARG% ^
|
||||||
|
--hidden-import=websockets ^
|
||||||
|
--hidden-import=requests ^
|
||||||
|
--hidden-import=winotify ^
|
||||||
|
--hidden-import=pystray ^
|
||||||
|
--hidden-import=PIL ^
|
||||||
|
--add-data "agent_config.example.json;." ^
|
||||||
|
client.py
|
||||||
|
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo BUILD FAILED
|
||||||
|
pause & exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ============================================
|
||||||
|
echo SUCCESS: dist\NotifyPulseAgent-V4.exe
|
||||||
|
echo ============================================
|
||||||
|
pause
|
||||||
7608
v4/windows_client/build/NotifyPulseAgent-V4/Analysis-00.toc
Normal file
7608
v4/windows_client/build/NotifyPulseAgent-V4/Analysis-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
4140
v4/windows_client/build/NotifyPulseAgent-V4/EXE-00.toc
Normal file
4140
v4/windows_client/build/NotifyPulseAgent-V4/EXE-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
4117
v4/windows_client/build/NotifyPulseAgent-V4/PKG-00.toc
Normal file
4117
v4/windows_client/build/NotifyPulseAgent-V4/PKG-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
BIN
v4/windows_client/build/NotifyPulseAgent-V4/PYZ-00.pyz
Normal file
BIN
v4/windows_client/build/NotifyPulseAgent-V4/PYZ-00.pyz
Normal file
Binary file not shown.
2889
v4/windows_client/build/NotifyPulseAgent-V4/PYZ-00.toc
Normal file
2889
v4/windows_client/build/NotifyPulseAgent-V4/PYZ-00.toc
Normal file
File diff suppressed because it is too large
Load Diff
BIN
v4/windows_client/build/NotifyPulseAgent-V4/base_library.zip
Normal file
BIN
v4/windows_client/build/NotifyPulseAgent-V4/base_library.zip
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
v4/windows_client/build/NotifyPulseAgent-V4/localpycs/struct.pyc
Normal file
BIN
v4/windows_client/build/NotifyPulseAgent-V4/localpycs/struct.pyc
Normal file
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
418
v4/windows_client/client.py
Normal file
418
v4/windows_client/client.py
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import ctypes
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from websockets.client import connect
|
||||||
|
|
||||||
|
try:
|
||||||
|
from winotify import Notification, audio
|
||||||
|
except Exception:
|
||||||
|
Notification = None
|
||||||
|
audio = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pystray
|
||||||
|
from PIL import Image, ImageDraw, ImageTk
|
||||||
|
except Exception:
|
||||||
|
pystray = None
|
||||||
|
Image = None
|
||||||
|
ImageDraw = None
|
||||||
|
ImageTk = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tkinter as tk
|
||||||
|
except Exception:
|
||||||
|
tk = None
|
||||||
|
|
||||||
|
AGENT_VERSION = "4.0.0-alpha"
|
||||||
|
APP_NAME = "NotifyPulseAgent"
|
||||||
|
SPI_SETDESKWALLPAPER = 0x0014
|
||||||
|
SPIF_UPDATEINIFILE = 0x01
|
||||||
|
SPIF_SENDCHANGE = 0x02
|
||||||
|
|
||||||
|
|
||||||
|
def appdata_dir() -> Path:
|
||||||
|
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
||||||
|
path = base / "NotifyPulseV4"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(path / "cache").mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
DATA_DIR = appdata_dir()
|
||||||
|
CLIENT_FILE = DATA_DIR / "client.json"
|
||||||
|
SETTINGS_FILE = DATA_DIR / "settings.json"
|
||||||
|
STOP_EVENT = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path: Path, fallback: Any) -> Any:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path: Path, payload: Any) -> None:
|
||||||
|
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def app_base_dir() -> Path:
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return Path(sys.executable).resolve().parent
|
||||||
|
return Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def load_agent_config() -> dict[str, Any]:
|
||||||
|
cfg_file = app_base_dir() / "agent_config.json"
|
||||||
|
cfg = load_json(cfg_file, {})
|
||||||
|
return cfg if isinstance(cfg, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_or_create_client_id() -> str:
|
||||||
|
raw = load_json(CLIENT_FILE, {})
|
||||||
|
did = raw.get("device_id", "").strip()
|
||||||
|
if did:
|
||||||
|
return did
|
||||||
|
did = f"win_{uuid.uuid4().hex[:12]}"
|
||||||
|
save_json(CLIENT_FILE, {"device_id": did, "created_at": int(time.time())})
|
||||||
|
return did
|
||||||
|
|
||||||
|
|
||||||
|
DEVICE_ID = load_or_create_client_id()
|
||||||
|
_cfg = load_agent_config()
|
||||||
|
SERVER_URL = str(
|
||||||
|
os.getenv("NP4_SERVER_URL", _cfg.get("server_url", "http://127.0.0.1:8080"))
|
||||||
|
).rstrip("/")
|
||||||
|
API_TOKEN = str(os.getenv("NP4_API_TOKEN", _cfg.get("api_token", "")))
|
||||||
|
|
||||||
|
|
||||||
|
def http_headers() -> dict[str, str]:
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if API_TOKEN:
|
||||||
|
headers["X-Api-Token"] = API_TOKEN
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def ws_url_from_http(base: str) -> str:
|
||||||
|
parsed = urlparse(base)
|
||||||
|
scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||||
|
netloc = parsed.netloc
|
||||||
|
return f"{scheme}://{netloc}/ws/device"
|
||||||
|
|
||||||
|
|
||||||
|
def toast(title: str, message: str) -> None:
|
||||||
|
if Notification is None:
|
||||||
|
print(f"[notify fallback] {title}: {message}")
|
||||||
|
return
|
||||||
|
n = Notification(app_id=APP_NAME, title=title, msg=message, duration="short")
|
||||||
|
if audio:
|
||||||
|
n.set_audio(audio.Default, loop=False)
|
||||||
|
n.show()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tray_icon_image():
|
||||||
|
if Image is None:
|
||||||
|
return None
|
||||||
|
icon_path = app_base_dir() / "icon.ico"
|
||||||
|
if icon_path.exists():
|
||||||
|
try:
|
||||||
|
return Image.open(icon_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
png_path = app_base_dir() / "icon.png"
|
||||||
|
if png_path.exists():
|
||||||
|
try:
|
||||||
|
return Image.open(png_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.ellipse((8, 8, 56, 56), fill=(79, 142, 247, 255))
|
||||||
|
d.ellipse((20, 20, 44, 44), fill=(255, 255, 255, 235))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def start_tray() -> None:
|
||||||
|
if pystray is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def on_quit(icon, _item):
|
||||||
|
STOP_EVENT.set()
|
||||||
|
icon.stop()
|
||||||
|
|
||||||
|
image = _load_tray_icon_image()
|
||||||
|
if image is None:
|
||||||
|
return
|
||||||
|
icon = pystray.Icon(
|
||||||
|
"NotifyPulseAgentV4",
|
||||||
|
image,
|
||||||
|
f"NotifyPulse Agent V4 ({DEVICE_ID})",
|
||||||
|
menu=pystray.Menu(pystray.MenuItem("Quit", on_quit)),
|
||||||
|
)
|
||||||
|
t = threading.Thread(target=icon.run, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
|
||||||
|
def set_wallpaper(path: str) -> bool:
|
||||||
|
try:
|
||||||
|
ok = ctypes.windll.user32.SystemParametersInfoW(
|
||||||
|
SPI_SETDESKWALLPAPER, 0, str(path), SPIF_UPDATEINIFILE | SPIF_SENDCHANGE
|
||||||
|
)
|
||||||
|
return bool(ok)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _download_to_cache(url: str) -> Path:
|
||||||
|
ext = Path(urlparse(url).path).suffix.lower() or ".jpg"
|
||||||
|
out = DATA_DIR / "cache" / f"wallpaper_{int(time.time())}{ext}"
|
||||||
|
r = requests.get(url, headers=http_headers(), timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
out.write_bytes(r.content)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_overlay_image(pil_img, sw: int, sh: int, stretch: bool):
|
||||||
|
if stretch:
|
||||||
|
return pil_img.resize((sw, sh), Image.Resampling.LANCZOS)
|
||||||
|
img_w, img_h = pil_img.size
|
||||||
|
scale = min(sw / img_w, sh / img_h)
|
||||||
|
nw, nh = int(img_w * scale), int(img_h * scale)
|
||||||
|
resized = pil_img.resize((nw, nh), Image.Resampling.LANCZOS)
|
||||||
|
canvas = Image.new("RGB", (sw, sh), "black")
|
||||||
|
canvas.paste(resized, ((sw - nw) // 2, (sh - nh) // 2))
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
|
||||||
|
def show_overlay(path: str, duration_ms: int, opacity: float = 0.4, stretch: bool = False) -> bool:
|
||||||
|
if tk is None or Image is None or ImageTk is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
pil_img = Image.open(path)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
|
||||||
|
win = tk.Toplevel(root)
|
||||||
|
win.attributes("-topmost", True)
|
||||||
|
win.attributes("-alpha", max(0.05, min(1.0, float(opacity))))
|
||||||
|
win.overrideredirect(True)
|
||||||
|
win.geometry(f"{sw}x{sh}+0+0")
|
||||||
|
prepared = _prepare_overlay_image(pil_img, sw, sh, stretch)
|
||||||
|
tk_img = ImageTk.PhotoImage(prepared)
|
||||||
|
label = tk.Label(win, image=tk_img, bg="black")
|
||||||
|
label.image = tk_img
|
||||||
|
label.pack(fill="both", expand=True)
|
||||||
|
win.after(max(500, int(duration_ms)), root.destroy)
|
||||||
|
root.mainloop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def version_tuple(v: str) -> tuple[int, ...]:
|
||||||
|
nums: list[int] = []
|
||||||
|
for part in v.replace("-", ".").split("."):
|
||||||
|
if part.isdigit():
|
||||||
|
nums.append(int(part))
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return tuple(nums or [0])
|
||||||
|
|
||||||
|
|
||||||
|
async def send_event(ws, name: str, data: dict[str, Any]) -> None:
|
||||||
|
await ws.send(json.dumps({"type": "event", "name": name, "data": data}))
|
||||||
|
|
||||||
|
|
||||||
|
async def send_ack(ws, command_id: str, status: str = "ok", detail: str = "") -> None:
|
||||||
|
await ws.send(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "ack",
|
||||||
|
"command_id": command_id,
|
||||||
|
"status": status,
|
||||||
|
"detail": detail,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_local_settings() -> dict[str, Any]:
|
||||||
|
return load_json(SETTINGS_FILE, {})
|
||||||
|
|
||||||
|
|
||||||
|
def save_local_settings(settings: dict[str, Any]) -> None:
|
||||||
|
save_json(SETTINGS_FILE, settings)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_command(ws, msg: dict[str, Any]) -> None:
|
||||||
|
cid = str(msg.get("id", ""))
|
||||||
|
action = str(msg.get("action", ""))
|
||||||
|
data = msg.get("data", {}) if isinstance(msg.get("data"), dict) else {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if action == "notify":
|
||||||
|
title = str(data.get("title", "NotifyPulse"))
|
||||||
|
message = str(data.get("message", ""))
|
||||||
|
toast(title, message)
|
||||||
|
await send_ack(ws, cid, "ok", "notification shown")
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "wallpaper_set":
|
||||||
|
path = str(data.get("path", "")).strip()
|
||||||
|
url = str(data.get("url", "")).strip()
|
||||||
|
if url:
|
||||||
|
local = _download_to_cache(url)
|
||||||
|
ok = set_wallpaper(str(local))
|
||||||
|
elif path:
|
||||||
|
ok = set_wallpaper(path)
|
||||||
|
else:
|
||||||
|
await send_ack(ws, cid, "error", "missing wallpaper path/url")
|
||||||
|
return
|
||||||
|
await send_ack(ws, cid, "ok" if ok else "error", "wallpaper applied" if ok else "wallpaper failed")
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "overlay_show":
|
||||||
|
path = str(data.get("path", "")).strip()
|
||||||
|
url = str(data.get("url", "")).strip()
|
||||||
|
duration_ms = int(data.get("duration_ms", 6000))
|
||||||
|
opacity = float(data.get("opacity", 0.4))
|
||||||
|
stretch = bool(data.get("stretch", False))
|
||||||
|
if url:
|
||||||
|
local = _download_to_cache(url)
|
||||||
|
ok = show_overlay(str(local), duration_ms, opacity=opacity, stretch=stretch)
|
||||||
|
elif path:
|
||||||
|
ok = show_overlay(path, duration_ms, opacity=opacity, stretch=stretch)
|
||||||
|
else:
|
||||||
|
await send_ack(ws, cid, "error", "missing overlay path/url")
|
||||||
|
return
|
||||||
|
await send_ack(ws, cid, "ok" if ok else "error", "overlay shown" if ok else "overlay failed")
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "sync_settings":
|
||||||
|
settings = data.get("settings", {})
|
||||||
|
if isinstance(settings, dict):
|
||||||
|
save_local_settings(settings)
|
||||||
|
await send_ack(ws, cid, "ok", "settings synced")
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "ping":
|
||||||
|
await send_ack(ws, cid, "ok", "pong")
|
||||||
|
return
|
||||||
|
|
||||||
|
await send_ack(ws, cid, "unsupported", f"unknown action: {action}")
|
||||||
|
except Exception as exc:
|
||||||
|
await send_ack(ws, cid, "error", str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
async def heartbeat_loop(ws) -> None:
|
||||||
|
while not STOP_EVENT.is_set():
|
||||||
|
await ws.send(json.dumps({"type": "heartbeat", "time": int(time.time())}))
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
|
||||||
|
|
||||||
|
def check_update_manifest() -> tuple[bool, dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
r = requests.get(
|
||||||
|
f"{SERVER_URL}/api/v4/update/manifest/windows",
|
||||||
|
headers=http_headers(),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
manifest = r.json()
|
||||||
|
remote_v = str(manifest.get("version", "0.0.0"))
|
||||||
|
return version_tuple(remote_v) > version_tuple(AGENT_VERSION), manifest
|
||||||
|
except Exception:
|
||||||
|
return False, {}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_client() -> None:
|
||||||
|
ws_url = ws_url_from_http(SERVER_URL)
|
||||||
|
backoff_s = 2
|
||||||
|
while not STOP_EVENT.is_set():
|
||||||
|
try:
|
||||||
|
async with connect(ws_url, ping_interval=20, ping_timeout=20, open_timeout=15) as ws:
|
||||||
|
hello = {
|
||||||
|
"type": "hello",
|
||||||
|
"token": API_TOKEN,
|
||||||
|
"payload": {
|
||||||
|
"device_id": DEVICE_ID,
|
||||||
|
"platform": "windows",
|
||||||
|
"version": AGENT_VERSION,
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"capabilities": ["notify", "wallpaper_set", "overlay_show", "sync_settings"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await ws.send(json.dumps(hello))
|
||||||
|
|
||||||
|
heartbeat_task = asyncio.create_task(heartbeat_loop(ws))
|
||||||
|
try:
|
||||||
|
update_available, manifest = check_update_manifest()
|
||||||
|
if update_available:
|
||||||
|
await send_event(
|
||||||
|
ws,
|
||||||
|
"update_available",
|
||||||
|
{
|
||||||
|
"current_version": AGENT_VERSION,
|
||||||
|
"target_version": manifest.get("version", ""),
|
||||||
|
"download_url": manifest.get("download_url", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
raw = await ws.recv()
|
||||||
|
msg = json.loads(raw)
|
||||||
|
mtype = msg.get("type", "")
|
||||||
|
if mtype == "welcome":
|
||||||
|
incoming = msg.get("settings")
|
||||||
|
if isinstance(incoming, dict):
|
||||||
|
save_local_settings(incoming)
|
||||||
|
elif mtype == "command":
|
||||||
|
await apply_command(ws, msg)
|
||||||
|
elif mtype == "heartbeat_ack":
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
heartbeat_task.cancel()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await heartbeat_task
|
||||||
|
|
||||||
|
backoff_s = 2
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[agent] disconnected: {exc}")
|
||||||
|
await asyncio.sleep(backoff_s)
|
||||||
|
backoff_s = min(backoff_s * 2, 30)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print(f"{APP_NAME} {AGENT_VERSION}")
|
||||||
|
print(f"Server: {SERVER_URL}")
|
||||||
|
print(f"Device: {DEVICE_ID}")
|
||||||
|
print(f"Host: {platform.node()}")
|
||||||
|
start_tray()
|
||||||
|
toast("NotifyPulse Agent", "Agent started and connecting to server")
|
||||||
|
asyncio.run(run_client())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
v4/windows_client/dist/NotifyPulseAgent-V4.exe
vendored
Normal file
BIN
v4/windows_client/dist/NotifyPulseAgent-V4.exe
vendored
Normal file
Binary file not shown.
BIN
v4/windows_client/icon.ico
Normal file
BIN
v4/windows_client/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
BIN
v4/windows_client/icon.png
Normal file
BIN
v4/windows_client/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
6
v4/windows_client/requirements.txt
Normal file
6
v4/windows_client/requirements.txt
Normal file
@@ -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