mirror of
https://github.com/nailuj05/framemate.git
synced 2026-10-07 18:00:33 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816aff8902 | ||
|
|
cd029b0050 | ||
|
|
724acd4ac6 |
No files matched your search
Generated
+1
-1
@@ -327,7 +327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framemate-agent"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ resolver = "3"
|
||||
members = ["crates/agent"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ rm framemate-agent.flatpak
|
||||
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.
|
||||
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 commands
|
||||
again. To **remove** it:
|
||||
@@ -106,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.
|
||||
|
||||
@@ -24,10 +24,22 @@ Make sure of the following:
|
||||
|
||||
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 --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
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "framemate-app",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -823,7 +823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framemate-app"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[package]
|
||||
name = "framemate-app"
|
||||
description = "FrameMate companion app"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
license = "GPL-3.0-or-later"
|
||||
edition = "2024"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "FrameMate",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"identifier": "dev.framemate.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
+58
-13
@@ -5,6 +5,10 @@ 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`. */
|
||||
@@ -28,8 +32,17 @@ 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() !== "";
|
||||
@@ -67,24 +80,55 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
@@ -99,6 +143,7 @@ class Agent {
|
||||
}
|
||||
|
||||
#close() {
|
||||
clearTimeout(this.#watchdog);
|
||||
clearTimeout(this.#retry);
|
||||
this.#retry = undefined;
|
||||
const socket = this.#socket;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -103,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);
|
||||
@@ -113,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>
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ pub struct Config {
|
||||
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 {
|
||||
@@ -44,6 +46,7 @@ impl Config {
|
||||
fps,
|
||||
bitrate,
|
||||
},
|
||||
allow_remote: matches!(env_or("FRAMEMATE_ALLOW_REMOTE", "").as_str(), "1" | "true" | "yes"),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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(());
|
||||
|
||||
@@ -49,6 +49,7 @@ 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(),
|
||||
@@ -63,9 +64,14 @@ pub async fn serve(hub: Arc<Hub>, stream: Arc<LiveStream>, config: &Config) -> a
|
||||
}
|
||||
result => result?,
|
||||
};
|
||||
let token = crate::config::format_token(&config.token);
|
||||
tracing::info!("listening on http://{}/?token={token} (token: {token})", config.listen);
|
||||
axum::serve(listener, app)
|
||||
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(())
|
||||
|
||||
@@ -80,6 +80,29 @@ pub async fn uninstall() -> anyhow::Result<()> {
|
||||
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()?;
|
||||
|
||||
Reference in new issue
Block a user