7 Commits
Author SHA1 Message Date
Julian 675a50fdb1 Release v0.1.3 2026-10-04 01:43:12 +02:00
Julian a0fd89809b Merge remote-tracking branch 'origin/main' 2026-10-04 01:39:26 +02:00
Julian 607da0f88d Android 17 local network prompt, connection robustness, comment cleanup
- App: request ACCESS_LOCAL_NETWORK on Android 17+ and show a hint with
  an "Open app settings" button when it's denied (#3)
- App: accept IPv6 addresses as host, suggest the IP when .local fails
- Agent: fall back to IPv4 if IPv6 is unavailable, prefer the stable
  /dev/video-enc0 encoder path
- Trim redundant comments across agent and app
2026-10-04 01:38:04 +02:00
Julian 9c04f99a25 missing --user flag fixed (#2) 2026-10-04 01:30:50 +02:00
Sil3nt 7034b8f88b Merge pull request #4 from bigkpwns/android17-local-network-permission
Declare ACCESS_LOCAL_NETWORK for Android 17 (target SDK 37)
2026-10-04 01:18:34 +02:00
Caleb Garber 4b2d141c4c Declare ACCESS_LOCAL_NETWORK for Android 17 (target SDK 37)
Android 17 blocks local network access by default for apps targeting
SDK 37. The released APK targets 37 and declares only INTERNET, so on
Android 17 devices (e.g. Pixel 11 Pro XL) the app's WebSocket to the
Frame's agent on port 7380 is dropped silently before any packet
leaves the phone. tcpdump on the frame's wlan0 showed browser
connections arriving but zero packets from the app, confirming the
block happens on the client side.

Adding the permission declaration lets the user grant it under
Settings > Apps > FrameMate > Permissions. This is verified: after
granting it on a Pixel 11 Pro XL running Android 17, the app
connected immediately. On Android 16 (SDK 36) the permission is an
opt-in flag and has no effect, on older releases it is unknown and
ignored, so no behavioral change.

The recommended companion (a runtime requestPermission() prompt before
first connect, to avoid requiring the user to find the toggle
themselves) is tracked in the linked issue; it is not included here
because it could not be verified on an Android 17 device during
development.

Signed-off-by: Caleb Garber <8532661+bigkpwns@users.noreply.github.com>
2026-10-03 17:40:57 -05:00
Sil3nt 7a83161228 Update README to remove flatpak file post-installation
Remove the downloaded flatpak file after installation.
2026-10-03 22:52:21 +02:00
38 changed files with 186 additions and 183 deletions

No files matched your search

Generated
+1 -1
View File
@@ -327,7 +327,7 @@ dependencies = [
[[package]]
name = "framemate-agent"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -5,7 +5,7 @@ resolver = "3"
members = ["crates/agent"]
[workspace.package]
version = "0.1.2"
version = "0.1.3"
edition = "2024"
license = "GPL-3.0-or-later"
+5 -4
View File
@@ -60,7 +60,8 @@ and run:
```sh
curl -LO https://github.com/nailuj05/framemate/releases/latest/download/framemate-agent.flatpak
flatpak install --user -y framemate-agent.flatpak
flatpak run dev.framemate.Agent install-service
flatpak run --user dev.framemate.Agent install-service
rm framemate-agent.flatpak
```
- `flatpak install` pulls the Freedesktop runtime from Flathub if it isn't installed yet
@@ -68,14 +69,14 @@ flatpak run dev.framemate.Agent install-service
- `install-service` registers a user service, so the agent starts with every boot – in
Game Mode too – and restarts it right away. It then runs a self check and prints the
address and access token (e.g. `MCK55-EGGCG`) you'll enter in the app.
- `flatpak run dev.framemate.Agent check` repeats the self check (useful when the app can't
connect); `flatpak run dev.framemate.Agent token` prints just the token.
- `flatpak run --user dev.framemate.Agent check` repeats the self check (useful when the app can't
connect); `flatpak run --user dev.framemate.Agent token` prints just the token.
To **update**, download the new `framemate-agent.flatpak` and run the same three commands
again. To **remove** it:
```sh
flatpak run dev.framemate.Agent uninstall-service
flatpak run --user dev.framemate.Agent uninstall-service
flatpak uninstall --user dev.framemate.Agent
```
+4 -3
View File
@@ -9,7 +9,7 @@ The more information you can give me the better, this page will also describe ho
Before going any further, please ensure that you are on an up to date version of SteamOS.
Also make sure you followed the installation guide fully.
Run `flatpak run dev.framemate.Agent check` on the frame (ssh / console in desktop mode) for a detailed report of what might be the issue.
Run `flatpak run --user dev.framemate.Agent check` on the frame (ssh / console in desktop mode) for a detailed report of what might be the issue.
## Connection issues
@@ -18,15 +18,16 @@ Make sure of the following:
- Both the frame and the phone running the app need to be in the same (local) network.
- There is no firewall or router settings blocking communication between app and frame.
- Port 7380 needs to be open on the frame, this is the case by default, if you installed or configured a firewall you'll need to open that port.
- Check whether you can reach the (debug) web interface, you can access it by visiting http://frame.local:7380/health or http://<frame-ip>:7380/healthz in your browser.
- Check whether you can reach the (debug) web interface, you can access it by visiting http://frame.local:7380/healthz or http://<frame-ip>:7380/healthz in your browser.
- mDNS used for resolving frame.local might be unreliable in some cases, use the plain ip from the frame instead
- Android 17 (SDK 37) blocks local network access by default for apps that target it. FrameMate declares `ACCESS_LOCAL_NETWORK`, if it still won't connect grant the permission under Settings > Apps > FrameMate > Permissions > Local network (it is part of the Nearby devices group, you may have to open that submenu). If you deny it, the connection will fail silently with a timeout.
Note: Some guest or mesh wifi networks may isolate devices by default, make sure that isn't the issue before proceeding.
## Installation issues
If you encounter an error during installation please send me the logs and the commands you ran in a github issue.
Should the app say "Wrong token", get it with `flatpak run dev.framemate.Agent token`. Note that reinstalling keeps the token.
Should the app say "Wrong token", get it with `flatpak run --user dev.framemate.Agent token`. Note that reinstalling keeps the token.
## Other issues
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "framemate-app",
"version": "0.1.2",
"version": "0.1.3",
"description": "",
"type": "module",
"scripts": {
+1 -1
View File
@@ -823,7 +823,7 @@ dependencies = [
[[package]]
name = "framemate-app"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"tauri",
"tauri-build",
+1 -1
View File
@@ -4,7 +4,7 @@
[package]
name = "framemate-app"
description = "FrameMate companion app"
version = "0.1.2"
version = "0.1.3"
license = "GPL-3.0-or-later"
edition = "2024"
@@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Android 17 (target SDK 37) blocks local network access by default, the
app only talks to the Frame on the LAN, so this is required to connect.
On Android 16 (SDK 36) this permission is an opt-in flag and has no
effect, on older releases it is unknown and ignored. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<application
android:icon="@mipmap/ic_launcher"
@@ -2,8 +2,11 @@ package dev.framemate.app
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.activity.enableEdgeToEdge
@@ -11,12 +14,22 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
// Android 17 (target SDK 37) blocks LAN traffic, WebView included, until this runtime
// permission ("Nearby devices") is granted. Declared in AndroidManifest.xml.
private const val LOCAL_NETWORK = "android.permission.ACCESS_LOCAL_NETWORK"
private const val ANDROID_17 = 37
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
// The WebView's reconnect loop picks the connection up once it's granted.
if (!localNetworkAllowed()) requestPermissions(arrayOf(LOCAL_NETWORK), 1)
}
private fun localNetworkAllowed() =
Build.VERSION.SDK_INT < ANDROID_17 || checkSelfPermission(LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
webView.addJavascriptInterface(NativeBridge(), "FrameMateAndroid")
@@ -24,11 +37,7 @@ class MainActivity : TauriActivity() {
/** `window.FrameMateAndroid`: things the WebView can't do itself. */
inner class NativeBridge {
/**
* The app is portrait-only (see AndroidManifest). The headset view calls
* `FrameMateAndroid.setFullscreen(true)` to go landscape without system bars.
* WebViews can't do this themselves: the Screen Orientation API is browser-only.
*/
/** Landscape without system bars for the headset view; WebViews can't lock orientation. */
@JavascriptInterface
fun setFullscreen(enabled: Boolean) = runOnUiThread {
requestedOrientation =
@@ -43,7 +52,7 @@ class MainActivity : TauriActivity() {
}
}
/** Opens a web link in the system browser instead of navigating the app's WebView away. */
/** System browser instead of navigating the WebView away. */
@JavascriptInterface
fun openUrl(url: String) = runOnUiThread {
val uri = Uri.parse(url)
@@ -51,5 +60,14 @@ class MainActivity : TauriActivity() {
startActivity(Intent(Intent.ACTION_VIEW, uri))
}
}
@JavascriptInterface
fun localNetworkAllowed() = this@MainActivity.localNetworkAllowed()
/** After two denials Android stops prompting; only the app's settings page is left. */
@JavascriptInterface
fun openAppSettings() = runOnUiThread {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)))
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "FrameMate",
"version": "0.1.2",
"version": "0.1.3",
"identifier": "dev.framemate.app",
"build": {
"beforeDevCommand": "bun run dev",
+7 -2
View File
@@ -1,4 +1,3 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
interface PageState {
@@ -9,7 +8,13 @@ declare global {
interface Window {
/** Native bridge from MainActivity.kt (Android only). */
FrameMateAndroid?: { setFullscreen(enabled: boolean): void; openUrl(url: string): void };
FrameMateAndroid?: {
setFullscreen(enabled: boolean): void;
openUrl(url: string): void;
/** False on Android 17+ until "Nearby devices" (local network) is granted. */
localNetworkAllowed?(): boolean;
openAppSettings?(): void;
};
}
}
+10 -5
View File
@@ -1,5 +1,4 @@
// Connection to the framemate-agent on the Frame. Holds the latest state pushed over
// `/api/ws` and reconnects on its own. Settings live in localStorage.
// Connection to the agent: latest state from `/api/ws`, reconnects on its own.
import type { AgentState } from "./types";
@@ -36,13 +35,14 @@ class Agent {
return this.settings.host.trim() !== "" && this.settings.token.trim() !== "";
}
/** `host:port` of the agent. */
get authority() {
const host = this.settings.host.trim();
if (host.startsWith("[")) return /\]:\d+$/.test(host) ? host : `${host}:${DEFAULT_PORT}`;
// A bare IPv6 address needs brackets before a port can follow.
if ((host.match(/:/g) ?? []).length > 1) return `[${host}]:${DEFAULT_PORT}`;
return /:\d+$/.test(host) ? host : `${host}:${DEFAULT_PORT}`;
}
/** WebSocket URL for an agent endpoint, with the token attached. */
socketUrl(path: string) {
return `ws://${this.authority}${path}?token=${encodeURIComponent(this.settings.token.trim())}`;
}
@@ -87,7 +87,7 @@ class Agent {
};
}
/** Tells a wrong token apart from an unreachable agent (needs CORS on /api/state). */
/** Wrong token (401) vs. unreachable; needs CORS on /api/state. */
async #probe(): Promise<ConnectionStatus> {
try {
const url = `http://${this.authority}/api/state?token=${encodeURIComponent(this.settings.token.trim())}`;
@@ -108,3 +108,8 @@ class Agent {
}
export const agent = new Agent();
/** Android 17+ blocks the LAN until "Nearby devices" is granted; fails like a timeout. */
export function localNetworkBlocked() {
return window.FrameMateAndroid?.localNetworkAllowed?.() === false;
}
+1 -3
View File
@@ -1,12 +1,10 @@
// Web links must not open inside the app's WebView (it would navigate the app away).
/** Opens `url` in the system browser: via the Android bridge, else a new browser tab/window. */
export function openExternal(url: string) {
if (window.FrameMateAndroid?.openUrl) window.FrameMateAndroid.openUrl(url);
else window.open(url, "_blank", "noopener");
}
/** Click handler for `<a href="https://…">`: keeps the href for semantics, opens externally. */
/** onclick for `<a href>`: keeps the href, opens externally. */
export function external(event: MouseEvent) {
event.preventDefault();
openExternal((event.currentTarget as HTMLAnchorElement).href);
+1 -3
View File
@@ -1,6 +1,4 @@
// Shape of the agent's `/api/ws` / `/api/state` JSON (see crates/agent/src/hub.rs and
// shim.js). Steam topics are loosely typed on the agent side; every field may be
// missing when Steam isn't connected or an API changed, hence the many optionals.
// Agent JSON (hub.rs, shim.js). Steam fields can be missing at any time, hence the optionals.
export interface AgentState {
agent: { version: string; hostname: string; started_at_ms: number; updated_at_ms: number };
+2 -3
View File
@@ -7,7 +7,7 @@ const LATEST_URL = "https://api.github.com/repos/nailuj05/framemate/releases/lat
const STORAGE_KEY = "framemate.updates";
const TIMEOUT_MS = 10_000;
/** The app's own version; tauri.conf.json is also what the APK's versionName comes from. */
/** Same source as the APK's versionName. */
export const APP_VERSION: string = tauriConf.version;
export interface Release {
@@ -38,7 +38,6 @@ function loadAutoCheck(): boolean {
}
class Updates {
/** Check on app start (Settings toggle). */
autoCheck = $state(loadAutoCheck());
latest = $state<Release | null>(null);
checking = $state(false);
@@ -47,7 +46,7 @@ class Updates {
dismissed = $state(false);
appOutdated = $derived(!!this.latest && isNewer(this.latest.version, APP_VERSION));
/** Only known while connected (the agent reports its version in the state). */
/** Only known while connected. */
agentOutdated = $derived(
!!this.latest && !!agent.state && isNewer(this.latest.version, agent.state.agent.version),
);
+1 -1
View File
@@ -112,7 +112,7 @@
.status.unauthorized {
background: var(--red);
}
/* Flex, so the icon isn't placed on a text line (line-height would shift it off-center). */
/* Flex: on a text line, line-height pushes the icon off-center. */
.help {
display: flex;
}
+1 -4
View File
@@ -1,5 +1,2 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
// SPA (adapter-static): no server inside Tauri.
export const ssr = false;
+4 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { agent } from "$lib/agent.svelte";
import { agent, localNetworkBlocked } from "$lib/agent.svelte";
import Battery from "$lib/components/Battery.svelte";
import GameArt from "$lib/components/GameArt.svelte";
import Icon from "$lib/components/Icon.svelte";
@@ -33,7 +33,9 @@
? "Set up the connection in Settings."
: agent.status === "unauthorized"
? "The Frame rejected the token. Check it in Settings."
: "Connecting to your Frame…"}
: agent.status === "offline" && localNetworkBlocked()
? "Android blocks the local network. Allow Nearby devices for FrameMate (see Settings)."
: "Connecting to your Frame…"}
</p>
{:else}
<Section title="Steam Frame">
+1 -1
View File
@@ -15,7 +15,7 @@
<Section title="Token">
<p>
FrameMate uses a token for communicating with your Steam Frame. This token is shown on installation or by running
<code>flatpak run dev.framemate.Agent token</code> on the headset with framemate-agent installed.
<code>flatpak run --user dev.framemate.Agent token</code> on the headset with framemate-agent installed.
Write it down, you will need it to connect the app with the headset.
</p>
</Section>
+15 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { agent } from "$lib/agent.svelte";
import { agent, localNetworkBlocked } from "$lib/agent.svelte";
import Row from "$lib/components/Row.svelte";
import Section from "$lib/components/Section.svelte";
import { openExternal } from "$lib/external";
@@ -13,7 +13,9 @@
unconfigured: "Enter host and token",
connecting: "Connecting…",
connected: `Connected to ${agent.state?.agent.hostname ?? agent.authority}`,
offline: `Can't reach ${agent.authority}`,
offline: agent.settings.host.includes(".local")
? `Can't reach ${agent.authority}. Try the Frame's IP address instead.`
: `Can't reach ${agent.authority}`,
unauthorized: "Wrong token. Check it on the Frame (see below).",
}[agent.status],
);
@@ -35,8 +37,17 @@
<input bind:value={token} placeholder="XXXXX-XXXXX" autocapitalize="characters" autocorrect="off" spellcheck="false" />
</label>
<button class="button" type="submit">Save &amp; connect</button>
<p class="hint">Show the token on the Frame with <code>flatpak run dev.framemate.Agent token</code>.</p>
<p class="status {agent.status}">{statusText}</p>
<p class="hint">Show the token on the Frame with <code>flatpak run --user dev.framemate.Agent token</code>.</p>
{#if agent.status === "offline" && localNetworkBlocked()}
<p class="status offline">
Android blocks FrameMate from your local network. Allow <b>Nearby devices</b> in the app's permissions.
</p>
<button class="button secondary" type="button" onclick={() => window.FrameMateAndroid?.openAppSettings?.()}>
Open app settings
</button>
{:else}
<p class="status {agent.status}">{statusText}</p>
{/if}
</form>
</Section>
+3 -6
View File
@@ -1,8 +1,6 @@
<script lang="ts">
// Live headset view. Same protocol as the agent's /stream page: a JSON header with the
// codec string, an fMP4 init segment, then one moof+mdat per frame, fed into MSE.
// The agent only captures while a viewer is connected, so leaving this tab stops it.
// The app is portrait-only; fullscreen switches to landscape (natively on Android).
// Same protocol as the agent's /stream page (JSON {codec}, fMP4 init, moof+mdat → MSE).
// The agent only captures while someone watches, so leaving this page stops it.
import { onDestroy, onMount } from "svelte";
import { pushState } from "$app/navigation";
import { page } from "$app/state";
@@ -93,8 +91,7 @@
};
}
// Catch up by playing slightly faster; seeking on every frame restarts decoding at the
// last keyframe and drops the picture to a few fps.
// Catch up by playing faster; seeking restarts decoding at the last keyframe.
function keepLive(buffer: SourceBuffer) {
if (buffer.updating || !buffer.buffered.length) return;
const start = buffer.buffered.start(0);
+2 -6
View File
@@ -4,15 +4,12 @@ import { sveltekit } from "@sveltejs/kit/vite";
import process from "node:process";
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(() => ({
plugins: [sveltekit()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
// Don't hide Rust errors.
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
// Tauri expects this fixed port.
server: {
port: 1420,
strictPort: true,
@@ -25,7 +22,6 @@ export default defineConfig(() => ({
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
+7 -15
View File
@@ -1,11 +1,6 @@
//! Connects to Steam's CEF remote debugging endpoint (Steam runs with
//! `-cef-enable-debugging`, listening on 127.0.0.1:8080), attaches to the
//! `SharedJSContext` target and injects `shim.js`. The shim pushes
//! `{topic, data}` JSON through the `__framemateEmit` CDP binding.
//!
//! Steam UI reloads create a new execution context; the shim is re-injected on
//! every `Runtime.executionContextCreated`. Steam restarts drop the socket and
//! we reconnect with backoff.
//! Steam CEF DevTools client (127.0.0.1:8080): attaches to `SharedJSContext`, injects `shim.js`
//! on every `executionContextCreated` (UI reloads) and receives `{topic, data}` through the
//! `__framemateEmit` binding. Reconnects with backoff when Steam restarts.
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -24,8 +19,7 @@ const TARGET_TITLE: &str = "SharedJSContext";
const BINDING: &str = "__framemateEmit";
const SHIM: &str = include_str!("shim.js");
const MAX_BACKOFF: Duration = Duration::from_secs(30);
/// The shim polls every few seconds, so silence this long means Steam may be hung: send a
/// trivial evaluate, and give up on the session if that gets no answer either.
/// The shim polls every few seconds; after this much silence, ping once, then reconnect.
const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
pub async fn run(hub: Arc<Hub>, cdp_url: String) {
@@ -152,7 +146,6 @@ fn handle_emit(hub: &Hub, payload: &str, devices: &mut DeviceMemory) {
});
}
/// Returns the WebSocket debugger URL of the SharedJSContext target.
async fn find_target(authority: &str) -> anyhow::Result<String> {
let body = http_get(authority, "/json").await?;
let targets: Vec<Value> = serde_json::from_slice(&body).context("parsing /json")?;
@@ -169,9 +162,8 @@ async fn find_target(authority: &str) -> anyhow::Result<String> {
Ok(format!("ws://{authority}{path}"))
}
/// Minimal HTTP/1.1 GET; avoids pulling an HTTP client into the dependency tree.
/// CEF's DevTools server drops HTTP/1.0 requests without answering and ignores
/// `Connection: close`, so the body is read by Content-Length, not until EOF.
/// Minimal HTTP/1.1 GET (no HTTP client dependency). CEF ignores HTTP/1.0 requests and
/// `Connection: close`, so the body is read by Content-Length.
pub(crate) async fn http_get(authority: &str, path: &str) -> anyhow::Result<Vec<u8>> {
tokio::time::timeout(Duration::from_secs(5), async {
let mut stream = TcpStream::connect(authority)
@@ -194,7 +186,7 @@ pub(crate) async fn http_get(authority: &str, path: &str) -> anyhow::Result<Vec<
};
let head = String::from_utf8_lossy(&response[..header_end]).into_owned();
if !head.starts_with("HTTP/1.1 200") {
// Without the query: it may carry the API token (`check`), and errors get printed.
// Drop the query: it may carry the token.
let path = path.split('?').next().unwrap_or_default();
bail!("GET {path}: {}", head.lines().next().unwrap_or_default());
}
+3 -6
View File
@@ -1,7 +1,5 @@
//! `check`: asks the running agent whether everything works, and prints what the companion
//! app needs. `install-service` runs it right after (re)starting the service; run it by hand
//! when the app can't connect. Runs in the same sandbox as the service, so device and D-Bus
//! access are checked with the service's permissions.
//! `check`: self check of the running agent, then prints what the app needs. Also run by
//! `install-service`. Same sandbox as the service, so permissions are checked too.
use std::ffi::CString;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
@@ -17,7 +15,6 @@ const STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
/// Steam data follows shortly after the agent is up (the shim injects, then reports).
const STEAM_TIMEOUT: Duration = Duration::from_secs(10);
/// Returns an error if the agent can't be reached or rejects its own token.
pub async fn run() -> anyhow::Result<()> {
let config = Config::from_env()?;
let addr = match config.listen.ip() {
@@ -79,7 +76,7 @@ pub async fn run() -> anyhow::Result<()> {
warn(&format!("SteamOS performance settings: {error}"));
}
// only check access without wakeing SteamVRs v4l2cam
// Only check access: opening the source would wake SteamVR's v4l2cam.
let devices = [
(config.stream.source_device.as_path(), "headset view"),
(Path::new(&config.stream.encoder_device), "H.264 encoder"),
+10 -10
View File
@@ -1,6 +1,5 @@
//! Runtime configuration from environment variables. Paths follow XDG so the same
//! binary works on the host and inside a Flatpak sandbox (where XDG_CONFIG_HOME
//! points into ~/.var/app/<id>/config).
//! Configuration from environment variables. XDG paths, so it works on the host and inside
//! the Flatpak (where XDG_CONFIG_HOME points into ~/.var/app/<id>/config).
use std::io::Read;
use std::net::SocketAddr;
@@ -13,7 +12,6 @@ use crate::stream::StreamConfig;
pub struct Config {
pub listen: SocketAddr,
/// Base URL of Steam's CEF remote debugging HTTP endpoint.
pub cdp_url: String,
pub token: String,
pub power_supply_dir: PathBuf,
@@ -22,8 +20,7 @@ pub struct Config {
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
// IPv6 wildcard: the socket is made dual-stack in server.rs, so this covers
// IPv4 clients too. `frame.local` resolves to an AAAA record on many networks.
// Dual-stack (see server.rs), so IPv4 clients work too.
let listen = env_or("FRAMEMATE_LISTEN", "[::]:7380")
.parse()
.context("FRAMEMATE_LISTEN must be host:port")?;
@@ -43,7 +40,7 @@ impl Config {
power_supply_dir: env_or("FRAMEMATE_POWER_SUPPLY_DIR", "/sys/class/power_supply").into(),
stream: StreamConfig {
source_device: env_or("FRAMEMATE_STREAM_SOURCE", "/dev/video99").into(),
encoder_device: env_or("FRAMEMATE_STREAM_ENCODER", "/dev/video23"),
encoder_device: std::env::var("FRAMEMATE_STREAM_ENCODER").unwrap_or_else(|_| default_encoder().into()),
fps,
bitrate,
},
@@ -51,6 +48,11 @@ impl Config {
}
}
/// videoN numbers depend on driver probe order; the udev symlink is stable.
fn default_encoder() -> &'static str {
if std::path::Path::new("/dev/video-enc0").exists() { "/dev/video-enc0" } else { "/dev/video23" }
}
fn env_or(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_owned())
}
@@ -59,7 +61,6 @@ fn config_dir() -> anyhow::Result<PathBuf> {
xdg_dir("XDG_CONFIG_HOME", ".config")
}
/// Persistent runtime state (e.g. remembered VR devices).
pub fn state_dir() -> anyhow::Result<PathBuf> {
xdg_dir("XDG_STATE_HOME", ".local/state")
}
@@ -100,8 +101,7 @@ fn is_current_format(token: &str) -> bool {
token.len() == TOKEN_LEN && token.bytes().all(|b| TOKEN_ALPHABET.contains(&b))
}
/// The API token lives in `$XDG_CONFIG_HOME/framemate/token` and is created on first run.
/// Tokens in an older format (32 hex chars) are replaced by a short one.
/// `$XDG_CONFIG_HOME/framemate/token`, created on first run; other formats are replaced.
pub fn load_or_create_token() -> anyhow::Result<String> {
let path = config_dir()?.join("token");
if let Ok(token) = std::fs::read_to_string(&path) {
+3 -3
View File
@@ -15,7 +15,7 @@
<p><a id="stream">headset view</a></p>
<pre id="out">connecting…</pre>
<script>
// Plain-text readout of /api/ws. Intentionally no styling or framework.
// Plain-text readout of /api/ws, intentionally unstyled.
const token = new URLSearchParams(location.search).get("token") ?? "";
const out = document.getElementById("out");
document.getElementById("stream").href = `/stream?token=${encodeURIComponent(token)}`;
@@ -44,8 +44,8 @@ function render(s) {
L.push(`FrameMate agent ${s.agent.version} on ${s.agent.hostname} · updated ${time(s.agent.updated_at_ms)} · ${link}`);
L.push(`Steam: ${s.steam.connected ? "connected" : `not connected (${s.steam.error ?? "?"})`} · last event ${time(s.steam.last_event_ms)}`);
// One battery. The percentage is Steam's (matches the headset UI; it reads ~kernel/0.9).
// The kernel fuel gauge adds power/temperature detail and is the fallback without Steam.
// Steam's percentage (≈ kernel / 0.9, matches the headset UI); kernel adds W/°C and is the
// fallback without Steam.
L.push("", "== Battery");
const b = t.battery, k = s.power?.battery;
if (b) {
+6 -10
View File
@@ -1,8 +1,7 @@
//! Remembers VR devices across SteamVR / Frame restarts. SteamVR only lists a controller
//! once it has connected since SteamVR started, so after a reboot sleeping controllers
//! would vanish. Their last known state is kept in `$XDG_STATE_HOME/framemate/devices.json`
//! and merged into the `vr_devices` topic as `connected: false, remembered: true`.
//! Devices are never forgotten (controller pairs rarely change); delete the file to reset.
//! Remembers VR devices across restarts: SteamVR only lists a controller once it has connected
//! since SteamVR started, so sleeping controllers would vanish after a reboot. Missing ones are
//! merged into `vr_devices` as `connected: false, remembered: true`. Never forgotten; delete
//! `$XDG_STATE_HOME/framemate/devices.json` to reset.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
@@ -16,8 +15,7 @@ const LAST_SEEN_SAVE_INTERVAL_MS: u64 = 5 * 60 * 1000;
pub struct DeviceMemory {
file: Option<PathBuf>,
/// Keyed by serial. Devices whose serial couldn't be read are passed through but not
/// remembered: keying them by path would leave a phantom duplicate once the serial shows up.
/// Keyed by serial; devices without one aren't remembered (no phantom duplicates).
known: BTreeMap<String, Value>,
last_save_ms: u64,
}
@@ -33,7 +31,6 @@ impl DeviceMemory {
Self { file, known, last_save_ms: now_ms() }
}
/// Merges a live `vr_devices` list with remembered devices and persists changes.
pub fn merge(&mut self, live: Value) -> Value {
let Value::Array(live) = live else { return live };
let now = now_ms();
@@ -80,8 +77,7 @@ impl DeviceMemory {
fn save(&mut self, now: u64) {
let Some(file) = &self.file else { return };
// Write + rename, so a crash mid-write can't leave a truncated file behind
// (which would load as empty and lose every remembered device).
// Write + rename: a truncated file would load as empty.
let tmp = file.with_extension("json.tmp");
let result = std::fs::create_dir_all(file.parent().unwrap())
.and_then(|()| std::fs::write(&tmp, serde_json::to_vec_pretty(&self.known).unwrap()))
+7 -16
View File
@@ -1,14 +1,9 @@
//! Hardware H.264 encoding on the Snapdragon `iris` V4L2 stateful encoder
//! (`/dev/video23`), using raw multiplanar M2M ioctls. No GStreamer: its
//! `v4l2h264enc` fails caps negotiation with this driver and the Flatpak
//! runtime doesn't ship it.
//! H.264 on the `iris` V4L2 stateful encoder (`/dev/video23`) via raw multiplanar M2M ioctls
//! (GStreamer's `v4l2h264enc` can't negotiate with this driver). Input is RGBA; the encoder
//! does the YUV conversion.
//!
//! Input is RGBA ('AB24'); the encoder converts to YUV in hardware. Our only
//! CPU work is expanding the RGB3 source to RGBA.
//!
//! Driver quirks seen on kernel 6.18:
//! - STREAMOFF on a queue that was never started returns EBUSY (so we never do that).
//! - Resolution is aligned to 16 (1080 → 1088); the SPS crops it back.
//! Driver quirks: STREAMOFF on a never-started queue returns EBUSY; height is aligned to 16
//! (1080 → 1088) and the SPS crops it back.
use std::fs::{File, OpenOptions};
use std::os::fd::{AsRawFd, RawFd};
@@ -115,16 +110,14 @@ impl Encoder {
})
}
/// Makes the next encoded frame a keyframe (for a newly joined viewer).
pub fn force_keyframe(&self) {
if let Err(e) = v4l2::set_control(self.fd(), CID_FORCE_KEY_FRAME, 1) {
tracing::warn!("encoder: force keyframe: {e}");
}
}
/// Queues one frame; `sink` receives each encoded access unit (Annex B) and
/// whether it is a keyframe, as they complete. Timestamps must increase: the
/// encoder's rate control uses them (constant timestamps undershoot the bitrate ~5×).
/// `sink` gets each finished access unit (Annex B) and whether it's a keyframe.
/// Timestamps must increase: rate control uses them (constant ones undershoot ~5×).
pub fn encode(&mut self, frame: &RgbFrame, timestamp_us: u64, sink: &mut impl FnMut(&[u8], bool)) -> Result<()> {
let index = loop {
if let Some(index) = self.free_outputs.pop() {
@@ -173,7 +166,6 @@ impl Drop for Encoder {
}
/// RGB3 → RGBA into the encoder's input buffer; rows past the source are black.
/// About 1.5 ms per 1080p frame on the Frame.
fn rgb_to_rgba(frame: &RgbFrame, dst: &mut [u8], stride: usize, height: usize) {
for y in 0..height {
let row = &mut dst[y * stride..y * stride + frame.width * 4];
@@ -182,7 +174,6 @@ fn rgb_to_rgba(frame: &RgbFrame, dst: &mut [u8], stride: usize, height: usize) {
continue;
}
let src = &frame.data[y * frame.stride..y * frame.stride + frame.width * 3];
// Four pixels per step (12 → 16 bytes) so the compiler can keep it in registers.
let mut dst_chunks = row.chunks_exact_mut(16);
let mut src_chunks = src.chunks_exact(12);
for (d, s) in (&mut dst_chunks).zip(&mut src_chunks) {
+1 -3
View File
@@ -1,7 +1,5 @@
//! Minimal fragmented-MP4 writer for one H.264 video track, as consumed by browser
//! Media Source Extensions: one init segment (ftyp+moov), then one moof+mdat per frame.
//! Fragmented MP4 for MSE, one H.264 track: init segment (ftyp+moov), then moof+mdat per frame.
/// Timescale of the video track (90 kHz, the usual for video).
pub const TIMESCALE: u32 = 90_000;
/// Splits an Annex B access unit into NAL units (without start codes).
+1 -3
View File
@@ -30,9 +30,7 @@ pub struct AgentInfo {
pub updated_at_ms: u64,
}
/// Data from the Steam client, as reported by the injected shim (see `shim.js`).
/// Topics are kept as loose JSON on purpose: the shim already normalizes them and
/// the underlying Steam APIs are undocumented and change between client updates.
/// Reported by `shim.js`. Topics stay loose JSON: the Steam APIs behind them change often.
#[derive(Debug, Default, Serialize)]
pub struct SteamState {
pub connected: bool,
+1 -2
View File
@@ -1,5 +1,4 @@
//! Battery and charger data from the kernel's power_supply class. Works without
//! Steam running and is readable from inside a Flatpak sandbox.
//! Battery and charger data from the kernel's power_supply class (works without Steam).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
+11 -10
View File
@@ -55,7 +55,14 @@ pub async fn serve(hub: Arc<Hub>, stream: Arc<LiveStream>, config: &Config) -> a
stream,
});
let listener = listen(config.listen)?;
let listener = match listen(config.listen) {
// IPv6 can be disabled (ipv6.disable=1); keep serving IPv4 then.
Err(e) if config.listen.is_ipv6() && config.listen.ip().is_unspecified() => {
tracing::warn!("{e:#}; falling back to IPv4 only");
listen(SocketAddr::from(([0, 0, 0, 0], config.listen.port())))?
}
result => result?,
};
let token = crate::config::format_token(&config.token);
tracing::info!("listening on http://{}/?token={token} (token: {token})", config.listen);
axum::serve(listener, app)
@@ -64,13 +71,8 @@ pub async fn serve(hub: Arc<Hub>, stream: Arc<LiveStream>, config: &Config) -> a
Ok(())
}
/// Binds the listener, accepting IPv4 *and* IPv6 when given an IPv6 wildcard address.
///
/// `frame.local` resolves to an AAAA record on many networks (and Chrome prefers it), while
/// the app is usually handed an IPv4 address, so the agent has to answer on both families.
/// A dual-stack socket needs `IPV6_V6ONLY` cleared before `bind`, which
/// `TcpListener::bind` can't express; leaving it to the `net.ipv6.bindv6only` sysctl would
/// silently drop IPv4 on a host that has it set.
/// Dual-stack for an IPv6 wildcard (`frame.local` often resolves to IPv6). `IPV6_V6ONLY` has to
/// be cleared before `bind`, which `TcpListener::bind` can't do.
fn listen(addr: SocketAddr) -> anyhow::Result<tokio::net::TcpListener> {
let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))
.context("creating the listening socket")?;
@@ -107,8 +109,7 @@ async fn state(
headers: HeaderMap,
Query(query): Query<HashMap<String, String>>,
) -> Response {
// CORS: the app's WebView (another origin) reads the status to tell a wrong token from
// an unreachable agent. Harmless, the token is still required.
// CORS so the app can read the 401 (wrong token vs. unreachable).
let cors = [(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")];
if !authorized(&app, &headers, &query) {
return (StatusCode::UNAUTHORIZED, cors).into_response();
+26 -10
View File
@@ -1,10 +1,5 @@
//! `install-service` / `uninstall-service`: a systemd **user** unit that starts the agent
//! with the session. Flatpaks can't autostart in Game Mode (XDG autostart only runs in
//! Plasma), so the unit runs `flatpak run …` itself. Talks to the user systemd over
//! D-Bus, which works from inside the sandbox.
//!
//! Flatpak permissions: `--filesystem=xdg-config/systemd/user:create`,
//! `--talk-name=org.freedesktop.systemd1`.
//! `install-service` / `uninstall-service`: a systemd user unit that runs `flatpak run …`, since
//! Flatpaks can't autostart in Game Mode (XDG autostart only runs in Plasma).
use std::path::PathBuf;
@@ -17,7 +12,16 @@ pub async fn install() -> anyhow::Result<()> {
let exec = match std::env::var("FLATPAK_ID") {
// `flatpak run` moves the app into its own scope outside this unit's cgroup, so
// stopping the unit would only kill the launcher; --die-with-parent ties them together.
Ok(app_id) => format!("/usr/bin/flatpak run --die-with-parent --command=framemate-agent {app_id}"),
Ok(app_id) => {
let installation = std::fs::read_to_string("/.flatpak-info")
.ok()
.and_then(|info| installation_flag(&info));
if installation.is_none() {
eprintln!("warning: couldn't tell whether the agent is a --user or --system install");
}
let flag = installation.map(|f| format!("{f} ")).unwrap_or_default();
format!("/usr/bin/flatpak run {flag}--die-with-parent --command=framemate-agent {app_id}")
}
Err(_) => std::env::current_exe()?.display().to_string(),
};
let unit = format!(
@@ -45,8 +49,7 @@ pub async fn install() -> anyhow::Result<()> {
systemd.call("EnableUnitFiles", &(&[UNIT][..], false, true)).await?;
systemd.call("RestartUnit", &(UNIT, "replace")).await?;
println!("Installed and started {UNIT} ({}).", path.display());
// `flatpak run` moves the app into its own app-flatpak-*.scope, so `-u {UNIT}` shows
// nothing; match the process name instead.
// `-u {UNIT}` shows nothing: `flatpak run` moves the app into its own scope.
println!("Logs: journalctl --user -f _COMM=framemate-agent");
println!();
crate::check::run().await
@@ -68,6 +71,19 @@ pub async fn uninstall() -> anyhow::Result<()> {
Ok(())
}
/// `--user` or `--system`, from `app-path` in the sandbox's `/.flatpak-info`. Plain `flatpak run`
/// fails when no system installation exists (fresh Frames, Flatpak 1.15.8).
fn installation_flag(flatpak_info: &str) -> Option<&'static str> {
let path = flatpak_info.lines().find_map(|l| l.trim().strip_prefix("app-path="))?;
if path.starts_with("/var/lib/flatpak/") {
Some("--system")
} else if path.contains("/.local/share/flatpak/") {
Some("--user")
} else {
None // a custom installation (installations.d); plain `flatpak run` finds it
}
}
/// `~/.config/systemd/user/…` on the host. Deliberately not `$XDG_CONFIG_HOME`, which a
/// Flatpak remaps into its own data directory.
fn unit_path() -> anyhow::Result<PathBuf> {
+9 -16
View File
@@ -1,9 +1,6 @@
// Injected by framemate-agent into Steam's SharedJSContext (see cdp.rs).
// Subscribes to SteamClient events and polls UI stores, then reports one
// normalized snapshot per topic via the CDP binding `__framemateEmit`.
// Everything here is undocumented Steam internals: guard every access, and
// report failures as {topic: "error"} instead of throwing.
// Must be idempotent: it is re-evaluated on every reconnect.
// Injected into Steam's SharedJSContext (cdp.rs); reports one snapshot per topic via
// `__framemateEmit`. Undocumented Steam internals: guard every access, report failures as
// {topic: "error"}. Must be idempotent: it is re-evaluated on every reconnect.
(() => {
const W = window;
try { W.__framemate?.dispose?.(); } catch {}
@@ -46,7 +43,7 @@
try { return W.appStore.GetAppOverviewByAppID(Number(appid))?.display_name ?? null; } catch { return null; }
};
// --- battery (Steam's view; the agent also reads sysfs directly) ---
// --- battery ---
const battery = () => subscribe("battery", cb => SteamClient.System.RegisterForBatteryStateChanges(cb), s =>
emit("battery", {
has_battery: s.bHasBattery,
@@ -56,9 +53,7 @@
battery_state: s.eBatteryState,
}));
// --- downloads: this device only ---
// Steam groups downloads per client; with Remote Downloads the other PCs on the
// account show up too (remote_client_id != "0"). Only the Frame's own are reported.
// --- downloads: this device only (other PCs show up via Remote Downloads) ---
const isLocal = clientId => String(clientId) === "0";
const downloads = () => {
subscribe("downloads", cb => SteamClient.Downloads.RegisterForDownloadItems(cb), (_flag, clients) => {
@@ -77,8 +72,7 @@
target_buildid: i.target_buildid,
})));
});
// Fires about once per second while something downloads. It describes a single
// client's transfer, which may be a remote one; report those as idle.
// ~1 Hz while downloading; may describe a remote client's transfer, reported as idle.
subscribe("download_overview", cb => SteamClient.Downloads.RegisterForDownloadOverview(cb), o => {
if (!isLocal(o.remote_client_id)) {
emit("download_overview", { appid: null });
@@ -114,9 +108,8 @@
});
};
// --- VR: headset + controllers via OpenVR device properties ---
// Property ids from openvr.h: 1001 ModelNumber, 1002 SerialNumber,
// 1011 DeviceIsCharging, 1012 DeviceBatteryPercentage, 1029 DeviceClass.
// --- VR devices. openvr.h property ids: 1001 ModelNumber, 1002 SerialNumber,
// 1011 DeviceIsCharging, 1012 DeviceBatteryPercentage, 1029 DeviceClass ---
let vrPaths = [];
const vrDevice = async path => {
const P = SteamClient.OpenVR.DeviceProperties;
@@ -153,7 +146,7 @@
});
};
// --- network, perf, system, user (polled store reads) ---
// --- network, perf, system, user (polled) ---
const stores = () => {
poll("network", 10000, () => {
const n = W.SystemNetworkStore, d = W.SystemPerfStore?.msgDiagnosticInfo;
+1 -2
View File
@@ -1,5 +1,4 @@
//! Read-only view of steamos-manager's session-bus properties
//! (performance profile, CPU/GPU tuning). Flatpak: --talk-name=com.steampowered.SteamOSManager1
//! Read-only view of steamos-manager's session-bus properties (performance profile, CPU/GPU).
use std::collections::BTreeMap;
use std::sync::Arc;
+4 -6
View File
@@ -15,9 +15,8 @@
<video autoplay muted playsinline></video>
<div id="status">connecting…</div>
<script>
// Headset view over /api/stream/ws: a JSON header with the codec string, then an fMP4
// init segment, then one moof+mdat per frame, fed into Media Source Extensions.
// (WebCodecs would be simpler but needs a secure context, which http://frame.local isn't.)
// /api/stream/ws: JSON {codec}, fMP4 init segment, then moof+mdat per frame → MSE.
// (WebCodecs needs a secure context, which http://frame.local isn't.)
const token = new URLSearchParams(location.search).get("token") ?? "";
const video = document.querySelector("video");
const status = document.getElementById("status");
@@ -62,9 +61,8 @@ function connect() {
};
}
// MSE buffers by default, which adds latency over time. Catch up by playing slightly
// faster; only seek when far behind, because every seek makes the decoder restart
// from the previous keyframe (seeking on every frame showed ~3 fps).
// Catch up by playing faster; seeking restarts decoding at the last keyframe (~3 fps if
// done every frame), so only seek when far behind.
function keepLive(buffer) {
if (buffer.updating || !buffer.buffered.length) return;
const start = buffer.buffered.start(0);
+3 -11
View File
@@ -1,13 +1,8 @@
//! Live H.264 stream of the headset view, shared by all viewers.
//!
//! SteamVR's `steamvr-v4l2cam` renders `IVRHeadsetView` into the v4l2loopback device
//! `/dev/video99` (1920×1080 RGB3, ~90 fps while the headset is active), but only
//! while someone has the device open. The first viewer starts one capture+encode
//! thread; it stops when the last viewer leaves, which also lets v4l2cam go idle.
//!
//! Frames are captured via mmap (no read() copy), paced down to the configured fps,
//! encoded in hardware (see encoder.rs) and broadcast as Annex B access units.
//! Flatpak: needs `--device=all` for /dev/video*.
//! SteamVR's v4l2cam only renders into `/dev/video99` (1920×1080 RGB3, ~90 fps) while the
//! device is open, so capture runs only while someone watches: the first viewer starts the
//! thread, it stops when the last one leaves.
use std::fs::{File, OpenOptions};
use std::os::fd::AsRawFd;
@@ -37,7 +32,6 @@ pub struct StreamConfig {
pub bitrate: u32,
}
/// One encoded frame.
pub struct Packet {
/// Annex B access unit; keyframes carry SPS/PPS.
pub data: Vec<u8>,
@@ -86,7 +80,6 @@ impl LiveStream {
running.tx.subscribe()
}
/// Asks for a keyframe, e.g. after a viewer fell behind.
pub fn request_keyframe(&self) {
if let Some(running) = &*self.running.lock().unwrap() {
running.want_keyframe.store(true, Ordering::Relaxed);
@@ -221,7 +214,6 @@ impl Drop for StopGuard<'_> {
}
}
/// mmap streaming capture from the v4l2loopback device.
struct LoopbackCapture {
buffers: Vec<Mapping>,
file: File,
+1 -1
View File
@@ -58,4 +58,4 @@ ssh() { /usr/bin/ssh "${SSH_OPTS[@]}" "$HOST" "$@"; }
ssh "systemctl --user stop framemate-agent-dev 2>/dev/null; \
flatpak install --user --reinstall --noninteractive -y /tmp/framemate-agent.flatpak && \
rm /tmp/framemate-agent.flatpak && \
flatpak run $APP_ID install-service" # also runs `check`, which prints the token
flatpak run --user $APP_ID install-service" # also runs `check`, which prints the token