various small fixes and improvements from testing

This commit is contained in:
Julian committed 2026-10-12 22:15:57 +02:00
1 parent fcd9b3e34c
commit 75e57b1b64
8 files changed
+85 -21

No files matched your search

@@ -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<String, Int>()
private val icons = HashMap<String, String?>()
@@ -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() {
+2
View File
@@ -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}"),
}
+2 -1
View File
@@ -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\
+5
View File
@@ -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;
}
+2 -1
View File
@@ -105,7 +105,8 @@
</Section>
{#if notifications.supported && notifications.settings}
<Section title="Notifications" collapsible>
<!-- Open while access is missing: the bell sends people here for the "restricted settings" step. -->
<Section title="Notifications" collapsible open={!notifications.access}>
{#snippet aside()}<span class="aside">{notifications.enabled ? "On" : "Off"}</span>{/snippet}
<Row label="Show in the headset" value={notifications.enabled ? "On" : "Off (bell in the top bar)"} />
{#if !notifications.access}
+22 -7
View File
@@ -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<String> = 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 = &params["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<Vec<
.await
.context("timeout")?
}
#[cfg(test)]
mod tests {
#[test]
fn shim_gets_the_logo() {
assert!(!super::SHIM.contains("__FRAMEMATE_LOGO_SVG__"));
assert!(super::SHIM.contains("<svg"));
}
}
+23 -5
View File
@@ -213,7 +213,9 @@
// Notifications"), "disable all toasts" and suppression while the headset is asleep.
// Webpack modules are found by shape, never by id (ids change with every Steam build).
const LOGO = "data:image/svg+xml;charset=utf-8," + encodeURIComponent("__FRAMEMATE_LOGO_SVG__");
const toasts = () => {
// 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";
+1 -1
View File
@@ -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
}