23 Commits
Author SHA1 Message Date
Julian a59eaf74ac Release v0.2.0 2026-10-09 19:45:30 +02:00
Julian af593840d1 paritiy between scripts and readme 2026-10-09 19:45:03 +02:00
Julian a1d7b84cec ML Kit rules (proguard) 2026-10-09 19:39:56 +02:00
Julian 3bb4497681 ask for camera permissions for QR scanning 2026-10-09 18:46:57 +02:00
Julian 2ecd4497cf smaller fixes with the tls connection 2026-10-09 17:32:05 +02:00
Sil3nt e22661e77d Update LICENSE-EXCEPTION.md for clarity
Clarified licensing terms
2026-10-08 12:16:26 +02:00
Julian Limburg c248f65722 local TLS through self signed cert + QR pairing and cert pinning
this adds TLS encryption and the needed pairing and pinning to make it
work in a local network without any CA. In its current state (a
readonly websocket) the app doesn't require any encryption in a local
network. However this lays the groundwork for future features such as
screenshot sharing, phone notifactions shown in headset etc.

The Webview in the App cannot pin a certificate, instead the
connection is now established through the rust backend and forwarded
to Tauri through a HTTP proxy (local to your phone only and further
protected through a secret as loopback isnt isolated between apps on
android). This works and is a fine implementation however I would like
to rewrite this is the future to use Tauris IPC protocol instead of a
loopback.

