mirror of
https://github.com/nailuj05/framemate.git
synced 2026-10-14 11:01:37 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675a50fdb1 | ||
|
|
a0fd89809b | ||
|
|
607da0f88d | ||
|
|
9c04f99a25 | ||
|
|
7034b8f88b | ||
|
|
4b2d141c4c | ||
|
|
7a83161228 |
No files matched your search
Generated
+1
-1
@@ -327,7 +327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framemate-agent"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "framemate-app",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -823,7 +823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framemate-app"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Vendored
+7
-2
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,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 };
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user