diff --git a/app/src-tauri/gen/android/app/src/main/java/dev/framemate/app/FrameNotificationListener.kt b/app/src-tauri/gen/android/app/src/main/java/dev/framemate/app/FrameNotificationListener.kt index 1366daf..6510235 100644 --- a/app/src-tauri/gen/android/app/src/main/java/dev/framemate/app/FrameNotificationListener.kt +++ b/app/src-tauri/gen/android/app/src/main/java/dev/framemate/app/FrameNotificationListener.kt @@ -5,15 +5,22 @@ import android.content.Context import android.content.pm.ApplicationInfo import android.graphics.Bitmap import android.service.notification.NotificationListenerService +import android.os.SystemClock import android.service.notification.StatusBarNotification import android.util.Base64 import androidx.core.graphics.drawable.toBitmap import androidx.core.os.BundleCompat import org.json.JSONObject import java.io.ByteArrayOutputStream -import java.util.concurrent.Executors +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit private const val ICON_PX = 96 +/** Older than this when its turn comes (Frame asleep or away, sends timing out): dropped. */ +private const val MAX_AGE_MS = 30_000L +/** Waiting sends; beyond that the oldest is dropped. */ +private const val MAX_QUEUED = 10 /** Notification flags that mean "status, not news": never shown in the headset. */ private const val SKIP_FLAGS = Notification.FLAG_ONGOING_EVENT or Notification.FLAG_FOREGROUND_SERVICE or Notification.FLAG_GROUP_SUMMARY @@ -26,9 +33,17 @@ private const val SKIP_FLAGS = * pinned TLS connection, and is never logged or stored. */ class FrameNotificationListener : NotificationListenerService() { - /** One at a time and in order, off the main thread: sending blocks on the network. */ - private val sender = Executors.newSingleThreadExecutor() - /** Last text per notification key: apps re-post the same notification for progress etc. */ + /** + * One at a time and in order, off the main thread: sending blocks on the network (up to ~15 s + * per notification while the Frame is unreachable), so the queue is short and drops the oldest. + */ + private val sender = ThreadPoolExecutor( + 1, 1, 0, TimeUnit.MILLISECONDS, LinkedBlockingQueue(MAX_QUEUED), ThreadPoolExecutor.DiscardOldestPolicy(), + ) + /** + * Last content per notification key: apps re-post the same notification for progress etc. A new + * message with the same text (e.g. a second "ok") has a new `when`, so it still goes through. + */ private val lastText = LinkedHashMap() private val icons = HashMap() @@ -48,7 +63,9 @@ class FrameNotificationListener : NotificationListenerService() { val body = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT) ?: extras.getCharSequence(Notification.EXTRA_TEXT)) ?.toString().orEmpty() if (title.isBlank() && body.isBlank()) return - val fingerprint = (title + "\u0000" + body).hashCode() + // ONLY_ALERT_ONCE: the app itself says re-posts aren't news, even if it bumps `when`. + val time = if (notification.flags and Notification.FLAG_ONLY_ALERT_ONCE != 0) 0L else notification.`when` + val fingerprint = (title + "\u0000" + body + "\u0000" + time).hashCode() synchronized(lastText) { if (lastText[sbn.key] == fingerprint) return lastText[sbn.key] = fingerprint @@ -61,7 +78,12 @@ class FrameNotificationListener : NotificationListenerService() { .put("body", body) .put("icon", icon(sbn.packageName, app) ?: JSONObject.NULL) .toString() - sender.execute { prefs.recordResult(NotificationBridge.send(dataDir.absolutePath, json)) } + val posted = SystemClock.elapsedRealtime() + sender.execute { + // A toast for something that happened minutes ago would only confuse. + if (SystemClock.elapsedRealtime() - posted > MAX_AGE_MS) return@execute + prefs.recordResult(NotificationBridge.send(dataDir.absolutePath, json)) + } } override fun onDestroy() { diff --git a/app/src-tauri/src/forward.rs b/app/src-tauri/src/forward.rs index 42a3d4b..bf4a5b4 100644 --- a/app/src-tauri/src/forward.rs +++ b/app/src-tauri/src/forward.rs @@ -50,6 +50,8 @@ pub fn send(config_dir: &Path, json: &str) -> String { .unwrap_or_else(|| "error: unexpected answer from the Frame".into()), Ok((401, _)) => "error: the Frame rejected the token, pair again".into(), Ok((429, _)) => "rate_limited".into(), + // Agents before v0.3.0 have no /api/notify. + Ok((404, _)) => "error: the FrameMate agent on the Frame is too old for notifications, update it".into(), Ok((status, body)) => format!("error: {status} {body}"), Err(e) => format!("error: {e}"), } diff --git a/app/src-tauri/src/proxy.rs b/app/src-tauri/src/proxy.rs index c94950d..20bbfd9 100644 --- a/app/src-tauri/src/proxy.rs +++ b/app/src-tauri/src/proxy.rs @@ -89,7 +89,8 @@ impl Target { pub async fn post_json(&self, path: &str, json: &str) -> Result<(u16, String), String> { let exchange = async { let mut tls = connect(&self.connector, &self.pairing, &self.preferred).await.map_err(|e| match e { - Failure::Unreachable(e) | Failure::Rejected(e) => e, + Failure::Rejected(e) => format!("this isn't the Frame the app was paired with, pair again ({e})"), + Failure::Unreachable(e) => format!("can't reach the Frame ({e})"), })?; let request = format!( "POST {path} HTTP/1.1\r\nHost: framemate\r\nAuthorization: Bearer {}\r\n\ diff --git a/app/src/lib/notifications.svelte.ts b/app/src/lib/notifications.svelte.ts index 928134b..5f088ee 100644 --- a/app/src/lib/notifications.svelte.ts +++ b/app/src/lib/notifications.svelte.ts @@ -19,6 +19,11 @@ class Notifications { access = $state(false); testing = $state(false); + constructor() { + // Known before the first render: Settings opens its section depending on `access`. + this.refresh(); + } + get enabled() { return this.settings?.enabled ?? false; } diff --git a/app/src/routes/settings/+page.svelte b/app/src/routes/settings/+page.svelte index fc8cb52..7c835f1 100644 --- a/app/src/routes/settings/+page.svelte +++ b/app/src/routes/settings/+page.svelte @@ -105,7 +105,8 @@ {#if notifications.supported && notifications.settings} -
+ +
{#snippet aside()}{notifications.enabled ? "On" : "Off"}{/snippet} {#if !notifications.access} diff --git a/crates/agent/src/cdp.rs b/crates/agent/src/cdp.rs index 31ed073..3dfe9a4 100644 --- a/crates/agent/src/cdp.rs +++ b/crates/agent/src/cdp.rs @@ -2,7 +2,7 @@ //! Receives `{topic, data}` through `__framemateEmit`. Reconnects when Steam restarts. Carries `/api/notify` requests to shim's hook (notify.rs). use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; use anyhow::{Context, bail}; @@ -19,9 +19,11 @@ use crate::notify::{self, Outcome}; const TARGET_TITLE: &str = "SharedJSContext"; const BINDING: &str = "__framemateEmit"; -const SHIM: &str = include_str!("shim.js"); -/// Fallback icon for toasts in the headset -const LOGO_SVG: &str = include_str!("../../../assets/framemate-black.svg"); +/// `shim.js` with the fallback toast icon (the FrameMate logo) filled in. +static SHIM: LazyLock = LazyLock::new(|| { + let logo = include_str!("../../../assets/framemate-black.svg"); + include_str!("shim.js").replace("\"__FRAMEMATE_LOGO_SVG__\"", &json!(logo).to_string()) +}); const MAX_BACKOFF: Duration = Duration::from_secs(30); /// after this much silence, ping once, then reconnect const IDLE_TIMEOUT: Duration = Duration::from_secs(30); @@ -82,9 +84,12 @@ async fn session( }); let mut pinged = false; + // Only traffic from Steam moves this, not toast requests: otherwise a steady stream of + // notifications would keep a hung connection alive. + let mut idle_until = tokio::time::Instant::now() + IDLE_TIMEOUT; loop { let next = tokio::select! { - next = tokio::time::timeout(IDLE_TIMEOUT, rx.next()) => next, + next = tokio::time::timeout_at(idle_until, rx.next()) => next, Some(request) = toasts.recv() => { // Queued while Steam was away; showing it now would be confusingly late. if Instant::now() >= request.deadline { @@ -107,11 +112,13 @@ async fn session( Err(_) if pinged => bail!("Steam stopped responding"), Err(_) => { pinged = true; + idle_until = tokio::time::Instant::now() + IDLE_TIMEOUT; tx.send(call("Runtime.evaluate", json!({"expression": "1"})).1).await?; continue; } }; pinged = false; + idle_until = tokio::time::Instant::now() + IDLE_TIMEOUT; let text = match msg? { Message::Text(text) => text, Message::Close(_) => break, @@ -124,8 +131,7 @@ async fn session( let context = ¶ms["context"]; if context["auxData"]["isDefault"].as_bool() == Some(true) { tracing::info!("steam CDP: injecting shim into context {}", context["id"]); - let shim = SHIM.replace("\"__FRAMEMATE_LOGO_SVG__\"", &serde_json::to_string(LOGO_SVG)?); - let params = json!({"expression": shim, "contextId": context["id"]}); + let params = json!({"expression": SHIM.as_str(), "contextId": context["id"]}); tx.send(call("Runtime.evaluate", params).1).await?; } } @@ -248,3 +254,12 @@ pub(crate) async fn http_get(authority: &str, path: &str) -> anyhow::Result { + // What the module scan finds, kept per JS context: + // requiring every module again on each re-injection (agent reconnects) would be slow + const findToastParts = () => { let require; W.webpackChunksteamui.push([[Symbol("framemate")], {}, r => { require = r; }]); // Steams runtime exposes no module cache (`require.c`), require every id (like decky) also evaluate modules Steam hadnt run yet @@ -246,12 +248,17 @@ if (!renderer || !runtimes.length || !css || type === undefined) { throw new Error(`toast hook: renderer ${!!renderer}, jsx runtimes ${runtimes.length}, css ${!!css}, type ${type}`); } + return { renderer, runtimes, css, type }; + }; + const toasts = () => { + const { renderer, runtimes, css, type } = (W.__framemateToastParts ??= findToastParts()); const { jsx, jsxs } = runtimes[0]; // originals, taken before patching const cx = (...names) => names.filter(Boolean).join(" "); // Same markup and classes as Steam's own short toast template. + const ours = group => group?.notifications?.findLast?.(n => n?.framemate)?.framemate; const Toast = ({ group, onDismiss }) => { - const n = group.notifications[group.notifications.length - 1].framemate; + const n = ours(group); return jsxs("div", { className: cx(css.ShortTemplate, n.body && css.TwoLine), onClick: () => onDismiss?.(), @@ -275,7 +282,7 @@ for (const name of ["jsx", "jsxs"]) { const original = runtime[name]; const wrapper = function (type, props) { - if (!disposed && type === renderer && props?.group?.notifications?.[0]?.framemate) { + if (!disposed && type === renderer && ours(props?.group)) { return original.call(this, Toast, props, ...[...arguments].slice(2)); } return original.apply(this, arguments); @@ -311,13 +318,24 @@ // Steam's stores load after the context is created; wait for them. const start = () => { if (disposed) return; - if (typeof SteamClient === "undefined" || !W.appStore || !W.SteamUIStore || !W.NotificationStore || !W.webpackChunksteamui) { + if (typeof SteamClient === "undefined" || !W.appStore || !W.SteamUIStore) { timers.push(setTimeout(start, 2000)); return; } - for (const section of [battery, downloads, apps, vr, stores, toasts]) { + for (const section of [battery, downloads, apps, vr, stores]) { try { section(); } catch (e) { fail(section.name, e); } } + startToasts(); + }; + // Toasts wait for their own prerequisites, so a Steam change there only costs the toasts. + const startToasts = (tries = 0) => { + if (disposed) return; + if (!W.NotificationStore || !W.webpackChunksteamui) { + if (tries < 30) timers.push(setTimeout(() => startToasts(tries + 1), 2000)); + else fail("toasts", new Error("NotificationStore or webpackChunksteamui not found")); + return; + } + try { toasts(); } catch (e) { fail("toasts", e); } }; start(); return "framemate shim installed"; diff --git a/scripts/install.sh b/scripts/install.sh index d0aa480..9a88c71 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -92,7 +92,7 @@ Run this in a terminal on the Frame via SSH, or use the Desktop Mode Konsole." say "" say "NOTE: the agent isn't running yet, which is normal in Desktop Mode installs." say "Scan the code above now anyway: The app will remember the fingerprint and connect to your headset once it's back up." - say "Reboot your headset of the service to start." + say "Reboot your headset or the service to start." fi }