mirror of
https://github.com/nailuj05/framemate.git
synced 2026-10-07 10:00:32 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816aff8902 | ||
|
|
cd029b0050 | ||
|
|
724acd4ac6 | ||
|
|
69ac066bad | ||
|
|
58836dfadc | ||
|
|
fb78f43862 | ||
|
|
8e02b62ca9 | ||
|
|
1e01b830b4 | ||
|
|
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.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ resolver = "3"
|
||||
members = ["crates/agent"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.2"
|
||||
version = "0.1.5"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
|
||||
@@ -54,13 +54,14 @@ You need a Steam Frame and an Android phone on the same network.
|
||||
|
||||
### 1. Agent on the Steam Frame
|
||||
|
||||
The agent ships as a Flatpak. Open a terminal on the Frame (Desktop Mode → Konsole, or via SSH)
|
||||
and run:
|
||||
The agent ships as a Flatpak. Open a terminal on the Frame via SSH (recommended), or Desktop
|
||||
Mode → Konsole ([see here](TROUBLESHOOTING.md#installing-from-desktop-mode)), 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,18 @@ 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.
|
||||
In Desktop Mode it can't start the agent right away (the nested desktop has no access to the
|
||||
user's systemd); the agent then starts with the next restart, and the command prints how to
|
||||
start it immediately.
|
||||
- `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, and
|
||||
`flatpak run --user dev.framemate.Agent rotate-token` replaces it with a new one.
|
||||
|
||||
To **update**, download the new `framemate-agent.flatpak` and run the same three commands
|
||||
To **update**, download the new `framemate-agent.flatpak` and run the same 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
|
||||
```
|
||||
|
||||
@@ -102,7 +107,8 @@ page in any browser, and `http://frame.local:7380/stream?token=<your token>` mir
|
||||
- **Local network only.** The agent listens on port 7380 and talks plain HTTP/WebSocket, protected
|
||||
by the token. Don't expose that port to the internet. The connection isn't encrypted, so others
|
||||
on the same network could read the token and what's sent, including Mirroring. Use FrameMate on
|
||||
networks you trust, like your home Wi-Fi, not on public or shared ones.
|
||||
networks you trust, like your home Wi-Fi, not on public or shared ones. The agent also rejects
|
||||
connections from public addresses outside your network (relevant for IPv6, where the Frame is globally addressable)
|
||||
- **Developer Mode.** FrameMate doesn't depend on it. Note that while it is on, SteamOS's devkit
|
||||
service exposes Steam's debugging interface to your whole network (port 8081); FrameMate never
|
||||
uses that port.
|
||||
@@ -173,6 +179,8 @@ FrameMate name, logo and application identifiers (forks must use their own).
|
||||
- [ ] PWA for the mobile client
|
||||
- [ ] Feed on the App notifying you of newly frame verified games (in your library)
|
||||
- [ ] Control Downloads (pause, resume, reorder)
|
||||
- [ ] View and Transfer Screenshots
|
||||
- [ ] Mobile Notifications in VR
|
||||
- [ ] Turn off controller, headset, etc.
|
||||
|
||||
## AI usage
|
||||
@@ -184,6 +192,7 @@ AI was also used for exploring how the Video Stream for Mirroring could be acces
|
||||
|
||||
- Icons: [Material Symbols](https://github.com/google/material-design-icons) (Apache-2.0); see
|
||||
[`app/src/lib/icons/LICENSES.md`](app/src/lib/icons/LICENSES.md).
|
||||
- Steam Frame Controller Icons: [Kenney Input Prompts](https://kenney.nl/assets/input-prompts) (CC0)
|
||||
- Game artwork is loaded from Steam's public CDN.
|
||||
- FrameMate Icon made by me
|
||||
|
||||
|
||||
+29
-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,41 @@ 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.
|
||||
|
||||
### "FrameMate only accepts connections from the local network"
|
||||
|
||||
The agent rejects devices it doesn't consider part of your local network. That can hit unusual
|
||||
setups: a phone on a VPN, a guest or mesh Wi-Fi with its own subnet, or a router handing out
|
||||
IPv6 addresses from several prefixes. The agent's log names the rejected address
|
||||
(`journalctl --user -n 50 _COMM=framemate-agent`). To turn the check off, add
|
||||
`Environment=FRAMEMATE_ALLOW_REMOTE=1` under `[Service]` in
|
||||
`~/.config/systemd/user/framemate-agent.service`, then run
|
||||
`systemctl --user daemon-reload && systemctl --user restart framemate-agent`. Please also open
|
||||
an issue with your setup, so the check can be improved.
|
||||
|
||||
## 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.
|
||||
You may generate a new token with `flatpak run --user dev.framemate.Agent rotate-token` and enter the it in the app.
|
||||
|
||||
### Installing from Desktop Mode
|
||||
|
||||
If `install-service` says "systemd isn't reachable from this terminal": Desktop Mode on the Frame is a nested desktop without access to your user's systemd, so
|
||||
`install-service` can't start the agent from there. The agent is still installed and starts with
|
||||
the next restart of the Frame. To start it right away, run the command it prints:
|
||||
|
||||
```sh
|
||||
env XDG_RUNTIME_DIR=/run/user/$(id -u) DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus \
|
||||
flatpak run --user dev.framemate.Agent install-service
|
||||
```
|
||||
|
||||
Installing over SSH doesn't have this problem.
|
||||
|
||||
## Other issues
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "framemate-app",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.5",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -823,7 +823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framemate-app"
|
||||
version = "0.1.2"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[package]
|
||||
name = "framemate-app"
|
||||
description = "FrameMate companion app"
|
||||
version = "0.1.2"
|
||||
version = "0.1.5"
|
||||
license = "GPL-3.0-or-later"
|
||||
edition = "2024"
|
||||
|
||||
|
||||
@@ -66,8 +66,8 @@ android {
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
@@ -76,7 +76,7 @@ android {
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_1_8
|
||||
jvmTarget = JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.5",
|
||||
"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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-18
@@ -1,11 +1,14 @@
|
||||
// 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";
|
||||
|
||||
const STORAGE_KEY = "framemate.connection";
|
||||
const DEFAULT_PORT = 7380;
|
||||
const RETRY_MS = 3000;
|
||||
/** A connect that hasn't opened by then is given up (a sleeping Frame never answers the SYN). */
|
||||
const CONNECT_TIMEOUT_MS = 8000;
|
||||
/** The agent pushes at least every ~10 s (power poll); silence beyond this means a dead socket. */
|
||||
const SILENCE_MS = 25000;
|
||||
|
||||
export interface ConnectionSettings {
|
||||
/** Hostname or IP, optionally with `:port`. */
|
||||
@@ -29,20 +32,30 @@ class Agent {
|
||||
state = $state<AgentState | null>(null);
|
||||
status = $state<ConnectionStatus>("unconfigured");
|
||||
|
||||
/** When the last message arrived; tells live data from a snapshot left over from before. */
|
||||
receivedAt = $state(0);
|
||||
|
||||
#socket: WebSocket | null = null;
|
||||
#retry: ReturnType<typeof setTimeout> | undefined;
|
||||
#watchdog: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
/** True while `state` is being kept current by an open connection. */
|
||||
get live() {
|
||||
return this.status === "connected";
|
||||
}
|
||||
|
||||
get configured() {
|
||||
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())}`;
|
||||
}
|
||||
@@ -67,27 +80,58 @@ class Agent {
|
||||
if (this.status !== "offline" && this.status !== "unauthorized") this.status = "connecting";
|
||||
const socket = new WebSocket(this.socketUrl("/api/ws"));
|
||||
this.#socket = socket;
|
||||
let opened = false;
|
||||
const lost = () => this.#lost(socket, opened);
|
||||
socket.onmessage = event => {
|
||||
if (this.#socket !== socket) return;
|
||||
this.state = JSON.parse(event.data);
|
||||
this.status = "connected";
|
||||
this.receivedAt = Date.now();
|
||||
this.#arm(socket, SILENCE_MS, lost);
|
||||
};
|
||||
let opened = false;
|
||||
socket.onopen = () => (opened = true);
|
||||
socket.onclose = async () => {
|
||||
if (this.#socket !== socket) return; // replaced by a newer connection
|
||||
this.#socket = null;
|
||||
// A rejected upgrade looks like any other failure to the WebSocket API; ask over HTTP.
|
||||
const status = opened ? "offline" : await this.#probe();
|
||||
if (this.#socket || this.#retry !== undefined) return; // reconnected meanwhile
|
||||
this.status = status;
|
||||
this.#retry = setTimeout(() => {
|
||||
this.#retry = undefined;
|
||||
this.connect();
|
||||
}, RETRY_MS);
|
||||
};
|
||||
socket.onclose = lost;
|
||||
this.#arm(socket, CONNECT_TIMEOUT_MS, lost);
|
||||
}
|
||||
|
||||
/** Tells a wrong token apart from an unreachable agent (needs CORS on /api/state). */
|
||||
/**
|
||||
* Fresh snapshot after the app was in the background: Android freezes the WebView, so the old
|
||||
* socket may be dead without ever having fired `onclose` and would keep showing old data.
|
||||
* The current state stays visible (marked stale via `live`) until the new one arrives.
|
||||
*/
|
||||
refresh() {
|
||||
if (!this.configured) return;
|
||||
if (this.status === "connected") this.status = "connecting";
|
||||
this.connect();
|
||||
}
|
||||
|
||||
/** Give up on `socket` if nothing arrives within `ms`. */
|
||||
#arm(socket: WebSocket, ms: number, lost: () => void) {
|
||||
clearTimeout(this.#watchdog);
|
||||
this.#watchdog = setTimeout(() => {
|
||||
if (this.#socket !== socket) return;
|
||||
// On a dead TCP connection onclose can take minutes; don't wait for it.
|
||||
socket.onclose = null;
|
||||
socket.close();
|
||||
lost();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
async #lost(socket: WebSocket, opened: boolean) {
|
||||
if (this.#socket !== socket) return; // replaced by a newer connection
|
||||
this.#socket = null;
|
||||
clearTimeout(this.#watchdog);
|
||||
// A rejected upgrade looks like any other failure to the WebSocket API; ask over HTTP.
|
||||
const status = opened ? "offline" : await this.#probe();
|
||||
if (this.#socket || this.#retry !== undefined) return; // reconnected meanwhile
|
||||
this.status = status;
|
||||
this.#retry = setTimeout(() => {
|
||||
this.#retry = undefined;
|
||||
this.connect();
|
||||
}, RETRY_MS);
|
||||
}
|
||||
|
||||
/** 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())}`;
|
||||
@@ -99,6 +143,7 @@ class Agent {
|
||||
}
|
||||
|
||||
#close() {
|
||||
clearTimeout(this.#watchdog);
|
||||
clearTimeout(this.#retry);
|
||||
this.#retry = undefined;
|
||||
const socket = this.#socket;
|
||||
@@ -108,3 +153,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);
|
||||
|
||||
@@ -14,7 +14,6 @@ glyph), width/height removed, `fill="currentColor"` added.
|
||||
| back.svg | `arrow_back` | 700 | https://cdn.jsdelivr.net/npm/@material-symbols/svg-700@0.47.6/sharp/arrow_back.svg |
|
||||
| bell-badge.svg | `notifications_unread` (fill) | 400 | https://cdn.jsdelivr.net/npm/@material-symbols/svg-400@0.47.6/sharp/notifications_unread-fill.svg |
|
||||
| cast.svg | `cast` | 700 | https://cdn.jsdelivr.net/npm/@material-symbols/svg-700@0.47.6/sharp/cast.svg |
|
||||
| controller.svg | `stadia_controller` (fill) | 400 | https://cdn.jsdelivr.net/npm/@material-symbols/svg-400@0.47.6/sharp/stadia_controller-fill.svg |
|
||||
| download.svg | `download` | 700 | https://cdn.jsdelivr.net/npm/@material-symbols/svg-700@0.47.6/sharp/download.svg |
|
||||
| friends.svg | `group` (fill) | 400 | https://cdn.jsdelivr.net/npm/@material-symbols/svg-400@0.47.6/sharp/group-fill.svg |
|
||||
| headset.svg | `head_mounted_device` (fill) | 400 | https://cdn.jsdelivr.net/npm/@material-symbols/svg-400@0.47.6/sharp/head_mounted_device-fill.svg |
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg viewBox="8 8 48 48" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" x1="-819.2" x2="819.2" spreadMethod="pad" gradientTransform="matrix(-0.00244140625 -0.0042266845703125 0.005279541015625 -0.0030517578125 36 36)" id="controller-left-shade">
|
||||
<stop offset="0" stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#DAE2E9"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g>
|
||||
<path stroke="none" fill="#FFFFFF" d="M35.35 41.05 Q35.7 48.15 34.7 51.85 33.55 56.1 30.5 56 26.55 55.9 24.45 50.75 22.45 45.75 22 35.75 21.75 30.65 19.9 27.6 18 24.7 18 21 18 15.65 22.1 11.8 26.2 8 32 8 37.8 8 41.9 11.8 46 15.65 46 21 46 26.4 41.9 30.2 L38.45 32.65 37.75 32.95 37.7 32.95 Q34.95 34.15 35.2 38.75 L35.35 41.05 M33 27.5 Q33 26.45 32.25 25.7 31.5 25 30.5 25 29.45 25 28.7 25.7 28 26.45 28 27.5 28 28.55 28.7 29.25 29.45 30 30.5 30 31.5 30 32.25 29.25 33 28.55 33 27.5 M40 21.5 Q40 20.05 38.95 19 37.9 18 36.5 18 35.05 18 34 19 33 20.05 33 21.5 33 22.95 34 23.95 35.05 25 36.5 25 37.9 25 38.95 23.95 40 22.95 40 21.5 M25.6 14 Q24.95 14 24.5 14.55 24 15.1 24 15.85 L24 17 22.85 17 Q22.15 17 21.55 17.5 21 17.95 21 18.6 L21 19.4 Q21 20.05 21.55 20.5 22.15 21 22.85 21 L24 21 24 22.15 Q24 22.85 24.5 23.45 24.95 24 25.6 24 L26.4 24 Q27.05 24 27.5 23.45 28 22.85 28 22.15 L28 21 29.15 21 Q29.9 21 30.45 20.5 31 20.05 31 19.4 L31 18.6 Q31 17.95 30.45 17.5 29.9 17 29.15 17 L28 17 28 15.85 Q28 15.1 27.5 14.55 27.05 14 26.4 14 L25.6 14"/>
|
||||
<path stroke="none" fill="url(#controller-left-shade)" d="M35.35 41.05 L35.2 38.75 Q34.95 34.15 37.7 32.95 L37.75 32.95 38.45 32.65 39.85 37.75 Q40.45 39.8 38.3 40.35 L36.2 40.9 35.35 41.05"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg viewBox="8 8 48 48" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" x1="-819.2" x2="819.2" spreadMethod="pad" gradientTransform="matrix(0.00244140625 -0.0042266845703125 -0.005279541015625 -0.0030517578125 28 36)" id="controller-right-shade">
|
||||
<stop offset="0" stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#DAE2E9"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g>
|
||||
<path stroke="none" fill="#FFFFFF" d="M25.55 32.65 Q23.7 31.7 22.1 30.2 18 26.4 18 21 18 15.65 22.1 11.8 26.2 8 32 8 37.8 8 41.9 11.8 46 15.65 46 21 46 24.7 44.1 27.6 42.25 30.65 42 35.75 41.55 45.75 39.55 50.75 37.45 55.9 33.5 56 30.45 56.1 29.3 51.85 28.3 48.15 28.65 41.05 L28.8 38.75 Q29.05 34.15 26.3 32.95 L26.25 32.95 25.55 32.65 M39 20.5 Q39 21.55 39.75 22.25 40.5 23 41.5 23 42.55 23 43.3 22.25 44 21.55 44 20.5 44 19.45 43.3 18.7 42.55 18 41.5 18 40.5 18 39.75 18.7 39 19.45 39 20.5 M23 25.5 Q23 26.95 24.05 27.95 25.1 29 26.5 29 27.95 29 29 27.95 30 26.95 30 25.5 30 24.05 29 23 27.95 22 26.5 22 25.1 22 24.05 23 23 24.05 23 25.5 M35 16.5 Q35 17.55 35.75 18.25 36.5 19 37.5 19 38.55 19 39.3 18.25 40 17.55 40 16.5 40 15.45 39.3 14.7 38.55 14 37.5 14 36.5 14 35.75 14.7 35 15.45 35 16.5 M31 20.5 Q31 21.55 31.75 22.25 32.5 23 33.5 23 34.55 23 35.3 22.25 36 21.55 36 20.5 36 19.45 35.3 18.7 34.55 18 33.5 18 32.5 18 31.75 18.7 31 19.45 31 20.5 M35 24.5 Q35 25.55 35.75 26.25 36.5 27 37.5 27 38.55 27 39.3 26.25 40 25.55 40 24.5 40 23.45 39.3 22.7 38.55 22 37.5 22 36.5 22 35.75 22.7 35 23.45 35 24.5"/>
|
||||
<path stroke="none" fill="url(#controller-right-shade)" d="M28.65 41.05 L27.8 40.9 25.7 40.35 Q23.55 39.8 24.15 37.75 L25.55 32.65 26.25 32.95 26.3 32.95 Q29.05 34.15 28.8 38.75 L28.65 41.05"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="36 -924 888 888" fill="currentColor"><path d="M189-160q-60 0-102.5-43T42-307q0-9 1-18t3-18l84-336q14-54 57-87.5t98-33.5h390q55 0 98 33.5t57 87.5l84 336q2 9 3.5 18.5T919-306q0 61-43.5 103.5T771-160q-42 0-78-22t-54-60l-28-58q-5-10-15-15t-21-5H385q-11 0-21 5t-15 15l-28 58q-18 38-54 60t-78 22Zm372.5-378.68q8.5-8.67 8.5-21.5 0-12.82-8.68-21.32-8.67-8.5-21.5-8.5-12.82 0-21.32 8.68-8.5 8.67-8.5 21.5 0 12.82 8.68 21.32 8.67 8.5 21.5 8.5 12.82 0 21.32-8.68Zm80-80q8.5-8.67 8.5-21.5 0-12.82-8.68-21.32-8.67-8.5-21.5-8.5-12.82 0-21.32 8.68-8.5 8.67-8.5 21.5 0 12.82 8.68 21.32 8.67 8.5 21.5 8.5 12.82 0 21.32-8.68Zm0 160q8.5-8.67 8.5-21.5 0-12.82-8.68-21.32-8.67-8.5-21.5-8.5-12.82 0-21.32 8.68-8.5 8.67-8.5 21.5 0 12.82 8.68 21.32 8.67 8.5 21.5 8.5 12.82 0 21.32-8.68Zm80-80q8.5-8.67 8.5-21.5 0-12.82-8.68-21.32-8.67-8.5-21.5-8.5-12.82 0-21.32 8.68-8.5 8.67-8.5 21.5 0 12.82 8.68 21.32 8.67 8.5 21.5 8.5 12.82 0 21.32-8.68ZM358-472.08q7-7.09 7-17.92v-45h45q10.83 0 17.92-7.12 7.08-7.11 7.08-18 0-10.88-7.08-17.88-7.09-7-17.92-7h-45v-45q0-10.83-7.12-17.92-7.11-7.08-18-7.08-10.88 0-17.88 7.08-7 7.09-7 17.92v45h-45q-10.83 0-17.92 7.12-7.08 7.11-7.08 18 0 10.88 7.08 17.88 7.09 7 17.92 7h45v45q0 10.83 7.12 17.92 7.11 7.08 18 7.08 10.88 0 17.88-7.08Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -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),
|
||||
);
|
||||
|
||||
@@ -20,17 +20,27 @@
|
||||
];
|
||||
|
||||
const battery = $derived(agent.state?.steam.topics.battery);
|
||||
// Old data stays visible while reconnecting, dimmed and with its age in the top bar.
|
||||
const stale = $derived(!!agent.state && !agent.live);
|
||||
const lastUpdate = $derived(
|
||||
new Date(agent.receivedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
agent.connect();
|
||||
if (updates.autoCheck) updates.check();
|
||||
if (!agent.configured) goto("/settings");
|
||||
// Mobile WebViews drop sockets in the background; reconnect when we come back.
|
||||
// Coming back from the background: the socket may be dead without knowing it, so always
|
||||
// fetch a fresh snapshot instead of trusting the old one.
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible" && agent.status !== "connected") agent.connect();
|
||||
if (document.visibilityState === "visible") agent.refresh();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
window.addEventListener("online", onVisible);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
window.removeEventListener("online", onVisible);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -40,18 +50,21 @@
|
||||
<a href="/" class="title">FrameMate</a>
|
||||
<span class="spacer"></span>
|
||||
{#if battery}
|
||||
<span class="battery">
|
||||
<span class="battery" class:stale>
|
||||
{Math.round(battery.level * 100)}%
|
||||
<Battery level={battery.level} charging={battery.ac_state === 2} size={18} />
|
||||
</span>
|
||||
{/if}
|
||||
{#if stale}
|
||||
<span class="stale-note">{agent.status === "connecting" ? "Updating…" : lastUpdate}</span>
|
||||
{/if}
|
||||
<span class="status {agent.status}" title={agent.status}></span>
|
||||
<a href="/help" class="help" class:active={true} aria-label="Help" >
|
||||
<Icon name="help" size={20} />
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<main class:stale>
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
@@ -112,7 +125,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;
|
||||
}
|
||||
@@ -121,6 +134,22 @@
|
||||
overflow-y: auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
/* Delayed, so the usual quick refresh on resume doesn't flash. */
|
||||
main.stale,
|
||||
.battery.stale {
|
||||
opacity: 0.45;
|
||||
transition: opacity 0.2s 0.8s;
|
||||
}
|
||||
.stale-note {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
animation: appear 0.2s 0.8s both;
|
||||
}
|
||||
@keyframes appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: var(--topbar);
|
||||
|
||||
@@ -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">
|
||||
@@ -70,7 +72,7 @@
|
||||
<div class="controllers">
|
||||
{#each controllers as c (c.path)}
|
||||
<div class="controller">
|
||||
<Icon name="controller" size={28} />
|
||||
<Icon name={c.model?.endsWith("_Right") ? "controller-right" : "controller-left"} size={28} />
|
||||
<div>
|
||||
<div>{controllerName(c.model)}</div>
|
||||
<div class="muted small">
|
||||
|
||||
@@ -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);
|
||||
@@ -106,6 +103,20 @@
|
||||
if (video.currentTime - start > 10) buffer.remove(start, video.currentTime - 5);
|
||||
}
|
||||
|
||||
// In the background the socket dies or, worse, keeps the Frame capturing for nobody:
|
||||
// stop it, and start fresh (new keyframe, no stale buffer) when the app comes back.
|
||||
function onVisibilityChange() {
|
||||
if (closed) return;
|
||||
clearTimeout(retry);
|
||||
if (socket) socket.onclose = null;
|
||||
socket?.close();
|
||||
socket = null;
|
||||
if (document.visibilityState === "visible") {
|
||||
status = "Connecting…";
|
||||
connect();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(connect);
|
||||
onDestroy(() => {
|
||||
if (fullscreen) applyFullscreen(false);
|
||||
@@ -116,7 +127,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document onfullscreenchange={onFullscreenChange} />
|
||||
<svelte:document onfullscreenchange={onFullscreenChange} onvisibilitychange={onVisibilityChange} />
|
||||
|
||||
<div class="player" class:fullscreen>
|
||||
<video bind:this={video} autoplay muted playsinline></video>
|
||||
|
||||
+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/**"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Only clients from the local network may connect. Mostly matters for IPv6, where the Frame has
|
||||
//! a globally routable address and only the router's firewall stands between it and the internet.
|
||||
//! Allowed: loopback, private/link-local/ULA ranges, CGNAT (Tailscale) and any address in the same
|
||||
//! subnet as one of the Frame's interfaces (LAN devices with global IPv6 addresses).
|
||||
//! `FRAMEMATE_ALLOW_REMOTE=1` turns the check off. The token stays the actual protection.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use axum::extract::{ConnectInfo, Request, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
/// At most one log line per this many seconds, so a scanner can't flood the journal.
|
||||
const LOG_INTERVAL_S: u64 = 10;
|
||||
|
||||
pub async fn local_only(
|
||||
State(allow_remote): State<bool>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let ip = peer.ip().to_canonical(); // IPv4 clients arrive as ::ffff:a.b.c.d on the dual-stack socket
|
||||
if allow_remote || is_local(ip, &interface_networks) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
static LAST_LOG: AtomicU64 = AtomicU64::new(0);
|
||||
let now = crate::hub::now_ms() / 1000;
|
||||
if now.saturating_sub(LAST_LOG.swap(now, Ordering::Relaxed)) >= LOG_INTERVAL_S {
|
||||
tracing::warn!("rejected {ip}: not in the local network (FRAMEMATE_ALLOW_REMOTE=1 allows it)");
|
||||
}
|
||||
(StatusCode::FORBIDDEN, "FrameMate only accepts connections from the local network\n").into_response()
|
||||
}
|
||||
|
||||
/// `networks` is only consulted for public addresses (reads the interfaces).
|
||||
fn is_local(ip: IpAddr, networks: &dyn Fn() -> Vec<(IpAddr, u8)>) -> bool {
|
||||
let always = match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_loopback() || v4.is_private() || v4.is_link_local() || in_network(ip, Ipv4Addr::new(100, 64, 0, 0).into(), 10)
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
v6.is_loopback()
|
||||
|| in_network(ip, Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0).into(), 7) // ULA
|
||||
|| in_network(ip, Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0).into(), 10) // link-local
|
||||
}
|
||||
};
|
||||
always || networks().into_iter().any(|(net, prefix)| in_network(ip, net, prefix))
|
||||
}
|
||||
|
||||
fn in_network(ip: IpAddr, net: IpAddr, prefix: u8) -> bool {
|
||||
match (ip, net) {
|
||||
(IpAddr::V4(ip), IpAddr::V4(net)) => {
|
||||
let mask = u32::MAX.checked_shl(32 - u32::from(prefix.min(32))).unwrap_or(0);
|
||||
u32::from(ip) & mask == u32::from(net) & mask
|
||||
}
|
||||
(IpAddr::V6(ip), IpAddr::V6(net)) => {
|
||||
let mask = u128::MAX.checked_shl(128 - u32::from(prefix.min(128))).unwrap_or(0);
|
||||
u128::from(ip) & mask == u128::from(net) & mask
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// (address, prefix length) of every interface address, via getifaddrs(3).
|
||||
fn interface_networks() -> Vec<(IpAddr, u8)> {
|
||||
let mut out = Vec::new();
|
||||
let mut list: *mut libc::ifaddrs = std::ptr::null_mut();
|
||||
// SAFETY: getifaddrs allocates the list, freed below.
|
||||
if unsafe { libc::getifaddrs(&mut list) } != 0 {
|
||||
return out;
|
||||
}
|
||||
let mut node = list;
|
||||
while !node.is_null() {
|
||||
// SAFETY: nodes stay valid until freeifaddrs.
|
||||
let ifa = unsafe { &*node };
|
||||
if let (Some(addr), Some(mask)) = (sockaddr_ip(ifa.ifa_addr), sockaddr_ip(ifa.ifa_netmask)) {
|
||||
let prefix = match mask {
|
||||
IpAddr::V4(m) => u32::from(m).count_ones(),
|
||||
IpAddr::V6(m) => u128::from(m).count_ones(),
|
||||
};
|
||||
out.push((addr, prefix as u8));
|
||||
}
|
||||
node = ifa.ifa_next;
|
||||
}
|
||||
// SAFETY: the list from getifaddrs above.
|
||||
unsafe { libc::freeifaddrs(list) };
|
||||
out
|
||||
}
|
||||
|
||||
fn sockaddr_ip(sa: *const libc::sockaddr) -> Option<IpAddr> {
|
||||
if sa.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: sa_family says which sockaddr variant this is.
|
||||
unsafe {
|
||||
match i32::from((*sa).sa_family) {
|
||||
libc::AF_INET => {
|
||||
let sin = &*(sa as *const libc::sockaddr_in);
|
||||
Some(Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr)).into())
|
||||
}
|
||||
libc::AF_INET6 => Some(Ipv6Addr::from((*(sa as *const libc::sockaddr_in6)).sin6_addr.s6_addr).into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn local(ip: &str) -> bool {
|
||||
// The Frame: 192.168.178.130/24 and a global IPv6 address in 2001:db8:1:2::/64.
|
||||
let lan = || vec![("192.168.178.130".parse().unwrap(), 24), ("2001:db8:1:2::abcd".parse().unwrap(), 64)];
|
||||
is_local(ip.parse::<IpAddr>().unwrap().to_canonical(), &lan)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_the_local_network() {
|
||||
for ip in ["127.0.0.1", "::1", "192.168.178.22", "10.1.2.3", "172.20.0.5", "169.254.1.1", "100.101.102.103",
|
||||
"fd7a:115c:a1e3::1", "fe80::1", "::ffff:192.168.178.22", "2001:db8:1:2::77"] {
|
||||
assert!(local(ip), "{ip} should be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_public_addresses() {
|
||||
for ip in ["8.8.8.8", "::ffff:1.1.1.1", "2001:db8:9:9::1", "2a00:1450:4001::200e"] {
|
||||
assert!(!local(ip), "{ip} should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_interfaces() {
|
||||
assert!(interface_networks().iter().any(|(ip, _)| ip.is_loopback()));
|
||||
}
|
||||
}
|
||||
+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"),
|
||||
|
||||
+23
-11
@@ -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,17 +12,17 @@ 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,
|
||||
pub stream: StreamConfig,
|
||||
/// Accept clients from outside the local network (see access.rs).
|
||||
pub allow_remote: bool,
|
||||
}
|
||||
|
||||
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,14 +42,20 @@ 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,
|
||||
},
|
||||
allow_remote: matches!(env_or("FRAMEMATE_ALLOW_REMOTE", "").as_str(), "1" | "true" | "yes"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +64,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 +104,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) {
|
||||
@@ -110,6 +113,15 @@ pub fn load_or_create_token() -> anyhow::Result<String> {
|
||||
return Ok(token);
|
||||
}
|
||||
}
|
||||
write_new_token(&path)
|
||||
}
|
||||
|
||||
/// Replaces the token; the running agent only reads it at startup.
|
||||
pub fn rotate_token() -> anyhow::Result<String> {
|
||||
write_new_token(&config_dir()?.join("token"))
|
||||
}
|
||||
|
||||
fn write_new_token(path: &std::path::Path) -> anyhow::Result<String> {
|
||||
let mut bytes = [0u8; TOKEN_LEN];
|
||||
std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
|
||||
// 256 is a multiple of 32, so `% 32` is unbiased.
|
||||
@@ -123,7 +135,7 @@ pub fn load_or_create_token() -> anyhow::Result<String> {
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.open(path)
|
||||
.with_context(|| format!("writing {}", path.display()))?;
|
||||
std::io::Write::write_all(&mut file, format_token(&token).as_bytes())?;
|
||||
tracing::info!("generated a new API token");
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
mod access;
|
||||
mod cdp;
|
||||
mod check;
|
||||
mod config;
|
||||
@@ -23,7 +24,8 @@ commands:
|
||||
install-service start the agent with the user session (systemd user unit)
|
||||
uninstall-service remove that unit again
|
||||
token print the API token for the companion app
|
||||
check check the running agent and print what the app needs";
|
||||
check check the running agent and print what the app needs
|
||||
rotate-token replace the API token (and restart the agent to use it)";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@@ -32,6 +34,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
Some("install-service") => return service::install().await,
|
||||
Some("uninstall-service") => return service::uninstall().await,
|
||||
Some("check") => return check::run().await,
|
||||
Some("rotate-token") => return service::rotate_token().await,
|
||||
Some("token") => {
|
||||
println!("{}", config::format_token(&config::load_or_create_token()?));
|
||||
return Ok(());
|
||||
|
||||
@@ -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};
|
||||
|
||||
+20
-13
@@ -49,28 +49,36 @@ pub async fn serve(hub: Arc<Hub>, stream: Arc<LiveStream>, config: &Config) -> a
|
||||
.route("/api/stream/ws", get(stream_ws))
|
||||
.route("/favicon.svg", get(|| async { asset("image/svg+xml", include_bytes!("../../../assets/framemate-black.svg")) }))
|
||||
.route("/healthz", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn_with_state(config.allow_remote, crate::access::local_only))
|
||||
.with_state(AppState {
|
||||
hub,
|
||||
token: config.token.as_str().into(),
|
||||
stream,
|
||||
});
|
||||
|
||||
let listener = listen(config.listen)?;
|
||||
let token = crate::config::format_token(&config.token);
|
||||
tracing::info!("listening on http://{}/?token={token} (token: {token})", config.listen);
|
||||
axum::serve(listener, app)
|
||||
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?,
|
||||
};
|
||||
tracing::info!("listening on {}", config.listen);
|
||||
// The token never goes to the log (people paste logs into issues); only to a terminal.
|
||||
// SAFETY: isatty only inspects the descriptor.
|
||||
if unsafe { libc::isatty(libc::STDOUT_FILENO) } == 1 {
|
||||
let token = crate::config::format_token(&config.token);
|
||||
println!("Dashboard: http://localhost:{}/?token={token}", config.listen.port());
|
||||
}
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
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 +115,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();
|
||||
|
||||
+119
-24
@@ -1,12 +1,7 @@
|
||||
//! `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;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Context;
|
||||
use zbus::Connection;
|
||||
@@ -14,11 +9,14 @@ use zbus::Connection;
|
||||
const UNIT: &str = "framemate-agent.service";
|
||||
|
||||
pub async fn install() -> anyhow::Result<()> {
|
||||
let exec = match std::env::var("FLATPAK_ID") {
|
||||
let flatpak = flatpak_run();
|
||||
let exec = match &flatpak {
|
||||
// `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}"),
|
||||
Err(_) => std::env::current_exe()?.display().to_string(),
|
||||
Some((flag, app_id)) => {
|
||||
format!("/usr/bin/flatpak run {flag}--die-with-parent --command=framemate-agent {app_id}")
|
||||
}
|
||||
None => std::env::current_exe()?.display().to_string(),
|
||||
};
|
||||
let unit = format!(
|
||||
"# Installed by `framemate-agent install-service`; remove with `uninstall-service`.\n\
|
||||
@@ -40,34 +38,128 @@ pub async fn install() -> anyhow::Result<()> {
|
||||
// Create the token now, so the starting service and a following `token` call can't race.
|
||||
crate::config::load_or_create_token()?;
|
||||
|
||||
let systemd = Systemd::connect().await?;
|
||||
systemd.call("Reload", &()).await?;
|
||||
let systemd = match Systemd::reachable().await {
|
||||
Ok(systemd) => systemd,
|
||||
Err(e) => {
|
||||
enable_without_systemd(&path)?;
|
||||
println!("Installed {UNIT}, but systemd isn't reachable from this terminal ({e:#}).");
|
||||
println!("That happens in the Frame's Desktop Mode. The agent starts with the next restart");
|
||||
println!("of the Frame. To start it now, run this instead:\n");
|
||||
println!("{}", rerun_on_user_bus(&flatpak, "install-service"));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
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
|
||||
}
|
||||
|
||||
pub async fn uninstall() -> anyhow::Result<()> {
|
||||
let systemd = Systemd::connect().await?;
|
||||
let path = unit_path()?;
|
||||
let systemd = match Systemd::reachable().await {
|
||||
Ok(systemd) => systemd,
|
||||
Err(e) => {
|
||||
remove_file(&wants_link(&path))?;
|
||||
remove_file(&path)?;
|
||||
println!("Removed {UNIT}, but systemd isn't reachable from this terminal ({e:#}).");
|
||||
println!("A running agent stops with the next restart of the Frame. To stop it now, run:\n");
|
||||
println!("{}", rerun_on_user_bus(&flatpak_run(), "uninstall-service"));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// Ignore errors: the unit may not be loaded or enabled.
|
||||
let _ = systemd.call("StopUnit", &(UNIT, "replace")).await;
|
||||
let _ = systemd.call("DisableUnitFiles", &(&[UNIT][..], false)).await;
|
||||
let path = unit_path()?;
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e).with_context(|| format!("removing {}", path.display())),
|
||||
}
|
||||
remove_file(&path)?;
|
||||
systemd.call("Reload", &()).await?;
|
||||
println!("Removed {UNIT}.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rotate_token() -> anyhow::Result<()> {
|
||||
anyhow::ensure!(
|
||||
std::env::var_os("FRAMEMATE_TOKEN").is_none(),
|
||||
"FRAMEMATE_TOKEN is set and overrides the token file"
|
||||
);
|
||||
let token = crate::config::format_token(&crate::config::rotate_token()?);
|
||||
println!("New token: {token}");
|
||||
match Systemd::reachable().await {
|
||||
// TryRestartUnit only restarts it if it's running; NoSuchUnit without install-service.
|
||||
Ok(systemd) => match systemd.call("TryRestartUnit", &(UNIT, "replace")).await {
|
||||
Ok(()) => println!("Restarted {UNIT}; enter the new token in the app."),
|
||||
Err(_) => println!("{UNIT} isn't installed; restart the agent to use the new token."),
|
||||
},
|
||||
Err(_) => {
|
||||
println!("The running agent keeps the old token until it restarts. Restart the Frame, or run:\n");
|
||||
println!(
|
||||
" env XDG_RUNTIME_DIR=/run/user/$(id -u) DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus \\\n systemctl --user restart {UNIT}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `(installation flag incl. trailing space, app id)` when running as a Flatpak.
|
||||
fn flatpak_run() -> Option<(String, String)> {
|
||||
let app_id = std::env::var("FLATPAK_ID").ok()?;
|
||||
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");
|
||||
}
|
||||
Some((installation.map(|f| format!("{f} ")).unwrap_or_default(), app_id))
|
||||
}
|
||||
|
||||
/// What `systemctl enable` does on disk; systemd picks it up at the next start of the session.
|
||||
fn enable_without_systemd(unit: &Path) -> anyhow::Result<()> {
|
||||
let link = wants_link(unit);
|
||||
std::fs::create_dir_all(link.parent().unwrap())?;
|
||||
remove_file(&link)?;
|
||||
std::os::unix::fs::symlink(unit, &link).with_context(|| format!("linking {}", link.display()))
|
||||
}
|
||||
|
||||
fn wants_link(unit: &Path) -> PathBuf {
|
||||
unit.with_file_name("default.target.wants").join(UNIT)
|
||||
}
|
||||
|
||||
fn remove_file(path: &Path) -> anyhow::Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
|
||||
Err(e).with_context(|| format!("removing {}", path.display()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Frame's Desktop Mode is a nested Plasma session whose own session bus has no systemd
|
||||
/// behind it (issue #6). Inside the sandbox only that bus is visible, so the command has to be
|
||||
/// started with the real user bus.
|
||||
fn rerun_on_user_bus(flatpak: &Option<(String, String)>, command: &str) -> String {
|
||||
let run = match flatpak {
|
||||
Some((flag, app_id)) => format!("flatpak run {flag}{app_id}"),
|
||||
None => "framemate-agent".into(),
|
||||
};
|
||||
format!(
|
||||
" env XDG_RUNTIME_DIR=/run/user/$(id -u) DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus \\\n {run} {command}"
|
||||
)
|
||||
}
|
||||
|
||||
/// `--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> {
|
||||
@@ -78,8 +170,11 @@ fn unit_path() -> anyhow::Result<PathBuf> {
|
||||
struct Systemd(Connection);
|
||||
|
||||
impl Systemd {
|
||||
async fn connect() -> anyhow::Result<Self> {
|
||||
Ok(Self(Connection::session().await.context("connecting to the session bus")?))
|
||||
/// Connects and checks that systemd answers on this session bus.
|
||||
async fn reachable() -> anyhow::Result<Self> {
|
||||
let systemd = Self(Connection::session().await.context("connecting to the session bus")?);
|
||||
systemd.call("Reload", &()).await?;
|
||||
Ok(systemd)
|
||||
}
|
||||
|
||||
async fn call<B>(&self, method: &str, body: &B) -> anyhow::Result<()>
|
||||
|
||||
@@ -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