sentry test push

This commit is contained in:
firestar5683
2026-08-15 11:48:39 -05:00
parent d97a4fdd65
commit ec485d078a
7 changed files with 417 additions and 28 deletions
+1
View File
@@ -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
@@ -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
@@ -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);
@@ -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`<p class="sentry-empty">No Sentry events recorded yet.</p>`
@@ -159,14 +189,15 @@ export function SentryMode() {
@change="${(event) => saveParam("SentryModeNtfyUrl", event.currentTarget.value.trim())}" />
</label>
<button
class="sentry-button"
@click="${async () => {
const permission = await requestSentryNotificationPermission()
showSnackbar(permission === "granted" ? "Browser alerts enabled." : "Browser alerts were not enabled.")
}}">
Enable browser alerts
</button>
<div class="sentry-action-row">
<button class="sentry-button" @click="${enablePush}" disabled="${() => state.pushBusy}">
${() => state.pushBusy ? "Enabling…" : "Enable Chrome notifications"}
</button>
<button class="sentry-button sentry-button-secondary" @click="${sendTestPush}" disabled="${() => state.pushBusy}">
${() => state.pushBusy ? "Sending…" : "Send test push"}
</button>
</div>
<p class="sentry-muted">Enable notifications once, then use the test push to verify Galaxy can reach this browser even when the page is not active.</p>
`}
</section>
@@ -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)
})
)
})
+206
View File
@@ -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 "{}"
Generated
+58 -19
View File
@@ -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"