This also makes a PWA version of the app harder to achieve.
2026-10-08 11:54:35 +02:00
Julian Limburg afb63e00c5 replace bun-slop with deno 2026-10-05 17:32:09 +02:00
Julian 816aff8902 better refreshing on client app to avoid showing stale data 2026-10-05 01:20:11 +02:00
Julian cd029b0050 Release v0.1.5 2026-10-04 21:20:59 +02:00
Julian 724acd4ac6 token rotation, connection blacklist, dont log token 2026-10-04 21:20:02 +02:00
Julian 69ac066bad Release v0.1.4 2026-10-04 20:56:19 +02:00
Julian 58836dfadc fix #6, systemd --user not available in Desktop Modes nested session
fixed with fallback in service.rs
adjusted documentation, ssh is now recommended
added to troublshooting doc
2026-10-04 20:54:58 +02:00
Julian fb78f43862 updated future section in readme 2026-10-04 01:58:15 +02:00
Julian 8e02b62ca9 proper steam frame controller icons (thanks to kenney's input prompt pack!) 2026-10-04 01:57:17 +02:00
Julian 1e01b830b4 bump java version to 17 2026-10-04 01:51:37 +02:00
Julian 675a50fdb1 Release v0.1.3 2026-10-04 01:43:12 +02:00
Julian a0fd89809b Merge remote-tracking branch 'origin/main' 2026-10-04 01:39:26 +02:00
Julian 607da0f88d Android 17 local network prompt, connection robustness, comment cleanup
- App: request ACCESS_LOCAL_NETWORK on Android 17+ and show a hint with
  an "Open app settings" button when it's denied (#3)
- App: accept IPv6 addresses as host, suggest the IP when .local fails
- Agent: fall back to IPv4 if IPv6 is unavailable, prefer the stable
  /dev/video-enc0 encoder path
- Trim redundant comments across agent and app
2026-10-04 01:38:04 +02:00
Julian 9c04f99a25 missing --user flag fixed (#2) 2026-10-04 01:30:50 +02:00
Sil3nt 7034b8f88b Merge pull request #4 from bigkpwns/android17-local-network-permission
Declare ACCESS_LOCAL_NETWORK for Android 17 (target SDK 37)
2026-10-04 01:18:34 +02:00
Caleb Garber 4b2d141c4c Declare ACCESS_LOCAL_NETWORK for Android 17 (target SDK 37)
Android 17 blocks local network access by default for apps targeting
SDK 37. The released APK targets 37 and declares only INTERNET, so on
Android 17 devices (e.g. Pixel 11 Pro XL) the app's WebSocket to the
Frame's agent on port 7380 is dropped silently before any packet
leaves the phone. tcpdump on the frame's wlan0 showed browser
connections arriving but zero packets from the app, confirming the
block happens on the client side.

Adding the permission declaration lets the user grant it under
Settings > Apps > FrameMate > Permissions. This is verified: after
granting it on a Pixel 11 Pro XL running Android 17, the app
connected immediately. On Android 16 (SDK 36) the permission is an
opt-in flag and has no effect, on older releases it is unknown and
ignored, so no behavioral change.

The recommended companion (a runtime requestPermission() prompt before
first connect, to avoid requiring the user to find the toggle
themselves) is tracked in the linked issue; it is not included here
because it could not be verified on an Android 17 device during
development.

Signed-off-by: Caleb Garber <8532661+bigkpwns@users.noreply.github.com>
2026-10-03 17:40:57 -05:00
Sil3nt 7a83161228 Update README to remove flatpak file post-installation
Remove the downloaded flatpak file after installation.
2026-10-03 22:52:21 +02:00
68 changed files with 3594 additions and 803 deletions

No files matched your search

+4 -2
View File
@@ -1,4 +1,6 @@
# Static aarch64 build for the Steam Frame. Works with rust-lld because the
# dependency tree is pure Rust (no TLS, no C) — keep it that way.
# Static aarch64 build for the Steam Frame
[target.aarch64-unknown-linux-musl]
linker = "rust-lld"
[env]
CC_aarch64_unknown_linux_musl = "clang"
+1
View File
@@ -0,0 +1 @@
use nix
+5 -3
View File
@@ -78,10 +78,12 @@ jobs:
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" >/dev/null
echo "NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
- uses: oven-sh/setup-bun@v2
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Install dependencies
run: bun install --frozen-lockfile
run: deno install --frozen
# Signed with the release key from the repository secrets (see build.gradle.kts).
- name: Build APK
@@ -92,7 +94,7 @@ jobs:
run: |
export ANDROID_KEYSTORE_PATH="$RUNNER_TEMP/release.jks"
base64 -d <<< "$ANDROID_KEYSTORE_BASE64" > "$ANDROID_KEYSTORE_PATH"
bun run tauri android build --apk --target aarch64
deno task tauri android build --apk --target aarch64
rm "$ANDROID_KEYSTORE_PATH"
cp src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk \
../framemate.apk
+3
View File
@@ -1,3 +1,6 @@
/target
ref/
*~
\#*\#
.\#*
.direnv/
+4 -6
View File
@@ -28,8 +28,7 @@ By signing off you certify the four points in [`DCO`](DCO), and that your
contribution is offered under:
- the **GNU GPL v3.0 or later** ([`LICENSE`](LICENSE)); **and**
- the additional terms in [`LICENSE-EXCEPTION.md`](LICENSE-EXCEPTION.md) —
notably the GPL §7 app store distribution permission.
- the additional terms in [`LICENSE-EXCEPTION.md`](LICENSE-EXCEPTION.md) (notably the GPL §7 app store distribution permission).
That second point is key, it allows future app store releases without needing to get approval from every past contributor.
@@ -44,7 +43,7 @@ That second point is key, it allows future app store releases without needing to
Following should be preserved:
- **The agent stays small and dependency-light.** It's ~5 MB and idles at
- **The agent stays small and dependency-light.** It's ~6.5 MB and idles at
practically zero CPU on a battery-powered headset. New dependencies in
`crates/agent` need to earn their place.
- **The agent stays free of heavyweight media stacks.** Encoding talks to the
@@ -58,7 +57,7 @@ source](README.md#building-from-source). The quick loop:
```sh
scripts/deploy.sh # build + run the current agent on the Frame (| logs | stop)
cd app && bun run tauri dev # desktop window for UI work
cd app && deno task tauri dev # desktop window for UI work
```
`scripts/*.sh` reach the Frame as `steamos@frame.local`; override with
@@ -70,9 +69,8 @@ CI only builds release artifacts on pushed `v*` tags, which needs push access to
```sh
cargo test # agent unit tests (host target, not musl)
cargo fmt --all # default rustfmt, no custom config
cargo clippy --all-targets
cd app && bun run check # svelte-check + TypeScript
cd app && deno task check # svelte-check + TypeScript
```
Also:
Generated
+438 -10
View File
@@ -17,6 +17,45 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -124,6 +163,15 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -160,6 +208,16 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -226,6 +284,26 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.10.7"
@@ -247,6 +325,17 @@ dependencies = [
"crypto-common 0.2.2",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -287,7 +376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -316,6 +405,12 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -327,16 +422,21 @@ dependencies = [
[[package]]
name = "framemate-agent"
version = "0.1.2"
version = "0.2.0"
dependencies = [
"anyhow",
"axum",
"futures-util",
"libc",
"qrcode",
"rcgen",
"ring",
"rustls",
"serde",
"serde_json",
"socket2",
"tokio",
"tokio-rustls",
"tokio-tungstenite 0.30.0",
"tracing",
"tracing-subscriber",
@@ -424,6 +524,17 @@ dependencies = [
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -636,6 +747,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.2.3"
@@ -644,7 +761,17 @@ checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
@@ -653,7 +780,50 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]]
@@ -690,6 +860,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "powerfmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -717,6 +893,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]]
name = "quote"
version = "1.0.47"
@@ -784,6 +966,19 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rcgen"
version = "0.14.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
dependencies = [
"ring",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
@@ -801,6 +996,29 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustix"
version = "1.1.5"
@@ -811,7 +1029,41 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
@@ -934,6 +1186,12 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -963,9 +1221,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
@@ -994,6 +1258,17 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -1004,7 +1279,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1036,6 +1311,36 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tokio"
version = "1.53.1"
@@ -1050,7 +1355,7 @@ dependencies = [
"socket2",
"tokio-macros",
"tracing",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1064,6 +1369,16 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
@@ -1254,7 +1569,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -1263,6 +1578,12 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "uuid"
version = "1.26.1"
@@ -1352,6 +1673,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1361,6 +1691,70 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "1.0.4"
@@ -1376,6 +1770,34 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"ring",
"rusticata-macros",
"thiserror",
"time",
]
[[package]]
name = "yasna"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec",
"time",
]
[[package]]
name = "zbus"
version = "5.19.0"
@@ -1399,7 +1821,7 @@ dependencies = [
"tracing",
"uds_windows",
"uuid",
"windows-sys",
"windows-sys 0.61.2",
"winnow",
"zbus_macros",
"zbus_names",
@@ -1461,6 +1883,12 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zmij"
version = "1.0.23"
+1 -1
View File
@@ -5,7 +5,7 @@ resolver = "3"
members = ["crates/agent"]
[workspace.package]
version = "0.1.2"
version = "0.2.0"
edition = "2024"
license = "GPL-3.0-or-later"
+3 -4
View File
@@ -1,9 +1,7 @@
# Additional terms
FrameMate is licensed under the GNU General Public License, version 3 or (at
your option) any later version — see [`LICENSE`](LICENSE). The following terms
accompany that license. Nothing here takes away any permission the GPL grants
you.
FrameMate is licensed under the GNU General Public License, version 3 (see [`LICENSE`](LICENSE)). The following terms
accompany that license.
## 1. Additional permission for app store distribution (GPL-3.0 section 7)
@@ -50,6 +48,7 @@ logo files and choose its own application identifier.
- Icons are from Material Symbols by Google, licensed Apache-2.0. See
[`app/src/lib/icons/LICENSES.md`](app/src/lib/icons/LICENSES.md). Apache-2.0
is compatible with GPL-3.0; the icons remain under their own license.
- Steam Frame Controller Icons are from "Input Prompts" by Kenney. Licensed under CC0.
- Game artwork shown in the app is loaded at runtime from Steam's public CDN
and is not part of this project.
- Steam, SteamOS and Steam Frame are trademarks of Valve Corporation.
+62 -37
View File
@@ -41,11 +41,12 @@ the load low.
- **Now playing** – the running game with its Steam artwork.
- **Downloads** – the Frame's download queue with progress, speed and time left, plus recently
finished updates. Downloads of your other PCs (Steam Remote Downloads) are left out.
- **Mirroring** – see above; also available in any browser.
- **Mirroring** – see above.
- **System** – performance profile, CPU/GPU settings, network throughput, SteamOS and Steam
client versions, battery temperature and health.
- **Web dashboard** – a plain-text status page on the Frame for any browser on your network.
- **Lightweight** – the agent on the Frame is a single static binary (~5 MB, a few MB of
- **Encrypted** – the phone talks to the Frame over TLS, pinned to the Frame's own key from the
pairing code. Implementation inspired by KDEConnect
- **Lightweight** – the agent on the Frame is a single static binary (~6.5 MB, a few MB of
RAM) that idles at practically zero CPU.
## Installation
@@ -54,55 +55,72 @@ 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:
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 -LsSf https://raw.githubusercontent.com/nailuj05/framemate/main/scripts/install.sh | sh
```
That installs the Flatpak, registers the user service, runs a self check and prints the pairing
QR code. Re-run the same command to update. If you'd rather not pipe a script into a shell,
[read it first](scripts/install.sh); it only runs the manual commands below.
<details>
<summary>Or do it by hand</summary>
```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
flatpak run --user dev.framemate.Agent pair
rm framemate-agent.flatpak
```
</details>
- `flatpak install` pulls the Freedesktop runtime from Flathub if it isn't installed yet
(about 270 MB, once).
- `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.
- `install-service` registers a user service, so the agent starts with every boot (in
Game Mode too) and restarts it right away, then runs a self check.
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. Either way `pair` prints the pairing code afterwards.
- `flatpak run --user dev.framemate.Agent pair` prints the pairing code again, as a QR code and
as plain text for terminals too narrow to draw it. It contains the access token, so treat it
like a password.
- `flatpak run --user dev.framemate.Agent check` repeats the self check, which helps when the app
can't connect. `token` prints just the token. `rotate-token` replaces it, so run `pair`
afterwards and scan again: the app takes the token from the pairing code. Rotating leaves the
encryption key alone, so it doesn't change who the app trusts.
To **update**, download the new `framemate-agent.flatpak` and run the same three commands
again. To **remove** it:
To **remove** it:
```sh
flatpak run dev.framemate.Agent uninstall-service
flatpak uninstall --user dev.framemate.Agent
curl -LsSf https://raw.githubusercontent.com/nailuj05/framemate/main/scripts/install.sh | sh -s -- uninstall
```
### 2. App on your phone
1. Download `framemate.apk` from the [latest release](../../releases/latest) on your phone.
2. Open it and allow your browser/file manager to install apps when Android asks.
3. In the app's **Settings** tab, enter the Frame's address (`frame.local`, or its IP) and the
token, then tap **Save & connect**.
3. In the app's **Settings** tab, tap **Scan pairing code** and point the camera at the QR code
from step 1. The code contains all information the app needs to find your frame, authenticate with it and verify its certificate.
### Troubleshooting
If you encounter any issues please check out [TROUBLESHOOTING](TROUBLESHOOTING.md).
### Web dashboard (Debug)
With the agent running, `http://frame.local:7380/?token=<your token>` shows a plain-text status
page in any browser, and `http://frame.local:7380/stream?token=<your token>` mirroring.
## Good to know
- **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. 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.
- **Encrypted, local network only.** The app connects on port 7381 over TLS, pinned to the key
whose fingerprint came from the pairing code, so nobody else on the network can read the token
or watch your Mirroring. Port 7380 serves the same API in plain HTTP but binds to `127.0.0.1`,
so only the Frame itself can reach it. Set `FRAMEMATE_LISTEN=[::]:7380` to expose it on your
LAN, and remember the token then travels in clear. Don't expose either port to the internet.
The agent also rejects connections from public addresses outside your network, which matters
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.
@@ -111,12 +129,12 @@ page in any browser, and `http://frame.local:7380/stream?token=<your token>` mir
## Building from source
Requirements: Rust (with the `aarch64-unknown-linux-musl` target), Bun, Flatpak, and for the app
the Android SDK + NDK and JDK 21.
Requirements: Rust (`rust-toolchain.toml` pins the toolchain and target), Deno, Flatpak, and for
the app the Android SDK + NDK and JDK 21. On Nix, `nix-shell` (or direnv) covers the agent and
Deno; the Android SDK/NDK and JDK are not included.
```sh
# Agent: static aarch64 binary → Flatpak bundle (no flatpak-builder or emulation needed)
rustup target add aarch64-unknown-linux-musl
scripts/flatpak.sh # → target/flatpak/framemate-agent.flatpak
scripts/flatpak.sh install # build, install on the Frame via SSH, register the service
@@ -125,9 +143,9 @@ scripts/deploy.sh # scripts/deploy.sh logs | stop
# App
cd app
bun install
bun run tauri android build --apk --target aarch64
bun run tauri dev # desktop window for UI work
deno install
deno task tauri android build --apk --target aarch64
deno task tauri dev # desktop window for UI work
```
`scripts/*.sh` reach the Frame as `steamos@frame.local` (override with `FRAME_HOST`).
@@ -139,7 +157,11 @@ bun run tauri dev # desktop window for UI work
| `GET /api/state` | full state as JSON |
| `GET /api/ws` | the same, pushed on every change |
| `GET /api/stream/ws` | mirroring: `{codec}` header, fMP4 init segment, one fragment per frame |
| `GET /` · `GET /stream` | web dashboard · mirroring player |
| `GET /healthz` | liveness, the only route without auth |
Reachable off the Frame on `https://<frame>:7381`. The agent signs its own certificate and the
app pins it by fingerprint, so other clients need to skip chain verification (`curl -k`). Plain
HTTP lives on `127.0.0.1:7380`, which only the Frame itself can reach.
Authenticate with `?token=<token>` or `Authorization: Bearer <token>`.
@@ -151,14 +173,14 @@ If you do and you want to contribute, please let me know!
## Contributing
Patches and bug reports are welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md).
Patches and bug reports are welcome, see [`CONTRIBUTING.md`](CONTRIBUTING.md).
Commits need a `Signed-off-by` line (`git commit -s`); there's no CLA.
## License
FrameMate is free software under the **GNU General Public License v3.0 or
later** ([`LICENSE`](LICENSE)). You may use, study, share and modify it. If you
distribute it — modified or not, free or for money — you must pass on the same
distribute it, modified or not, free or for money, you must pass on the same
freedoms and make the complete source available under the same license. Closed
forks are not permitted.
@@ -168,11 +190,13 @@ FrameMate name, logo and application identifiers (forks must use their own).
## Future
- [x] TLS Support, Cert pinning, QR pairing
- [ ] 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)
- [ ] View and Transfer Screenshots
- [ ] Mobile Notifications in VR
- [ ] Turn off controller, headset, etc.
## AI usage
@@ -184,6 +208,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
+54 -4
View File
@@ -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
@@ -17,16 +17,66 @@ 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.
- Port **7381** 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. (Port 7380 serves the same API unencrypted but listens only on the Frame itself, so it isn't what the app uses.)
- Check whether the frame answers: `curl -k https://frame.local:7381/healthz` should print `ok`. Pass `-k` because the agent signs its own certificate. The app checks it against the fingerprint from the pairing code instead of a certificate authority, so curl and browsers will warn about it.
- 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`.
I tried to keep this very loose, if you think your setup isnt that unsual and should not be blocked,
please also open an issue with your details, I can improve the check.
## 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 the token was rejected, pair again with `flatpak run --user dev.framemate.Agent pair`.
Reinstalling keeps the token and the encryption key. Both live in the app's config directory, which Flatpak leaves
alone, so an existing pairing survives updates and uninstalls.
If you want a clean slate uninstall run
```sh
flatpak uninstall --user --delete-data dev.framemate.Agent
```
To replace the token run `flatpak run --user dev.framemate.Agent rotate-token`
Needs a new `pair` afterwards.
If the app says the Frame isn't the one it was paired with, the agent's encryption key changed.
That happens if its config directory was wiped. Run `pair` and scan again.
### 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.
**Scan the pairing code straight away regardless.** Pairing doesn't need the agent, so the app shows the Frame as
offline and connects on its own once it is running.
If you already restarted and the QR-Code is gone run `flatpak run --user dev.framemate.Agent pair` to print it again.
To start the agent right away instead, 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
-208
View File
@@ -1,208 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "app",
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.65.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tauri-apps/cli": "^2",
"svelte": "^5.56.3",
"svelte-check": "^4.6.0",
"typescript": "~6.0.3",
"vite": "^8.0.16",
},
},
},
"packages": {
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
"@sveltejs/kit": ["@sveltejs/kit@2.70.3", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.3.1", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^1.0.0", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA=="],
"@tauri-apps/cli": ["@tauri-apps/cli@2.12.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.12.1", "@tauri-apps/cli-darwin-x64": "2.12.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.1", "@tauri-apps/cli-linux-arm64-gnu": "2.12.1", "@tauri-apps/cli-linux-arm64-musl": "2.12.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.12.1", "@tauri-apps/cli-linux-x64-gnu": "2.12.1", "@tauri-apps/cli-linux-x64-musl": "2.12.1", "@tauri-apps/cli-win32-arm64-msvc": "2.12.1", "@tauri-apps/cli-win32-ia32-msvc": "2.12.1", "@tauri-apps/cli-win32-x64-msvc": "2.12.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg=="],
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.12.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw=="],
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.12.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg=="],
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.12.1", "", { "os": "linux", "cpu": "arm" }, "sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ=="],
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.12.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw=="],
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.12.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg=="],
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.12.1", "", { "os": "linux", "cpu": "none" }, "sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ=="],
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.12.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA=="],
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.12.1", "", { "os": "linux", "cpu": "x64" }, "sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w=="],
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.12.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA=="],
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.12.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q=="],
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.12.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.4.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"svelte": ["svelte@5.57.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@jridgewell/sourcemap-codec": "^1.6.0", "@sveltejs/acorn-typescript": "^1.0.13", "@types/estree": "^1.0.9", "acorn": "^8.18.0", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.9.2", "esm-env": "^1.2.1", "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA=="],
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"vite": ["vite@8.3.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="],
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"nodeModulesDir": "auto"
}
Generated
+586
View File
@@ -0,0 +1,586 @@
{
"version": "5",
"specifiers": {
"npm:@sveltejs/adapter-static@^3.0.10": "3.0.10_@sveltejs+kit@2.70.3__@sveltejs+vite-plugin-svelte@7.3.1___svelte@5.57.1___vite@8.3.2__svelte@5.57.1__typescript@6.0.3__vite@8.3.2_@sveltejs+vite-plugin-svelte@7.3.1__svelte@5.57.1__vite@8.3.2_svelte@5.57.1_typescript@6.0.3_vite@8.3.2",
"npm:@sveltejs/kit@^2.65.1": "2.70.3_@sveltejs+vite-plugin-svelte@7.3.1__svelte@5.57.1__vite@8.3.2_svelte@5.57.1_typescript@6.0.3_vite@8.3.2",
"npm:@sveltejs/vite-plugin-svelte@^7.1.2": "7.3.1_svelte@5.57.1_vite@8.3.2",
"npm:@tauri-apps/api@2": "2.12.1",
"npm:@tauri-apps/cli@2": "2.12.1",
"npm:@tauri-apps/plugin-barcode-scanner@2": "2.5.1",
"npm:svelte-check@^4.6.0": "4.7.6_svelte@5.57.1_typescript@6.0.3",
"npm:svelte@^5.56.3": "5.57.1",
"npm:typescript@~6.0.3": "6.0.3",
"npm:vite@^8.0.16": "8.3.2"
},
"npm": {
"@jridgewell/gen-mapping@0.3.13": {
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dependencies": [
"@jridgewell/sourcemap-codec",
"@jridgewell/trace-mapping"
]
},
"@jridgewell/remapping@2.3.5": {
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dependencies": [
"@jridgewell/gen-mapping",
"@jridgewell/trace-mapping"
]
},
"@jridgewell/resolve-uri@3.1.2": {
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="
},
"@jridgewell/sourcemap-codec@1.6.0": {
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="
},
"@jridgewell/trace-mapping@0.3.31": {
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dependencies": [
"@jridgewell/resolve-uri",
"@jridgewell/sourcemap-codec"
]
},
"@oxc-project/types@0.152.0": {
"integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="
},
"@polka/url@1.0.0-next.29": {
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
},
"@rolldown/binding-android-arm-eabi@1.2.12": {
"integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
"os": ["android"],
"cpu": ["arm"]
},
"@rolldown/binding-android-arm64@1.2.12": {
"integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
"os": ["android"],
"cpu": ["arm64"]
},
"@rolldown/binding-darwin-arm64@1.2.12": {
"integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@rolldown/binding-darwin-x64@1.2.12": {
"integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@rolldown/binding-freebsd-x64@1.2.12": {
"integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@rolldown/binding-linux-arm-gnueabihf@1.2.12": {
"integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
"os": ["linux"],
"cpu": ["arm"]
},
"@rolldown/binding-linux-arm64-gnu@1.2.12": {
"integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rolldown/binding-linux-arm64-musl@1.2.12": {
"integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@rolldown/binding-linux-ppc64-gnu@1.2.12": {
"integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@rolldown/binding-linux-s390x-gnu@1.2.12": {
"integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@rolldown/binding-linux-x64-gnu@1.2.12": {
"integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rolldown/binding-linux-x64-musl@1.2.12": {
"integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
"os": ["linux"],
"cpu": ["x64"]
},
"@rolldown/binding-openharmony-arm64@1.2.12": {
"integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
"@rolldown/binding-win32-arm64-msvc@1.2.12": {
"integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@rolldown/binding-win32-x64-msvc@1.2.12": {
"integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
"os": ["win32"],
"cpu": ["x64"]
},
"@rolldown/pluginutils@1.0.1": {
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="
},
"@standard-schema/spec@1.1.0": {
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
"@sveltejs/acorn-typescript@1.0.13_acorn@8.18.0": {
"integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
"dependencies": [
"acorn"
]
},
"@sveltejs/adapter-static@3.0.10_@sveltejs+kit@2.70.3__@sveltejs+vite-plugin-svelte@7.3.1___svelte@5.57.1___vite@8.3.2__svelte@5.57.1__typescript@6.0.3__vite@8.3.2_@sveltejs+vite-plugin-svelte@7.3.1__svelte@5.57.1__vite@8.3.2_svelte@5.57.1_typescript@6.0.3_vite@8.3.2": {
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dependencies": [
"@sveltejs/kit"
]
},
"@sveltejs/kit@2.70.3_@sveltejs+vite-plugin-svelte@7.3.1__svelte@5.57.1__vite@8.3.2_svelte@5.57.1_typescript@6.0.3_vite@8.3.2": {
"integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==",
"dependencies": [
"@standard-schema/spec",
"@sveltejs/acorn-typescript",
"@sveltejs/vite-plugin-svelte",
"@types/cookie",
"acorn",
"cookie",
"devalue",
"esm-env",
"kleur",
"magic-string@0.30.21",
"mrmime",
"set-cookie-parser",
"sirv",
"svelte",
"typescript",
"vite"
],
"optionalPeers": [
"typescript"
],
"bin": true
},
"@sveltejs/load-config@0.2.3": {
"integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="
},
"@sveltejs/vite-plugin-svelte@7.3.1_svelte@5.57.1_vite@8.3.2": {
"integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==",
"dependencies": [
"deepmerge",
"magic-string@1.4.2",
"obug",
"svelte",
"vite",
"vitefu"
]
},
"@tauri-apps/api@2.12.1": {
"integrity": "sha512-DeyFHa3wynpyoqTDikDEDGTJIq4LQ5USfolQGRmGIWT6JMADyxZBTDa5cAdT3tDg73rUXufPaCwN7aXBos4OnQ=="
},
"@tauri-apps/cli-darwin-arm64@2.12.1": {
"integrity": "sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@tauri-apps/cli-darwin-x64@2.12.1": {
"integrity": "sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@tauri-apps/cli-linux-arm-gnueabihf@2.12.1": {
"integrity": "sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ==",
"os": ["linux"],
"cpu": ["arm"]
},
"@tauri-apps/cli-linux-arm64-gnu@2.12.1": {
"integrity": "sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@tauri-apps/cli-linux-arm64-musl@2.12.1": {
"integrity": "sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@tauri-apps/cli-linux-riscv64-gnu@2.12.1": {
"integrity": "sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@tauri-apps/cli-linux-x64-gnu@2.12.1": {
"integrity": "sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA==",
"os": ["linux"],
"cpu": ["x64"]
},
"@tauri-apps/cli-linux-x64-musl@2.12.1": {
"integrity": "sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w==",
"os": ["linux"],
"cpu": ["x64"]
},
"@tauri-apps/cli-win32-arm64-msvc@2.12.1": {
"integrity": "sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@tauri-apps/cli-win32-ia32-msvc@2.12.1": {
"integrity": "sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q==",
"os": ["win32"],
"cpu": ["ia32"]
},
"@tauri-apps/cli-win32-x64-msvc@2.12.1": {
"integrity": "sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg==",
"os": ["win32"],
"cpu": ["x64"]
},
"@tauri-apps/cli@2.12.1": {
"integrity": "sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg==",
"optionalDependencies": [
"@tauri-apps/cli-darwin-arm64",
"@tauri-apps/cli-darwin-x64",
"@tauri-apps/cli-linux-arm-gnueabihf",
"@tauri-apps/cli-linux-arm64-gnu",
"@tauri-apps/cli-linux-arm64-musl",
"@tauri-apps/cli-linux-riscv64-gnu",
"@tauri-apps/cli-linux-x64-gnu",
"@tauri-apps/cli-linux-x64-musl",
"@tauri-apps/cli-win32-arm64-msvc",
"@tauri-apps/cli-win32-ia32-msvc",
"@tauri-apps/cli-win32-x64-msvc"
],
"bin": true
},
"@tauri-apps/plugin-barcode-scanner@2.5.1": {
"integrity": "sha512-VepKHdSGAKqFcPIXS3Nd0GA5uqQ840cJnHKY1B1Tf1Am9y2RSCyTht4FQd/tMpbu7g9vAmk3HI+qvN71jiQIyA==",
"dependencies": [
"@tauri-apps/api"
]
},
"@types/cookie@0.6.0": {
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"@types/estree@1.0.9": {
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="
},
"acorn@8.18.0": {
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"bin": true
},
"aria-query@5.3.1": {
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="
},
"axobject-query@4.1.0": {
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
},
"chokidar@4.0.3": {
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dependencies": [
"readdirp"
]
},
"clsx@2.1.1": {
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
},
"cookie@0.6.0": {
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
},
"deepmerge@4.3.1": {
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
},
"detect-libc@2.1.2": {
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
},
"devalue@5.9.4": {
"integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="
},
"esm-env@1.2.2": {
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
},
"esrap@2.4.0": {
"integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==",
"dependencies": [
"@jridgewell/sourcemap-codec"
]
},
"fdir@6.5.0_picomatch@4.0.7": {
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dependencies": [
"picomatch"
],
"optionalPeers": [
"picomatch"
]
},
"fsevents@2.3.3": {
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"os": ["darwin"],
"scripts": true
},
"is-reference@3.0.3": {
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dependencies": [
"@types/estree"
]
},
"kleur@4.1.5": {
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="
},
"lightningcss-android-arm64@1.33.0": {
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"os": ["android"],
"cpu": ["arm64"]
},
"lightningcss-darwin-arm64@1.33.0": {
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"lightningcss-darwin-x64@1.33.0": {
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"os": ["darwin"],
"cpu": ["x64"]
},
"lightningcss-freebsd-x64@1.33.0": {
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"lightningcss-linux-arm-gnueabihf@1.33.0": {
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"os": ["linux"],
"cpu": ["arm"]
},
"lightningcss-linux-arm64-gnu@1.33.0": {
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"os": ["linux"],
"cpu": ["arm64"]
},
"lightningcss-linux-arm64-musl@1.33.0": {
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"os": ["linux"],
"cpu": ["arm64"]
},
"lightningcss-linux-x64-gnu@1.33.0": {
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"os": ["linux"],
"cpu": ["x64"]
},
"lightningcss-linux-x64-musl@1.33.0": {
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"os": ["linux"],
"cpu": ["x64"]
},
"lightningcss-win32-arm64-msvc@1.33.0": {
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"os": ["win32"],
"cpu": ["arm64"]
},
"lightningcss-win32-x64-msvc@1.33.0": {
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"os": ["win32"],
"cpu": ["x64"]
},
"lightningcss@1.33.0": {
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dependencies": [
"detect-libc"
],
"optionalDependencies": [
"lightningcss-android-arm64",
"lightningcss-darwin-arm64",
"lightningcss-darwin-x64",
"lightningcss-freebsd-x64",
"lightningcss-linux-arm-gnueabihf",
"lightningcss-linux-arm64-gnu",
"lightningcss-linux-arm64-musl",
"lightningcss-linux-x64-gnu",
"lightningcss-linux-x64-musl",
"lightningcss-win32-arm64-msvc",
"lightningcss-win32-x64-msvc"
]
},
"locate-character@3.0.0": {
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
},
"magic-string@0.30.21": {
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dependencies": [
"@jridgewell/sourcemap-codec"
]
},
"magic-string@1.4.2": {
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dependencies": [
"@jridgewell/sourcemap-codec"
]
},
"mri@1.2.0": {
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
},
"mrmime@2.0.1": {
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="
},
"nanoid@3.3.19": {
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"bin": true
},
"obug@2.2.1": {
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="
},
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"picomatch@4.0.7": {
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="
},
"postcss@8.5.28": {
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"dependencies": [
"nanoid",
"picocolors",
"source-map-js"
]
},
"readdirp@4.1.2": {
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="
},
"rolldown@1.2.12": {
"integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
"dependencies": [
"@oxc-project/types",
"@rolldown/pluginutils"
],
"optionalDependencies": [
"@rolldown/binding-android-arm-eabi",
"@rolldown/binding-android-arm64",
"@rolldown/binding-darwin-arm64",
"@rolldown/binding-darwin-x64",
"@rolldown/binding-freebsd-x64",
"@rolldown/binding-linux-arm-gnueabihf",
"@rolldown/binding-linux-arm64-gnu",
"@rolldown/binding-linux-arm64-musl",
"@rolldown/binding-linux-ppc64-gnu",
"@rolldown/binding-linux-s390x-gnu",
"@rolldown/binding-linux-x64-gnu",
"@rolldown/binding-linux-x64-musl",
"@rolldown/binding-openharmony-arm64",
"@rolldown/binding-win32-arm64-msvc",
"@rolldown/binding-win32-x64-msvc"
],
"bin": true
},
"sade@1.8.1": {
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dependencies": [
"mri"
]
},
"set-cookie-parser@3.1.2": {
"integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="
},
"sirv@3.0.2": {
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dependencies": [
"@polka/url",
"mrmime",
"totalist"
]
},
"source-map-js@1.2.2": {
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="
},
"svelte-check@4.7.6_svelte@5.57.1_typescript@6.0.3": {
"integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==",
"dependencies": [
"@jridgewell/trace-mapping",
"@sveltejs/load-config",
"chokidar",
"fdir",
"picocolors",
"sade",
"svelte",
"typescript"
],
"bin": true
},
"svelte@5.57.1": {
"integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==",
"dependencies": [
"@jridgewell/remapping",
"@jridgewell/sourcemap-codec",
"@sveltejs/acorn-typescript",
"@types/estree",
"acorn",
"aria-query",
"axobject-query",
"clsx",
"devalue",
"esm-env",
"esrap",
"is-reference",
"locate-character",
"magic-string@0.30.21",
"zimmerframe"
]
},
"tinyglobby@0.2.17": {
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dependencies": [
"fdir",
"picomatch"
]
},
"totalist@3.0.1": {
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
},
"typescript@6.0.3": {
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"bin": true
},
"vite@8.3.2": {
"integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==",
"dependencies": [
"lightningcss",
"picomatch",
"postcss",
"rolldown",
"tinyglobby"
],
"optionalDependencies": [
"fsevents"
],
"bin": true
},
"vitefu@1.1.3_vite@8.3.2": {
"integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
"dependencies": [
"vite"
],
"optionalPeers": [
"vite"
]
},
"zimmerframe@1.1.5": {
"integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="
}
},
"workspace": {
"packageJson": {
"dependencies": [
"npm:@sveltejs/adapter-static@^3.0.10",
"npm:@sveltejs/kit@^2.65.1",
"npm:@sveltejs/vite-plugin-svelte@^7.1.2",
"npm:@tauri-apps/api@2",
"npm:@tauri-apps/cli@2",
"npm:@tauri-apps/plugin-barcode-scanner@2",
"npm:svelte-check@^4.6.0",
"npm:svelte@^5.56.3",
"npm:typescript@~6.0.3",
"npm:vite@^8.0.16"
]
}
}
}
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "framemate-app",
"version": "0.1.2",
"version": "0.2.0",
"description": "",
"type": "module",
"scripts": {
@@ -22,5 +22,9 @@
"typescript": "~6.0.3",
"vite": "^8.0.16",
"@tauri-apps/cli": "^2"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-barcode-scanner": "^2"
}
}
+330 -4
View File
@@ -47,6 +47,45 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror 2.0.21",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -106,7 +145,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
"bit-vec 0.8.0",
]
[[package]]
@@ -115,6 +154,15 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -494,6 +542,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbus"
version = "0.9.12"
@@ -536,6 +590,20 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -823,10 +891,19 @@ dependencies = [
[[package]]
name = "framemate-app"
version = "0.1.2"
version = "0.2.0"
dependencies = [
"rcgen",
"ring",
"rustls",
"rustls-webpki",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-barcode-scanner",
"tokio",
"tokio-rustls",
]
[[package]]
@@ -1009,6 +1086,17 @@ dependencies = [
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -1655,6 +1743,12 @@ dependencies = [
"serde",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -1766,6 +1860,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1854,12 +1954,41 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2087,6 +2216,15 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2391,6 +2529,19 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rcgen"
version = "0.14.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
dependencies = [
"ring",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2493,6 +2644,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -2508,6 +2673,49 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustls"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -2903,6 +3111,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.8"
@@ -2955,6 +3169,17 @@ dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -3149,6 +3374,36 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-plugin"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-barcode-scanner"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85252806c040742d74fc511a6bf89ae70c9c362393320056c3374af338c82e51"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
]
[[package]]
name = "tauri-runtime"
version = "2.12.1"
@@ -3355,9 +3610,31 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -3583,6 +3860,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4028,6 +4311,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -4290,6 +4582,34 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"ring",
"rusticata-macros",
"thiserror 2.0.21",
"time",
]
[[package]]
name = "yasna"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec 0.9.1",
"time",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -4310,7 +4630,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -4331,9 +4651,15 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
+16 -1
View File
@@ -4,7 +4,7 @@
[package]
name = "framemate-app"
description = "FrameMate companion app"
version = "0.1.2"
version = "0.2.0"
license = "GPL-3.0-or-later"
edition = "2024"
@@ -17,7 +17,22 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
ring = "0.17.14"
rustls = { version = "0.23.45", default-features = false, features = ["ring", "std"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "macros", "time"] }
tokio-rustls = { version = "0.26.6", default-features = false, features = ["ring"] }
webpki = { package = "rustls-webpki", version = "0.103", default-features = false, features = ["alloc", "ring"] }
# Mobile-only: the scanner plugin has no desktop implementation.
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-barcode-scanner = "2"
[dev-dependencies]
rcgen = { version = "0.14.10", default-features = false, features = ["crypto", "ring"] }
tokio = { version = "1", features = ["time"] }
[profile.release]
strip = true
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/mobile-schema.json",
"identifier": "mobile",
"description": "Pairing by scanning the agent's QR code",
"platforms": ["android", "iOS"],
"windows": ["main"],
"permissions": [
"barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel",
"barcode-scanner:allow-check-permissions",
"barcode-scanner:allow-request-permissions"
]
}
@@ -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
}
}
+9
View File
@@ -16,6 +16,15 @@
@android.webkit.JavascriptInterface <methods>;
}
# ML Kit (QR pairing via tauri-plugin-barcode-scanner) creates its component registrars by
# reflection from manifest meta-data. Its own rule keeps only their names, and R8 full mode
# then drops the constructor and getComponents(): no SharedPrefManager etc. get registered,
# and the first camera frame crashes with an NPE in mlkit_vision_common.
-keep class * implements com.google.firebase.components.ComponentRegistrar {
<init>();
*;
}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
@@ -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)))
}
}
}
@@ -23,7 +23,7 @@ abstract class BuildTask : DefaultTask() {
@TaskAction
fun assemble() {
val executable = """bun""";
val executable = """deno""";
try {
runTauriCli(executable)
} catch (e: Exception) {
@@ -55,7 +55,7 @@ abstract class BuildTask : DefaultTask() {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("tauri", "android", "android-studio-script");
val args = listOf("task", "tauri", "android", "android-studio-script");
execOperations.exec {
workingDir(File(projectDir, rootDirRel))
+95 -1
View File
@@ -1,6 +1,100 @@
pub mod pairing;
pub mod proxy;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use serde::Serialize;
use tauri::{Manager, State};
use pairing::Pairing;
/// connection from frontend -> proxy -> agent, secret is for proxy so no other android app can connect
#[derive(Serialize)]
struct Connection {
port: u16,
secret: String,
token: String,
paired: bool,
}
struct App {
current: proxy::Current,
status: Arc<proxy::Status>,
proxy: Mutex<Option<proxy::Proxy>>,
store: PathBuf,
}
/// Takes a lock, ignoring poisoning: a panicked relay task must not leave the app permanently
/// unable to report its own connection details. Everything guarded here is plain data.
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|e| e.into_inner())
}
impl App {
fn connection(&self) -> Option<Connection> {
let proxy = lock(&self.proxy);
let proxy = proxy.as_ref()?;
let current = lock(&self.current);
Some(Connection {
port: proxy.port,
secret: proxy.secret.clone(),
token: current.as_ref().map(|t| t.token()).unwrap_or_default(),
paired: current.is_some(),
})
}
}
#[tauri::command]
fn connection(app: State<'_, App>) -> Option<Connection> {
app.connection()
}
/// Accept scanned pairing payload, or one typed in by hand.
#[tauri::command]
fn pair(payload: String, app: State<'_, App>) -> Result<Connection, String> {
let pairing = Pairing::parse(payload.trim())?;
// Built before it is stored, so a pairing that can't be used never reaches the disk.
let target = proxy::Target::new(pairing.clone())?;
pairing::store(&app.store, &pairing)?;
*lock(&app.current) = Some(target);
app.connection().ok_or_else(|| "the local proxy isn't running".to_owned())
}
/// A failure the user has to act on
#[tauri::command]
fn last_error(app: State<'_, App>) -> Option<String> {
app.status.take()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let builder = tauri::Builder::default();
#[cfg(mobile)]
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
builder
.invoke_handler(tauri::generate_handler![connection, pair, last_error])
.setup(|app| {
let store = app.path().app_config_dir()?.join("pairing.json");
let current: proxy::Current = Arc::new(Mutex::new(
pairing::load(&store).and_then(|p| proxy::Target::new(p).ok()),
));
let status = Arc::new(proxy::Status::default());
let state = App {
current: current.clone(),
status: status.clone(),
proxy: Mutex::new(None),
store,
};
app.manage(state);
// Bound before the WebView can ask for the port. Only the bind is awaited; the accept loop runs on its own task.
match tauri::async_runtime::block_on(proxy::spawn(current, status)) {
Ok(proxy) => *lock(&app.state::<App>().proxy) = Some(proxy),
Err(e) => eprintln!("framemate: local proxy failed to start: {e}"),
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+140
View File
@@ -0,0 +1,140 @@
//! The scanned pairing payload, persisted so the app reconnects without rescanning.
//!
//! `FM1 <host> <port> <token> <pin> [<ip>]` : see crates/agent/src/pair.rs.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
const TAG: &str = "FM1";
const ABSENT: &str = "-";
/// 128 bits in Crockford base32.
const PIN_LEN: usize = 26;
/// Must match the agent's token alphabet (crates/agent/src/config.rs).
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Pairing {
/// `<hostname>.local`, or `None` when the agent couldn't determine it.
pub host: Option<String>,
/// IPv4 fallback
pub ip: Option<String>,
pub port: u16,
pub token: String,
pub pin: String,
}
impl Pairing {
pub fn parse(payload: &str) -> Result<Self, String> {
let fields: Vec<&str> = payload.split_whitespace().collect();
let [tag, host, port, token, pin, rest @ ..] = fields.as_slice() else {
return Err("not a FrameMate pairing code".into());
};
if *tag != TAG {
// app is older version than agent (or the other way round)
return Err(format!("unknown pairing format {tag}; update the app or the agent"));
}
let pin = normalise_pin(pin)?;
let optional = |s: &str| (s != ABSENT).then(|| s.to_owned());
Ok(Self {
host: optional(host),
ip: rest.first().and_then(|ip| optional(ip)),
port: port.parse().map_err(|_| format!("bad port {port}"))?,
token: token.to_string(),
pin,
})
}
// try mDNS first then address
pub fn candidates(&self) -> Vec<String> {
self.host.iter().chain(self.ip.iter()).cloned().collect()
}
}
fn normalise_pin(pin: &str) -> Result<String, String> {
let pin: String = pin
.chars()
.map(|c| match c.to_ascii_uppercase() {
'O' => '0',
'I' | 'L' => '1',
c => c,
})
.collect();
if pin.len() != PIN_LEN || !pin.bytes().all(|b| ALPHABET.contains(&b)) {
return Err("pairing code is damaged; print a new one with `pair` and scan it".into());
}
Ok(pin)
}
pub fn load(path: &PathBuf) -> Option<Pairing> {
serde_json::from_slice(&std::fs::read(path).ok()?).ok()
}
pub fn store(path: &PathBuf, pairing: &Pairing) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let json = serde_json::to_vec(pairing).map_err(|e| e.to_string())?;
std::fs::write(path, json).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_what_the_agent_prints() {
let pin = "9F8Q3K2M7PWZ4X5TJH6NBCDRVA";
let p = Pairing::parse(&format!("FM1 frame.local 7381 ABCDE-FGHJK {pin} 192.168.1.50")).unwrap();
assert_eq!(p.host.as_deref(), Some("frame.local"));
assert_eq!(p.ip.as_deref(), Some("192.168.1.50"));
assert_eq!((p.port, p.token.as_str(), p.pin.as_str()), (7381, "ABCDE-FGHJK", pin));
assert_eq!(p.candidates(), ["frame.local", "192.168.1.50"]);
// No address: the field is absent, not empty.
let p = Pairing::parse(&format!("FM1 frame.local 7381 ABCDE-FGHJK {pin}")).unwrap();
assert_eq!(p.ip, None);
assert_eq!(p.candidates(), ["frame.local"]);
// No hostname either; only the address is usable.
let p = Pairing::parse(&format!("FM1 - 7381 ABCDE-FGHJK {pin} 192.168.1.50")).unwrap();
assert_eq!(p.host, None);
assert_eq!(p.candidates(), ["192.168.1.50"]);
}
#[test]
fn rejects_payloads_it_cannot_trust() {
let pin = "9F8Q3K2M7PWZ4X5TJH6NBCDRVA";
for bad in [
"",
"FM1 frame.local 7381",
&format!("FM2 frame.local 7381 ABCDE-FGHJK {pin}"),
"FM1 frame.local 7381 ABCDE-FGHJK TOOSHORT",
// U is not in the Crockford alphabet.
&format!("FM1 frame.local 7381 ABCDE-FGHJK {}", "U".repeat(26)),
&format!("FM1 frame.local notaport ABCDE-FGHJK {pin}"),
] {
assert!(Pairing::parse(bad).is_err(), "{bad:?} should be rejected");
}
}
#[test]
fn a_hand_typed_pin_is_read_leniently() {
let typed = "9f8q3k2m7pwz4x5tjh6nbcdrva";
let p = Pairing::parse(&format!("FM1 frame.local 7381 ABCDE-FGHJK {typed}")).unwrap();
assert_eq!(p.pin, "9F8Q3K2M7PWZ4X5TJH6NBCDRVA");
// O/I/L are the look-alikes Crockford folds away.
let p = Pairing::parse("FM1 frame.local 7381 ABCDE-FGHJK OIL23456789012345678901234").unwrap();
assert_eq!(&p.pin[..3], "011");
}
#[test]
fn survives_a_round_trip() {
let pin = "9F8Q3K2M7PWZ4X5TJH6NBCDRVA";
let p = Pairing::parse(&format!("FM1 frame.local 7381 ABCDE-FGHJK {pin} 192.168.1.50")).unwrap();
let path = std::env::temp_dir().join("framemate-pairing-test.json");
store(&path, &p).unwrap();
assert_eq!(load(&path).unwrap(), p);
let _ = std::fs::remove_file(&path);
}
}
+328
View File
@@ -0,0 +1,328 @@
//! Pinned TLS to the Frame, WebView can't pin so I just proxy it via loopback + secret
//!
//! Loopback isn't isolated between apps so we use a secret injected by rust on start to limit access to our frontend
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use crate::pairing::Pairing;
const HEADER_LIMIT: usize = 8192;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2);
const ACCEPT_BACKOFF: Duration = Duration::from_millis(100);
const HEAD_TIMEOUT: Duration = Duration::from_secs(10);
/// Crockford base32
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const HANDSHAKE_NAME: &str = "framemate-agent.invalid";
/// Set when a connection fails in a way the user has to act on
#[derive(Default)]
pub struct Status(Mutex<Option<String>>);
impl Status {
pub fn take(&self) -> Option<String> {
self.guard().take()
}
fn set(&self, message: impl Into<String>) {
*self.guard() = Some(message.into());
}
/// Poisoning is ignored: this is one `Option<String>`, and a panicked relay task must not
/// stop the app reporting anything ever again.
fn guard(&self) -> std::sync::MutexGuard<'_, Option<String>> {
self.0.lock().unwrap_or_else(|e| e.into_inner())
}
}
pub struct Proxy {
pub port: u16,
pub secret: String,
}
/// Separates a Frame that isn't answering, which the UI already reports as offline, from one
/// that answered but isn't the Frame we paired with — the only case the user must act on.
enum Failure {
Unreachable(String),
Rejected(String),
}
/// Proxy Target for the WebView, valid for the life of the process
pub struct Target {
pairing: Pairing,
connector: tokio_rustls::TlsConnector,
preferred: Arc<AtomicUsize>,
}
impl Target {
pub fn new(pairing: Pairing) -> Result<Self, String> {
let verifier = Arc::new(PinnedKey::new(pairing.pin.clone()));
let mut config = rustls::ClientConfig::builder_with_provider(verifier.provider.clone())
.with_safe_default_protocol_versions()
.map_err(|e| e.to_string())?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth();
// The agent only speaks HTTP/1.1; WebSockets over h2 would need RFC 8441.
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Self {
pairing,
connector: tokio_rustls::TlsConnector::from(Arc::new(config)),
preferred: Arc::new(AtomicUsize::new(0)),
})
}
pub fn token(&self) -> String {
self.pairing.token.clone()
}
}
pub type Current = Arc<Mutex<Option<Target>>>;
/// Binds loopback and serves until the process exits.
pub async fn spawn(current: Current, status: Arc<Status>) -> Result<Proxy, String> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let secret = random_secret()?;
let gate = format!("s={secret}");
tokio::spawn(async move {
loop {
let inbound = match listener.accept().await {
Ok((inbound, _)) => inbound,
// Don't spin at 100% on a persistent error such as EMFILE.
Err(e) => {
eprintln!("framemate: accept failed: {e}");
tokio::time::sleep(ACCEPT_BACKOFF).await;
continue;
}
};
let (current, status, gate) = (current.clone(), status.clone(), gate.clone());
tokio::spawn(async move {
// Cloned out of the lock so a slow connection doesn't hold up re-pairing.
let Some((pairing, connector, preferred)) = current
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.map(|t| (t.pairing.clone(), t.connector.clone(), t.preferred.clone()))
else {
return;
};
match relay(inbound, &connector, &pairing, &preferred, &gate).await {
Err(Failure::Rejected(e)) => status.set(e),
// Routine (asleep, off the network). Logged for diagnosis, not shown: the
// UI already says "offline", and the frontend retries every few seconds.
Err(Failure::Unreachable(e)) => eprintln!("framemate: {e}"),
Ok(()) => {}
}
});
}
});
Ok(Proxy { port, secret })
}
async fn relay(
mut inbound: TcpStream,
connector: &tokio_rustls::TlsConnector,
pairing: &Pairing,
preferred: &AtomicUsize,
gate: &str,
) -> Result<(), Failure> {
// Read only the request line, check the gate, then forward it verbatim
let mut head = Vec::with_capacity(256);
let mut byte = [0u8; 1];
let deadline = tokio::time::Instant::now() + HEAD_TIMEOUT;
while !head.ends_with(b"\r\n") {
match tokio::time::timeout_at(deadline, inbound.read(&mut byte)).await {
// Probe, hang-up, or a connection parked without ever sending a request line.
Err(_) | Ok(Ok(0)) | Ok(Err(_)) => return Ok(()),
Ok(Ok(_)) => head.push(byte[0]),
}
if head.len() > HEADER_LIMIT {
return Ok(());
}
}
if !String::from_utf8_lossy(&head).contains(gate) {
// Another app on the phone, ignore
return Ok(());
}
let mut upstream = connect(connector, pairing, preferred).await?;
upstream.write_all(&head).await.map_err(|e| Failure::Unreachable(e.to_string()))?;
let _ = tokio::io::copy_bidirectional(&mut inbound, &mut upstream).await;
Ok(())
}
// Tries the mDNS name and the address, starting with whichever worked last
async fn connect(
connector: &tokio_rustls::TlsConnector,
pairing: &Pairing,
preferred: &AtomicUsize,
) -> Result<tokio_rustls::client::TlsStream<TcpStream>, Failure> {
let candidates = pairing.candidates();
if candidates.is_empty() {
return Err(Failure::Rejected("the pairing code carries no address".into()));
}
let first = preferred.load(Ordering::Relaxed) % candidates.len();
let mut last = String::new();
for offset in 0..candidates.len() {
let index = (first + offset) % candidates.len();
let host = &candidates[index];
let tcp = match tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect((host.as_str(), pairing.port))).await
{
Ok(Ok(tcp)) => tcp,
Ok(Err(e)) => {
last = format!("{host}: {e}");
continue;
}
Err(_) => {
last = format!("{host}: no answer within {}s", CONNECT_TIMEOUT.as_secs());
continue;
}
};
let name = ServerName::try_from(HANDSHAKE_NAME).map_err(|e| Failure::Rejected(e.to_string()))?;
let Ok(handshake) = tokio::time::timeout(HANDSHAKE_TIMEOUT, connector.connect(name, tcp)).await else {
last = format!("{host}: TLS handshake stalled for {}s", HANDSHAKE_TIMEOUT.as_secs());
continue;
};
match handshake {
Ok(tls) => {
preferred.store(index, Ordering::Relaxed);
return Ok(tls);
}
// A certificate problem means something answered and it isn't ours; don't fall
// through to the next address, which would turn a pin mismatch into a vague
// timeout. A plain IO error (reset, EOF, sleeping Frame) is just unreachable.
Err(e) if rejected_us(&e) => return Err(Failure::Rejected(format!("{host}: {e}"))),
Err(e) => {
last = format!("{host}: {e}");
continue;
}
}
}
Err(Failure::Unreachable(last))
}
/// tokio-rustls wraps rustls errors in `io::Error`; everything else is transport trouble.
fn rejected_us(error: &std::io::Error) -> bool {
error
.get_ref()
.and_then(|inner| inner.downcast_ref::<rustls::Error>())
.is_some_and(|e| matches!(e, rustls::Error::InvalidCertificate(_) | rustls::Error::General(_)))
}
#[derive(Debug)]
struct PinnedKey {
pin: String,
provider: Arc<rustls::crypto::CryptoProvider>,
}
impl PinnedKey {
fn new(pin: String) -> Self {
Self { pin, provider: Arc::new(rustls::crypto::ring::default_provider()) }
}
}
impl ServerCertVerifier for PinnedKey {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
let cert = webpki::EndEntityCert::try_from(end_entity)
.map_err(|_| rustls::Error::General("unparseable certificate".into()))?;
if pin_of(cert.subject_public_key_info().as_ref()) == self.pin {
return Ok(ServerCertVerified::assertion());
}
Err(rustls::Error::General(
"this is not the Frame the app was paired with; scan the pairing code again".into(),
))
}
/// Required by the trait but unreachable: the agent is TLS 1.3 only (see tls.rs). Kept
/// delegating rather than stubbed, so it stays correct if that ever changes.
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.provider.signature_verification_algorithms.supported_schemes()
}
}
/// Must match `Identity::pin` in the agent: 128 bits of SHA-256(SPKI), base32
fn pin_of(spki: &[u8]) -> String {
let digest = ring::digest::digest(&ring::digest::SHA256, spki);
base32(&digest.as_ref()[..16])
}
fn base32(bytes: &[u8]) -> String {
let mut out = String::new();
let (mut acc, mut bits) = (0u32, 0u32);
for &byte in bytes {
acc = (acc << 8) | u32::from(byte);
bits += 8;
while bits >= 5 {
bits -= 5;
out.push(ALPHABET[((acc >> bits) & 31) as usize] as char);
}
}
if bits > 0 {
out.push(ALPHABET[((acc << (5 - bits)) & 31) as usize] as char);
}
out
}
fn random_secret() -> Result<String, String> {
use ring::rand::SecureRandom;
let mut bytes = [0u8; 16];
ring::rand::SystemRandom::new().fill(&mut bytes).map_err(|_| "no randomness available".to_owned())?;
Ok(base32(&bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pin_matches_the_agents_encoding() {
// Same vector as `pin_formula_is_stable` in crates/agent/src/tls.rs. The pin is derived
// twice, in two crates that share no code; this is what stops them drifting apart.
// 91 bytes is the real SPKI length for the keys rcgen generates.
assert_eq!(pin_of(&[0xab; 91]), "KB2BXCR99PG5ADYCFQZDNYKSPR");
assert_eq!(base32(&[0x00]), "00");
assert_eq!(base32(&[0xff, 0xff]), "ZZZG");
assert!(base32(&[0x5a; 16]).bytes().all(|b| ALPHABET.contains(&b)));
}
#[test]
fn secrets_differ_per_run() {
let (a, b) = (random_secret().unwrap(), random_secret().unwrap());
assert_eq!(a.len(), 26);
assert_ne!(a, b);
}
}
+3 -3
View File
@@ -1,12 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "FrameMate",
"version": "0.1.2",
"version": "0.2.0",
"identifier": "dev.framemate.app",
"build": {
"beforeDevCommand": "bun run dev",
"beforeDevCommand": "deno task dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run build",
"beforeBuildCommand": "deno task build",
"frontendDist": "../build"
},
"app": {
+50
View File
@@ -0,0 +1,50 @@
//! The app's real proxy against a real agent: the pin the agent printed must be the pin the
//! verifier accepts, within the time budget the UI allows.
//!
//! Skipped unless FRAMEMATE_PAIRING holds a `framemate-agent pair --text` payload for an agent
//! that is actually running:
//!
//! FRAMEMATE_PAIRING="$(framemate-agent pair --text | head -1)" cargo test --test live
use std::sync::{Arc, Mutex};
use std::time::Instant;
use framemate_app_lib::pairing::Pairing;
use framemate_app_lib::proxy;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
// Ignored by default so a plain `cargo test` reports it as ignored rather than as passing:
// it needs a running agent, and a skip that says `ok` would hide the only check that ties the
// agent's pin to the proxy's verifier. Run it with:
// FRAMEMATE_PAIRING="$(framemate-agent pair --text | head -1)" cargo test --test live -- --ignored
#[tokio::test]
#[ignore = "needs a running agent and FRAMEMATE_PAIRING"]
async fn reaches_the_real_agent_through_the_proxy() {
let payload = std::env::var("FRAMEMATE_PAIRING")
.expect("set FRAMEMATE_PAIRING to a `pair --text` payload for a running agent");
let pairing = Pairing::parse(payload.trim()).expect("payload must parse");
let token = pairing.token.clone();
let current = Arc::new(Mutex::new(Some(proxy::Target::new(pairing).unwrap())));
let status = Arc::new(proxy::Status::default());
let p = proxy::spawn(current, status.clone()).await.unwrap();
for attempt in 1..=2 {
let start = Instant::now();
let mut socket = TcpStream::connect(("127.0.0.1", p.port)).await.unwrap();
let request = format!(
"GET /api/state?token={token}&s={} HTTP/1.1\r\nHost: frame.local\r\nConnection: close\r\n\r\n",
p.secret
);
socket.write_all(request.as_bytes()).await.unwrap();
let mut body = String::new();
tokio::time::timeout(std::time::Duration::from_secs(8), socket.read_to_string(&mut body))
.await
.unwrap_or_else(|_| panic!("attempt {attempt} exceeded the UI's 8s budget"))
.unwrap();
println!("attempt {attempt}: {} bytes in {:?}", body.len(), start.elapsed());
assert!(body.contains("200 OK"), "attempt {attempt}: {body}");
assert!(body.contains("\"agent\""));
assert_eq!(status.take(), None);
}
}
+147
View File
@@ -0,0 +1,147 @@
// Needs the GTK/WebKit stack, since the crate under test is the Tauri lib. Verified
// out-of-tree against the same sources where that stack isn't installed.
use std::sync::{Arc, Mutex};
use framemate_app_lib::pairing::Pairing;
use framemate_app_lib::proxy;
use rcgen::PublicKeyData;
use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
const BODY: &[u8] = b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok";
fn base32(bytes: &[u8]) -> String {
const A: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
let (mut acc, mut bits, mut out) = (0u32, 0u32, String::new());
for &b in bytes {
acc = (acc << 8) | u32::from(b);
bits += 8;
while bits >= 5 {
bits -= 5;
out.push(A[((acc >> bits) & 31) as usize] as char);
}
}
if bits > 0 {
out.push(A[((acc << (5 - bits)) & 31) as usize] as char);
}
out
}
/// A stand-in for the agent: TLS, replies to one request, records what it received.
async fn agent() -> (u16, String, Arc<Mutex<Vec<String>>>) {
let issued = rcgen::generate_simple_self_signed(vec!["frame.local".to_string()]).unwrap();
let digest = ring::digest::digest(&ring::digest::SHA256, &issued.signing_key.subject_public_key_info());
let pin = base32(&digest.as_ref()[..16]);
let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(issued.signing_key.serialize_der()));
let mut config = rustls::ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(vec![issued.cert.der().clone()], key)
.unwrap();
config.alpn_protocols = vec![b"http/1.1".to_vec()];
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config));
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
let seen = Arc::new(Mutex::new(Vec::new()));
let recorded = seen.clone();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let (acceptor, recorded) = (acceptor.clone(), recorded.clone());
tokio::spawn(async move {
let Ok(mut tls) = acceptor.accept(stream).await else { return };
let mut buf = [0u8; 512];
if let Ok(n) = tls.read(&mut buf).await {
recorded.lock().unwrap().push(String::from_utf8_lossy(&buf[..n]).to_string());
}
let _ = tls.write_all(BODY).await;
let _ = tls.flush().await;
});
}
});
(port, pin, seen)
}
async fn request(port: u16, line: &str) -> String {
let mut socket = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
socket.write_all(format!("{line}\r\n\r\n").as_bytes()).await.unwrap();
let mut out = String::new();
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), socket.read_to_string(&mut out)).await;
out
}
fn pairing(port: u16, pin: &str) -> Pairing {
Pairing { host: Some("127.0.0.1".into()), ip: None, port, token: "ABCDE-FGHJK".into(), pin: pin.into() }
}
#[tokio::test]
async fn relays_when_the_pin_and_secret_match() {
let (agent_port, pin, seen) = agent().await;
let current = Arc::new(Mutex::new(Some(proxy::Target::new(pairing(agent_port, &pin)).unwrap())));
let status = Arc::new(proxy::Status::default());
let p = proxy::spawn(current, status.clone()).await.unwrap();
let got = request(p.port, &format!("GET /api/ws?token=ABCDE-FGHJK&s={} HTTP/1.1", p.secret)).await;
assert!(got.contains("200 OK"), "expected the agent's reply, got {got:?}");
assert_eq!(status.take(), None, "a good connection must not record an error");
// The request line reaches the agent verbatim, secret and all, the agent ignores `s`.
let forwarded = seen.lock().unwrap().clone();
assert!(forwarded[0].contains("token=ABCDE-FGHJK"), "token must survive: {forwarded:?}");
assert!(forwarded[0].contains(&format!("s={}", p.secret)));
}
#[tokio::test]
async fn refuses_a_request_without_the_secret() {
let (agent_port, pin, seen) = agent().await;
let current = Arc::new(Mutex::new(Some(proxy::Target::new(pairing(agent_port, &pin)).unwrap())));
let status = Arc::new(proxy::Status::default());
let p = proxy::spawn(current, status.clone()).await.unwrap();
let got = request(p.port, "GET /api/ws?token=ABCDE-FGHJK HTTP/1.1").await;
assert_eq!(got, "", "a request without the secret must get nothing");
assert!(seen.lock().unwrap().is_empty(), "it must never reach the agent");
// Not the user's problem, so it stays out of the UI.
assert_eq!(status.take(), None);
}
#[tokio::test]
async fn refuses_a_frame_with_the_wrong_pin() {
let (agent_port, _pin, seen) = agent().await;
let wrong = "0000000000000000000000000A";
let current = Arc::new(Mutex::new(Some(proxy::Target::new(pairing(agent_port, wrong)).unwrap())));
let status = Arc::new(proxy::Status::default());
let p = proxy::spawn(current, status.clone()).await.unwrap();
let got = request(p.port, &format!("GET /api/ws?token=ABCDE-FGHJK&s={} HTTP/1.1", p.secret)).await;
assert_eq!(got, "", "a pin mismatch must not relay anything");
assert!(seen.lock().unwrap().is_empty(), "the request must not reach the agent");
let error = status.take().expect("a pin mismatch must be surfaced to the user");
assert!(error.contains("paired with"), "unhelpful message: {error}");
}
#[tokio::test]
async fn moves_on_from_an_address_that_stalls_the_handshake() {
let (agent_port, pin, _seen) = agent().await;
// Same port on another loopback address: accepts TCP, then never says a word.
let staller = TcpListener::bind(("127.0.0.2", agent_port)).await.unwrap();
tokio::spawn(async move {
let mut parked = Vec::new();
while let Ok((stream, _)) = staller.accept().await {
parked.push(stream);
}
});
let pairing = Pairing { host: Some("127.0.0.2".into()), ip: Some("127.0.0.1".into()), ..pairing(agent_port, &pin) };
let current = Arc::new(Mutex::new(Some(proxy::Target::new(pairing).unwrap())));
let p = proxy::spawn(current, Arc::new(proxy::Status::default())).await.unwrap();
let mut socket = TcpStream::connect(("127.0.0.1", p.port)).await.unwrap();
let line = format!("GET /api/ws?token=ABCDE-FGHJK&s={} HTTP/1.1\r\n\r\n", p.secret);
socket.write_all(line.as_bytes()).await.unwrap();
let mut got = String::new();
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), socket.read_to_string(&mut got)).await;
assert!(got.contains("200 OK"), "expected the second address to answer, got {got:?}");
}
+7 -2
View File
@@ -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;
};
}
}
+109 -42
View File
@@ -1,57 +1,84 @@
// 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.
//
// The socket goes to the app's own loopback proxy (src-tauri/src/proxy.rs), which holds the
// pinned TLS connection to the Frame, a WebView can't pin a certificate itself. Hence
// `127.0.0.1` here and the `s=` secret on every request.
import { invoke } from "@tauri-apps/api/core";
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`. */
host: string;
/** Where the proxy listens, and the two secrets every request carries. */
export interface Connection {
port: number;
/** Gates the proxy, so other apps on the phone can't use it to reach the LAN. */
secret: string;
/** The agent's own token, from the pairing code. */
token: string;
paired: boolean;
}
/** `unauthorized`: the agent answered but rejected the token. */
export type ConnectionStatus = "unconfigured" | "connecting" | "connected" | "offline" | "unauthorized";
function loadSettings(): ConnectionSettings {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null");
if (saved?.host !== undefined && saved?.token !== undefined) return saved;
} catch {}
return { host: "frame.local", token: "" };
}
class Agent {
settings = $state<ConnectionSettings>(loadSettings());
connection = $state<Connection | null>(null);
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);
/** Something the user has to act on — above all, a Frame whose key no longer matches. */
error = $state<string | null>(null);
#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() !== "";
return this.connection?.paired ?? false;
}
/** `host:port` of the agent. */
get authority() {
const host = this.settings.host.trim();
return /:\d+$/.test(host) ? host : `${host}:${DEFAULT_PORT}`;
return this.connection ? `127.0.0.1:${this.connection.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())}`;
return `ws://${this.authority}${path}?${this.#query()}`;
}
save(settings: ConnectionSettings) {
this.settings = { host: settings.host.trim(), token: settings.token.trim() };
#query() {
const { token, secret } = this.connection!;
return `token=${encodeURIComponent(token)}&s=${encodeURIComponent(secret)}`;
}
/** Picks up an existing pairing and connects. Resolves before the socket opens. */
async start() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.settings));
} catch {}
this.connection = await invoke<Connection | null>("connection");
} catch {
// Not running under Tauri (plain `deno task dev` in a browser).
this.connection = null;
}
this.connect();
}
/** Takes a scanned or pasted pairing code; throws with a message worth showing. */
async pair(payload: string) {
this.connection = await invoke<Connection>("pair", { payload });
this.error = null;
this.state = null;
this.status = "connecting";
this.connect();
@@ -67,31 +94,65 @@ 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.error = null;
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();
// The proxy reports what the socket can't, e.g. the Frame's key not matching the pairing.
this.error = await invoke<string | null>("last_error").catch(() => null);
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())}`;
const response = await fetch(url, { signal: AbortSignal.timeout(RETRY_MS) });
const url = `http://${this.authority}/api/state?${this.#query()}`;
const response = await fetch(url, { signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) });
return response.status === 401 ? "unauthorized" : "offline";
} catch {
return "offline";
@@ -99,6 +160,7 @@ class Agent {
}
#close() {
clearTimeout(this.#watchdog);
clearTimeout(this.#retry);
this.#retry = undefined;
const socket = this.#socket;
@@ -108,3 +170,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 -3
View File
@@ -1,12 +1,10 @@
// Web links must not open inside the app's WebView (it would navigate the app away).
/** Opens `url` in the system browser: via the Android bridge, else a new browser tab/window. */
export function openExternal(url: string) {
if (window.FrameMateAndroid?.openUrl) window.FrameMateAndroid.openUrl(url);
else window.open(url, "_blank", "noopener");
}
/** Click handler for `<a href="https://…">`: keeps the href for semantics, opens externally. */
/** onclick for `<a href>`: keeps the href, opens externally. */
export function external(event: MouseEvent) {
event.preventDefault();
openExternal((event.currentTarget as HTMLAnchorElement).href);
-1
View File
@@ -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 |
+12
View File
@@ -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

+12
View File
@@ -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
View File
@@ -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 -3
View File
@@ -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 };
+2 -3
View File
@@ -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),
);
+39 -8
View File
@@ -20,17 +20,29 @@
];
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();
// Pairing lives on the Rust side now, so the connection details arrive asynchronously.
agent.start().then(() => {
if (!agent.configured) goto("/settings");
});
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 +52,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 +127,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 +136,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 -4
View File
@@ -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;
+5 -3
View File
@@ -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">
+23 -8
View File
@@ -7,24 +7,39 @@
<Section title="Installation">
<p>
This app needs a helper called framemate-agent, a small service running on the Frame. <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>
Open a terminal on the Frame — over SSH, or through the Desktop Mode Terminal — and run the
installer from the GitHub page. It installs the agent, starts it, and prints a QR code. <br>
<b>Check out the GitHub page for exact install instructions</b>
</p>
</Section>
<Section title="Token">
<Section title="Pairing">
<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.
Write it down, you will need it to connect the app with the headset.
Tap <b>Scan pairing code</b> in Settings and point the camera at the QR code the installer
printed. Print it again any time with <code>flatpak run --user dev.framemate.Agent pair</code>.
Pairing doesn't need the agent to be running, so it's always safe to scan right away: the app
shows the Frame as offline until the agent is up, then connects on its own.
The code carries the Frame's address, the access token and the fingerprint of the Frame's
encryption key, so one scan is all the setup there is.
</p>
<p>
The code is a secret: anyone who has it can read your headset's screen. Don't share a photo
of it. If the terminal is too narrow for the QR code, the same line of text is printed below
it and can be typed into Settings instead.
</p>
</Section>
<Section title="Connecting">
<p>
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.
Both headset and phone need to be on the same local network. The app reaches the Frame by its
.local name and falls back to the IP address from the pairing code, so a router that doesn't
forward mDNS is handled automatically.
</p>
<p>
Traffic between phone and Frame is encrypted and tied to the key fingerprint from the pairing
code. Updating or reinstalling the agent keeps that key, so pairing survives it. If the key
does change, because the agent's configuration was wiped, the app refuses to connect and
asks you to pair again rather than trusting a new key silently.
</p>
</Section>
+76 -20
View File
@@ -1,42 +1,98 @@
<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";
import { APP_VERSION, updates } from "$lib/updates.svelte";
let host = $state(agent.settings.host);
let token = $state(agent.settings.token);
let code = $state("");
let failure = $state<string | null>(null);
let cameraBlocked = $state(false);
function describe(error: unknown) {
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
const message = (error as { message?: unknown } | null)?.message;
return typeof message === "string" ? message : JSON.stringify(error);
}
const statusText = $derived(
{
unconfigured: "Enter host and token",
unconfigured: "Not paired yet",
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).",
connected: `Connected to ${agent.state?.agent.hostname ?? "the Frame"}`,
offline: "Can't reach the Frame. Is it awake and on the same network?",
unauthorized: "The Frame rejected the token. Pair again.",
}[agent.status],
);
function save(event: SubmitEvent) {
event.preventDefault();
agent.save({ host, token });
async function pair(payload: string) {
failure = null;
try {
await agent.pair(payload);
code = "";
} catch (error) {
failure = describe(error);
}
}
async function scanCode() {
failure = null;
cameraBlocked = false;
try {
const scanner = await import("@tauri-apps/plugin-barcode-scanner");
// The plugin's scan() refuses without the permission and never asks for it itself.
let permission = await scanner.checkPermissions();
if (permission !== "granted") permission = await scanner.requestPermissions();
if (permission !== "granted") {
// After a "don't ask again" Android answers "denied" without showing a dialog.
cameraBlocked = true;
return;
}
const result = await scanner.scan({ windowed: false, formats: [scanner.Format.QRCode] });
await pair(result.content);
} catch (error) {
const message = describe(error);
if (message !== "cancelled") failure = message;
}
}
</script>
<Section title="Connection">
<form onsubmit={save}>
<form onsubmit={event => { event.preventDefault(); pair(code); }}>
<button class="button" type="button" onclick={scanCode}>Scan pairing code</button>
<p class="hint">
Run <code>flatpak run --user dev.framemate.Agent pair</code> on the Frame and scan the QR code.
</p>
<label>
<span>Frame address</span>
<input bind:value={host} placeholder="frame.local or 192.168.x.x" autocapitalize="off" autocorrect="off" spellcheck="false" />
<span>Or enter the code it prints</span>
<input bind:value={code} placeholder="FM1 frame.local 7381 …" autocapitalize="characters" autocorrect="off" spellcheck="false" />
</label>
<label>
<span>Token</span>
<input bind:value={token} placeholder="XXXXX-XXXXX" autocapitalize="characters" autocorrect="off" spellcheck="false" />
</label>
<button class="button" type="submit">Save &amp; 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>
<button class="button secondary" type="submit" disabled={!code.trim()}>Pair</button>
{#if failure}
<p class="status offline">{failure}</p>
{/if}
{#if cameraBlocked}
<p class="status offline">
Scanning needs the camera. Allow <b>Camera</b> in the app's permissions, or enter the code by hand.
</p>
<button class="button secondary" type="button" onclick={() => window.FrameMateAndroid?.openAppSettings?.()}>
Open app settings
</button>
{/if}
{#if agent.error}
<p class="status offline">{agent.error}</p>
{/if}
{#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>
+18 -7
View File
@@ -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
View File
@@ -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/**"],
},
},
+6
View File
@@ -9,10 +9,16 @@ anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["ws"] }
futures-util = "0.3.34"
libc = "0.2.190"
qrcode = { version = "0.14.1", default-features = false }
rcgen = { version = "0.14.10", default-features = false, features = ["crypto", "ring"] }
# ring comes in via rustls/rcgen anyway; using it directly avoids a second SHA-256 in the binary.
ring = "0.17.14"
rustls = { version = "0.23.45", default-features = false, features = ["ring", "std"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
socket2 = "0.6.5"
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "net", "time", "sync", "io-util", "signal"] }
tokio-rustls = { version = "0.26.6", default-features = false, features = ["ring"] }
tokio-tungstenite = "0.30.0"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
+179
View File
@@ -0,0 +1,179 @@
//! 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};
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(crate::server::Peer(peer)): ConnectInfo<crate::server::Peer>,
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;
// compare_exchange, not swap: swapping on every rejection kept pushing the window forward,
// so a scanner faster than one request per interval silenced the log after the first line.
let last = LAST_LOG.load(Ordering::Relaxed);
if now.saturating_sub(last) >= LOG_INTERVAL_S
&& LAST_LOG.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed).is_ok()
{
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()
}
/// The address for the pairing payload, used by the app only when mDNS doesn't resolve.
///
/// IPv4 only, deliberately. The hostname is the primary route and the listener is dual-stack
/// (`listen` in server.rs), so an AAAA from mDNS is answered without the pairing code carrying
/// an IPv6 literal at all. Carrying one would mean either a link-local address, which needs a
/// zone index (`fe80::1%wlan0`) that means nothing on another host, or a global one, which can
/// rotate away under privacy extensions and leave the pairing stale. A LAN with no IPv4 at all
/// is rare enough to leave to typing the address in by hand.
///
/// Asks the routing table rather than scanning `getifaddrs`, so a `docker0` or VPN address
/// can't win over the one a phone would actually use. `connect` on UDP sends nothing.
pub fn lan_address() -> Option<IpAddr> {
let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("192.0.2.1:9").ok()?; // TEST-NET-1, never actually contacted
let ip = socket.local_addr().ok()?.ip();
dialable(ip).then_some(ip)
}
fn dialable(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => !v4.is_loopback() && !v4.is_link_local() && !v4.is_unspecified(),
IpAddr::V6(_) => false,
}
}
/// `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 skips_addresses_the_phone_cannot_dial() {
// IPv6 is never offered: the hostname plus a dual-stack listener covers it.
for ip in ["127.0.0.1", "169.254.1.1", "0.0.0.0", "::1", "fe80::1", "::",
"fd12:3456:789a::1", "2001:db8:1:2::abcd"] {
assert!(!dialable(ip.parse().unwrap()), "{ip} should not be offered for pairing");
}
for ip in ["192.168.178.130", "10.1.2.3", "172.20.0.5"] {
assert!(dialable(ip.parse().unwrap()), "{ip} should be offered for pairing");
}
}
#[test]
fn reads_interfaces() {
assert!(interface_networks().iter().any(|(ip, _)| ip.is_loopback()));
}
}
+7 -15
View File
@@ -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());
}
+18 -18
View File
@@ -1,23 +1,20 @@
//! `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};
use std::net::{Ipv4Addr, SocketAddr};
use std::path::Path;
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::config::{self, Config};
use crate::config::Config;
/// The service needs a moment after `RestartUnit` (`flatpak run` startup).
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"),
@@ -95,10 +92,20 @@ pub async fn run() -> anyhow::Result<()> {
warn(&format!("Mirroring unavailable, can't access {}", missing.join(", ")));
}
let ip = lan_ip().map(|ip| format!(" (or {ip})")).unwrap_or_default();
let tls = match config.listen_tls.ip() {
ip if ip.is_unspecified() => SocketAddr::new(Ipv4Addr::LOCALHOST.into(), config.listen_tls.port()),
_ => config.listen_tls,
};
// A TCP connect is enough: the realistic failure is the port being taken, not a bad handshake.
match tokio::net::TcpStream::connect(tls).await {
Ok(_) => ok(&format!("TLS port {} open for the app", config.listen_tls.port())),
Err(e) => warn(&format!("app can't connect: nothing on port {} ({e})", config.listen_tls.port())),
}
println!();
println!("Connect the app to: {}.local{ip}", config::hostname());
println!("Token: {}", config::format_token(&config.token));
// Deliberately no token here: install-service runs this, and TROUBLESHOOTING asks users to
// paste the output into public issues. `pair` is the one place credentials are printed.
println!("Pair the app: run `pair` and scan the QR code it prints.");
Ok(())
}
@@ -111,13 +118,6 @@ fn accessible(path: &Path) -> bool {
.is_ok_and(|p| unsafe { libc::access(p.as_ptr(), libc::R_OK | libc::W_OK) } == 0)
}
/// The address of the interface the default route uses. `connect` on UDP sends nothing.
fn lan_ip() -> Option<IpAddr> {
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("192.0.2.1:9").ok()?; // TEST-NET-1, never actually contacted
Some(socket.local_addr().ok()?.ip())
}
fn ok(msg: &str) {
println!(" [ok] {msg}");
}
+92 -21
View File
@@ -1,11 +1,10 @@
//! 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;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use anyhow::Context;
@@ -13,53 +12,68 @@ use crate::stream::StreamConfig;
pub struct Config {
pub listen: SocketAddr,
/// Base URL of Steam's CEF remote debugging HTTP endpoint.
/// Pinned TLS for the app (see tls.rs); the plaintext `listen` port stays for browsers.
pub listen_tls: SocketAddr,
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.
let listen = env_or("FRAMEMATE_LISTEN", "[::]:7380")
// Loopback by default: nothing on the network needs the cleartext port since the app
// moved to TLS, and `check` plus the installer only ever probe it locally. Set it to
// `[::]:7380` to expose the plain API on the LAN again (the token then travels in clear).
let listen = env_or("FRAMEMATE_LISTEN", "127.0.0.1:7380")
.parse()
.context("FRAMEMATE_LISTEN must be host:port")?;
let listen_tls = env_or("FRAMEMATE_LISTEN_TLS", "[::]:7381")
.parse()
.context("FRAMEMATE_LISTEN_TLS must be host:port")?;
let token = match std::env::var("FRAMEMATE_TOKEN") {
Ok(token) if !token.is_empty() => token,
_ => load_or_create_token()?,
};
let token = normalize_token(&token);
// format_token splits in the middle, so a non-ASCII token would panic later.
anyhow::ensure!(token.is_ascii(), "FRAMEMATE_TOKEN must be ASCII");
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,
listen_tls,
cdp_url: env_or("FRAMEMATE_CDP", "http://127.0.0.1:8080"),
token,
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())
}
fn config_dir() -> anyhow::Result<PathBuf> {
pub 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,21 +114,51 @@ 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) {
let token = normalize_token(token.trim());
if is_current_format(&token) {
return Ok(token);
match std::fs::read_to_string(&path) {
Ok(token) => {
let token = normalize_token(token.trim());
// An older format is replaced on purpose; see is_current_format.
if is_current_format(&token) {
return Ok(token);
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
// Minting a replacement here would hand `pair` a token the running agent rejects, and
// re-pairing would never fix it; only a restart would, with nothing to say so.
Err(e) => {
return Err(e).with_context(|| {
format!(
"reading {}. Delete it and restart the agent to get a new token \
(the app has to be paired again afterwards)",
path.display()
)
});
}
}
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.
let token: String = bytes.iter().map(|b| TOKEN_ALPHABET[(b % 32) as usize] as char).collect();
write_private(path, format_token(&token).as_bytes())?;
tracing::info!("generated a new API token");
Ok(token)
}
/// Writes `bytes` to a 0600 file in a 0700 directory, creating both.
pub fn write_private(path: &Path, bytes: &[u8]) -> anyhow::Result<()> {
let dir = path.parent().unwrap();
std::fs::create_dir_all(dir)?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
@@ -123,11 +167,28 @@ 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");
Ok(token)
std::io::Write::write_all(&mut file, bytes)?;
Ok(())
}
/// Crockford base32, no padding
pub fn base32(bytes: &[u8]) -> String {
let mut out = String::new();
let (mut acc, mut bits) = (0u32, 0u32);
for &byte in bytes {
acc = (acc << 8) | u32::from(byte);
bits += 8;
while bits >= 5 {
bits -= 5;
out.push(TOKEN_ALPHABET[((acc >> bits) & 31) as usize] as char);
}
}
if bits > 0 {
out.push(TOKEN_ALPHABET[((acc << (5 - bits)) & 31) as usize] as char);
}
out
}
pub fn hostname() -> String {
@@ -140,6 +201,16 @@ pub fn hostname() -> String {
mod tests {
use super::*;
#[test]
fn encodes_base32() {
assert_eq!(base32(&[]), "");
assert_eq!(base32(&[0x00]), "00");
assert_eq!(base32(&[0xff, 0xff]), "ZZZG");
// 16 bytes is the pin: 128 bits over 5-bit groups.
assert_eq!(base32(&[0xab; 16]).len(), 26);
assert!(base32(&[0x5a; 16]).bytes().all(|b| TOKEN_ALPHABET.contains(&b)));
}
#[test]
fn tokens_compare_leniently() {
assert_eq!(normalize_token("abcde-fghjk"), "ABCDEFGHJK");
-136
View File
@@ -1,136 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FrameMate</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<style>
:root { color-scheme: light dark; }
body { margin: 16px; font: 14px/1.45 ui-monospace, Menlo, Consolas, monospace; }
pre { margin: 0; white-space: pre-wrap; }
</style>
</head>
<body>
<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.
const token = new URLSearchParams(location.search).get("token") ?? "";
const out = document.getElementById("out");
document.getElementById("stream").href = `/stream?token=${encodeURIComponent(token)}`;
let last = null;
let link = "connecting";
const pct = v => (v == null ? "?" : `${Math.round(v * 100)}%`);
const bytes = n => {
if (n == null) return "?";
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (n >= 1000 && i < units.length - 1) { n /= 1000; i++; }
return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
};
const dur = s => {
if (s == null || s < 0) return "?";
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
return h ? `${h}h ${m}m` : m ? `${m}m ${Math.floor(s % 60)}s` : `${Math.floor(s)}s`;
};
const time = ms => (ms ? new Date(ms).toLocaleTimeString() : "never");
const pad = (s, n) => String(s ?? "").padEnd(n);
const HMD_ACTIVITY = { "-1": "unknown", 0: "idle", 1: "in use", 2: "in use (timeout)", 3: "standby", 4: "idle (timeout)" };
function render(s) {
const t = s.steam.topics, L = [];
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.
L.push("", "== Battery");
const b = t.battery, k = s.power?.battery;
if (b) {
// seconds_remaining is time-to-full while on AC.
L.push(`${pct(b.level)} ${b.ac_state === 2 ? `charging, full in ~${dur(b.seconds_remaining)}` : `on battery, ~${dur(b.seconds_remaining)} left`}`);
} else if (k) {
L.push(`${k.capacity_percent}% (raw gauge, Steam not connected) ${k.status}`);
}
if (k) L.push(`${Math.abs(k.power_w ?? 0).toFixed(1)} W ${k.temp_c?.toFixed(1)} °C health ${k.health} cycles ${k.cycle_count}`);
L.push("", "== VR devices");
for (const d of t.vr_devices ?? [])
L.push(`${pad(d.model, 14)} ${pad(pct(d.battery), 5)} ${d.charging ? "charging " : " "} ${d.connected ? "connected" : "not connected"}`);
const vr = t.vr_state;
if (vr) L.push(`Headset: ${HMD_ACTIVITY[vr.hmd_activity] ?? vr.hmd_activity} · VR app: ${vr.scene_app_name ?? vr.scene_appid ?? "none"}`);
L.push("", "== Now playing");
const running = t.running_apps ?? [];
L.push(running.length ? running.map(a => `${a.name} (${a.appid})`).join(", ") : "nothing");
const ev = t.last_app_event;
if (ev) L.push(`last event: ${ev.name ?? ev.appid} ${ev.running ? "started" : "stopped"} at ${time(ev.at_ms)}`);
L.push("", "== Downloads");
const o = t.download_overview;
if (o?.appid) {
L.push(`Now: ${o.name ?? o.appid}${o.is_workshop ? " (workshop)" : ""} ${o.state}${o.paused ? " (paused)" : ""}` +
` ${o.percent}% ${bytes(o.bytes_per_sec)}/s ETA ${dur(o.eta_sec)}`);
} else {
L.push("Now: idle");
}
const items = [...(t.downloads ?? [])].sort((a, b) => (a.completed - b.completed) || (a.queue_index - b.queue_index));
for (const i of items) {
const st = i.completed ? "done" : i.active ? "active" : i.paused ? "paused" : `queued #${i.queue_index}`;
L.push(`${pad(st, 10)} ${pad(i.name ?? i.appid, 40)} ${i.completed ? "" : i.percent != null ? `${i.percent}%` : ""}${i.error ? ` error: ${i.error}` : ""}`);
}
L.push("", "== Headset stream");
const st = s.stream;
L.push(st ? `${st.viewers} viewer(s) ${st.width}x${st.height} H.264 ${st.fps.toFixed(1)} fps (source ${st.source_fps.toFixed(0)})` +
` ${(st.kbit_per_sec / 1000).toFixed(1)} Mbit/s ${st.encode_ms.toFixed(1)} ms/frame CPU` : "off (no viewers)");
L.push("", "== Network");
const n = t.network;
if (n) {
L.push(`connected ${n.connected} internet ${n.internet} steam ${n.steam}`);
for (const i of n.interfaces) L.push(`${pad(i.name, 8)} rx ${bytes(i.rx_bytes_per_sec)}/s tx ${bytes(i.tx_bytes_per_sec)}/s`);
}
L.push("", "== Performance");
const so = s.steamos;
if (so.available) for (const [key, v] of Object.entries(so.properties)) L.push(`${pad(key, 26)} ${Array.isArray(v) ? v.join(" / ") : v}`);
else L.push(`steamos-manager: ${so.error ?? "not available"}`);
const p = t.perf;
if (p) L.push(`TDP limit ${p.tdp_limit_w ?? "off"} FPS limit ${p.fps_limit ?? "off"} battery temp ${p.battery_temp_c ?? "?"} °C`);
L.push("", "== System");
const si = t.system_info;
if (si) {
L.push(`${si.os} ${si.os_version} (${si.os_variant}) build ${si.os_build} kernel ${si.kernel}`);
L.push(`Steam ${si.steam_version} (${si.steam_build_date}) ${si.gpu} / ${si.gpu_driver} ${si.cpu_cores} cores ${si.ram_mb} MB`);
}
const u = t.user;
if (u) L.push(`User: ${u.persona_name ?? "?"} (${u.account_name ?? "?"})`);
const errors = Object.entries(s.steam.topic_errors);
if (errors.length) {
L.push("", "== Shim errors");
for (const [topic, msg] of errors) L.push(`${topic}: ${msg}`);
}
L.push("", `raw JSON: /api/state?token=…`);
out.textContent = L.join("\n");
}
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/api/ws?token=${encodeURIComponent(token)}`);
ws.onopen = () => { link = "live"; };
ws.onmessage = e => { last = JSON.parse(e.data); render(last); };
ws.onclose = () => {
link = "disconnected, retrying";
if (last) render(last); else out.textContent = "cannot connect (wrong token?), retrying…";
setTimeout(connect, 2000);
};
}
connect();
</script>
</body>
</html>
+6 -10
View File
@@ -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()))
+7 -16
View File
@@ -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 -3
View File
@@ -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).
+1 -3
View File
@@ -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,
+8 -1
View File
@@ -1,3 +1,4 @@
mod access;
mod cdp;
mod check;
mod config;
@@ -5,11 +6,13 @@ mod devices;
mod encoder;
mod fmp4;
mod hub;
mod pair;
mod power;
mod server;
mod service;
mod steamos;
mod stream;
mod tls;
mod v4l2;
use tracing_subscriber::EnvFilter;
@@ -22,8 +25,10 @@ Without a command, runs the agent.
commands:
install-service start the agent with the user session (systemd user unit)
uninstall-service remove that unit again
pair print the pairing QR code for the companion app
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 +37,8 @@ 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("pair") => return pair::run(std::env::args().nth(2).as_deref() == Some("--text")).await,
Some("token") => {
println!("{}", config::format_token(&config::load_or_create_token()?));
return Ok(());
+148
View File
@@ -0,0 +1,148 @@
//! `pair`: cert, local-domain, token and IP in a QR code.
//!
//! Works without a running agent by only reading the token and key off disk
use std::net::IpAddr;
use qrcode::{Color, EcLevel, QrCode};
use crate::config;
use crate::tls::Identity;
const TAG: &str = "FM1";
const ABSENT: &str = "-";
const QUIET: isize = 2;
pub async fn run(text_only: bool) -> anyhow::Result<()> {
// `pair --text | head -1` closes the pipe early; die quietly like other CLI tools instead of
// println! panicking on EPIPE. Sockets are unaffected (Rust sends with MSG_NOSIGNAL).
// SAFETY: restores the default disposition the runtime replaced with SIG_IGN.
unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) };
let config = config::Config::from_env()?;
let token = config::format_token(&config.token);
let identity = Identity::load_or_create()?;
let payload = payload(&host(), config.listen_tls.port(), &token, &identity.pin(), crate::access::lan_address());
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)?;
let needed = code.width() + 2 * QUIET as usize;
match () {
_ if text_only => {}
_ if needed > columns() => {
println!("Terminal is {} columns, the QR code needs {needed}. Enter this in the app:\n", columns());
}
_ => println!("{}", half_blocks(&code)),
}
println!(" {payload}\n");
warn_if_the_agent_disagrees(&config).await;
println!("Scan it in the app under Settings, or type the fields in by hand.");
println!("Treat it like a password: it contains the access token.");
Ok(())
}
/// A running agent serves the token and key it read at startup. If either file changed since then, this code is already wrong
async fn warn_if_the_agent_disagrees(config: &config::Config) {
let authority = match config.listen.ip() {
ip if ip.is_unspecified() => format!("127.0.0.1:{}", config.listen.port()),
ip => format!("{}", std::net::SocketAddr::new(ip, config.listen.port())),
};
// No answer at all means no agent running, which is normal right after install.
if crate::cdp::http_get(&authority, "/healthz").await.is_err() {
return;
}
if crate::cdp::http_get(&authority, &format!("/api/state?token={}", config.token)).await.is_err() {
println!("WARNING: the running agent does not accept the token above, so its files were");
println!("replaced while it was running. Restart the agent before pairing:");
println!(" systemctl --user restart framemate-agent.service\n");
}
}
/// `<hostname>.local`, which is how the app reaches the Frame when mDNS works.
fn host() -> String {
let hostname = config::hostname();
match hostname.as_str() {
"" => ABSENT.to_owned(),
h if h.contains('.') => h.to_owned(),
h => format!("{h}.local"),
}
}
fn payload(host: &str, port: u16, token: &str, pin: &str, ip: Option<IpAddr>) -> String {
let mut out = format!("{TAG} {host} {port} {token} {pin}");
// Last and optional, so IPv6 colons and a missing address are both harmless to parse.
if let Some(ip) = ip {
out.push(' ');
out.push_str(&ip.to_string());
}
out
}
/// Terminal width, or 80 when stdout isn't a terminal.
fn columns() -> usize {
let mut size: libc::winsize = unsafe { std::mem::zeroed() };
// SAFETY: TIOCGWINSZ writes one winsize through the pointer.
let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut size) } == 0;
match size.ws_col {
cols if ok && cols > 0 => usize::from(cols),
_ => 80,
}
}
/// Two QR rows per text line, making it compact by using half blocks
fn half_blocks(code: &QrCode) -> String {
let width = code.width();
let modules = code.to_colors();
let dark = |x: isize, y: isize| {
(0..width as isize).contains(&x)
&& (0..width as isize).contains(&y)
&& modules[y as usize * width + x as usize] == Color::Dark
};
let mut out = String::new();
for row in 0..(width as isize + 2 * QUIET + 1) / 2 {
for x in -QUIET..width as isize + QUIET {
let y = row * 2 - QUIET;
out.push(match (dark(x, y), dark(x, y + 1)) {
(true, true) => '█',
(true, false) => '▀',
(false, true) => '▄',
(false, false) => ' ',
});
}
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payload_is_positional() {
let pin = "9F8Q3K2M7PWZ4X5TJH6NBCDRVA";
let v4 = payload("frame.local", 7381, "ABCDE-FGHJK", pin, Some("192.168.1.50".parse().unwrap()));
assert_eq!(v4, format!("FM1 frame.local 7381 ABCDE-FGHJK {pin} 192.168.1.50"));
assert_eq!(v4.split(' ').count(), 6);
// IPv6 has no spaces, so it doesn't disturb the field split.
let v6 = payload("frame.local", 7381, "ABCDE-FGHJK", pin, Some("fd12:3456:789a::1".parse().unwrap()));
assert_eq!(v6.split(' ').nth(5), Some("fd12:3456:789a::1"));
assert_eq!(v6.split(' ').count(), 6);
// No address at all: the field is simply absent, earlier ones keep their positions.
let none = payload(ABSENT, 7381, "ABCDE-FGHJK", pin, None);
assert_eq!(none.split(' ').count(), 5);
assert_eq!(none.split(' ').nth(1), Some(ABSENT));
}
#[test]
fn fits_a_standard_terminal() {
let pin = "9F8Q3K2M7PWZ4X5TJH6NBCDRVA";
for ip in ["192.168.1.50", "fd12:3456:789a::1", "2001:db8:85a3::8a2e:370:7334"] {
let payload = payload("frame.local", 7381, "ABCDE-FGHJK", pin, Some(ip.parse().unwrap()));
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M).unwrap();
let render = half_blocks(&code);
let (cols, rows) = (render.lines().map(|l| l.chars().count()).max().unwrap(), render.lines().count());
assert!(cols <= 80 && rows <= 24, "{ip}: {cols}x{rows} doesn't fit 80x24");
}
}
}
+1 -2
View File
@@ -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};
+118 -30
View File
@@ -1,13 +1,11 @@
//! HTTP API and plain-text dashboard.
//! HTTP API for the app. Served twice: plaintext on `listen`, and pinned TLS on `listen_tls`
//! (see tls.rs). Same router both times, so every route and the token check are identical.
//!
//! - `GET /` dashboard page (token is read from `?token=` by the page itself)
//! - `GET /api/state` full state as JSON
//! - `GET /api/ws` full state as JSON on connect and after every change (throttled)
//! - `GET /stream` headset-view player page (token read from `?token=` by the page)
//! - `GET /api/stream/ws` headset view: JSON `{codec}`, fMP4 init segment, then one
//! moof+mdat per frame (see stream.rs, fmp4.rs)
//! - `GET /favicon.svg` logo from `assets/`, no auth
//! - `GET /healthz` liveness, no auth
//! - `GET /healthz` liveness, the only route without auth
//!
//! `/api/*` requires the token via `?token=` or `Authorization: Bearer`.
@@ -18,20 +16,27 @@ use std::time::Duration;
use anyhow::Context;
use axum::Router;
use axum::extract::connect_info::Connected;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{Html, IntoResponse, Response};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::serve::IncomingStream;
use socket2::{Domain, Protocol, Socket, Type};
use tokio_rustls::TlsAcceptor;
use crate::config::Config;
use crate::fmp4;
use crate::hub::Hub;
use crate::stream::LiveStream;
use crate::tls::Identity;
/// Coalesces bursts (download progress fires every second) into one push.
const PUSH_THROTTLE: Duration = Duration::from_millis(250);
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
/// Completed handshakes waiting for `axum::serve` to pick them up.
const HANDSHAKE_QUEUE: usize = 64;
#[derive(Clone)]
struct AppState {
@@ -42,35 +47,126 @@ struct AppState {
pub async fn serve(hub: Arc<Hub>, stream: Arc<LiveStream>, config: &Config) -> anyhow::Result<()> {
let app = Router::new()
.route("/", get(|| async { Html(include_str!("dashboard.html")) }))
.route("/api/state", get(state))
.route("/api/ws", get(ws))
.route("/stream", get(|| async { Html(include_str!("stream.html")) }))
.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)
.with_graceful_shutdown(shutdown_signal())
.await?;
let plain = bind(config.listen)?;
// Fatal rather than degrading to plaintext only: an agent the app silently can't reach is a
// worse support case than one that fails loudly with the address in the message.
let tls = TlsListener::spawn(
bind(config.listen_tls)?,
TlsAcceptor::from(Identity::load_or_create()?.server_config()?),
)?;
tracing::info!("listening on {} and {} (TLS)", config.listen, config.listen_tls);
// A prompt for whoever is sitting at the terminal; pointless in journald.
// SAFETY: isatty only inspects the descriptor.
if unsafe { libc::isatty(libc::STDOUT_FILENO) } == 1 {
println!("Run `framemate-agent pair` for the code to scan in the app.");
}
// Two signal registrations of the same kind; tokio delivers to all of them.
tokio::try_join!(
axum::serve(plain, app.clone().into_make_service_with_connect_info::<Peer>())
.with_graceful_shutdown(shutdown_signal()),
axum::serve(tls, app.into_make_service_with_connect_info::<Peer>())
.with_graceful_shutdown(shutdown_signal()),
)?;
Ok(())
}
/// Binds the listener, accepting IPv4 *and* IPv6 when given an IPv6 wildcard address.
/// Terminates TLS so `axum::serve` keeps handling graceful shutdown, `ConnectInfo` and the
///
/// `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.
/// Handshakes deliberately do *not* happen in `accept`: awaiting one there is serial, so a
/// client that connects and then sends nothing would block every later connection and take the
/// whole TLS port down. They run on their own tasks and queue up here instead.
struct TlsListener {
local: SocketAddr,
ready: tokio::sync::mpsc::Receiver<(tokio_rustls::server::TlsStream<tokio::net::TcpStream>, SocketAddr)>,
}
impl TlsListener {
fn spawn(mut tcp: tokio::net::TcpListener, acceptor: TlsAcceptor) -> anyhow::Result<Self> {
let local = tcp.local_addr()?;
let (tx, ready) = tokio::sync::mpsc::channel(HANDSHAKE_QUEUE);
tokio::spawn(async move {
loop {
// Delegating keeps axum's own policy for accept errors (it backs off on EMFILE).
let (stream, peer) = axum::serve::Listener::accept(&mut tcp).await;
let (acceptor, tx) = (acceptor.clone(), tx.clone());
// One task each, with no cap on how many run at once: capping them would mean
// queueing, and half-open connections would starve real ones all over again.
// What bounds this is the timeout above and the process's file descriptor limit.
tokio::spawn(async move {
match tokio::time::timeout(HANDSHAKE_TIMEOUT, acceptor.accept(stream)).await {
Ok(Ok(tls)) => {
let _ = tx.send((tls, peer)).await;
}
// A scanner, or plain HTTP to the TLS port. Drop it and keep serving.
Ok(Err(e)) => tracing::debug!("{peer}: TLS handshake failed: {e}"),
Err(_) => tracing::debug!("{peer}: TLS handshake timed out"),
}
});
}
});
Ok(Self { local, ready })
}
}
impl axum::serve::Listener for TlsListener {
type Io = tokio_rustls::server::TlsStream<tokio::net::TcpStream>;
type Addr = SocketAddr;
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
match self.ready.recv().await {
Some(ready) => ready,
// The accept task runs for the life of the process; only reachable if it panicked,
// and `accept` has no way to report that, so stop handing out connections.
None => std::future::pending().await,
}
}
fn local_addr(&self) -> std::io::Result<Self::Addr> {
Ok(self.local)
}
}
/// The peer address access.rs checks. Crate-local because axum ships `Connected` only for its
/// own `TcpListener`, and the orphan rule rejects an impl for `SocketAddr`: `TlsListener`
/// appears only as a nested parameter, which doesn't make the impl local.
#[derive(Clone, Copy)]
pub struct Peer(pub SocketAddr);
impl Connected<IncomingStream<'_, tokio::net::TcpListener>> for Peer {
fn connect_info(stream: IncomingStream<'_, tokio::net::TcpListener>) -> Self {
Self(*stream.remote_addr())
}
}
impl Connected<IncomingStream<'_, TlsListener>> for Peer {
fn connect_info(stream: IncomingStream<'_, TlsListener>) -> Self {
Self(*stream.remote_addr())
}
}
fn bind(addr: SocketAddr) -> anyhow::Result<tokio::net::TcpListener> {
match listen(addr) {
// IPv6 can be disabled (ipv6.disable=1); keep serving IPv4 then.
Err(e) if addr.is_ipv6() && addr.ip().is_unspecified() => {
tracing::warn!("{e:#}; falling back to IPv4 only");
listen(SocketAddr::from(([0, 0, 0, 0], addr.port())))
}
result => result,
}
}
/// 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")?;
@@ -85,13 +181,6 @@ fn listen(addr: SocketAddr) -> anyhow::Result<tokio::net::TcpListener> {
Ok(tokio::net::TcpListener::from_std(socket.into())?)
}
fn asset(content_type: &'static str, body: &'static [u8]) -> impl IntoResponse {
(
[(header::CONTENT_TYPE, content_type), (header::CACHE_CONTROL, "public, max-age=86400")],
body,
)
}
fn authorized(app: &AppState, headers: &HeaderMap, query: &HashMap<String, String>) -> bool {
let bearer = headers
.get(header::AUTHORIZATION)
@@ -107,8 +196,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();
+122 -25
View File
@@ -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\
@@ -37,37 +35,133 @@ pub async fn install() -> anyhow::Result<()> {
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(&path, unit).with_context(|| format!("writing {}", path.display()))?;
// Create the token now, so the starting service and a following `token` call can't race.
// Create the token and TLS key now, so the starting service and a following `token` or
// `pair` call can't race over generating them.
crate::config::load_or_create_token()?;
crate::tls::Identity::load_or_create()?;
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}; run `pair` and scan the new code 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 +172,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<()>
+9 -16
View File
@@ -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 -2
View File
@@ -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;
-84
View File
@@ -1,84 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FrameMate · headset view</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<style>
body { margin: 0; background: #000; color: #ccc; font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace; }
video { display: block; width: 100vw; height: 100vh; object-fit: contain; }
#status { position: fixed; left: 8px; bottom: 8px; opacity: .7; }
</style>
</head>
<body>
<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.)
const token = new URLSearchParams(location.search).get("token") ?? "";
const video = document.querySelector("video");
const status = document.getElementById("status");
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/api/stream/ws?token=${encodeURIComponent(token)}`);
ws.binaryType = "arraybuffer";
let buffer = null;
const queue = [];
const pump = () => {
if (buffer && !buffer.updating && queue.length) buffer.appendBuffer(queue.shift());
};
ws.onmessage = e => {
if (typeof e.data !== "string") {
queue.push(e.data);
pump();
return;
}
const { codec } = JSON.parse(e.data);
const type = `video/mp4; codecs="${codec}"`;
if (!MediaSource.isTypeSupported(type)) {
status.textContent = `this browser can't play ${type}`;
ws.onclose = null;
ws.close();
return;
}
const source = new MediaSource();
video.src = URL.createObjectURL(source);
source.addEventListener("sourceopen", () => {
buffer = source.addSourceBuffer(type);
buffer.mode = "sequence";
buffer.addEventListener("updateend", () => { keepLive(buffer); pump(); });
pump();
}, { once: true });
status.textContent = `live · ${codec}`;
};
ws.onclose = () => {
status.textContent = "disconnected, retrying…";
setTimeout(connect, 2000);
};
}
// 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).
function keepLive(buffer) {
if (buffer.updating || !buffer.buffered.length) return;
const start = buffer.buffered.start(0);
const end = buffer.buffered.end(buffer.buffered.length - 1);
const behind = end - video.currentTime;
if (video.currentTime < start || behind > 2) {
video.currentTime = Math.max(start, end - 0.2);
}
video.playbackRate = behind > 0.3 ? 1.1 : 1.0;
if (video.paused) video.play().catch(() => {});
if (video.currentTime - start > 10) buffer.remove(start, video.currentTime - 5);
}
connect();
</script>
</body>
</html>
+3 -11
View File
@@ -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,
+103
View File
@@ -0,0 +1,103 @@
//! The agent's TLS identity.
//!
//! The app pins `Identity::pin()`, a hash of the public key, and never checks the certificate's
//! name or validity. So only the key is kept on disk and the certificate is rebuilt at every
//! start: a cert/key mismatch in the config directory becomes impossible, and the pin stays
//! valid for as long as the key file does (including across `rotate-token`).
use std::sync::Arc;
use anyhow::Context;
use rcgen::PublicKeyData;
use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
/// 128 bits of SHA-256(SubjectPublicKeyInfo). Impersonating the agent needs a second preimage
/// rather than a collision, so this is ample, and it keeps the pairing QR one version smaller.
const PIN_BYTES: usize = 16;
/// Placeholder name for the handshake
const SAN: &str = "framemate-agent.invalid";
pub struct Identity(rcgen::KeyPair);
impl Identity {
/// `$XDG_CONFIG_HOME/framemate/key.der` (PKCS#8), created on first use.
pub fn load_or_create() -> anyhow::Result<Self> {
let path = crate::config::config_dir()?.join("key.der");
match std::fs::read(&path) {
Ok(der) => match rcgen::KeyPair::try_from(der) {
Ok(key) => return Ok(Self(key)),
// Replacing it costs a re-pairing; refusing to start costs everything.
Err(e) => tracing::warn!("{}: not a usable key ({e}), replacing it", path.display()),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
// Anything else (a permission problem, bad disk) would otherwise look like "no key"
// and overwrite one that is still perfectly good, silently unpairing every device.
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
}
let key = rcgen::KeyPair::generate().context("generating a TLS key")?;
crate::config::write_private(&path, &key.serialize_der())?;
tracing::info!("generated a new TLS key; the app needs to be paired again");
Ok(Self(key))
}
/// What the app pins, and what goes in the pairing QR.
pub fn pin(&self) -> String {
let spki = self.0.subject_public_key_info();
let digest = ring::digest::digest(&ring::digest::SHA256, &spki);
crate::config::base32(&digest.as_ref()[..PIN_BYTES])
}
pub fn server_config(&self) -> anyhow::Result<Arc<rustls::ServerConfig>> {
let cert = rcgen::CertificateParams::new(vec![SAN.to_owned()])
.context("building certificate parameters")?
.self_signed(&self.0)
.context("self-signing the certificate")?;
let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(self.0.serialize_der()));
let mut config = rustls::ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.context("selecting TLS versions")?
.with_no_client_auth()
.with_single_cert(vec![cert.der().clone()], key)
.context("loading the certificate")?;
// TLS 1.3 only: rustls is built without its `tls12` feature, so "safe defaults" above
// resolve to 1.3 alone. Both ends are ours, so there is nothing to stay compatible with.
// Never let h2 be negotiated either: WebSockets over h2 need extended CONNECT (RFC 8441),
// which axum doesn't implement, so /api/ws would break for any client offering it.
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Arc::new(config))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pin_follows_the_key() {
let (a, b) = (
Identity(rcgen::KeyPair::generate().unwrap()),
Identity(rcgen::KeyPair::generate().unwrap()),
);
assert_eq!(a.pin().len(), 26);
assert_ne!(a.pin(), b.pin());
// Stable across certificates, which are regenerated on every start.
assert_eq!(a.pin(), a.pin());
assert!(a.server_config().is_ok());
}
/// Mirrors `pin_matches_the_agents_encoding` in app/src-tauri/src/proxy.rs, which derives
/// the pin from its own copy of this formula. Both must agree or pairing silently breaks.
#[test]
fn pin_formula_is_stable() {
let digest = ring::digest::digest(&ring::digest::SHA256, &[0xab; 91]);
assert_eq!(crate::config::base32(&digest.as_ref()[..PIN_BYTES]), "KB2BXCR99PG5ADYCFQZDNYKSPR");
}
#[test]
fn key_survives_a_der_round_trip() {
let key = rcgen::KeyPair::generate().unwrap();
let reloaded = rcgen::KeyPair::try_from(key.serialize_der()).unwrap();
assert_eq!(Identity(key).pin(), Identity(reloaded).pin());
}
}
+5
View File
@@ -0,0 +1,5 @@
[toolchain]
channel = "stable"
profile = "minimal"
components = ["rustfmt", "clippy"]
targets = ["aarch64-unknown-linux-musl"]
+6 -3
View File
@@ -4,7 +4,8 @@
# (binary + metadata) and exported, so this works on an x86 desktop.
#
# scripts/flatpak.sh build target/flatpak/framemate-agent.flatpak
# scripts/flatpak.sh install ...then install it on the Frame and register the service
# scripts/flatpak.sh install ...then install it on the Frame like scripts/install.sh does
# (service + self check + pairing code), from this build
#
# Env: FRAME_HOST (default steamos@frame.local), FRAME_SSH_OPTS (see deploy.sh).
set -euo pipefail
@@ -54,8 +55,10 @@ echo "Built $BUNDLE"
ssh() { /usr/bin/ssh "${SSH_OPTS[@]}" "$HOST" "$@"; }
/usr/bin/scp -q "${SSH_OPTS[@]}" "$BUNDLE" "$HOST:/tmp/framemate-agent.flatpak"
# The dev unit from deploy.sh would hold port 7380.
# Same steps as scripts/install.sh. The dev unit from deploy.sh would hold the ports.
ssh "systemctl --user stop framemate-agent-dev 2>/dev/null; \
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo && \
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 && \
echo && flatpak run --user $APP_ID pair"
+85
View File
@@ -0,0 +1,85 @@
#!/bin/sh
# FrameMate agent installer for the Steam Frame. Installs the Flatpak, registers the user service and prints the pairing QR code for the app.
#
# curl -LsSf https://raw.githubusercontent.com/nailuj05/framemate/main/scripts/install.sh | sh
#
# Re-run it to update. To remove everything again:
#
# curl -LsSf https://raw.githubusercontent.com/nailuj05/framemate/main/scripts/install.sh | sh -s -- uninstall
#
# Same thing as running the commands from read me manually, just quicker
set -eu
APP_ID=dev.framemate.Agent
BUNDLE=framemate-agent.flatpak
RELEASE=https://github.com/nailuj05/framemate/releases/latest/download
say() { printf '%s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
agent() { flatpak run --user "$APP_ID" "$@"; }
uninstall() {
if flatpak info --user "$APP_ID" >/dev/null 2>&1; then
agent uninstall-service || say "Couldn't remove the service; continuing."
# No --delete-data: the token and TLS key stay, so reinstalling doesn't force a re-pair.
flatpak uninstall --user -y "$APP_ID"
say "Removed FrameMate."
else
say "FrameMate isn't installed for this user; nothing to do."
fi
}
# The agent lives in the user's Flatpak installation and a systemd *user* unit, so as root
# all of it would land in the wrong place.
[ "$(id -u)" -ne 0 ] || die "run this as your normal user, not as root."
command -v flatpak >/dev/null || die "flatpak is not installed."
case "${1:-install}" in
install) ;;
uninstall) uninstall; exit 0 ;;
*) die "unknown command '$1' (install, uninstall)" ;;
esac
arch=$(uname -m)
[ "$arch" = aarch64 ] || die "the agent runs on the Steam Frame (aarch64), not on $arch.
Run this in a terminal on the Frame via SSH, or use the Desktop Mode Konsole."
command -v curl >/dev/null || die "curl is not installed."
# A fresh Frame may not have the remote the Freedesktop runtime comes from.
flatpak remote-add --user --if-not-exists \
flathub https://dl.flathub.org/repo/flathub.flatpakrepo >/dev/null
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT INT TERM
say "Downloading the agent..."
curl -fL --progress-bar -o "$tmp/$BUNDLE" "$RELEASE/$BUNDLE" ||
die "download failed. Check the network, or grab $BUNDLE from the releases page by hand."
say ""
say "Installing. Pulling Freedesktop Runtime from Flathub the first time (about 270 MB), may take a while."
# --reinstall so re-running this script updates an existing install.
flatpak install --user -y --reinstall "$tmp/$BUNDLE"
say ""
# Register the unit
# Restart agent
# Runs the self check
# In Desktop Mode it cant reach systemd, says so, and still exits 0 with the unit enabled for the next boot.
agent install-service
say ""
agent pair
# install-service exits 0 whether or not the agent actually came up, so ask the agent itself.
# The pairing code above stays valid either way; only connecting has to wait.
if ! curl -fsS --max-time 5 -o /dev/null "http://127.0.0.1:7380/healthz" 2>/dev/null; then
say ""
say "NOTE: the agent isn't running yet, which is normal in Desktop Mode installs."
say "Scan the code above now anyway: The app will remember the fingerprint and connect to your headset once it's back up."
fi
+26
View File
@@ -0,0 +1,26 @@
{ pkgs ? import <nixpkgs> { } }:
pkgs.mkShell {
nativeBuildInputs = with pkgs; [
# Toolchain and target come from rust-toolchain.toml; nixpkgs' rustc has host std only.
rustup
# nix cc-wrapper injects host glibc headers and breaks --target builds.
llvmPackages.clang-unwrapped
pkg-config
deno
flatpak
shellcheck
];
# Tauri desktop building for verification
buildInputs = with pkgs; [
glib
gtk3
libsoup_3
webkitgtk_4_1
openssl
];
CC_aarch64_unknown_linux_musl = "clang";
}