3 Commits
Author SHA1 Message Date
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
41 changed files with 2973 additions and 623 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
+437 -9
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"
@@ -333,10 +428,15 @@ dependencies = [
"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"
+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.
+54 -38
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,61 +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 via SSH (recommended), or Desktop
Mode → Konsole ([see here](TROUBLESHOOTING.md#installing-from-desktop-mode)), 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 --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.
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.
- `flatpak run --user dev.framemate.Agent check` repeats the self check (useful when the app can't
connect); `flatpak run --user dev.framemate.Agent token` prints just the token, and
`flatpak run --user dev.framemate.Agent rotate-token` replaces it with a new one.
start it immediately. Either way it prints the pairing code.
- `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 commands
again. To **remove** it:
To **remove** it:
```sh
flatpak run --user 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. The agent also rejects
connections from public addresses outside your network (relevant for IPv6, where the Frame is globally addressable)
- **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.
@@ -117,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
@@ -131,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`).
@@ -145,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>`.
@@ -157,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.
@@ -174,8 +190,8 @@ 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)
+35 -11
View File
@@ -17,8 +17,8 @@ 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/healthz 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.
@@ -26,26 +26,50 @@ Make sure of the following:
### "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
The agent rejects devices it doesn't consider part of your local network.
That can hit unusual setups: a phone on a VPN, a guest or mesh Wi-Fi with its own subnet, or a router handing out
IPv6 addresses from several prefixes. The agent's log names the rejected address
(`journalctl --user -n 50 _COMM=framemate-agent`). To turn the check off, add
`Environment=FRAMEMATE_ALLOW_REMOTE=1` under `[Service]` in
`~/.config/systemd/user/framemate-agent.service`, then run
`systemctl --user daemon-reload && systemctl --user restart framemate-agent`. Please also open
an issue with your setup, so the check can be improved.
`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 --user dev.framemate.Agent token`. Note that reinstalling keeps the token.
You may generate a new token with `flatpak run --user dev.framemate.Agent rotate-token` and enter the it in the app.
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. To start it right away, run the command it prints:
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 \
-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"
]
}
}
}
+4
View File
@@ -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"
}
}
+329 -3
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"
@@ -825,8 +893,17 @@ dependencies = [
name = "framemate-app"
version = "0.1.5"
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"
+15
View File
@@ -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"
]
}
@@ -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);
}
}
+329
View File
@@ -0,0 +1,329 @@
//! 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;
/// Enough for a request line plus the WebSocket headers.
const HEADER_LIMIT: usize = 8192;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
/// Pause after a failed accept, so a persistent error (EMFILE) cannot become a busy loop.
const ACCEPT_BACKOFF: Duration = Duration::from_millis(100);
/// A client that opens the loopback socket and then says nothing is dropped after this.
const HEAD_TIMEOUT: Duration = Duration::from_secs(10);
/// Crockford base32
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// Presented in the handshake and never checked: the pin is the whole trust decision.
/// Deliberately not the Frame's real address: rustls omits SNI for IP literals, so the real
/// host would make the handshake differ by candidate, and the ClientHello is plaintext on the wire. Matches `SAN` in crates/agent/src/tls.rs
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()))?;
match connector.connect(name, tcp).await {
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);
}
}
+2 -2
View File
@@ -4,9 +4,9 @@
"version": "0.1.5",
"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);
}
}
+124
View File
@@ -0,0 +1,124 @@
// 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}");
}
+43 -26
View File
@@ -1,40 +1,43 @@
// 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;
@@ -45,26 +48,37 @@ class Agent {
}
get configured() {
return this.settings.host.trim() !== "" && this.settings.token.trim() !== "";
return this.connection?.paired ?? false;
}
get authority() {
const host = this.settings.host.trim();
if (host.startsWith("[")) return /\]:\d+$/.test(host) ? host : `${host}:${DEFAULT_PORT}`;
// A bare IPv6 address needs brackets before a port can follow.
if ((host.match(/:/g) ?? []).length > 1) return `[${host}]:${DEFAULT_PORT}`;
return /:\d+$/.test(host) ? host : `${host}:${DEFAULT_PORT}`;
return this.connection ? `127.0.0.1:${this.connection.port}` : "";
}
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();
@@ -86,6 +100,7 @@ class Agent {
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);
};
@@ -123,6 +138,8 @@ class Agent {
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(() => {
@@ -134,7 +151,7 @@ class Agent {
/** 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 url = `http://${this.authority}/api/state?${this.#query()}`;
const response = await fetch(url, { signal: AbortSignal.timeout(RETRY_MS) });
return response.status === 401 ? "unauthorized" : "offline";
} catch {
+4 -2
View File
@@ -27,9 +27,11 @@
);
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");
// 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 = () => {
+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 --user dev.framemate.Agent token</code> on the headset with framemate-agent installed.
Write it down, you will need it to connect the app with the headset.
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>
+39 -20
View File
@@ -5,39 +5,58 @@
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);
const statusText = $derived(
{
unconfigured: "Enter host and token",
unconfigured: "Not paired yet",
connecting: "Connecting…",
connected: `Connected to ${agent.state?.agent.hostname ?? agent.authority}`,
offline: agent.settings.host.includes(".local")
? `Can't reach ${agent.authority}. Try the Frame's IP address instead.`
: `Can't reach ${agent.authority}`,
unauthorized: "Wrong token. Check it on the Frame (see below).",
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 = String(error);
}
}
async function scanCode() {
failure = null;
try {
const { scan, Format } = await import("@tauri-apps/plugin-barcode-scanner");
const result = await scan({ windowed: false, formats: [Format.QRCode] });
await pair(result.content);
} catch (error) {
failure = String(error);
}
}
</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 --user dev.framemate.Agent token</code>.</p>
<button class="button secondary" type="submit" disabled={!code.trim()}>Pair</button>
{#if failure}
<p class="status offline">{failure}</p>
{/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.
+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"] }
+45 -3
View File
@@ -4,7 +4,7 @@
//! subnet as one of the Frame's interfaces (LAN devices with global IPv6 addresses).
//! `FRAMEMATE_ALLOW_REMOTE=1` turns the check off. The token stays the actual protection.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::atomic::{AtomicU64, Ordering};
use axum::extract::{ConnectInfo, Request, State};
@@ -17,7 +17,7 @@ const LOG_INTERVAL_S: u64 = 10;
pub async fn local_only(
State(allow_remote): State<bool>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
ConnectInfo(crate::server::Peer(peer)): ConnectInfo<crate::server::Peer>,
request: Request,
next: Next,
) -> Response {
@@ -27,12 +27,42 @@ pub async fn local_only(
}
static LAST_LOG: AtomicU64 = AtomicU64::new(0);
let now = crate::hub::now_ms() / 1000;
if now.saturating_sub(LAST_LOG.swap(now, Ordering::Relaxed)) >= LOG_INTERVAL_S {
// 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 {
@@ -130,6 +160,18 @@ mod tests {
}
}
#[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()));
+15 -12
View File
@@ -2,13 +2,13 @@
//! `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);
@@ -92,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(())
}
@@ -108,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}");
}
+70 -11
View File
@@ -4,7 +4,7 @@
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;
@@ -12,6 +12,8 @@ use crate::stream::StreamConfig;
pub struct Config {
pub listen: SocketAddr,
/// 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,
@@ -22,21 +24,29 @@ pub struct Config {
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
// Dual-stack (see server.rs), so IPv4 clients work too.
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(),
@@ -60,7 +70,7 @@ 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")
}
@@ -107,10 +117,25 @@ fn is_current_format(token: &str) -> bool {
/// `$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)
@@ -127,6 +152,13 @@ fn write_new_token(path: &std::path::Path) -> anyhow::Result<String> {
// 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))?;
@@ -137,9 +169,26 @@ fn write_new_token(path: &std::path::Path) -> anyhow::Result<String> {
.mode(0o600)
.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 {
@@ -152,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 unstyled.
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)}`);
// Steam's percentage (≈ kernel / 0.9, matches the headset UI); kernel adds W/°C and is the
// fallback without Steam.
L.push("", "== Battery");
const b = t.battery, k = s.power?.battery;
if (b) {
// 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>
+4
View File
@@ -6,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;
@@ -23,6 +25,7 @@ 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
rotate-token replace the API token (and restart the agent to use it)";
@@ -35,6 +38,7 @@ async fn main() -> anyhow::Result<()> {
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(());
+150
View File
@@ -0,0 +1,150 @@
//! `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;
/// Format marker. The app is side-loaded, so its version drifts from the agent's freely; this
/// lets a mismatch be reported instead of misparsed.
const TAG: &str = "FM1";
/// Placeholder for a field the agent couldn't determine, so parsing stays positional.
const ABSENT: &str = "-";
/// Modules of quiet zone. The spec asks for 4; 2 scans fine and saves four terminal lines.
const QUIET: isize = 2;
pub async fn run(text_only: bool) -> anyhow::Result<()> {
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");
}
}
}
+114 -33
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,12 +47,9 @@ 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 {
@@ -56,29 +58,115 @@ pub async fn serve(hub: Arc<Hub>, stream: Arc<LiveStream>, config: &Config) -> a
stream,
});
let listener = match listen(config.listen) {
// IPv6 can be disabled (ipv6.disable=1); keep serving IPv4 then.
Err(e) if config.listen.is_ipv6() && config.listen.ip().is_unspecified() => {
tracing::warn!("{e:#}; falling back to IPv4 only");
listen(SocketAddr::from(([0, 0, 0, 0], config.listen.port())))?
}
result => result?,
};
tracing::info!("listening on {}", config.listen);
// The token never goes to the log (people paste logs into issues); only to a terminal.
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 {
let token = crate::config::format_token(&config.token);
println!("Dashboard: http://localhost:{}/?token={token}", config.listen.port());
println!("Run `framemate-agent pair` for the code to scan in the app.");
}
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(shutdown_signal())
.await?;
// 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(())
}
/// 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.
/// Terminates TLS so `axum::serve` keeps handling graceful shutdown, `ConnectInfo` and the
///
/// 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")?;
@@ -93,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)
+3 -1
View File
@@ -35,8 +35,10 @@ 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 = match Systemd::reachable().await {
Ok(systemd) => systemd,
-82
View File
@@ -1,82 +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>
// /api/stream/ws: JSON {codec}, fMP4 init segment, then moof+mdat per frame → MSE.
// (WebCodecs needs a secure context, which http://frame.local isn't.)
const token = new URLSearchParams(location.search).get("token") ?? "";
const video = document.querySelector("video");
const status = document.getElementById("status");
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);
};
}
// Catch up by playing faster; seeking restarts decoding at the last keyframe (~3 fps if
// done every frame), so only seek when far behind.
function keepLive(buffer) {
if (buffer.updating || !buffer.buffered.length) return;
const start = buffer.buffered.start(0);
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>
+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"]
+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";
}