mirror of
https://github.com/nailuj05/framemate.git
synced 2026-10-17 13:01:12 +02:00
various small fixes and improvements from testing
This commit is contained in:
1 parent
fcd9b3e34c
commit
75e57b1b64
8 files changed
+85
-21
No files matched your search
+28
-6
@@ -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() {
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
|
||||
@@ -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\
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = ¶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<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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user