diff --git a/pyproject.toml b/pyproject.toml index 9f1acfb4f..8dce92875 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "sounddevice", # micd + soundd "pyserial", # pigeond + qcomgpsd "requests", # many one-off uses + "pywebpush", # Galaxy Sentry browser push notifications "sympy", # rednose + friends "crcmod", # cars + qcomgpsd "tqdm", # cars (fw_versions.py) on start + many one-off uses diff --git a/starpilot/system/the_galaxy/assets/components/sentry_notifications.js b/starpilot/system/the_galaxy/assets/components/sentry_notifications.js index 19bddef4c..9081f7acc 100644 --- a/starpilot/system/the_galaxy/assets/components/sentry_notifications.js +++ b/starpilot/system/the_galaxy/assets/components/sentry_notifications.js @@ -1,5 +1,6 @@ const STORAGE_KEY = "starpilot.sentry.last-event" const POLL_INTERVAL_MS = 5000 +const SERVICE_WORKER_PATH = "/service-worker.js" let started = false let initialized = false @@ -49,6 +50,74 @@ export async function requestSentryNotificationPermission() { return Notification.requestPermission() } +function base64ToUint8Array(value) { + const padding = "=".repeat((4 - (value.length % 4)) % 4) + const normalized = (value + padding).replace(/-/g, "+").replace(/_/g, "/") + const raw = window.atob(normalized) + return Uint8Array.from(raw, (character) => character.charCodeAt(0)) +} + +function subscriptionPayload(subscription) { + if (typeof subscription.toJSON === "function") return subscription.toJSON() + + const key = (name) => subscription.getKey(name) + const encode = (value) => btoa(String.fromCharCode(...new Uint8Array(value))) + return { + endpoint: subscription.endpoint, + expirationTime: subscription.expirationTime, + keys: { + p256dh: encode(key("p256dh")), + auth: encode(key("auth")), + }, + } +} + +export async function enableSentryPush() { + if (typeof Notification === "undefined" || !("serviceWorker" in navigator) || !("PushManager" in window)) { + return { ok: false, message: "This browser does not support Chrome Web Push." } + } + if (!window.isSecureContext) { + return { ok: false, message: "Chrome notifications require Galaxy over HTTPS." } + } + + const permission = await requestSentryNotificationPermission() + if (permission !== "granted") { + return { ok: false, message: "Chrome notification permission was not granted." } + } + + const configResponse = await fetch("/api/sentry/push/config", { cache: "no-store" }) + const config = await configResponse.json() + if (!configResponse.ok || !config.enabled || !config.publicKey) { + return { ok: false, message: config.error || "Galaxy Web Push is unavailable." } + } + + await navigator.serviceWorker.register(SERVICE_WORKER_PATH, { scope: "/" }) + const registration = await navigator.serviceWorker.ready + let subscription = await registration.pushManager.getSubscription() + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: base64ToUint8Array(config.publicKey), + }) + } + + const response = await fetch("/api/sentry/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(subscriptionPayload(subscription)), + }) + const payload = await response.json() + if (!response.ok) return { ok: false, message: payload.error || "Galaxy could not save this browser." } + return { ok: true, message: "Chrome notifications enabled for this browser." } +} + +export async function sendSentryTestPush() { + const response = await fetch("/api/sentry/push/test", { method: "POST" }) + const payload = await response.json() + if (!response.ok) throw new Error(payload.error || "Galaxy could not send the test push.") + return payload +} + export function startSentryNotifications() { if (started) return started = true diff --git a/starpilot/system/the_galaxy/assets/components/tools/sentry.css b/starpilot/system/the_galaxy/assets/components/tools/sentry.css index 5109c65ea..f6016b604 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/sentry.css +++ b/starpilot/system/the_galaxy/assets/components/tools/sentry.css @@ -117,6 +117,12 @@ white-space: nowrap; } +.sentry-action-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + .sentry-loading, .sentry-empty { color: var(--text-muted); diff --git a/starpilot/system/the_galaxy/assets/components/tools/sentry.js b/starpilot/system/the_galaxy/assets/components/tools/sentry.js index 86dd6fb17..7d484c17f 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/sentry.js +++ b/starpilot/system/the_galaxy/assets/components/tools/sentry.js @@ -1,6 +1,9 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" import { isGalaxyTunnel } from "/assets/js/utils.js" -import { requestSentryNotificationPermission } from "/assets/components/sentry_notifications.js" +import { + enableSentryPush, + sendSentryTestPush, +} from "/assets/components/sentry_notifications.js" const state = reactive({ loading: true, @@ -9,6 +12,7 @@ const state = reactive({ status: {}, event: {}, testBusy: false, + pushBusy: false, }) let pollTimer = null @@ -83,6 +87,32 @@ async function sendTestEvent() { } } +async function enablePush() { + if (state.pushBusy) return + state.pushBusy = true + try { + const result = await enableSentryPush() + showSnackbar(result.message) + } catch (error) { + showSnackbar(error.message || "Could not enable Chrome notifications.") + } finally { + state.pushBusy = false + } +} + +async function sendTestPush() { + if (state.pushBusy) return + state.pushBusy = true + try { + await sendSentryTestPush() + showSnackbar("Test push sent. Check your Chrome notifications.") + } catch (error) { + showSnackbar(error.message || "Could not send the test push.") + } finally { + state.pushBusy = false + } +} + function renderEvent() { const event = state.event || {} if (!event.eventId) return html`
No Sentry events recorded yet.
` @@ -159,14 +189,15 @@ export function SentryMode() { @change="${(event) => saveParam("SentryModeNtfyUrl", event.currentTarget.value.trim())}" /> - +Enable notifications once, then use the test push to verify Galaxy can reach this browser even when the page is not active.
`} diff --git a/starpilot/system/the_galaxy/assets/service-worker.js b/starpilot/system/the_galaxy/assets/service-worker.js new file mode 100644 index 000000000..eb417a429 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/service-worker.js @@ -0,0 +1,37 @@ +self.addEventListener("push", (event) => { + let data = {} + try { + data = event.data ? event.data.json() : {} + } catch { + data = { body: event.data?.text() || "Sentry event detected." } + } + + const title = data.title || "StarPilot Sentry Mode" + const options = { + body: data.body || "Movement detected while parked.", + tag: `starpilot-sentry-${data.eventId || "event"}`, + data: { url: data.url || "/sentry" }, + icon: "/assets/images/favicon.ico", + badge: "/assets/images/favicon-32x32.png", + requireInteraction: true, + } + + event.waitUntil(self.registration.showNotification(title, options)) +}) + +self.addEventListener("notificationclick", (event) => { + event.notification.close() + const targetUrl = new URL(event.notification.data?.url || "/sentry", self.location.origin).href + + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then((windowClients) => { + for (const client of windowClients) { + if ("focus" in client) { + client.navigate(targetUrl) + return client.focus() + } + } + return clients.openWindow(targetUrl) + }) + ) +}) diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 680879a1f..2b2a3e564 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -595,7 +595,142 @@ def _capture_sentry_test_images(event_id: str) -> list[str]: return paths +_SENTRY_PUSH_LOCK = threading.Lock() +_SENTRY_PUSH_PRIVATE_KEY_NAME = "sentry_vapid_private.pem" +_SENTRY_PUSH_SUBSCRIPTIONS_NAME = "sentry_push_subscriptions.json" +_SENTRY_PUSH_SUBJECT = os.getenv("STARPILOT_VAPID_SUBJECT", "mailto:galaxy@firestar.link") + + +def _sentry_push_paths() -> tuple[Path, Path]: + galaxy_dir = _get_galaxy_dir() + return galaxy_dir / _SENTRY_PUSH_PRIVATE_KEY_NAME, galaxy_dir / _SENTRY_PUSH_SUBSCRIPTIONS_NAME + + +def _load_sentry_push_subscriptions() -> list[dict]: + _, subscriptions_path = _sentry_push_paths() + try: + payload = json.loads(subscriptions_path.read_text()) + except (OSError, TypeError, ValueError, json.JSONDecodeError): + return [] + + if not isinstance(payload, list): + return [] + return [subscription for subscription in payload if isinstance(subscription, dict)] + + +def _save_sentry_push_subscriptions(subscriptions: list[dict]) -> None: + _, subscriptions_path = _sentry_push_paths() + subscriptions_path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = subscriptions_path.with_suffix(".tmp") + temporary_path.write_text(json.dumps(subscriptions, separators=(",", ":"))) + temporary_path.chmod(0o600) + temporary_path.replace(subscriptions_path) + + +def _normalize_sentry_push_subscription(payload) -> dict | None: + if not isinstance(payload, dict): + return None + + subscription = payload.get("subscription", payload) + if not isinstance(subscription, dict): + return None + + endpoint = str(subscription.get("endpoint") or "").strip() + keys = subscription.get("keys") + if not endpoint.startswith("https://") or len(endpoint) > 4096 or not isinstance(keys, dict): + return None + + p256dh = str(keys.get("p256dh") or "").strip() + auth = str(keys.get("auth") or "").strip() + if not p256dh or not auth or len(p256dh) > 512 or len(auth) > 512: + return None + + return { + "endpoint": endpoint, + "expirationTime": subscription.get("expirationTime"), + "keys": {"p256dh": p256dh, "auth": auth}, + } + + +def _get_sentry_vapid(): + try: + from py_vapid import Vapid + except ModuleNotFoundError as error: + raise RuntimeError("pywebpush is not installed") from error + + private_key_path, _ = _sentry_push_paths() + private_key_path.parent.mkdir(parents=True, exist_ok=True) + if private_key_path.is_file(): + return Vapid.from_file(str(private_key_path)) + + vapid = Vapid() + vapid.generate_keys() + vapid.save_key(str(private_key_path)) + private_key_path.chmod(0o600) + return vapid + + +def _sentry_vapid_public_key(vapid) -> str: + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + raw_key = vapid.public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint) + return base64.urlsafe_b64encode(raw_key).rstrip(b"=").decode("ascii") + + +def _sentry_push_subscription_count() -> int: + with _SENTRY_PUSH_LOCK: + return len(_load_sentry_push_subscriptions()) + + +def _dispatch_sentry_push(event: dict) -> None: + try: + from pywebpush import webpush + + vapid = _get_sentry_vapid() + except Exception: + cloudlog.exception("Galaxy: Sentry Web Push is unavailable") + return + + event_id = str(event.get("eventId") or "") + payload = { + "title": "StarPilot Sentry Mode", + "body": str(event.get("message") or "Movement detected while parked."), + "eventId": event_id, + "url": f"/sentry?event={quote(event_id, safe='')}", + } + + with _SENTRY_PUSH_LOCK: + subscriptions = _load_sentry_push_subscriptions() + + expired_endpoints = set() + for subscription in subscriptions: + endpoint = subscription.get("endpoint") + try: + webpush( + subscription_info=subscription, + data=json.dumps(payload, separators=(",", ":")), + vapid_private_key=vapid, + vapid_claims={"sub": _SENTRY_PUSH_SUBJECT}, + ttl=300, + timeout=10, + ) + except Exception as error: + response = getattr(error, "response", None) + if getattr(response, "status_code", None) in {404, 410}: + expired_endpoints.add(endpoint) + cloudlog.warning("Galaxy: Sentry Web Push delivery failed: %s", error) + + if expired_endpoints: + with _SENTRY_PUSH_LOCK: + current = _load_sentry_push_subscriptions() + _save_sentry_push_subscriptions([ + subscription for subscription in current + if subscription.get("endpoint") not in expired_endpoints + ]) + + def _dispatch_sentry_event(event: dict) -> None: + _dispatch_sentry_push(event) message = f"🚨 StarPilot Sentry Mode: {event['message']}" webhook = (params.get("SentryModeWebhook", encoding="utf-8") or "").strip() if webhook: @@ -6707,6 +6842,77 @@ def setup(app): "warning": "This wipes local params, backups, themes, models, maps, and route data.", }), 202 + @app.route("/service-worker.js", methods=["GET"]) + def sentry_service_worker(): + response = send_from_directory(app.static_folder, "service-worker.js", mimetype="application/javascript") + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + return response + + @app.route("/api/sentry/push/config", methods=["GET"]) + def sentry_push_config(): + try: + public_key = _sentry_vapid_public_key(_get_sentry_vapid()) + except Exception: + return jsonify({"enabled": False, "error": "Web Push dependencies are unavailable."}), 503 + + return jsonify({ + "enabled": True, + "publicKey": public_key, + "subscriptionCount": _sentry_push_subscription_count(), + }) + + @app.route("/api/sentry/push/subscribe", methods=["POST"]) + def sentry_push_subscribe(): + subscription = _normalize_sentry_push_subscription(request.get_json(silent=True)) + if subscription is None: + return jsonify({"error": "Invalid browser push subscription."}), 400 + + try: + _get_sentry_vapid() + except Exception: + return jsonify({"error": "Web Push dependencies are unavailable."}), 503 + + with _SENTRY_PUSH_LOCK: + subscriptions = _load_sentry_push_subscriptions() + subscriptions = [ + existing for existing in subscriptions + if existing.get("endpoint") != subscription["endpoint"] + ] + subscriptions.append(subscription) + _save_sentry_push_subscriptions(subscriptions) + + return jsonify({"subscribed": True, "subscriptionCount": len(subscriptions)}) + + @app.route("/api/sentry/push/unsubscribe", methods=["POST"]) + def sentry_push_unsubscribe(): + payload = request.get_json(silent=True) or {} + endpoint = str(payload.get("endpoint") or "").strip() + if not endpoint: + return jsonify({"error": "Missing browser push endpoint."}), 400 + + with _SENTRY_PUSH_LOCK: + subscriptions = [ + subscription for subscription in _load_sentry_push_subscriptions() + if subscription.get("endpoint") != endpoint + ] + _save_sentry_push_subscriptions(subscriptions) + + return jsonify({"unsubscribed": True, "subscriptionCount": len(subscriptions)}) + + @app.route("/api/sentry/push/test", methods=["POST"]) + def sentry_push_test(): + if _sentry_push_subscription_count() == 0: + return jsonify({"error": "Enable Chrome notifications first."}), 409 + + event = { + "eventId": f"push-test-{int(time.time())}-{secrets.token_hex(4)}", + "kind": "warning", + "detectedAt": datetime.now(timezone.utc).isoformat(), + "message": "This is a test StarPilot Sentry push notification.", + } + threading.Thread(target=_dispatch_sentry_push, args=(event,), name="galaxy-sentry-push-test", daemon=True).start() + return jsonify({"accepted": True, "eventId": event["eventId"]}), 202 + @app.route("/api/sentry/status", methods=["GET"]) def sentry_status(): raw_event = params.get("SentryModeLastEvent", encoding="utf-8") or "{}" diff --git a/uv.lock b/uv.lock index c8094b725..bffed0725 100644 --- a/uv.lock +++ b/uv.lock @@ -448,7 +448,7 @@ name = "cuda-bindings" version = "13.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "sys_platform != 'darwin'" }, + { name = "cuda-pathfinder", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" }, @@ -475,37 +475,37 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" }, ] cufft = [ - { name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cufft", marker = "sys_platform == 'linux'" }, ] cufile = [ { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" }, ] curand = [ - { name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-curand", marker = "sys_platform == 'linux'" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" }, ] [[package]] @@ -594,7 +594,7 @@ version = "0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-xlib", marker = "sys_platform == 'linux'" }, - { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, + { name = "typing-extensions", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/2f/3a/46ca34abf0725a754bc44ef474ad34aedcc3ea23b052d97b18b76715a6a9/EWMHlib-0.2-py3-none-any.whl", hash = "sha256:f5b07d8cfd4c7734462ee744c32d490f2f3233fa7ab354240069344208d2f6f5", size = 46657, upload-time = "2024-04-17T08:15:56.338Z" }, @@ -760,6 +760,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/e2/a111dbb8625af467ea4760a1373d6ef27aac3137931219902406ccc05423/gymnasium-1.2.0-py3-none-any.whl", hash = "sha256:fc4a1e4121a9464c29b4d7dc6ade3fbeaa36dea448682f5f71a6d2c17489ea76", size = 944301, upload-time = "2025-06-27T08:21:18.83Z" }, ] +[[package]] +name = "http-ece" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/af/249d1576653b69c20b9ac30e284b63bd94af6a175d72d87813235caf2482/http_ece-1.2.1.tar.gz", hash = "sha256:8c6ab23116bbf6affda894acfd5f2ca0fb8facbcbb72121c11c75c33e7ce8cff", size = 8830, upload-time = "2024-08-08T00:10:47.301Z" } + [[package]] name = "hypothesis" version = "6.47.5" @@ -1395,7 +1404,7 @@ name = "nvidia-cudnn-cu13" version = "9.19.0.56" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" }, @@ -1407,7 +1416,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -1437,9 +1446,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'darwin'" }, - { name = "nvidia-cusparse", marker = "sys_platform != 'darwin'" }, - { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-cublas", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, + { name = "nvidia-cusparse", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -1451,7 +1460,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin'" }, + { name = "nvidia-nvjitlink", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -1592,6 +1601,7 @@ dependencies = [ { name = "pyjwt" }, { name = "pyopenssl" }, { name = "pyserial" }, + { name = "pywebpush" }, { name = "pyzmq" }, { name = "qrcode" }, { name = "raylib" }, @@ -1717,6 +1727,7 @@ requires-dist = [ { name = "pytest-timeout", marker = "extra == 'testing'" }, { name = "pytest-xdist", marker = "extra == 'testing'", git = "https://github.com/sshane/pytest-xdist?rev=2b4372bd62699fb412c4fe2f95bf9f01bd2018da" }, { name = "pytools", marker = "platform_machine != 'aarch64' and extra == 'dev'", specifier = ">=2025.1.6" }, + { name = "pywebpush" }, { name = "pywinctl", marker = "extra == 'dev'" }, { name = "pyzmq" }, { name = "qrcode" }, @@ -1987,6 +1998,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971, upload-time = "2025-09-17T20:15:12.262Z" }, ] +[[package]] +name = "py-vapid" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/57/5c1c61f27ce01f939443cf3f6c279a295f7ec0327b18a1cbbcfefe0b5456/py_vapid-1.9.2.tar.gz", hash = "sha256:3c8973b6cf8384ad0c9ae64d6270ccc480e0b92c702d8f5ea2cc03e6b51247f9", size = 20300, upload-time = "2024-11-19T21:55:41.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/fb/b877a221b09dabcebeb073d5e7f19244f3fa1d5aec87092c359a6049a006/py_vapid-1.9.2-py3-none-any.whl", hash = "sha256:4ccf8a00fc54f1f99f66fb543c96f2c82622508ad814b6e9225f2c26948934d7", size = 21492, upload-time = "2024-11-19T21:55:40.832Z" }, +] + [[package]] name = "pyaudio" version = "0.2.14" @@ -4761,7 +4784,7 @@ name = "python-xlib" version = "0.33" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six", marker = "sys_platform != 'darwin'" }, + { name = "six", marker = "sys_platform != 'darwin' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } wheels = [ @@ -4794,6 +4817,22 @@ version = "1.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/79/0c/c16bc93ac2755bac0066a8ecbd2a2931a1735a6fffd99a2b9681c7e83e90/pytweening-1.2.0.tar.gz", hash = "sha256:243318b7736698066c5f362ec5c2b6434ecf4297c3c8e7caa8abfe6af4cac71b", size = 171241, upload-time = "2024-02-20T03:37:56.809Z" } +[[package]] +name = "pywebpush" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "cryptography" }, + { name = "http-ece" }, + { name = "py-vapid" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d9/e497a24bc9f659bfc0e570382a41e6b2d6726fbcfa4d85aaa23fe9c81ba2/pywebpush-2.3.0.tar.gz", hash = "sha256:d1e27db8de9e6757c1875f67292554bd54c41874c36f4b5c4ebb5442dce204f2", size = 28489, upload-time = "2026-02-09T23:30:18.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/ac21241cf8007cb93255eabf318da4f425ec0f75d28c366992253aa8c1b2/pywebpush-2.3.0-py3-none-any.whl", hash = "sha256:3d97469fb14d4323c362319d438183737249a4115b50e146ce233e7f01e3cf98", size = 22851, upload-time = "2026-02-09T23:30:16.093Z" }, +] + [[package]] name = "pywin32" version = "311"