mirror of
https://github.com/nailuj05/framemate.git
synced 2026-10-06 19:00:33 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816aff8902 | ||
|
|
cd029b0050 | ||
|
|
724acd4ac6 | ||
|
|
69ac066bad | ||
|
|
58836dfadc | ||
|
|
fb78f43862 | ||
|
|
8e02b62ca9 | ||
|
|
1e01b830b4 |
No files matched your search
Generated
+1
-1
@@ -327,7 +327,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framemate-agent"
|
||||
version = "0.1.3"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ resolver = "3"
|
||||
members = ["crates/agent"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.3"
|
||||
version = "0.1.5"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ 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
|
||||
@@ -69,10 +69,14 @@ rm framemate-agent.flatpak
|
||||
- `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.
|
||||
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.
|
||||
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
|
||||
@@ -103,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.
|
||||
@@ -174,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
|
||||
@@ -185,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
|
||||
|
||||
|
||||
@@ -24,10 +24,35 @@ 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
|
||||
|
||||
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.3",
|
||||
"version": "0.1.5",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -823,7 +823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framemate-app"
|
||||
version = "0.1.3"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[package]
|
||||
name = "framemate-app"
|
||||
description = "FrameMate companion app"
|
||||
version = "0.1.3"
|
||||
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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "FrameMate",
|
||||
"version": "0.1.3",
|
||||
"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;
|
||||
|
||||
@@ -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 |
@@ -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);
|
||||
|
||||
@@ -72,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">
|
||||
|
||||
@@ -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(())
|
||||
|
||||
+101
-22
@@ -1,7 +1,7 @@
|
||||
//! `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;
|
||||
@@ -9,20 +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) => {
|
||||
let installation = std::fs::read_to_string("/.flatpak-info")
|
||||
.ok()
|
||||
.and_then(|info| installation_flag(&info));
|
||||
if installation.is_none() {
|
||||
eprintln!("warning: couldn't tell whether the agent is a --user or --system install");
|
||||
}
|
||||
let flag = installation.map(|f| format!("{f} ")).unwrap_or_default();
|
||||
Some((flag, app_id)) => {
|
||||
format!("/usr/bin/flatpak run {flag}--die-with-parent --command=framemate-agent {app_id}")
|
||||
}
|
||||
Err(_) => std::env::current_exe()?.display().to_string(),
|
||||
None => std::env::current_exe()?.display().to_string(),
|
||||
};
|
||||
let unit = format!(
|
||||
"# Installed by `framemate-agent install-service`; remove with `uninstall-service`.\n\
|
||||
@@ -44,8 +38,17 @@ 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());
|
||||
@@ -56,21 +59,94 @@ pub async fn install() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -94,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<()>
|
||||
|
||||
Reference in new issue
Block a user