6 Commits
Author SHA1 Message Date
Julian 1e859f1102 Release v0.1.1 2026-10-03 13:10:18 +02:00
Julian 71f8a5ba03 and update the versions manually (for a last time) 2026-10-03 13:03:00 +02:00
Julian 76223e3229 release script so this doesnt happen again lol 2026-10-03 13:01:41 +02:00
Julian 8b5db9ba87 updated future 2026-10-03 12:48:15 +02:00
Julian 373cb6001a various fixes and typos 2026-10-03 12:46:21 +02:00
Julian 23836330ae only build on tagged releases going forward 2026-10-03 11:43:19 +02:00
26 changed files with 220 additions and 108 deletions

No files matched your search

+1 -3
View File
@@ -1,10 +1,8 @@
# Builds the agent Flatpak bundle and the Android APK and uploads both as workflow artifacts.
# A pushed `v*` tag additionally publishes them as a GitHub release.
# Runs on pushed `v*` tags: builds the agent Flatpak bundle and the Android APK, uploads both
name: Build
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
Generated
+1 -1
View File
@@ -327,7 +327,7 @@ dependencies = [
[[package]]
name = "framemate-agent"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -5,7 +5,7 @@ resolver = "3"
members = ["crates/agent"]
[workspace.package]
version = "0.1.0"
version = "0.1.1"
edition = "2024"
[profile.release]
+4 -1
View File
@@ -95,7 +95,9 @@ page in any browser, and `http://frame.local:7380/stream?token=<your token>` mir
- **Unofficial.** FrameMate relies on undocumented Steam internals. A Steam or SteamOS update can
break parts of it until the agent is updated. Tested on SteamOS 0.4.3 (beta branch).
- **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.
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.
- **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.
@@ -144,6 +146,7 @@ If you do and you want to contribute, please let me know!
## Future
- [ ] iOS Support
- [ ] TLS Support (self signed + pinning + QR pairing)
- [ ] PWA for the mobile client
- [ ] Feed on the App notifying you of newly frame verified games (in your library)
- [ ] Control Downloads (pause, resume, reorder)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "framemate-app",
"version": "0.1.0",
"version": "0.1.1",
"description": "",
"type": "module",
"scripts": {
+1 -1
View File
@@ -823,7 +823,7 @@ dependencies = [
[[package]]
name = "framemate-app"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"tauri",
"tauri-build",
+1 -1
View File
@@ -4,7 +4,7 @@
[package]
name = "framemate-app"
description = "FrameMate companion app"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
[lib]
+2 -2
View File
@@ -11,8 +11,8 @@
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# FullscreenBridge, called from JS as `window.FrameMateAndroid` (see MainActivity.kt).
-keepclassmembers class dev.framemate.app.MainActivity$FullscreenBridge {
# NativeBridge, called from JS as `window.FrameMateAndroid` (see MainActivity.kt).
-keepclassmembers class dev.framemate.app.MainActivity$NativeBridge {
@android.webkit.JavascriptInterface <methods>;
}
@@ -2,9 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -20,8 +17,6 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
@@ -1,6 +1,8 @@
package dev.framemate.app
import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.Bundle
import android.webkit.JavascriptInterface
import android.webkit.WebView
@@ -17,15 +19,16 @@ class MainActivity : TauriActivity() {
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
webView.addJavascriptInterface(FullscreenBridge(), "FrameMateAndroid")
webView.addJavascriptInterface(NativeBridge(), "FrameMateAndroid")
}
/**
* 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.
*/
inner class FullscreenBridge {
/** `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.
*/
@JavascriptInterface
fun setFullscreen(enabled: Boolean) = runOnUiThread {
requestedOrientation =
@@ -39,5 +42,14 @@ class MainActivity : TauriActivity() {
bars.show(WindowInsetsCompat.Type.systemBars())
}
}
/** Opens a web link in the system browser instead of navigating the app's WebView away. */
@JavascriptInterface
fun openUrl(url: String) = runOnUiThread {
val uri = Uri.parse(url)
if (uri.scheme == "https" || uri.scheme == "http") {
startActivity(Intent(Intent.ACTION_VIEW, uri))
}
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "FrameMate",
"version": "0.1.0",
"version": "0.1.1",
"identifier": "dev.framemate.app",
"build": {
"beforeDevCommand": "bun run dev",
+1 -1
View File
@@ -9,7 +9,7 @@ declare global {
interface Window {
/** Native bridge from MainActivity.kt (Android only). */
FrameMateAndroid?: { setFullscreen(enabled: boolean): void };
FrameMateAndroid?: { setFullscreen(enabled: boolean): void; openUrl(url: string): void };
}
}
+28 -5
View File
@@ -13,7 +13,8 @@ export interface ConnectionSettings {
token: string;
}
export type ConnectionStatus = "unconfigured" | "connecting" | "connected" | "offline";
/** `unauthorized`: the agent answered but rejected the token. */
export type ConnectionStatus = "unconfigured" | "connecting" | "connected" | "offline" | "unauthorized";
function loadSettings(): ConnectionSettings {
try {
@@ -52,6 +53,7 @@ class Agent {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.settings));
} catch {}
this.state = null;
this.status = "connecting";
this.connect();
}
@@ -61,23 +63,44 @@ class Agent {
this.status = "unconfigured";
return;
}
this.status = "connecting";
// Retries keep showing the last failure instead of flickering back to "connecting".
if (this.status !== "offline" && this.status !== "unauthorized") this.status = "connecting";
const socket = new WebSocket(this.socketUrl("/api/ws"));
this.#socket = socket;
socket.onmessage = event => {
this.state = JSON.parse(event.data);
this.status = "connected";
};
socket.onclose = () => {
let opened = false;
socket.onopen = () => (opened = true);
socket.onclose = async () => {
if (this.#socket !== socket) return; // replaced by a newer connection
this.#socket = null;
this.status = "offline";
this.#retry = setTimeout(() => this.connect(), RETRY_MS);
// 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);
};
}
/** Tells a wrong token apart from an unreachable agent (needs CORS on /api/state). */
async #probe(): Promise<ConnectionStatus> {
try {
const url = `http://${this.authority}/api/state?token=${encodeURIComponent(this.settings.token.trim())}`;
const response = await fetch(url, { signal: AbortSignal.timeout(RETRY_MS) });
return response.status === 401 ? "unauthorized" : "offline";
} catch {
return "offline";
}
}
#close() {
clearTimeout(this.#retry);
this.#retry = undefined;
const socket = this.#socket;
this.#socket = null;
socket?.close();
+13
View File
@@ -0,0 +1,13 @@
// 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. */
export function external(event: MouseEvent) {
event.preventDefault();
openExternal((event.currentTarget as HTMLAnchorElement).href);
}
+2 -1
View File
@@ -101,7 +101,8 @@
.status.connected {
background: var(--green);
}
.status.offline {
.status.offline,
.status.unauthorized {
background: var(--red);
}
/* Flex, so the icon isn't placed on a text line (line-height would shift it off-center). */
+5 -1
View File
@@ -29,7 +29,11 @@
{#if !agent.state}
<p class="empty">
{agent.status === "unconfigured" ? "Set up the connection in Settings." : "Connecting to your Frame…"}
{agent.status === "unconfigured"
? "Set up the connection in Settings."
: agent.status === "unauthorized"
? "The Frame rejected the token. Check it in Settings."
: "Connecting to your Frame…"}
</p>
{:else}
<Section title="Steam Frame">
+6 -6
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Section from "$lib/components/Section.svelte";
import Row from "$lib/components/Row.svelte";
import { external } from "$lib/external";
</script>
<Section title="Installation">
@@ -13,7 +14,7 @@
<Section title="Token">
<p>
FrameMate uses a token for communicating with your Steam Frame. This token is show on installation or by running
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.
Write it down, you will need it to connect the app with the headset.
</p>
@@ -21,8 +22,8 @@
<Section title="Connecting">
<p>
Once the agent is installed on the headset, you aquired the token and the app is ready on your phone you can connect the two.
The connection requires the Frame's address, usually this will be frame.local, if that doesn't work try your headsets IP address directly.
Once the agent is installed on the headset, you acquired the token and the app is ready on your phone you can connect the two.
The connection requires the Frame's address, usually this will be frame.local, if that doesn't work try your headset's IP address directly.
You can find out your IP by running <code>ip a</code>. Both headset and phone need to be in the same local network for the connection to work.
</p>
</Section>
@@ -30,13 +31,12 @@
<Section title="Issues">
<p>
If you encounter any issues with the app or agent, please submit a GitHub Issue so I can get it fixed. <br>
<a href="https://github.com/nailuj05/framemate/issues/new">Submit an Issue here</a>
<a href="https://github.com/nailuj05/framemate/issues/new" onclick={external}>Submit an Issue here</a>
</p>
</Section>
<style>
p {
gap: 12px;
min-height: 48px;
padding: 8px var(--gutter);
background: var(--surface);
@@ -44,6 +44,6 @@
color: var(--text);
}
a {
color: #0000EE;
color: var(--accent);
}
</style>
-49
View File
@@ -1,49 +0,0 @@
<script lang="ts">
import Section from "$lib/components/Section.svelte";
import Row from "$lib/components/Row.svelte";
</script>
<Section title="Installation">
<p>
This app cannot natively connect to the Steam Frame. It needs a helper called framemate-agent, a small service running on the Frame, providing the information and connection to the headset for this app. <br>
The agent is shipped as a Flatpak that you will need to install on your Steam Frame. The easiest way to install it is through SSH if you have that setup to your Frame, otherwise the Desktop Mode Terminal will also work. <br>
<b>Check out the GitHub page for exact install instructions</b>
</p>
</Section>
<Section title="Token">
<p>
FrameMate uses a token for communicating with your Steam Frame. This token is show on installation or by running
<code>flatpak run 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>
<Section title="Connecting">
<p>
Once the agent is installed on the headset, you aquired the token and the app is ready on your phone you can connect the two.
The connection requires the Frame's address, usually this will be frame.local, if that doesn't work try your headsets IP address directly.
You can find out your IP by running <code>ip a</code>. Both headset and phone need to be in the same local network for the connection to work.
</p>
</Section>
<Section title="Issues">
<p>
If you encounter any issues with the app or agent, please submit a GitHub Issue so I can get it fixed. <br>
<a href="https://github.com/nailuj05/framemate/issues/new">Submit an Issue here</a>
</p>
</Section>
<style>
p {
gap: 16px;
min-height: 48px;
padding: 8px var(--gutter);
background: var(--surface);
border-bottom: 1px solid var(--divider);
color: var(--text);
}
a {
color: #0000EE;
}
</style>
+3 -1
View File
@@ -12,6 +12,7 @@
connecting: "Connecting…",
connected: `Connected to ${agent.state?.agent.hostname ?? agent.authority}`,
offline: `Can't reach ${agent.authority}`,
unauthorized: "Wrong token. Check it on the Frame (see below).",
}[agent.status],
);
@@ -81,7 +82,8 @@
.status.connected {
color: var(--green);
}
.status.offline {
.status.offline,
.status.unauthorized {
color: var(--red);
}
</style>
+5 -1
View File
@@ -16,6 +16,7 @@
let socket: WebSocket | null = null;
let retry: ReturnType<typeof setTimeout> | undefined;
let closed = false;
let objectUrl: string | undefined;
const stats = $derived(agent.state?.stream);
const fullscreen = $derived(!!page.state.fullscreen);
@@ -67,7 +68,9 @@
return;
}
const source = new MediaSource();
video.src = URL.createObjectURL(source);
if (objectUrl) URL.revokeObjectURL(objectUrl); // previous connection's source
objectUrl = URL.createObjectURL(source);
video.src = objectUrl;
source.addEventListener(
"sourceopen",
() => {
@@ -109,6 +112,7 @@
closed = true;
clearTimeout(retry);
socket?.close();
if (objectUrl) URL.revokeObjectURL(objectUrl);
});
</script>
+16 -1
View File
@@ -24,6 +24,9 @@ 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.
const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
pub async fn run(hub: Arc<Hub>, cdp_url: String) {
let authority = cdp_url
@@ -72,7 +75,19 @@ async fn session(hub: &Hub, authority: &str, devices: &mut DeviceMemory) -> anyh
s.steam.error = None;
});
while let Some(msg) = rx.next().await {
let mut pinged = false;
loop {
let msg = match tokio::time::timeout(IDLE_TIMEOUT, rx.next()).await {
Ok(Some(msg)) => msg,
Ok(None) => break,
Err(_) if pinged => bail!("Steam stopped responding"),
Err(_) => {
pinged = true;
tx.send(call("Runtime.evaluate", json!({"expression": "1"}))).await?;
continue;
}
};
pinged = false;
let text = match msg? {
Message::Text(text) => text,
Message::Close(_) => break,
+6 -2
View File
@@ -30,6 +30,10 @@ impl Config {
_ => load_or_create_token()?,
};
let token = normalize_token(&token);
let fps: u32 = env_or("FRAMEMATE_STREAM_FPS", "30").parse().context("FRAMEMATE_STREAM_FPS")?;
anyhow::ensure!((1..=120).contains(&fps), "FRAMEMATE_STREAM_FPS must be 1–120");
let bitrate: u32 = env_or("FRAMEMATE_STREAM_BITRATE", "6000000").parse().context("FRAMEMATE_STREAM_BITRATE")?;
anyhow::ensure!(bitrate > 0, "FRAMEMATE_STREAM_BITRATE must be > 0");
Ok(Self {
listen,
cdp_url: env_or("FRAMEMATE_CDP", "http://127.0.0.1:8080"),
@@ -38,8 +42,8 @@ impl Config {
stream: StreamConfig {
source_device: env_or("FRAMEMATE_STREAM_SOURCE", "/dev/video99").into(),
encoder_device: env_or("FRAMEMATE_STREAM_ENCODER", "/dev/video23"),
fps: env_or("FRAMEMATE_STREAM_FPS", "30").parse().context("FRAMEMATE_STREAM_FPS")?,
bitrate: env_or("FRAMEMATE_STREAM_BITRATE", "6000000").parse().context("FRAMEMATE_STREAM_BITRATE")?,
fps,
bitrate,
},
})
}
+36 -9
View File
@@ -16,7 +16,8 @@ const LAST_SEEN_SAVE_INTERVAL_MS: u64 = 5 * 60 * 1000;
pub struct DeviceMemory {
file: Option<PathBuf>,
/// Keyed by serial (stable), falling back to the OpenVR path.
/// 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.
known: BTreeMap<String, Value>,
last_save_ms: u64,
}
@@ -41,10 +42,14 @@ impl DeviceMemory {
let mut out = Vec::with_capacity(live.len());
let mut present = BTreeSet::new();
for mut device in live {
let Some(key) = key(&device) else {
let Some(key) = serial(&device) else {
out.push(device);
continue;
};
// Older versions keyed serial-less devices by OpenVR path; drop such leftovers.
if let Some(path) = device["path"].as_str().filter(|p| *p != key) {
self.known.remove(path);
}
device["last_seen_ms"] = if device["connected"] == true {
json!(now)
} else {
@@ -75,8 +80,12 @@ 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).
let tmp = file.with_extension("json.tmp");
let result = std::fs::create_dir_all(file.parent().unwrap())
.and_then(|()| std::fs::write(file, serde_json::to_vec_pretty(&self.known).unwrap()));
.and_then(|()| std::fs::write(&tmp, serde_json::to_vec_pretty(&self.known).unwrap()))
.and_then(|()| std::fs::rename(&tmp, file));
match result {
Ok(()) => self.last_save_ms = now,
Err(e) => tracing::warn!("saving {}: {e}", file.display()),
@@ -84,12 +93,8 @@ impl DeviceMemory {
}
}
fn key(device: &Value) -> Option<String> {
device["serial"]
.as_str()
.filter(|s| !s.is_empty())
.or_else(|| device["path"].as_str())
.map(str::to_owned)
fn serial(device: &Value) -> Option<String> {
device["serial"].as_str().filter(|s| !s.is_empty()).map(str::to_owned)
}
fn without_last_seen(known: &BTreeMap<String, Value>) -> BTreeMap<&String, Value> {
@@ -129,4 +134,26 @@ mod tests {
assert!(left["last_seen_ms"].is_u64());
assert!(merged.iter().find(|d| d["serial"] == "hmd").unwrap().get("remembered").is_none());
}
#[test]
fn serial_less_devices_are_not_remembered() {
let mut memory = DeviceMemory { file: None, known: BTreeMap::new(), last_save_ms: 0 };
// Serial read failed while the controller was connecting.
let mut connecting = device("right", true, 0.7);
connecting["serial"] = Value::Null;
memory.merge(json!([connecting]));
// Next poll has the serial: exactly one device, nothing remembered by path.
let merged = memory.merge(json!([device("right", true, 0.7)]));
assert_eq!(merged.as_array().unwrap().len(), 1);
assert_eq!(memory.known.len(), 1);
}
#[test]
fn drops_path_keyed_leftovers() {
let mut memory = DeviceMemory { file: None, known: BTreeMap::new(), last_save_ms: 0 };
memory.known.insert("/devices/right".into(), device("right", false, 0.5));
let merged = memory.merge(json!([device("right", true, 0.7)]));
assert_eq!(merged.as_array().unwrap().len(), 1);
assert!(!memory.known.contains_key("/devices/right"));
}
}
+5 -2
View File
@@ -83,10 +83,13 @@ 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.
let cors = [(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")];
if !authorized(&app, &headers, &query) {
return StatusCode::UNAUTHORIZED.into_response();
return (StatusCode::UNAUTHORIZED, cors).into_response();
}
([(header::CONTENT_TYPE, "application/json")], app.hub.snapshot_json()).into_response()
(cors, [(header::CONTENT_TYPE, "application/json")], app.hub.snapshot_json()).into_response()
}
async fn ws(
+25 -5
View File
@@ -14,7 +14,7 @@ use std::os::fd::AsRawFd;
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, ensure};
@@ -100,20 +100,22 @@ impl LiveStream {
std::thread::Builder::new()
.name("stream".into())
.spawn(move || {
// Unregisters the pipeline however the thread ends, including a panic, so
// the next viewer starts a fresh one instead of joining a dead channel.
let _guard = StopGuard { stream: &this, tx: &thread_tx };
tracing::info!("stream: starting");
if let Err(e) = this.run(&thread_tx, &thread_want) {
tracing::warn!("stream: {e:#}");
}
// Dropping our sender ends all viewers if we stopped on an error.
this.stop_if_current(&thread_tx);
tracing::info!("stream: stopped");
})
.expect("spawn stream thread");
Running { tx, want_keyframe }
}
/// Dropping our sender (and the registered clone) ends all viewers.
fn stop_if_current(&self, tx: &broadcast::Sender<Arc<Packet>>) {
let mut running = self.running.lock().unwrap();
// Runs during unwinding too: a poisoned lock must not turn into a double panic.
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
if running.as_ref().is_some_and(|r| r.tx.same_channel(tx)) {
*running = None;
}
@@ -136,6 +138,7 @@ impl LiveStream {
fn run(&self, tx: &broadcast::Sender<Arc<Packet>>, want_keyframe: &AtomicBool) -> Result<()> {
let source = LoopbackCapture::open(&self.config.source_device)?;
let (width, height) = (source.width, source.height);
let frame_len = source.stride as usize * (height as usize).saturating_sub(1) + width as usize * 3;
let mut encoder = Encoder::open(&self.config.encoder_device, encoder::Config {
width,
height,
@@ -156,6 +159,11 @@ impl LiveStream {
continue;
};
source_frames += 1;
if len < frame_len {
tracing::debug!("stream: short frame ({len} of {frame_len} bytes), skipped");
source.release(index)?;
continue;
}
let now = Instant::now();
if now >= next_due {
next_due = (next_due + interval).max(now);
@@ -201,6 +209,18 @@ impl LiveStream {
}
}
struct StopGuard<'a> {
stream: &'a LiveStream,
tx: &'a broadcast::Sender<Arc<Packet>>,
}
impl Drop for StopGuard<'_> {
fn drop(&mut self) {
self.stream.stop_if_current(self.tx);
tracing::info!("stream: stopped");
}
}
/// mmap streaming capture from the v4l2loopback device.
struct LoopbackCapture {
buffers: Vec<Mapping>,
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Sets the version everywhere, commits, tags `v<version>` and pushes both. The tag push
# triggers CI (.github/workflows/build.yml), which builds and publishes the GitHub release.
#
# scripts/release.sh 0.2.0
set -euo pipefail
VERSION="${1:-}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "usage: $0 <major.minor.patch>" >&2
exit 2
fi
TAG="v$VERSION"
cd "$(dirname "$0")/.."
if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
echo "Working tree has uncommitted changes; commit or stash them first." >&2
exit 1
fi
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Tag $TAG already exists." >&2
exit 1
fi
# Agent (workspace) and app shell versions; the APK's version comes from tauri.conf.json.
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml app/src-tauri/Cargo.toml
sed -i "0,/\"version\": \".*\"/s//\"version\": \"$VERSION\"/" app/src-tauri/tauri.conf.json app/package.json
# Refresh our own entries in both lockfiles (no dependency changes).
cargo update --workspace --offline --quiet
(cd app/src-tauri && cargo update --workspace --offline --quiet)
git add Cargo.toml Cargo.lock app/src-tauri/Cargo.toml app/src-tauri/Cargo.lock \
app/src-tauri/tauri.conf.json app/package.json
git commit -q -m "Release $TAG"
git tag -a "$TAG" -m "FrameMate $TAG"
git push --atomic origin HEAD "$TAG"
echo "Pushed $TAG; CI builds and publishes the release."