mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 13:00:33 +01:00
Compare commits
873
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
087bb1ef9f | ||
|
|
0051ac4570 | ||
|
|
37e25e62ac | ||
|
|
d1e526cb4b | ||
|
|
8230fdb19d | ||
|
|
2937a12864 | ||
|
|
71fe4a3ebb | ||
|
|
42f3b2e1e7 | ||
|
|
ca4c370b33 | ||
|
|
c50f96510f | ||
|
|
43e801091a | ||
|
|
b4e632b218 | ||
|
|
d84491c0ec | ||
|
|
83b8d13846 | ||
|
|
340ca88359 | ||
|
|
e62589417b | ||
|
|
03b3796d8d | ||
|
|
93da4e466f | ||
|
|
05093ea4d4 | ||
|
|
232b253e7a | ||
|
|
de8abe32c6 | ||
|
|
42492e7879 | ||
|
|
f50139fe6a | ||
|
|
698a643eb7 | ||
|
|
d0cef31656 | ||
|
|
4ac59f1a44 | ||
|
|
c91cfff797 | ||
|
|
d5e62f643d | ||
|
|
f3f574666f | ||
|
|
40ece4fef0 | ||
|
|
f52c020310 | ||
|
|
efe0c508f4 | ||
|
|
1af2bb45d9 | ||
|
|
daee952873 | ||
|
|
98909cb543 | ||
|
|
1903153d7b | ||
|
|
7172be17fc | ||
|
|
27af46dd20 | ||
|
|
761e952d8f | ||
|
|
0237e342d2 | ||
|
|
33d85dff1c | ||
|
|
ad57662d40 | ||
|
|
5381667947 | ||
|
|
7cdff4285a | ||
|
|
fe1da43171 | ||
|
|
1c1d941a1c | ||
|
|
c2f94c242d | ||
|
|
e6e42a93f0 | ||
|
|
10b196beaa | ||
|
|
3f74ff4bb0 | ||
|
|
f2f7a3f4c1 | ||
|
|
5c4c447d2d | ||
|
|
63f1f7fb39 | ||
|
|
c88736c6ce | ||
|
|
fa354ce365 | ||
|
|
fcdd1f22f5 | ||
|
|
d43e760aef | ||
|
|
a3e0ec01d4 | ||
|
|
240288df65 | ||
|
|
a68c5584a9 | ||
|
|
b5d50a8d89 | ||
|
|
0c9bed1254 | ||
|
|
741735aa98 | ||
|
|
4133ea1c7d | ||
|
|
3aa510a0e0 | ||
|
|
637813024f | ||
|
|
c87e5c6e8a | ||
|
|
c9777851c9 | ||
|
|
af03081f27 | ||
|
|
ef99d4953b | ||
|
|
17a7b0b80e | ||
|
|
c38e7e8da4 | ||
|
|
fc00878a23 | ||
|
|
657b4449f7 | ||
|
|
30a65facde | ||
|
|
9135f355f4 | ||
|
|
784118b71e | ||
|
|
e0115f356d | ||
|
|
56bde24be1 | ||
|
|
59c270a7ad | ||
|
|
a4d8073e31 | ||
|
|
c0275b483a | ||
|
|
ff49b44350 | ||
|
|
7e6fb7e342 | ||
|
|
57c1c4564b | ||
|
|
9aa26a8322 | ||
|
|
38c388f3f3 | ||
|
|
27e3c42ed0 | ||
|
|
c2be12fa75 | ||
|
|
c164606fe3 | ||
|
|
8d9d4807c3 | ||
|
|
7f8a743cb5 | ||
|
|
40df801cdb | ||
|
|
35d53e6010 | ||
|
|
1b02d7f558 | ||
|
|
edfa48d1f5 | ||
|
|
c89ddffcbd | ||
|
|
5183f30068 | ||
|
|
37e2faacc7 | ||
|
|
b0c4647b85 | ||
|
|
ff3b0dd6f9 | ||
|
|
2b6b05a175 | ||
|
|
5c9ca110ed | ||
|
|
e3540cab5e | ||
|
|
0eaa9f2088 | ||
|
|
59f7348aa5 | ||
|
|
67b9151513 | ||
|
|
67a95d4f4c | ||
|
|
ce6e8f807f | ||
|
|
e7fe34d865 | ||
|
|
fd0b0a946f | ||
|
|
2895d5711e | ||
|
|
1ad6a97a36 | ||
|
|
b795c1dc56 | ||
|
|
db079eec9f | ||
|
|
bc72c41be5 | ||
|
|
697e68f098 | ||
|
|
3921e59035 | ||
|
|
b863527c33 | ||
|
|
c08f5df677 | ||
|
|
c62bffc8ef | ||
|
|
aeb6bb5294 | ||
|
|
65964f0e46 | ||
|
|
792e9c016e | ||
|
|
aeac07cf54 | ||
|
|
d90d111218 | ||
|
|
8fc1117209 | ||
|
|
442b5ecfd8 | ||
|
|
5afffbef89 | ||
|
|
76f619280a | ||
|
|
2b92599d94 | ||
|
|
d87b863aa3 | ||
|
|
6aa74619d5 | ||
|
|
0a463dea23 | ||
|
|
e44ea65080 | ||
|
|
ebb1091a37 | ||
|
|
e6430c39ed | ||
|
|
b518f8c741 | ||
|
|
1bb5ed6216 | ||
|
|
93a4ae95fa | ||
|
|
e3bb608dde | ||
|
|
f2e73d98f4 | ||
|
|
78d284b154 | ||
|
|
2c4581f122 | ||
|
|
65fee8ca8d | ||
|
|
4372c913a6 | ||
|
|
5239dae1a8 | ||
|
|
52a0974a84 | ||
|
|
f910c3b278 | ||
|
|
9bc5294f36 | ||
|
|
7ed08c5bbd | ||
|
|
449146bbdb | ||
|
|
e115006ac9 | ||
|
|
30fbadbb23 | ||
|
|
cae1270796 | ||
|
|
587a7fc8c3 | ||
|
|
dd62de4f6d | ||
|
|
081d133084 | ||
|
|
94e050d5a0 | ||
|
|
cd6ecf073f | ||
|
|
5a4848cab9 | ||
|
|
480c6dcc36 | ||
|
|
30b20cbdd6 | ||
|
|
3d6bd463ce | ||
|
|
981173a1f2 | ||
|
|
19e8b826c6 | ||
|
|
9146234758 | ||
|
|
aea65bb282 | ||
|
|
2c7b93f756 | ||
|
|
c6e54041e0 | ||
|
|
e67a90b397 | ||
|
|
91f60c26e3 | ||
|
|
879e84468b | ||
|
|
cb9ad1b6cb | ||
|
|
285871bc39 | ||
|
|
779f9c5a53 | ||
|
|
43112f5143 | ||
|
|
0e6c00525d | ||
|
|
416c574240 | ||
|
|
d77e2bd056 | ||
|
|
6ea9eb2618 | ||
|
|
2a6736a02c | ||
|
|
c8ef81ad93 | ||
|
|
e529d60554 | ||
|
|
e9273beaa1 | ||
|
|
48b404bc3b | ||
|
|
aeb991bc1c | ||
|
|
63820d1909 | ||
|
|
1aaa3c8c06 | ||
|
|
be18ed1a8b | ||
|
|
3f4aaf539c | ||
|
|
3acc2d91e6 | ||
|
|
0867debebd | ||
|
|
e400cb1dcf | ||
|
|
1701e622dc | ||
|
|
31edf6b737 | ||
|
|
005ad9b71d | ||
|
|
aaf2668882 | ||
|
|
cfb0310e9b | ||
|
|
493ab35635 | ||
|
|
313d1ec9c8 | ||
|
|
6ee7bacc47 | ||
|
|
027bbfa701 | ||
|
|
0f8d69a263 | ||
|
|
2a9d4f886e | ||
|
|
34cd35056b | ||
|
|
734cd4f36c | ||
|
|
44ca81f527 | ||
|
|
3e5349ec39 | ||
|
|
3c184f5741 | ||
|
|
93bffc21d7 | ||
|
|
8423a309ff | ||
|
|
698a3b11d9 | ||
|
|
5952bf8e9d | ||
|
|
d186dfbc60 | ||
|
|
c7e76fdd64 | ||
|
|
559eec96b3 | ||
|
|
020f93a187 | ||
|
|
69a912cc7d | ||
|
|
aa6078c11a | ||
|
|
01d1fba915 | ||
|
|
e89ff39cff | ||
|
|
4cfbd5883f | ||
|
|
33fdd9d296 | ||
|
|
45bd789cb7 | ||
|
|
1db65d6c82 | ||
|
|
6b6ec64381 | ||
|
|
1e9aa42282 | ||
|
|
1e69ca4194 | ||
|
|
8296657a45 | ||
|
|
2b2d72ac9a | ||
|
|
fe4e09032d | ||
|
|
1356ea4df1 | ||
|
|
c79ba7571f | ||
|
|
76dcd0c426 | ||
|
|
f668a10982 | ||
|
|
29f2f57bc0 | ||
|
|
a257ec0bf9 | ||
|
|
99504554e0 | ||
|
|
b53293eddd | ||
|
|
229f17696e | ||
|
|
f6a88f1a51 | ||
|
|
b7caa7d7d3 | ||
|
|
a61dfdd3ab | ||
|
|
840f53f032 | ||
|
|
5932b6a8a4 | ||
|
|
3f2e32db87 | ||
|
|
a6a1e0b258 | ||
|
|
db74e3be96 | ||
|
|
faf7bc6224 | ||
|
|
289a971c90 | ||
|
|
f2f6085887 | ||
|
|
d1541099b8 | ||
|
|
0440959834 | ||
|
|
482d031a18 | ||
|
|
20a5ced63d | ||
|
|
02fe664583 | ||
|
|
e2ffcd34f3 | ||
|
|
2002b9d70e | ||
|
|
9b86264813 | ||
|
|
ba97a67580 | ||
|
|
c7a17478a3 | ||
|
|
a625800d62 | ||
|
|
f0f39ac823 | ||
|
|
1252ecc7af | ||
|
|
7b53af1c18 | ||
|
|
1255ef2bbf | ||
|
|
fdd60d3ccd | ||
|
|
d5fa8feb31 | ||
|
|
4e07409627 | ||
|
|
da34d94009 | ||
|
|
234b0a1814 | ||
|
|
7a8e919546 | ||
|
|
d79efb4730 | ||
|
|
95643b7ea0 | ||
|
|
9dab523af9 | ||
|
|
a29fac2594 | ||
|
|
6d5cb20847 | ||
|
|
987c36c5f6 | ||
|
|
649185237f | ||
|
|
5b43170fda | ||
|
|
0cb085dc50 | ||
|
|
61db62edcf | ||
|
|
b0a65129f1 | ||
|
|
958743c79d | ||
|
|
05308932e8 | ||
|
|
4e4853e82b | ||
|
|
d44b6121ae | ||
|
|
af6f97ca5a | ||
|
|
3438c248d9 | ||
|
|
509ddbdc95 | ||
|
|
f2d293bd1a | ||
|
|
a5a554d0c2 | ||
|
|
af2dca8365 | ||
|
|
6a8e0093de | ||
|
|
53107fd91b | ||
|
|
548f5d8496 | ||
|
|
a03c94ae12 | ||
|
|
dd3df85fd6 | ||
|
|
e7bfa80e09 | ||
|
|
b26e34ce09 | ||
|
|
ab4b58b1c7 | ||
|
|
74089fb2f1 | ||
|
|
7f828cbcf8 | ||
|
|
42665d14db | ||
|
|
8dd6eb3fe9 | ||
|
|
ef309c9931 | ||
|
|
de0f0eb0e3 | ||
|
|
bdef125f68 | ||
|
|
f64cbe5bac | ||
|
|
d4714eadf7 | ||
|
|
bcdd69f6d8 | ||
|
|
f5becdd2c0 | ||
|
|
e185cb3b29 | ||
|
|
2320dc4d0e | ||
|
|
affe3eda65 | ||
|
|
9e9b25e9f4 | ||
|
|
f7153b3ab1 | ||
|
|
5c987ae8b8 | ||
|
|
42b0d729cb | ||
|
|
ae63fa584c | ||
|
|
337ac99b3e | ||
|
|
aada257325 | ||
|
|
8bc3d63126 | ||
|
|
c690e691a3 | ||
|
|
e3fcd3eef8 | ||
|
|
0da1fe5077 | ||
|
|
1f7615b312 | ||
|
|
2ded4c5a0d | ||
|
|
5b47cd3330 | ||
|
|
aead570b5d | ||
|
|
de931bc56f | ||
|
|
71288d8980 | ||
|
|
64b9babe87 | ||
|
|
4e456480ae | ||
|
|
241f538f99 | ||
|
|
21dacfcbe7 | ||
|
|
eb181d8996 | ||
|
|
827ddcf14f | ||
|
|
7c3894e630 | ||
|
|
040581a4c7 | ||
|
|
17f6d52717 | ||
|
|
b4e80cab0a | ||
|
|
b98e3e496f | ||
|
|
a148e02d39 | ||
|
|
0fb9174c23 | ||
|
|
c7489a88eb | ||
|
|
be74850a8e | ||
|
|
dc21428f85 | ||
|
|
fa43c83b06 | ||
|
|
1d4c57d648 | ||
|
|
c6215712fe | ||
|
|
cd7ab2d9f7 | ||
|
|
4b68971383 | ||
|
|
54c001d537 | ||
|
|
5ce5c023b6 | ||
|
|
709fef0dbd | ||
|
|
49408dc171 | ||
|
|
c728e64fbd | ||
|
|
0626c52e12 | ||
|
|
b9b4a00485 | ||
|
|
eb125b59c7 | ||
|
|
7e53fe32cb | ||
|
|
ebae409632 | ||
|
|
08cb267ce4 | ||
|
|
0119544c3e | ||
|
|
2a9e7dcabf | ||
|
|
5a9099dddb | ||
|
|
ec4086db69 | ||
|
|
5a7c3541f8 | ||
|
|
525040162e | ||
|
|
21e771d16e | ||
|
|
2cd095a8c4 | ||
|
|
bb9236b2b7 | ||
|
|
1bc090d00d | ||
|
|
63ba25ffdd | ||
|
|
98e6e90d84 | ||
|
|
7c061846bd | ||
|
|
a91d55151f | ||
|
|
7f25e87b42 | ||
|
|
ff245953ac | ||
|
|
46665d51c0 | ||
|
|
67615e85c3 | ||
|
|
9e3c3cd31d | ||
|
|
537754ebce | ||
|
|
573a9e2730 | ||
|
|
8b91b58336 | ||
|
|
12c60ec024 | ||
|
|
60261f093f | ||
|
|
3120a3cacf | ||
|
|
fecb226e43 | ||
|
|
e9cbb8fd55 | ||
|
|
d72be95f25 | ||
|
|
65fedbf01e | ||
|
|
311ee70783 | ||
|
|
f832d0b912 | ||
|
|
361bcf8a1b | ||
|
|
10f86d3e7a | ||
|
|
5fea1526ca | ||
|
|
7a7dca70de | ||
|
|
bf8c500a87 | ||
|
|
71fe208fea | ||
|
|
2b838ede9e | ||
|
|
8402a671ef | ||
|
|
310e778d00 | ||
|
|
a909beb39e | ||
|
|
e2601965bd | ||
|
|
96502e6e4d | ||
|
|
d5f26efc1a | ||
|
|
eac9557286 | ||
|
|
4aa9f18fd1 | ||
|
|
fa40e30f3c | ||
|
|
26206e85b4 | ||
|
|
d7a887b98c | ||
|
|
30cbe1c901 | ||
|
|
f4efefa958 | ||
|
|
a5980d8f98 | ||
|
|
e179616ad0 | ||
|
|
8f245cede7 | ||
|
|
359407bd06 | ||
|
|
810ef1822d | ||
|
|
3dd8e982a3 | ||
|
|
fce1800dcf | ||
|
|
3f377d8f56 | ||
|
|
9060142f5d | ||
|
|
172851971c | ||
|
|
48e9ce58ee | ||
|
|
9a5d697579 | ||
|
|
71ef6a57c7 | ||
|
|
be9e7b5cda | ||
|
|
5c44d56f53 | ||
|
|
fed2249776 | ||
|
|
1e44a74165 | ||
|
|
2a3e52f708 | ||
|
|
b2485500e1 | ||
|
|
c5521a917f | ||
|
|
a13f4fafab | ||
|
|
f9496d67da | ||
|
|
7eae209b1c | ||
|
|
28bc0506a6 | ||
|
|
0a4f008d03 | ||
|
|
ea0b6a80bd | ||
|
|
16b37bc667 | ||
|
|
a8e5d7cf61 | ||
|
|
5cdfee4f62 | ||
|
|
dd80ca7b74 | ||
|
|
612a766057 | ||
|
|
d546568a11 | ||
|
|
5b531c84fb | ||
|
|
ae8cc1b4a6 | ||
|
|
e89912d936 | ||
|
|
4c27a3397c | ||
|
|
75acf4a3e9 | ||
|
|
629a2fafb6 | ||
|
|
b0d848dc5c | ||
|
|
57a9d8c717 | ||
|
|
fa9ee01cc1 | ||
|
|
63017e1969 | ||
|
|
b351fe7ad6 | ||
|
|
6aab62983e | ||
|
|
bcdd9d0830 | ||
|
|
ddd2d1c295 | ||
|
|
8f2e34a40e | ||
|
|
351230fcad | ||
|
|
5b35e9cc5d | ||
|
|
baaddd7622 | ||
|
|
671c4c06e7 | ||
|
|
07fc7ca3ff | ||
|
|
443813a088 | ||
|
|
e885e9adba | ||
|
|
7aa7c55be9 | ||
|
|
92a68285b5 | ||
|
|
2554309c7d | ||
|
|
5878c45ddb | ||
|
|
adc1e44cf1 | ||
|
|
7f46cd54a4 | ||
|
|
ba0ac6022b | ||
|
|
8b9bd2119c | ||
|
|
d773babaa3 | ||
|
|
a8888cb2fc | ||
|
|
1f51cc89d2 | ||
|
|
bcdb4c3b78 | ||
|
|
e9b02d61e7 | ||
|
|
02419ffbd4 | ||
|
|
5db9631571 | ||
|
|
c060df4b50 | ||
|
|
c7125bc3de | ||
|
|
9c33711e6f | ||
|
|
9d92396d49 | ||
|
|
be2eda8304 | ||
|
|
f5bed08931 | ||
|
|
e144325375 | ||
|
|
291b8ec295 | ||
|
|
9697812984 | ||
|
|
0fc6fbe596 | ||
|
|
f2261bac1c | ||
|
|
7c45d0ff24 | ||
|
|
19233fbf7d | ||
|
|
3ef978e232 | ||
|
|
8afa1113c9 | ||
|
|
7fe7e504da | ||
|
|
f678b4c8a2 | ||
|
|
33e4d2c092 | ||
|
|
621330d397 | ||
|
|
15e14c0d47 | ||
|
|
141b95ddde | ||
|
|
4ec2ff40c3 | ||
|
|
39e819872a | ||
|
|
ee7951e8d0 | ||
|
|
77c6c286cd | ||
|
|
ca53b26701 | ||
|
|
baec984449 | ||
|
|
18e5053b64 | ||
|
|
4ecc03280e | ||
|
|
2f1aeaac72 | ||
|
|
8347c2c3f7 | ||
|
|
a94c45d014 | ||
|
|
7faf80fc2b | ||
|
|
6ea20a2806 | ||
|
|
e20225604d | ||
|
|
2d81c92114 | ||
|
|
fe73906f1f | ||
|
|
84912ea4e1 | ||
|
|
d456aa1f3d | ||
|
|
6c7b2ca22f | ||
|
|
8a06848b5f | ||
|
|
9cca67529b | ||
|
|
b656f80922 | ||
|
|
7cc418662d | ||
|
|
b2bbff4305 | ||
|
|
2cfcdaaf0e | ||
|
|
2960581541 | ||
|
|
8b1c290730 | ||
|
|
b64082c974 | ||
|
|
7c1d698400 | ||
|
|
b6406a90a4 | ||
|
|
9b26c907ce | ||
|
|
5f3798b79b | ||
|
|
6e0b140efc | ||
|
|
226ec40720 | ||
|
|
f1aaed6279 | ||
|
|
b89538f506 | ||
|
|
311fb5f6c7 | ||
|
|
93ae2b1bd1 | ||
|
|
12bd10ee42 | ||
|
|
218df4f312 | ||
|
|
ce1a271064 | ||
|
|
dbf6ffc79c | ||
|
|
a8d5c4b44d | ||
|
|
b365a85665 | ||
|
|
8aeeaef666 | ||
|
|
4e374de040 | ||
|
|
d17e911c5b | ||
|
|
6746a8220d | ||
|
|
5fb02770db | ||
|
|
62d233f9c9 | ||
|
|
2861567d15 | ||
|
|
fcf2d12411 | ||
|
|
b5c8d2f19d | ||
|
|
4a489e4606 | ||
|
|
0304575e5b | ||
|
|
81bf646b0e | ||
|
|
4f99cd75d0 | ||
|
|
3e814cfa1a | ||
|
|
6e566db1a7 | ||
|
|
f221a5f588 | ||
|
|
b1f33c804c | ||
|
|
db9bee2903 | ||
|
|
22df550ff2 | ||
|
|
7871aa1ca0 | ||
|
|
0a9bb2e0c3 | ||
|
|
b25855d13a | ||
|
|
9e70cdb48a | ||
|
|
9da1dea974 | ||
|
|
241e35d3b6 | ||
|
|
70309d3cec | ||
|
|
76878403c3 | ||
|
|
105b92a0dd | ||
|
|
d356d9a9d5 | ||
|
|
6f5aa33197 | ||
|
|
e47eefec7c | ||
|
|
d92a9fc7ae | ||
|
|
875e92ea7b | ||
|
|
f9a87e76e8 | ||
|
|
95ccae5494 | ||
|
|
d58a926257 | ||
|
|
9292ce0a4c | ||
|
|
f5f3a1f9ca | ||
|
|
9d062b7ba0 | ||
|
|
a46c28d6c8 | ||
|
|
a41f635a7c | ||
|
|
67bb71a708 | ||
|
|
0b9a165d83 | ||
|
|
88a2fe6728 | ||
|
|
ba9706b69e | ||
|
|
551cc54bbc | ||
|
|
c3e651cd25 | ||
|
|
f0ba42bfba | ||
|
|
99fc15bd79 | ||
|
|
e63dc43c2f | ||
|
|
80f433a2d3 | ||
|
|
07f44f9082 | ||
|
|
c305daae15 | ||
|
|
a4031db052 | ||
|
|
87884f382b | ||
|
|
f98f3e728b | ||
|
|
90034540ae | ||
|
|
cc9af9eba4 | ||
|
|
bc0e0fc043 | ||
|
|
5b4efc87b3 | ||
|
|
c2c1cf0168 | ||
|
|
01ba796881 | ||
|
|
b9f96866e5 | ||
|
|
3fb876ad5a | ||
|
|
9bd6890fe8 | ||
|
|
0d181b676e | ||
|
|
8de0292568 | ||
|
|
8e8c24f370 | ||
|
|
79dda60bde | ||
|
|
0bb2f9eb03 | ||
|
|
049d50f43a | ||
|
|
3a95d3b638 | ||
|
|
30509da71f | ||
|
|
d4b34e1bad | ||
|
|
22548bc42f | ||
|
|
1863c92101 | ||
|
|
989962aecc | ||
|
|
497b5d9ae6 | ||
|
|
04995c066f | ||
|
|
0bceb5c928 | ||
|
|
08d75e3ffb | ||
|
|
22bb9c6469 | ||
|
|
fab04f0558 | ||
|
|
01d5c612c0 | ||
|
|
c0ae75d56b | ||
|
|
0520082586 | ||
|
|
28073e212a | ||
|
|
6abc765e22 | ||
|
|
f73efe414c | ||
|
|
704e5d7780 | ||
|
|
36dd5a05f2 | ||
|
|
002c040223 | ||
|
|
4b1bb1557d | ||
|
|
73b97fedc5 | ||
|
|
924e37aa30 | ||
|
|
edbe8d4109 | ||
|
|
b151ae691e | ||
|
|
36db6b1ac6 | ||
|
|
bd24436fbc | ||
|
|
895cc0ebbd | ||
|
|
f2c8466ba9 | ||
|
|
12aaec7888 | ||
|
|
7ea999d5b4 | ||
|
|
6cf01729e2 | ||
|
|
63c1a9ff55 | ||
|
|
f7dbad500a | ||
|
|
47a29afb4c | ||
|
|
a0f810c018 | ||
|
|
e8db571a2c | ||
|
|
d9cd40c035 | ||
|
|
865e8dc17f | ||
|
|
34a334fa27 | ||
|
|
3f20e5f9d3 | ||
|
|
3f273ca37a | ||
|
|
72ffec45c2 | ||
|
|
2ca0e6924a | ||
|
|
cf2db32721 | ||
|
|
3f0f09b138 | ||
|
|
b8ed53f2ff | ||
|
|
19a0d0af18 | ||
|
|
7308ac09b1 | ||
|
|
d6fb5e917a | ||
|
|
8051687e1e | ||
|
|
1a5f089e57 | ||
|
|
9e25ae9025 | ||
|
|
ed4a0ad5cd | ||
|
|
cb0a90a9c3 | ||
|
|
32fbbfe475 | ||
|
|
83d74548cd | ||
|
|
daf30515b1 | ||
|
|
2f056dbcff | ||
|
|
fa6d4fd81b | ||
|
|
e3176a2de8 | ||
|
|
7cc4abaffa | ||
|
|
02b9413f78 | ||
|
|
1a08258e3d | ||
|
|
a84d6b912b | ||
|
|
448720d8d6 | ||
|
|
1cd56740a6 | ||
|
|
b685a601f7 | ||
|
|
31de17aab8 | ||
|
|
ffef4d897a | ||
|
|
5aea46afd0 | ||
|
|
8fca0fe0a2 | ||
|
|
6d3cde64fe | ||
|
|
3f280ba59a | ||
|
|
83fa5c6c5b | ||
|
|
094c12c6d8 | ||
|
|
416f3181cc | ||
|
|
3ef7312654 | ||
|
|
70e7f7f5e5 | ||
|
|
2bd86207c7 | ||
|
|
be1ab129c9 | ||
|
|
a38d0cda6e | ||
|
|
c46bf68dd0 | ||
|
|
e702adbd77 | ||
|
|
3cb3defaa7 | ||
|
|
ab1985b6b3 | ||
|
|
1203ec0a9e | ||
|
|
dc4a64a9c3 | ||
|
|
a992f6d896 | ||
|
|
d9d538690e | ||
|
|
08fbe730c6 | ||
|
|
1cf353f419 | ||
|
|
ef56025ad1 | ||
|
|
697452e5a9 | ||
|
|
4bb7a1ee86 | ||
|
|
c0183ca8b2 | ||
|
|
ae7f733b90 | ||
|
|
bf8a478063 | ||
|
|
b00db2484d | ||
|
|
215bc357ef | ||
|
|
7d6ff7f919 | ||
|
|
363497fdd5 | ||
|
|
ff52f50c0c | ||
|
|
b0d6039eec | ||
|
|
86b8ab1d82 | ||
|
|
e288946b81 | ||
|
|
1343900aa1 | ||
|
|
d2a5db7caf | ||
|
|
020e3386e1 | ||
|
|
f81f70ed5d | ||
|
|
a08ba6f65b | ||
|
|
53c51e1888 | ||
|
|
72e4ccf080 | ||
|
|
222d5eccc9 | ||
|
|
a9740ad6f2 | ||
|
|
cb6f294299 | ||
|
|
2c8e2fb2a8 | ||
|
|
b87be6866b | ||
|
|
747dbcf72f | ||
|
|
409e328880 | ||
|
|
c5a614d989 | ||
|
|
0f33b4f094 | ||
|
|
49378b2c80 | ||
|
|
22863f2f87 | ||
|
|
b779376f5b | ||
|
|
8bd8d63546 | ||
|
|
42b51afc5a | ||
|
|
6597a90059 | ||
|
|
c3e3f2629c | ||
|
|
d18f1655be | ||
|
|
90fd2684ba | ||
|
|
fa05a4b310 | ||
|
|
f81c98a87b | ||
|
|
93ebd34f2c | ||
|
|
cb05395280 | ||
|
|
34e91988b1 | ||
|
|
3b36feff52 | ||
|
|
1b5f540a66 | ||
|
|
47266afe85 | ||
|
|
b9714fda45 | ||
|
|
a045f5479f | ||
|
|
5874fe33f6 | ||
|
|
2f9ddeea76 | ||
|
|
4589221661 | ||
|
|
7af5916378 | ||
|
|
1b39fc1718 | ||
|
|
2df0f0e32a | ||
|
|
d7cb967786 | ||
|
|
f7bc2a8f68 | ||
|
|
98ef6944c9 | ||
|
|
391ecbeac5 | ||
|
|
1dc2a99617 | ||
|
|
2e9bc8624b | ||
|
|
b5caee5b86 | ||
|
|
d6f3d7c380 | ||
|
|
7990553620 | ||
|
|
838eaa4e6b | ||
|
|
6c1ece1b62 | ||
|
|
6104c3f192 | ||
|
|
fde2f9620a | ||
|
|
f0db805d4b | ||
|
|
baefa105a9 | ||
|
|
70897cfd1d | ||
|
|
060801c674 | ||
|
|
da5c954ce4 | ||
|
|
c69ea6266f | ||
|
|
2466c5aa14 | ||
|
|
060cf9cd57 | ||
|
|
829897087a | ||
|
|
a97e8a6183 | ||
|
|
43e19d17f6 | ||
|
|
08c306c3a6 | ||
|
|
cd202433bb | ||
|
|
4d4e45f622 | ||
|
|
175c39a2b0 | ||
|
|
8315c7c3aa | ||
|
|
6ecd0cea39 | ||
|
|
6a63a5a597 | ||
|
|
3fb541dce7 | ||
|
|
c335cd5130 | ||
|
|
51ef5d8283 | ||
|
|
91c5853627 | ||
|
|
0d448ab510 | ||
|
|
72352c5914 | ||
|
|
d3fa282377 | ||
|
|
6c41a341e5 | ||
|
|
0622afcae9 | ||
|
|
522c46ed6f | ||
|
|
a7261b1601 | ||
|
|
c5fc552c60 | ||
|
|
4fd8bb79af | ||
|
|
5ac109c381 | ||
|
|
ba33d2ff40 | ||
|
|
559caa2dd0 | ||
|
|
f695f398de | ||
|
|
5000fa4147 | ||
|
|
41ac28248a | ||
|
|
9b0fedf602 | ||
|
|
a9d78679ec | ||
|
|
ddcf3b2ad2 | ||
|
|
029c92bccb | ||
|
|
7c439fdf28 | ||
|
|
c68afa6c5d | ||
|
|
328b7ed211 | ||
|
|
3149a6e269 | ||
|
|
ea73145fbc | ||
|
|
69f790b83a | ||
|
|
9dd57cde4e | ||
|
|
41f08ac9a2 | ||
|
|
d383a26746 | ||
|
|
98a5ec45bb | ||
|
|
f10b5fe159 | ||
|
|
7e2228b15e | ||
|
|
a24d27013c | ||
|
|
c5b5930582 | ||
|
|
70a0bd0d38 | ||
|
|
e4421d966a | ||
|
|
318bc3b84f | ||
|
|
cb182dbce5 | ||
|
|
7844577be8 | ||
|
|
efffd72c52 | ||
|
|
18334b5989 | ||
|
|
d5e8193ce5 | ||
|
|
6765fbcb20 | ||
|
|
13eb65603b | ||
|
|
08037ab3f5 | ||
|
|
41684e2d90 | ||
|
|
75b2478a55 | ||
|
|
c06b3285f2 | ||
|
|
a954fc83c9 | ||
|
|
5bf1268c82 | ||
|
|
784a48f218 | ||
|
|
f4dbb1180c | ||
|
|
038dcd48cd | ||
|
|
f1b12a6eb6 | ||
|
|
113216cc0e | ||
|
|
9e9e5950d0 | ||
|
|
268afccf05 | ||
|
|
e1c0ac983c | ||
|
|
077eab2b79 | ||
|
|
35ef3af54b | ||
|
|
f10282294d | ||
|
|
6217b5f2fa | ||
|
|
b9e31178f3 | ||
|
|
50d5e14539 | ||
|
|
fd15f1de4c | ||
|
|
064cf95f6d | ||
|
|
8d75ede0ab | ||
|
|
0dbe18d824 | ||
|
|
a1bc6522c3 | ||
|
|
724b020e93 |
No files matched your search
@@ -22,6 +22,7 @@ desktop or panels.
|
||||
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
|
||||
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
|
||||
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
|
||||
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
|
||||
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
|
||||
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
|
||||
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
|
||||
|
||||
@@ -6,3 +6,8 @@
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.bat text eol=crlf
|
||||
# Test fixtures are byte-exact (hashes, signatures): never convert line endings.
|
||||
tests/fixtures/** -text
|
||||
*.apk binary
|
||||
*.jar binary
|
||||
*.obb binary
|
||||
@@ -5,6 +5,11 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# A newer push to the same pull request supersedes a run still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -17,7 +22,9 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: Script syntax
|
||||
run: |
|
||||
sh -n ui/local-bin/ssh
|
||||
@@ -34,10 +41,14 @@ jobs:
|
||||
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Linux Wireless Adapter doctor (read-only PC smoke)
|
||||
run: |
|
||||
python ui/frame_wireless_doctor.py --json > "$RUNNER_TEMP/linux-wireless-doctor.json"
|
||||
python -c 'import json, os; from pathlib import Path; d=json.loads((Path(os.environ["RUNNER_TEMP"]) / "linux-wireless-doctor.json").read_text()); assert d["supported"] and d["selected"] is None and d["schema"] == 1'
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: Updater tests
|
||||
run: node --test app/test/updater.test.js
|
||||
run: node --check app/main.js && node --check app/obs-source.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: App tests
|
||||
run: node --test app/test/*.test.js
|
||||
- name: Website
|
||||
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
|
||||
|
||||
@@ -60,9 +71,29 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: OBS lifecycle tests
|
||||
run: node app/test/obs-source.test.js
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
# Fast, PC-only Windows query validation, without capture libraries or a headset.
|
||||
wireless-windows:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Wireless doctor fixtures, UI and live read-only Windows inventory
|
||||
run: python -m unittest discover -s tests -p 'test_wireless.py' -v
|
||||
|
||||
# The iPhone app: builds for the Simulator and runs its unit tests.
|
||||
ios:
|
||||
runs-on: macos-latest
|
||||
@@ -84,6 +115,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: End-to-end tests
|
||||
run: scripts/e2e.sh
|
||||
@@ -0,0 +1,53 @@
|
||||
name: PC host libraries
|
||||
# PC in the headset (Experimental). The Windows job builds the native adapter
|
||||
# and runs the real-encoder protocol tests; the installer's own bundle is
|
||||
# tested in release.yml. The Linux adapter from PR #38 isn't shipped or offered
|
||||
# in the app, but shares capture.c and controller.c, so it's still built here.
|
||||
# These builds add the GPL x264 encoder for headless tests only: they're never
|
||||
# uploaded or shipped.
|
||||
on:
|
||||
pull_request:
|
||||
paths: ["desktop/**", "ui/**", "tests/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
linux:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Native libraries
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
|
||||
python3 desktop/build.py --with-x264
|
||||
- name: Tests (real x264, fake capture consent/input)
|
||||
env:
|
||||
FRAME_PC_REQUIRE_NATIVE: "1"
|
||||
FRAME_PC_REQUIRE_X264: "1"
|
||||
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
|
||||
# flaky mirror can't break a release build.
|
||||
- name: MSYS2 packages for the PC host
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
python desktop/build.py --with-x264
|
||||
FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_REQUIRE_X264=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
@@ -2,19 +2,67 @@ name: release
|
||||
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that touch the app build the same installers as artifacts.
|
||||
# Pull requests that change how the installers are built (the Electron app, the
|
||||
# Windows PC helper libraries, the macOS helper, or this workflow) build the same installers as
|
||||
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
|
||||
# are tested by the checks workflow; run this one by hand to package them early.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", "scripts/check-appimage-launch.py", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# One job makes the draft, before the builds: three matrix jobs each running
|
||||
# "view || create" could race and make duplicate drafts. The draft is tied to
|
||||
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
|
||||
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
|
||||
# by tag. Editing a draft can make GitHub show an untagged-* name again; the
|
||||
# upload step below recovers that exact draft if tag lookup fails. It is
|
||||
# created with the REST API so the id comes straight back: the
|
||||
# release list can lag a fresh draft (v0.4.2's first run found nothing and
|
||||
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
|
||||
# this step against tests/fakegh.
|
||||
draft:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create the draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
# A rerun finds the draft even if GitHub temporarily shows untagged-*.
|
||||
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
|
||||
--jq ".[] | select(.tag_name == \"$tag\" or (.draft and (.tag_name | startswith(\"untagged-\")) and .name == \"Frame Control ${tag#v}\")) | .id")
|
||||
if [ -n "$id" ] && ! [[ "$id" =~ ^[0-9]+$ ]]; then
|
||||
echo "expected one release for $tag; found: $id" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$id" ]; then
|
||||
# Given a missing tag, GitHub would create it on the default branch.
|
||||
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|
||||
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
|
||||
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
|
||||
-F draft=true -f body="" --jq .id)
|
||||
fi
|
||||
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
|
||||
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
|
||||
|
||||
build:
|
||||
needs: draft
|
||||
# Still runs for pull requests and manual runs, where the draft job is skipped.
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -22,6 +70,9 @@ jobs:
|
||||
- os: macos-latest
|
||||
script: dist
|
||||
files: app/dist/*.dmg app/dist/*.zip
|
||||
- os: macos-15-intel
|
||||
script: dist:mac:x64
|
||||
files: app/dist/*.dmg app/dist/*.zip
|
||||
- os: windows-latest
|
||||
script: dist:win
|
||||
files: app/dist/*.exe app/dist/*.zip
|
||||
@@ -34,22 +85,102 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
# PC in the headset (Experimental, Windows only): our capture adapter and
|
||||
# the GStreamer libraries it uses, bundled into resources/desktop/bundle.
|
||||
- uses: msys2/setup-msys2@v2
|
||||
if: runner.os == 'Windows'
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
|
||||
# flaky mirror can't break a release build.
|
||||
- name: MSYS2 packages for the PC host
|
||||
if: runner.os == 'Windows'
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
|
||||
sleep $((attempt * 30))
|
||||
done
|
||||
exit 1
|
||||
- name: Windows PC streaming libraries
|
||||
if: runner.os == 'Windows'
|
||||
shell: msys2 {0}
|
||||
run: python desktop/build.py --out app/build/desktop/win-x64
|
||||
- name: Build
|
||||
working-directory: app
|
||||
shell: bash
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
- name: Check the Mac bundle matches its runner
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
bundle=$(find app/dist -maxdepth 2 -name 'Frame Control.app' -type d -print -quit)
|
||||
test -n "$bundle"
|
||||
resources="$bundle/Contents/Resources"
|
||||
for binary in "$bundle/Contents/MacOS/Frame Control" "$resources/python/bin/python3" "$resources/tools/adb" "$resources/mac/bin/frame-mac-view"; do
|
||||
lipo "$binary" -verify_arch "$(uname -m)"
|
||||
done
|
||||
"$resources/python/bin/python3" -I -B -c 'import http.server, ssl, sqlite3; print(ssl.OPENSSL_VERSION)'
|
||||
- name: Launch the built Linux AppImage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: python3 scripts/check-appimage-launch.py app/dist/Frame-Control-linux-x86_64.AppImage
|
||||
# The installer, not just the unpacked build: its 7-Zip payload once dropped
|
||||
# the OpenXR layer's arm64 library without any error. Install it silently
|
||||
# and compare every installed file with the unpacked build.
|
||||
- name: Check the Windows installer installs every file
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$setup = Get-ChildItem app/dist/Frame-Control-Setup-*.exe | Select-Object -First 1
|
||||
$p = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" }
|
||||
$installed = Join-Path $env:LOCALAPPDATA 'Programs\Frame Control'
|
||||
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
|
||||
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
# From the installed app, so this also proves the installer carries the
|
||||
# libraries. After the check above, and without writing __pycache__,
|
||||
# so it can't change what that check compares.
|
||||
- name: Test the installed app's Python with the Windows PC libraries
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
run: |
|
||||
resources="$(cygpath -u "$LOCALAPPDATA")/Programs/Frame Control/resources"
|
||||
python="$resources/python/python.exe"
|
||||
native="$(cygpath -w "$resources/desktop/bundle")"
|
||||
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_INSTALLER_BUNDLE=1 "$python" -B -m unittest discover -s tests -p 'test_pc*.py' -v
|
||||
- name: Upload to the release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# A release-notes edit can make GitHub expose a draft as untagged-*.
|
||||
# Keep the normal upload path; recover only that lookup failure.
|
||||
if result=$(gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber 2>&1); then
|
||||
printf '%s\n' "$result"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$result" >&2
|
||||
case "$result" in *"release not found"*) ;; *) exit 1 ;; esac
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
|
||||
ids=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
|
||||
--jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\" and (.tag_name == \"$tag\" or (.tag_name | startswith(\"untagged-\")))) | .id")
|
||||
if ! [[ "$ids" =~ ^[0-9]+$ ]]; then
|
||||
echo "expected one matching draft for $tag; found: ${ids:-none}" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh api -X PATCH "repos/$GH_REPO/releases/$ids" -f tag_name="$tag" >/dev/null
|
||||
gh release upload "$tag" ${{ matrix.files }} --clobber
|
||||
# Tagged builds are attached to the release above; only other builds need artifacts.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
path: |
|
||||
@@ -59,3 +190,24 @@ jobs:
|
||||
app/dist/*.AppImage
|
||||
app/dist/*.deb
|
||||
if-no-files-found: ignore
|
||||
|
||||
checksums:
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Attach SHA256SUMS after all installers upload
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq '.[]' > releases.jsonl
|
||||
python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" < releases.jsonl > SHA256SUMS
|
||||
# Find the same draft using its exact tag, or its version title if a
|
||||
# draft edit made GitHub temporarily show an untagged-* tag name.
|
||||
id=$(python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" --id < releases.jsonl)
|
||||
old=$(gh api "repos/$GH_REPO/releases/$id" --jq '.assets[] | select(.name == "SHA256SUMS") | .id')
|
||||
for asset in $old; do gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset" >/dev/null; done
|
||||
gh api -X POST "https://uploads.github.com/repos/$GH_REPO/releases/$id/assets?name=SHA256SUMS" \
|
||||
-H "Content-Type: text/plain" --input SHA256SUMS >/dev/null
|
||||
+13
@@ -6,3 +6,16 @@ compat-db/.env.lakebed.server
|
||||
compat-db/.lakebed/
|
||||
tests/smoke/results/
|
||||
mac/bin/
|
||||
|
||||
# Shared desktop streaming controller build products
|
||||
desktop/controller.dll
|
||||
desktop/controller.dylib
|
||||
desktop/controller.so
|
||||
desktop/*.obj
|
||||
desktop/*.lib
|
||||
desktop/*.exp
|
||||
desktop/bundle/
|
||||
app/build/desktop/
|
||||
|
||||
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
|
||||
frame/kdeconnect/packages/
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fix smoke cleanup skipping orphan metadata and Steam shortcuts after a test
|
||||
title directory was already removed. Sweep and verify all six exact reserved
|
||||
smoke IDs; preserve similarly named titles. Make `scripts/frame-smoke.sh`
|
||||
work with POSIX sh and Python 3 without zsh, including from another working
|
||||
directory. Local regressions cover both fixes; no new headset run.
|
||||
|
||||
- Skip backup saves and settings reached through a symbolic link anywhere below
|
||||
the home folder, and recheck paths before packing each file. Local synthetic
|
||||
tests cover redirected Proton prefixes and links changed after discovery;
|
||||
concurrent filesystem changes can still race the checks.
|
||||
[Backup layout and limits](docs/backup-and-recovery.md).
|
||||
|
||||
- Import a base APK with supported native-library or asset splits from a local
|
||||
Android backup or XAPK/APKS/APKM bundle. Check package and version before
|
||||
install; refuse resource, code and feature splits and device-variant sets.
|
||||
This route has local fixture coverage, not a real split-game headset test.
|
||||
|
||||
- Save Steam screenshots under a separate folder for each headset, Steam
|
||||
account and game. Equal screenshot filenames no longer overwrite one another;
|
||||
existing files in the old flat folder are left in place.
|
||||
[Copying guide](docs/recording.md).
|
||||
|
||||
- Keep Steam clip exports separate for each headset, Steam account and nested
|
||||
recording session. Manual export and automatic copying use the same folders;
|
||||
existing flat exports stay untouched and do not mark unrelated clips saved.
|
||||
[Recording guide](docs/recording.md#steam-clips).
|
||||
|
||||
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
|
||||
Highlight exact PnP instances and known state changes after a dropout;
|
||||
incomplete inventories stay unknown. A Forget button clears the comparison.
|
||||
No adapter setting changes or report uploads are made.
|
||||
|
||||
- Add recovery steps to the Windows Wireless Adapter doctor for streams that
|
||||
drop as they start and dongles that will not reconnect. Compare exact PnP IDs
|
||||
and test one connection condition at a time. In-app disable/re-enable remains
|
||||
deferred until an active Steam adapter instance can be identified safely.
|
||||
[Guide and limits](docs/windows-wireless-doctor.md).
|
||||
|
||||
- Add an explicit host window-switch button to whole-display panels: Alt-Tab
|
||||
on Windows and Command-Tab on macOS. Single-window panels keep their own
|
||||
source, so the button is hidden there.
|
||||
|
||||
- Enable the in-app OBS source Start/Stop control on Windows. A control pipe
|
||||
lets the Python relay close its SSH child on Stop, app quit or parent exit;
|
||||
add a short [Discord guide](docs/streaming-to-discord.md).
|
||||
|
||||
- Add a read-only wired connection doctor for a PC using the Frame's observed
|
||||
USB-C network: show the local route, SSH port and pinned-key login result
|
||||
without changing IP, host keys or headset settings.
|
||||
[Usage and limits](docs/wired-connection.md).
|
||||
|
||||
- Start the Android app catalogue without waiting for the headset's installed-app
|
||||
list. Browsing remains available if that status request is slow or fails;
|
||||
installation still waits for a confirmed list to avoid duplicate installs.
|
||||
|
||||
- Verify the packaged OBS Start and Stop control with a real Frame, and record
|
||||
37.600 seconds of headset mirror video and non-silent output audio in OBS.
|
||||
Listening, sync and active passthrough remain unverified.
|
||||
[Test record](docs/evidence/obs-media-source-real-2026-10-25.md).
|
||||
|
||||
- Bundle the OBS source helper in Linux and macOS installers and add a
|
||||
**Capture & Live** Start/Stop control. The app selects the active Frame and
|
||||
closes its SSH stream on a headset switch or app exit. A real Frame stream
|
||||
reached OBS with video and non-silent audio; listening and sync remain
|
||||
unverified. [Setup and limits](docs/obs-headset-source.md).
|
||||
|
||||
- Add a source-checkout helper that sends the Frame's SteamVR mirror and
|
||||
default-output audio to a local OBS Media Source. A real Frame supplied a
|
||||
4.021-second H.264/AAC stream to a Linux receiver; OBS playback and sync
|
||||
remain unverified. [Setup and limits](docs/obs-headset-source.md).
|
||||
|
||||
- Link emulator setup guidance from Store → Emulators. Existing Steam library
|
||||
shortcuts and artwork cover Android apps and sideloaded titles; Android ROM
|
||||
transfer still needs a verified per-instance workflow.
|
||||
[Guide](docs/emulators.md).
|
||||
|
||||
- Record the current Frame OS mirror and charge-limit checks. Active
|
||||
passthrough in the mirror is still unknown; this build has no supported
|
||||
user-writable 80% charge-limit control.
|
||||
[Headset evidence](docs/evidence/round3-headset-2026-10-25.md).
|
||||
|
||||
- Add Windows Tools → Wireless Adapter doctor: an explicit read-only PC snapshot
|
||||
of distinct Wi-Fi Direct PnP instances, presence/link state, PC Wi-Fi and
|
||||
reported channels/bands, with possible 2.4 GHz overlap guidance. Unknowns stay
|
||||
visible; no adapter disable action is offered because an unused instance
|
||||
cannot be identified reliably. No headset access or settings changes.
|
||||
[Sources, verification and limits](docs/windows-wireless-doctor.md).
|
||||
|
||||
- Add a Linux PC Wireless Adapter doctor in Settings and Devices, with a source
|
||||
CLI and local JSON report. Select observed hardware without assuming Valve
|
||||
IDs; inspect USB/interface presence, driver/module binding, udev/permissions,
|
||||
rfkill, channel/band and network manager evidence. Missing evidence stays
|
||||
unknown; all checks are read-only and never contact the Frame.
|
||||
[Usage and verification limits](docs/linux-wireless-adapter-doctor.md).
|
||||
|
||||
- Add Linux Tools → Steam desktop sharing: request `steam -pipewire` only after
|
||||
checking Steam is not running, or open KDE Application Permissions with
|
||||
instructions to revoke a verified Steam screen-sharing session manually. No
|
||||
automatic permission reset, Steam restart, or headset access. Frame Control
|
||||
Linux capture remains disabled; live portal behavior is unverified.
|
||||
[Usage and limits](docs/steam-pipewire.md).
|
||||
|
||||
- Use gamescope's input for the keyboard and trackpad, following the focused
|
||||
headset panel without installing an input service. Start KDE Connect only for
|
||||
accents and emoji, keeping queued text ordered and cancelling it on headset
|
||||
switches. Retain intended-headset checks before starting either control and
|
||||
prevent Unicode helper startup from deadlocking with a headset switch.
|
||||
- Keep accepted keyboard text when the Unicode queue is full: reject new batches
|
||||
before sending their clicks or keys, and count in-flight text toward the limit.
|
||||
Retain unsent text after helper failures/timeouts, report the error and resume
|
||||
it with Try again. Clean up failed workers without touching a new headset's queue.
|
||||
[Current verification and device limits](docs/evidence/pad-gamescope-2026-10-24.md).
|
||||
|
||||
- Keep media playback running when SteamVR refuses the optional theatre dark
|
||||
surround, including a retry after standby. Log the refusal and play without
|
||||
the surround. Overlay teardown attempts every owned handle and always calls
|
||||
OpenVR shutdown even if a handle fails to close.
|
||||
|
||||
- Check the desktop server's single-instance lock before starting background
|
||||
downloads, so a second server exits cleanly with the existing "already
|
||||
running" message.
|
||||
|
||||
- Recheck saved spatial layouts on SteamOS 0.5.6 / SteamVR 2.18.2 (issue #31).
|
||||
Read-only probes expose some panel geometry, but safe restoration of
|
||||
Steam/gamescope docking and positions remains unverified. Record exact API
|
||||
results and a reproducible probe; saved layouts remain unavailable.
|
||||
|
||||
- Record packaged v0.4.17 Desktop Capture succeeding on a mapped nested KDE
|
||||
compositor on a real Frame with SteamOS 0.5.6. Headset overlay visibility was
|
||||
not established; this is evidence, not a new fix or resolution of issue #73.
|
||||
|
||||
- Correct the Return to Game Mode help text: Frame Control selects the switch
|
||||
supported by the headset's SteamOS build, including `steamosctl` on current
|
||||
Frames. The button's behavior is unchanged.
|
||||
|
||||
- Distinguish an unreadable gamescope panel list from a confirmed empty list in
|
||||
Desktop Capture and Control (issue #73). Failed discovery now asks users to
|
||||
check SteamVR and report the exact message and SteamOS version, rather than
|
||||
claiming no panel is open. This does not establish a new capture fix.
|
||||
|
||||
- Add an opt-in desktop low-battery alert without starting a family session.
|
||||
Existing status reads confirm low charge and drain; recovery hysteresis and a
|
||||
ten-minute cooldown limit repeat alerts. Off on page open/headset switch;
|
||||
pauses with hidden-page status refreshes. No extra polling or headset changes.
|
||||
|
||||
- Clarify first-time desktop downloads, installation and opening on the website
|
||||
and README, including CPU choices, unsigned-app warnings, pairing fallback
|
||||
and the source-only iOS build. Keep section navigation available on mobile.
|
||||
|
||||
- Treat headset aliases and other CSV metadata as plain text in power and heat
|
||||
exports, so spreadsheet formula prefixes cannot run when a report is opened.
|
||||
|
||||
- Add desktop Tools → Power & heat report: bounded read-only one/five-minute
|
||||
observations with progress, stop and HTML/JSON/CSV downloads. Received samples
|
||||
survive stop, disconnect and headset switch, labeled incomplete and tied to the
|
||||
original headset. Shared sampler/report modules ship with the app; no power or
|
||||
thermal controls are added. Download before replacing a report or quitting.
|
||||
|
||||
- Fix Return to Game Mode misidentifying full Plasma Desktop Mode when its VR
|
||||
overlay also runs gamescope (issue #142). Session targets now determine the
|
||||
mode; conflicting or incomplete evidence stays unknown. Use SteamOS's
|
||||
`steamosctl` switch when available, with older selector commands retained.
|
||||
|
||||
- Export read-only power and heat observations to an offline accessible HTML
|
||||
report with JSON/CSV downloads. `--html FILE` limits sessions to ten minutes;
|
||||
Ctrl+C saves received samples with an explicit incomplete state. Units,
|
||||
unknown counts and sources remain visible; no headset controls are added.
|
||||
|
||||
- Add a guarded `frame-doctor.sh --plan` with read-only, evidence-specific next
|
||||
steps and explicit local baseline reports for before/after diagnostics (issue
|
||||
#34). Restart risk blocks plans/comparisons; no automatic repairs, password
|
||||
changes or headset security changes are performed.
|
||||
|
||||
- Recover a tagged release upload when GitHub temporarily displays its draft
|
||||
as `untagged-…` after an edit, without creating a duplicate draft or
|
||||
silently retrying unrelated upload failures.
|
||||
|
||||
- Import standalone APK/OBB backups from XAPK/APKS/APKM archives through the
|
||||
existing preview and game-data setup flow. Unsafe archives, split sets and
|
||||
Android/data restores are refused with guidance. This does not establish RE4
|
||||
compatibility or diagnose its reported failure (issue #102).
|
||||
|
||||
- Add `scripts/frame-power-sample.sh`, a bounded read-only SSH sampler for
|
||||
battery, CPU, named temperatures, fan and available power rails (issue #52).
|
||||
JSON samples/summaries and CSV exports retain explicit unavailable states;
|
||||
active smoke-test locks stop measurement. No power settings are changed.
|
||||
|
||||
- Show saved Android compatibility reports on store search cards and app details.
|
||||
Reports refresh in the background, so a stalled database or credential lookup
|
||||
cannot delay search. Warnings allow installation; build compatibility blockers
|
||||
still apply. Public copies use this computer's reports, not private shared ratings.
|
||||
|
||||
- Fix two cases where optional diagnostic logs could retain part of a headset
|
||||
display name or an SSH host name, while preserving existing privacy redaction
|
||||
(issue #81).
|
||||
|
||||
- Keep the full host-key recovery explanation readable in Devices, including
|
||||
long addresses, and remove doubled punctuation from the connection tooltip
|
||||
(issue #87). Refresh stays within the header and switching headsets selects
|
||||
the new headset's details.
|
||||
|
||||
- Fix experimental PC screen capture on Windows builds whose streaming libraries
|
||||
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
|
||||
windows are hidden with an explanation instead of failing when shown. A Windows
|
||||
VM streamed a real desktop to the Frame; physical input remains unverified.
|
||||
|
||||
- Add read-only battery health in Tools: charging state, temperature, cycle count,
|
||||
and available capacity with explicit unknown states. The parser was checked
|
||||
on a real Frame; the connected desktop UI was tested with synthetic readings.
|
||||
|
||||
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
|
||||
library/game install-size breakdown, with unknown sizes, partial inventories
|
||||
and permission/missing-drive states. Includes instructions for Steam's own
|
||||
Move UI; Frame Control does not move or delete game files. The read-only
|
||||
parser was checked on a real Frame; the connected UI and game moves were not.
|
||||
|
||||
- Add `scripts/frame-doctor.sh` for read-only setup and pairing diagnostics,
|
||||
with verified SSH identity, private summaries and actionable next steps
|
||||
(issue #34). Connection steps links to the diagnostic guide. Repairs remain
|
||||
manual; restart-loop evidence stops deeper diagnostics. The real Frame
|
||||
verified the SSH connection and safety stop; deeper checks were skipped.
|
||||
- Fixed headset smoke cleanup reporting success while a smoke-title process
|
||||
could remain running after its files were removed. Cleanup now stops and
|
||||
rechecks processes tied to exact reserved test-title paths, uses PID handles
|
||||
for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and
|
||||
shared Wine services are left alone. Verified with local Linux process
|
||||
regressions and a 13/13 real Frame smoke run that stopped five test-owned
|
||||
Proton process PIDs before removing the Windows test title.
|
||||
|
||||
- Keep failed automatic screenshot and clip copies visible after three attempts.
|
||||
**Resume** and **Copy everything now** allow another try without restarting;
|
||||
headset switches cannot use another headset's list or saved-file history.
|
||||
[Copying guide](docs/recording.md).
|
||||
@@ -58,6 +58,9 @@ node --test site/test/*.test.mjs # website feedback function
|
||||
|
||||
Say what you tested, and whether you tried it on a real Steam Frame.
|
||||
|
||||
To add a local feature without changing the core app, see the
|
||||
[extension guide](docs/extensions.md) and its working example.
|
||||
|
||||
## Blocking
|
||||
|
||||
If you ignore this document twice, or spam the tracker with agent-generated
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
**Manage your Valve Steam Frame from your computer.**<br>
|
||||
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
|
||||
|
||||
[](https://github.com/saphid/steam-frame/releases/latest)
|
||||
[](https://github.com/saphid/frame-control/releases/latest)
|
||||
[](#install)
|
||||
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||
[](https://github.com/saphid/frame-control/actions/workflows/checks.yml)
|
||||
[](LICENSE)
|
||||
|
||||
[**Website**](https://frame-control.pages.dev) · [**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
|
||||
@@ -19,12 +19,14 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
<img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
|
||||
|
||||
<a id="trailer"></a>
|
||||
<a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
|
||||
<a href="https://github.com/saphid/frame-control/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
|
||||
|
||||
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
|
||||
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
[See what was tested on a real Frame, in a VM, or only in automated checks](docs/current-support-matrix.md).
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -35,8 +37,8 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**👓 Headset view**<br>
|
||||
Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
|
||||
**👓 Headset view and Desktop**<br>
|
||||
Live video of what the lenses show, or of the app panel in use, flat and still however the wearer looks around. Turn on Control and tap or click right on it to use the Frame from your phone or computer.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -44,13 +46,19 @@ Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom
|
||||
**🔋 Battery and status**<br>
|
||||
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||
|
||||
For PC-side USB, driver, radio and network manager evidence, use the
|
||||
[Linux Wireless Adapter doctor](docs/linux-wireless-adapter-doctor.md).
|
||||
|
||||
For repeatable read-only battery, power-rail, temperature and fan measurements,
|
||||
use the [power and heat sampler](docs/power-and-heat.md).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**🎮 Steam games**<br>
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
|
||||
Everything you own with its Steam Frame rating. Install onto the headset with live progress, search the store, and force quit a game that stops responding.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
@@ -63,14 +71,17 @@ About 4,500 F-Droid apps rated for the Frame. One click installs each as its own
|
||||
<tr>
|
||||
<td valign="top">
|
||||
|
||||
**📁 Files, games and clipboard**<br>
|
||||
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
|
||||
**📁 Files and games**<br>
|
||||
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you.
|
||||
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
**📸 Screenshots**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||
**📸 Screenshots and recordings**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record SteamVR's mirrored headset view, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder. [Recording details and passthrough limits](docs/recording.md#does-record-include-passthrough).
|
||||
|
||||
**⌨️ Keyboard and trackpad**<br>
|
||||
Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one. Copy text to the headset's clipboard and back again. Press T to open it.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -86,27 +97,73 @@ Install desktop apps like Moonlight or VLC, and set each Android app's resolutio
|
||||
**⚡ One-click tools**<br>
|
||||
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
**💾 Backup and recovery**<br>
|
||||
Copy game saves, settings and Android app data to your computer, and restore them after a factory reset. Sign-in files and keys are never copied. A Return to Game Mode button helps when the headset is stuck in Desktop Mode. Not tested on a headset yet. [How it works](docs/backup-and-recovery.md).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Nothing is installed on the Frame for any of this: the app uses what SteamOS
|
||||
The optional [Family and comfort](docs/family-comfort.md) card adds session
|
||||
limits, breaks, local alerts and one-click casting. A session copies a small
|
||||
Frame Control worker into your headset user account.
|
||||
|
||||
For the other features, nothing is installed on the Frame: the app uses what SteamOS
|
||||
already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
|
||||
`~/devkit-utils`, as Valve's Devkit Client does). The optional
|
||||
[performance HUD](docs/vr-utilities.md) copies our own Python helpers into
|
||||
`~/.local/share/frame-control/vr/`. [How each feature works](docs/frame-control.md).
|
||||
|
||||
## Install
|
||||
|
||||
**Install Frame Control on your computer, not on the headset.** The website is
|
||||
for downloads and instructions; it does not connect to your Frame in a browser.
|
||||
Choose the operating system and processor of the computer you will use.
|
||||
|
||||
| | Download | Needs |
|
||||
|---|---|---|
|
||||
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra |
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||
| **macOS** (Intel, v0.4.39+) | [Frame-Control-mac-x64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-x64.dmg) | Nothing extra |
|
||||
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip) | OpenSSH Client (normally installed) |
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
|
||||
**iPhone and iPad:** the same features from your phone, with nothing to install on
|
||||
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
|
||||
### Is it safe? Check your download
|
||||
|
||||
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
|
||||
Starting with **v0.4.26**, get the installer and [SHA256SUMS](https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS) from the **same release**. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Use the command for your downloaded file, then compare all 64 characters with its line in `SHA256SUMS`:
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256
|
||||
```
|
||||
|
||||
```sh
|
||||
# macOS: shasum -a 256 Frame-Control-mac-arm64.dmg
|
||||
# Linux: sha256sum Frame-Control-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
The [source](https://github.com/saphid/frame-control) and [release build workflow](.github/workflows/release.yml) are public. The Windows installer is **not code-signed**, so SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.
|
||||
|
||||
日本語: [5 ステップの接続ガイド](docs/connection-faq.md#日本語-5-ステップで接続)を参照してください。v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
|
||||
|
||||
**Which build?** On a Mac, check Apple menu → About This Mac: use `mac-arm64`
|
||||
for Apple Silicon (M-series), or `mac-x64` for Intel (available from v0.4.39). On Windows,
|
||||
check Settings → System → About → System type: the release is x64, not a native
|
||||
Windows ARM build. On Linux, `uname -m` reports `x86_64` (x64) or `aarch64`
|
||||
(arm64). Use `.deb` on Debian/Ubuntu, or AppImage on other distributions.
|
||||
|
||||
**iPhone and iPad:** no App Store, TestFlight or `.ipa` download is provided in
|
||||
the current release. It runs from your phone without a computer after setup.
|
||||
Build it from [`ios/`](ios) in Xcode on a Mac with your own signing team; see
|
||||
[docs/iphone.md](docs/iphone.md).
|
||||
|
||||
The app brings its own Python and `adb`. macOS includes SSH; Windows needs the
|
||||
OpenSSH Client optional feature (normally installed).
|
||||
From 0.4 it updates itself: when a new version is published, a banner offers
|
||||
**Update and restart**. It sends anonymous usage statistics, which you can turn
|
||||
off. Sharing compatibility results and error details is opt-in. See
|
||||
@@ -117,16 +174,44 @@ distribution's. If you already have `adb`, the app uses yours.
|
||||
<details>
|
||||
<summary><b>macOS: the app isn't notarized</b></summary>
|
||||
|
||||
There's no paid Apple developer account behind it, so macOS says the app is
|
||||
damaged or can't be checked. Drag it to Applications, then clear the download
|
||||
quarantine once:
|
||||
The app has an **ad hoc signature**, no Apple Developer ID signature, and no
|
||||
notarization. The DMG is unsigned. First verify the download against the
|
||||
`SHA256SUMS` from the same release, then open the DMG and drag **Frame Control**
|
||||
to **Applications**.
|
||||
|
||||
Check your processor before opening: v0.4.38 and earlier publish only
|
||||
`mac-arm64` for Apple Silicon. On an Intel Mac, this build cannot run;
|
||||
removing quarantine or installing Rosetta does not make an ARM app run on Intel.
|
||||
On a real Intel MacBook Air with macOS 15.7.7, opening v0.4.35 and v0.4.36
|
||||
reported: **“The application cannot be opened because it has an incorrect
|
||||
executable format.”** This is an architecture failure, not an observed
|
||||
Gatekeeper warning. The official **v0.4.39 Intel DMG** was then verified,
|
||||
installed and launched on that Air. Both CLI and T3 browser downloads lacked
|
||||
quarantine metadata; no Gatekeeper dialog appeared, while `spctl` rejected the
|
||||
ad hoc signed app. This does not verify Safari/Chrome warnings or Open Anyway.
|
||||
See the [released Intel test](docs/evidence/mac-v0439-2026-10-26.md) and
|
||||
[earlier architecture failure](docs/evidence/mac-desktop-2026-10-26.md).
|
||||
|
||||
On a compatible Mac, if macOS blocks an unverified developer, try opening the
|
||||
app once, then go to **System Settings → Privacy & Security → Open Anyway**
|
||||
and confirm **Open**. Only do this for a download you verified and trust.
|
||||
[Apple's opening instructions](https://support.apple.com/102445) describe the
|
||||
current security controls. The exact warning and this override flow have not
|
||||
yet been verified here on either Mac architecture.
|
||||
|
||||
If a trusted, verified copy still reports “damaged” and offers no Open Anyway
|
||||
option, the Terminal fallback removes only this app's download quarantine
|
||||
(it does not sign or notarize the app):
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
```
|
||||
|
||||
The first time, macOS also asks to allow local network access (for SSH) and
|
||||
control of Terminal (for the password prompts).
|
||||
Then open it from Applications. Do not disable Gatekeeper globally. Depending
|
||||
on the features you use, macOS may also request Local Network access, permission
|
||||
to control Terminal, or Screen Recording; approve only the feature you intend
|
||||
to use. Those permission prompts were not verified in the release test above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -148,26 +233,54 @@ If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
|
||||
or run it with `--appimage-extract-and-run`.
|
||||
</details>
|
||||
|
||||
## Open the app
|
||||
|
||||
**Optional: open at login.** In the installed desktop app, choose **Frame → Open at Login**. It is off by default. When on, a login launch starts in the tray or menu bar if that control is available; click its icon to open the window, or use its **Quit Frame Control** menu item. Closing the window keeps the app in the tray while this option is on. Turn the same menu option off to remove its OS login item. On Linux this creates or removes only Frame Control's own file in `~/.config/autostart/` (or `$XDG_CONFIG_HOME/autostart/`). If the desktop has no working tray, the window opens at login instead. A source checkout does not offer this setting.
|
||||
|
||||
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
|
||||
open it from Applications. For the Gatekeeper warning, use the macOS steps
|
||||
above. Allow local network access and control of Terminal when prompted.
|
||||
- **Windows:** run the installer, then open **Frame Control** from Start. For
|
||||
the portable download, extract the entire `.zip` first and run
|
||||
`Frame Control.exe` inside it. See the SmartScreen note above.
|
||||
- **Linux:** install the `.deb` with your package installer and launch
|
||||
**Frame Control** from your applications menu; or make the AppImage executable
|
||||
and run it as described above. Install the OpenSSH client if `ssh` is missing.
|
||||
|
||||
## Set up the headset
|
||||
|
||||
You type one password on the headset, once. Everything else happens on your
|
||||
computer.
|
||||
Keep the Frame awake and connect it and your computer to the same trusted local
|
||||
network. Guest Wi-Fi/client isolation can prevent discovery and connections.
|
||||
|
||||
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
|
||||
in the Developer section, **Set User Password**. Pick something short:
|
||||
you'll type it once more on your computer and then never again.
|
||||
2. **On your computer:** open Frame Control. It offers to **Set Up
|
||||
Connection**, which finds the headset, creates an SSH key, and asks for that
|
||||
password once in a terminal window. If it can't find the Frame, type the
|
||||
IP address from the Frame's Quick Settings.
|
||||
Developer → **Set User Password**. Choose a password that isn't easy to guess:
|
||||
it also protects `sudo` and remote desktop, even after SSH keys are set up.
|
||||
2. **On your computer:** open Frame Control and choose **Set Up Connection…**.
|
||||
If you dismissed the first-run prompt, use **Frame → Set Up Connection…**.
|
||||
Setup opens a terminal and tries to find the headset. If it can't, use its IP
|
||||
address from Quick Settings or Steam Settings → Internet. Windows/Linux setup
|
||||
prompts for it; macOS exits instead. On macOS, return to the app →
|
||||
**Devices & connection → + Add a headset…**, set SSH alias to `frame` for
|
||||
the default headset, fill in **Address**, then choose **Set Up…**.
|
||||
3. **Pair or enter the password:** setup first tries Valve's devkit pairing.
|
||||
Open Steam Settings → Developer → **Pair new host** in the headset and approve
|
||||
only the request you just started. The service and pairing-mode step are
|
||||
verified on a Frame; approval and key installation through that flow remain
|
||||
unverified. If pairing fails or times out, setup falls back to asking for the
|
||||
Developer Mode password in the computer's terminal. Password typing may show
|
||||
no characters; press Enter when done. See [SSH details](docs/ssh.md#password-free-pairing-steamos-devkit-service).
|
||||
4. After setup succeeds, return to Frame Control. It uses SSH keys for later
|
||||
connections whenever the Frame is awake and reachable. If it stays offline,
|
||||
check Developer Mode, the address and network isolation, then run setup again.
|
||||
For connections away from home, see [Tailscale](docs/tailscale.md).
|
||||
|
||||
Before asking for the password it tries Valve's SteamOS devkit pairing: in
|
||||
the headset, open Steam Settings → Developer → **Pair new host** and approve
|
||||
the request, and no password is needed. (The service and the pairing-mode
|
||||
step are verified on a Frame; the approval itself isn't yet. See
|
||||
[SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).)
|
||||
3. That's it. The app now reaches the headset whenever it's awake and on the
|
||||
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
|
||||
**Does Frame Control lower streaming lag?** No. Frame Control manages the Frame
|
||||
over SSH. SteamVR or Steam Link carries the game stream. Frame Control does not
|
||||
carry that stream, lower ping, or need the Wireless Adapter. It can connect over
|
||||
Wi-Fi, the Wireless Adapter, or USB when your computer has an SSH route to the
|
||||
Frame. The wired doctor checks only that SSH route. A data-capable USB-A-to-C
|
||||
cable has not been tested for the Frame's USB network. See the
|
||||
[connection FAQ and Japanese quick start](docs/connection-faq.md).
|
||||
|
||||
**What it changes:** only what you click. Installs go to your user account on
|
||||
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
|
||||
@@ -180,7 +293,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
||||
|
||||
This is a first public test, so reports are really useful, especially from
|
||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
||||
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
|
||||
diagnostics with personal details removed, shows you exactly what's included,
|
||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||
@@ -203,17 +316,24 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
|
||||
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
|
||||
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||
| [SteamOS October 2026 compatibility](docs/steamos-2026-10-compat.md) | Frame OS 0.5.6 connection changes and SteamOS 3.9.5 PC host notes |
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
|
||||
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Steam desktop sharing (Linux)](docs/steam-pipewire.md) | External Steam helper: launch with `-pipewire` after exiting Steam, or open KDE permissions to re-pick a screen manually; no automatic permission reset |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control in VR](docs/headset-app.md) · [Frame plugin author guide](docs/frame-plugin-author.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, a removable headset panel, and the local extension API |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
|
||||
| [Power and heat measurements](docs/power-and-heat.md) | Read-only sampling and offline HTML reports with JSON/CSV downloads |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
|
||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||
|
||||
<details>
|
||||
@@ -228,8 +348,8 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
|
||||
database key (maintainer-only) is never written to the repo.
|
||||
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
|
||||
you've switched to key auth, a short password still protects `sudo` and
|
||||
RDP, so pick one that isn't trivially guessable.
|
||||
you've switched to key auth, that password still protects `sudo` and RDP,
|
||||
so pick one that isn't trivially guessable.
|
||||
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
|
||||
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
|
||||
**every** Frame port is reachable from your tailnet, including Steam's DevTools
|
||||
@@ -251,5 +371,9 @@ into a draft release, which reaches users once published. See
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE). Steam, Steam Frame and SteamVR are trademarks of Valve
|
||||
[MIT](LICENSE). The apps also ship other people's software under its own
|
||||
licence, notably KDE Connect (GPL) for the keyboard and trackpad; see
|
||||
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Steam, Steam Frame and SteamVR are trademarks of Valve
|
||||
Corporation. This project isn't affiliated with or endorsed by Valve.
|
||||
|
||||
See [Game storage](docs/game-storage.md) for read-only headset space and Steam install sizes, and [the changelog](CHANGELOG.md) for unreleased changes.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Third-party software in Frame Control
|
||||
|
||||
Frame Control's own code is under the [MIT licence](LICENSE). The apps also
|
||||
ship other people's software, unchanged, each under its own licence:
|
||||
|
||||
| What | Where | Licence | Details |
|
||||
|---|---|---|---|
|
||||
| KDE Connect 24.02.2 and five libraries, as Valve builds them for the Frame | Desktop apps and the iPhone app; copied to the Frame for the keyboard and trackpad | GPL and LGPL (per package) | [frame/kdeconnect/NOTICE.md](frame/kdeconnect/NOTICE.md), licence texts in [frame/kdeconnect/LICENSES](frame/kdeconnect/LICENSES), complete source in the [kdeconnect-frame-24.02.2-1 release](https://github.com/saphid/frame-control/releases/tag/kdeconnect-frame-24.02.2-1) |
|
||||
| Python 3.12 ([python-build-standalone](https://github.com/astral-sh/python-build-standalone)) | Desktop apps | PSF License and others | Included with it, in the app's `python` folder |
|
||||
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
|
||||
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
|
||||
| Electron | Desktop apps | MIT (Chromium: BSD-3-Clause and others) | `LICENSE` and `LICENSES.chromium.html` in the app |
|
||||
| GStreamer 1.x, a few of its plugins and the libraries they need, as [MSYS2](https://packages.msys2.org/) builds them | Windows app only, for the experimental [PC in the headset](docs/pc-in-headset.md) | LGPL-2.1-or-later and others (per package); the GPL x264 encoder is left out | `libraries.json` (exact package versions) and the `licenses` folder in the app's `desktop/bundle` folder |
|
||||
|
||||
Frame Control starts KDE Connect and talks to it over its network protocol;
|
||||
it doesn't link to it or include its code. On Windows, Frame Control's own
|
||||
`pc-host.dll` links to GStreamer dynamically; the libraries are unmodified.
|
||||
@@ -6,11 +6,25 @@ A report: package, version, result (runs | crashes | install_failed |
|
||||
instance_failed, from an automated test), rating (works | issues | broken, from
|
||||
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
|
||||
Newest wins, and a person's rating beats an automated result.
|
||||
|
||||
An install_failed report saying the file had no arm64-v8a build (or needed a
|
||||
newer Android) is about that one APK file, often the wrong per-ABI download of
|
||||
an app that has an arm64 build, so it says nothing about the app and is left out.
|
||||
"""
|
||||
import re
|
||||
|
||||
FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
|
||||
|
||||
|
||||
def about_app(r):
|
||||
"""False for reports about one wrong APK file rather than the app itself."""
|
||||
return not (r.get('result') == 'install_failed' and not r.get('rating')
|
||||
and FILE_FAULT.search(r.get('notes') or ''))
|
||||
|
||||
|
||||
def verdict(reports):
|
||||
"""(verdict, summary lines) for one package's reports, or None."""
|
||||
reports = [r for r in reports or () if about_app(r)]
|
||||
if not reports:
|
||||
return None
|
||||
rs = sorted(reports, key=lambda r: r.get('date') or '')
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Checks that the app's resources hold what Frame Control can't work without.
|
||||
// The OpenXR compatibility layer is why this exists: VR APK installs need its
|
||||
// arm64-v8a library (ui/frame_android.py XR_COMPAT_FILES).
|
||||
//
|
||||
// As electron-builder's afterPack hook it checks the unpacked app. From the
|
||||
// command line it checks an installed copy, which is what caught the Windows
|
||||
// installer silently dropping the library (see .github/workflows/release.yml):
|
||||
// node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]
|
||||
// With a reference (the unpacked build's resources), every file in it must also
|
||||
// be in the installed copy, at the same size.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const REQUIRED = [
|
||||
"ui/server.py",
|
||||
"ui/frame_power.py",
|
||||
"ui/frame_status.py",
|
||||
"scripts/frame_power_sample.py",
|
||||
"scripts/frame_power_report.py",
|
||||
"scripts/obs-headset-source.py",
|
||||
"ui/frame_android.py",
|
||||
"frame/android/lepton-app.sh",
|
||||
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
|
||||
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz",
|
||||
];
|
||||
|
||||
function resourcesDir(context) {
|
||||
if (context.electronPlatformName === "darwin" || context.electronPlatformName === "mas") {
|
||||
const app = `${context.packager.appInfo.productFilename}.app`;
|
||||
return path.join(context.appOutDir, app, "Contents", "Resources");
|
||||
}
|
||||
return path.join(context.appOutDir, "resources");
|
||||
}
|
||||
|
||||
function size(file) {
|
||||
try {
|
||||
return fs.statSync(file).size;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Required files that are missing or empty.
|
||||
function missing(dir) {
|
||||
return REQUIRED.filter((rel) => size(path.join(dir, rel)) <= 0);
|
||||
}
|
||||
|
||||
// Files under reference that aren't in dir at the same size.
|
||||
function differences(dir, reference) {
|
||||
const out = [];
|
||||
(function walk(rel) {
|
||||
for (const e of fs.readdirSync(path.join(reference, rel), { withFileTypes: true })) {
|
||||
const r = path.join(rel, e.name);
|
||||
if (e.isDirectory()) walk(r);
|
||||
else if (e.isFile() && size(path.join(dir, r)) !== size(path.join(reference, r))) out.push(r.split(path.sep).join("/"));
|
||||
}
|
||||
})("");
|
||||
return out;
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
const dir = resourcesDir(context);
|
||||
const gone = missing(dir);
|
||||
if (gone.length) {
|
||||
throw new Error(`packaged app is missing required resources in ${dir}: ${gone.join(", ")}`);
|
||||
}
|
||||
};
|
||||
exports.missing = missing;
|
||||
exports.differences = differences;
|
||||
exports.REQUIRED = REQUIRED;
|
||||
|
||||
if (require.main === module) {
|
||||
const [dir, reference] = process.argv.slice(2);
|
||||
if (!dir) {
|
||||
console.error("usage: node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]");
|
||||
process.exit(2);
|
||||
}
|
||||
const problems = [...missing(dir), ...(reference ? differences(dir, reference) : [])];
|
||||
if (problems.length) {
|
||||
console.error(`${dir} is missing or has the wrong size for:\n ${[...new Set(problems)].join("\n ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`${dir}: all required resources present${reference ? ", and every file of " + reference : ""}`);
|
||||
}
|
||||
+23
-2
@@ -1,8 +1,10 @@
|
||||
// Downloads what the app bundles so users install nothing else: a standalone
|
||||
// Python (python-build-standalone), adb (Android platform-tools) and a CA
|
||||
// bundle. Each goes in build/deps/<os>-<arch>/{python,tools}, which package.json
|
||||
// copies into the app's resources. Everything is pinned by version and SHA-256.
|
||||
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
|
||||
// copies into the app's resources. Also KDE Connect for the Frame (the same arm64
|
||||
// packages for every build), into ../frame/kdeconnect/packages as listed in
|
||||
// ../frame/kdeconnect/packages.json. Everything is pinned by version and SHA-256.
|
||||
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
@@ -13,6 +15,7 @@ const PY = "3.12.14+20260924";
|
||||
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
|
||||
+ `${PY.split("+")[1]}/cpython-${PY}-${triple}-install_only_stripped.tar.gz`;
|
||||
const PYTHON = {
|
||||
"mac-x64": ["x86_64-apple-darwin", "7ea9761b9069c10b9a20531d568645849d604c59e9c7f11f6659f1e1790c968e"],
|
||||
"mac-arm64": ["aarch64-apple-darwin", "c2edb321cd32ec2b170df208db0446dccc4398db602ca27cf2079098fb1f7d9d"],
|
||||
"win-x64": ["x86_64-pc-windows-msvc", "c5bf8edfe858c1df9891be498b5bbc8761d383df5b9790658b088fea4870433a"],
|
||||
"linux-x64": ["x86_64-unknown-linux-gnu", "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41"],
|
||||
@@ -127,8 +130,26 @@ async function fetch(os, arch) {
|
||||
console.log(`${key}: ${version} -> ${out}`);
|
||||
}
|
||||
|
||||
// As frame/kdeconnect/fetch.py does for the iPhone app's bundle.
|
||||
async function fetchKdeConnect() {
|
||||
const dir = path.join(__dirname, "..", "..", "frame", "kdeconnect");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "packages.json"), "utf8"));
|
||||
const out = path.join(dir, "packages");
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
for (const p of manifest.packages) {
|
||||
const file = path.join(out, p.file);
|
||||
if (fs.existsSync(file) && sha(file) === p.sha256) continue;
|
||||
await download(manifest.release + p.file, p.sha256, file);
|
||||
}
|
||||
const wanted = new Set(manifest.packages.map((p) => p.file));
|
||||
for (const f of fs.readdirSync(out)) if (!wanted.has(f)) fs.rmSync(path.join(out, f), { recursive: true, force: true });
|
||||
console.log(`KDE Connect for the Frame: ${manifest.packages.length} packages -> ${out}`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const [os, ...archs] = process.argv.slice(2);
|
||||
if (!os || !archs.length) throw new Error("usage: node build/fetch-deps.js <mac|win|linux> <arch>...");
|
||||
for (const arch of archs) await fetch(os, arch);
|
||||
await fetchKdeConnect();
|
||||
})().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
@@ -0,0 +1,53 @@
|
||||
// Opt-in OS login item. Linux owns only its marked autostart file; an existing
|
||||
// custom entry is never overwritten or removed.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const MARKER = "# Managed by Frame Control; use Frame > Open at Login to remove.\n";
|
||||
const FILE = "com.saphid.frame-control.desktop";
|
||||
|
||||
function quoteExec(value) {
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`").replace(/%/g, "%%")}"`;
|
||||
}
|
||||
|
||||
function createLoginItem({ platform = process.platform, electronApp, env = process.env,
|
||||
executable = process.execPath, filesystem = fs,
|
||||
home = os.homedir() }) {
|
||||
const config = env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME)
|
||||
? env.XDG_CONFIG_HOME : path.join(home, ".config");
|
||||
const file = path.join(config, "autostart", FILE);
|
||||
const command = platform === "linux" && env.APPIMAGE && path.isAbsolute(env.APPIMAGE)
|
||||
? env.APPIMAGE : executable;
|
||||
function owned() {
|
||||
try { return filesystem.readFileSync(file, "utf8").startsWith(MARKER); }
|
||||
catch (err) { if (err.code === "ENOENT") return false; throw err; }
|
||||
}
|
||||
function enabled() {
|
||||
if (platform === "linux") return owned();
|
||||
return !!electronApp.getLoginItemSettings().openAtLogin;
|
||||
}
|
||||
function set(enabledNow) {
|
||||
if (platform !== "linux") {
|
||||
electronApp.setLoginItemSettings({ openAtLogin: !!enabledNow, openAsHidden: !!enabledNow,
|
||||
args: enabledNow ? ["--hidden"] : [] });
|
||||
if (enabled() !== !!enabledNow) throw new Error("The operating system did not save the login item.");
|
||||
return enabled();
|
||||
}
|
||||
if (enabledNow) {
|
||||
if (filesystem.existsSync(file) && !owned()) throw new Error(`A different autostart entry already exists: ${file}`);
|
||||
filesystem.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const body = MARKER + "[Desktop Entry]\nType=Application\nName=Frame Control\n" +
|
||||
`Exec=${quoteExec(command)} --hidden\nTerminal=false\nX-GNOME-Autostart-enabled=true\n`;
|
||||
const tmp = file + `.tmp-${process.pid}`;
|
||||
try { filesystem.writeFileSync(tmp, body, { mode: 0o600 }); filesystem.renameSync(tmp, file); }
|
||||
finally { try { filesystem.unlinkSync(tmp); } catch (err) { if (err.code !== "ENOENT") throw err; } }
|
||||
} else if (owned()) {
|
||||
filesystem.unlinkSync(file);
|
||||
}
|
||||
return enabled();
|
||||
}
|
||||
return { enabled, set, file };
|
||||
}
|
||||
|
||||
module.exports = { createLoginItem, quoteExec };
|
||||
+304
-31
@@ -1,7 +1,7 @@
|
||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, shell } = require("electron");
|
||||
const { app, BrowserWindow, Menu, Notification, Tray, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
@@ -11,6 +11,9 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
const { preferenceStore } = require("./preferences");
|
||||
const { createLoginItem } = require("./login-item");
|
||||
const { createMetaSignIn } = require("./meta-signin");
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
@@ -27,10 +30,13 @@ const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Contr
|
||||
const LOG = path.join(LOG_DIR, "server.log");
|
||||
const BG = "#0d1117";
|
||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
|
||||
const loginItem = createLoginItem({ electronApp: app });
|
||||
|
||||
let server = null;
|
||||
let url = null;
|
||||
let win = null;
|
||||
let tray = null;
|
||||
let quitting = false;
|
||||
let python = null;
|
||||
|
||||
@@ -115,10 +121,11 @@ function ping(target) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
||||
// sends nothing from a source checkout.
|
||||
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
|
||||
// which sends nothing from a source checkout.
|
||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||
python = await findPython(env);
|
||||
@@ -149,7 +156,7 @@ async function startServer() {
|
||||
const target = `http://127.0.0.1:${port}/`;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
||||
if (await ping(target)) { url = target; return; }
|
||||
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (server === child) server = null;
|
||||
@@ -159,51 +166,76 @@ async function startServer() {
|
||||
|
||||
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
||||
// way on Windows); SIGTERM does the same elsewhere.
|
||||
// Resolves once it has exited (or after 20 s), so a replacement can take the server
|
||||
// lock: server.py allows one per user.
|
||||
function endServer(child) {
|
||||
const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
|
||||
: new Promise((resolve) => child.once("exit", resolve));
|
||||
try { child.stdin.end(); } catch {}
|
||||
if (!IS_WIN) child.kill("SIGTERM");
|
||||
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
|
||||
// server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill.
|
||||
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref();
|
||||
return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]);
|
||||
}
|
||||
|
||||
function stopServer() {
|
||||
if (server) endServer(server);
|
||||
}
|
||||
|
||||
function errorPage(message) {
|
||||
function errorPage(message, title = "Frame Control couldn't start") {
|
||||
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
||||
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
|
||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
|
||||
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
|
||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
|
||||
<p>${esc(message)}</p>
|
||||
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
|
||||
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
|
||||
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
|
||||
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
||||
}
|
||||
|
||||
// A server that had been running starts again by itself (something stopped it: a
|
||||
// signal, a crash). One that stops again within a minute shows the error instead,
|
||||
// so a server that can't stay up doesn't restart forever.
|
||||
let serverStarted = 0;
|
||||
function serverDied(why) {
|
||||
url = null;
|
||||
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
||||
if (!win) return;
|
||||
if (Date.now() - serverStarted > 60000) restartServer();
|
||||
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
const old = server;
|
||||
server = null;
|
||||
url = null;
|
||||
if (old) endServer(old);
|
||||
await load();
|
||||
// Restarts that overlap share one: two could each start a server, and the one
|
||||
// that lost the lock would leave the app pointing at nothing.
|
||||
let restarting = null;
|
||||
function restartServer() {
|
||||
if (!restarting) {
|
||||
restarting = (async () => {
|
||||
const old = server;
|
||||
server = null;
|
||||
url = null;
|
||||
if (old) await endServer(old);
|
||||
if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh
|
||||
await load();
|
||||
})().finally(() => { restarting = null; });
|
||||
}
|
||||
return restarting;
|
||||
}
|
||||
|
||||
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||
const CHROME_CSS = IS_MAC && `
|
||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
||||
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
||||
header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; }
|
||||
`;
|
||||
|
||||
// Restart Server can start a new load while an older one is still waiting for
|
||||
// its server; only the newest load may touch the window.
|
||||
let loadGen = 0;
|
||||
let starting = null; // loads that overlap share one server start
|
||||
async function load() {
|
||||
const gen = ++loadGen;
|
||||
try {
|
||||
if (!url) await startServer();
|
||||
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
|
||||
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
||||
} catch (e) {
|
||||
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
||||
@@ -247,12 +279,158 @@ function fromUi(e) {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// The error page's Try Again button. The error page is the only data: page the window
|
||||
// shows (`url` can still be set then: the server answered but the page failed to load).
|
||||
ipcMain.handle("server:restart", (e) => {
|
||||
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
|
||||
});
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
// Text from the headset's clipboard onto this computer's.
|
||||
ipcMain.handle("clipboard:write", (e, text) => {
|
||||
if (!fromUi(e) || typeof text !== "string") return false;
|
||||
clipboard.writeText(text);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("preferences:read", (e) => fromUi(e) ? preferences.read() : {});
|
||||
ipcMain.handle("preferences:write", (e, key, value) => fromUi(e) ? preferences.write(key, value) : false);
|
||||
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
|
||||
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
|
||||
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
|
||||
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
|
||||
if (img.isEmpty()) throw new Error("not an image");
|
||||
clipboard.writeImage(img);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
||||
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
||||
|
||||
// The OBS source is local to this computer. Closing the Python relay's control
|
||||
// pipe lets it stop SSH on every platform, including Windows where SIGTERM
|
||||
// terminates Python without running its cleanup block.
|
||||
const OBS_SCRIPT = path.join(SCRIPTS, "obs-headset-source.py");
|
||||
const { createObsSource } = require("./obs-source");
|
||||
const obsSource = createObsSource({
|
||||
spawn,
|
||||
// Keep the button hidden until native Windows/Frame streaming is verified.
|
||||
supported: !IS_WIN,
|
||||
prepare: async alias => {
|
||||
if (!fs.existsSync(OBS_SCRIPT)) throw new Error("The OBS helper is missing; reinstall Frame Control.");
|
||||
const env = { ...process.env, PATH: await loginPath() };
|
||||
const executable = python || await findPython(env);
|
||||
if (!executable) throw new Error(PYTHON_HELP);
|
||||
if (quitting || alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
|
||||
return { executable, args: [...PY_FLAGS, OBS_SCRIPT], env };
|
||||
},
|
||||
onState: state => {
|
||||
if (win && !win.isDestroyed()) win.webContents.send("obs:state", state);
|
||||
},
|
||||
});
|
||||
const publicObs = () => obsSource.status();
|
||||
const stopObs = () => obsSource.stop();
|
||||
ipcMain.handle("obs:status", e => fromUi(e) ? publicObs() : null);
|
||||
ipcMain.handle("obs:start", (e, port) => fromUi(e) ? obsSource.start(activeAlias(), port) : null);
|
||||
ipcMain.handle("obs:stop", e => fromUi(e) ? stopObs() : null);
|
||||
|
||||
// The page reports the headsets it knows (the server's Devices tab), so the Frame
|
||||
// menu can switch between them. Only plain names and ids go into the menu.
|
||||
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
let devices = [];
|
||||
ipcMain.on("devices:changed", (e, list) => {
|
||||
if (!fromUi(e) || !Array.isArray(list)) return;
|
||||
const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || ""))
|
||||
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
|
||||
if (JSON.stringify(next) === JSON.stringify(devices)) return;
|
||||
devices = next;
|
||||
if (obsSource.status().alias && obsSource.status().alias !== activeAlias()) stopObs();
|
||||
buildMenu();
|
||||
});
|
||||
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
|
||||
|
||||
// SSH through the server, so it goes to the headset and address the app is using
|
||||
// (with its own pinned identity), and refuses when there's none.
|
||||
function openSsh() {
|
||||
if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running.");
|
||||
const body = JSON.stringify({ what: "terminal" });
|
||||
const req = http.request(new URL("/api/open", url), {
|
||||
method: "POST", timeout: 15000,
|
||||
headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) },
|
||||
}, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (c) => { data += c; });
|
||||
res.on("end", () => {
|
||||
if (res.statusCode === 200) return;
|
||||
let why = `HTTP ${res.statusCode}`;
|
||||
try { why = JSON.parse(data).error || why; } catch {}
|
||||
dialog.showErrorBox("Couldn't open SSH", why);
|
||||
});
|
||||
});
|
||||
req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message));
|
||||
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
|
||||
req.end(body);
|
||||
}
|
||||
function showDevices() {
|
||||
if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
|
||||
}
|
||||
|
||||
ipcMain.handle("comfort:notify", (e, message) => {
|
||||
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
||||
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
||||
return new Promise((resolve, reject) => {
|
||||
const notification = new Notification({title: "Frame Control", body: message});
|
||||
const timer = setTimeout(() => reject(new Error("Notification delivery was not confirmed. Check system notification settings.")), 5000);
|
||||
notification.once("show", () => { clearTimeout(timer); resolve(true); });
|
||||
notification.once("failed", (_event, error) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Notification delivery failed. Check system notification settings: " + error));
|
||||
});
|
||||
notification.show();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Sign in with Meta (app/meta-signin.js, docs/meta-library.md) ----
|
||||
// The token goes from the sign-in window's own session straight to the server's
|
||||
// connect path; the page asks for it and only hears the server's answer.
|
||||
function postApi(apiPath, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url) return reject(new Error("Frame Control's server isn't running."));
|
||||
const data = JSON.stringify(body);
|
||||
const req = http.request(new URL(apiPath, url), {
|
||||
method: "POST", timeout: 60000,
|
||||
headers: { "Content-Type": "application/json", "X-Frame-UI": process.env.FRAME_UI_KEY || "1",
|
||||
"Content-Length": Buffer.byteLength(data) },
|
||||
}, (res) => {
|
||||
let text = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (c) => { text += c; });
|
||||
res.on("end", () => {
|
||||
let parsed = {};
|
||||
try { parsed = JSON.parse(text); } catch {}
|
||||
resolve({ status: res.statusCode, data: parsed });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
|
||||
req.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
let metaSignIn = null;
|
||||
const meta = () => (metaSignIn ||= createMetaSignIn({
|
||||
BrowserWindow, session, shell, parent: () => win,
|
||||
// The page only offers sign-in after its "I understand" box is ticked (checked again below).
|
||||
connect: (token, attempt) => postApi("/api/meta", { action: "connect", token, acknowledged: true, attempt }),
|
||||
cancel: (attempt) => postApi("/api/meta", { action: "cancel-signin", attempt }),
|
||||
}));
|
||||
ipcMain.handle("meta:signin", (e, acknowledged) => {
|
||||
if (!fromUi(e)) return null;
|
||||
if (acknowledged !== true) return { ok: false, error: "My Meta library is experimental: tick that you understand the warning first." };
|
||||
return meta().signIn();
|
||||
});
|
||||
ipcMain.handle("meta:signout", (e) => fromUi(e) ? meta().signOut() : false);
|
||||
|
||||
// frame-control://install links from websites (docs/web-install.md). They can
|
||||
// arrive before the window or server exists (macOS open-url on a cold launch),
|
||||
// so they wait here until the page asks for them. The page checks the link with
|
||||
@@ -369,16 +547,72 @@ function registerScheme() {
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
function showWindow() {
|
||||
if (!win) return createWindow(true);
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
function ensureTray() {
|
||||
if (tray) return true;
|
||||
try {
|
||||
const icon = nativeImage.createFromPath(path.join(__dirname, "build", "icon.png"));
|
||||
if (icon.isEmpty()) return false;
|
||||
tray = new Tray(icon);
|
||||
// Some Linux desktops accept the Tray object but expose no status icon.
|
||||
// Keep a visible login window rather than hiding the only way back in.
|
||||
if (process.platform === "linux") {
|
||||
const bounds = tray.getBounds();
|
||||
if (!bounds.width || !bounds.height) { tray.destroy(); tray = null; return false; }
|
||||
}
|
||||
tray.setToolTip("Frame Control");
|
||||
tray.on("click", showWindow);
|
||||
updateTrayMenu();
|
||||
return true;
|
||||
} catch {
|
||||
if (tray) { try { tray.destroy(); } catch {} tray = null; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTrayMenu() {
|
||||
if (!tray) return;
|
||||
tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: "Open Frame Control", click: showWindow },
|
||||
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(), click: toggleLoginItem },
|
||||
{ type: "separator" },
|
||||
{ label: "Quit Frame Control", click: () => app.quit() },
|
||||
]));
|
||||
}
|
||||
|
||||
function toggleLoginItem() {
|
||||
try {
|
||||
const on = loginItem.set(!loginItem.enabled());
|
||||
if (on) ensureTray();
|
||||
else if (tray) { tray.destroy(); tray = null; }
|
||||
buildMenu();
|
||||
updateTrayMenu();
|
||||
} catch (err) {
|
||||
dialog.showErrorBox("Open at Login", String(err.message || err));
|
||||
buildMenu();
|
||||
updateTrayMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(showOnReady = !process.argv.includes("--hidden") || !tray) {
|
||||
win = new BrowserWindow({
|
||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||
title: "Frame Control", backgroundColor: BG, show: false,
|
||||
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
|
||||
: { icon: path.join(__dirname, "build", "icon.png") }),
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
|
||||
preload: path.join(__dirname, "preload.js") },
|
||||
});
|
||||
win.once("ready-to-show", () => win.show());
|
||||
win.once("ready-to-show", () => { if (showOnReady) win.show(); });
|
||||
win.on("close", (event) => {
|
||||
if (!quitting && tray && loginItem.enabled()) { event.preventDefault(); win.hide(); }
|
||||
});
|
||||
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
|
||||
// External links open in the default browser; the app never navigates away.
|
||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||
@@ -409,13 +643,14 @@ async function runInTerminal(argv) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setUpConnection() {
|
||||
const alias = `FRAME_ALIAS=${FRAME}`;
|
||||
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
|
||||
async function setUpConnection(name = activeAlias()) {
|
||||
if (!ALIAS_RE.test(name)) return;
|
||||
const alias = `FRAME_ALIAS=${name}`;
|
||||
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
||||
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
|
||||
// A new console inherits our environment on Windows; Linux terminals may not.
|
||||
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
|
||||
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
|
||||
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
|
||||
}
|
||||
|
||||
function buildMenu() {
|
||||
@@ -430,8 +665,17 @@ function buildMenu() {
|
||||
{
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: setUpConnection },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
|
||||
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(),
|
||||
enabled: app.isPackaged, click: toggleLoginItem },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
|
||||
{ type: "separator" },
|
||||
...(devices.length > 1 ? [{
|
||||
label: "Headset",
|
||||
submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active,
|
||||
click: () => { if (win) win.webContents.send("use-device", d.id); } })),
|
||||
}] : []),
|
||||
{ label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices },
|
||||
{ type: "separator" },
|
||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||
@@ -472,7 +716,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
|
||||
// Windows and Linux start a second instance with the link as an argument.
|
||||
app.on("second-instance", (_e, argv) => {
|
||||
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||
if (!argv.includes("--hidden")) showWindow();
|
||||
const link = linkFromArgv(argv);
|
||||
if (link) openInstallLink(link);
|
||||
});
|
||||
@@ -480,12 +724,41 @@ if (!app.requestSingleInstanceLock()) {
|
||||
if (firstLink) openInstallLink(firstLink);
|
||||
app.whenReady().then(() => {
|
||||
registerScheme();
|
||||
if (loginItem.enabled()) ensureTray();
|
||||
buildMenu();
|
||||
createWindow();
|
||||
scheduleUpdateChecks();
|
||||
});
|
||||
app.on("activate", () => { if (!win) createWindow(); });
|
||||
app.on("activate", showWindow);
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
app.on("before-quit", () => { quitting = true; stopServer(); });
|
||||
process.on("exit", stopServer);
|
||||
let obsQuitWaiting = false;
|
||||
let obsQuitReady = false;
|
||||
app.on("before-quit", (event) => {
|
||||
quitting = true;
|
||||
stopServer();
|
||||
if (!obsSource.child || obsQuitReady) { stopObs(); return; }
|
||||
event.preventDefault();
|
||||
if (obsQuitWaiting) return;
|
||||
obsQuitWaiting = true;
|
||||
const child = obsSource.child;
|
||||
let timer;
|
||||
child.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
obsQuitReady = true;
|
||||
app.quit();
|
||||
});
|
||||
stopObs();
|
||||
timer = setTimeout(() => {
|
||||
if (child.exitCode === null) {
|
||||
if (IS_WIN) {
|
||||
// Only the helper and its SSH child: never enumerate unrelated SSH jobs.
|
||||
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"],
|
||||
{ stdio: "ignore", windowsHide: true, detached: true }).unref();
|
||||
} else child.kill("SIGKILL");
|
||||
}
|
||||
obsQuitReady = true;
|
||||
app.quit();
|
||||
}, 8000);
|
||||
});
|
||||
process.on("exit", () => { stopObs(); stopServer(); });
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// "Sign in with Meta" for My Meta library (docs/meta-library.md), the way BSManager's
|
||||
// "Log in with Meta" works: Meta's own login page in a Frame Control window, then the
|
||||
// access token cookie (oc_ac_at) that page sets is handed to the local server's
|
||||
// existing connect path (format check, "who am I" with Meta, 0600 file).
|
||||
//
|
||||
// The window has its own persistent session (PARTITION), so the cookie is read only
|
||||
// from there: never from Frame Control's main window, never from any browser. It has
|
||||
// no preload and no Node, and it only navigates within Meta's login sites; anything
|
||||
// else opens in the system browser. Nothing here logs the token, and the page only
|
||||
// ever gets back the server's answer (connected, display name), never the token.
|
||||
// Electron is passed in, so app/test/meta-signin.test.js can run it with fakes.
|
||||
const crypto = require("crypto");
|
||||
|
||||
const PARTITION = "persist:meta-signin";
|
||||
// BSManager's loginUrl (src/main/services/bs-version-download/bs-oculus-downloader.service.ts).
|
||||
const LOGIN_URL = "https://secure.oculus.com";
|
||||
const COOKIE = "oc_ac_at";
|
||||
// Sites the login can go through: the Oculus store web, Meta accounts, Facebook login,
|
||||
// and Instagram (Meta's login passes through www.instagram.com: seen live, 2026-10-14).
|
||||
const ALLOWED = ["oculus.com", "meta.com", "facebook.com", "instagram.com"];
|
||||
const TIMEOUT = 10 * 60 * 1000; // as BSManager
|
||||
|
||||
const underDomain = (host, domains) => {
|
||||
host = String(host || "").replace(/^\./, "").toLowerCase();
|
||||
return domains.some((d) => host === d || host.endsWith("." + d));
|
||||
};
|
||||
|
||||
// Plain HTTPS on the default port, to one of the login sites.
|
||||
function allowedUrl(raw) {
|
||||
let u;
|
||||
try { u = new URL(String(raw)); } catch { return false; }
|
||||
if (u.protocol !== "https:" || u.username || u.password || (u.port && u.port !== "443")) return false;
|
||||
return underDomain(u.hostname, ALLOWED);
|
||||
}
|
||||
|
||||
// Frames inside the page: any HTTPS address (Meta's login can embed captcha and CDN
|
||||
// frames from other sites), or an empty frame; never data:, blob:, javascript:, http:,
|
||||
// file: or any other scheme. Frames can't reach the cookie read or IPC (main process only).
|
||||
function allowedSubframeUrl(raw) {
|
||||
if (/^about:(?:blank|srcdoc)$/.test(String(raw))) return true;
|
||||
let u;
|
||||
try { u = new URL(String(raw)); } catch { return false; }
|
||||
return u.protocol === "https:" && !u.username && !u.password;
|
||||
}
|
||||
|
||||
const onOculus = (raw) => { try { return underDomain(new URL(raw).hostname, ["oculus.com"]); } catch { return false; } };
|
||||
|
||||
// A cheap first look, so a signed-out or app value is never sent anywhere; the server's
|
||||
// check_token (ui/frame_meta.py) has the final word.
|
||||
function looksLikeUserToken(v) {
|
||||
return typeof v === "string" && /^(?:OC|FRL)[A-Za-z0-9+/=_-]{18,4093}$/.test(v)
|
||||
&& !/^OC\d{15}$/.test(v) && /[a-z]/.test(v);
|
||||
}
|
||||
|
||||
const fingerprint = (v) => crypto.createHash("sha256").update(v).digest("hex");
|
||||
|
||||
// Anything token-shaped out of a message, in case a server error ever carried one.
|
||||
function scrub(text, token) {
|
||||
let s = String(text == null ? "" : text);
|
||||
if (token) s = s.split(token).join("<redacted>");
|
||||
return s.replace(/(?:OC|FRL)[A-Za-z0-9+/=_%|:-]{8,}/g, "<redacted>").slice(0, 600);
|
||||
}
|
||||
|
||||
// deps: { BrowserWindow, session, shell, parent() -> BrowserWindow|null, timeout,
|
||||
// connect(token, attempt) -> Promise<{status, data}> (the server's connect action),
|
||||
// cancel(attempt) -> Promise (the server's cancel-signin: never save that attempt) }
|
||||
function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) {
|
||||
let active = null; // { win, promise, finish }
|
||||
const pending = new Set(); // cancels still to reach the server, from flows already finished
|
||||
let signingOut = null; // the sign-out under way; no sign-in starts while it runs
|
||||
const SIGNING_OUT = "Frame Control is signing out of Meta; try again in a moment.";
|
||||
|
||||
const openOutside = (target) => {
|
||||
if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target));
|
||||
};
|
||||
|
||||
function signIn() {
|
||||
if (signingOut) return Promise.resolve({ ok: false, error: SIGNING_OUT });
|
||||
if (active) { active.win.focus(); return active.promise; }
|
||||
const ses = session.fromPartition(PARTITION);
|
||||
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false));
|
||||
ses.setPermissionCheckHandler(() => false);
|
||||
// Electron adds its own name and the app's to the user agent; send a plain Chrome one.
|
||||
if (ses.getUserAgent && ses.setUserAgent) {
|
||||
ses.setUserAgent(ses.getUserAgent().replace(/\s(?:Electron|frame-control|Frame Control)\/\S+/g, ""));
|
||||
}
|
||||
const p = parent();
|
||||
const win = new BrowserWindow({
|
||||
width: 520, height: 760, title: "Sign in with Meta – Frame Control", autoHideMenuBar: true,
|
||||
...(p ? { parent: p } : {}),
|
||||
webPreferences: { partition: PARTITION, contextIsolation: true, nodeIntegration: false, sandbox: true,
|
||||
nodeIntegrationInSubFrames: false, webviewTag: false, safeDialogs: true, devTools: false },
|
||||
});
|
||||
const wc = win.webContents;
|
||||
|
||||
let resolve;
|
||||
const promise = new Promise((r) => { resolve = r; });
|
||||
let done = false, busy = false, again = false, lastError = null, blocked = null;
|
||||
const tried = new Set(); // fingerprints of values already sent, so each goes once
|
||||
// The server refuses to save a connect from an attempt this window gave up on.
|
||||
const attemptId = crypto.randomUUID();
|
||||
|
||||
const onCookie = (_e, cookie, _cause, removed) => {
|
||||
if (!removed && cookie && cookie.name === COOKIE && underDomain(cookie.domain, ["oculus.com"])) attempt();
|
||||
};
|
||||
const timer = setTimeout(() => finish({ ok: false, error: "Sign-in timed out after 10 minutes. Try again." }), timeout);
|
||||
if (timer.unref) timer.unref();
|
||||
|
||||
// Stops cookie intake first, then the window (destroy: a page's beforeunload can't
|
||||
// keep it open), and answers the page. Unless it connected, and if it sent a token, it
|
||||
// then has the server drop this attempt: only once the connect under way has settled,
|
||||
// so the server always sees the cancel after the connect (and deletes the token if
|
||||
// that connect saved it). The returned promise settles when that is done.
|
||||
let finished = null;
|
||||
let inflight = null; // the last connect sent
|
||||
function finish(result) {
|
||||
if (done) return finished || Promise.resolve(); // destroy() below re-enters through "closed"
|
||||
done = true;
|
||||
ses.cookies.removeListener("changed", onCookie);
|
||||
clearTimeout(timer);
|
||||
if (active && active.win === win) active = null;
|
||||
if (!win.isDestroyed()) win.destroy();
|
||||
resolve(result);
|
||||
if (result.ok || !inflight) return (finished = Promise.resolve()); // nothing sent: nothing to cancel
|
||||
finished = Promise.resolve(inflight).catch(() => {}).then(() => cancel(attemptId)).catch(() => {});
|
||||
pending.add(finished);
|
||||
finished.then(() => pending.delete(finished));
|
||||
return finished;
|
||||
}
|
||||
|
||||
async function attempt() {
|
||||
if (done) return;
|
||||
if (busy) { again = true; return; }
|
||||
busy = true;
|
||||
try {
|
||||
do {
|
||||
again = false;
|
||||
// Only this window's own session, and only Meta's Oculus cookie.
|
||||
const cookies = await ses.cookies.get({ name: COOKIE });
|
||||
const c = cookies.find((k) => underDomain(k.domain, ["oculus.com"]) && looksLikeUserToken(k.value));
|
||||
if (!c || done) continue;
|
||||
const key = fingerprint(c.value);
|
||||
if (tried.has(key)) continue;
|
||||
tried.add(key);
|
||||
let r;
|
||||
try {
|
||||
inflight = connect(c.value, attemptId);
|
||||
r = await inflight;
|
||||
} catch {
|
||||
lastError = "Frame Control's local server didn't answer.";
|
||||
continue;
|
||||
}
|
||||
if (done) return;
|
||||
const d = (r && r.data) || {};
|
||||
if (r && r.status === 200 && d.connected) {
|
||||
finish({ ok: true, connected: true, user: d.user && d.user.name ? { name: scrub(d.user.name, c.value).slice(0, 80) } : null,
|
||||
message: scrub(d.message || "Connected your Meta account (Experimental)", c.value) });
|
||||
return;
|
||||
}
|
||||
lastError = scrub(d.error || `HTTP ${r && r.status}`, c.value);
|
||||
} while (again && !done);
|
||||
} catch {
|
||||
// The session went away (the window closed); nothing to do.
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation stays on Meta's login sites. A link elsewhere opens in the system
|
||||
// browser; a redirect elsewhere is just stopped (it may carry sign-in data).
|
||||
// Every frame: the window itself stays on the list; frames inside it may load any
|
||||
// HTTPS page (captchas), and one going anywhere else is just stopped.
|
||||
wc.on("will-navigate", (e, target) => { if (!allowedUrl(target)) { e.preventDefault(); openOutside(target); } });
|
||||
// A stopped page change in the window itself says where it went (the host only), so
|
||||
// closing the stuck window explains why instead of "closed before you signed in".
|
||||
const stopped = (to) => {
|
||||
let host = "";
|
||||
try { host = new URL(String(to)).hostname; } catch { /* not an address */ }
|
||||
blocked = `Meta's sign-in went to ${scrub(host || "an address", null).slice(0, 80)}, which Frame Control doesn't allow.`;
|
||||
};
|
||||
wc.on("will-frame-navigate", (e) => {
|
||||
if (e.isMainFrame ? allowedUrl(e.url) : allowedSubframeUrl(e.url)) return;
|
||||
e.preventDefault();
|
||||
if (e.isMainFrame) stopped(e.url);
|
||||
});
|
||||
// Redirects fire for frames too: the same rule per frame (details on the event in
|
||||
// current Electron, positional arguments in older ones; unknown counts as the window).
|
||||
wc.on("will-redirect", (e, target, _inPlace, isMainFrame) => {
|
||||
const to = e && e.url !== undefined ? e.url : target;
|
||||
const main = e && e.isMainFrame !== undefined ? e.isMainFrame : isMainFrame !== false;
|
||||
if (main ? allowedUrl(to) : allowedSubframeUrl(to)) return;
|
||||
e.preventDefault();
|
||||
if (main) stopped(to);
|
||||
});
|
||||
// A page's beforeunload never keeps the window open.
|
||||
wc.on("will-prevent-unload", (e) => e.preventDefault());
|
||||
wc.setWindowOpenHandler(({ url: target }) => {
|
||||
if (allowedUrl(target)) wc.loadURL(target).catch(() => {});
|
||||
else openOutside(target);
|
||||
return { action: "deny" };
|
||||
});
|
||||
// Signed in: the cookie changes, or the window lands back on Meta's store site.
|
||||
ses.cookies.on("changed", onCookie);
|
||||
wc.on("did-navigate", (_e, target) => { blocked = null; if (onOculus(target)) attempt(); }); // it recovered
|
||||
wc.on("did-navigate-in-page", (_e, target) => { if (onOculus(target)) attempt(); });
|
||||
win.on("closed", () => finish({ ok: false, cancelled: true,
|
||||
error: lastError ? `The sign-in window closed before Frame Control could connect. ${lastError}`
|
||||
: blocked ? `The sign-in window was closed before you signed in. ${blocked}`
|
||||
: "The sign-in window was closed before you signed in." }));
|
||||
|
||||
active = { win, promise, finish };
|
||||
wc.loadURL(LOGIN_URL).catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Signs out of Meta in Frame Control: stops any sign-in under way (and has the server
|
||||
// refuse to save it), destroys the window, then clears everything
|
||||
// the sign-in session stored (cookies, storage, cache), so the next sign-in starts fresh.
|
||||
// One at a time (a second call joins the first), and no sign-in can start meanwhile.
|
||||
// It goes round until no window is open and no connect or cancel is left, so nothing
|
||||
// started before it can set a token after it (or after the Disconnect that follows).
|
||||
function signOut() {
|
||||
if (!signingOut) {
|
||||
signingOut = (async () => {
|
||||
for (;;) {
|
||||
if (active) {
|
||||
const { win, finish } = active;
|
||||
await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." });
|
||||
// Clear only once nothing can write to the session again.
|
||||
if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again.");
|
||||
} else if (pending.size) {
|
||||
// A connect under way settles and is cancelled before the caller (Disconnect)
|
||||
// deletes the account, so it can't be saved afterwards.
|
||||
await Promise.all([...pending]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const ses = session.fromPartition(PARTITION);
|
||||
await ses.clearStorageData();
|
||||
await ses.clearCache();
|
||||
if (ses.clearAuthCache) await ses.clearAuthCache();
|
||||
return true;
|
||||
})().finally(() => { signingOut = null; });
|
||||
}
|
||||
return signingOut;
|
||||
}
|
||||
|
||||
return { signIn, signOut, isOpen: () => !!active, isSigningOut: () => !!signingOut };
|
||||
}
|
||||
|
||||
module.exports = { createMetaSignIn, allowedUrl, allowedSubframeUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED };
|
||||
@@ -0,0 +1,59 @@
|
||||
// Own one Python relay. Async preparation can be cancelled before a process exists.
|
||||
const ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
function createObsSource({ prepare, spawn, onState = () => {}, supported = true }) {
|
||||
let child = null, attempt = 0;
|
||||
let state = { state: 'idle', alias: '', port: 15099, error: '', supported };
|
||||
const status = () => ({ ...state });
|
||||
function set(next, alias = state.alias, port = state.port, error = '') {
|
||||
state = { state: next, alias, port, error, supported };
|
||||
onState(status()); return status();
|
||||
}
|
||||
function stop() {
|
||||
++attempt;
|
||||
if (!child) return set('idle');
|
||||
set('stopping');
|
||||
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
||||
return status();
|
||||
}
|
||||
async function start(alias, port = 15099) {
|
||||
if (!supported) return status();
|
||||
if (!ALIAS.test(alias || '')) throw new Error('Select a valid headset first.');
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('OBS port must be a whole number from 1024 to 65535.');
|
||||
if (child || state.state === 'starting') return status();
|
||||
const id = ++attempt;
|
||||
set('starting', alias, port);
|
||||
try {
|
||||
const { executable, args = [], env } = await prepare(alias);
|
||||
if (id !== attempt) return status();
|
||||
const proc = spawn(executable, [...args, '--frame', alias, '--port', String(port), '--control-stdin'],
|
||||
{ env, stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
|
||||
child = proc;
|
||||
// The helper may exit between a click and closing its control pipe.
|
||||
proc.stdin.on('error', () => {});
|
||||
let output = '';
|
||||
proc.stderr.on('data', chunk => {
|
||||
output = (output + chunk.toString('utf8')).slice(-2048);
|
||||
if (child === proc && state.state === 'starting' && output.includes('Frame Control OBS stream is ready'))
|
||||
set('running', alias, port);
|
||||
});
|
||||
proc.once('error', error => {
|
||||
if (child !== proc) return;
|
||||
child = null; set('failed', alias, port, `Could not start OBS source: ${error.message}`);
|
||||
});
|
||||
// close follows stderr EOF, so the final failure message is available.
|
||||
proc.once('close', (code, signal) => {
|
||||
if (child !== proc) return;
|
||||
const stopping = state.state === 'stopping';
|
||||
child = null;
|
||||
set(stopping ? 'idle' : 'failed', alias, port,
|
||||
stopping ? '' : `OBS source stopped (${signal || code}). ${output.trim().split('\n').pop() || 'Check SteamVR and SSH.'}`);
|
||||
});
|
||||
return status();
|
||||
} catch (error) {
|
||||
if (id !== attempt) return status();
|
||||
return set('failed', alias, port, error.message);
|
||||
}
|
||||
}
|
||||
return { start, stop, status, get child() { return child; } };
|
||||
}
|
||||
module.exports = { createObsSource };
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.44",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.44",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+64
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.44",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -9,10 +9,11 @@
|
||||
"scripts": {
|
||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never",
|
||||
"dist:mac:x64": "FRAME_MAC_ARCH=x86_64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac x64 && electron-builder --mac --x64 --publish never"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
@@ -29,15 +30,20 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"afterPack": "build/check-resources.js",
|
||||
"directories": {
|
||||
"output": "dist",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"obs-source.js",
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"preferences.js",
|
||||
"login-item.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
@@ -48,14 +54,28 @@
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.html",
|
||||
"telemetry.json"
|
||||
"*.js",
|
||||
"*.css",
|
||||
"telemetry.json",
|
||||
"framey-compatibility.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../ui/apk_sources",
|
||||
"to": "ui/apk_sources",
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../scripts",
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh"
|
||||
"*.sh",
|
||||
"obs-headset-source.py",
|
||||
"frame_power_sample.py",
|
||||
"frame_power_report.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -63,7 +83,16 @@
|
||||
"to": "frame/android",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"*.py"
|
||||
"*.py",
|
||||
"*.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../frame/openxr-compat",
|
||||
"to": "frame/openxr-compat",
|
||||
"filter": [
|
||||
"XrApiLayer_FRAME_compat.json",
|
||||
"prebuilt/**/*.so.gz"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -74,6 +103,16 @@
|
||||
"!**/__pycache__/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../frame/kdeconnect",
|
||||
"to": "frame/kdeconnect",
|
||||
"filter": [
|
||||
"packages.json",
|
||||
"NOTICE.md",
|
||||
"LICENSES/**/*",
|
||||
"packages/*.pkg.tar.zst"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../apk-catalog",
|
||||
"to": "apk-catalog",
|
||||
@@ -96,6 +135,14 @@
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../THIRD_PARTY_NOTICES.md",
|
||||
"to": "THIRD_PARTY_NOTICES.md"
|
||||
},
|
||||
{
|
||||
"from": "../LICENSE",
|
||||
"to": "LICENSE"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -158,7 +205,16 @@
|
||||
"zip"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}"
|
||||
"artifactName": "Frame-Control-win-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/desktop/${os}-${arch}",
|
||||
"to": "desktop/bundle",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Client choices live outside the local server's changing URL/origin.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const KEYS = ["mvSeparate", "padUsed", "pcView"];
|
||||
const valid = value => value === "0" || value === "1";
|
||||
|
||||
function preferenceStore(file) {
|
||||
function read() {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
|
||||
return Object.fromEntries(KEYS.filter(key => valid(saved[key])).map(key => [key, saved[key]]));
|
||||
} catch { return {}; }
|
||||
}
|
||||
function write(key, value) {
|
||||
if (!KEYS.includes(key) || !valid(value)) throw new Error("Invalid preference");
|
||||
const saved = { ...read(), [key]: value };
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = file + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(saved), { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
return true;
|
||||
}
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
module.exports = { preferenceStore };
|
||||
+37
-4
@@ -1,16 +1,43 @@
|
||||
// Lets the page read this computer's clipboard through Electron, so sending it
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// Lets the page read and write this computer's clipboard through Electron, so sending
|
||||
// it to the Frame, and copying the Frame's back, needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// It can open Set Up Connection when the headset can't be reached.
|
||||
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
|
||||
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
// And it passes update state both ways: see app/updater.js.
|
||||
// And it passes update state both ways: see app/updater.js. It opens the Sign in
|
||||
// with Meta window and signs out of it (app/meta-signin.js).
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
preferences: {
|
||||
read: () => ipcRenderer.invoke("preferences:read"),
|
||||
write: (key, value) => ipcRenderer.invoke("preferences:write", key, value),
|
||||
},
|
||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
writeClipboard: (text) => ipcRenderer.invoke("clipboard:write", text),
|
||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||
obs: {
|
||||
status: () => ipcRenderer.invoke("obs:status"),
|
||||
start: (port) => ipcRenderer.invoke("obs:start", port),
|
||||
stop: () => ipcRenderer.invoke("obs:stop"),
|
||||
onState: (cb) => {
|
||||
ipcRenderer.removeAllListeners("obs:state");
|
||||
ipcRenderer.on("obs:state", (_e, state) => cb(state));
|
||||
},
|
||||
},
|
||||
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
||||
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
||||
onUseDevice: (cb) => {
|
||||
ipcRenderer.removeAllListeners("use-device");
|
||||
ipcRenderer.on("use-device", (_e, id) => cb(String(id)));
|
||||
},
|
||||
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
|
||||
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||
// Updates (app/updater.js): the page shows a banner and an Update button.
|
||||
update: {
|
||||
@@ -27,6 +54,12 @@ contextBridge.exposeInMainWorld("frameApp", {
|
||||
ipcRenderer.removeAllListeners("report:open");
|
||||
ipcRenderer.on("report:open", () => cb());
|
||||
},
|
||||
// Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
|
||||
// answer is the server's (connected, display name), never the token.
|
||||
meta: {
|
||||
signIn: (acknowledged) => ipcRenderer.invoke("meta:signin", acknowledged === true),
|
||||
signOut: () => ipcRenderer.invoke("meta:signout"),
|
||||
},
|
||||
onInstallLink: (cb) => {
|
||||
ipcRenderer.removeAllListeners("install-link");
|
||||
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { createLoginItem } = require("../login-item");
|
||||
|
||||
test("Linux login toggle creates and removes only its own desktop entry", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-login-test-"));
|
||||
try {
|
||||
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root },
|
||||
executable: "/opt/Frame Control/frame-control" });
|
||||
assert.equal(login.enabled(), false);
|
||||
assert.equal(login.set(true), true);
|
||||
const body = fs.readFileSync(login.file, "utf8");
|
||||
assert.match(body, /^# Managed by Frame Control;/);
|
||||
assert.match(body, /Exec="\/opt\/Frame Control\/frame-control" --hidden/);
|
||||
assert.equal(fs.statSync(login.file).mode & 0o777, 0o600);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.equal(fs.existsSync(login.file), false);
|
||||
fs.mkdirSync(path.dirname(login.file), { recursive: true });
|
||||
fs.writeFileSync(login.file, "[Desktop Entry]\nName=My custom launcher\n");
|
||||
assert.throws(() => login.set(true), /different autostart entry/);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.match(fs.readFileSync(login.file, "utf8"), /My custom launcher/);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("AppImage autostart launches the AppImage, with safe desktop escaping", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-appimage-test-"));
|
||||
try {
|
||||
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root,
|
||||
APPIMAGE: '/home/user/Frame Control 100%.AppImage' },
|
||||
executable: "/tmp/.mount_extracted/AppRun" });
|
||||
login.set(true);
|
||||
assert.match(fs.readFileSync(login.file, "utf8"), /Exec="\/home\/user\/Frame Control 100%%\.AppImage" --hidden/);
|
||||
login.set(false);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
for (const platform of ["win32", "darwin"]) {
|
||||
test(`${platform} login toggle uses the OS API and starts hidden`, () => {
|
||||
let openAtLogin = false;
|
||||
const calls = [];
|
||||
const electronApp = {
|
||||
getLoginItemSettings: () => ({ openAtLogin }),
|
||||
setLoginItemSettings: opts => { calls.push(opts); openAtLogin = opts.openAtLogin; },
|
||||
};
|
||||
const login = createLoginItem({ platform, electronApp });
|
||||
assert.equal(login.enabled(), false);
|
||||
assert.equal(login.set(true), true);
|
||||
assert.deepEqual(calls.at(-1).args, ["--hidden"]);
|
||||
assert.equal(calls.at(-1).openAsHidden, true);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.deepEqual(calls.at(-1).args, []);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
// Run: node --test app/test/
|
||||
// "Sign in with Meta" (app/meta-signin.js) with fake Electron windows, sessions and cookies.
|
||||
const nodeTest = require("node:test");
|
||||
// A broken flow can leave a sign-in waiting forever; fail it instead of hanging.
|
||||
const test = (name, fn) => nodeTest(name, { timeout: 5000 }, fn);
|
||||
const assert = require("node:assert");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createMetaSignIn, allowedUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE } = require("../meta-signin");
|
||||
|
||||
const TOKEN = "OCAbcdefghijklmnopQRSTUVwxyz0123456789abcdEFGH";
|
||||
const OTHER = "OCzyxwvutsrqponmlkJIHGFEDcba9876543210zyxwVUTS";
|
||||
|
||||
class FakeCookies extends EventEmitter {
|
||||
constructor() { super(); this.store = []; this.reads = 0; }
|
||||
async get(filter) {
|
||||
this.reads++;
|
||||
return this.store.filter((c) => !filter.name || c.name === filter.name);
|
||||
}
|
||||
set(cookie) { // as the page would: store it, then tell listeners
|
||||
this.store = this.store.filter((c) => !(c.name === cookie.name && c.domain === cookie.domain)).concat(cookie);
|
||||
this.emit("changed", {}, cookie, "explicit", false);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSession {
|
||||
constructor(name, log) { this.name = name; this.log = log; this.cookies = new FakeCookies(); this.cleared = []; this.permission = null; }
|
||||
setPermissionRequestHandler(fn) { this.permission = fn; }
|
||||
setPermissionCheckHandler(fn) { this.permissionCheck = fn; }
|
||||
getUserAgent() { return "Mozilla/5.0 Chrome/140.0 frame-control/0.4.4 Electron/44.4.5 Safari/537.36"; }
|
||||
setUserAgent(ua) { this.ua = ua; }
|
||||
async clearStorageData(opts) { this.log.push("clear"); this.cleared.push(["storage", opts]); }
|
||||
async clearCache() { this.cleared.push(["cache"]); }
|
||||
async clearAuthCache() { this.cleared.push(["auth"]); }
|
||||
}
|
||||
|
||||
function fakes({ timeout } = {}) {
|
||||
const log = []; // the order things happened in
|
||||
const sessions = new Map();
|
||||
const defaultSession = new FakeSession("default", log);
|
||||
const session = {
|
||||
defaultSession,
|
||||
fromPartition(name) {
|
||||
if (!sessions.has(name)) sessions.set(name, new FakeSession(name, log));
|
||||
return sessions.get(name);
|
||||
},
|
||||
};
|
||||
const windows = [];
|
||||
class BrowserWindow extends EventEmitter {
|
||||
constructor(opts) {
|
||||
super();
|
||||
this.opts = opts;
|
||||
this.destroyed = false;
|
||||
this.veto = false; // a page whose beforeunload keeps the window open on close()
|
||||
this.focused = 0;
|
||||
const wc = new EventEmitter();
|
||||
wc.loaded = [];
|
||||
wc.loadURL = async (u) => { wc.loaded.push(u); };
|
||||
wc.setWindowOpenHandler = (fn) => { wc.openHandler = fn; };
|
||||
// Deliberately a different session: the code must read the partition's, not guess.
|
||||
wc.session = defaultSession;
|
||||
this.webContents = wc;
|
||||
windows.push(this);
|
||||
}
|
||||
isDestroyed() { return this.destroyed; }
|
||||
focus() { this.focused++; }
|
||||
close() { if (this.destroyed || this.veto) return; this.destroy(); }
|
||||
destroy() { if (this.destroyed) return; this.destroyed = true; log.push("destroyed"); this.emit("closed"); }
|
||||
}
|
||||
const opened = [];
|
||||
const shell = { openExternal: (u) => { opened.push(u); } };
|
||||
const calls = [], attempts = [], cancels = [];
|
||||
let answer = () => ({ status: 200, data: { connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" } });
|
||||
const connect = async (token, attempt) => { calls.push(token); attempts.push(attempt); log.push("connect"); return answer(token); };
|
||||
const cancel = async (attempt) => {
|
||||
cancels.push(attempt);
|
||||
log.push(`cancel (cookie listeners: ${session.fromPartition(PARTITION).cookies.listenerCount("changed")})`);
|
||||
};
|
||||
const flow = createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent: () => null, ...(timeout ? { timeout } : {}) });
|
||||
return { flow, session, sessions, windows, opened, calls, attempts, cancels, log, setAnswer: (fn) => { answer = fn; },
|
||||
meta: () => session.fromPartition(PARTITION) };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setImmediate(r));
|
||||
async function settle() { for (let i = 0; i < 10; i++) await tick(); }
|
||||
|
||||
function navigate(wc, event, target) {
|
||||
let prevented = false;
|
||||
wc.emit(event, { preventDefault: () => { prevented = true; } }, target);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("only plain HTTPS addresses on Meta's login sites are allowed", () => {
|
||||
for (const ok of ["https://secure.oculus.com", "https://secure.oculus.com/my/profile", "https://auth.meta.com/login/",
|
||||
"https://www.meta.com/", "https://www.facebook.com/login.php", "https://oculus.com:443/",
|
||||
"https://www.instagram.com/accounts/login/"]) {
|
||||
assert.ok(allowedUrl(ok), ok);
|
||||
}
|
||||
for (const bad of ["http://secure.oculus.com/", "https://oculus.com.evil.example/", "https://evil-oculus.com/",
|
||||
"https://notmeta.com/", "https://example.com/", "javascript:alert(1)", "file:///etc/passwd",
|
||||
"https://secure.oculus.com:8443/", "https://user:pw@secure.oculus.com/", "https://evil.example/?u=https://www.meta.com",
|
||||
"data:text/html,hi", "not a url"]) {
|
||||
assert.ok(!allowedUrl(bad), bad);
|
||||
}
|
||||
});
|
||||
|
||||
test("the window is isolated: its own partition, no preload, no Node, sandboxed, on Meta's login page", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const [w] = f.windows;
|
||||
const wp = w.opts.webPreferences;
|
||||
assert.strictEqual(wp.partition, PARTITION);
|
||||
assert.strictEqual(PARTITION, "persist:meta-signin");
|
||||
assert.strictEqual(wp.contextIsolation, true);
|
||||
assert.strictEqual(wp.nodeIntegration, false);
|
||||
assert.strictEqual(wp.sandbox, true);
|
||||
assert.strictEqual(wp.preload, undefined);
|
||||
assert.strictEqual(wp.devTools, false);
|
||||
assert.deepStrictEqual(w.webContents.loaded, [LOGIN_URL]);
|
||||
assert.strictEqual(LOGIN_URL, "https://secure.oculus.com");
|
||||
// Permission prompts (camera, notifications...) are refused.
|
||||
let granted = null;
|
||||
f.meta().permission(null, "media", (v) => { granted = v; });
|
||||
assert.strictEqual(granted, false);
|
||||
assert.strictEqual(f.meta().permissionCheck(null, "notifications", "https://secure.oculus.com"), false);
|
||||
assert.ok(!/Electron|frame-control/.test(f.meta().ua));
|
||||
w.close();
|
||||
});
|
||||
|
||||
test("navigation stays on Meta's sites; other links open in the system browser", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://auth.meta.com/login/"), false);
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://www.facebook.com/login.php"), false);
|
||||
assert.strictEqual(navigate(wc, "will-navigate", "https://example.com/help"), true);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
|
||||
// A redirect elsewhere is stopped and not opened anywhere (it may carry sign-in data).
|
||||
assert.strictEqual(navigate(wc, "will-redirect", "https://evil.example/cb?x=1"), true);
|
||||
assert.strictEqual(navigate(wc, "will-redirect", "https://auth.meta.com/next"), false);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
|
||||
// New windows: never created. Meta's own load in this window; others go outside; non-web schemes nowhere.
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "https://www.meta.com/legal/terms/" }), { action: "deny" });
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "https://example.org/" }), { action: "deny" });
|
||||
assert.deepStrictEqual(wc.openHandler({ url: "file:///etc/passwd" }), { action: "deny" });
|
||||
await settle();
|
||||
assert.deepStrictEqual(wc.loaded, [LOGIN_URL, "https://www.meta.com/legal/terms/"]);
|
||||
assert.deepStrictEqual(f.opened, ["https://example.com/help", "https://example.org/"]);
|
||||
f.windows[0].close();
|
||||
});
|
||||
|
||||
test("the cookie is read only from the sign-in partition, only for Meta's Oculus domain", async () => {
|
||||
const f = fakes();
|
||||
// The same cookie elsewhere: the default session and another partition. Never read.
|
||||
f.session.defaultSession.cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
f.session.fromPartition("persist:other").cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
const done = f.flow.signIn();
|
||||
const w = f.windows[0];
|
||||
// A cookie with the right name on another site, and a signed-out value: ignored.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: OTHER });
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: "OC123456789012345" });
|
||||
w.webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(f.session.defaultSession.cookies.reads, 0);
|
||||
assert.strictEqual(f.session.fromPartition("persist:other").cookies.reads, 0);
|
||||
assert.ok(f.sessions.get(PARTITION).cookies.reads > 0);
|
||||
});
|
||||
|
||||
test("landing on the logged-in page picks up a cookie set earlier", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.store.push({ name: COOKIE, domain: "secure.oculus.com", value: TOKEN }); // no change event
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://auth.meta.com/login/"); // not Meta's store site yet
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
|
||||
assert.strictEqual((await done).ok, true);
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("after a connect the window closes, and the page gets the answer, never the token", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.ok(!f.flow.isOpen());
|
||||
assert.deepStrictEqual(r, { ok: true, connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" });
|
||||
assert.ok(!JSON.stringify(r).includes(TOKEN));
|
||||
// The cookie listener is gone: a later change sends nothing.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("a token the server refuses keeps the window open, is sent once, and the next one connects", async () => {
|
||||
const f = fakes();
|
||||
f.setAnswer((t) => t === TOKEN ? { status: 400, data: { error: "Meta didn't accept this token." } }
|
||||
: { status: 200, data: { connected: true, user: null } });
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile"); // same value again
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
assert.ok(!f.windows[0].isDestroyed());
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
const r = await done;
|
||||
assert.deepStrictEqual(f.calls, [TOKEN, OTHER]);
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(r.user, null);
|
||||
});
|
||||
|
||||
test("the token is never logged, and an error that echoed it is scrubbed", async () => {
|
||||
const seen = [];
|
||||
const names = ["log", "info", "warn", "error", "debug", "trace"];
|
||||
const saved = Object.fromEntries(names.map((n) => [n, console[n]]));
|
||||
const writes = [process.stdout.write, process.stderr.write];
|
||||
for (const n of names) console[n] = (...a) => { seen.push(a.map(String).join(" ")); };
|
||||
process.stdout.write = (c, ...rest) => { seen.push(String(c)); return writes[0].call(process.stdout, c, ...rest); };
|
||||
process.stderr.write = (c, ...rest) => { seen.push(String(c)); return writes[1].call(process.stderr, c, ...rest); };
|
||||
let r;
|
||||
try {
|
||||
const f = fakes();
|
||||
f.setAnswer((t) => ({ status: 500, data: { error: `RuntimeError: bad ${t} and ${t.slice(0, 12)}` } }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close(); // the user gives up
|
||||
r = await done;
|
||||
} finally {
|
||||
for (const n of names) console[n] = saved[n];
|
||||
[process.stdout.write, process.stderr.write] = writes;
|
||||
}
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.strictEqual(r.cancelled, true);
|
||||
assert.ok(!r.error.includes(TOKEN) && !r.error.includes(TOKEN.slice(0, 12)), r.error);
|
||||
assert.match(r.error, /<redacted>/);
|
||||
assert.ok(!seen.some((s) => s.includes(TOKEN.slice(0, 12))), "token in output");
|
||||
assert.strictEqual(scrub(`x FRL${"a".repeat(30)} y`), "x <redacted> y");
|
||||
});
|
||||
|
||||
test("closing the window before signing in reports it, and a second click reuses the open window", async () => {
|
||||
const f = fakes();
|
||||
const a = f.flow.signIn();
|
||||
const b = f.flow.signIn();
|
||||
assert.strictEqual(a, b);
|
||||
assert.strictEqual(f.windows.length, 1);
|
||||
assert.strictEqual(f.windows[0].focused, 1);
|
||||
f.windows[0].close();
|
||||
const r = await a;
|
||||
assert.deepStrictEqual(r, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test("disconnect's sign-out closes the window and clears only the sign-in partition", async () => {
|
||||
const f = fakes();
|
||||
f.session.fromPartition("persist:other");
|
||||
const pending = f.flow.signIn();
|
||||
await f.flow.signOut();
|
||||
const r = await pending;
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.deepStrictEqual(f.meta().cleared, [["storage", undefined], ["cache"], ["auth"]]);
|
||||
assert.deepStrictEqual(f.session.defaultSession.cleared, []);
|
||||
assert.deepStrictEqual(f.session.fromPartition("persist:other").cleared, []);
|
||||
// The next sign-in starts with a new window.
|
||||
f.flow.signIn();
|
||||
assert.strictEqual(f.windows.length, 2);
|
||||
f.windows[1].close();
|
||||
});
|
||||
|
||||
test("the first look at a cookie value matches the server's token rules", () => {
|
||||
assert.ok(looksLikeUserToken(TOKEN));
|
||||
assert.ok(looksLikeUserToken("FRL" + "aB3".repeat(10)));
|
||||
for (const bad of [undefined, "", "OC123456789012345", "OC|123|abc", "OCabc%2Fdef" + "x".repeat(20), "OCshort",
|
||||
"XY" + "a".repeat(30), "OC" + "A".repeat(30)]) {
|
||||
assert.ok(!looksLikeUserToken(bad), String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
function frameNav(wc, url, isMainFrame) {
|
||||
let prevented = false;
|
||||
wc.emit("will-frame-navigate", { url, isMainFrame, preventDefault: () => { prevented = true; } });
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("the window stays on Meta's sites; frames inside it may load any HTTPS page, nothing else", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
// Frames: captcha and CDN frames from other sites load; other schemes are stopped.
|
||||
for (const ok of ["https://client-api.arkoselabs.com/fc/gc/", "https://newassets.hcaptcha.com/captcha/v1/x",
|
||||
"https://www.google.com/recaptcha/api2/anchor", "https://static.xx.fbcdn.net/x",
|
||||
"https://www.facebook.com/plugin", "about:blank", "about:srcdoc"]) {
|
||||
assert.strictEqual(frameNav(wc, ok, false), false, ok);
|
||||
}
|
||||
for (const bad of ["http://www.facebook.com/plugin", "data:text/html,<script>x</script>", "blob:https://evil.example/1",
|
||||
"javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/",
|
||||
"chrome://settings", "not a url"]) {
|
||||
assert.strictEqual(frameNav(wc, bad, false), true, bad);
|
||||
}
|
||||
// The window itself: Meta's sites only.
|
||||
assert.strictEqual(frameNav(wc, "about:blank", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://evil.example/", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true);
|
||||
assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false);
|
||||
assert.deepStrictEqual(f.opened, []); // stopped, never opened outside
|
||||
f.windows[0].destroy();
|
||||
});
|
||||
|
||||
function redirect(wc, url, isMainFrame, style) {
|
||||
let prevented = false;
|
||||
const e = { preventDefault: () => { prevented = true; } };
|
||||
if (style === "details") wc.emit("will-redirect", Object.assign(e, { url, isMainFrame }));
|
||||
else wc.emit("will-redirect", e, url, false, isMainFrame);
|
||||
return prevented;
|
||||
}
|
||||
|
||||
test("redirects follow the same rule per frame", async () => {
|
||||
const f = fakes();
|
||||
f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
for (const style of ["details", "positional"]) {
|
||||
// A captcha frame redirected within HTTPS loads; a frame redirected to data: or http: doesn't.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", false, style), false, style);
|
||||
assert.strictEqual(redirect(wc, "https://newassets.hcaptcha.com/x", false, style), false, style);
|
||||
assert.strictEqual(redirect(wc, "data:text/html,<b>x</b>", false, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "http://www.facebook.com/x", false, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "javascript:alert(1)", false, style), true, style);
|
||||
// The window: Meta's sites only.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", true, style), true, style);
|
||||
assert.strictEqual(redirect(wc, "https://auth.meta.com/next", true, style), false, style);
|
||||
}
|
||||
// Without frame information it is treated as the window.
|
||||
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/", undefined, "positional"), true);
|
||||
assert.deepStrictEqual(f.opened, []);
|
||||
f.windows[0].destroy();
|
||||
});
|
||||
|
||||
test("a redirect the window stops is named when the window is closed", async () => {
|
||||
const f = fakes();
|
||||
const p = f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(redirect(wc, "https://www.instagram.com/x?next=1", true, "details"), false); // Meta's login goes here
|
||||
assert.strictEqual(redirect(wc, "https://login.evil.example/cb?code=SECRET", true, "details"), true);
|
||||
f.windows[0].close();
|
||||
const r = await p;
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.match(r.error, /went to login\.evil\.example, which Frame Control doesn't allow/);
|
||||
assert.doesNotMatch(r.error, /SECRET|code=/);
|
||||
});
|
||||
|
||||
test("a stopped redirect is forgotten once the window loads a page again", async () => {
|
||||
const f = fakes();
|
||||
const p = f.flow.signIn();
|
||||
const wc = f.windows[0].webContents;
|
||||
assert.strictEqual(redirect(wc, "https://login.evil.example/cb", true, "details"), true);
|
||||
wc.emit("did-navigate", {}, "https://auth.meta.com/login/"); // the user carried on
|
||||
f.windows[0].close();
|
||||
assert.deepStrictEqual(await p, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
|
||||
});
|
||||
|
||||
test("a page's beforeunload can't keep the window open", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
const w = f.windows[0];
|
||||
let prevented = false;
|
||||
w.webContents.emit("will-prevent-unload", { preventDefault: () => { prevented = true; } });
|
||||
assert.ok(prevented);
|
||||
// Even if close() would be vetoed, finishing destroys the window.
|
||||
w.veto = true;
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
assert.strictEqual((await done).ok, true);
|
||||
assert.ok(w.isDestroyed());
|
||||
});
|
||||
|
||||
test("closing during a connect: the cancel goes to the server only after that connect settles", async () => {
|
||||
for (const outcome of ["saved", "refused", "failed"]) {
|
||||
const f = fakes();
|
||||
let release, fail;
|
||||
f.setAnswer(() => new Promise((r, j) => { release = r; fail = j; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
f.windows[0].close(); // the user gives up while the server and Meta are answering
|
||||
const r = await done; // the page hears at once
|
||||
assert.strictEqual(r.ok, false);
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.cancels, [], outcome); // not before the connect has settled
|
||||
if (outcome === "saved") release({ status: 200, data: { connected: true } });
|
||||
else if (outcome === "refused") release({ status: 400, data: { error: "no" } });
|
||||
else fail(new Error("socket hang up"));
|
||||
await settle();
|
||||
// The same attempt id the connect carried, after it settled and after cookie intake stopped.
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]], outcome);
|
||||
assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/);
|
||||
assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"], outcome);
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
}
|
||||
});
|
||||
|
||||
test("closing before any connect needs no cancel", async () => {
|
||||
const f = fakes();
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: TOKEN }); // nothing usable
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
assert.strictEqual((await done).ok, false);
|
||||
await f.flow.signOut();
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, []);
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
});
|
||||
|
||||
test("a timeout during a connect cancels after it settles", async () => {
|
||||
const f = fakes({ timeout: 50 });
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; })); // Meta is slow
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
const r = await done;
|
||||
assert.match(r.error, /timed out/);
|
||||
assert.ok(f.windows[0].isDestroyed());
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
release({ status: 400, data: { error: "no" } });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("disconnect during a connect waits for it, cancels it, then clears, before the page deletes the account", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.log.length = 0;
|
||||
let signedOut = false;
|
||||
const out = f.flow.signOut().then(() => { signedOut = true; }); // the page's Disconnect, step one
|
||||
assert.strictEqual((await done).ok, false);
|
||||
await settle();
|
||||
assert.strictEqual(signedOut, false); // still waiting for the connect
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
release({ status: 200, data: { connected: true } }); // it saved, just now
|
||||
await out;
|
||||
// Only now would the page post its disconnect: the cancel (which deletes what that
|
||||
// connect saved) has reached the server, and the session is cleared.
|
||||
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("a later disconnect still waits for a closed window's connect", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const done = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
await done;
|
||||
let signedOut = false;
|
||||
const out = f.flow.signOut().then(() => { signedOut = true; });
|
||||
await settle();
|
||||
assert.strictEqual(signedOut, false);
|
||||
release({ status: 400, data: { error: "no" } });
|
||||
await out;
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
});
|
||||
|
||||
test("nothing sent means nothing to cancel; sign-out destroys the window, cancels, then clears", async () => {
|
||||
const f = fakes();
|
||||
// Nothing sent: closing cancels nothing.
|
||||
const a = f.flow.signIn();
|
||||
f.windows[0].close();
|
||||
await a;
|
||||
assert.deepStrictEqual(f.cancels, []);
|
||||
// Something sent, then signed out: the attempt is cancelled before the session is cleared.
|
||||
f.setAnswer(() => ({ status: 400, data: { error: "Meta didn't accept this token." } }));
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[1].veto = true; // and its page tries to stay open
|
||||
f.log.length = 0;
|
||||
await f.flow.signOut();
|
||||
assert.strictEqual((await b).ok, false);
|
||||
assert.ok(f.windows[1].isDestroyed());
|
||||
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
// After sign-out a new cookie in the partition sends nothing.
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
});
|
||||
|
||||
test("each sign-in has its own attempt id", async () => {
|
||||
const f = fakes();
|
||||
f.setAnswer(() => ({ status: 400, data: { error: "no" } }));
|
||||
const a = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close();
|
||||
await a;
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
f.windows[1].close();
|
||||
await b;
|
||||
await settle();
|
||||
assert.strictEqual(f.attempts.length, 2);
|
||||
assert.notStrictEqual(f.attempts[0], f.attempts[1]);
|
||||
assert.deepStrictEqual(f.cancels, f.attempts);
|
||||
});
|
||||
|
||||
test("no sign-in can start while a sign-out waits on an earlier connect", async () => {
|
||||
const f = fakes();
|
||||
let release;
|
||||
f.setAnswer(() => new Promise((r) => { release = r; }));
|
||||
const first = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
const out = f.flow.signOut(); // Disconnect pressed while that connect is under way
|
||||
assert.strictEqual((await first).ok, false);
|
||||
assert.ok(f.flow.isSigningOut());
|
||||
// The page answered, so the user tries again at once: refused, no window, nothing sent.
|
||||
const second = await f.flow.signIn();
|
||||
assert.deepStrictEqual(second, { ok: false, error: "Frame Control is signing out of Meta; try again in a moment." });
|
||||
assert.strictEqual(f.windows.length, 1);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
assert.deepStrictEqual(f.calls, [TOKEN]);
|
||||
// A second sign-out joins the first.
|
||||
assert.strictEqual(f.flow.signOut(), out);
|
||||
release({ status: 200, data: { connected: true } });
|
||||
await out;
|
||||
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
|
||||
assert.strictEqual(f.meta().cleared.filter(([k]) => k === "storage").length, 1);
|
||||
assert.ok(!f.flow.isSigningOut());
|
||||
// Once it is done, signing in works again.
|
||||
f.setAnswer(() => ({ status: 200, data: { connected: true } }));
|
||||
const third = f.flow.signIn();
|
||||
assert.strictEqual(f.windows.length, 2);
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
assert.strictEqual((await third).ok, true);
|
||||
});
|
||||
|
||||
test("sign-out goes round until no window and no connect or cancel is left", async () => {
|
||||
const f = fakes();
|
||||
let releaseA;
|
||||
f.setAnswer(() => new Promise((r) => { releaseA = r; }));
|
||||
const a = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
|
||||
await settle();
|
||||
f.windows[0].close(); // window A closed; its cancel waits on its connect
|
||||
await a;
|
||||
// Window B is open, with its own connect under way, when sign-out starts.
|
||||
let releaseB;
|
||||
f.setAnswer(() => new Promise((r) => { releaseB = r; }));
|
||||
const b = f.flow.signIn();
|
||||
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
|
||||
await settle();
|
||||
let finished = false;
|
||||
const out = f.flow.signOut().then(() => { finished = true; });
|
||||
await b;
|
||||
releaseB({ status: 400, data: { error: "no" } });
|
||||
await settle();
|
||||
assert.strictEqual(finished, false); // A's connect is still under way
|
||||
releaseA({ status: 200, data: { connected: true } });
|
||||
await out;
|
||||
assert.deepStrictEqual([...f.cancels].sort(), [...f.attempts].sort());
|
||||
assert.strictEqual(f.attempts.length, 2);
|
||||
assert.strictEqual(f.log[f.log.length - 1], "clear");
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { createObsSource } = require('../obs-source');
|
||||
|
||||
function fixture(prepare) {
|
||||
const states = [], children = [];
|
||||
const source = createObsSource({
|
||||
prepare: prepare || (async () => ({ executable: 'python', args: ['helper.py'], env: {} })),
|
||||
spawn(executable, args, options) {
|
||||
const child = new EventEmitter();
|
||||
child.stderr = new EventEmitter(); child.stdin = new EventEmitter();
|
||||
child.stdin.end = () => { child.stdin.writableEnded = true; };
|
||||
children.push({ child, executable, args, options }); return child;
|
||||
},
|
||||
onState: s => states.push(s),
|
||||
});
|
||||
return { source, states, children };
|
||||
}
|
||||
|
||||
test('Stop during Python discovery cancels the pending start', async () => {
|
||||
let resolve;
|
||||
const f = fixture(() => new Promise(r => { resolve = r; }));
|
||||
const pending = f.source.start('frame', 15100);
|
||||
assert.equal(f.source.status().state, 'starting');
|
||||
f.source.stop();
|
||||
resolve({ executable: 'python', args: ['helper.py'], env: {} });
|
||||
await pending;
|
||||
assert.equal(f.children.length, 0);
|
||||
assert.equal(f.source.status().state, 'idle');
|
||||
});
|
||||
|
||||
test('late stderr cannot undo Stop; closing allows a fresh start on another port', async () => {
|
||||
const f = fixture(); await f.source.start('frame', 15100);
|
||||
const { child, args } = f.children[0];
|
||||
assert.deepEqual(args.slice(-5), ['--frame', 'frame', '--port', '15100', '--control-stdin']);
|
||||
child.stderr.emit('data', Buffer.from('Frame Control OBS stream is '));
|
||||
child.stderr.emit('data', Buffer.from('ready\n'));
|
||||
assert.equal(f.source.status().state, 'running');
|
||||
f.source.stop();
|
||||
child.stderr.emit('data', Buffer.from('last ffmpeg warning\n'));
|
||||
assert.equal(f.source.status().state, 'stopping');
|
||||
child.emit('close', 0, null);
|
||||
assert.equal(f.source.status().state, 'idle');
|
||||
await f.source.start('other-frame', 15101);
|
||||
assert.equal(f.source.status().port, 15101);
|
||||
// An old child cannot overwrite the replacement's state.
|
||||
child.emit('close', 255, null);
|
||||
assert.equal(f.source.status().state, 'starting');
|
||||
});
|
||||
|
||||
test('concurrent starts spawn once and preparation failures are retryable', async () => {
|
||||
let attempts = 0;
|
||||
const f = fixture(async () => {
|
||||
if (++attempts === 1) throw new Error('Python missing');
|
||||
return { executable: 'python', args: [], env: {} };
|
||||
});
|
||||
assert.equal((await f.source.start('frame')).state, 'failed');
|
||||
assert.match(f.source.status().error, /Python missing/);
|
||||
await Promise.all([f.source.start('frame'), f.source.start('frame')]);
|
||||
assert.equal(f.children.length, 1);
|
||||
});
|
||||
|
||||
test('invalid ports and aliases cannot spawn; errors and closed pipes remain visible', async () => {
|
||||
const f = fixture();
|
||||
for (const port of [0, 1023, 65536, 15099.5, '15100']) {
|
||||
await assert.rejects(f.source.start('frame', port), /port/i);
|
||||
}
|
||||
await assert.rejects(f.source.start('-bad'), /headset/i);
|
||||
assert.equal(f.children.length, 0);
|
||||
await f.source.start('frame');
|
||||
const { child } = f.children[0];
|
||||
child.stdin.emit('error', Object.assign(new Error('closed pipe'), { code: 'EPIPE' }));
|
||||
child.stderr.emit('data', Buffer.from('network lost\n'));
|
||||
child.emit('close', 255, null);
|
||||
assert.equal(f.source.status().state, 'failed');
|
||||
assert.match(f.source.status().error, /network lost/);
|
||||
});
|
||||
|
||||
test('an unverified host hides the controls and cannot spawn a relay', async () => {
|
||||
let spawned = false;
|
||||
const source = createObsSource({ supported: false, prepare: async () => ({}), spawn: () => { spawned = true; } });
|
||||
assert.equal(source.status().supported, false);
|
||||
await source.start('frame');
|
||||
assert.equal(spawned, false);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { preferenceStore } = require("../preferences");
|
||||
|
||||
function fixture(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-preferences-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return path.join(dir, "client-preferences.json");
|
||||
}
|
||||
|
||||
test("desktop packaging includes every local application module dependency", () => {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const packaged = new Set(require("../package.json").build.files);
|
||||
for (const file of ["main.js", "preload.js"]) {
|
||||
const source = fs.readFileSync(path.join(root, file), "utf8");
|
||||
for (const match of source.matchAll(/require\(["'](\.\/[^"']+)["']\)/g)) {
|
||||
const target = match[1].slice(2) + ".js";
|
||||
assert.ok(packaged.has(target), `${file} needs packaged ${target}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("client choices survive a new native session and local server origin", t => {
|
||||
const file = fixture(t);
|
||||
const first = preferenceStore(file);
|
||||
assert.deepEqual(first.read(), {});
|
||||
first.write("mvSeparate", "0");
|
||||
first.write("padUsed", "1");
|
||||
// A new process/session reads the same client store, regardless of its URL.
|
||||
const second = preferenceStore(file);
|
||||
assert.deepEqual(second.read(), { mvSeparate: "0", padUsed: "1" });
|
||||
second.write("mvSeparate", "1");
|
||||
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
|
||||
});
|
||||
|
||||
test("only the non-sensitive binary choices can enter the native store", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
|
||||
assert.throws(() => store.write(key, value), /Invalid preference/);
|
||||
}
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
fs.writeFileSync(file, JSON.stringify({ token: "discard", mvSeparate: "0", padUsed: 1 }));
|
||||
assert.deepEqual(store.read(), { mvSeparate: "0" });
|
||||
store.write("padUsed", "1");
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { mvSeparate: "0", padUsed: "1" });
|
||||
});
|
||||
|
||||
test("a missing or damaged preference file retains usable defaults", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
assert.deepEqual(store.read(), {});
|
||||
for (const bad of ["{broken", "null", "[]", '"text"']) {
|
||||
fs.writeFileSync(file, bad);
|
||||
assert.deepEqual(store.read(), {});
|
||||
}
|
||||
store.write("padUsed", "1");
|
||||
assert.deepEqual(store.read(), { padUsed: "1" });
|
||||
});
|
||||
|
||||
test("the PC in the headset opt-in is remembered and off unless saved", t => {
|
||||
const file = fixture(t), store = preferenceStore(file);
|
||||
assert.equal(store.read().pcView, undefined);
|
||||
store.write("pcView", "1");
|
||||
assert.deepEqual(preferenceStore(file).read(), { pcView: "1" });
|
||||
assert.throws(() => store.write("pcView", "on"), /Invalid preference/);
|
||||
});
|
||||
@@ -51,6 +51,27 @@ test("update.json assets always download from this repository's release", () =>
|
||||
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
|
||||
});
|
||||
|
||||
test("the release page is this repository's tag page, never a draft's untagged-... link", () => {
|
||||
const { fromManifest, fromApi } = require("../updater");
|
||||
const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0";
|
||||
const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page;
|
||||
assert.strictEqual(page(tagPage), tagPage);
|
||||
// 0.4.0's real update.json: the draft's address, a 404 once published.
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage);
|
||||
assert.strictEqual(page(undefined), tagPage);
|
||||
assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage);
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage);
|
||||
// Paths that normalize to another repository.
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"),
|
||||
tagPage);
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"),
|
||||
tagPage);
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag
|
||||
assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases");
|
||||
assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [],
|
||||
html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage);
|
||||
});
|
||||
|
||||
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
|
||||
const { prepare } = require("../updater");
|
||||
const release = { version: "0.3.1", assets: [] };
|
||||
|
||||
+18
-5
@@ -106,12 +106,24 @@ async function getJson(url, headers) {
|
||||
return JSON.parse(body);
|
||||
}
|
||||
|
||||
// The release page the banner links to. update.json for 0.4.0 carried the draft's
|
||||
// address (releases/tag/untagged-...), which is dead once the release is published,
|
||||
// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo
|
||||
// normalizes to another repository) before shell.openExternal. So the given page is
|
||||
// ignored: the link is always this repository's tag page, built from a valid version.
|
||||
function releasePage(version) {
|
||||
const v = parseVersion(version);
|
||||
if (!v) return RELEASES;
|
||||
return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`;
|
||||
}
|
||||
|
||||
// update.json and the API's release both become { version, notes, page, assets }.
|
||||
function fromManifest(m) {
|
||||
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
|
||||
const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
|
||||
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
|
||||
page: m.page || RELEASES,
|
||||
const version = String(m.version).replace(/^v/i, "");
|
||||
const base = `https://github.com/${REPO}/releases/download/v${version}/`;
|
||||
return { version, notes: String(m.notes || "").slice(0, 4000),
|
||||
page: releasePage(version),
|
||||
// Assets always come from this repository's release, whatever the manifest says.
|
||||
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
|
||||
size: a.size, digest: a.digest || null })) };
|
||||
@@ -119,8 +131,9 @@ function fromManifest(m) {
|
||||
|
||||
function fromApi(r) {
|
||||
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
|
||||
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
|
||||
page: r.html_url || RELEASES,
|
||||
const version = String(r.tag_name || "").replace(/^v/i, "");
|
||||
return { version, notes: String(r.body || "").slice(0, 4000),
|
||||
page: releasePage(version),
|
||||
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
|
||||
digest: a.digest || null })) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"label": "pc-agent-frame-arm64-hairpin",
|
||||
"date": "2026-09-28T22:36:19",
|
||||
"commit": "c5fc552+dirty",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "separate",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"usb": false,
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 10.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
|
||||
"source": "",
|
||||
"pc_host": true,
|
||||
"headset": "0:03.056203 [Info] - 1 - leaving standby",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 351,
|
||||
"frames_drawn": 351,
|
||||
"frames_shown": 324,
|
||||
"duration_s": 10.1,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.5,
|
||||
"n": 351
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.3,
|
||||
"p99": 0.4,
|
||||
"n": 351
|
||||
},
|
||||
"encode": {
|
||||
"p50": 2.0,
|
||||
"p95": 2.9,
|
||||
"p99": 3.5,
|
||||
"n": 351
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.4,
|
||||
"p99": 0.6,
|
||||
"n": 351
|
||||
},
|
||||
"network": {
|
||||
"p50": 11.6,
|
||||
"p95": 75.7,
|
||||
"p99": 130.1,
|
||||
"n": 351
|
||||
},
|
||||
"decode": {
|
||||
"p50": 0.7,
|
||||
"p95": 2.1,
|
||||
"p99": 4.1,
|
||||
"n": 351
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.4,
|
||||
"p95": 0.8,
|
||||
"p99": 1.1,
|
||||
"n": 351
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.8,
|
||||
"p95": 18.1,
|
||||
"p99": 24.9,
|
||||
"n": 324
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.1,
|
||||
"p95": 80.3,
|
||||
"p99": 134.2,
|
||||
"n": 351
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 21.0,
|
||||
"p95": 71.8,
|
||||
"p99": 146.3,
|
||||
"n": 324
|
||||
}
|
||||
},
|
||||
"fps": 34.8,
|
||||
"fps_shown": 32.0,
|
||||
"late_pct": 53.41,
|
||||
"stall_max": 226.8,
|
||||
"stalls_over_100ms": 7,
|
||||
"mbps": 0.26,
|
||||
"keyframes": 6,
|
||||
"size": "1280x720",
|
||||
"captured": 607,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 43.3,
|
||||
"p95": 98.4,
|
||||
"p99": 110.1,
|
||||
"n": 18
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 54.7,
|
||||
"p95": 98.9,
|
||||
"p99": 114.0,
|
||||
"n": 18
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 17.1,
|
||||
"p95": 48.3,
|
||||
"p99": 53.1,
|
||||
"n": 18
|
||||
},
|
||||
"mac": {
|
||||
"p50": 12.5,
|
||||
"p95": 32.9,
|
||||
"p99": 35.7,
|
||||
"n": 18
|
||||
},
|
||||
"back": {
|
||||
"p50": 16.8,
|
||||
"p95": 50.0,
|
||||
"p99": 55.9,
|
||||
"n": 18
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 18,
|
||||
"sent": 18,
|
||||
"seen": 18
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 38,
|
||||
"mbps": 0.3,
|
||||
"content_p50": 14.8,
|
||||
"content_p95": 25.3,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 33,
|
||||
"mbps": 0.24,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 103.5,
|
||||
"bitrate": 3845448,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 39,
|
||||
"mbps": 0.28,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 29.9,
|
||||
"bitrate": 5283790,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 25,
|
||||
"mbps": 0.21,
|
||||
"content_p50": 17.8,
|
||||
"content_p95": 175.9,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 34,
|
||||
"mbps": 0.22,
|
||||
"content_p50": 15.4,
|
||||
"content_p95": 69.8,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 38,
|
||||
"mbps": 0.29,
|
||||
"content_p50": 17.7,
|
||||
"content_p95": 68.7,
|
||||
"bitrate": 2764800,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 38,
|
||||
"mbps": 0.26,
|
||||
"content_p50": 15.0,
|
||||
"content_p95": 18.2,
|
||||
"bitrate": 3091280,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 30,
|
||||
"mbps": 0.22,
|
||||
"content_p50": 19.2,
|
||||
"content_p95": 111.0,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 35,
|
||||
"mbps": 0.26,
|
||||
"content_p50": 13.4,
|
||||
"content_p95": 102.6,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 38,
|
||||
"mbps": 0.28,
|
||||
"content_p50": 12.3,
|
||||
"content_p95": 20.9,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 3,
|
||||
"mbps": 0.02,
|
||||
"content_p50": 20.9,
|
||||
"content_p95": 24.7,
|
||||
"bitrate": 1725204,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 80.3,
|
||||
"input_p50": 43.3,
|
||||
"input_p95": 98.4,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "acceptable",
|
||||
"input_p50": "local",
|
||||
"input_p95": "acceptable",
|
||||
"fps": "bad",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "acceptable"
|
||||
},
|
||||
"controller": {
|
||||
"target": 3442081,
|
||||
"ceiling": 5529600,
|
||||
"tier": 0,
|
||||
"fps": 60,
|
||||
"scale": 1.0,
|
||||
"baseRtt": 13.334,
|
||||
"inFlight": 1,
|
||||
"slack": 71.738,
|
||||
"adapt": true
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"t": 3.77,
|
||||
"e": "target 2764800 bit/s; tier 0"
|
||||
},
|
||||
{
|
||||
"t": 7.42,
|
||||
"e": "target 1725204 bit/s; tier 0"
|
||||
}
|
||||
],
|
||||
"source_fps": 60.1,
|
||||
"cpu": {
|
||||
"host": "not sampled"
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
|
||||
"show_s": 1.88,
|
||||
"panel": "valve.steam.desktopgame.2001932457",
|
||||
"src": "test",
|
||||
"route": "network"
|
||||
}
|
||||
],
|
||||
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"label": "pc-agent-frame-arm64-final",
|
||||
"date": "2026-09-28T22:55:27",
|
||||
"commit": "cd20243",
|
||||
"config": {
|
||||
"quality": "balanced",
|
||||
"mode": "native",
|
||||
"net": "none",
|
||||
"delay_ms": 0,
|
||||
"buffer_ms": 250,
|
||||
"host": "frame",
|
||||
"usb": false,
|
||||
"ssh_opts": [],
|
||||
"encoder": "",
|
||||
"duration_s": 10.0,
|
||||
"browser_flags": []
|
||||
},
|
||||
"frame_build": "20260925.6191901",
|
||||
"mac": "26.5.2",
|
||||
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
|
||||
"source": "",
|
||||
"pc_host": true,
|
||||
"headset": "0:03.056203 [Info] - 1 - leaving standby",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "test",
|
||||
"frames_sent": 400,
|
||||
"frames_drawn": 400,
|
||||
"frames_shown": 339,
|
||||
"duration_s": 10.2,
|
||||
"stages_ms": {
|
||||
"capture": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.1,
|
||||
"p99": 0.2,
|
||||
"n": 400
|
||||
},
|
||||
"queue": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.2,
|
||||
"p99": 0.4,
|
||||
"n": 400
|
||||
},
|
||||
"encode": {
|
||||
"p50": 2.0,
|
||||
"p95": 2.7,
|
||||
"p99": 3.1,
|
||||
"n": 400
|
||||
},
|
||||
"socket": {
|
||||
"p50": 0.1,
|
||||
"p95": 0.3,
|
||||
"p99": 0.4,
|
||||
"n": 400
|
||||
},
|
||||
"network": {
|
||||
"p50": 11.8,
|
||||
"p95": 25.2,
|
||||
"p99": 37.8,
|
||||
"n": 400
|
||||
},
|
||||
"decode": {
|
||||
"p50": 0.7,
|
||||
"p95": 1.5,
|
||||
"p99": 2.0,
|
||||
"n": 400
|
||||
},
|
||||
"draw": {
|
||||
"p50": 0.3,
|
||||
"p95": 0.7,
|
||||
"p99": 1.0,
|
||||
"n": 400
|
||||
},
|
||||
"present": {
|
||||
"p50": 0.8,
|
||||
"p95": 19.3,
|
||||
"p99": 26.8,
|
||||
"n": 339
|
||||
},
|
||||
"content": {
|
||||
"p50": 15.1,
|
||||
"p95": 28.6,
|
||||
"p99": 41.3,
|
||||
"n": 400
|
||||
},
|
||||
"content_shown": {
|
||||
"p50": 17.6,
|
||||
"p95": 39.0,
|
||||
"p99": 47.2,
|
||||
"n": 339
|
||||
}
|
||||
},
|
||||
"fps": 39.3,
|
||||
"fps_shown": 33.4,
|
||||
"late_pct": 50.25,
|
||||
"stall_max": 69.3,
|
||||
"stalls_over_100ms": 0,
|
||||
"mbps": 0.35,
|
||||
"keyframes": 6,
|
||||
"size": "1280x720",
|
||||
"captured": 612,
|
||||
"viewer_never_drawn": 0,
|
||||
"input_ms": {
|
||||
"p50": 76.6,
|
||||
"p95": 98.2,
|
||||
"p99": 127.8,
|
||||
"n": 19
|
||||
},
|
||||
"input_shown_ms": {
|
||||
"p50": 80.5,
|
||||
"p95": 107.8,
|
||||
"p99": 128.1,
|
||||
"n": 16
|
||||
},
|
||||
"input_parts_ms": {
|
||||
"uplink": {
|
||||
"p50": 36.5,
|
||||
"p95": 57.6,
|
||||
"p99": 83.2,
|
||||
"n": 19
|
||||
},
|
||||
"mac": {
|
||||
"p50": 14.6,
|
||||
"p95": 31.3,
|
||||
"p99": 31.7,
|
||||
"n": 19
|
||||
},
|
||||
"back": {
|
||||
"p50": 15.1,
|
||||
"p95": 32.8,
|
||||
"p99": 36.2,
|
||||
"n": 19
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"asked": 19,
|
||||
"sent": 19,
|
||||
"seen": 19
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"t": 0,
|
||||
"fps": 40,
|
||||
"mbps": 0.34,
|
||||
"content_p50": 11.7,
|
||||
"content_p95": 27.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 1,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 11.9,
|
||||
"content_p95": 28.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 2,
|
||||
"fps": 41,
|
||||
"mbps": 0.35,
|
||||
"content_p50": 11.4,
|
||||
"content_p95": 16.6,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 3,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 12.7,
|
||||
"content_p95": 28.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 4,
|
||||
"fps": 38,
|
||||
"mbps": 0.33,
|
||||
"content_p50": 15.3,
|
||||
"content_p95": 30.0,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 5,
|
||||
"fps": 39,
|
||||
"mbps": 0.37,
|
||||
"content_p50": 16.7,
|
||||
"content_p95": 28.5,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 6,
|
||||
"fps": 41,
|
||||
"mbps": 0.38,
|
||||
"content_p50": 15.9,
|
||||
"content_p95": 29.4,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 7,
|
||||
"fps": 41,
|
||||
"mbps": 0.31,
|
||||
"content_p50": 15.6,
|
||||
"content_p95": 28.2,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 8,
|
||||
"fps": 38,
|
||||
"mbps": 0.38,
|
||||
"content_p50": 15.5,
|
||||
"content_p95": 25.9,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 9,
|
||||
"fps": 38,
|
||||
"mbps": 0.29,
|
||||
"content_p50": 16.7,
|
||||
"content_p95": 28.8,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
},
|
||||
{
|
||||
"t": 10,
|
||||
"fps": 6,
|
||||
"mbps": 0.03,
|
||||
"content_p50": 17.6,
|
||||
"content_p95": 23.1,
|
||||
"bitrate": 5529600,
|
||||
"tier": 0,
|
||||
"w": 1280,
|
||||
"net": null
|
||||
}
|
||||
],
|
||||
"adapt": [],
|
||||
"content_p50": 15.1,
|
||||
"content_p95": 28.6,
|
||||
"input_p50": 76.6,
|
||||
"input_p95": 98.2,
|
||||
"grades": {
|
||||
"input_replies": "local",
|
||||
"content_p50": "local",
|
||||
"content_p95": "local",
|
||||
"input_p50": "acceptable",
|
||||
"input_p95": "acceptable",
|
||||
"fps": "bad",
|
||||
"late_pct": "bad",
|
||||
"stall_max": "local"
|
||||
},
|
||||
"controller": {
|
||||
"target": 5529600,
|
||||
"ceiling": 5529600,
|
||||
"tier": 0,
|
||||
"fps": 60,
|
||||
"scale": 1.0,
|
||||
"baseRtt": 12.332,
|
||||
"inFlight": 0,
|
||||
"slack": 74.12,
|
||||
"adapt": true
|
||||
},
|
||||
"events": [],
|
||||
"source_fps": 60.0,
|
||||
"cpu": {
|
||||
"host": "not sampled"
|
||||
},
|
||||
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
|
||||
"show_s": 1.75,
|
||||
"panel": "valve.steam.desktopgame.2001932457",
|
||||
"src": "test",
|
||||
"route": "network"
|
||||
}
|
||||
],
|
||||
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
root = Path(__file__).resolve().parent
|
||||
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
|
||||
out = root / ('controller' + suffix)
|
||||
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
|
||||
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
|
||||
else:
|
||||
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
|
||||
subprocess.run(cmd, cwd=root, check=True)
|
||||
print(out)
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build our native PC adapter and bundle its ordinary shared libraries.
|
||||
|
||||
Run on the target architecture after installing GStreamer development packages.
|
||||
No GStreamer executable or desktop streaming application is shipped or invoked.
|
||||
Linux libc, display/GPU drivers and the portal service remain platform pieces.
|
||||
|
||||
x264 is GPL, so the bundle the Windows installer ships leaves it out: Windows
|
||||
encodes with Media Foundation. --with-x264 adds it for CI's headless protocol
|
||||
tests, which have no GPU or desktop to encode with.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def output(*args):
|
||||
return subprocess.check_output(args, text=True).strip()
|
||||
|
||||
|
||||
def build(destination, with_x264=False):
|
||||
win = sys.platform == 'win32'
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
|
||||
if not win:
|
||||
modules += ['gio-unix-2.0']
|
||||
import shlex
|
||||
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
|
||||
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
|
||||
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
|
||||
if not win:
|
||||
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
|
||||
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
|
||||
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
|
||||
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
|
||||
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
|
||||
plugins_out.mkdir(parents=True, exist_ok=True)
|
||||
libs_out.mkdir(parents=True, exist_ok=True)
|
||||
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad']
|
||||
if with_x264:
|
||||
names.append('x264')
|
||||
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
|
||||
pending = [lib]
|
||||
for name in names:
|
||||
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
|
||||
if not matches:
|
||||
raise SystemExit('Missing GStreamer library: ' + name)
|
||||
for source in matches:
|
||||
target = plugins_out / source.name
|
||||
shutil.copy2(source, target)
|
||||
pending.append(source)
|
||||
if not win:
|
||||
# PipeWire dynamically loads support and protocol modules. ldd cannot
|
||||
# discover those; bundle them with a private client-only config instead
|
||||
# of accidentally loading a different distro's client modules/config.
|
||||
libdir = plugin_dir.parent
|
||||
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
|
||||
'pipewire-0.3/libpipewire-module-client-node.so',
|
||||
'pipewire-0.3/libpipewire-module-adapter.so',
|
||||
'spa-0.2/support/libspa-support.so',
|
||||
'spa-0.2/support/libspa-dbus.so',
|
||||
'spa-0.2/videoconvert/libspa-videoconvert.so'):
|
||||
source = libdir / relative
|
||||
if not source.is_file():
|
||||
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
|
||||
target = destination / 'lib' / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
pending.append(source)
|
||||
config = destination / 'share' / 'pipewire'
|
||||
config.mkdir(parents=True, exist_ok=True)
|
||||
(config / 'client.conf').write_text(
|
||||
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
|
||||
'context.modules = [\n'
|
||||
' { name = libpipewire-module-protocol-native }\n'
|
||||
' { name = libpipewire-module-client-node }\n'
|
||||
' { name = libpipewire-module-adapter }\n'
|
||||
']\n')
|
||||
copied = set()
|
||||
while pending:
|
||||
binary = pending.pop()
|
||||
if win:
|
||||
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
|
||||
dependencies = [prefix / 'bin' / name for name in imported]
|
||||
else:
|
||||
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
|
||||
for dep in dependencies:
|
||||
if not dep.is_file() or dep.name in copied:
|
||||
continue
|
||||
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
|
||||
continue
|
||||
shutil.copy2(dep, libs_out / dep.name)
|
||||
copied.add(dep.name)
|
||||
pending.append(dep)
|
||||
# License texts and package provenance accompany the dynamically linked
|
||||
# libraries. Distribution builders keep the upstream package/source URLs.
|
||||
licenses = destination / 'licenses'
|
||||
licenses.mkdir(exist_ok=True)
|
||||
if win:
|
||||
source = prefix / 'share' / 'licenses'
|
||||
if source.exists():
|
||||
shutil.copytree(source, licenses, dirs_exist_ok=True)
|
||||
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
|
||||
else:
|
||||
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
|
||||
# Debian copyright files contain licenses and source homepage details.
|
||||
for path in Path('/usr/share/doc').glob('*/copyright'):
|
||||
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
|
||||
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
|
||||
plugins=names, libraries=sorted(copied), packages=packages), indent=2))
|
||||
print(lib)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
|
||||
parser.add_argument('--with-x264', action='store_true', help='add the GPL x264 encoder (CI tests only, never shipped)')
|
||||
args = parser.parse_args()
|
||||
build(args.out.resolve(), args.with_x264)
|
||||
@@ -0,0 +1,134 @@
|
||||
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
|
||||
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
|
||||
#include "controller.h"
|
||||
#include <gst/gst.h>
|
||||
#include <gst/app/gstappsink.h>
|
||||
#include <gst/video/video-event.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
|
||||
typedef struct {
|
||||
GstElement *pipeline, *encoder, *sink, *raw_queue;
|
||||
Gate gate;
|
||||
GstSample *sample;
|
||||
GstMapInfo map;
|
||||
int mapped;
|
||||
char error[512];
|
||||
} Capture;
|
||||
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
|
||||
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
|
||||
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
|
||||
FC_API int fc_has_element(const char *name) {
|
||||
GstElementFactory *f=gst_element_factory_find(name);
|
||||
if(!f)return 0;gst_object_unref(f);return 1;
|
||||
}
|
||||
FC_API int fc_has_property(const char *element, const char *property) {
|
||||
/* Probe the same initialized GStreamer instance that capture uses. The
|
||||
* bundled MSYS2 plugin can omit optional WGC properties entirely. */
|
||||
GstElement *source=gst_element_factory_make(element,NULL);
|
||||
if(!source)return 0;
|
||||
int found=g_object_class_find_property(G_OBJECT_GET_CLASS(source),property)!=NULL;
|
||||
gst_object_unref(source);
|
||||
return found;
|
||||
}
|
||||
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
|
||||
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
|
||||
if(!b)return GST_PAD_PROBE_OK;
|
||||
int64_t now=fc_now(), cap=now;
|
||||
GstClock *clock=gst_element_get_clock(c->pipeline);
|
||||
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
|
||||
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
|
||||
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
|
||||
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
|
||||
}
|
||||
if(clock)gst_object_unref(clock);
|
||||
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
|
||||
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
|
||||
int phase=0;
|
||||
for(;;) {
|
||||
/* While congested, prefer a newer raw picture queued upstream. If
|
||||
* nothing changed, retain this last picture until the gate opens;
|
||||
* an idle window must not stay stale after a dropped final update. */
|
||||
guint queued=0;
|
||||
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
|
||||
if(queued)return GST_PAD_PROBE_DROP;
|
||||
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
|
||||
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
|
||||
phase=2;g_usleep(2000);
|
||||
}
|
||||
}
|
||||
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
|
||||
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
|
||||
c->pipeline=gst_parse_launch(pipeline,&e);
|
||||
if(e || !c->pipeline) {
|
||||
g_strlcpy(error,e?e->message:"No pipeline",capacity);
|
||||
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
|
||||
}
|
||||
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
|
||||
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
|
||||
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
|
||||
if(!c->encoder || !c->sink || !raw) {
|
||||
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
|
||||
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
|
||||
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
|
||||
}
|
||||
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
|
||||
GstPad *p=gst_element_get_static_pad(raw,"src");
|
||||
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
|
||||
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
|
||||
p=gst_element_get_static_pad(c->encoder,"sink");
|
||||
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
|
||||
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
|
||||
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
|
||||
return c;
|
||||
}
|
||||
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
|
||||
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
|
||||
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
|
||||
GstBus *bus=gst_element_get_bus(c->pipeline);
|
||||
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
|
||||
if(m) {
|
||||
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
|
||||
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
|
||||
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
|
||||
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
|
||||
gst_message_unref(m);return -1;
|
||||
}
|
||||
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
|
||||
if(!c->sample) {
|
||||
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
|
||||
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
GstBuffer *b=gst_sample_get_buffer(c->sample);
|
||||
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
|
||||
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
|
||||
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
|
||||
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
|
||||
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
|
||||
}
|
||||
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
|
||||
FC_API void fc_capture_bitrate(Capture *c,int bps) {
|
||||
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
|
||||
}
|
||||
FC_API void fc_capture_test(Capture *c,uint32_t input) {
|
||||
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
|
||||
if(source) {
|
||||
/* Each benchmark click visibly changes the ball, before injection is
|
||||
* timestamped. This is a response, not merely a protocol input echo. */
|
||||
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
|
||||
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
|
||||
}
|
||||
}
|
||||
FC_API void fc_capture_key(Capture *c) {
|
||||
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
|
||||
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
|
||||
}
|
||||
FC_API void fc_capture_close(Capture *c) {
|
||||
gst_element_set_state(c->pipeline,GST_STATE_NULL);
|
||||
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
|
||||
if(c->sample)gst_sample_unref(c->sample);
|
||||
if(c->raw_queue)gst_object_unref(c->raw_queue);
|
||||
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* A C port of the Mac agent's RateController (mac/frame-mac-view/Sources/
|
||||
* Controller.swift) for the Windows host. Same gate, demand protection, bitrate
|
||||
* recovery and tier hysteresis; bounded sample buffers. The Mac agent keeps its
|
||||
* Swift original, so a change to either must be made to both:
|
||||
* tests/test_pc_controller.py checks this one against the Swift trace. */
|
||||
#include "controller.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#define CAP 1024
|
||||
#define MIN(a,b) ((a)<(b)?(a):(b))
|
||||
#define MAX(a,b) ((a)>(b)?(a):(b))
|
||||
typedef struct { int64_t t, v; uint32_t seq; } Sample;
|
||||
typedef struct { Sample a[CAP]; int n; } Samples;
|
||||
struct FCController {
|
||||
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
|
||||
int64_t decrease, increase, below, above, slack;
|
||||
Samples flight, rtts, acked, sent, captures;
|
||||
};
|
||||
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
|
||||
static const double floors[] = {.45,.28,.16,.08,0};
|
||||
static void remove_first(Samples *s, int n) {
|
||||
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
|
||||
}
|
||||
static void add(Samples *s, Sample v, int cap) {
|
||||
if (s->n >= cap) remove_first(s, 1);
|
||||
s->a[s->n++] = v;
|
||||
}
|
||||
static void expire(Samples *s, int64_t oldest) {
|
||||
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
|
||||
remove_first(s,n);
|
||||
}
|
||||
static int64_t base(FCController *c) {
|
||||
int64_t v=0;
|
||||
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
|
||||
return v;
|
||||
}
|
||||
static int compare(const void *a, const void *b) {
|
||||
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
|
||||
return (x>y)-(x<y);
|
||||
}
|
||||
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
|
||||
int64_t a[CAP]; int n=0;
|
||||
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
|
||||
if(!n) return 0;
|
||||
qsort(a,(size_t)n,sizeof(int64_t),compare);
|
||||
return a[n*numerator/denominator];
|
||||
}
|
||||
static int rate(Samples *s) {
|
||||
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
|
||||
return (int)MIN(total*16,2147483647);
|
||||
}
|
||||
FCController *fc_new(int max_fps,int enabled) {
|
||||
FCController *c=calloc(1,sizeof(*c));
|
||||
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
|
||||
return c;
|
||||
}
|
||||
void fc_free(FCController *c) {free(c);}
|
||||
void fc_ceiling(FCController *c,int bps) {
|
||||
if(!c->target || c->target>bps) c->target=bps;
|
||||
c->ceiling=bps;
|
||||
}
|
||||
int fc_gate(FCController *c,int64_t now,int counts) {
|
||||
if(!c->enabled || !c->saw_ack) return 1;
|
||||
expire(&c->flight,now-2000000);
|
||||
if(!c->flight.n) return 1;
|
||||
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
|
||||
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
|
||||
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
|
||||
if(counts) c->held++;
|
||||
return 0;
|
||||
}
|
||||
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
|
||||
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
|
||||
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
|
||||
}
|
||||
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
|
||||
c->saw_ack=1;
|
||||
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
|
||||
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
|
||||
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
|
||||
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
int fc_update(FCController *c,int64_t now) {
|
||||
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
|
||||
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
|
||||
expire(&c->captures,now-1000000);
|
||||
if(!c->enabled || c->ceiling<=0) return 0;
|
||||
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
|
||||
int64_t jitter=spread ? spread-baseline : 0;
|
||||
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
|
||||
int64_t recent=quantile(&c->rtts,now-300000,1,2);
|
||||
int64_t queue=recent ? recent-baseline : 0;
|
||||
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
|
||||
int delivered=rate(&c->acked),sending=rate(&c->sent);
|
||||
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
|
||||
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
|
||||
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
|
||||
int congested=signal && c->signal;c->signal=signal;c->held=0;
|
||||
if(congested && now-c->decrease>300000) {
|
||||
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
|
||||
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
|
||||
c->target=next;c->decrease=now;
|
||||
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
|
||||
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
|
||||
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
|
||||
}
|
||||
double share=(double)c->target/MAX(c->ceiling,1);
|
||||
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
|
||||
if(!c->below)c->below=now;
|
||||
if(now-c->below>500000) {
|
||||
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
|
||||
c->below=0;
|
||||
}
|
||||
} else c->below=0;
|
||||
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
|
||||
if(!c->above)c->above=now;
|
||||
if(now-c->above>2000000) {c->tier--;c->above=0;}
|
||||
} else c->above=0;
|
||||
return c->target;
|
||||
}
|
||||
int64_t fc_value(FCController *c,int field) {
|
||||
switch(field) {
|
||||
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
|
||||
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
|
||||
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
|
||||
default:return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Shared latency controller. Times are monotonic host microseconds.
|
||||
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
|
||||
* encoded inter frame would break the decoder's reference chain. */
|
||||
#ifndef FRAME_CONTROLLER_H
|
||||
#define FRAME_CONTROLLER_H
|
||||
#include <stdint.h>
|
||||
#ifdef _WIN32
|
||||
#define FC_API __declspec(dllexport)
|
||||
#else
|
||||
#define FC_API
|
||||
#endif
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
typedef struct FCController FCController;
|
||||
FC_API FCController *fc_new(int fps, int enabled);
|
||||
FC_API void fc_free(FCController *c);
|
||||
FC_API void fc_ceiling(FCController *c, int bps);
|
||||
FC_API int fc_gate(FCController *c, int64_t now, int counts);
|
||||
FC_API void fc_capture(FCController *c, int64_t now);
|
||||
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
|
||||
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
|
||||
FC_API int fc_update(FCController *c, int64_t now);
|
||||
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
|
||||
FC_API int64_t fc_value(FCController *c, int field);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,147 @@
|
||||
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
|
||||
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
|
||||
* Input uses only the devices granted in Start's response. */
|
||||
#ifndef _WIN32
|
||||
#include "controller.h"
|
||||
#include <gio/gio.h>
|
||||
#include <gio/gunixfdlist.h>
|
||||
#include <unistd.h>
|
||||
#include <string.h>
|
||||
#define BUS "org.freedesktop.portal.Desktop"
|
||||
#define PATH "/org/freedesktop/portal/desktop"
|
||||
#define RD "org.freedesktop.portal.RemoteDesktop"
|
||||
#define SC "org.freedesktop.portal.ScreenCast"
|
||||
typedef struct {
|
||||
GDBusConnection *bus;
|
||||
GMainContext *context;
|
||||
char *session;
|
||||
int fd;
|
||||
unsigned node, devices;
|
||||
int width,height;
|
||||
} Portal;
|
||||
typedef struct {GVariant *result;int done;unsigned code;} Response;
|
||||
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
|
||||
const char *signal,GVariant *params,gpointer data) {
|
||||
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
|
||||
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
|
||||
}
|
||||
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
|
||||
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
|
||||
const char *token,char *error,int capacity) {
|
||||
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
|
||||
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
|
||||
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
|
||||
Response r={0};GError *e=NULL;
|
||||
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
|
||||
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
|
||||
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
|
||||
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
|
||||
if(reply)g_variant_unref(reply);
|
||||
int64_t deadline=g_get_monotonic_time()+120000000;
|
||||
while(!e && !r.done && g_get_monotonic_time()<deadline) {
|
||||
while(g_main_context_iteration(p->context,FALSE)) {}
|
||||
g_usleep(10000);
|
||||
}
|
||||
if(!r.done) {
|
||||
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
|
||||
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
|
||||
if(closed)g_variant_unref(closed);
|
||||
}
|
||||
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
|
||||
if(e || !r.done || r.code) {
|
||||
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
|
||||
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
|
||||
}
|
||||
return r.result;
|
||||
}
|
||||
FC_API void fc_portal_close(Portal *p) {
|
||||
if(!p)return;
|
||||
if(p->session && p->bus) {
|
||||
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
|
||||
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
|
||||
if(r)g_variant_unref(r);
|
||||
}
|
||||
if(p->fd>=0){close(p->fd);p->fd=-1;}
|
||||
g_free(p->session);if(p->bus)g_object_unref(p->bus);
|
||||
if(p->context)g_main_context_unref(p->context);g_free(p);
|
||||
}
|
||||
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
|
||||
* previously consumed protocol socket is not a new connection.
|
||||
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
|
||||
* pipewiresrc never takes the fd it is given: its core connects with
|
||||
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
|
||||
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
|
||||
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
|
||||
* close; not closing it would leak one socket per pipeline reopen. The
|
||||
* caller closes the previous pipeline before asking for a new fd. */
|
||||
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
|
||||
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
GUnixFDList *fds=NULL;GError *e=NULL;
|
||||
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
|
||||
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
|
||||
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
|
||||
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
|
||||
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
|
||||
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
|
||||
if(p->fd>=0)close(p->fd);
|
||||
p->fd=fd;return fd;
|
||||
}
|
||||
FC_API Portal *fc_portal_select(char *error,int capacity) {
|
||||
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
|
||||
g_main_context_push_thread_default(p->context);
|
||||
GError *e=NULL;GVariant *r=NULL;
|
||||
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
|
||||
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
|
||||
GVariantBuilder b;char token[64],session[64];
|
||||
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
|
||||
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
|
||||
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
|
||||
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
|
||||
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
|
||||
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
|
||||
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
|
||||
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
|
||||
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
option(&b,"handle_token",g_variant_new_string(token));
|
||||
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
|
||||
g_variant_lookup(r,"devices","u",&p->devices);
|
||||
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
|
||||
if(streams && g_variant_n_children(streams)>0) {
|
||||
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
|
||||
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
|
||||
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
|
||||
g_variant_unref(props);g_variant_unref(entry);
|
||||
}
|
||||
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
|
||||
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
|
||||
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
|
||||
g_main_context_pop_thread_default(p->context);return p;
|
||||
fail:
|
||||
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
|
||||
}
|
||||
FC_API int fc_portal_value(Portal *p,int field) {
|
||||
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
|
||||
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
|
||||
}
|
||||
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
|
||||
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
|
||||
if(type==0 && (p->devices&2)) {
|
||||
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
|
||||
} else if(type==1 && (p->devices&2)) {
|
||||
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
|
||||
} else if(type==2 && (p->devices&2)) {
|
||||
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
|
||||
} else if(type==3 && (p->devices&1)) {
|
||||
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
|
||||
} else return 0;
|
||||
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
|
||||
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
|
||||
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
|
||||
}
|
||||
#endif
|
||||
+38
-1
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
|
||||
|
||||
## Assistant panel
|
||||
|
||||
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
|
||||
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
|
||||
To put the same page in the headset, with the HTTP server still running:
|
||||
|
||||
```sh
|
||||
@@ -168,6 +168,43 @@ on this branch (run 36421345682).
|
||||
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
|
||||
browser profile and SSH tunnel and remove the profile and panel log.
|
||||
|
||||
**Verified end to end on the same Frame/build (2026-09-29), with mutations:**
|
||||
a stdio MCP client started `ui/frame_mcp.py` in its default mode (private
|
||||
backend, no API key, no prestarted server) and a human approved or rejected
|
||||
each change in the approval page in a real Chrome window:
|
||||
|
||||
| Tool | Result on the Frame |
|
||||
|---|---|
|
||||
| `send_file` | Approved; the file arrived in `~/Downloads` with identical contents |
|
||||
| `install` | Approved; `io.github.fizzyizzy05.binary` job finished in about 35 s |
|
||||
| `panel` | Approved; gamescope listed a new panel window, and `computer_state` reported the same window ID and PID. The app rendered in that window (below) |
|
||||
| `launch` | Approved; Keep Talking and Nobody Explodes (341800) started under Proton and `computer_state` reported it as the focused app |
|
||||
| `uninstall` | Approved; app and locale removed |
|
||||
| `power` | Rejected in the page. Unapproved retries, the same token used for `uninstall`, and a retry after rejection were all refused. Nothing was powered off |
|
||||
| `send_text` | Approved, then refused because the Plasma desktop was not open (documented requirement) |
|
||||
| `keep_awake` | `status` reports the script unavailable until PR #16 lands |
|
||||
|
||||
A separate Claude Code CLI session, with only this server configured, read
|
||||
status, `computer_state` and a headset capture, and requested an install. It
|
||||
received an approval URL and did not execute anything.
|
||||
|
||||
The assistant opened as a Frame panel through `scripts/assistant-on-frame.py`.
|
||||
Against a loopback stub model, a send without consent made zero requests. With
|
||||
consent it made exactly one, carrying the text and a fresh Frame screenshot.
|
||||
Consent unticked itself after sending. SIGTERM removed the panel, profile, log
|
||||
and tunnel.
|
||||
|
||||
**Not verified while unworn:** every headset capture was a uniform dark frame,
|
||||
so SteamVR's rendered view of panels and the game could not be checked; window
|
||||
captures (`xwd`) were used instead. MCP can launch a game or panel but has no
|
||||
tool to stop one: the tester stopped them over SSH. Removing an app leaves any
|
||||
runtime it pulled in; Flatpak may also remove related extensions when that
|
||||
runtime is removed by hand.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Computer-use coverage
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Import an Android game backup
|
||||
|
||||
A SideQuest backup can contain a small APK and much larger `main.…obb` and
|
||||
`patch.…obb` files. All of them belong to the same game. Installing only the APK
|
||||
can leave the game unable to start because its assets are missing.
|
||||
|
||||
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the base APK, any supported splits, and its OBB files together.
|
||||
You can also drop them anywhere in the window. In the desktop app, dropping the
|
||||
APK alone finds matching OBB files beside it; dropping a folder containing the
|
||||
APK and OBBs also works. Browsers can read only the files you explicitly choose.
|
||||
|
||||
The import screen shows the game app and its game-data files together. **Import
|
||||
game & data** installs the APK, starts its own Steam library instance, waits for
|
||||
Android and the installed package, copies the OBBs, and closes the game. Open it
|
||||
again from the Steam library to play. The screen explains this before import;
|
||||
choosing files alone never starts a game.
|
||||
|
||||
Files are matched using the package inside the APK and the standard
|
||||
`main|patch.<version>.<package>.obb` name, not the APK's filename. Both main and
|
||||
patch files are preserved. OBB version numbers need not equal the APK's current
|
||||
version code: updates can reuse older expansion files. The copy uses the existing
|
||||
SHA-256 transfer check inside Android and writes to that game's own
|
||||
`/sdcard/Android/obb/<package>` folder.
|
||||
|
||||
If no data files are selected, the screen reminds you to include any OBBs in the
|
||||
backup before offering **Install game app**. An APK cannot reliably tell Frame
|
||||
Control whether separate expansion files are required. Game-data files are never
|
||||
silently sent to Downloads by this flow.
|
||||
|
||||
If setup fails, the import screen keeps the error and retry instructions visible.
|
||||
The APK may already be installed, but the game is not reported ready. Import the
|
||||
same backup again, or choose just its OBB files to add data to an existing install.
|
||||
The importer checks the installed package and version before writing data and
|
||||
closes the instance after a failed copy. Unrelated or duplicate files, running
|
||||
games that would be replaced, and downgrades are rejected. Uploaded staging files
|
||||
are discarded after use or cancellation; abandoned imports expire after an hour
|
||||
when the importer is next used, and dead-server staging folders are swept at
|
||||
startup.
|
||||
|
||||
This flow handles one base APK, supported native-library or asset splits, and
|
||||
standard OBB files for one game at a time. It checks the package and version in
|
||||
each split manifest before touching the headset. The existing Quest merger
|
||||
rejects resource, code and feature splits and sets with multiple device
|
||||
variants. A merged APK is re-signed; that can affect a game's licence or login.
|
||||
XAPK/APKS/APKM archives use the same checks. Nested Android/data folders and
|
||||
private save archives remain unsupported. PC ZIP/folder/EXE imports keep their
|
||||
existing route. Quest USB and Meta library imports keep their existing workflow.
|
||||
|
||||
Validation covers actual APK parsing, the three-file backup layout, package
|
||||
matching, sibling discovery, browser upload staging and cleanup, instance/version
|
||||
guards and failure recovery with mocked headset operations. Browser verification
|
||||
uses the real page and upload/inspection endpoints with simulated installation.
|
||||
**Verified 2026-10-20 on a real Steam Frame**, BUILD_ID `20260925.6191901`:
|
||||
F-Droid Basic 2.0.2 with two synthetic main/patch OBBs completed the automatic
|
||||
install/start/copy/close sequence. Reopening its persistent instance confirmed
|
||||
both SHA-256 hashes in `/sdcard/Android/obb/org.fdroid.basic`. The test app,
|
||||
shortcut and instance were removed afterward, the shared device lock was
|
||||
released, and the battery remained at 99%. See [device evidence](evidence/android-import/device.json).
|
||||
The headset was unworn; this checks transfer and persistence, not rendering,
|
||||
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
|
||||
flow does not change APK compatibility or remove licence checks.
|
||||
The supported-split backup route was checked with generated local fixtures and
|
||||
existing merger tests, not a real split game on the Frame.
|
||||
@@ -0,0 +1,197 @@
|
||||
# APK repositories
|
||||
|
||||
Frame Control supports **F-Droid-format repositories**, including F-Droid,
|
||||
F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository
|
||||
indexes are authenticated before their apps appear. Search lists builds with
|
||||
Android API ≤30 and arm64-v8a or no native libraries, using the same streaming
|
||||
reducer as the existing catalogue. This does not guarantee an app works in Lepton.
|
||||
|
||||
## Formats considered
|
||||
|
||||
| Format | Users and purpose | Support in this source |
|
||||
|---|---|---|
|
||||
| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes |
|
||||
| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` |
|
||||
| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index |
|
||||
| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter |
|
||||
| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source |
|
||||
| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid |
|
||||
|
||||
Research references (checked 2026-09-28):
|
||||
|
||||
- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and
|
||||
[repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/).
|
||||
- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/)
|
||||
and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/).
|
||||
- [Droid-ify](https://github.com/Droid-ify/client) and
|
||||
[Neo Store](https://github.com/NeoApplications/Neo-Store).
|
||||
- [Obtainium](https://github.com/ImranR98/Obtainium), its
|
||||
[configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/),
|
||||
and [community app configurations](https://apps.obtainium.imranr.dev/).
|
||||
- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest).
|
||||
The absence of a specification in these materials is not proof that no
|
||||
historical or private custom-feed format exists.
|
||||
|
||||
## Add a repository in Frame Control
|
||||
|
||||
From the Frame Control checkout, use its source-management CLI:
|
||||
|
||||
```sh
|
||||
python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps'
|
||||
python3 ui/apk_sources/fdroid.py list
|
||||
python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music'
|
||||
python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app
|
||||
python3 ui/apk_sources/fdroid.py remove SOURCE_ID
|
||||
```
|
||||
|
||||
Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint`
|
||||
can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…`
|
||||
links are accepted and converted to HTTPS. Conflicting fingerprints are refused.
|
||||
A URL must identify the repository directory, not its website or an index file.
|
||||
|
||||
Adding fetches and validates the complete index **before saving** the source.
|
||||
Without a fingerprint, Frame Control verifies the JAR signature and remembers
|
||||
its signer: trust on first use (TOFU). This establishes continuity with the
|
||||
first server response, not independent publisher identity. Obtain the published
|
||||
fingerprint through a trusted channel when possible; the store's Add a source
|
||||
form shows the pinned one ("Trusted on first use: …") so you can compare it.
|
||||
Re-adding an existing URL preserves its pin; changing it requires deliberately
|
||||
removing and re-adding it.
|
||||
|
||||
### Repositories behind a login
|
||||
|
||||
Some self-hosted repositories sit behind HTTP basic auth. As in F-Droid's own
|
||||
client, the login can be part of the address
|
||||
(`https://user:pass@example.org/fdroid/repo`), or given separately: in the
|
||||
store's **Add a source** form under "This repository needs a login", in the
|
||||
server API (`username` and `password` beside `url`), or on the command line,
|
||||
where the password is read from standard input so it stays out of shell history
|
||||
and `ps`:
|
||||
|
||||
```sh
|
||||
printf '%s\n' "$PASSWORD" | python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo' --username me --password-stdin
|
||||
```
|
||||
|
||||
How the login is kept and used:
|
||||
|
||||
- **Kept out of the address.** The `user:pass@` part is removed before anything
|
||||
is saved, so the saved repository URL, its id, the source list, job results
|
||||
and the page never contain it. The store only says "Uses a login".
|
||||
- **Stored separately.** Logins live in
|
||||
`frame_host.data_dir('source-credentials.json')`
|
||||
(`~/Library/Application Support/Frame Control/source-credentials.json` on
|
||||
macOS), written with mode 0600 and keyed by source id. They are stored in
|
||||
plain text there, protected by file permissions only (on Windows,
|
||||
the folder's normal ACLs). Removing the repository deletes its login, and
|
||||
the file goes when the last login is removed. Each change holds a lock
|
||||
file (`source-credentials.lock`), so the app and the command line can't undo
|
||||
each other's saves or removals.
|
||||
- **HTTPS only.** An `http://` address with a login is refused before any request.
|
||||
- **Inside the repository only.** The `Authorization` header is decided
|
||||
separately for every request, redirects included: index, `entry.jar`,
|
||||
APKs and the store's artwork proxy. It is sent only when the request's
|
||||
scheme, host and port match the repository's and its path is inside the
|
||||
repository's directory by whole path segments. A redirect to another host
|
||||
or port, or to another path on the same server, gets no login, and that
|
||||
request's own `Authorization` header is removed. Paths with `.` or `..`
|
||||
segments (plain or percent-encoded), encoded `/` or `\`, double encoding
|
||||
or `;` get no login, since a server could normalise them to a path
|
||||
outside the repository. Index file names like that are refused outright.
|
||||
- **Never echoed.** Logins are not written to logs, error messages, API
|
||||
responses or the cache. The CLI prints only the saved source.
|
||||
- **Checked before saving.** As with any repository, the whole index must
|
||||
download and verify before anything is saved. HTTP 401 (or 403 when a login
|
||||
was sent) stops the add with "check the username and password" and saves
|
||||
nothing: no repository, no login, no rollback state. A 403 with a login is
|
||||
not treated as rate limiting.
|
||||
- **Same verification.** The login changes only who may download. Signature,
|
||||
fingerprint pin or TOFU, index hash and rollback checks apply exactly as for
|
||||
any other repository.
|
||||
|
||||
Re-adding the same address with a new login replaces the saved one. Re-adding
|
||||
it without a login keeps the saved one.
|
||||
|
||||
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
|
||||
`add_repo(url, fingerprint=None, name=None, username=None, password=None)`, `remove_repo(source_id)`,
|
||||
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
|
||||
`sources`, `search`, `details`, and `download` from the shared source contract.
|
||||
This change supplies the CLI and API; the integrated source-management UI is
|
||||
separate work. Built-in sources can be disabled but cannot be removed.
|
||||
|
||||
Settings and pins live in `frame_host.data_dir('apk-repos.json')`
|
||||
(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS).
|
||||
Authenticated reduced indexes and APKs live under
|
||||
`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours. An
|
||||
expired index is still served (marked stale in the store) while it refreshes in
|
||||
the background; a failed refresh is retried after 10 minutes.
|
||||
Only the running Frame Control app prunes cached APKs (at start and after store
|
||||
downloads); the command-line tools never do.
|
||||
The existing catalogue's unverified index cache is never treated as authenticated.
|
||||
|
||||
Rollback protection: each repository's newest accepted signed index timestamp
|
||||
is kept in `apk-repo-state.json` next to the settings, and an older index is
|
||||
refused. Once a repository has served a v2 `entry.jar`, a missing `entry.jar`
|
||||
is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
|
||||
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
|
||||
Removing a repository clears its state.
|
||||
|
||||
## Publish your own repository
|
||||
|
||||
Only publish free APKs you own or have the developer's permission to distribute.
|
||||
Do not publish paid app mirrors or bypass store licences. Check distribution
|
||||
terms before adding someone else's repository; this module does not infer legal
|
||||
permission from a signature or automatically audit a repository's terms.
|
||||
|
||||
Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/)
|
||||
and its documented Android/Java dependencies on the publishing machine, then:
|
||||
|
||||
```sh
|
||||
mkdir my-fdroid
|
||||
cd my-fdroid
|
||||
fdroid init
|
||||
# Set repo_url in config.yml to https://example.org/fdroid/repo
|
||||
# Also set repo_name and repo_description; keep the generated signing key safe.
|
||||
cp /path/to/your-free-app.apk repo/
|
||||
fdroid update --create-metadata
|
||||
# Review the generated metadata (name, summary, licence, source and website).
|
||||
fdroid update
|
||||
```
|
||||
|
||||
Serve the generated **repo directory** at that HTTPS URL, including APKs,
|
||||
icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the
|
||||
private signing keystore or configuration passwords. Configure fdroidserver's
|
||||
`serverwebroot` and run `fdroid deploy` for managed publication, or copy the
|
||||
public directory with your existing deployment tool. Publish the SHA-256
|
||||
repository certificate fingerprint displayed by fdroidserver in a link such as
|
||||
`https://example.org/fdroid/repo?fingerprint=…`.
|
||||
|
||||
Keep the repository signing key backed up: changing it breaks existing pins.
|
||||
For updates, add the new APK, edit metadata as needed, run `fdroid update` and
|
||||
publish again. Test the published URL with Frame Control's `add`, `search` and
|
||||
`download` commands. The above publisher setup is documented from fdroidserver;
|
||||
it was not executed as part of this implementation.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of
|
||||
2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are
|
||||
recognized. It checks the signer certificate pin, the signature over `.SF`,
|
||||
the whole-manifest digest, and the manifest's digest of the JSON member.
|
||||
ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are
|
||||
rejected. Certificates are pinned identities, not validated as Web PKI chains.
|
||||
HTTPS certificates are separately checked by Python's normal TLS validation.
|
||||
|
||||
v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature,
|
||||
fingerprint, index hash, TLS and server errors never trigger an unsigned
|
||||
fallback. APKs are cached by SHA-256 and checked again before reuse. Here,
|
||||
`verified: true` means the bytes match the signed repository's APK hash; it
|
||||
is not an independent APK publisher-signature or runtime compatibility verdict.
|
||||
There is no repository timestamp rollback/expiry policy or automated signing-key
|
||||
rotation yet. An old correctly signed index can still validate.
|
||||
|
||||
Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache
|
||||
reuse, URL rejection and corruption of every signature/hash layer. On the Mac,
|
||||
the real IzzyOnDroid repository was added with its published pin, searched for
|
||||
Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256
|
||||
`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`.
|
||||
No headset connection or installation was performed.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Developer-consented APK sources
|
||||
|
||||
Surveyed 2026-09-28. Free access is not proof of redistribution permission or
|
||||
Frame compatibility. These adapters fetch only public publisher releases or
|
||||
link to publisher pages. They do not acquire store entitlements, defeat access
|
||||
checks, install anything, or rehost APKs. See [VR compatibility](vr-apks.md).
|
||||
|
||||
| Source | Developer consent and automated-access position | API/feed; VR coverage | Decision |
|
||||
|---|---|---|---|
|
||||
| [itch.io](https://itch.io/docs/legal/terms) | Publishers warrant distribution rights (§4). Users may access content through the service; this is not blanket scraping permission. Main robots excludes `/game/download/`; author subdomains exclude `/*/download/`. No challenge bypass. | Public free Android RSS for `openxr` and `oculus-quest`; substantial indie VR. Server API is mostly authenticated publisher/account functionality, not a general anonymous store-download API. | Implement RSS search, artwork and page links; `downloadable: False`. The supplied free-download script follows keyed download pages excluded by robots, so it is not shipped. |
|
||||
| [GitHub releases](https://docs.github.com/en/rest/releases/releases) | Maintainers publish assets; curated repositories below establish provenance. Public hosting or an open-source topic alone does not establish rights to every uploaded binary. Use supported REST API under [API terms](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service#h-api-terms), not HTML crawling. | Releases API includes APK assets and sometimes SHA-256. Topic search finds OpenXR/Quest projects. 60 unauthenticated requests/hour; authenticated user limits are generally 5,000/hour, with separate search/secondary limits. | Implement curated downloads and explicit topic discovery. Unreviewed topic results are page-only. |
|
||||
| [Uptodown](https://www.uptodown.com/aboutus) | Developer distribution program exists, but that does not prove publisher authorization for every catalog item. [Privacy policy](https://www.uptodown.com/aboutus/privacy) explicitly describes protection against automated access. General automation permission was not established. | Broad Android catalog, limited VR focus; no supported public consumer-download API established in this survey. | Page links only; no downloader. Do not infer consent from an unchanged APK signature. |
|
||||
| [APKPure](https://apkpure.com/terms) | Third-party APK catalog; individual publisher consent and automation rights were not established. Terms request returned HTTP 403; no bypass attempted. | Broad Android coverage, incidental VR; internal endpoints are not permission to automate. | Exclude automatic indexing/downloading; user may open site. |
|
||||
| [APKMirror](https://www.apkmirror.com/faq/) | Publisher-signed files and a free-app policy are not a blanket developer-consent or automation grant. FAQ request returned HTTP 403, so current terms could not be confirmed. | General Android/version archive; APK bundles often need another installer; little VR focus. No supported consumer-download API established. | Page links only, no scraping or bundle conversion. |
|
||||
| [Aptoide](https://en.aptoide.com/company/legal) | Terms define an app supplier as developer, owner or authorized distributor; user stores still require per-item provenance. API availability alone does not settle third-party access rights. | API ecosystem and general Android catalog; weak VR focus. | Defer until a publisher-owned store and its API terms can be approved. No blanket community-store downloader. |
|
||||
| [Amazon Appstore](https://developer.amazon.com/docs/app-submission/understanding-submission.html) | Official developer submissions; store account, device and license rules apply. Publisher submission APIs do not authorize public binary extraction. | Fire-device distribution; Android-device Appstore support ended in 2025; little Quest relevance. | Official product links only; no account or entitlement extraction. |
|
||||
| [PICO / ByteDance store](https://developer.picoxr.com/document/distribute) | Official publisher channel with store/device entitlements. No public unauthenticated binary-download grant established; documentation request encountered a redirect error. | Strong standalone VR; PICO builds may depend on PICO services/extensions. | Store links only. A developer's independently published GitHub/itch build can qualify separately. |
|
||||
| [Meta Horizon Store / former App Lab](https://www.meta.com/experiences/) | Official developer submissions. A free store entitlement is still an entitlement; no license bypass or authenticated store extraction. App Lab was folded into the main store in 2024. | Strongest Quest coverage; no supported anonymous APK-download API established. | Store links only; independently distributed free builds use their publisher source. |
|
||||
| [Khronos samples](https://github.com/KhronosGroup/OpenXR-SDK-Source) | Official upstream, Apache-2.0 sample; developer-published release APKs. GitHub API terms apply. | `hello_xr` Vulkan/OpenGL ES APKs; excellent OpenXR diagnostics. | Included in GitHub curated list, Vulkan variant selected. |
|
||||
| [Meta OpenXR samples](https://github.com/meta-quest/Meta-OpenXR-SDK) | Official upstream; check each sample's license. Source availability does not imply a published APK, and some samples require Meta extensions/services. | Source/build examples, inconsistent ready-made APK releases. | Link to upstream; add specific free APKs only after release/provenance review. |
|
||||
| [Godot XR demos](https://github.com/GodotVR/godot-xr-tools) | Official project source and publisher demo pages; licenses and dependencies vary by demo. | OpenXR examples on GitHub/itch. Older Godot builds can fail on Lepton's missing clipboard service. | Covered by source discovery; no compatibility promise from an OpenXR tag. |
|
||||
|
||||
The table distinguishes observed restrictions from unknown permission. An
|
||||
unverified policy is a reason to defer automation, not a claim that a site is
|
||||
unlawful. Only the two implemented source kinds are registered by their own
|
||||
`sources()` functions; the other rows are recommendations, not new UI entries.
|
||||
|
||||
## Adapters
|
||||
|
||||
`ui/apk_sources/github.py` uses `github_curated.json`: Khronos `hello_xr`,
|
||||
[Open Brush](https://github.com/icosa-foundation/open-brush), and
|
||||
[SuperTux 3D](https://github.com/SgtBilko76/SuperTux-3D). These have official
|
||||
OpenXR project/release evidence, not a blanket claim of headset compatibility.
|
||||
Open Brush's compatibility evidence is recorded in [vr-apks.md](vr-apks.md).
|
||||
Open Brush and SuperTux publish the selected builds as prereleases; curated
|
||||
opt-ins preserve that label in version records. Exact APK filename patterns
|
||||
avoid downloading desktop archives or alternate non-Quest builds.
|
||||
[OpenSaberPlus](https://github.com/arpruss/OpenSaberPlus) was examined but not
|
||||
curated: GitHub reports its license as `NOASSERTION`, and current OpenXR APK
|
||||
provenance was not established in this pass.
|
||||
|
||||
Default GitHub search is offline against this small list. Queries
|
||||
`topic:openxr`, `topic:oculus-quest`, and `topic:quest` explicitly call repository
|
||||
search. Results outside the curated list stay page-only, even if a repository
|
||||
claims an open-source license. This prevents an arbitrary tagged mirror from
|
||||
becoming a trusted downloader. Extend the curated JSON after provenance review.
|
||||
|
||||
For a higher API quota, add a GitHub access token in the store's source
|
||||
settings (the GitHub row's token field) or through the server API
|
||||
(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and
|
||||
`source: "github"`). `FRAME_GITHUB_TOKEN` in the process environment still
|
||||
works and wins over a saved token. A saved token is kept with repository
|
||||
logins in `source-credentials.json` (mode 0600, see
|
||||
[APK repositories](apk-repos.md#repositories-behind-a-login)). The source list
|
||||
reports only whether a token is set, where it came from and whether GitHub
|
||||
rejected it, never its value. Tokens are sent as `Authorization: Bearer` only to
|
||||
`api.github.com`, never written to the cache, never sent to asset hosts (public
|
||||
release downloads don't need them), and removed on redirects. If GitHub answers
|
||||
401 to the token, the request is retried once without it, as Obtainium does.
|
||||
The rejection is logged without the token and shown in source settings. The
|
||||
token is not tried again until it is saved again or the app restarts. Rate-limit
|
||||
backoff is kept per login (anonymous, or each token), so saving a token after
|
||||
anonymous requests were limited lets the next request go out with it. The
|
||||
adapter does not read `gh` credentials automatically. Metadata is cached for one hour under
|
||||
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
|
||||
fetches at most ten releases. Rate-limit errors are surfaced without retry
|
||||
loops. Asset IDs and release tags are not Android version codes: metadata
|
||||
leaves the latter unknown and rejects a requested `version_code` rather than
|
||||
silently fetching a different build.
|
||||
|
||||
`itch.py` exposes separate OpenXR and Quest feed sources, so one feed's failure
|
||||
does not suppress the other at the aggregator level. Queries filter the current
|
||||
feed window locally: this is not an exhaustive historical itch search. Only
|
||||
explicit zero-price Android entries are returned. Covers are exposed in
|
||||
`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
|
||||
Curated GitHub entries include publisher artwork and plain-language summaries.
|
||||
Repository image URLs are pinned to inspected commits. Open Brush screenshots
|
||||
come from its README-linked Steam listing; SuperTux uses the upstream gameplay
|
||||
preview embedded in the port's README (not a headset capture). The hello_xr
|
||||
sample has a launcher icon and GitHub social banner; no published screenshot
|
||||
was found in the inspected repository/README, so its screenshot list is empty.
|
||||
Uncurated topic results use the owner's avatar and GitHub's repository social
|
||||
preview. These are repository placeholders, not app screenshots. Itch's recorded
|
||||
RSS includes only covers, so screenshot lists remain empty without page scraping. VR is
|
||||
based on curated evidence or a VR-specific feed/topic, not a compatibility claim.
|
||||
|
||||
Downloads stream to unique temporary files, require an APK manifest entry,
|
||||
restrict HTTPS origins and redirects, and enforce a 2 GiB ceiling. `verified`
|
||||
means the downloaded SHA-256 matches GitHub's published digest. Without such a
|
||||
digest, the computed SHA-256 is returned with `verified: False`; neither value
|
||||
claims publisher-signature validation. Installation must inspect the APK as
|
||||
usual. OBBs, split APKs, paid assets and external release-body download links
|
||||
are unsupported.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
On this Mac, Python 3.9 downloaded the real Khronos Vulkan 1.1.63 APK through
|
||||
the GitHub adapter, matched its published SHA-256
|
||||
`f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34`, and
|
||||
`python3 ui/frame_android.py info <apk>` exited 0: package
|
||||
`org.khronos.openxr.hello_xr.vulkan`, version code 1063, minimum API 24,
|
||||
arm64-v8a present, OpenXR detected. No Frame connection or installation occurred.
|
||||
|
||||
The itch OpenXR RSS was fetched successfully and recorded as a fixture.
|
||||
Subsequent live adapter search encountered HTTP 429; it is not claimed as a
|
||||
successful live end-to-end search. Fixture search finds Off Nominal and parses
|
||||
nine Android entries from the ten-item feed (one has only an HTML platform).
|
||||
A real itch download and APK inspection were deliberately not performed:
|
||||
robots restrictions take precedence over that requested proof. No current
|
||||
policy text is claimed verified where the table records failed access.
|
||||
|
||||
Tests use recorded, reduced API/RSS fixtures with network access blocked in
|
||||
the new test class. They cover selection, prereleases, unknown topic results,
|
||||
paid/non-Android exclusion, URL restrictions, redirect credential removal,
|
||||
caching, rate limits, checksum mismatch, non-APK rejection and partial-file
|
||||
cleanup. See `.claude/NOTES-more-sources.md` for commands and local evidence.
|
||||
+148
@@ -39,6 +39,12 @@ section below), instead of going into Lepton Development:
|
||||
run at once alongside Lepton Development, each in its own container
|
||||
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
|
||||
|
||||
Split APKs (a base plus `split_*.apk` files) install as one app:
|
||||
`python3 ui/frame_android.py install base.apk split1.apk …`. Lepton runs one
|
||||
APK, so splits that add native libraries or assets are merged into the base
|
||||
and re-signed; density and language splits are refused. See
|
||||
[quest-import.md](quest-import.md#split-apks). Not yet tried on a real Frame.
|
||||
|
||||
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
|
||||
from the shortcut, stopped, and relaunched with their data intact. ADB and
|
||||
Lepton Development aren't involved.
|
||||
@@ -177,6 +183,92 @@ See
|
||||
[compat-db/README.md](../compat-db/README.md) and
|
||||
[apk-catalog/README.md](../apk-catalog/README.md).
|
||||
|
||||
## Store and Library in Frame Control
|
||||
|
||||
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
|
||||
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
|
||||
Frame as cards, with Play and a menu for Test, Report and Remove; the display
|
||||
settings and recent reports are below it.
|
||||
|
||||
Store search cards and app details also show **Reported not working** or
|
||||
**Reported issues** when saved compatibility reports say so. Details include
|
||||
the report date, version and notes. These are advisories: Install remains
|
||||
available unless the selected build has an existing compatibility blocker
|
||||
(for example, no arm64-v8a build or Android API above 30). Reports never clear
|
||||
those blockers. Apps reported not working are excluded from Home's featured
|
||||
app and recommendation shelves; search and category results still show them.
|
||||
An unrated install failure caused by the wrong APK architecture or Android API
|
||||
is ignored, using the same rules as the catalogue. A person's rating takes
|
||||
priority over automated results, with the latest rating winning.
|
||||
|
||||
Reports warm in a background worker at server start. Search and details read
|
||||
only the last complete in-memory snapshot and schedule a single background
|
||||
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
|
||||
the report database. During initial loading they show the usual build verdict;
|
||||
after a refresh, the next search or detail request sees the reports. Failed
|
||||
refreshes keep the previous snapshot and retry after another 60 seconds.
|
||||
|
||||
**Availability:** public copies benefit from their own saved reports, including
|
||||
reports for apps outside the bundled catalogue, across every source with the
|
||||
same package name. This does not give public users the private shared ratings.
|
||||
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
|
||||
report verdicts, so it cannot supply a useful public report snapshot. No private
|
||||
reports, free-text notes or credentials are exported or published. The existing
|
||||
maintainer loader still uses its configured database access in the background;
|
||||
the search feature adds no sharing, endpoint or network destination. Tests leave
|
||||
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
|
||||
server subprocesses) and use synthetic reports to exercise refresh behavior.
|
||||
|
||||
The store groups apps by what a headset is good for, not by F-Droid's list:
|
||||
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
|
||||
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
|
||||
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
|
||||
*Social Network* is Web & chat).
|
||||
|
||||
Apps built around a phone are hidden from browsing unless **Show phone-only
|
||||
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
|
||||
a category that only makes sense in a phone's own system (launchers, keyboards,
|
||||
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
|
||||
fitness, alarms, system tools) unless a useful category says otherwise
|
||||
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
|
||||
part (a widget, a launcher, a battery tool), and apps that need hardware the
|
||||
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
|
||||
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
|
||||
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
|
||||
|
||||
Without a search, each source offers its best 300 for the chosen category:
|
||||
apps with a banner first, then with screenshots, then the newest.
|
||||
|
||||
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
|
||||
|
||||
The **APK Store** panel is the Store above, drawn for the laser: the same
|
||||
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
|
||||
its own floating panel:
|
||||
|
||||
```sh
|
||||
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
|
||||
scripts/store-on-frame.sh close
|
||||
```
|
||||
|
||||
`open` copies the same bundle the iPhone app uses
|
||||
(`ios/scripts/make_frame_bundle.py`) to
|
||||
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
|
||||
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
|
||||
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
|
||||
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
|
||||
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
|
||||
all run on the Frame, so the Mac can go away after `open`.
|
||||
|
||||
The page has a category rail (Home, the store's categories, Installed), eight
|
||||
art cards a page, and a featured VR app on Home. It turns pages instead of
|
||||
scrolling, because a tagged panel gets mouse clicks and a drag would select
|
||||
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
|
||||
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
|
||||
the real sources): Home, categories, Installed, the detail sheet. **Still to
|
||||
check in the headset:** that the panel opens and takes laser clicks, that an
|
||||
install completes when run on the Frame, and that **Open** brings up the app's
|
||||
panel.
|
||||
|
||||
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
|
||||
|
||||
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
|
||||
@@ -331,3 +423,59 @@ because gamescope scales Lepton's surface to fit the same panel. Also unverified
|
||||
whether the settings survive the app or its Lepton instance relaunching.
|
||||
Lepton Development rebuilds its Android data on exit, so there they probably
|
||||
don't.
|
||||
|
||||
## Expansion files and save backups
|
||||
|
||||
SideQuest-inspired CLI helpers install local OBB files into an already-running
|
||||
app instance and back up/restore a stopped instance's private app data. See
|
||||
[SideQuest features and limits](sidequest.md) for commands, archive scope and
|
||||
verification status. These paths have offline coverage; real Frame storage and
|
||||
permissions remain unverified. They do not change APK install or launch behavior.
|
||||
|
||||
|
||||
## Local APK/OBB backups and Android bundles
|
||||
|
||||
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
|
||||
base APK, any supported splits, and its matching `main.<version>.<package>.obb` and/or
|
||||
`patch.<version>.<package>.obb` files together. The desktop app also discovers
|
||||
matching OBB files beside a lone selected APK. Browser uploads use only the
|
||||
files you selected. Installed apps can receive OBB-only imports.
|
||||
|
||||
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
|
||||
one base APK, optional supported splits and matching OBBs. These extensions describe
|
||||
containers, not a guarantee that their contents are supported. OBBs may be
|
||||
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
|
||||
one enclosing backup folder is allowed. Known manifest/icon/signature
|
||||
sidecars are ignored; the APK manifest determines the package. Unknown files,
|
||||
unsafe paths, links, encrypted entries, duplicate paths (including case
|
||||
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
|
||||
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
|
||||
100-file/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
|
||||
extract an Android ZIP yourself and select its APK/OBBs.
|
||||
|
||||
The importer previews the app and files before **Import game & data**.
|
||||
Confirming authorizes installing the app, starting it to prepare Android,
|
||||
checking the running package/version, copying OBBs through the existing
|
||||
verified data helper, and closing the game. Open it from Steam afterwards.
|
||||
If setup fails, read the error and retry the same backup; do not uninstall
|
||||
first, since that deletes saves. An app that cannot start may also prevent
|
||||
data setup. Uploaded and extracted staging is removed on cancellation,
|
||||
inspection failure, or job completion/failure; abandoned imports expire
|
||||
through the existing one-hour staging lifecycle. Original local files stay intact.
|
||||
|
||||
**Limits:** this backup importer requires exactly one base APK. For split sets,
|
||||
each split must identify the same package and version, and pass the existing
|
||||
[Quest split merger](quest-import.md#split-apks). It refuses resource, code and
|
||||
feature splits and does not choose among device variants. Merging re-signs the
|
||||
APK, which can affect licences. It does not restore `Android/data`, private app
|
||||
data, saves or entitlements. These split imports have local fixture and merger
|
||||
tests, but no real split game has been installed with this backup route on the
|
||||
Frame.
|
||||
|
||||
For issue #102, the RE4 report only establishes that an APK attempt failed.
|
||||
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
|
||||
and runtime compatibility remain unknown. Record those details, Frame Control
|
||||
and SteamOS versions, and sanitized server/crash errors before diagnosing it.
|
||||
`python3 ui/frame_android.py info /path/to/game.apk` inspects the APK locally.
|
||||
Successful installation or fixture tests do not establish RE4 compatibility;
|
||||
OBBs cannot fix ABI/API, VrApi/OpenXR, entitlement or Android-service failures.
|
||||
@@ -0,0 +1,228 @@
|
||||
# Backup and recovery
|
||||
|
||||
**Tools → Backup & recovery** copies what a factory reset would lose from the
|
||||
Steam Frame to your computer, puts it back afterwards, and gets the headset out
|
||||
of Desktop Mode. It runs from Frame Control on a computer, not from the iPhone
|
||||
app or the Frame itself.
|
||||
|
||||
**Partly tested on a headset.** On a Frame running SteamOS 0.5.6 (build
|
||||
20261015.6141207), a full backup saved 26 parts (40.7 MB): 14 games' Proton
|
||||
saves, Linux game saves, Steam and desktop settings and 9 Android apps' data,
|
||||
with no sign-in files or keys in the archives. Restoring one game's saves on top
|
||||
of identical files wrote nothing and left nothing behind. The session check
|
||||
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
|
||||
headset: a restore after a real factory reset, a restore that replaces files,
|
||||
Android data restore. Unit tests and the fake Frame cover those (see
|
||||
[Verification](#verification)).
|
||||
On 2026-10-24, the real-device reproduction in [issue #142](https://github.com/saphid/frame-control/issues/142)
|
||||
confirmed full Plasma Desktop Mode on the same build: the Plasma session target
|
||||
was active and the Game Mode target inactive. Desktop Mode also ran gamescope
|
||||
for `steamvr-plasma.service`, so the old process check incorrectly reported
|
||||
Game Mode. SteamOS's `steamosctl switch-to-game-mode` restored Game Mode and
|
||||
reversed the target states; battery stayed at 99%, and the rollback timer and
|
||||
shared lock were cleaned up. After #143 passed CI, the corrected code was
|
||||
tested in another full Plasma session under a three-minute rollback timer.
|
||||
`ui/frame_backup.py session` reported Desktop Mode despite gamescope, and
|
||||
`ui/frame_backup.py game-mode` invoked the same guarded Return function used by
|
||||
the button. Game Mode became active and a second call made no switch. Battery
|
||||
remained at 99%; the timer and lock were removed. That test used the production
|
||||
CLI rather than clicking the desktop button.
|
||||
|
||||
Later on 2026-10-24, the published **v0.4.17 Linux x86_64 AppImage** passed the
|
||||
literal packaged-button check on the same Frame/build. With the shared lock and
|
||||
a verified three-minute rollback timer, the headset entered full Plasma Desktop
|
||||
Mode. In the packaged Electron window, **Tools → Backup & recovery → Check**
|
||||
reported Desktop Mode despite gamescope. A trusted automated pointer click on
|
||||
the visible **Return to Game Mode** button, accepting its confirmation, returned
|
||||
`switched: true` and `steamosctl switch-to-game-mode`. The systemd targets then
|
||||
confirmed Game Mode. A second confirmed button click returned `switched: false`;
|
||||
the Game Mode service invocation and SteamVR PID stayed unchanged. **Check**
|
||||
then reported Game Mode and disabled Return. The test app/server and virtual
|
||||
display were stopped; the timer and shared lock were removed. Steam and SteamVR remained running
|
||||
at 99% battery. [Commands, responses, cleanup and limits](evidence/return-game-mode-packaged-2026-10-24.json),
|
||||
[before](evidence/return-game-mode-packaged-2026-10-24-before.png) and
|
||||
[after](evidence/return-game-mode-packaged-2026-10-24-after.png).
|
||||
|
||||
This used the unchanged extracted AppImage payload with its bundled Python on
|
||||
Xvfb (`--no-sandbox --disable-gpu`), following the packaged Export test in #156.
|
||||
FUSE mounting, desktop integration, hardware rendering and the view inside a
|
||||
worn headset were not checked. Older SteamOS builds remain fixture-tested.
|
||||
|
||||
## Back up
|
||||
|
||||
Choose **Game saves**, **Steam and desktop settings** and **Android app data**
|
||||
(all three by default), then **Back up now**. **See what's included** lists each
|
||||
part and its size first. Backing up only reads the headset.
|
||||
|
||||
Each backup is a folder named after the time it started:
|
||||
|
||||
```
|
||||
~/Documents/Frame Control Backups/2026-10-23-142530/
|
||||
manifest.json
|
||||
saves/saves-1234.tar.gz one per game
|
||||
saves/saves-linux.tar.gz
|
||||
settings/settings-steam.tar.gz
|
||||
settings/settings-system.tar.gz
|
||||
android/org.example.game.tar.gz one per Android app
|
||||
```
|
||||
|
||||
`manifest.json` records the headset's name, SteamOS version and BUILD_ID, every
|
||||
file with its size and SHA-256, and each archive's SHA-256. It also lists what
|
||||
was skipped and why. A backup is written under a hidden `.…partial` name and
|
||||
renamed when complete, so a cut-off backup never shows up as one.
|
||||
|
||||
### What's saved
|
||||
|
||||
| Part | From the headset (`~` is `/home/steamos`) | Restored? |
|
||||
|---|---|---|
|
||||
| Windows game saves | `~/.local/share/Steam/steamapps/compatdata/<appid>/pfx/drive_c/users/steamuser/` → `AppData/Roaming`, `AppData/Local`, `AppData/LocalLow`, `Documents`, `Saved Games`. Wine's own folders in them (`Temp`, `Microsoft`, `D3DSCache`, `CrashDumps`) are left out. Sideloaded Windows games (non-Steam shortcuts) have prefixes here too | Yes |
|
||||
| Linux game saves | `~/.config/unity3d`, `~/.local/share/godot/app_userdata`: where Unity and Godot games save by default | Yes |
|
||||
| Steam controller layouts | `userdata/<account>/241100/remote/controller_config`, `steamapps/common/Steam Controller Configs/<account>/config` | Yes |
|
||||
| Steam library settings | `userdata/<account>/config/localconfig.vdf`, `shortcuts.vdf`, `grid/` (custom artwork) | Kept for reference |
|
||||
| Desktop settings | `~/.config/kdeglobals`, `kwinrc`, `kxkbrc` (keyboard layout), `konsolerc`, `dolphinrc` | Yes |
|
||||
| SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` | Kept for reference |
|
||||
| Android app data | Each Frame Control Android app's private data, through the existing `backup-data` helper ([sidequest.md](sidequest.md#private-app-data-backups)) | Yes |
|
||||
|
||||
"Kept for reference" files are in the backup but not put back automatically:
|
||||
Steam and SteamVR rewrite them while they run, and an old `shortcuts.vdf` would
|
||||
point at apps that aren't installed after a reset. Open the backup folder to
|
||||
copy one by hand if you need it.
|
||||
|
||||
### What's never copied
|
||||
|
||||
- **Steam sign-in:** `steam.token`, `config/config.vdf`, `loginusers.vdf`,
|
||||
`registry.vdf` and `ssfn*` files.
|
||||
- **Keys and passwords:** `~/.ssh`, `~/.gnupg`, keyrings and KWallet, any
|
||||
`*.token`, `*.key` or `*.pem` file, Tailscale state and proxy settings
|
||||
(`secrets.env`). These are refused by name wherever they turn up, including
|
||||
inside a game's folder, and a restore refuses them too.
|
||||
- Games, Proton and the Steam runtimes: reinstall them from Steam.
|
||||
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
|
||||
screenshots (**Tools → Capture & Live** saves those to Pictures).
|
||||
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
|
||||
- Symbolic links, including any ancestor below the home folder of a selected
|
||||
save or settings path, and single files over 2 GiB. Discovery checks each
|
||||
component, and packing checks it again before opening each file.
|
||||
|
||||
Steam backups use the normal `~/.local/share/Steam` directory tree. A separate
|
||||
`~/.steam/steam` alias does not affect this route. A library, Proton prefix or
|
||||
Wine save folder redirected by a symlink below the home folder is skipped,
|
||||
even if its target is inside home; it is not resolved and backed up elsewhere.
|
||||
The home folder itself is trusted and may use the host's normal path aliases.
|
||||
|
||||
These are path checks, not a filesystem snapshot: a concurrent rename or link
|
||||
replacement between a check and a read can still race it. Close games and
|
||||
avoid moving or relinking these folders during backup. This fix rejects links
|
||||
already present when checked, including ones introduced after discovery; it
|
||||
does not provide atomic protection against concurrent filesystem changes.
|
||||
|
||||
Backups aren't encrypted. Game folders can still hold a launcher's login, and
|
||||
Android app data can hold an app's, so keep backups private.
|
||||
|
||||
Android apps that are open are skipped (the helper needs the app closed); close
|
||||
them and back up again.
|
||||
|
||||
## Restore after a factory reset
|
||||
|
||||
1. Set up the headset again: Developer Mode, **Set Up Connection**, sign in to
|
||||
Steam with the same account.
|
||||
2. Reinstall your games, and **start each once** so Proton creates its folders.
|
||||
3. Reinstall your Android apps from Frame Control.
|
||||
4. **Tools → Backup & recovery → Restore…** on a backup. Frame Control checks
|
||||
each part against the headset and says what it would do: how many files it
|
||||
replaces and how many are new.
|
||||
5. Tick the parts to restore and **Restore selected**. It asks before replacing
|
||||
anything.
|
||||
|
||||
Parts that can't go back yet are skipped, not failed, with the reason:
|
||||
|
||||
| Reason | What to do |
|
||||
|---|---|
|
||||
| Install the game first | Install it from Steam, then restore |
|
||||
| Start the game once | Proton hasn't made its folders yet; start the game, quit, restore |
|
||||
| Sign in to Steam first | Controller layouts go under your account's folder |
|
||||
| Install the app first / close the app first | Android app data needs the app installed and closed |
|
||||
| The backup file doesn't match its checksum | The archive changed since it was made |
|
||||
|
||||
A restore writes only the files in the parts you chose, and only inside that
|
||||
part's folders: it never deletes a file, and a save archive can't write outside
|
||||
its game. Before replacing a file it copies the current one to
|
||||
`~/.local/share/frame-control/restore-previous/<time>/` on the headset. Files
|
||||
already identical are left alone. Android app data goes through the existing
|
||||
`restore-data` helper, which keeps the previous data folder beside it.
|
||||
|
||||
## Stuck in Desktop Mode
|
||||
|
||||
On the Frame, Game Mode is gamescope running Steam, and the Linux desktop
|
||||
normally runs *inside* it as one panel (`steamos-nested-desktop`, see
|
||||
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
|
||||
is what you see, you're already in Game Mode: close it from the Steam dashboard.
|
||||
|
||||
Full Desktop Mode is a different session. **Check** reads the main user's
|
||||
systemd session targets over SSH: `gamescope-session.target` and
|
||||
`plasma-workspace-wayland.target`. Game Mode requires the former active and the
|
||||
latter inactive; Desktop Mode requires the reverse. This takes precedence over
|
||||
process names: full Desktop Mode can run gamescope for its VR overlay, and
|
||||
Game Mode can run a nested Plasma panel. Check changes nothing.
|
||||
|
||||
Conflicting, transitional, failed or unreadable targets produce **unknown**.
|
||||
On older images where both targets are explicitly absent, the fallback accepts
|
||||
only gamescope without Plasma/nested desktop as Game Mode, or Plasma without
|
||||
gamescope/nested desktop as Desktop Mode. Mixed processes remain unknown.
|
||||
The SDDM autologin file is still reported as a login preference; it cannot
|
||||
establish which session is running.
|
||||
|
||||
After confirmation, **Return to Game Mode** runs exactly
|
||||
`steamosctl switch-to-game-mode` when available, or the older
|
||||
`holo-session-select gamescope` / `steamos-session-select gamescope` command.
|
||||
It closes the desktop and anything open in it. Frame Control never edits the
|
||||
SDDM files itself, and runs no switch when the headset is in Game Mode, when
|
||||
its mode is unknown, or when no supported switch command is installed. A
|
||||
refused command reports an error; a request being sent does not verify that
|
||||
the new session has started.
|
||||
|
||||
### Manual fallback
|
||||
|
||||
If the button can't help, or the headset isn't reachable:
|
||||
|
||||
1. **Restart it:** hold Power about 10 seconds until the light goes off, then
|
||||
power on. On the Steam Deck, **Switch to Desktop** starts a one-shot
|
||||
Desktop Mode session, so a restart brings Game Mode back. That the Frame
|
||||
behaves the same is **inferred**, not tested.
|
||||
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
|
||||
`ssh frame steamosctl switch-to-game-mode` (verified on SteamOS 0.5.6).
|
||||
Older images may instead provide `steamos-session-select gamescope` or
|
||||
`holo-session-select gamescope`.
|
||||
3. **Boot menu:** hold Power 10 seconds, then power on while holding the AUX
|
||||
button on top of Power. Choose `Current`, or `Previous` to start the other
|
||||
SteamOS slot; both keep your data. Details and the further options
|
||||
(`Repair Steam Installation`, `Erase User Data`, a full re-image) are in
|
||||
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing) and
|
||||
[recovery-and-images.md](recovery-and-images.md). Back up first if you can:
|
||||
`Erase User Data` is a factory reset.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
|
||||
for real against a folder laid out like the Frame's home, with only SSH faked:
|
||||
which paths are picked, that sign-in files and keys never are (also inside game
|
||||
folders), links in save/settings roots and their ancestors refused, the manifest
|
||||
and its checksums, restore checks
|
||||
after a reset (game not installed, no Proton folders yet, not signed in), that a
|
||||
restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
(`..`, absolute paths, links, other games' folders) writing nothing, backup
|
||||
names that try to leave the backups folder, and the exact Game Mode command.
|
||||
Synthetic symlink fixtures cover every component of a Proton save root below
|
||||
home, including `compatdata/<appid>` and `steamuser/AppData`, plus directory
|
||||
and file links introduced between discovery and packing. Ordinary Proton and
|
||||
non-Steam saves, Linux saves, controller layouts, reference settings and home
|
||||
aliases still pack with their original bytes. These are local fixture checks;
|
||||
this path fix has not been tested on a headset.
|
||||
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
|
||||
the server, deletes it and restores it. Neither involves a real headset.
|
||||
|
||||
Isolated session tests in the same suite reproduce full Plasma plus gamescope,
|
||||
Game Mode with and without the nested desktop, conflicting/transitional targets,
|
||||
missing targets on older builds, failed/timed-out systemd reads, selector
|
||||
compatibility, and the confirmed Return action with SSH mocked. Run them with
|
||||
`python3 -m unittest discover -s tests -p test_backup.py -v`.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Battery and health monitoring
|
||||
|
||||
Open **Control Center → Battery & thermal health**, or expand **System
|
||||
information**, to read the Frame's charge, charging state, signed battery
|
||||
power, battery temperature and driver-reported health. Optional readings show
|
||||
charge cycles, full charge capacity, design capacity and full / design capacity.
|
||||
The existing battery chip also shows charging state and temperature.
|
||||
|
||||
These are read-only readings from the Linux host's `/sys/class/power_supply`,
|
||||
not Android's container battery view. The probe prefers the documented Frame
|
||||
fuel gauge, `max1720x_bat_7-36`, over other battery nodes, excludes peripheral
|
||||
(`scope=Device`) and absent batteries, and otherwise uses a deterministic
|
||||
battery fallback. The UI shows the selected source path.
|
||||
|
||||
| Reading | Linux attribute | Interpretation |
|
||||
|---|---|---|
|
||||
| Charge | `capacity` | Integer 0–100 percent; zero is a real reading |
|
||||
| Charging state | `status` | Charging, Full, Discharging or Not charging; missing/unknown states stay unavailable |
|
||||
| Battery power | `voltage_now × current_now` | Microvolts and microamps converted to signed watts; positive means charging on the Frame |
|
||||
| Temperature | `temp` | Tenths of a degree Celsius, including valid negative readings |
|
||||
| Battery health | `health` | Driver status such as Good or Overheat; not a wear percentage |
|
||||
| Charge cycles | `cycle_count` | Driver-reported count, including zero |
|
||||
| Full/design capacity | `charge_full`, `charge_full_design` | Microamp-hours converted to mAh |
|
||||
| Full / design capacity | Ratio of those two capacities | Fuel-gauge estimate; not a diagnosis or a guaranteed runtime |
|
||||
| Time estimate | `time_to_full_now`, `time_to_empty_now` | Seconds; displayed only for the corresponding charging/discharging state |
|
||||
|
||||
Unavailable means the field is absent, unreadable or invalid. One missing
|
||||
sensor does not hide the others. Zero design capacity cannot produce a ratio. Time estimates outside 1 second
|
||||
to 7 days and battery temperatures outside −273.15 to 200 °C are rejected
|
||||
as invalid readings; these bounds are not thermal warning thresholds.
|
||||
The ratio may exceed 100% because full capacity is a gauge estimate, so the app
|
||||
does not clamp it or invent a wear warning. The app does not estimate health
|
||||
from charge percentage or assign temperature thresholds. Existing thermal
|
||||
alerts use the kernel's own hot/critical trip points.
|
||||
|
||||
A connected charger does not necessarily mean charging: the Frame can still
|
||||
drain under load or sit idle near full charge. A 100% capacity reading alone
|
||||
does not prove the driver reports Full. Missing status is explicitly unknown;
|
||||
near-zero measured battery power with a charger is shown as not charging.
|
||||
Failed, stale or disconnected status samples clear Control Center readings.
|
||||
|
||||
**No charge controls are provided.** Frame's SteamOS charge-limit backend is
|
||||
unsupported; see [charge-limit research](charge-limit.md). Monitoring never
|
||||
writes to power supplies, pauses charging, changes current or sets a limit.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_battery.py` uses temporary sysfs-like files for conversion,
|
||||
malformed/missing values, permission failures, source selection and partial
|
||||
sensor support. `tests/control_center.cjs` exercises charging states, optional
|
||||
metrics and failed/stale/disconnected sample handling. These tests do not
|
||||
connect to a headset. On 2026-10-24, the PR's uninstalled parser was also
|
||||
piped over SSH to a real Frame running SteamOS 0.5.6 (build
|
||||
`20261015.6141207`). Its read-only sysfs result selected
|
||||
`max1720x_bat_7-36` and reported 99% charge, Not charging, 24.9 °C,
|
||||
Good driver health, 14 cycles, 2,920 mAh full and 2,700 mAh design capacity
|
||||
(108.1% fuel-gauge estimate). The Control Center UI was not run against this
|
||||
headset; Windows and macOS were not tested. Optional fields are not claimed to
|
||||
be available on every firmware build.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Battery monitoring now selects the Frame fuel gauge predictably, handles
|
||||
missing or invalid readings, and shows optional cycle count, full/design
|
||||
capacity and estimated capacity health in Control Center. Unknown charging
|
||||
status stays explicit. Monitoring is read-only; charge limits remain
|
||||
unsupported on Frame. The read-only parser was checked on a real Frame; the UI was not.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Battery charge limit (80%): research
|
||||
|
||||
**Question:** can Frame Control set an 80% battery charge limit on the Frame
|
||||
over SSH or adb, safely?
|
||||
|
||||
**Verdict: don't build it.** Valve's supported mechanism (the one Steam's own
|
||||
charge-limit setting uses on the Deck) is not configured for the Frame. The
|
||||
only control the Frame has is a vendor charger attribute that Valve's own
|
||||
charging daemon also manages. Research as of 2026-10-23, confirmed with the
|
||||
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
|
||||
|
||||
Rechecked read-only on build `20261022.6153909` on 2026-10-25: the
|
||||
`BatteryChargeLimit1` interface still returned `UnknownInterface`, no
|
||||
`charge_control_*` sysfs attribute existed, and the vendor charging switches
|
||||
were root-owned mode `644`. See [Round 3 headset evidence](evidence/round3-headset-2026-10-25.md).
|
||||
|
||||
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
|
||||
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
|
||||
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
|
||||
- Power supplies: `max1720x_bat_7-36` (Battery, driver `max1720x`),
|
||||
`pm8550b-charger` (driver `qcom,pm8550b-charger`) and the USB-C `tcpm-source-psy-…`.
|
||||
No `charge_control_end_threshold` anywhere; `pm8550b-charger` has
|
||||
`charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`).
|
||||
- `deckard-charger.service` (Deckard Battery Charger service) is enabled and running.
|
||||
|
||||
For safe read-only readings in Control Center, see [battery and health
|
||||
monitoring](battery-health.md). It does not provide charging controls.
|
||||
|
||||
## How Steam does it on other devices
|
||||
|
||||
- Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in
|
||||
SteamOS 3.7.8 (beta client 2025-05-08)
|
||||
([GamingOnLinux](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)).
|
||||
- Steam calls SteamOS Manager on the session bus:
|
||||
`com.steampowered.SteamOSManager1.BatteryChargeLimit1`, with properties
|
||||
`MaxChargeLevel` (`i`, read/write; `-1` resets to the default) and
|
||||
`SuggestedMinimumLimit` (`i`, read-only). The CLI is
|
||||
`steamosctl get-max-charge-level` / `set-max-charge-level` /
|
||||
`suggested-minimum-charge-limit`. Source: `data/interfaces/com.steampowered.SteamOSManager1.xml`
|
||||
and `steamos-manager/src/bin/steamosctl.rs` in
|
||||
[holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager),
|
||||
read at commit `83981ab` (2026-10-21).
|
||||
- The root daemon writes the value to sysfs only if the device's TOML in
|
||||
`data/devices/` has a `[battery_charge_limit]` section. Two methods exist
|
||||
(`steamos-manager/src/power.rs`, `BatteryChargeLimitMethod`):
|
||||
- `hwmon_attribute`: the Deck writes `max_battery_charge_level` on
|
||||
`steamdeck_hwmon` (`data/devices/steam-deck.toml`).
|
||||
- `acpi_sb`: ROG Ally, MSI Claw and others write the standard
|
||||
`charge_control_end_threshold` on the ACPI battery
|
||||
(`/sys/bus/{platform,acpi}/drivers/…battery…/PNP0C0A:00/…/power_supply/*/`).
|
||||
On ARM there's no ACPI, so this path can't match there either.
|
||||
- With no `[battery_charge_limit]` section, the interface fails with
|
||||
`UnknownInterface` (Steam can still show its toggle on unsupported hardware:
|
||||
[steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044)).
|
||||
|
||||
## What the Frame has
|
||||
|
||||
- **Upstream `data/devices/steam-frame.toml`** (matched by
|
||||
`dt.compatible = "qcom,sm8650"`, variant `Deckard`) configures GPU clocks
|
||||
and performance profiles only. It has **no `[battery_charge_limit]`**, so
|
||||
SteamOS Manager on the Frame has no charge-limit backend. Its git history
|
||||
(2026-09-10 to 2026-10-12) never added one.
|
||||
- **Battery driver:** the Frame's battery is
|
||||
`/sys/class/power_supply/max1720x_bat_7-36`, and the charger is a
|
||||
`tcpm-source-psy-…` USB-PD supply ([how-the-frame-works.md](how-the-frame-works.md)).
|
||||
Both were verified 2026-09-25, and `ui/frame_status.py` `battery()` and
|
||||
`power_source()` read them. The Maxim MAX1720x is a **fuel gauge** and
|
||||
can't control charging. Mainline `drivers/power/supply/max1720x_battery.c`
|
||||
exposes only read-only properties (capacity, voltage, current, temp,
|
||||
charge_full…), with no `charge_control_end_threshold`. Valve's build is
|
||||
patched: it reports `time_to_full_now`, which mainline doesn't have. Even
|
||||
so, a charge stop would have to come from the separate charger IC, not this
|
||||
node.
|
||||
- **Not the qcom_battmgr path.** Some other SM8650/SM8750 handhelds get
|
||||
`charge_control_end_threshold` from Qualcomm's `qcom_battmgr`
|
||||
(pmic_glink) driver, clamped at 55% or above. A third-party distro enabled
|
||||
Steam's limit on those by patching steamos-manager
|
||||
([armada-os/armada#758](https://github.com/armada-os/armada/pull/758)). The
|
||||
Frame's battery isn't exposed by `qcom_battmgr`, so that doesn't carry over.
|
||||
(Unverified: whether a `qcom_battmgr` node also exists on the Frame. The
|
||||
read-only checks below answer this.)
|
||||
- **What third parties found:**
|
||||
[y0usaf/frame-charge](https://github.com/y0usaf/frame-charge) reports that
|
||||
the Frame has no `charge_control_end_threshold` and falls back to a vendor
|
||||
`charging_pause` attribute. It also reports that `constant_charge_current`
|
||||
changes on its own, which it attributes to Valve's `deckard_charge_daemon`
|
||||
(`deckard-charger.service`). By its own README, that tool has only run its
|
||||
read-only `status` on a Frame and has **not** been tested against real
|
||||
charging. Two questions are still open: whether pausing stops the current,
|
||||
and whether Valve's daemon undoes it.
|
||||
|
||||
## Why not build it now
|
||||
|
||||
- **No supported endpoint.** The only Valve-supported interface,
|
||||
`BatteryChargeLimit1`, isn't backed on the Frame. Writing sysfs ourselves
|
||||
would bypass Steam and SteamOS Manager, and it needs root (`sudo -S` with
|
||||
the user's password).
|
||||
- **A rival owner.** Valve's charge daemon already adjusts charging
|
||||
parameters. Toggling `charging_pause` underneath it could be undone without
|
||||
warning, fight it in a loop, or leave charging paused.
|
||||
- **Stuck at a low charge.** If the app or the Frame dies while charging is
|
||||
paused, the Frame could sit at a low charge on the charger. Whether a
|
||||
reboot clears `charging_pause` is unknown.
|
||||
- **The UI would mislead.** Steam's setting (if it shows at all) wouldn't
|
||||
reflect our limit.
|
||||
- **adb doesn't help.** Wi-Fi adb (`frame:5555`) reaches the Lepton Android
|
||||
container only. USB-C adb reaches the Linux side as `steamos` (non-root, the
|
||||
same as SSH, [ssh.md](ssh.md)). Neither adds a charging-control route.
|
||||
Android's `dumpsys battery` in Lepton is a container view, not the hardware.
|
||||
|
||||
**Revisit when** a SteamOS update adds `[battery_charge_limit]` to
|
||||
`steam-frame.toml` (or `steamosctl get-max-charge-level` stops returning
|
||||
`UnknownInterface` on the Frame). Then the feature is just a SteamOS Manager
|
||||
call:
|
||||
|
||||
- Read: `steamosctl get-max-charge-level`
|
||||
- Set: `steamosctl set-max-charge-level 80`
|
||||
- Undo: `steamosctl set-max-charge-level -- -1`
|
||||
|
||||
That runs as `steamos` with no sudo. It's the same value Steam's own
|
||||
Settings → Power shows, so both stay in sync.
|
||||
|
||||
## Headset checks (read-only first)
|
||||
|
||||
Run these over SSH as `steamos`. Steps 1–4 only read.
|
||||
|
||||
```sh
|
||||
# 1. Does SteamOS Manager offer the interface on this build?
|
||||
steamosctl get-max-charge-level; steamosctl suggested-minimum-charge-limit
|
||||
busctl --user introspect com.steampowered.SteamOSManager1 \
|
||||
/com/steampowered/SteamOSManager1 | grep -i -A3 ChargeLimit
|
||||
grep -n battery_charge_limit /usr/share/steamos-manager/devices/*frame* 2>/dev/null
|
||||
|
||||
# 2. Which power supplies exist, and which drivers back them?
|
||||
for d in /sys/class/power_supply/*; do
|
||||
echo "== $d ($(cat $d/type)) driver=$(basename "$(readlink -f $d/device/driver)" 2>/dev/null)"
|
||||
ls $d
|
||||
done
|
||||
|
||||
# 3. Any charge-control attributes, with their values and permissions?
|
||||
ls -l /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
|
||||
grep -H . /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
|
||||
|
||||
# 4. What is Valve's charger daemon, and what does it touch?
|
||||
systemctl status deckard-charger.service --no-pager
|
||||
systemctl cat deckard-charger.service
|
||||
journalctl -b -u deckard-charger.service --no-pager | tail -50
|
||||
```
|
||||
|
||||
If step 1 succeeds on some build, test `steamosctl set-max-charge-level 80`
|
||||
on a charger at a charge above 80%. Watch `current_now` on the
|
||||
`max1720x_bat_*` node fall to about 0, then `steamosctl set-max-charge-level -- -1` and
|
||||
confirm charging resumes. Do this on a supervised Frame only. If only step 3
|
||||
finds a vendor attribute, **stop**: don't write it, and leave the question
|
||||
with Valve.
|
||||
|
||||
## Sources
|
||||
|
||||
- steamos-manager source, commit `83981ab` (2026-10-21):
|
||||
`data/devices/steam-frame.toml`, `data/devices/steam-deck.toml`,
|
||||
`data/interfaces/com.steampowered.SteamOSManager1.xml`,
|
||||
`steamos-manager/src/power.rs`, `steamos-manager/src/manager/user.rs`.
|
||||
[gitlab.steamos.cloud/holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager)
|
||||
- Mainline [`max1720x_battery.c`](https://github.com/torvalds/linux/blob/master/drivers/power/supply/max1720x_battery.c)
|
||||
- [ValveSoftware/steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044):
|
||||
the Steam toggle shows on unsupported hardware
|
||||
- [armada-os/armada#758](https://github.com/armada-os/armada/pull/758):
|
||||
`qcom_battmgr` threshold on other SM8650/SM8750 devices
|
||||
- [y0usaf/frame-charge](https://github.com/y0usaf/frame-charge): third-party,
|
||||
untested on real charging
|
||||
- [GamingOnLinux: SteamOS 3.7.7 beta charge limit](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)
|
||||
- Repo: [how-the-frame-works.md](how-the-frame-works.md) (battery and
|
||||
charger nodes), [ssh.md](ssh.md) (adb scope), `ui/frame_status.py`
|
||||
@@ -0,0 +1,37 @@
|
||||
# Connection and streaming lag FAQ
|
||||
|
||||
## Does Frame Control lower my streaming lag?
|
||||
|
||||
No. Frame Control uses SSH to manage the Frame. SteamVR or Steam Link sends the
|
||||
game stream. Frame Control does not carry the game stream. It cannot lower your
|
||||
game-stream ping or fix a stream that stops on the PC. A 40–75 ms ping in a VR
|
||||
game is not a Frame Control measurement.
|
||||
|
||||
You do **not** need the Steam Wireless Adapter to use Frame Control. The app can
|
||||
connect over Wi-Fi, the Wireless Adapter, or USB if the computer has an SSH
|
||||
route to the Frame. Use the route that lets the app connect. To improve game
|
||||
streaming, check the SteamVR or Steam Link connection on the PC and the Frame.
|
||||
The [wired connection doctor](wired-connection.md) checks only Frame Control's
|
||||
SSH route. A successful doctor check does not prove that the game stream uses
|
||||
the cable or has low lag.
|
||||
|
||||
## Can I use a USB-A-to-C cable?
|
||||
|
||||
A Mac reached the Frame over a USB-C connection on the
|
||||
`10.86.200.232/29` USB network. That test used a USB-C host port. We have
|
||||
**not tested a USB-A-to-C cable** with the Frame. A data-capable USB-A-to-C
|
||||
cable might show a USB network interface on your PC, but this is unknown on
|
||||
the tested Frame OS and PC hosts. Check whether your PC shows that interface
|
||||
and whether Frame Control's wired doctor can reach the Frame at
|
||||
`10.86.200.233`. A charge-only cable cannot carry USB data. You can use Wi-Fi
|
||||
for Frame Control if the USB route does not work.
|
||||
|
||||
## 日本語: 5 ステップで接続
|
||||
|
||||
1. [公式リリース](https://github.com/saphid/frame-control/releases/latest)から PC の OS 用ファイルをダウンロードします。Windows は `.exe`、macOS は `.dmg`、Linux は `.AppImage` または `.deb` です。同じリリースの `SHA256SUMS` でファイルを確認してください。
|
||||
2. PC に Frame Control をインストールして起動します。Windows はスタートメニュー、macOS はアプリケーション、Linux はアプリ一覧から開きます。AppImage の場合は実行権限を付けて開きます。
|
||||
3. Frame の Steam 設定 → システムで開発者モードを有効にし、開発者設定でユーザーパスワードを設定します。Frame と PC を同じ信頼できるネットワークに接続し、Frame を起こしておきます。
|
||||
4. PC の Frame Control で **Set Up Connection…** を選びます。Frame が見つからない場合は、Frame の設定で IP アドレスを確認して入力します。macOS では **Devices & connection → + Add a headset…** からアドレスを設定できます。
|
||||
5. Frame に **Pair new host** が出たら、自分が開始した要求だけを承認します。ペアリングに失敗した場合は、PC のターミナルで開発者モードのパスワードを入力します。接続後、Frame Control に戻って状態を確認します。
|
||||
|
||||
Frame Control は管理用の SSH 接続を使います。ゲーム映像は SteamVR / Steam Link が送ります。Frame Control を入れてもゲームの ping は下がりません。無線アダプターは必須ではありません。USB-A-to-C ケーブルで Frame の USB ネットワークが使えるかは、まだ確認できていません。
|
||||
@@ -0,0 +1,30 @@
|
||||
# What we have tested
|
||||
|
||||
Release baseline: Frame Control **v0.4.41**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): official v0.4.39 x64 DMG and ZIP hashes/architectures verified; clean DMG app installed and launched. Native UI, preferences across restart, update discovery of 0.4.40, offline OBS Start/Stop and 116-entry Decky store checked. Actual Open at Login menu toggled on, persisted across Quit/relaunch, then restored off. A 54-byte relay upload/readback/delete passed under coordinator/headset locks | No | Ordinary browser quarantine, right-click Open and Open Anyway remain unverified; the synthetic quarantine probe was inconclusive. Actual login startup untested. Direct frame.local had no route and app SSH trust failed; relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Earlier shutdown race fix #284 shipped in v0.4.40, whose Intel installer was not tested here | 26 Oct, official v0.4.39 tag `fd0b0a9`: [repeat Intel acceptance evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); [earlier release test](evidence/mac-v0439-2026-10-26.md) |
|
||||
| Core title smoke test | Linux lxso2 ran the exact v0.4.41 tagged source against the real Frame; 13/13 reserved title steps passed and cleanup completed | No | The headset was unworn; visible rendering and controller input were not checked. Optional Steam Linux Runtime 4 was absent, so the x86_64 test executable did not actually launch. Other feature paths retain their separate limits below | 26 Oct, exact v0.4.41 tag `b5d50a8`, Frame OS 0.5.6 build `20261022.6153909`: [tagged release evidence](evidence/v0441-release-2026-10-26.md); [earlier candidate smoke](evidence/v0441-candidate-frame-2026-10-26.md) |
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
| OBS headset source on Linux | Real Frame to OBS 30.0.2 on a Linux host: 187.367 s, 720p30 H.264/AAC stereo, 5,621 frames, no encoder skips; room passthrough appeared in the recording | No | Synthetic tone was measured, not real game audio or audible listening. Default audio led by about 0.43 s in this setup; a measured +480 ms OBS offset removed residual skew at test resolution. Source-image latency was 1.431 s in one trial; other hosts and long sessions are unverified | 25 Oct, v0.4.30 package plus PR #219 runtime: [validation](evidence/obs-validation-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | Windows 11 VM helper fixture passed, but a native bridge to the real Frame sent no packets | In-app OBS controls remain hidden; native Windows OBS and firewall path are unverified | 25 Oct, PR #219: [validation](evidence/obs-validation-2026-10-25.md), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | Physical Intel Air: official v0.4.39 native Start/Stop returned to idle offline | No | Real headset stream, OBS receiver and audio/video remain unverified; the app rejected its stale SSH identity | 26 Oct, v0.4.39: [Mac evidence](evidence/mac-v0439-2026-10-26.md), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
|
||||
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
|
||||
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
|
||||
| Linux Wireless Adapter doctor | No Steam Wireless Adapter hardware test | No | Read-only Linux host checks and mocked adapter states; no adapter-specific hardware observation | 24 Oct, v0.4.20: [PR #165](https://github.com/saphid/frame-control/pull/165), [doctor guide](linux-wireless-adapter-doctor.md) |
|
||||
| Switch PC windows | No physical Windows PC test | No real VM-to-Frame Alt+Tab check recorded | Explicit chord is covered by tests; actual window focus, keyboard layouts and controller interaction are unverified | 25 Oct, v0.4.24: [PR #181](https://github.com/saphid/frame-control/pull/181), [PC guide](pc-in-headset.md) |
|
||||
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
|
||||
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
|
||||
| Android game backup import | F-Droid Basic APK with synthetic main and patch OBBs installed, transferred and read back on a real Frame | No | v0.4.30 restricted split-APK backup route has generated-fixture and existing merger tests only; no real split game or RE4 gameplay test | 20 Oct OBB headset check; 25 Oct, v0.4.30 split fixtures: [OBB device evidence](evidence/android-import/device.json), [split PR #206](https://github.com/saphid/frame-control/pull/206), [guide](android-backups.md) |
|
||||
| Passthrough in recordings | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; official v0.4.39 on the physical Intel Air toggled the actual native menu on, retained its checkmark after Quit/relaunch and restored it off. Linux autostart file previously toggled on a physical Linux host with an isolated config directory | No | A full OS login session and physical Windows host login remain untested | 26 Oct, v0.4.39: [native Intel toggle evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); Linux v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 26 Oct, v0.4.29–0.4.40: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [post-#292 unworn sample exercise](evidence/v0440-release-2026-10-26.md#post-tag-292-panel-regression), [earlier standby-gated exercise](evidence/framey-vr-standby-gate-2026-10-26.md), [worn checklist](framey-vr-check.md) |
|
||||
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. v0.4.41 classifies the 116 exact-version plugins as 3 works, 48 partial, 54 broken and 11 not applicable. Notebook works for its default flow; Volume Mixer and Mixer work for tested per-app silent-stream volume control only. TabMaster is partial. Deck Shelves moved to partial after its exact chunked frontend rendered settings in local Chromium; its backend and Steam Home shelves remain unverified. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Volume Mixer and Mixer controlled a named silent stream through real ARM64 Decky with independent Pulse readbacks; Volume Boost default-sink reads matched, but real writes were denied. Audible output, Bluetooth playback, internal-node controls and worn VR remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.41: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [ARM64 audio evidence](evidence/framey-audio-arm64-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.md) |
|
||||
| Experimental Framey VR plugin panels and store | Unattended Frame OS 0.5.6 runs showed the Framey sample panel in stereo compositor and mirror captures. The latest standby-gated exercise restored the original power settings and released its shared lock. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; v0.4.40 recorded 101 local Chromium initializers ready and 15 failed, up from 98/18 in v0.4.39. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.40: [post-#294 unworn sample and scroll evidence](evidence/framey-scroll-probe-2026-10-26.md#post-294-exact-main-sample-check), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [post-release sample exercise](evidence/v0439-release-2026-10-26.md#post-release-framey-library-change-282), [worn checklist](framey-vr-check.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Decky Loader on Steam Frame
|
||||
|
||||
For the modding-path survey, licence review and update limits, see
|
||||
[plugin and modding research](plugins-research.md).
|
||||
|
||||
**Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS
|
||||
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
|
||||
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
|
||||
verified. Frame Control therefore labels its user-service manager experimental.
|
||||
ProtonDB Badges loaded in the backend and Gamepad UI plugin registry, but its
|
||||
VR user interface has not been seen or used.
|
||||
|
||||
## Official store plugin audit, 2026-10-25
|
||||
|
||||
On a real Frame with Frame OS 0.5.6 build `20261022.6153909`, SteamVR 2.18.2
|
||||
reported `activity_level` 3 (standby). The battery was 99% before and after.
|
||||
The test downloaded the official store's current archives, checked each SHA-256
|
||||
against store metadata, and inspected their manifests and binaries. For seven
|
||||
non-root candidates, the removable user-service loader started each plugin in
|
||||
turn. It stopped between plugins. Logs showed `Loaded <name>` for all seven.
|
||||
The service and test folders were removed after the run; the owned shared lock
|
||||
was released. **A load message is not proof that a plugin's page, settings, or
|
||||
action works in VR.** No plugin was tried by a wearer.
|
||||
|
||||
| Official store plugin (tested version) | Result | Evidence and limit |
|
||||
| --- | --- | --- |
|
||||
| ProtonDB Badges 1.3.4 | Partial | ARM64 backend loaded. Earlier 1.1.0 also registered in the shared Gamepad UI; badges in the VR shell were not seen. |
|
||||
| CSS Loader 2.1.3 | Partial | Backend loaded; one `SyntaxWarning`. Theme controls and CSS application in VR were not checked. |
|
||||
| SteamGridDB 1.8.1 | Partial | Backend loaded. Artwork selection and its UI were not checked. |
|
||||
| Animation Changer 1.3.2 | Partial | Backend loaded. Boot animation replacement was not attempted. |
|
||||
| Storage Cleaner 1.4.0 | Partial | Backend loaded. No files were cleaned; no UI check. |
|
||||
| HLTB for Deck 2.0.12 | Partial | Passive plugin loaded. Game page content in VR was not checked. |
|
||||
| Bluetooth 3.1.1 | Partial | Backend logged `Bluetooth plugin loaded`. Pairing and VR controls were not checked. |
|
||||
| PowerTools 2.0.3 | Not applicable | Manifest asks for root; archive includes x86_64 Ryzen binaries and targets Steam Deck hardware. The Frame user service blocks it. |
|
||||
| Audio Loader 1.6.1 | Not applicable | Manifest asks for `_root`; the Frame user service blocks it. |
|
||||
| Wine Cellar 0.2.1 | Broken on ARM64 | Archive includes an x86_64 backend binary. The store installer blocks it before execution. |
|
||||
| MagicPods 2.0.21 | Broken on ARM64 | Archive includes an x86_64 `magicpodscore` binary. The store installer blocks it before execution. |
|
||||
| Decky Recorder | Not in current official store | No matching plugin appeared in the official store list on this date; no archive was installed. Frame Control's own headset panel has a separate recorder. |
|
||||
|
||||
The table reflects the tested versions only. Other releases can change their
|
||||
files or behavior. The official store's digest checks the archive in transit;
|
||||
it does not certify plugin code. Plugins run with the headset user's access.
|
||||
For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-author.md).
|
||||
|
||||
### Which of these plugins can open a Framey panel?
|
||||
|
||||
The separate Framey panel accepts a Frame Control store install with a supported
|
||||
API v1/v2 frontend or a legacy Decky IIFE frontend in `dist/index.js`.
|
||||
A backend load does not bypass this frontend check. We downloaded the 11 versions above
|
||||
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
|
||||
catalogue. All 11 had a frontend bundle, but their manifests differed:
|
||||
|
||||
| Manifest in the checked archive | Plugins | Framey panel result |
|
||||
| --- | --- | --- |
|
||||
| API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. |
|
||||
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. |
|
||||
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | CSS Loader's legacy IIFE passes the Framey panel packaging check; its theme UI remains untested. Animation Changer's panel remains untested. Audio Loader and PowerTools request root and are blocked. |
|
||||
|
||||
The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json)
|
||||
has each store ID, exact version, SHA-256, manifest field, and bundle check.
|
||||
The archive audit itself is static PC inspection. Later local host and fake
|
||||
Frame tests cover the Storage Cleaner, HLTB, CSS Loader and SteamGridDB cases noted above; none shows
|
||||
that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above
|
||||
remain the only device evidence for these versions. A new store version needs a
|
||||
new check because its manifest and binaries can change.
|
||||
|
||||
Pinned CSS Loader 2.1.3 and SteamGridDB 1.8.1 archives now pass the fake Frame
|
||||
managed install/remove flow. CSS Loader's `plugin.json` has no `api_version` and
|
||||
its older IIFE bundle passes the Framey packaging check added in
|
||||
[#261](https://github.com/saphid/frame-control/pull/261). The fixture does not
|
||||
render its theme controls. SteamGridDB declares API v1 and its bundle passes Framey's packaging
|
||||
check, but it looks up Steam library modules while loading and patches game
|
||||
context menus. Those modules and menus do not exist in Framey's isolated
|
||||
iframe, so its artwork picker cannot be called a usable VR panel. Framey does
|
||||
not apply its Steam library changes. These checks did not run CSS themes or
|
||||
modify game artwork on the real Frame.
|
||||
|
||||
The new store transaction was also exercised on the same real Frame:
|
||||
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
|
||||
store update → store remove → managed loader remove**. Status reported the
|
||||
managed store ID and version while the plugin was installed. The service
|
||||
remained active after each store change. The battery was 99% before and after.
|
||||
The test removed its plugin folder, loader, and owned lock. This validates the
|
||||
store file and service path over SSH, not a visible VR plugin page.
|
||||
|
||||
Frame Control's desktop app and opt-in headset panel now show official store
|
||||
entries. Install, update, and remove need an explicit confirmation. They only
|
||||
change Frame Control owned plugin folders under
|
||||
`~/.local/share/frame-control/decky/plugins`. The installer checks the store
|
||||
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
|
||||
back an update if the service restart fails. An update is also refused if its
|
||||
new plugin name is already used by another installed plugin, because Decky
|
||||
routes backend calls by name. This collision check has local tests only.
|
||||
For a managed plugin with a valid recorded SHA-256, Update and Remove recheck
|
||||
that installed receipt under the headset transaction lock. If another store
|
||||
action changed it after the list was read, refresh the installed list and
|
||||
review the action again. This race check has local transaction tests; older
|
||||
receipts without a valid hash still use the existing ownership check.
|
||||
The current VR shell does not provide Decky's Steam Deck Quick Access Menu.
|
||||
Frame Control's headset panel
|
||||
shows installed plugins and store actions. Its separate Framey iframe hosts
|
||||
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
|
||||
with settings, local routes, and panel toasts where supported.
|
||||
Steam route patches and system notifications remain unavailable. The panel
|
||||
still needs a worn-headset visual and controller check. See the
|
||||
[current compatibility table](frame-plugin-author.md#framey-decky-api-compatibility).
|
||||
|
||||
## What was checked on the real headset
|
||||
|
||||
The headset was SteamOS `0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`,
|
||||
`aarch64` (SteamOS 3.9.x family). SSH reached it through the existing `frame`
|
||||
alias. `/` was mounted read-write as Btrfs; no root filesystem change was
|
||||
needed. `systemctl --user` was running. The Steam client launched with
|
||||
`-cef-enable-debugging`, `-vrgamepadui`, and `-steamdeck`; its web helper served
|
||||
CEF remote debugging on `127.0.0.1:8080`. The usual
|
||||
`.cef-enable-remote-debugging` marker was absent, yet debugging was active.
|
||||
|
||||
CEF listed a `SharedJSContext` target at `steamloopback.host/routes/library/home`
|
||||
and a separate `SteamVR` target for the VR overlay. The Decky 3.3.0 ARM64
|
||||
binary started from `/tmp` as the normal user: port `127.0.0.1:1337` answered
|
||||
HTTP 200, the log reported `Loading Decky frontend!`, and CEF showed
|
||||
`window.deckyHasLoaded === true` and one registered Decky tab in
|
||||
`SharedJSContext`. The `SteamVR` target had neither that global nor a Decky tab;
|
||||
its current body text did not contain Decky. The Steam web helper had
|
||||
`--disable-quick-menu`. This is evidence of backend and shared-context
|
||||
injection, not proof of an accessible Decky plugin menu inside VR. A QAM
|
||||
interaction in the headset and a plugin UI test remain necessary.
|
||||
|
||||
Frame Control's per-user manager was then exercised on the real Frame:
|
||||
**install → status → update → status → remove → status**. The service became
|
||||
active, the binary matched Decky's published SHA-256, and removal left no
|
||||
service, binary or listening port 1337. Decky's already injected JavaScript
|
||||
remained in Steam's running web helper until that helper was restarted. After a
|
||||
web helper restart, both `deckyHasLoaded` and the tab hook were absent. All
|
||||
temporary trial files and the test's empty managed data folder were removed.
|
||||
|
||||
## ProtonDB Badges compatibility test
|
||||
|
||||
The [ProtonDB Badges v1.1.0 release](https://github.com/OMGDuke/protondb-decky/releases/tag/v1.1.0)
|
||||
was tested on the same Frame OS build. It is a popular, passive Decky plugin
|
||||
(133 GitHub stars at test time) that adds badges to game pages. The published
|
||||
archive SHA-256 was
|
||||
`b4c72ff0ffe60b1a14abafe75a62f644cbb6689f398a753a1905c281151d9173`.
|
||||
Its six archive entries had no links or path traversal, and its manifest declared
|
||||
`flags: []` and `api_version: 2`. The release contains a frontend bundle and
|
||||
no backend `main.py`; its source backend only manages its own settings.
|
||||
|
||||
The test copied the archive into Frame Control's managed Decky plugin folder,
|
||||
then installed the official Decky 3.3.0 ARM64 binary as a user service. Decky's
|
||||
log said `Plugin ProtonDB Badges is passive` and `Loaded ProtonDB Badges`.
|
||||
`DeckyPluginLoader.plugins` in Steam's `SharedJSContext` contained
|
||||
`ProtonDB Badges`; `deckyHasLoaded` was true there. The separate `SteamVR`
|
||||
CEF target had no Decky globals or plugin text, and SteamVR listed no Decky
|
||||
overlay. The headset was in standby, so no in-headset plugin page or badge
|
||||
interaction was verified. The backend's unauthenticated root URL returned
|
||||
HTTP 403, as expected.
|
||||
|
||||
The test removed the managed Decky service and binary, the test plugin folder,
|
||||
and its shared-device lock. The service was no longer running, the plugin list
|
||||
was empty, the binary and unit were gone, and port 1337 was closed. Decky's
|
||||
already injected frontend may remain in the running Steam web helper until
|
||||
Steam normally restarts; the test did not restart Steam or SteamVR.
|
||||
|
||||
## Why the official Decky installer is not used
|
||||
|
||||
The current [Decky installer](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
|
||||
selects `PluginLoader-arm64` on `aarch64`, creates `~/homebrew/plugins`, writes
|
||||
the CEF marker, installs `/etc/systemd/system/plugin_loader.service`, and uses
|
||||
`sudo systemctl` to enable a **root** service. Its
|
||||
[uninstaller](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/uninstall.sh)
|
||||
also uses sudo. These scripts do not call `steamos-readonly`, but they do write
|
||||
under `/etc`; their root service has broad control over the headset. The
|
||||
current [Decky loader](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/backend/decky_loader/injector.py)
|
||||
targets Steam's shared Gamepad UI CEF page, injects a frontend served on port
|
||||
1337, and patches the standard Quick Access Menu. On Frame, Steam supplies
|
||||
the CEF target, while visible VR shell integration is unresolved.
|
||||
|
||||
Frame Control downloads only Decky's official latest ARM64 release, requires
|
||||
its published SHA-256 digest, and checks the ELF architecture. It installs a
|
||||
**user** service named `frame-control-decky.service` in
|
||||
`~/.config/systemd/user`, with the binary and plugin folder under
|
||||
`~/.local/share/frame-control/decky`. It never asks for a sudo password, edits
|
||||
`/etc`, or changes CEF flags. Decky warns that running without root is not
|
||||
officially supported. Plugins that require root, assume `~/homebrew`, or rely
|
||||
on Steam Deck hardware may fail even if the loader runs. Frame Control lists
|
||||
plugins already present in its managed folder and the upstream `~/homebrew`
|
||||
folder. The opt-in store manager installs compatible archives only into its
|
||||
own folder. It cannot make Steam Deck QAM plugin pages visible in Frame VR.
|
||||
The experimental [Framey panel host](framey-loader.md) can render compatible
|
||||
plugin settings inside Frame Control's separate SteamVR dashboard panel.
|
||||
The real headset still needs a worn-user visibility and controller check.
|
||||
|
||||
The latest tested release was
|
||||
[Decky 3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0),
|
||||
`PluginLoader-arm64` SHA-256
|
||||
`8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6`.
|
||||
This digest comes from that release's GitHub API asset metadata; it detects a
|
||||
download mismatch, but is not an independent signature.
|
||||
|
||||
## Opt in and remove
|
||||
|
||||
In Frame Control, open **Tools → Decky Loader on Frame**, read the experimental
|
||||
notice, check the acknowledgement box, and choose **Install**. **Update**
|
||||
downloads and verifies the latest ARM64 release again. **Remove managed
|
||||
Decky** disables and removes only Frame Control's user service and binary.
|
||||
It preserves plugin files and settings so unrelated user data is not deleted.
|
||||
Open **Official plugin store** to review a plugin, then confirm each install,
|
||||
update, or remove. Root and x86-only plugins are blocked. The manager only
|
||||
removes plugin folders that it installed; it leaves other Decky folders alone.
|
||||
The desktop app and headset panel share one lock for managed Decky changes.
|
||||
If another change is in progress, wait for it to finish and try again.
|
||||
Restart Steam or the headset to clear the frontend already injected into its
|
||||
current web helper process. No action wipes the headset.
|
||||
|
||||
Manual removal if Frame Control is unavailable:
|
||||
|
||||
```sh
|
||||
ssh frame 'systemctl --user disable --now frame-control-decky.service; rm -f ~/.config/systemd/user/frame-control-decky.service ~/.local/share/frame-control/decky/PluginLoader ~/.local/share/frame-control/decky/.frame-control/receipt.json; systemctl --user daemon-reload; systemctl --user reset-failed frame-control-decky.service'
|
||||
```
|
||||
|
||||
That command intentionally keeps `~/.local/share/frame-control/decky/plugins`
|
||||
and `settings`. Check those folders before deleting them yourself. An upstream
|
||||
Decky installation is separate; use its own installer to remove it. Frame
|
||||
Control refuses to install alongside an upstream system service.
|
||||
|
||||
## Next compatibility test
|
||||
|
||||
In the headset, open Steam's VR dashboard and look for Decky's plugin tab or
|
||||
Quick Access Menu after an opt-in install. If none appears, inspect whether
|
||||
the VR shell exposes a different menu component and whether Decky's frontend
|
||||
can target it without patching Steam globally. Confirm the plugin's visible
|
||||
UI, controller interaction, and persistence across a normal Steam restart.
|
||||
Until those checks pass,
|
||||
Frame Control should not describe Decky plugins as usable in VR yet. A worn
|
||||
headset check is needed for both the Decky menu and the Frame Control SteamVR
|
||||
panel; only then can their controller interaction and plugin presentation be
|
||||
confirmed.
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
# Headsets, addresses and the connection
|
||||
|
||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||
reached at more than one address: a LAN IP at home, another at the office, its
|
||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||
tab (key 5) lists them. The connection pill in the header says whether the one in
|
||||
use is connected and how (Tailscale, or the network's name); click it to add,
|
||||
edit or reorder that headset's addresses, or to see each step of connecting.
|
||||
|
||||
The code is in three modules, all stdlib-only Python on your computer:
|
||||
|
||||
| Module | What it does |
|
||||
|---|---|
|
||||
| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys |
|
||||
| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state |
|
||||
| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API |
|
||||
|
||||
## Headsets
|
||||
|
||||
Each headset keeps its own SSH alias, as Set Up Connection has always written
|
||||
it: the first is `frame`, the next `frame-2`, and so on. Terminal's
|
||||
`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`)
|
||||
work for each one.
|
||||
|
||||
- **Nothing to migrate by hand.** On first start, the app imports every
|
||||
`# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with
|
||||
the block's HostName as its first address. It also copies the host key your
|
||||
`known_hosts` already trusts for that address into the headset's own
|
||||
known_hosts file, `~/.ssh/frame-control-hosts/<id>`, so nobody is asked to trust it again.
|
||||
- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS,
|
||||
`ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias.
|
||||
When it writes its block, the app picks the headset up by itself. If Set Up
|
||||
Connection runs again and finds a headset somewhere new, that address is added
|
||||
at the top of its list.
|
||||
- **Use this headset** (or the switcher in the header, or the app's
|
||||
**Frame → Headset** menu) moves the whole app to another headset; every panel
|
||||
reloads from it. From that moment no command goes to the previous headset, even
|
||||
if the new one never answers. It waits while an install is running, since an
|
||||
install reads the SSH settings step by step.
|
||||
- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick
|
||||
the box; either way it isn't imported again unless Set Up Connection changes it.
|
||||
- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the
|
||||
app shows it as not set up and lets ssh's own config decide where it goes.
|
||||
|
||||
## Addresses
|
||||
|
||||
Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address
|
||||
and changeable), an optional label, the networks it has worked on, and when it
|
||||
last worked with its round-trip time.
|
||||
|
||||
When connecting, the app **tries all addresses at once** (TCP to the SSH port)
|
||||
and ranks them:
|
||||
|
||||
1. addresses that worked on the network this computer is on now;
|
||||
2. mDNS names;
|
||||
3. Tailscale addresses, if Tailscale is running here;
|
||||
4. addresses not tried on this network yet;
|
||||
5. addresses that only ever worked on other networks;
|
||||
6. Tailscale addresses while Tailscale is off.
|
||||
|
||||
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
|
||||
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||
still trying. If SSH to the winner fails in a way another address could fix
|
||||
(a different device answered there, or the link dropped), the next one that
|
||||
answered is tried. Every success records the network on that address, so next
|
||||
time on that network it's tried first.
|
||||
|
||||
**Test now** probes every address and tries SSH on each one that answers, without
|
||||
disturbing the connection in use: "SSH works", "answered as a different
|
||||
headset", "refused this computer's key", or why it didn't answer. **Find on
|
||||
Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale
|
||||
status --json`, including the Mac app's own CLI) with buttons to add their
|
||||
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||
services and checks `ALIAS.local` and `frame.local`.
|
||||
|
||||
**The pill's dialog** lists the same addresses, with what each one answered, and
|
||||
can add, edit, reorder and remove them without leaving the page you're on. When
|
||||
the headset reports a LAN IP on the same network as this computer and that IP
|
||||
isn't saved, it offers to add it. The offer puts the address first in the list,
|
||||
so on that network it wins over the Tailscale name; away from home the Tailscale
|
||||
name still leads. A new or edited address is tested straight away. While
|
||||
connected, **Reconnect** applies your changes now rather than at the next
|
||||
connection: it tries the addresses again, ranked as above, and the best-ranked
|
||||
one that answers promptly wins. It doesn't pick a particular address.
|
||||
|
||||
## Networks
|
||||
|
||||
A network is told apart by its default gateway: the router's IP address plus its
|
||||
hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh`
|
||||
(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and
|
||||
on macOS 14 and later, which hides the Wi-Fi name from apps without Location
|
||||
permission. Where the system does share the Wi-Fi name, it's shown, and you can
|
||||
name any network yourself ("Home Wi-Fi") on the Devices tab.
|
||||
|
||||
The app rereads the gateway every 5 seconds and Tailscale's state every
|
||||
30 seconds. Changing networks reconnects.
|
||||
|
||||
## The connection, stage by stage
|
||||
|
||||
When a connection fails, the header pill keeps its compact offline state and
|
||||
retry countdown; its tooltip and connection dialog contain the full reason.
|
||||
Devices shows a short reason in the headset row (with the full reason in its
|
||||
tooltip) and a wrapping recovery explanation in the selected headset's details.
|
||||
**Use this headset** also selects that headset's details.
|
||||
|
||||
The connector runs in the server (`frame_link.Link`) and moves through:
|
||||
|
||||
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
|
||||
2. **Finding the headset**: each address resolving, trying, answered in N ms,
|
||||
no answer, refused, or can't be found.
|
||||
3. **Opening SSH** to the address that answered.
|
||||
4. **Checking the headset's identity**: the host key must match the one pinned
|
||||
for this headset.
|
||||
5. **Logging in** as the headset's user.
|
||||
6. **Connected** via network N, address A, round trip T; or **failed** at a stage
|
||||
with the reason in plain words and a countdown to the next try (5, 10, 20,
|
||||
then every 30 seconds). Retry now skips the wait.
|
||||
|
||||
Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the
|
||||
connection is an SSH ControlMaster that every command shares; when it dies (the
|
||||
headset slept or left the network) the connector notices and starts again. On
|
||||
Windows, where OpenSSH can't share a connection, the same handshake runs once
|
||||
and each command then connects on its own; a command that can't reach the
|
||||
headset makes the connector start again. There ssh's stderr goes to a file that
|
||||
the connector reads as it grows, not a pipe: Windows' OpenSSH can block writing
|
||||
to a redirected pipe.
|
||||
|
||||
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
|
||||
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
|
||||
-o UserKnownHostsFile=~/.ssh/frame-control-hosts/<id> -o HashKnownHosts=no -o User=… -o Port=…`. The
|
||||
alias's block in `~/.ssh/config` is also updated to the last address that
|
||||
worked (and to the user and port you set), so Terminal's `ssh frame` and the
|
||||
scripts follow. Edits to `~/.ssh/config` take a lock file
|
||||
(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never
|
||||
write over a change someone else made since the app last read the file.
|
||||
|
||||
**Host keys are pinned per headset, not per address.** Your own `known_hosts`
|
||||
is keyed by address, so a different device answering at a remembered IP (a DHCP
|
||||
lease that moved) would look like a new host there. The app keeps one known_hosts
|
||||
file per headset instead, so saving or forgetting one headset's key never touches
|
||||
another's: a different device answering at one of its
|
||||
addresses is refused, and the pill says so. A headset's first connection trusts
|
||||
the key it shows, as Set Up Connection does: while a headset has never had an
|
||||
identity here, every ssh command gets `StrictHostKeyChecking=accept-new` (the
|
||||
first contact saves it, whichever command makes it), and from then on
|
||||
`StrictHostKeyChecking=yes`. `accept-new` never replaces a saved key, so it
|
||||
can't hide a change. The saved key's fingerprint is also recorded in
|
||||
`devices.json` (`identity`), along with a flag that never clears
|
||||
(`identity_established`), as soon as ssh saves the key: even if the login is then
|
||||
refused. A headset counts as having had an identity if the flag is set, a
|
||||
fingerprint is recorded, its pin file exists at all, or a connection to it ever
|
||||
worked (older files get the flag from that when loaded, so editing or removing
|
||||
the address that worked doesn't undo it); then
|
||||
a pin file that's deleted, empty, unreadable or not parseable **fails closed**
|
||||
(*Checking the headset's identity* fails, "missing or can't be read") rather than
|
||||
trusting whatever answers, and only **Check its key…** saves a key again. It
|
||||
says when the key it reads matches the recorded fingerprint.
|
||||
|
||||
**When the key changes** (SteamOS reinstalled or reset, a new headset at the
|
||||
same address, or a different device answering there), nothing is accepted by
|
||||
itself. The connection fails at *Checking the headset's identity*; the banner
|
||||
and the headset on the Devices tab say so, with **Check the new key…** (also
|
||||
**Check its key…** on the Devices tab at any time). That reads the key the
|
||||
headset shows now, and nothing more: ssh writes it to a throwaway known_hosts
|
||||
file during the key exchange, before any login, and is stopped as soon as it
|
||||
has. Every other kind of contact is ruled out on the command line, ahead of
|
||||
anything `~/.ssh/config` says: no authentication of any kind, no agent
|
||||
(`IdentityAgent=none`, `ForwardAgent=no`), no X11 or port forwarding, no local
|
||||
or remote command (`-N`), no terminal, and `-F none`. Through a ProxyJump host
|
||||
it builds that hop itself, as a ProxyCommand whose ssh forwards nothing and runs
|
||||
no local command (ProxyJump's own ssh would take `ForwardAgent` and
|
||||
`LocalCommand` from `~/.ssh/config`); it still logs in to the jump host as
|
||||
configured. Every word of that ProxyCommand is quoted for the shell that runs it
|
||||
(and `%` doubled for ssh's own tokens), the forward is `[%h]:%p` so an IPv6
|
||||
headset works, and an IPv6 jump host is passed bare with its port. Through a ProxyCommand from `~/.ssh/config`, or several jump hosts,
|
||||
it refuses, since those can't be made safe from here: add an address it can
|
||||
reach directly, or compare the key by hand. It shows the key's SHA256 fingerprint next to
|
||||
the saved one, with the command that prints it on the headset
|
||||
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`, in Konsole on its desktop).
|
||||
**This is my headset: trust its new key** then saves exactly that key, replacing
|
||||
the old one, and reconnects. The server keeps the key it showed for 10 minutes
|
||||
and saves it only if the page sends back the same fingerprint, and only while
|
||||
the headset's port, user and that address are unchanged; it never saves whatever
|
||||
answers next. The API's old `forget-identity`, which deleted the pin so that the
|
||||
next contact saved any key unseen, is refused. Set Up Connection can't fix a changed key: it checks your
|
||||
own `known_hosts`, not the headset's file.
|
||||
|
||||
**When the headset refuses this computer's key** (`Permission denied
|
||||
(publickey,…)`), the connection stops at *Logging in*, and the banner and the
|
||||
Devices tab offer **Set Up Connection…**, which pairs this computer again.
|
||||
|
||||
Neither is reported as a fault: the diagnostics report each kind at most once
|
||||
a session ([privacy.md](privacy.md)).
|
||||
|
||||
## One server at a time
|
||||
|
||||
Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's
|
||||
data folder). Two would each connect, reconnect and edit the headsets on their own, and
|
||||
one could move the other's install to a different headset. A second one, say
|
||||
`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already
|
||||
running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets.
|
||||
|
||||
## API
|
||||
|
||||
All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
|
||||
|
||||
| Request | Returns |
|
||||
|---|---|
|
||||
| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` |
|
||||
| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the header) |
|
||||
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
|
||||
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
|
||||
| `GET /api/devices/mdns?id=` | Headsets found on this network |
|
||||
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `identity-scan` (read the key the headset shows: `scan` with `type`, `fingerprint`, `host`, `saved`, `recorded`, `matches`, `recorded_match`), `identity-trust` (`fingerprint`: save the key `identity-scan` showed), `forget-identity` (refused: use the two before), `name-network`, `setup` (alias, optional host), `retry` |
|
||||
|
||||
Every host, alias and user is checked against strict patterns before it's
|
||||
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
|
||||
through a shell.
|
||||
|
||||
## The registry file
|
||||
|
||||
`devices.json` in the app's data folder (`~/Library/Application Support/Frame
|
||||
Control` on macOS, `%APPDATA%\Frame Control` on Windows,
|
||||
`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can
|
||||
share the format later (it still connects to one host; see
|
||||
[iphone.md](iphone.md)):
|
||||
|
||||
```json
|
||||
{"version": 1, "active": "f67f8b7e",
|
||||
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
|
||||
"identity_files": ["~/.ssh/id_ed25519_frame"], "identity": ["SHA256:…"], "identity_established": true,
|
||||
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
|
||||
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
|
||||
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
|
||||
"gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}}
|
||||
```
|
||||
|
||||
A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run
|
||||
with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that
|
||||
prints what `ssh -v` prints and plays a ControlMaster, real sockets on this
|
||||
computer for the addresses, and temporary folders for `~/.ssh`
|
||||
(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they
|
||||
never touch yours.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Emulators and your own games
|
||||
|
||||
Frame Control's **Store → Emulators** lists Android emulators from its open
|
||||
app sources. Pick an app, read its compatibility result, and install it. An
|
||||
installed Android app gets its own Steam library shortcut and artwork; **Add
|
||||
artwork** in its library menu can fill missing images later. The artwork
|
||||
workflow also covers native Linux and Windows titles added through **Send to
|
||||
Frame**. An optional SteamGridDB key under **Settings → Library artwork**
|
||||
can supply exact-title community images; source images and generated art are
|
||||
fallbacks. See [VR APKs → In the Steam library](vr-apks.md#in-the-steam-library)
|
||||
for the limits and tested behavior.
|
||||
|
||||
## Set up an emulator
|
||||
|
||||
1. In **Store → Emulators**, choose an emulator that supports the console and
|
||||
file format you want. Check its compatibility result before installing;
|
||||
an app listed in the store is not automatically verified on the Frame.
|
||||
2. Launch the new shortcut from Steam. Open the emulator's own settings to
|
||||
choose a gamepad layout, core or system, and game folder. These controls
|
||||
differ by emulator; Frame Control does not change them.
|
||||
3. Use game files you own and any firmware that emulator's instructions
|
||||
require. Frame Control does not provide ROMs or firmware.
|
||||
|
||||
**Android apps run in separate Lepton instances.** Copying a ROM to the
|
||||
Frame's Linux `~/Downloads` through **Send to Frame** does not automatically
|
||||
put it in an Android app's `/sdcard/Download`. A one-click ROM transfer is
|
||||
deferred until an emulator and its file picker can be tested end to end on a
|
||||
real Frame. An app's own import or sync feature may be enough. Advanced users
|
||||
can use [Lepton's per-instance ADB port](apks.md)
|
||||
to copy into that instance, after confirming the correct container and port.
|
||||
Do not assume that port 5555 is the emulator: it is Lepton Development, while
|
||||
installed apps receive their own ports.
|
||||
|
||||
For a native Linux emulator that can read the Frame's home directory, use
|
||||
**Send to Frame → Copy to ~/Downloads instead** for your files, then open them
|
||||
from the emulator. Whether a particular emulator has a Frame-compatible ARM64
|
||||
build, correct controller mapping and readable storage must be checked for
|
||||
that app. **Linux apps** in Frame Control lists Flatpak options; a Windows or
|
||||
native game archive can instead be added through
|
||||
[Send to Frame](sideloading.md#using-it), which registers a Steam title and
|
||||
artwork. Each ROM is usually opened *inside* its emulator; Frame Control does
|
||||
not create one Steam shortcut per ROM.
|
||||
|
||||
## If something does not appear
|
||||
|
||||
- Check that the emulator is running and is the same app instance to which
|
||||
you sent the file. Android instances do not share `/sdcard` storage.
|
||||
- Check the emulator's supported extension and whether it expects an archive
|
||||
or an extracted file. Some systems need firmware before games appear.
|
||||
- For an empty Steam tile, use **Add artwork** in the installed app or
|
||||
sideloaded title menu. This changes artwork, not emulator data or ROMs.
|
||||
|
||||
The Frame's microSD/USB path into Android apps remains unverified without an
|
||||
inserted medium and an app readback test; see [issue #169](https://github.com/saphid/frame-control/issues/169).
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"date": "2026-10-20T03:53:25Z",
|
||||
"build_id": "20260925.6191901",
|
||||
"app": "F-Droid Basic 2.0.2 (org.fdroid.basic, version code 2000052)",
|
||||
"input": "One real APK plus synthetic main and patch OBBs named with older version code 2000050",
|
||||
"sibling_discovery": true,
|
||||
"automatic_import": "passed",
|
||||
"closed_after_import": true,
|
||||
"independent_readback": [
|
||||
"5023b41e7ac80f8b26a868db1f66884976bf864fab81a070ad37539f3481b352 /sdcard/Android/obb/org.fdroid.basic/main.2000050.org.fdroid.basic.obb",
|
||||
"aecbed7433e81aaeef40942069d03ad23130f09d93b7e78bbe224865a9573157 /sdcard/Android/obb/org.fdroid.basic/patch.2000050.org.fdroid.basic.obb"
|
||||
],
|
||||
"cleanup": {
|
||||
"test_install_removed": true,
|
||||
"running_instances_before": {},
|
||||
"running_instances_after": {},
|
||||
"battery_before": [
|
||||
"100",
|
||||
"Full",
|
||||
"Charging"
|
||||
],
|
||||
"battery_after": [
|
||||
"99",
|
||||
"Discharging",
|
||||
"Charging"
|
||||
],
|
||||
"shared_lock_released": true
|
||||
},
|
||||
"limits": [
|
||||
"No headset rendering or gameplay assertion; headset unworn.",
|
||||
"Synthetic expansion files verify transport and persistence, not Resident Evil 4 compatibility.",
|
||||
"A game that exits before its Lepton instance can accept data was not tested."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# Auto-sync retry and headset-selection reliability
|
||||
|
||||
Date: 25 October 2026. Base: `d79efb4`, Frame Control v0.4.30.
|
||||
Issue: [#214](https://github.com/saphid/frame-control/issues/214).
|
||||
Environment: Linux x86_64, Python 3.12.3, Node 24.15.0.
|
||||
|
||||
## Reproduced problems
|
||||
|
||||
The existing 19 auto-sync tests passed on the base revision. Added regressions
|
||||
failed before the implementation: an exhausted clip lost its error, explicit
|
||||
retry did not recover failed screenshots or clips, and switches around listing
|
||||
or copying continued the old pass. The original callback interface also had no
|
||||
way to carry the tick's captured device into the server list reservation.
|
||||
|
||||
The final fake-source tests exercise these boundaries:
|
||||
|
||||
- Fail screenshots or clips three times, keep checking without further copy
|
||||
attempts, and retain the source error plus an explicit retry instruction.
|
||||
- Recover the source and use either Resume or Copy everything now without
|
||||
restarting. Successful copies stay deduplicated; Resume retains the ledger
|
||||
even after a local file is deleted. Copy everything now retains its documented
|
||||
ability to restore deleted files and include older media.
|
||||
- Reset an exhausted budget while the source still fails: another three
|
||||
attempts are allowed, rather than unlimited background attempts.
|
||||
- Save an exhausted item by hand and clear its stale failure on the next list.
|
||||
- Switch after tick captures A, during a list, after screenshot bookkeeping,
|
||||
between media kinds, before a transfer, or before transfer bookkeeping.
|
||||
Interrupted passes do not mark the other headset's IDs or continue copying.
|
||||
- Switch to B exactly at callback entry through the production `_sync_list`
|
||||
adapter. `working(A)` refuses before calling the source list, leaving both
|
||||
ledgers and copy callbacks untouched. The same production reservation blocks
|
||||
a device switch during a list and rejects stale screenshot/clip transfers.
|
||||
- Execute the production Settings renderer in Node: Resume is available for
|
||||
copy errors and disk-full pauses, and hidden for a healthy state.
|
||||
|
||||
## Implementation boundary
|
||||
|
||||
Each list callback now receives the captured device, like the copy callbacks.
|
||||
The existing server `working(meant)` mechanism checks that intended headset
|
||||
under `_work_lock` while reserving work; `devices_post` uses the same lock to
|
||||
refuse switches during that work. The worker also checks for interruption
|
||||
around lists and transfers. No new device lock or clip destination logic is
|
||||
introduced. The server diff is confined to `_sync_list`.
|
||||
|
||||
The failed-attempt and error maps are in-memory and reset on explicit retry or
|
||||
restart. The successful-copy ledger and settings persistence retain their
|
||||
existing format. Disk-full pauses, offline backoff, clip settling and the
|
||||
three-attempt limit remain covered by the original tests.
|
||||
|
||||
## Checks
|
||||
|
||||
- `python3 -m unittest discover -s tests -p test_autosync.py -v`: 33 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_shot_copy.py -v`: 2 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_clips.py -v`: 34 passed,
|
||||
including local ffmpeg coverage.
|
||||
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
|
||||
36 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_page_scripts.py -v`: 5 passed.
|
||||
- `node --test site/test/*.test.mjs`: 2 test files passed.
|
||||
- `python3 -m py_compile ui/frame_autosync.py ui/server.py tests/test_autosync.py`
|
||||
and `git diff --check`: passed.
|
||||
|
||||
- `python3 -m unittest discover -s tests -v`: 1,525 tests run in 184.876
|
||||
seconds, passed with 16 skips. Ran with sandbox escalation for loopback
|
||||
sockets; test isolation still used throwaway app/SSH data and mocked headset
|
||||
transport. Final output:
|
||||
|
||||
```text
|
||||
Ran 1525 tests in 184.876s
|
||||
OK (skipped=16)
|
||||
```
|
||||
|
||||
## Limits and handoff
|
||||
|
||||
All new regressions use fake sources and mocked transport; they do not prove
|
||||
real-headset transfer or UI rendering. No headset was accessed: its shared test
|
||||
lock was held by another task. The Docker fake-Frame end-to-end suite and
|
||||
Windows/macOS runs were not performed locally. The coordinator owns review and
|
||||
merge; this task does not release, change PR #113, or publish announcements.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"date_utc": "2026-10-24T07:54:37Z",
|
||||
"frame_control_tag": "v0.4.16",
|
||||
"steamos_version": "0.5.6",
|
||||
"steamos_build": "20261015.6141207",
|
||||
"host": "lxso2",
|
||||
"interface": "source UI served by a temporary private Frame Control server, opened in T3 Code's desktop browser",
|
||||
"action": "Clicked the visible Export button for Steam's retained real saved clip in Capture & Live",
|
||||
"clip_id": "10230640/clips/clip_13009719723623972864_20261024_072115",
|
||||
"api_observed": {
|
||||
"post_clips_export_http": 200,
|
||||
"get_job_http": 200,
|
||||
"get_clips_http": 200
|
||||
},
|
||||
"ui_result": "Saved clip_13009719723623972864_20261024_072115.mp4 to ~/Videos/SteamFrame; button changed to Export again",
|
||||
"output": {
|
||||
"path": "~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4",
|
||||
"bytes": 159829,
|
||||
"sha256": "f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1",
|
||||
"matches_previously_fully_decoded_export": true,
|
||||
"duration_seconds": 10.288532,
|
||||
"video": "H.264 1920x1080",
|
||||
"audio": "AAC stereo"
|
||||
},
|
||||
"cleanup": {
|
||||
"temporary_server_stopped": true,
|
||||
"shared_device_lock_released": true,
|
||||
"battery_percent_after": 99,
|
||||
"desktop_mode_active_after": false,
|
||||
"test_title_created": false,
|
||||
"steam_source_clip_retained": true
|
||||
},
|
||||
"limits": [
|
||||
"The test used the source UI, not a packaged desktop installer.",
|
||||
"The retained source clip was nearly black; visible gameplay capture remains unverified.",
|
||||
"Perceptual audio/video sync, long clips, trims and automatic copying were not tested."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"date_utc": "2026-10-24",
|
||||
"version": "v0.4.17",
|
||||
"steam_os_version": "0.5.6",
|
||||
"steam_os_build": "20261015.6141207",
|
||||
"result": "PASS: the real Steam-saved clip exported beyond 60 seconds",
|
||||
"method": "Steam manual recording of a reserved test title, then Frame Control's production frame_clips.export_clip path; no packaged GUI button click",
|
||||
"source": {
|
||||
"kind": "saved Steam clip",
|
||||
"duration_seconds": 100.801,
|
||||
"chunks": 68,
|
||||
"gaps": 0
|
||||
},
|
||||
"export": {
|
||||
"format": "MP4",
|
||||
"bytes": 1377849,
|
||||
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
|
||||
"duration_seconds_ffprobe": 100.833335,
|
||||
"video": "H.264, 1920x1080, 6049 frames",
|
||||
"audio": "AAC, 48000 Hz, stereo",
|
||||
"full_ffmpeg_decode": "passed without errors"
|
||||
},
|
||||
"cleanup": "Restored Steam recording mode to its prior off setting; stopped and removed only the reserved title and its processes; no recording or running Steam apps remained; Game Mode active, battery 100%, shared test lock released",
|
||||
"limits": [
|
||||
"This exercised the production export code path, not a packaged GUI Export click.",
|
||||
"Visible gameplay quality, perceptual audio/video sync, trims and longer recordings were not checked.",
|
||||
"The newly saved Steam source clip was retained; no user recording was deleted."
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"date_utc": "2026-10-24T09:08:35.523Z",
|
||||
"result": "PASS: packaged Electron visible Export click saved the retained real Steam clip beyond 60 seconds",
|
||||
"frame_control_tag": "v0.4.17",
|
||||
"documentation_base_commit": "4e456480aefbe541e334dbd247328537f535bd94",
|
||||
"host": "lxso2",
|
||||
"steamos_version": "0.5.6",
|
||||
"steamos_build": "20261015.6141207",
|
||||
"package": {
|
||||
"asset": "Frame-Control-linux-x86_64.AppImage",
|
||||
"url": "https://github.com/saphid/frame-control/releases/download/v0.4.17/Frame-Control-linux-x86_64.AppImage",
|
||||
"sha256": "b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d",
|
||||
"launch": "Verified release asset; --appimage-extract, then unmodified squashfs-root/AppRun. Electron used the bundled server and Python, not checkout source.",
|
||||
"display": "Xvfb :137, 1440x1000x24; Electron window 1400x950 including native chrome",
|
||||
"flags": [
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
"--remote-debugging-port=49371",
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--user-data-dir=/tmp/frame-packaged-export-20261024/electron-profile"
|
||||
],
|
||||
"isolation": "Throwaway XDG_CONFIG_HOME, FRAME_CONTROL_DATA_DIR and Electron profile; telemetry and update checks disabled; existing SSH identity and normal home Videos destination used."
|
||||
},
|
||||
"ui": {
|
||||
"navigation": "Dismissed optional email prompt with No thanks; clicked Tools, then Capture & Live; scrolled Steam clips into view.",
|
||||
"row": "Steam recording \u00b7 Clip; Oct 24, 8:53 AM \u00b7 1:40 \u00b7 2 MB",
|
||||
"action": "Playwright connected to this Electron instance over local CDP; locator.click() clicked its visible button named exactly Export. The test did not directly invoke an export function or API POST.",
|
||||
"button": "button.small[data-clip-export=\"0\"]",
|
||||
"click_is_trusted": true,
|
||||
"button_bounds_css_pixels": {
|
||||
"x": 1300.390625,
|
||||
"y": 701.15625,
|
||||
"width": 61.609375,
|
||||
"height": 28
|
||||
},
|
||||
"post_clips_export_http": 200,
|
||||
"job": "0ab20b1788adce63",
|
||||
"job_poll_http": 200,
|
||||
"saved_message": "Saved clip_15658413054201167872_20261024_085343.mp4 to ~/Videos/SteamFrame",
|
||||
"final_row": "ON PC; Export again",
|
||||
"screenshots": [
|
||||
"clip-packaged-export-2026-10-24-before.png",
|
||||
"clip-packaged-export-2026-10-24-after.png"
|
||||
],
|
||||
"screenshot_scope": "Cropped to Steam clip rows and saved notification; unrelated screenshot thumbnails and host connection identity excluded."
|
||||
},
|
||||
"source": {
|
||||
"clip_id": "10230640/clips/clip_15658413054201167872_20261024_085343",
|
||||
"duration_seconds": 100.801,
|
||||
"duration_provenance": "Retained real Steam clip from clip-long-2026-10-24.json; current packaged UI displays 1:40."
|
||||
},
|
||||
"output": {
|
||||
"path": "~/Videos/SteamFrame/clip_15658413054201167872_20261024_085343.mp4",
|
||||
"bytes": 1377849,
|
||||
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
|
||||
"matches_prior_production_export_size_and_sha256": true,
|
||||
"duration_seconds_ffprobe": 100.833335,
|
||||
"duration_difference_from_steam_seconds": 0.032335,
|
||||
"video": "H.264, 1920x1080, 6049 decoded frames",
|
||||
"audio": "AAC, 48000 Hz, stereo, 4724 decoded audio frames",
|
||||
"validation": [
|
||||
"ffprobe -v error -count_frames -show_entries format=duration,size:stream=codec_name,codec_type,width,height,sample_rate,channels,nb_read_frames -of json <output>: exit 0",
|
||||
"sha256sum <output>: exact match to prior production export",
|
||||
"ffmpeg -v error -xerror -i <output> -f null -: exit 0, no errors"
|
||||
]
|
||||
},
|
||||
"cleanup": {
|
||||
"shared_lock": "Acquired with ssh frame mkdir /tmp/frame-test.lock after read-only battery/build/lock checks; released with rmdir after app shutdown; absence read back.",
|
||||
"battery_percent_before": 99,
|
||||
"battery_percent_after": 99,
|
||||
"battery_status": "Not charging; charger Full",
|
||||
"packaged_app_and_server_exited": true,
|
||||
"test_xvfb_stopped": true,
|
||||
"frame_export_cache_directories_remaining": 0,
|
||||
"ffmpeg_processes_after": 0,
|
||||
"game_mode_and_steamvr_running_after": true,
|
||||
"steam_source_clip_retained": true,
|
||||
"user_recordings_deleted": false,
|
||||
"headset_install_or_settings_changes": false,
|
||||
"local_export_retained": true
|
||||
},
|
||||
"limits": [
|
||||
"The unchanged AppImage payload ran after extraction on a virtual X desktop, with Chromium sandbox and GPU disabled. FUSE mounting, desktop integration and hardware rendering were not tested.",
|
||||
"The click was automated trusted pointer input in a visible Electron window on Xvfb, not a human click on a physical monitor. T3 collaborative preview opened successfully but drives separate browser tabs; Electron-native automation was used for this window.",
|
||||
"No new game or Steam recording was started. Visible gameplay quality, audible content and perceptual audio/video sync were not assessed.",
|
||||
"Clips longer than this retained 100-second clip, trims and automatic copying remain unverified.",
|
||||
"No release, tag or application code was changed."
|
||||
],
|
||||
"validation_local": {
|
||||
"clip_regression_suite": "python3 -m unittest discover -s tests -p test_clips.py -v: 34 tests passed in 20.173s, including real ffmpeg long-recording export",
|
||||
"json": "python3 -m json.tool: passed",
|
||||
"diff": "git diff --check: passed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"command": "scripts/frame-power-sample.sh --count 2 --interval 1",
|
||||
"exitCode": 0,
|
||||
"stopped": null,
|
||||
"lockPolicy": "Existing sampler checked local and remote /tmp/frame-test.lock before reading; no lock acquired, bypassed or removed. Read-only run only.",
|
||||
"samples": [
|
||||
{
|
||||
"time": 1792828506.4317346,
|
||||
"osBuild": "20261015.6141207",
|
||||
"elapsedSeconds": 0.057539,
|
||||
"battery": {
|
||||
"battery.percent": {
|
||||
"value": 99,
|
||||
"unit": "%",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.status": {
|
||||
"value": "Not charging",
|
||||
"unit": "state",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.watts": {
|
||||
"value": -0.06,
|
||||
"unit": "W",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.tempC": {
|
||||
"value": 24.0,
|
||||
"unit": "C",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": 1792828507.4357972,
|
||||
"osBuild": "20261015.6141207",
|
||||
"elapsedSeconds": 1.061612,
|
||||
"battery": {
|
||||
"battery.percent": {
|
||||
"value": 99,
|
||||
"unit": "%",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.status": {
|
||||
"value": "Not charging",
|
||||
"unit": "state",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.watts": {
|
||||
"value": -0.05,
|
||||
"unit": "W",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
},
|
||||
"battery.tempC": {
|
||||
"value": 24.0,
|
||||
"unit": "C",
|
||||
"source": "/sys/class/power_supply/max1720x_bat_7-36",
|
||||
"state": "available"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"limitations": [
|
||||
"No real low-charge or thermal condition induced.",
|
||||
"No native notification delivery verified.",
|
||||
"No worn-headset or native package tests.",
|
||||
"Read-only parser/transport check; no background monitor or power-saving claim."
|
||||
],
|
||||
"browserCheck": {
|
||||
"page": "Production ui/index.html served statically; API calls intentionally unavailable",
|
||||
"viewport": {
|
||||
"width": 1280,
|
||||
"height": 800
|
||||
},
|
||||
"bridge": "Synthetic desktop notification bridge; no native OS notification sent",
|
||||
"result": "Initially unchecked and visible only after desktop host/bridge setup. Checkbox enabled through browser. Synthetic 12%, Discharging, -4 W samples at 0/30/60 seconds produced one bridge request, an in-page status and a toast. No horizontal overflow.",
|
||||
"apiLimit": "Static server returned expected 404/501 API errors; not an end-to-end live desktop connection test."
|
||||
},
|
||||
"offlineValidation": [
|
||||
"test_desktop_battery_comfort.py: 2 tests, production JS with monotonic fake clock and notification bridge",
|
||||
"test_comfort*.py: 25 tests",
|
||||
"test_battery.py: 7 tests",
|
||||
"test_power*.py: 36 tests (loopback HTTP test run outside socket-restricted sandbox)",
|
||||
"test_ui_workspace.py: 1 Python wrapper / Node workspace suite",
|
||||
"test_ui_review.py: 1 Python wrapper / 9 Node regression tests, including switch reset and retained cooldown"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"date_utc": "2026-10-24",
|
||||
"base_commit": "de931bc",
|
||||
"issue": 73,
|
||||
"result": "Packaged Desktop Capture passed for a mapped nested KDE compositor on the real Frame; wearer-visible overlay condition was not established. Do not close #73 from this test.",
|
||||
"package": {
|
||||
"tag": "v0.4.17",
|
||||
"asset": "Frame-Control-linux-x86_64.AppImage",
|
||||
"sha256": "b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d",
|
||||
"release_digest_matched": true,
|
||||
"panel_helper_matches_base": true,
|
||||
"unmodified_payload": true,
|
||||
"host": "lxso2",
|
||||
"display": "Xvfb :139",
|
||||
"flags": [
|
||||
"--no-sandbox",
|
||||
"--disable-gpu"
|
||||
],
|
||||
"private_profile_and_data": true,
|
||||
"telemetry_and_update_checks_disabled": true
|
||||
},
|
||||
"preflight": {
|
||||
"steamos_version": "0.5.6",
|
||||
"build_id": "20261015.6141207",
|
||||
"battery_percent": 99,
|
||||
"battery_status": "Not charging",
|
||||
"game_mode": true,
|
||||
"full_plasma_session": false,
|
||||
"nested_desktop_running": false,
|
||||
"nested_runtime_present": false,
|
||||
"lock_present": false,
|
||||
"steam_pid": 297826,
|
||||
"vrserver_pid": 297506,
|
||||
"vrcompositor_pid": 297620,
|
||||
"gamescope_service_invocation": "e8c1085b3bbd4e79a323b3e4a9bc6355",
|
||||
"focusable_windows": [],
|
||||
"focused_windows": []
|
||||
},
|
||||
"session": {
|
||||
"started_utc": "2026-10-24T09:43:48Z",
|
||||
"lock_acquired": true,
|
||||
"launch": [
|
||||
"systemd-run",
|
||||
"--user",
|
||||
"--unit=fc-issue73-desktop-20261024",
|
||||
"--collect",
|
||||
"--property=RuntimeMaxSec=180s",
|
||||
"--property=TimeoutStopSec=15s",
|
||||
"--property=KillMode=control-group",
|
||||
"--setenv=DISPLAY=:0",
|
||||
"--setenv=XDG_RUNTIME_DIR=/run/user/1000",
|
||||
"--setenv=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus",
|
||||
"--setenv=XDG_SESSION_TYPE=x11",
|
||||
"--setenv=XDG_CURRENT_DESKTOP=gamescope",
|
||||
"/usr/bin/steamos-nested-desktop"
|
||||
],
|
||||
"response": "Running as unit: fc-issue73-desktop-20261024.service; invocation ID: 92cff1e57c794212997d46c0a68877ec\n"
|
||||
},
|
||||
"discovery": {
|
||||
"focus": 62914560,
|
||||
"focus_display": ":0",
|
||||
"panels": [
|
||||
{
|
||||
"window": 62914560,
|
||||
"display": ":0",
|
||||
"name": "KDE Wayland Compositor - Press right control key to grab input",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"app": 0,
|
||||
"focused": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"window_evidence": {
|
||||
"xwininfo_map_state": "IsViewable",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"focused_after_show_request": true,
|
||||
"steamvr_overlay_key": "valve.steam.desktopgame.0",
|
||||
"steamvr_overlay_visibility_after_show_request": "not_visible"
|
||||
},
|
||||
"capture": {
|
||||
"repeat_utc": "2026-10-24T09:44:47.323Z",
|
||||
"route": "/api/screenshot?view=panel&window=62914560&display=%3A0",
|
||||
"http": 200,
|
||||
"content_type": "image/png",
|
||||
"content_length": 1209980,
|
||||
"source_header": "panel",
|
||||
"decoded": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"source": "panel",
|
||||
"selected": {
|
||||
"window": 62914560,
|
||||
"display": ":0",
|
||||
"name": "KDE Wayland Compositor - Press right control key to grab input",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"app": 0,
|
||||
"focused": true
|
||||
},
|
||||
"stamp": "9:44:48 AM",
|
||||
"badge": "Desktop \u00b7 KDE Wayland Compositor - Press right control key to grab input"
|
||||
},
|
||||
"canvas_png_bytes": 2011278,
|
||||
"first_capture_pixel_statistics": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"min": 2.3333333333333335,
|
||||
"max": 255,
|
||||
"hidden": false
|
||||
},
|
||||
"first_capture_click_event": {
|
||||
"isTrusted": true,
|
||||
"utc": "2026-10-24T09:44:12.738Z"
|
||||
},
|
||||
"actions": "Two Playwright locator.click() actions on the visible #shotBtn in the actual packaged Electron window; first click recorded isTrusted=true. No direct screenshot API invocation by harness."
|
||||
},
|
||||
"device_cleanup": {
|
||||
"utc": "2026-10-24T09:45:33Z",
|
||||
"before_stop": {
|
||||
"exit": 0,
|
||||
"out": "MainPID=300739\nControlGroup=/user.slice/user-1000.slice/user@1000.service/app.slice/fc-issue73-desktop-20261024.service\nActiveState=active\n",
|
||||
"err": ""
|
||||
},
|
||||
"hide_dashboard": {
|
||||
"exit": 0,
|
||||
"out": "HideDashboard",
|
||||
"err": ""
|
||||
},
|
||||
"stop_owned_unit": {
|
||||
"exit": 0,
|
||||
"out": "",
|
||||
"err": ""
|
||||
},
|
||||
"after_stop": {
|
||||
"exit": 0,
|
||||
"out": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
|
||||
"err": ""
|
||||
},
|
||||
"desktop_processes": {
|
||||
"exit": 1,
|
||||
"out": "",
|
||||
"err": ""
|
||||
},
|
||||
"nested_runtime_exists": false,
|
||||
"session": {
|
||||
"exit": 0,
|
||||
"out": "Id=gamescope-session.target\nActiveState=active\nInvocationID=a2fee8ebf55c42c7b546390d14cd985c\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nInvocationID=\n\nId=gamescope-session.service\nActiveState=active\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n",
|
||||
"err": ""
|
||||
},
|
||||
"steam": {
|
||||
"exit": 0,
|
||||
"out": "297826 /home/steamos/.local/share/Steam/steamrtarm64/steam -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n",
|
||||
"err": ""
|
||||
},
|
||||
"steamvr": {
|
||||
"exit": 0,
|
||||
"out": "297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n297620 /opt/steamvr/bin/linuxarm64/vrcompositor\n",
|
||||
"err": ""
|
||||
},
|
||||
"xprops": {
|
||||
"exit": 0,
|
||||
"out": "GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = \nGAMESCOPE_FOCUSED_WINDOW(CARDINAL) = \n",
|
||||
"err": ""
|
||||
},
|
||||
"battery": {
|
||||
"capacity": "99",
|
||||
"status": "Not charging"
|
||||
},
|
||||
"owned_lock_released": true
|
||||
},
|
||||
"host_cleanup": {
|
||||
"electron": {
|
||||
"pid": 1578431,
|
||||
"exists_after": false
|
||||
},
|
||||
"xvfb": {
|
||||
"pid": 1578428,
|
||||
"exists_after": false
|
||||
},
|
||||
"x_socket_absent": true
|
||||
},
|
||||
"delayed_final_readback": "2026-10-24T09:46:39Z\nshared-lock-absent\nLoadState=not-found\nActiveState=inactive\nId=gamescope-session.target\nActiveState=active\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n297826 /home/steamos/.local/share/Steam/steamrtarm64/steam -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n297620 /opt/steamvr/bin/linuxarm64/vrcompositor\nnested-runtime-absent\n100\nFull\nGAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = \nGAMESCOPE_FOCUSED_WINDOW(CARDINAL) = \n",
|
||||
"validation": {
|
||||
"focused_panel_suite": "42 tests passed in 14.805s (test_touch.py), including production-page Node behavior tests",
|
||||
"website": "2 tests passed (node --test site/test/*.test.mjs)",
|
||||
"node_panels": "Passed independently (node tests/page/desk_panels.mjs)",
|
||||
"metadata_and_links": "JSON parsed; result/cleanup assertions and relative evidence/streaming/testing links passed",
|
||||
"diff": "git diff --check passed"
|
||||
},
|
||||
"limits": [
|
||||
"SteamVR continued reporting not_visible after ShowDashboard, despite mapped X window and confirmed focus. No worn-headset visual assessment; does not establish the reporter's visible-overlay condition.",
|
||||
"SteamOS 0.5.6 only; reporter used 0.4.5 with unknown Frame Control version and no reproduction steps.",
|
||||
"Nested desktop launched with SteamOS steamos-nested-desktop from a bounded transient user unit using Steam display/session variables; not launched through a wearer's Steam UI.",
|
||||
"Extracted unchanged AppImage on a virtual X display with Chromium sandbox and GPU disabled; no FUSE, physical monitor or hardware-rendering test.",
|
||||
"No Live video, Control input or full Desktop Mode test.",
|
||||
"Nested-desktop journal included gamescope WSI/Vulkan-hook warnings. No warning was dismissed; captured compositor pixels were nonblank.",
|
||||
"Initial CDP response.body() yielded zero bytes despite successful image decode. Repeat verified Content-Length, fresh decoded image, panel selection and PNG export from canvas; the initial raw-body size is not treated as capture failure or proof.",
|
||||
"No private desktop pixels, issue screenshot or raw desktop journal committed."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
# Packaged Desktop Capture, 2026-10-24
|
||||
|
||||
Base: `de931bc`. Package: Linux x86_64 **v0.4.17**. Device: real Frame,
|
||||
SteamOS **0.5.6**, BUILD_ID **20261015.6141207**. Host: lxso2.
|
||||
[Sanitized state and response evidence](desktop-capture-packaged-2026-10-24.json).
|
||||
|
||||
## Result and issue boundary
|
||||
|
||||
The packaged app discovered the nested KDE compositor and both automated
|
||||
Desktop **Capture** button clicks succeeded. The repeat returned HTTP 200,
|
||||
`Content-Type: image/png`, `X-Capture-Source: panel`, and Content-Length
|
||||
1,209,980. The app decoded a fresh, nonblank 1920×1080 image and displayed
|
||||
the Desktop panel badge. No further product failure was reproduced or fixed.
|
||||
|
||||
This does **not** establish the full visible-desktop condition in
|
||||
[#73](https://github.com/saphid/frame-control/issues/73). `xwininfo` reported
|
||||
the desktop window `IsViewable`, and gamescope confirmed focus after
|
||||
`vrcmd --showdashboard valve.steam.desktopgame.0`. However, SteamVR still
|
||||
reported that overlay `not_visible`. There was no worn-headset visual check.
|
||||
The observation proves capture of a mapped nested compositor, not its visible
|
||||
presentation to a wearer. Do not close #73 from this evidence.
|
||||
|
||||
The issue screenshot shows a desktop panel in the headset view while Desktop
|
||||
Capture logs “no app panel is open in the headset.” The reporter used SteamOS
|
||||
0.4.5, gave no Frame Control version or reproduction steps, and has not replied
|
||||
to the maintainer's 0.4.3 update request. The duplicate page function was already
|
||||
fixed; [PR #152](https://github.com/saphid/frame-control/pull/152) subsequently
|
||||
improved unknown-state diagnostics. Its [earlier investigation](panel-discovery-2026-10-24.md)
|
||||
was blocked from starting a desktop by another task's lock. This run adds a
|
||||
current-package capture observation, without reproducing the older failure.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Read-only preflight found Game Mode active, full Plasma inactive, Steam and
|
||||
SteamVR running, battery 99%, no nested desktop/runtime and no test lock.
|
||||
2. Compared the retained AppImage's SHA-256 with the live v0.4.17 release asset
|
||||
digest: `b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d`.
|
||||
Extracted an independent copy; `cmp` confirmed its `frame_touch.py` matches
|
||||
the base checkout. No payload was edited.
|
||||
3. Started the package's own AppRun, bundled server and Python on Xvfb `:139`
|
||||
with private config/cache/data/profile, telemetry and update checks disabled,
|
||||
and `--no-sandbox --disable-gpu`. Used the existing authorized `ssh frame`
|
||||
identity. T3 preview opened the package server page but does not attach to
|
||||
the existing Electron window. The literal Electron button test used the
|
||||
same Playwright-over-loopback-CDP workflow as the earlier packaged tests.
|
||||
4. Acquired `/tmp/frame-test.lock` with atomic `mkdir` and wrote an owner marker.
|
||||
Rechecked no nested runtime or Plasma was present. Started SteamOS's
|
||||
`/usr/bin/steamos-nested-desktop` in the owned transient user unit
|
||||
`fc-issue73-desktop-20261024.service`, using Steam's display/session variables.
|
||||
`RuntimeMaxSec=180s`, `TimeoutStopSec=15s` and `KillMode=control-group` bounded
|
||||
the session. This launches a desktop panel inside Game Mode; no full Desktop
|
||||
Mode switch was sent.
|
||||
5. In the actual packaged Electron window, navigated Tools → Capture & Live →
|
||||
Desktop and clicked Capture with `locator.click()`. The first click event
|
||||
recorded `isTrusted=true`. The page selected the KDE compositor on `:0`;
|
||||
the production screenshot path used window `62914560`. Requested the desktop
|
||||
dashboard, read back focus/visibility, and clicked Capture again. Recorded
|
||||
response metadata, fresh decoded image dimensions and canvas pixel range.
|
||||
No direct screenshot API call was substituted for these button clicks.
|
||||
6. Ran the packaged `frame_touch.py panels` read-only over SSH to retain its
|
||||
exact discovery result: one focused KDE compositor panel, app ID 0, 1920×1080,
|
||||
display `:0`, with no diagnostic `why` field.
|
||||
|
||||
## Cleanup and limits
|
||||
|
||||
The owned desktop session ran **09:43:48–09:45:33 UTC** (105 seconds). Hid the
|
||||
dashboard to restore its initial hidden state, stopped only the owned unit,
|
||||
and verified it was unloaded/inactive with MainPID 0. No Plasma/KWin desktop
|
||||
process or nested runtime remained; gamescope's focusable/focused lists were
|
||||
empty again. Released only this task's lock, after checking its owner marker.
|
||||
|
||||
Delayed readback at **09:46:39 UTC** confirmed the lock absent, Game Mode active,
|
||||
full Plasma inactive and the original Steam, vrserver and vrcompositor PIDs
|
||||
still running. The Game Mode service invocation was unchanged. Battery was
|
||||
99% at cleanup and 100%/Full on the delayed check. Closed both owned preview
|
||||
tabs, Electron and Xvfb; their processes and X socket were gone. No test server
|
||||
remained. No reboot, installation, security/global setting change or full
|
||||
Desktop Mode transition was performed.
|
||||
|
||||
The nested desktop journal included gamescope WSI/Vulkan-hook warnings. No
|
||||
warning was dismissed. Capture produced nonblank compositor pixels, but this
|
||||
run does not assess their usability to a wearer. Private image files stayed
|
||||
outside the repository; only state and response metadata are published.
|
||||
|
||||
The initial CDP `response.body()` read yielded zero bytes despite successful
|
||||
decoding in the page. The repeat verified the PNG Content-Length, fresh image
|
||||
dimensions/source/selection and a 2,011,278-byte PNG exported from the canvas.
|
||||
The initial raw-body measurement is a harness limitation, not evidence of a
|
||||
product capture failure. No Live video, Control input, physical monitor, FUSE
|
||||
mounting or hardware rendering was tested. Older SteamOS and other packages
|
||||
remain outside this observation.
|
||||
|
||||
## Offline verification
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_touch.py -v`: **42 tests passed**,
|
||||
including the production page's Node panel behavior tests, duplicate-function
|
||||
guard, absent versus empty panel diagnostics, alternate displays, capture/input
|
||||
target validation and discovery deadlines. JSON parsing and `git diff --check`
|
||||
were also checked. No new regression test was added because this is an
|
||||
evidence-only change with no reproduced product defect.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Headset smoke: unavailable x86 runtime is a skip
|
||||
|
||||
The v0.4.41 tagged smoke result recorded 13/13 passed even though its x86_64 launch outcome was `runtime missing`: Steam had acknowledged the title but had not started it. This follow-up separates an unavailable optional runtime from a verified launch. It keeps the smoke usable on a Frame without Steam Linux Runtime 4 while making the missing coverage explicit in its console summary and saved JSON.
|
||||
|
||||
Focused local `test_smoke_cleanup.py` ran **22 tests successfully**. The new regression starts an x86_64 title cycle with a fresh missing-runtime Steam log, verifies `skipped=true` and `outcome=runtime missing`, and verifies that the title is still removed. A result test checks separate passed/skipped/failed counts and a zero exit code when an optional runtime is absent but nothing fails.
|
||||
|
||||
The revised `scripts/frame-smoke.sh` then ran on the real Frame from this branch. It acquired its unique owner in `/tmp/frame-test.lock` before any headset read. Locked preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, the result was **12 passed / 1 skipped / 0 failed**. ARM64 and Proton test executables ran; x86_64 was explicitly skipped because Steam Linux Runtime 4 was not installed. Both title cleanup steps passed. The command exited 0, including release of its owned shared lock. The detailed result remains private at `tests/smoke/results/2026-10-26T050711Z-20261022.6153909.json` in the test checkout.
|
||||
|
||||
The headset was in standby and nobody wore it for this test. Title smoke does not prove wearer-visible pixels, physical controller input, official Decky panels, audio output or comfort. It also does not prove x86_64 runtime execution on this Frame; that path needs the optional runtime present. The hourly worn check remains enabled.
|
||||
@@ -0,0 +1,20 @@
|
||||
# API v1 panel check, 25 October 2026
|
||||
|
||||
The unmodified official Storage Cleaner 1.4.0 archive (store ID 50, SHA-256
|
||||
`1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591`)
|
||||
declares `api_version: 1`, but its bundled frontend connects to Decky's modern
|
||||
frontend API. Framey admitted that bundle from a local store receipt and
|
||||
rendered it in Chromium inside an iframe with `sandbox="allow-scripts
|
||||
allow-popups"`.
|
||||
|
||||
A test parent returned empty lists and `0B` for the plugin's two list and two
|
||||
size backend calls. Chromium reported `framey:ready`, displayed Shader Cache
|
||||
and Compatibility Data sections, and showed all four cleanup buttons disabled.
|
||||
No cleanup action was invoked. The backend was a stub; the test did not run the
|
||||
plugin's Python code or connect to a headset.
|
||||
|
||||
This confirms a panel shell for this exact archive with empty data. Storage
|
||||
Cleaner may remove compatibility data and local game saves when used with a
|
||||
real backend. Its cleanup actions, game names, SteamClient app details, VR
|
||||
visibility, and controller input remain unverified on Frame. Do not infer that
|
||||
its deletion controls are safe or complete in VR from this browser check.
|
||||
@@ -0,0 +1,384 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"timeUtc": "2026-10-26T04:05:15.145624+00:00",
|
||||
"baseCommit": "56bde24",
|
||||
"frameOS": "0.5.6",
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"scope": "Local Chromium opaque Framey panels, production SSH/CDP bridge and actual unmodified official backends in ARM64 Decky; real user audio session, silent test stream only; no worn VR",
|
||||
"plugins": [
|
||||
{
|
||||
"id": 38,
|
||||
"name": "Volume Mixer",
|
||||
"version": "1.2.1-1",
|
||||
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"name": "Volume Boost",
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc"
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"name": "Mixer",
|
||||
"version": "0.1.0",
|
||||
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106"
|
||||
}
|
||||
],
|
||||
"readOnlySession": {
|
||||
"timeUtc": "2026-10-26T04:03:54.509107+00:00",
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"volumeMixerLogExists": false,
|
||||
"defaultSinkVolume": {
|
||||
"code": 0,
|
||||
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"pactlInfoExit": 0,
|
||||
"pwDumpExit": 0
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"volumeMixerLogExists": false,
|
||||
"defaultSinkVolume": {
|
||||
"code": 0,
|
||||
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"pactlInfoExit": 0,
|
||||
"pwDumpExit": 0
|
||||
},
|
||||
"calls": [
|
||||
{
|
||||
"id": 38,
|
||||
"method": "get_sink_inputs",
|
||||
"args": [
|
||||
{}
|
||||
],
|
||||
"legacy": true,
|
||||
"version": "1.2.1-1",
|
||||
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3",
|
||||
"result": {
|
||||
"success": true,
|
||||
"streamCount": 1,
|
||||
"volumes": [
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"legacy": true,
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"legacy": true,
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "get_settings",
|
||||
"args": [],
|
||||
"legacy": false,
|
||||
"version": "0.1.0",
|
||||
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
|
||||
"result": {
|
||||
"result": {
|
||||
"filters": [],
|
||||
"boostLimit": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "get_playbacks",
|
||||
"args": [],
|
||||
"legacy": false,
|
||||
"version": "0.1.0",
|
||||
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
|
||||
"result": {
|
||||
"count": 6,
|
||||
"internalAudioNodeCount": 5,
|
||||
"steamStreamCount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"fixedLogAbsent": true
|
||||
},
|
||||
"lockReleased": true
|
||||
},
|
||||
"liveSilentStream": {
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"volumeMixerLogExists": false,
|
||||
"defaultSinkVolume": {
|
||||
"code": 0,
|
||||
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"pactlInfoExit": 0,
|
||||
"pwDumpExit": 0
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"volumeMixerLogExists": false,
|
||||
"defaultSinkVolume": {
|
||||
"code": 0,
|
||||
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"pactlInfoExit": 0,
|
||||
"pwDumpExit": 0
|
||||
},
|
||||
"source": "paplay --raw --format=s16le --rate=48000 --channels=2 --client-name=FrameySilentARM64 --stream-name=\"Framey ARM64 silent verification\" /dev/zero",
|
||||
"writes": [
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 31400,
|
||||
"volume": 0.5
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentReadback": [
|
||||
{
|
||||
"index": 31400,
|
||||
"name": "FrameySilentARM64",
|
||||
"volume": {
|
||||
"front-left": {
|
||||
"value": 32768,
|
||||
"value_percent": "50%",
|
||||
"db": "-18.06 dB"
|
||||
},
|
||||
"front-right": {
|
||||
"value": 32768,
|
||||
"value_percent": "50%",
|
||||
"db": "-18.06 dB"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 31400,
|
||||
"volume": 0.45
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentReadback": [
|
||||
{
|
||||
"index": 31400,
|
||||
"name": "FrameySilentARM64",
|
||||
"volume": {
|
||||
"front-left": {
|
||||
"value": 29491,
|
||||
"value_percent": "45%",
|
||||
"db": "-20.81 dB"
|
||||
},
|
||||
"front-right": {
|
||||
"value": 29491,
|
||||
"value_percent": "45%",
|
||||
"db": "-20.81 dB"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
199,
|
||||
37
|
||||
],
|
||||
"result": true,
|
||||
"independentReadback": [
|
||||
{
|
||||
"index": 31400,
|
||||
"name": "FrameySilentARM64",
|
||||
"volume": {
|
||||
"front-left": {
|
||||
"value": 24248,
|
||||
"value_percent": "37%",
|
||||
"db": "-25.91 dB"
|
||||
},
|
||||
"front-right": {
|
||||
"value": 24248,
|
||||
"value_percent": "37%",
|
||||
"db": "-25.91 dB"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
199,
|
||||
38
|
||||
],
|
||||
"result": true,
|
||||
"independentReadback": [
|
||||
{
|
||||
"index": 31400,
|
||||
"name": "FrameySilentARM64",
|
||||
"volume": {
|
||||
"front-left": {
|
||||
"value": 24904,
|
||||
"value_percent": "38%",
|
||||
"db": "-25.21 dB"
|
||||
},
|
||||
"front-right": {
|
||||
"value": 24904,
|
||||
"value_percent": "38%",
|
||||
"db": "-25.21 dB"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"volumeMixerReopen": {
|
||||
"id": 38,
|
||||
"method": "get_sink_inputs",
|
||||
"args": [
|
||||
{}
|
||||
],
|
||||
"result": {
|
||||
"result": [
|
||||
{
|
||||
"name": "FrameySilentARM64",
|
||||
"index": 31400,
|
||||
"volume": 0.3800048828125
|
||||
}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"mixerReadAfterChange": {
|
||||
"id": 144,
|
||||
"method": "get_playbacks",
|
||||
"args": [],
|
||||
"result": [
|
||||
{
|
||||
"id": 199,
|
||||
"name": "FrameySilentARM64",
|
||||
"mediaName": "Framey ARM64 silent verification",
|
||||
"volume": 38
|
||||
}
|
||||
]
|
||||
},
|
||||
"volumeBoostReads": [
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"fixedLogAbsent": true
|
||||
},
|
||||
"lockReleased": true
|
||||
},
|
||||
"independentCleanupAudit": {
|
||||
"testPathsAbsent": true,
|
||||
"units": {
|
||||
"framey-audio-kp0z857q58-32363fd7": "MainPID=0\nLoadState=not-found\nActiveState=inactive",
|
||||
"framey-audio-kp0z857q58-c08f3f5d": "MainPID=0\nLoadState=not-found\nActiveState=inactive",
|
||||
"framey-audio-kp0z857q58-c08f3f5d-silence": "MainPID=0\nLoadState=not-found\nActiveState=inactive"
|
||||
},
|
||||
"silentStreamsAbsent": true
|
||||
},
|
||||
"limitations": [
|
||||
"Physical audible output, default-sink writes, boost above 100%, Bluetooth playback controls and worn VR unverified.",
|
||||
"Mixer also exposes internal Frame PipeWire nodes and Reset; neither was changed or invoked.",
|
||||
"Private Decky/installed-plugin outer status fixture; real RPC used production framey_panel.call_method over SSH and live Steam CEF, not a deployed headset HTTP endpoint test.",
|
||||
"Mixer filter save/restart was verified on amd64 in the earlier evidence, not repeated on ARM64."
|
||||
],
|
||||
"catalogRefresh": {
|
||||
"timeUtc": "2026-10-26T04:11:59.449535+00:00",
|
||||
"visibleCount": 116,
|
||||
"idsVersionsDigestsUnchanged": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
# Official audio plugins on the ARM64 Frame
|
||||
|
||||
Volume Mixer 1.2.1-1 (38) and Mixer 0.1.0 (144) now qualify as **works for
|
||||
per-app volume control**. Their exact official, unmodified Python backends ran
|
||||
in published Decky 3.3.0 ARM64 on Frame OS 0.5.6. Local Chromium rendered the
|
||||
actual Framey panels and changed only a deliberately created silent playback
|
||||
stream in the Frame's real user audio session. Independent Pulse readbacks
|
||||
confirmed the changes. This is useful backend behavior, not initializer-only
|
||||
or fake-backend evidence.
|
||||
|
||||
Volume Boost 0.0.7 (58) remains **partial**. Its real ARM64 default-sink reads
|
||||
matched independent `pactl` reads, and its controls rendered. Default-sink writes
|
||||
were denied throughout the real-Frame run. Its earlier slider/numeric write
|
||||
checks used an isolated amd64 virtual sink.
|
||||
|
||||
[Digest-pinned observations and cleanup](framey-audio-arm64-2026-10-26.json)
|
||||
record both runs. No physical audible output or worn-VR result is claimed.
|
||||
|
||||
| Plugin | Real Frame result | Remaining limits |
|
||||
| --- | --- | --- |
|
||||
| Volume Mixer | `pulsectl`/`ravel` import; real Pulse session discovery; silent-stream slider click 100 → 50%, ArrowDown → 45%; reopen reads back the 38% subsequently set by Mixer | Bluetooth discovery/playback commands, audible output and worn-VR input unverified. The range input rounds the reopened 0.38000488 backend value to its configured step; no extra write occurred. |
|
||||
| Volume Boost | `pactl` default sink and stereo channel parsing; both backend reads return 0 and agree with independent 0% / 0% sink reads; local panel shows 0 | No real default-sink writes or boosts; no audible output or worn-VR result. Zero alone is not proof: upstream returns 0 when its command fails. |
|
||||
| Mixer | `pw-dump`/`wpctl` available; real playbacks/settings reads; exact silent-stream numeric input 37%, ArrowUp → 38%, independently read back | Exposes internal Frame PipeWire nodes as well as apps. Internal-node controls and Reset were not exercised. Filter persistence passed on amd64 earlier, not ARM64. Audible output and worn-VR input unverified. |
|
||||
|
||||
## Runtime and transport
|
||||
|
||||
The normal atomic `/tmp/frame-test.lock` was acquired separately for each run.
|
||||
An existing owner would abort testing; the lock was never stolen. The headset
|
||||
was not woken, and no power setting was changed.
|
||||
|
||||
The actual Frame UID is 1000. The legacy plugins' hard-coded
|
||||
`XDG_RUNTIME_DIR=/run/user/1000` therefore reaches this Frame's existing audio
|
||||
session. Volume Mixer's bundled pure Python wrappers successfully loaded the
|
||||
system ARM64 Pulse and D-Bus libraries. No native rebuild was needed. It also
|
||||
sets `HOME=/home/deck` inside its own plugin process; this test does not establish
|
||||
that arbitrary Deck paths work on the Frame.
|
||||
|
||||
The upstream `PluginLoader-arm64` release was verified against GitHub's
|
||||
published SHA-256 and ELF machine 183 before transfer. Two private, bounded,
|
||||
removable user-service installs used their own Decky plugins/settings bases.
|
||||
Neither service was enabled. The normal managed Decky service was already
|
||||
failed, and the normal headset-panel service inactive; both states remained
|
||||
unchanged. A pre-existing `/tmp/VolumeMixer-decky.log` would have aborted the
|
||||
run because the upstream plugin truncates that fixed path during import.
|
||||
|
||||
The local relay served actual `ui/headset.html`, vendored React, `framey-host.js`
|
||||
and digest-checked official frontend bytes in an opaque `allow-scripts` iframe.
|
||||
Only outer status/install responses were fixtures. Plugin RPC used production
|
||||
`framey_panel.call_method`, SSH through lxso2, `frame_steam.Page`, the live Steam
|
||||
CEF `DeckyBackend.call`, and actual Decky plugin subprocesses. Private install
|
||||
paths were redirected to the temporary base. No second Decky websocket client
|
||||
was attached. This does not verify the deployed headset HTTP endpoint or a VR
|
||||
compositor interaction.
|
||||
|
||||
## Safe control check
|
||||
|
||||
The first run was read-only. Independent `pactl info`, default-sink volume and
|
||||
`pw-dump` commands exited successfully, so empty/zero fallback values were not
|
||||
accepted on their own. Volume Mixer saw a live Steam stream; Mixer also saw
|
||||
Frame spatialization, echo-cancellation and ALSA internal nodes.
|
||||
|
||||
For the second run, a separate task-owned transient user service ran:
|
||||
|
||||
```sh
|
||||
paplay --raw --format=s16le --rate=48000 --channels=2 \
|
||||
--client-name=FrameySilentARM64 \
|
||||
--stream-name='Framey ARM64 silent verification' /dev/zero
|
||||
```
|
||||
|
||||
This sends zero-valued samples to the existing user server. It is an explicit
|
||||
silent client, not the earlier private null-server fixture. No server, device,
|
||||
default-route or physical-sink configuration was changed. The relay resolved
|
||||
its live stream IDs before every write and allowed only this exact application
|
||||
name, bounded to 80% volume. The spatialization node with a similar prefix was
|
||||
excluded. All Volume Boost writes, Mixer Reset/settings writes and Bluetooth
|
||||
commands were denied. Existing streams and internal nodes were never write
|
||||
targets. This broader real-session check supersedes the earlier suggested
|
||||
null-server-only ARM64 follow-up; that older fixture could not prove access to
|
||||
the Frame's real session.
|
||||
|
||||
Every browser-triggered volume RPC was followed by independent
|
||||
`pactl --format=json list sink-inputs` channel readback. Only the named silent
|
||||
stream's rows are published. Background streams can appear/disappear naturally;
|
||||
no whole-session stability claim is made.
|
||||
|
||||
## Cleanup and catalog interpretation
|
||||
|
||||
Both runs removed their private Decky units/bases and the test-created fixed
|
||||
plugin log. The live run also stopped its transient silent client. Decky's main
|
||||
process exited during shutdown but left children waiting; those remaining
|
||||
processes were killed only within the test service's systemd cgroup before
|
||||
cleanup completed. Both locks were released after cleanup. An independent
|
||||
fresh-lock audit confirmed all three test units unloaded/inactive with PID 0,
|
||||
private paths absent, and no named silent stream. Battery was 100 → 100 and
|
||||
independent default-sink reads stayed 0% / 0% in both runs.
|
||||
|
||||
The canonical store data and generated compatibility table now report
|
||||
**3 works / 48 partial / 54 broken / 11 N/A**. “Works” describes the tested
|
||||
per-app control behavior; read each row's remaining limits. Historical
|
||||
[fake-Frame archive checks](framey-module-lookup-2026-10-26.md) cover transport
|
||||
with plugin execution stubbed, and the
|
||||
[earlier local audio checks](framey-audio-backends-2026-10-26.md) cover isolated
|
||||
amd64 controls/filter persistence. Neither is relabelled as a real ARM64 test.
|
||||
|
||||
## Repository checks
|
||||
|
||||
The full Python suite passed 1,621 tests (16 skips), and the focused Framey
|
||||
suite passed all 43 tests. `scripts/framey-compatibility-doc.py --check` and
|
||||
`git diff --check` passed. A fresh official-store fetch at 04:11:59 UTC found
|
||||
all 116 IDs, versions and digests unchanged. This PR changes catalog/evidence
|
||||
and generated documentation; it adds no backend shim or plugin patch.
|
||||
@@ -0,0 +1,677 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"timeUtc": "2026-10-26T01:30:56.939937+00:00",
|
||||
"baseCommit": "442b5ec",
|
||||
"hostChange": "SliderField editableValue numeric input",
|
||||
"verification": "local Chromium + real Decky 3.3.0 amd64 container; no real Frame/worn VR",
|
||||
"decky": {
|
||||
"tag": "v3.3.0",
|
||||
"name": "PluginLoader",
|
||||
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
|
||||
},
|
||||
"audioVersions": {
|
||||
"pipewire": "1.4.2",
|
||||
"wireplumber": "0.5.8",
|
||||
"pactl": "17.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": 38,
|
||||
"name": "Volume Mixer",
|
||||
"version": "1.2.1-1",
|
||||
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3",
|
||||
"folder": "VolumeMixer-decky"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"name": "Volume Boost",
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
|
||||
"folder": "volume-boost"
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"name": "Mixer",
|
||||
"version": "0.1.0",
|
||||
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
|
||||
"folder": "decky-volume-mixer"
|
||||
}
|
||||
],
|
||||
"isolation": {
|
||||
"network": "none",
|
||||
"devices": "none",
|
||||
"hostAudioSockets": "none",
|
||||
"audio": "private PipeWire/Pulse null sink; two silent streams; WirePlumber policy-only"
|
||||
},
|
||||
"fixtureBoundaries": [
|
||||
"Outer headset status/installed-plugin responses were fixture-shaped. Actual ui/headset.html, React 18.3.1, opaque allow-scripts iframe and unmodified official bundles ran in Chromium.",
|
||||
"The relay used Decky 3.3.0 official websocket RPC routes directly inside the isolated container; Steam CEF and the production SSH/CDP method transport were not part of this audio run.",
|
||||
"Actual published amd64 Decky binary and official unmodified Python backends executed. No ARM64 or real Frame audio claim.",
|
||||
"Previous full-catalog fake-Frame evidence tests archive/install/remove transport only; its backends remain stubbed."
|
||||
],
|
||||
"sharedHeadsetLock": {
|
||||
"acquired": false,
|
||||
"owner": "delightvr-r4-a56d43b4c0",
|
||||
"headsetReadsOrChanges": false
|
||||
},
|
||||
"audioBefore": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "45%",
|
||||
"front-right": "50%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"mutatingCalls": [
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 75,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "75%",
|
||||
"front-right": "75%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 74,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "74%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 73,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "73%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 75,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "75%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 74,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 46,
|
||||
"volume": 0.5
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "50%",
|
||||
"front-right": "50%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 46,
|
||||
"volume": 0.45
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "45%",
|
||||
"front-right": "45%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
37
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "37%",
|
||||
"front-right": "37%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
38
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 42,
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 42,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "42%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "save_settings",
|
||||
"args": [
|
||||
{
|
||||
"filters": [
|
||||
"FrameySilent0"
|
||||
],
|
||||
"boostLimit": 0
|
||||
}
|
||||
],
|
||||
"result": true
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
39
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "42%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "39%",
|
||||
"front-right": "39%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"volumeMixer38": "Discovery, slider click and ArrowDown: only FrameySilent1 changed 100\u219250\u219245%; FrameySilent0 stayed100%.",
|
||||
"volumeBoost58": "Sync both channels 75\u219274\u219273%; unsynced right 75\u219274% preserved left73%; reopen restored unchecked sync and73/74. Numeric left42 preserved right74; re-sync set right42 and disabled both right inputs.",
|
||||
"mixer144": "Numeric37\u219238\u219239 changed FrameySilent1 only; server readbacks37/38/39%. Invalid101 and off-step37.5 produced no set_volume RPC. Filter FrameySilent0 saved with boostLimit0, survived actual Decky shutdown/restart and reopened showing only FrameySilent1 at38%.",
|
||||
"invalidNumericBrowserMessages": {
|
||||
"inputs": [
|
||||
"101",
|
||||
"37.5",
|
||||
"39"
|
||||
],
|
||||
"setVolumeMessages": [
|
||||
{
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
39
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"notRun": [
|
||||
"boost above100%",
|
||||
"Bluetooth playback commands",
|
||||
"reset_volumes",
|
||||
"physical headset audio",
|
||||
"worn VR",
|
||||
"VR keyboard/controller input"
|
||||
]
|
||||
},
|
||||
"pipewireConfig": "context.properties = { core.daemon = true core.name = framey-audio }\ncontext.spa-libs = { audio.convert.* = audioconvert/libspa-audioconvert support.* = support/libspa-support }\ncontext.modules = [\n { name = libpipewire-module-protocol-native }\n { name = libpipewire-module-access }\n { name = libpipewire-module-spa-node-factory }\n { name = libpipewire-module-client-node }\n { name = libpipewire-module-client-device }\n { name = libpipewire-module-adapter }\n { name = libpipewire-module-metadata }\n { name = libpipewire-module-link-factory }\n { name = libpipewire-module-session-manager }\n]\ncontext.objects = [\n\n { factory = spa-node-factory args = { factory.name = support.node.driver node.name = Dummy-Driver node.group = pipewire.dummy priority.driver = 20000 } }\n { factory = adapter args = { factory.name = support.null-audio-sink node.name = framey-silent-test node.description = \"Framey Silent Test\" media.class = Audio/Sink audio.position = [ FL FR ] } }\n]",
|
||||
"remaining": "All three remain partial until real ARM64 Frame dependencies and audio-session mapping are verified under the shared lock.",
|
||||
"cleanup": {
|
||||
"testContainerRemoved": true,
|
||||
"browserRelayStopped": true,
|
||||
"headsetUntouched": true,
|
||||
"testServicesOnHeadset": false
|
||||
},
|
||||
"catalogRefresh": {
|
||||
"checkedAtUtc": "2026-10-26T01:34:07.257386+00:00",
|
||||
"plugins": 116,
|
||||
"idsVersionsDigestsUnchanged": true
|
||||
},
|
||||
"regression": {
|
||||
"serverTests": {
|
||||
"count": 1612,
|
||||
"skipped": 16,
|
||||
"result": "OK"
|
||||
},
|
||||
"siteTests": {
|
||||
"passed": 2,
|
||||
"failed": 0
|
||||
},
|
||||
"focusedFrameyTests": 38,
|
||||
"officialFrontendAudit": {
|
||||
"attempted": 116,
|
||||
"ready": 98,
|
||||
"failed": 18,
|
||||
"readyStateChanges": [],
|
||||
"initializerOnly": true,
|
||||
"backendAndNetworkDenied": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# Official audio plugins: local real-backend checks
|
||||
|
||||
Historical amd64 run. The [later ARM64 Frame check](framey-audio-arm64-2026-10-26.md)
|
||||
supersedes the ARM64 gaps and follow-up plan below; it promotes the two per-app
|
||||
mixers to works for their verified scope. Volume Boost remains partial.
|
||||
|
||||
Volume Mixer 1.2.1-1 (38), Volume Boost 0.0.7 (58) and Mixer 0.1.0 (144)
|
||||
passed useful audio controls in local Chromium with their **unmodified official
|
||||
Python backends running in actual Decky 3.3.0**. All three remain **partial**:
|
||||
this run used amd64 Linux, and Frame ARM64 dependencies/audio-session mapping
|
||||
are unverified. The shared headset lock was occupied; no headset reads or
|
||||
changes occurred. No worn-VR result is claimed.
|
||||
|
||||
The private test container and browser relay were removed after verification.
|
||||
|
||||
The [machine-readable observations](framey-audio-backends-2026-10-26.json)
|
||||
pin all three official archives and the published Decky binary digest, and
|
||||
record each browser-triggered write with independent `pactl` channel readbacks.
|
||||
|
||||
| Plugin | Useful behavior checked | Still unverified on Frame |
|
||||
| --- | --- | --- |
|
||||
| Volume Mixer | Discover two playback streams; slider click and ArrowDown change only the selected stream, 100 → 50 → 45%; other stream remains 100% | ARM64 libpulse/libdbus, actual user audio session, Bluetooth playback controls |
|
||||
| Volume Boost | Both-channel slider; separate right-channel change preserves left; reopen restores sync preference and actual values; exact numeric left entry; syncing disables both right controls | `pactl`, default sink, hard-coded runtime UID, physical output, boost above 100% |
|
||||
| Mixer | Exact stream volume 37 → 38%, independently read back; invalid 101 and off-step 37.5 produce no write; playback filter saved, survives actual Decky restart, hides the intended stream on reopen | `pw-dump`/`wpctl`, actual Frame session, physical output, worn VR |
|
||||
|
||||
## Reproduced host gap
|
||||
|
||||
Both Volume Boost and Mixer request `SliderField.editableValue`. Framey
|
||||
previously ignored that prop and rendered only a range slider. It now supplies
|
||||
an accessible numeric input with the same minimum, maximum, step and disabled
|
||||
state, and emits numeric `onChange` values. Empty, non-finite, out-of-range and
|
||||
browser-invalid drafts do not reach a backend. No official archive was patched.
|
||||
This also makes the control available to other plugins requesting that prop;
|
||||
it does not establish their compatibility.
|
||||
|
||||
## Runtime and isolation
|
||||
|
||||
The disposable Debian 13 container ran PipeWire 1.4.2, WirePlumber 0.5.8 and
|
||||
PulseAudio client tools 17.0. Docker used `--network none --cap-drop ALL
|
||||
--security-opt no-new-privileges`, no host audio sockets and no sound devices.
|
||||
Only the archive cache and test inputs were mounted, read-only. Debian 12 was
|
||||
insufficient for the published Decky binary's GLIBC requirement; that initial
|
||||
runtime did not start.
|
||||
|
||||
Private environment:
|
||||
|
||||
```text
|
||||
XDG_RUNTIME_DIR=/tmp/framey-audio-runtime
|
||||
PIPEWIRE_REMOTE=framey-audio
|
||||
PULSE_SERVER=unix:/tmp/framey-audio-runtime/pulse-native
|
||||
DBUS_SYSTEM_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
|
||||
DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
|
||||
```
|
||||
|
||||
XDG config/data/state and both Decky paths were below the private test base.
|
||||
Decky ran unprivileged with `CHOWN_PLUGIN_PATH=0`, `LIVE_RELOAD=0`,
|
||||
`USE_CEF_CLOSE_WORKAROUND=0` and `KEEP_SYSTEMD_SERVICE=1`. A private D-Bus
|
||||
session satisfied Volume Mixer's import; there was no real BlueZ service.
|
||||
Its fixed `/tmp/VolumeMixer-decky.log` existed only inside the container.
|
||||
|
||||
The JSON includes the complete PipeWire server configuration: only a dummy
|
||||
driver and null stereo sink, no hardware factories. A second PipeWire process
|
||||
loaded protocol-native, client-node, adapter, metadata and protocol-pulse with
|
||||
the private absolute socket. `wireplumber --profile policy` managed this server;
|
||||
that installed profile contains no hardware monitors. Only private default-sink
|
||||
metadata was selected. Two `paplay --raw --format=s16le --rate=48000 --channels=2
|
||||
/dev/zero` clients supplied silence named `FrameySilent0` and `FrameySilent1`.
|
||||
Without policy, successful volume RPCs initially left readbacks unchanged; those
|
||||
fixture attempts are excluded from the passing observations.
|
||||
|
||||
The browser relay served actual `ui/headset.html`, vendored React 18.3.1,
|
||||
`ui/framey-host.js` and the exact archived frontends in an opaque `allow-scripts`
|
||||
iframe. Outer headset/installed-plugin responses were fixture-shaped. Backend
|
||||
RPC used the official Decky websocket protocol inside the isolated container:
|
||||
`loader/call_legacy_plugin_method` with keyword objects for 38/58 and
|
||||
`loader/call_plugin_method` with positional arguments for 144. This bypassed
|
||||
Steam CEF and the production SSH/CDP transport; neither is an audio verification
|
||||
claim from this run.
|
||||
|
||||
The relay allowed discovery/settings reads, bounded volume writes only to
|
||||
identified `FrameySilent` streams/the private default sink, and Mixer filters
|
||||
`[]` or `["FrameySilent0"]` with boost limit 0. It denied Bluetooth commands,
|
||||
`reset_volumes` and boosts. Every volume write was followed by a separate
|
||||
`pactl -f json list sinks` / `list sink-inputs` read. A clean Decky shutdown and
|
||||
restart retained Mixer's actual settings file and filter behavior.
|
||||
|
||||
Earlier full-catalog fake-Frame evidence for these exact digests covers
|
||||
install/receipt/frontend/removal transport, with plugin execution stubbed.
|
||||
The new evidence adds actual local backend execution; it does not turn that
|
||||
older fake-Frame result into an ARM64 backend test.
|
||||
|
||||
## Regression checks
|
||||
|
||||
The required server suite passed 1,612 tests (16 skips); all 38 focused Framey
|
||||
tests and both website tests passed. The 116-plugin browser audit retained
|
||||
98 initializer-ready / 18 failed, with no change to any ready state. That audit
|
||||
keeps backend/network actions denied. A fresh official catalog fetch confirmed
|
||||
all 116 IDs, versions and digests unchanged.
|
||||
|
||||
## Next device check
|
||||
|
||||
Acquire the normal atomic `/tmp/frame-test.lock`; refuse an existing owner.
|
||||
First inspect available tools/libraries and audio runtime UID. If supported,
|
||||
repeat with a private ARM64 Decky user service, isolated audio socket, dummy
|
||||
sink/streams and hardware discovery disabled. Check Volume Mixer's fixed log
|
||||
path for collisions before loading it. Never attach the fixture to the normal
|
||||
headset output/default server. Remove only test-owned services/processes/files,
|
||||
read back original service/battery state and release only the matching lock.
|
||||
Keep all three partial until that check passes; physical output, Bluetooth and
|
||||
worn-VR controls need separate evidence.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bluetooth 3.1.1 compatibility fixture, 25 October 2026
|
||||
|
||||
The unmodified official Decky store archive for Bluetooth 3.1.1 (store ID 18,
|
||||
SHA-256 `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5`)
|
||||
passes Frame Control's archive rules. The fake Frame e2e harness installed it
|
||||
through the store API, verified the managed receipt and frontend bytes, then
|
||||
removed it. An earlier real Frame check recorded the backend's `Bluetooth
|
||||
plugin loaded` line while SteamVR was in standby.
|
||||
|
||||
In a local Node host test, its official frontend starts and builds an initial
|
||||
panel tree. Framey supplies empty Steam token maps and readable English
|
||||
fallbacks for the three Bluetooth labels that lack plugin-owned fallbacks.
|
||||
The test leaves React effects and click
|
||||
handlers inert and sends no backend calls. It does not inspect paired devices,
|
||||
change Bluetooth state, exercise a controller, or prove the panel is visible in
|
||||
VR. No worn-headset check has passed for this plugin.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Framey loader evidence, 25 October 2026
|
||||
|
||||
## Local browser and fake Frame
|
||||
|
||||
The official Decky store returned 116 validated entries. ProtonDB Badges was
|
||||
entry 14, release 1.3.4, SHA-256
|
||||
`90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358`.
|
||||
Its published ZIP passed Frame Control's store manifest and path validator. The current
|
||||
`dist/index.js` bundle was loaded in an opaque-origin iframe in local Chromium
|
||||
with the Framey React/DFL shim. The [browser screenshot](framey-browser-2026-10-25.png)
|
||||
shows the plugin's settings panel. A pointer click on **Enable Badge on
|
||||
Library** issued `set_setting('settings', …)` through the iframe's request
|
||||
bridge. The local test parent returned a stub backend reply; it did not use a
|
||||
real headset or Decky backend. A separate fake Frame CEF fixture returned
|
||||
`get_setting` and `set_setting` responses through Frame Control's CDP bridge;
|
||||
that fixture does not model the real Decky process.
|
||||
|
||||
The disposable fake Frame end-to-end harness passed the store catalogue,
|
||||
acknowledgement and HTTP auth checks, hash-verified install, update, and
|
||||
removal through the real Frame Control HTTP server and SSH transaction. The
|
||||
full fake Frame suite passed 48 tests; the full unit suite passed 1,559 tests
|
||||
(16 skipped). Unit tests covered unsafe archives,
|
||||
unmanaged-folder preservation, asset isolation, backend-call scope,
|
||||
and safe aborts of the worn-headset check.
|
||||
|
||||
## Real headset status
|
||||
|
||||
Read-only SSH on Frame OS 0.5.6 found battery at 98–99%. SteamVR activity was
|
||||
initially 3 (standby), briefly 0, then 3 again. During the active probe the
|
||||
shared `/tmp/frame-test.lock` belonged to `obs-aacapv3evh-20261025`. It later
|
||||
became free after SteamVR returned to standby. Framey did not acquire, replace,
|
||||
or remove it. The active probe found no installed Frame Control VR panel or
|
||||
Framey plugin; the scripted worn check exited before acquiring a lock or
|
||||
altering the headset. There is **no** wearer-visible VR
|
||||
menu screenshot, controller-input proof, or live settings-persistence result
|
||||
from this round.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,19 @@
|
||||
# Framey cache check, 25 October 2026
|
||||
|
||||
The official Steam Status 1.2.3 ZIP pinned in
|
||||
`tests/fixtures/framey-official/123.zip` was loaded through Framey's local
|
||||
HTTP panel in Chromium. The iframe retained `sandbox="allow-scripts
|
||||
allow-popups"`, so its native storage remained unavailable. The new Framey
|
||||
storage facade accepted a set/get probe without a `SecurityError`. Steam
|
||||
Status rendered and issued four `get_settings` calls to a stub parent backend.
|
||||
Its `steamstat_last_status` cache entry was about 33 KiB, below the bridge's
|
||||
128 KiB cap. After reopening the iframe, the parent supplied the saved cache
|
||||
snapshot and the plugin wrote the same cache key again. The browser log had
|
||||
zero `SecurityError` or `localStorage` errors.
|
||||
|
||||
The parent was a local test page with in-memory Decky backend replies. One
|
||||
public status fetch also failed in the test browser, so this check does not
|
||||
establish offline correctness or the freshness of Steam Status data. The
|
||||
per-plugin save/reopen protocol, stale panel session rejection, and cache
|
||||
limits are covered by `tests/page/framey_cache.mjs`. Frame OS persistence,
|
||||
live Decky settings, VR visibility, and controller input remain unverified.
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"dateUtc": "2026-10-26",
|
||||
"plugin": {
|
||||
"storeId": 135,
|
||||
"name": "Deck Shelves",
|
||||
"version": "2.0.0",
|
||||
"sha256": "b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91"
|
||||
},
|
||||
"mode": "opaque allow-scripts iframe in local Chromium, exact archived frontend, backend and network denied",
|
||||
"baselineError": "relative JavaScript chunk imports cannot resolve from a blob URL",
|
||||
"result": {
|
||||
"initializerReady": true,
|
||||
"error": null,
|
||||
"moduleResponses200": 9,
|
||||
"settingsVisible": [
|
||||
"Enable shelves on Home",
|
||||
"Create default shelves",
|
||||
"Behavior",
|
||||
"Shelves"
|
||||
],
|
||||
"attemptedBackendMethods": [
|
||||
"get_settings"
|
||||
],
|
||||
"backendMethodsExecuted": [],
|
||||
"steamLibraryRead": "denied by audit",
|
||||
"pluginNetwork": "denied by audit"
|
||||
},
|
||||
"limits": [
|
||||
"No Steam Home shelf injection or settings write was verified",
|
||||
"No official backend executed",
|
||||
"No official Deck Shelves interaction occurred on the real Frame or in worn VR",
|
||||
"Settings visibility alone does not prove the plugin works"
|
||||
],
|
||||
"realFrameGate": {
|
||||
"sourceCommit": "b0c4647b856035c970ae27ea4e2ddbf6e51f6963",
|
||||
"headsetOsBuild": "20261022.6153909",
|
||||
"preflightActivity": 3,
|
||||
"preflightBatteryPercent": 100,
|
||||
"postBatteryPercent": 99,
|
||||
"sampleExercisePassed": true,
|
||||
"sampleSettingPersistedAfterFreshIframe": true,
|
||||
"syntheticOpenVrPointerTrusted": true,
|
||||
"stereoPixelsChanged": true,
|
||||
"mirrorCapturePresent": true,
|
||||
"nativeScrollMoved": false,
|
||||
"powerValuesRestored": true,
|
||||
"recoveryTimerRemoved": true,
|
||||
"lockReleased": true,
|
||||
"cleanupErrors": [],
|
||||
"genericFrameSmokePassed": 13,
|
||||
"genericFrameSmokeFailed": 0,
|
||||
"officialDeckShelvesBackendOrVrTested": false,
|
||||
"physicalControllerVerified": false,
|
||||
"wornVisualComfortVerified": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Deck Shelves chunked frontend, 26 October 2026
|
||||
|
||||
The exact official Deck Shelves 2.0.0 archive (store ID 135, SHA-256 `b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91`) contains `dist/index.js` and eight JavaScript chunks. Its API v1 `connect(1, "Deck Shelves")` bridge sits in `chunks/decky-api-WgbNuFYS.js`, not in the entry file; the original static scanner therefore reported its frontend API as unknown. Framey's prior blob import failed before initialization because a blob URL has no directory in which to resolve `./chunks/...`. The [numeric observation](framey-deck-shelves-browser-2026-10-26.json) and [local Chromium capture](framey-deck-shelves-browser-2026-10-26.png) record a targeted follow-up using the unchanged official frontend.
|
||||
|
||||
Framey now serves JavaScript files from the managed plugin's `dist` directory using hierarchical module URLs. Its opaque `allow-scripts` iframe still has no Frame UI key. Local Chromium received HTTP 200 for the entry and eight chunks, reported the initializer ready, and rendered the settings panel with “Enable shelves on Home,” “Create default shelves,” “Behavior,” and “Shelves.” The browser audit denied the attempted `get_settings` backend call, Steam library read and plugin network access. No backend method executed.
|
||||
|
||||
The catalog moves this exact version from broken to **partial** for visible local settings only. The plugin's main feature injects shelves into Steam Home; Framey has no verified Steam Home patch target. Settings writes, official backend behavior, real Frame module serving, wearer-visible VR, and physical controller input remain unverified. The [compatibility table](../framey-compatibility.md) and store views retain these limits.
|
||||
|
||||
## Exact source real Frame gates
|
||||
|
||||
The exact source commit `b0c4647b856035c970ae27ea4e2ddbf6e51f6963` ran the unattended `scripts/framey-vr-check.py --exercise` on SteamOS build `20261022.6153909` under its unique shared-lock owner. Preflight found SteamVR activity 3 (standby), battery 100%, and proximity 6.25. The repository sample panel appeared, stereo compositor pixels changed, and `/dev/video99` supplied a mirror frame. Synthetic OpenVR pointer input changed the sample setting through actual Decky; the value survived a fresh iframe reopen. Native scroll did not move. The runner removed its temporary services and recovery timer, read back the original `false`/`5` power values, found battery 99%, reported no cleanup errors, and released its lock.
|
||||
|
||||
The same source then passed `scripts/frame-smoke.sh` **13/13** on the real Frame, starting from standby at battery 99%. Its final cleanup step passed. The [sanitized result fields](framey-deck-shelves-browser-2026-10-26.json) omit room pixels, raw overlays, identifiers, and settings contents; private captures were not committed. These gates exercise the Framey repository sample and generic install/launch/remove paths. They do **not** exercise Deck Shelves' official ARM64 backend or prove its settings or shelves in VR. No person wore the headset and no physical controller was checked.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T09:28:48.295443+00:00",
|
||||
"plugin": {
|
||||
"id": 124,
|
||||
"name": "AlarMe",
|
||||
"version": "1.6.3",
|
||||
"sha256": "17d4166a9e98979df5fdb07c7eae42317acd54a453d3b82757508f971193c0fe"
|
||||
},
|
||||
"status": "partial",
|
||||
"sharedControls": {
|
||||
"ConfirmModalOfficialImporters": 43,
|
||||
"FocusableOfficialImporters": 65,
|
||||
"claim": "Import counts show shared surface, not successful behavior in every plugin. Only AlarMe received a real-backend follow-up."
|
||||
},
|
||||
"realFrame": {
|
||||
"os": "Frame OS 0.5.6",
|
||||
"arch": "aarch64",
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"transport": "trusted SSH through coordinator host; receipt-bound real CEF Decky RPC/events",
|
||||
"isolation": "Exact official archive installed by production APPLY into a random private path. Removable unprivileged user service, not enabled, RuntimeMaxSec=900 and TimeoutStopSec=5.",
|
||||
"settings": {
|
||||
"timer_subtle_mode": true,
|
||||
"timer_volume": 0,
|
||||
"timer_auto_suspend": false,
|
||||
"timer_prevent_sleep": false,
|
||||
"pomodoro_subtle_mode": true,
|
||||
"pomodoro_volume": 0,
|
||||
"pomodoro_prevent_sleep": false,
|
||||
"overlay_enabled": false
|
||||
},
|
||||
"safeRpcResults": [
|
||||
{
|
||||
"method": "create_timer",
|
||||
"args": [
|
||||
300,
|
||||
"Framey silent private timer",
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"result": "91c1f87d"
|
||||
},
|
||||
{
|
||||
"method": "pause_timer",
|
||||
"args": [
|
||||
"91c1f87d"
|
||||
],
|
||||
"result": true
|
||||
},
|
||||
{
|
||||
"method": "save_preset_from_timer",
|
||||
"args": [
|
||||
"91c1f87d"
|
||||
],
|
||||
"result": {
|
||||
"id": "47c046cd",
|
||||
"seconds": 300,
|
||||
"label": "Framey silent private timer",
|
||||
"subtle_mode": true,
|
||||
"auto_suspend": false,
|
||||
"prevent_sleep": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "remove_preset",
|
||||
"args": [
|
||||
"47c046cd"
|
||||
],
|
||||
"result": true
|
||||
},
|
||||
{
|
||||
"method": "cancel_timer",
|
||||
"args": [
|
||||
"91c1f87d"
|
||||
],
|
||||
"result": true
|
||||
},
|
||||
{
|
||||
"method": "stopwatch_start",
|
||||
"args": [],
|
||||
"result": {
|
||||
"status": "running",
|
||||
"start_time": 1793006404.791917,
|
||||
"elapsed_ms": 0,
|
||||
"laps": [],
|
||||
"prevent_sleep": false,
|
||||
"current_elapsed_ms": 1.0344982147216797
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "stopwatch_lap",
|
||||
"args": [],
|
||||
"result": {
|
||||
"status": "running",
|
||||
"start_time": 1793006404.791917,
|
||||
"elapsed_ms": 0,
|
||||
"laps": [
|
||||
{
|
||||
"label": "Lap 1",
|
||||
"absolute_ms": 31747.3783493042,
|
||||
"split_ms": 31747.3783493042
|
||||
}
|
||||
],
|
||||
"prevent_sleep": false,
|
||||
"current_elapsed_ms": 31750.77533721924,
|
||||
"total_lap_count": 1,
|
||||
"lap_limit_reached": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "stopwatch_pause",
|
||||
"args": [],
|
||||
"result": {
|
||||
"status": "paused",
|
||||
"start_time": null,
|
||||
"elapsed_ms": 34339.426040649414,
|
||||
"laps": [
|
||||
{
|
||||
"label": "Lap 1",
|
||||
"absolute_ms": 31747.3783493042,
|
||||
"split_ms": 31747.3783493042
|
||||
}
|
||||
],
|
||||
"prevent_sleep": false,
|
||||
"current_elapsed_ms": 34339.426040649414,
|
||||
"total_lap_count": 1,
|
||||
"lap_limit_reached": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "stopwatch_reset",
|
||||
"args": [],
|
||||
"result": {
|
||||
"status": "idle",
|
||||
"start_time": null,
|
||||
"elapsed_ms": 0,
|
||||
"laps": [],
|
||||
"current_elapsed_ms": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"eventCounts": {
|
||||
"alarme_timers_updated": 3,
|
||||
"alarme_sleep_inhibitor_updated": 6,
|
||||
"alarme_timer_tick": 124,
|
||||
"alarme_presets_updated": 2,
|
||||
"alarme_stopwatch_updated": 3
|
||||
},
|
||||
"finalReadback": {
|
||||
"timers": [],
|
||||
"stopwatch": {
|
||||
"status": "idle",
|
||||
"start_time": null,
|
||||
"elapsed_ms": 0,
|
||||
"laps": [],
|
||||
"current_elapsed_ms": 0
|
||||
},
|
||||
"sleep": {
|
||||
"active": false,
|
||||
"items": []
|
||||
},
|
||||
"presets": [
|
||||
{
|
||||
"id": "preset-5",
|
||||
"seconds": 300,
|
||||
"label": "5 minutes",
|
||||
"subtle_mode": false,
|
||||
"auto_suspend": false
|
||||
},
|
||||
{
|
||||
"id": "preset-10",
|
||||
"seconds": 600,
|
||||
"label": "10 minutes",
|
||||
"subtle_mode": false,
|
||||
"auto_suspend": false
|
||||
},
|
||||
{
|
||||
"id": "preset-15",
|
||||
"seconds": 900,
|
||||
"label": "15 minutes",
|
||||
"subtle_mode": false,
|
||||
"auto_suspend": false
|
||||
},
|
||||
{
|
||||
"id": "preset-30",
|
||||
"seconds": 1800,
|
||||
"label": "30 minutes",
|
||||
"subtle_mode": false,
|
||||
"auto_suspend": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"before": {
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"battery": [
|
||||
97
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"battery": [
|
||||
96
|
||||
]
|
||||
},
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"lockReleased": true
|
||||
},
|
||||
"browser": {
|
||||
"host": "Repository headset.html and opaque allow-scripts iframe with bundled React 18.3.1; loopback RPC/event relay to the real ARM64 backend",
|
||||
"verified": [
|
||||
"labeled silent timer creation and live tick display",
|
||||
"timer pause through onActivate and backend readback",
|
||||
"save paused timer as preset through onActivate",
|
||||
"delete confirmation shows preset name and description",
|
||||
"Cancel closes dialog without dispatching remove_preset; backend readback retains preset",
|
||||
"Delete invokes remove_preset once and readback removes the private preset",
|
||||
"timer cancellation",
|
||||
"Watch tab activation; stopwatch start/lap/pause",
|
||||
"fresh panel reopen retains paused 34.339-second stopwatch and one lap",
|
||||
"stopwatch reset; no active timers or sleep inhibitors in final backend readback"
|
||||
],
|
||||
"domFixture": {
|
||||
"type": "fixture:result",
|
||||
"checks": [
|
||||
"description visible",
|
||||
"underlying panel inert",
|
||||
"dialog fixed",
|
||||
"Tab wraps in top dialog",
|
||||
"Cancel callback once",
|
||||
"parent draft preserved",
|
||||
"disabled actions",
|
||||
"old Close retains child",
|
||||
"programmatic Close skips callback",
|
||||
"OK does not close newly opened dialog",
|
||||
"Update changes body",
|
||||
"Escape cancels top dialog",
|
||||
"panel reactivated",
|
||||
"pointer Enter Space activate once each"
|
||||
]
|
||||
},
|
||||
"limits": "Local Chromium with real backend, not the deployed headset HTTP host or worn VR. Synthetic DOM checks use programmatic DOM events, not physical controllers."
|
||||
},
|
||||
"fixtureCorrection": {
|
||||
"issue": "Initial seed used settings/<display-name>; actual Decky uses settings/<folder>. Safety assertion failed on readback before any timer action.",
|
||||
"resolution": "First private service/data removed and lock released. Correct folder seed read back before second run exposed actions."
|
||||
},
|
||||
"cleanupAudit": {
|
||||
"servicesAudited": 2,
|
||||
"allBaseUnitArchiveAbsent": true,
|
||||
"allLoadStateNotFound": true,
|
||||
"allActiveStateInactive": true,
|
||||
"allMainPidZero": true,
|
||||
"sharedLock": "Fresh atomic lock acquired and released for cleanup audit"
|
||||
},
|
||||
"unverified": [
|
||||
"audio playback and sound imports",
|
||||
"alarms and reminders",
|
||||
"auto-suspend and /dev/uinput sleep inhibition",
|
||||
"in-game/global Steam overlay: no injection target in isolated panel",
|
||||
"background notifications after panel closes",
|
||||
"worn VR and physical controller/keyboard input"
|
||||
],
|
||||
"validation": {
|
||||
"fullPython": {
|
||||
"ran": 1628,
|
||||
"skipped": 16,
|
||||
"result": "pass"
|
||||
},
|
||||
"nodeContracts": [
|
||||
"tests/page/framey_actions.mjs",
|
||||
"tests/page/framey_running.mjs"
|
||||
],
|
||||
"siteTestFiles": 2,
|
||||
"currentStoreRefresh": {
|
||||
"plugins": 116,
|
||||
"exactIdsVersionsAndDigestsUnchanged": true
|
||||
},
|
||||
"fakeFrame": {
|
||||
"blocked": 56,
|
||||
"installed-removed": 60
|
||||
},
|
||||
"noContextBrowser": {
|
||||
"plugins": 116,
|
||||
"ready": 101,
|
||||
"failed": 15,
|
||||
"noRegressionsAgainstCatalogBaseline": true,
|
||||
"scope": "Initialization only, all backend/network denied; library-dependent plugins lack Steam context"
|
||||
},
|
||||
"contextReplayBrowser": {
|
||||
"plugins": 116,
|
||||
"ready": 104,
|
||||
"failed": 12,
|
||||
"initializationRegressionsAgainstPriorContextReplay": [],
|
||||
"scope": "Synthetic library/running replay; backend and network denied, initialization only"
|
||||
}
|
||||
},
|
||||
"reviewFollowUp": {
|
||||
"checkedAtUtc": "2026-10-26T10:10:05.650676+00:00",
|
||||
"change": "Update reinjects the scoped close callback into replacement React dialogs and uses the replacement close callback.",
|
||||
"verification": "Node regression: updated ConfirmModal OK and Cancel, with and without custom close callbacks; ClosedPromise resolves; original callback not retained; repeated Close/Update inert. Regression reproduced before the fix.",
|
||||
"limits": "No additional local Chromium or real Frame check for this small follow-up; prior runtime evidence applies to initial dialog and workflow paths."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# Framey activation and dialog follow-up — 2026-10-26
|
||||
|
||||
AlarMe 1.6.3 now has usable timer, preset and stopwatch controls in the local
|
||||
Framey host with its official backend running on the ARM64 Frame. It remains
|
||||
**partial**. This check does not establish audio, alarm/reminder, sleep-control,
|
||||
background-notification or worn-VR compatibility.
|
||||
|
||||
The exact archive is store ID 124, SHA-256
|
||||
`17d4166a9e98979df5fdb07c7eae42317acd54a453d3b82757508f971193c0fe`.
|
||||
See the [structured results](framey-dialog-controls-2026-10-26.json).
|
||||
|
||||
## Reproduced blockers and repairs
|
||||
|
||||
AlarMe uses DFL `Focusable.onActivate` for timer pause, save-as-preset, cancel,
|
||||
preset deletion, tabs and stopwatch controls. Framey's previous `div`
|
||||
passthrough ignored that callback: clicks on the save/pause controls did not
|
||||
dispatch backend calls. Focusable now maps pointer activation and Enter/Space
|
||||
to plugin callbacks, exposes a keyboard focus target and respects disabled
|
||||
controls. A click does not invoke both `onClick` and `onActivate` when a plugin
|
||||
supplies both; nested activation stops bubbling to the parent action.
|
||||
|
||||
The previous ConfirmModal omitted `strDescription`, Cancel, middle actions and
|
||||
disabled flags. showModal did not inject a `closeModal` callback into custom
|
||||
dialog components, and every Close handle cleared the same root. A confirmation
|
||||
opened by another dialog could therefore disappear when the earlier action
|
||||
closed. Dialogs also appeared below the panel's long page content.
|
||||
|
||||
Dialogs now occupy a scrollable overlay inside the opaque iframe. They preserve
|
||||
the underlying dialog's mounted state, receive scoped close callbacks, support
|
||||
Close/ClosedPromise/Update, show confirmation descriptions and cancel/middle
|
||||
actions, and respect disabled flags. The underlying plugin surface becomes
|
||||
inert while a dialog is open; Tab stays in the top dialog, Escape cancels it and
|
||||
closing restores focus. Steam popouts and controller navigation are not emulated.
|
||||
|
||||
43 official archives import ConfirmModal and 65 import Focusable in this
|
||||
snapshot. These are shared API surfaces, **not** verification or status
|
||||
promotions for every importing plugin.
|
||||
|
||||
## Real backend and local browser verification
|
||||
|
||||
Held the normal atomic shared headset lock. Installed the exact official archive
|
||||
through the production archive validation/extraction path into a random private
|
||||
directory. Used published Decky 3.3.0 ARM64, an unprivileged user service that was
|
||||
never enabled, with a 900-second runtime bound and five-second stop timeout.
|
||||
RPCs and events used Framey's production receipt-bound bridge through real Steam
|
||||
CEF. The Chromium host served repository headset.html, React and framey-host.js;
|
||||
its status endpoints were fixture-shaped, while AlarMe RPCs/events were live.
|
||||
|
||||
The initial private settings seed used the plugin display name instead of the
|
||||
archive folder. A safety assertion failed on readback of the defaults before any
|
||||
timer action. That service/data was removed and the lock released. The corrected
|
||||
run read back subtle mode, zero volume, no auto-suspend, no sleep inhibition and
|
||||
no overlay before exposing actions. Its relay allowed only the documented safe
|
||||
methods; sound, suspend, sleep-inhibitor and arbitrary plugin actions were denied.
|
||||
|
||||
Verified with actual browser clicks and real backend readback:
|
||||
|
||||
1. Add Label opened a custom timer dialog. Created a silent five-minute timer
|
||||
named `Framey silent private timer`; live backend ticks changed its display.
|
||||
2. Pause and save-as-preset activated after the Focusable repair. Backend
|
||||
readback reported a paused timer and its saved preset.
|
||||
3. Delete Preset showed the preset's name in its confirmation description.
|
||||
Cancel closed the dialog without dispatching `remove_preset`; readback kept
|
||||
the preset. Reopening and choosing Delete dispatched it once and removed it.
|
||||
4. Cancel removed the private timer. Watch opened the stopwatch; Start, Lap and
|
||||
Pause produced real backend state and events.
|
||||
5. Closing and reopening the panel retained the paused stopwatch at 34.339
|
||||
seconds and one lap. Reset cleared it. Final readback showed no active timers,
|
||||
no laps and no active sleep inhibitor.
|
||||
|
||||
A separate local Chromium DOM fixture passed 14 checks for nested-dialog state,
|
||||
scoped handles, descriptions, disabled buttons, Cancel, Escape, Tab wrapping,
|
||||
Close/ClosedPromise/Update and pointer/Enter/Space activation. These programmatic
|
||||
DOM checks do not verify physical input devices.
|
||||
|
||||
Review follow-up: Update also injects the scoped close callback into replacement
|
||||
dialogs. Node regression contracts update to ConfirmModal and exercise both OK
|
||||
and Cancel, with and without a plugin close callback, then await ClosedPromise.
|
||||
They verify that the replacement's callback runs once and the original callback
|
||||
is not retained. This extends the local host's Update behavior so replacements
|
||||
remain dismissible; this follow-up was not rerun in Chromium or on the headset.
|
||||
|
||||
## Cleanup and remaining limits
|
||||
|
||||
Removed both private service units, bases and uploaded archives; reloaded the
|
||||
user service manager and released the matching shared lock. A fresh locked audit
|
||||
confirmed both units were not found, inactive and PID zero, with all test paths
|
||||
absent. Managed Decky remained failed and the headset panel inactive, matching
|
||||
the initial state. Battery was 97% before and 96% after. No power settings changed.
|
||||
|
||||
AlarMe stays partial. Audio, sound imports, alarms/reminders, auto-suspend and
|
||||
`/dev/uinput` sleep inhibition were not exercised. Steam's global/in-game overlay
|
||||
has no injection target inside Framey's isolated panel. Notifications require
|
||||
the plugin panel to remain open. The deployed headset HTTP host, worn VR and
|
||||
physical controller/keyboard input were not tested in this pass.
|
||||
|
||||
The store and documentation use the same exact-digest catalog reason. Aggregate
|
||||
counts remain **4 works / 49 partial / 52 broken / 11 N/A**.
|
||||
|
||||
Validation: all 116 official IDs/versions/digests unchanged on store refresh;
|
||||
fake-Frame 60 installed/removed and 56 blocked; full Python suite 1,628 tests,
|
||||
16 skipped; Framey action/running Node contracts and both site test files pass.
|
||||
The no-Steam-context Chromium pass attempted all 116 frontends (101 initialized,
|
||||
15 failed); backend and network access were denied throughout that pass.
|
||||
The repeat with synthetic library/running context initialized 104 of 116, with no
|
||||
initialization regressions against the prior context-replay pass. Neither
|
||||
initialization pass is a useful-functionality check.
|
||||
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:12:47.296778+00:00",
|
||||
"method": "Local Chromium actual Framey headset page and opaque official plugin iframe; production method/event bridge relayed by SSH to real ARM64 Decky 3.3.0 in a private removable user service. Test relay restricts RPC methods and timer durations. Backend code is unmodified, no fake plugin replies. Only file paths to private install and outer status/data APIs differ. Not worn VR.",
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"hostSourceSha256": {
|
||||
"framey-host.js": "3bad52546c0b78c20e6479c91899f1f3864b74687c6fce37e776abededfb5f1c",
|
||||
"framey-events.js": "89046f4c89bdab0f578b910170a044d5ada540db0f7b71d6a370c68b4b008c3f",
|
||||
"framey_panel.py": "8ca4b3ba5f0d51c51bb3601f9ccd24c4af397fbf8f63c383a3042e1ae3f7b39b",
|
||||
"headset.html": "ebaf1711639a929ec7287c01dbbbe8eb08f000b852fdc877f31c9d99e5fe69fc"
|
||||
},
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"powerSettingsChanged": false,
|
||||
"plugins": [
|
||||
{
|
||||
"id": 19,
|
||||
"name": "Notebook",
|
||||
"version": "0.1.5",
|
||||
"sha256": "c35b09de4bba4b4157fbe84cb7b0d20fc3924f78fa3c48ae17686b4314c1cc0c",
|
||||
"calls": [
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 0,
|
||||
"empty": true,
|
||||
"data": ""
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "savePage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"timestamp": 1792973680303
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [
|
||||
{
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false
|
||||
}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [],
|
||||
"checks": {
|
||||
"pointerStrokeSaved": true,
|
||||
"sameStrokeLoadedAfterBackendRestart": true,
|
||||
"defaultAppId": 0,
|
||||
"existingNotesTouched": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"name": "Simple Timer",
|
||||
"version": "1.0.0",
|
||||
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312",
|
||||
"calls": [
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
300
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "cancel_timer",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
5
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 5,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 6,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
294.9945251941681
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 7,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
289.99201130867004
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 8,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
284.9877219200134
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 9,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 10,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 11,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 12,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
-0.005679607391357422
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 13,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 14,
|
||||
"event": "simple_timer_event",
|
||||
"args": [
|
||||
"Your session has ended!",
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"uiStart300Seconds": true,
|
||||
"uiCancel": true,
|
||||
"shortTimer5Seconds": true,
|
||||
"completionToastVisible": true,
|
||||
"recentTimersAndSubtleModeSurvivedRestart": true,
|
||||
"subscriptionsAfterPanelClose": 0
|
||||
},
|
||||
"upstreamError": "Simple Timer 1.0.0 _main after restarting with an expired timer raises AttributeError: 'Plugin' object has no attribute 'timer_task' (main.py:94,129). Frontend read requests still load recent timers and subtle mode."
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"serviceActive": false,
|
||||
"independentlyRechecked": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
"regressions": {
|
||||
"unitTests": 1606,
|
||||
"unitSkips": 16,
|
||||
"browserPlugins": 116,
|
||||
"browserReady": 96,
|
||||
"browserFailed": 20,
|
||||
"fakeFrameInstalled": 60,
|
||||
"fakeFrameRejected": 56
|
||||
},
|
||||
"limitations": [
|
||||
"Timer sound asset and suspend action unsupported; no suspend/power action run.",
|
||||
"Closing a Framey panel removes its event channel; it is not a global background notification service.",
|
||||
"Notebook default notebook only (appid 0): no per-game auto-selection or background artwork.",
|
||||
"No worn VR, physical controller, keyboard, or optical check."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T08:14:37.777131+00:00",
|
||||
"plugin": {
|
||||
"id": 84,
|
||||
"name": "Junk-Store",
|
||||
"version": "1.1.13",
|
||||
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1"
|
||||
},
|
||||
"projection": {
|
||||
"field": "local_per_client_data.installed",
|
||||
"wire": "optional ninth scalar: boolean or null",
|
||||
"unknown": "null creates an empty detached metadata object; installed stays undefined",
|
||||
"privateFieldsCopied": false,
|
||||
"oldEightScalarRows": "accepted; running updates preserve existing library metadata",
|
||||
"liveObservation": false
|
||||
},
|
||||
"realFrame": {
|
||||
"os": "Frame OS 0.5.6",
|
||||
"arch": "aarch64",
|
||||
"uid": 1000,
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"transport": "trusted SSH through lxso2",
|
||||
"sharedLock": true,
|
||||
"runtime": "private bounded removable user service; not enabled",
|
||||
"library": {
|
||||
"apps": 1106,
|
||||
"games": 895,
|
||||
"installedTrue": 41,
|
||||
"installedFalse": 0,
|
||||
"installedUnknown": 1065
|
||||
},
|
||||
"protonRuntimeInstalledFields": "undefined for both EasyAntiCheat and BattlEye; no false value invented",
|
||||
"legendaryInstalled": false,
|
||||
"allowedMethods": [
|
||||
"execute_action",
|
||||
"get_websocket_port"
|
||||
],
|
||||
"staticActions": [
|
||||
"MainMenu/GetContent",
|
||||
"init/InitActions"
|
||||
],
|
||||
"noMutationActions": true
|
||||
},
|
||||
"localBrowser": {
|
||||
"host": "actual headset.html/framey-host.js, vendored React, opaque iframe",
|
||||
"backend": "exact official ARM64 plugin through production receipt-bound bridge",
|
||||
"library": "production receipt-bound snapshot from real Frame CEF",
|
||||
"outerStatusInstallCheck": "fixture responses",
|
||||
"passed": [
|
||||
"main menu",
|
||||
"About content and tab list",
|
||||
"Dependencies tab controls render; no buttons invoked",
|
||||
"Back to main menu",
|
||||
"close/reopen and About again"
|
||||
],
|
||||
"limits": [
|
||||
"optional localhost websocket blocked by test relay CSP",
|
||||
"RSS calls denied",
|
||||
"running-context relay returns fixture/unavailable data; no lifetime claims",
|
||||
"outer extension/status fixture is incomplete; unrelated outer status/extension errors excluded from plugin claim",
|
||||
"deployed headset HTTP endpoint unverified",
|
||||
"worn VR unverified"
|
||||
]
|
||||
},
|
||||
"cleanup": {
|
||||
"initialSshStop": "timed out while test unit deactivating",
|
||||
"recovery": "SIGKILL to exact owned unit only after matching lock owner; private files removed",
|
||||
"result": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"archiveAbsent": true,
|
||||
"state": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
|
||||
"normal": {
|
||||
"frame-control-decky.service": "inactive",
|
||||
"frame-control-headset.service": "inactive"
|
||||
},
|
||||
"battery": [
|
||||
99
|
||||
]
|
||||
},
|
||||
"freshLockedAudit": {
|
||||
"services": 1,
|
||||
"allArtifactsAbsent": true,
|
||||
"allUnloadedInactivePidZero": true
|
||||
},
|
||||
"lockReleased": true,
|
||||
"batteryBeforeAfter": [
|
||||
[
|
||||
99
|
||||
],
|
||||
[
|
||||
99
|
||||
]
|
||||
],
|
||||
"powerSettingChanged": false,
|
||||
"localRelayAndBrowserClosed": true
|
||||
},
|
||||
"verification": {
|
||||
"python": {
|
||||
"run": 1628,
|
||||
"skipped": 16,
|
||||
"failures": 0
|
||||
},
|
||||
"panelFocused": {
|
||||
"run": 22,
|
||||
"failures": 0
|
||||
},
|
||||
"nodeContracts": [
|
||||
"framey_actions.mjs",
|
||||
"framey_running.mjs"
|
||||
],
|
||||
"siteTestFiles": {
|
||||
"passed": 2,
|
||||
"failures": 0
|
||||
},
|
||||
"fakeFrame": {
|
||||
"blocked": 56,
|
||||
"installed-removed": 60
|
||||
},
|
||||
"officialStore": {
|
||||
"plugins": 116,
|
||||
"errors": 0,
|
||||
"identitySetUnchanged": true,
|
||||
"checkedAtUtc": "2026-10-26T08:21:01.653768+00:00"
|
||||
}
|
||||
},
|
||||
"status": "partial",
|
||||
"unverified": [
|
||||
"dependency/runtime installation",
|
||||
"login",
|
||||
"game listing/download/launch",
|
||||
"RSS",
|
||||
"optional websocket operations",
|
||||
"Steam shortcut mutation",
|
||||
"worn VR"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Junk-Store About and read-only installed metadata
|
||||
|
||||
Junk-Store 1.1.13 (official ID 84) now renders its About and Dependencies tabs
|
||||
without the `local_per_client_data.installed` crash recorded in the
|
||||
[script-entrypoint audit](framey-script-entrypoints-2026-10-26.md). It remains
|
||||
**partial**. The catalog and both store views use the same updated reason.
|
||||
|
||||
The library snapshot omitted the entire `local_per_client_data` object. The
|
||||
official frontend dereferences it to display its dependency controls. Under the
|
||||
shared headset lock, a read-only probe confirmed that the real Frame exposes
|
||||
this object for the two Proton runtimes, but neither has an `installed` boolean.
|
||||
Across 1,106 app overviews, 41 had `installed: true`, none had `false`, and 1,065
|
||||
had no installed boolean. Those absent values must not be relabelled as false.
|
||||
|
||||
The snapshot adds one bounded scalar per app: a Steam-supplied boolean or null.
|
||||
Framey's detached overview creates a minimal metadata object, copying `installed`
|
||||
only for a boolean. Null leaves that property undefined. No client IDs, paths,
|
||||
other metadata, live Steam objects or methods cross the iframe boundary. Eight
|
||||
scalar rows remain accepted, and running-app updates preserve library metadata.
|
||||
The snapshot still refreshes at panel open; it is not a live installation feed.
|
||||
|
||||
## Actual ARM64 backend and local Chromium
|
||||
|
||||
The unmodified, hash-verified official archive ran in published Decky 3.3.0 ARM64
|
||||
on Frame OS 0.5.6 using a private, bounded, removable user service. Legendary was
|
||||
absent; no dependency installer, runtime install, login, game operation or custom
|
||||
backend download was invoked. The exact plugin and loader digests are in the
|
||||
[redacted results](framey-installed-context-2026-10-26.json).
|
||||
|
||||
Local Chromium ran the actual Framey host and official frontend in an opaque
|
||||
iframe. The production receipt-bound bridges read the real Steam library and
|
||||
called the official ARM64 backend's static menu actions. About content and its
|
||||
tab list, Dependencies controls, Back, close/reopen, and About again passed.
|
||||
No dependency buttons were pressed. The real library counts matched the probe.
|
||||
|
||||
The optional localhost websocket was blocked by the test relay CSP; RSS was
|
||||
deliberately denied. Outer status/install/check replies were fixtures; incomplete
|
||||
outer extension/status and running-context replies are excluded from the plugin
|
||||
claim. The screenshot still used the catalog reason from before this follow-up.
|
||||
Dependency installation, login, game operations, RSS, websocket operations,
|
||||
shortcut changes, deployed headset HTTP and worn VR remain unverified.
|
||||
|
||||
## Cleanup and regression checks
|
||||
|
||||
The initial cleanup SSH timed out while the private Decky unit was deactivating.
|
||||
Recovery checked the matching lock owner, killed only that exact test unit, and
|
||||
removed its private base, unit file and archive. A fresh locked audit independently
|
||||
confirmed unloaded/inactive/PID 0 and no artifacts, then released its lock. Normal
|
||||
managed Decky/panel services stayed inactive; battery was 99 → 99. No power-setting
|
||||
action ran. Local relay and owned browser tab are closed.
|
||||
|
||||
- Full Python suite: 1,628 tests, 16 skipped, no failures; focused panel: 22 passed.
|
||||
- Framey action/running Node contracts passed, including true/false/unknown,
|
||||
malformed metadata, detached values, old rows and running-update preservation.
|
||||
- Both site test files passed. Generated docs and diff checks passed.
|
||||
- All 116 exact official archives passed the fake-Frame audit: 60 installed/removed,
|
||||
56 expected blocks; disposable project removed. Backend execution is stubbed in
|
||||
that harness. The fresh official store has 116 entries, no audit errors, and an
|
||||
unchanged ID/version/digest set.
|
||||
|
||||
No new works claim is made. Catalog totals remain **4 works / 49 partial / 52 broken
|
||||
/ 11 N/A**; only Junk-Store's verified scope and remaining limits change.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Legacy Decky frontend bridge
|
||||
|
||||
The exact official MusicControl 1.1.6 archive (`12.zip` in the test fixtures)
|
||||
has no `api_version` field and returns its initializer from a Decky IIFE.
|
||||
Framey now wraps that expression in an ES module inside the existing
|
||||
opaque-origin iframe. It passes a legacy `serverAPI` to the initializer.
|
||||
|
||||
Local Chromium ran the unchanged official frontend, including React effects,
|
||||
with all backend calls rejected and network access blocked. It emitted
|
||||
`framey:ready` without a JavaScript startup error during the observation window.
|
||||
This proves initialization, not playback, MPRIS access, or a useful populated UI.
|
||||
|
||||
The fake Frame installed, packed, checked the frontend bytes and removed this
|
||||
exact archive. Its synthetic Sample Panel also read a legacy keyword argument,
|
||||
wrote a setting, and read that setting through the modern API. This exercised
|
||||
the real HTTP → SSH → CDP path with a fake Decky backend. The RPC contract was
|
||||
checked against Decky's `handle_plugin_method_call_legacy`: it calls with a
|
||||
keyword object and returns `{success, result}`. Invalid argument shapes and
|
||||
unmanaged plugin IDs are rejected.
|
||||
|
||||
Verification: `python3 -m unittest discover -s tests -p 'test_framey*.py'`
|
||||
(21 passed), `node tests/page/framey_actions.mjs`, and
|
||||
`scripts/e2e.sh test_framey` (2 passed). No official Python backend code or
|
||||
headset state was changed by these checks. Real Frame backend behavior,
|
||||
controller input, playback and worn VR use remain unverified.
|
||||
@@ -0,0 +1,326 @@
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:23:08.939647+00:00",
|
||||
"theme": "Detached read-only Steam library and current-user context",
|
||||
"baselineMain": "3921e59",
|
||||
"hostFileSha256": "3f70e9769c6c95d39cfa329ccf3f822df85d411e9295bdf888508b4738679e9d",
|
||||
"realFrame": {
|
||||
"platform": "Frame OS 0.5.6 ARM64",
|
||||
"path": "SSH via lxso2; Steam SharedJSContext CDP",
|
||||
"lock": "/tmp/frame-test.lock atomic mkdir; matching owner released in finally after every probe",
|
||||
"apps": 1106,
|
||||
"games": 895,
|
||||
"compactBytesWithoutUser": 88451,
|
||||
"invalidRows": 0,
|
||||
"missingGameOverviews": 0,
|
||||
"currentUser": "17-digit string verified; value withheld",
|
||||
"mutations": "none; no wake, service, setting, screenshot or library changes; no device artifacts created",
|
||||
"limits": "read-only CEF projection, not an official backend or worn-VR test"
|
||||
},
|
||||
"browser": {
|
||||
"mode": "opaque iframe Chromium; detached real Frame snapshot replay; all backend/network actions denied",
|
||||
"attempted": 116,
|
||||
"initializerReady": 101,
|
||||
"failed": 15,
|
||||
"initializerGains": [
|
||||
46,
|
||||
53,
|
||||
66
|
||||
],
|
||||
"regressions": [],
|
||||
"focused": [
|
||||
{
|
||||
"id": 37,
|
||||
"name": "Discord Status",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "TypeError: saveInfos.map is not a function"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"set_account_id",
|
||||
"get_saveinfos"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"name": "Shotty",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"set_id_map_fronend"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Failed to set current user: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Error: Read-only compatibility audit: backend method blocked"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"set_current_user",
|
||||
"fetch_playtime_information",
|
||||
"statistics_for_last_two_weeks",
|
||||
"daily_statistics_for_period"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: Error: Read-only compatibility audit: live Steam API blocked"
|
||||
}
|
||||
],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 132,
|
||||
"name": "SuggestMe",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load config: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load library status: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load play next list: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load excluded games: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load filter presets: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load history: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load non-steam info: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load collections: Error: Read-only compatibility audit: backend method blocked"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"log_message",
|
||||
"get_config",
|
||||
"get_library_status",
|
||||
"get_play_next_list",
|
||||
"get_excluded_games",
|
||||
"get_filter_presets",
|
||||
"get_suggestion_history",
|
||||
"get_non_steam_games",
|
||||
"get_collections",
|
||||
"get_available_genres",
|
||||
"get_available_tags",
|
||||
"get_available_community_tags"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 134,
|
||||
"name": "Deck Progress Tracker",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_settings",
|
||||
"get_tag_statistics"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 135,
|
||||
"name": "Deck Shelves",
|
||||
"ready": false,
|
||||
"error": "Failed to resolve module specifier \"./chunks/pluginApi-B8sV7970.js\". Invalid relative url or base scheme isn't hierarchical.",
|
||||
"logs": [],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fakeFrame": {
|
||||
"attempted": 116,
|
||||
"installedRemoved": 60,
|
||||
"expectedBlockers": 56,
|
||||
"backendExecution": "stubbed",
|
||||
"cleanup": "private Compose containers/network/volumes removed"
|
||||
},
|
||||
"localBackends": {
|
||||
"timeUtc": "2026-10-26T02:18:51.848256+00:00",
|
||||
"verification": "local Chromium + actual Decky 3.3.0 amd64, detached real Frame SSH snapshot; no worn VR",
|
||||
"calls": [
|
||||
{
|
||||
"id": 46,
|
||||
"method": "set_account_id",
|
||||
"resultType": "dict",
|
||||
"resultCount": 2
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"method": "get_saveinfos",
|
||||
"resultType": "dict",
|
||||
"resultCount": 2
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "set_current_user",
|
||||
"resultType": "NoneType"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "statistics_for_last_two_weeks",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "fetch_playtime_information",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "daily_statistics_for_period",
|
||||
"resultType": "dict",
|
||||
"resultCount": 3
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "fetch_playtime_information",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "statistics_for_last_two_weeks",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"tag": "v3.3.0",
|
||||
"name": "PluginLoader",
|
||||
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
|
||||
},
|
||||
"legacyReadback": {
|
||||
"plugin": 46,
|
||||
"method": "get_saveinfos",
|
||||
"replyType": 1,
|
||||
"success": true,
|
||||
"saveInfoCount": 0
|
||||
}
|
||||
},
|
||||
"uiChecks": {
|
||||
"Steamback": "Supported games panel, actual private backend set_account_id and get_saveinfos returned successfully; empty isolated Steam save data",
|
||||
"PlayTime": "Statistics panel (empty database) and routed General settings/tabs rendered; actual private backend selected user and returned statistics",
|
||||
"Deck Settings": "Installed game picker rendered real Frame install-folder/name snapshot; search modal rendered",
|
||||
"Shotty": "Initializer repaired; actual Decky 3.3.0 backend fails importing unused click.get_app_dir: ModuleNotFoundError; no screenshot methods invoked"
|
||||
},
|
||||
"notVerified": [
|
||||
"Real ARM64 official backends in this theme",
|
||||
"Worn VR/controllers/keyboard",
|
||||
"Live MobX or running-game observations",
|
||||
"Steamback backups/restores and automatic notifications",
|
||||
"PlayTime automatic recording, existing databases or account switches while panel remains open",
|
||||
"Deck Settings remote reports/proxy fetch and settings changes",
|
||||
"Shotty screenshot aggregation/rescue"
|
||||
],
|
||||
"cleanup": {
|
||||
"localContainer": "stopped/removed; docker ps -a matching name empty",
|
||||
"headsetLockReleased": true,
|
||||
"httpHelpers": "three task-local HTTP servers stopped"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": 37,
|
||||
"name": "Discord Status",
|
||||
"version": "1.5.0",
|
||||
"sha256": "00a45dd919abb6e050685e1e408ce8023b9c4a09d6e91ba528d50aed2a63bf45"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"version": "1.0.0-1",
|
||||
"sha256": "ed5f2fbb47cdfc3aa9a4349a139ab20c29976117a3be0de7e8a588bb265ee7f3"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"name": "Shotty",
|
||||
"version": "0.1.0",
|
||||
"sha256": "ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"version": "3.2.5",
|
||||
"sha256": "10fc3beea48991c77cb02867e919b618d4cd6d5e9c6a6d0ccd5266c13214039f"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"version": "1.2.6",
|
||||
"sha256": "23c2a933a6883e2622f0184c33a76ce4a4e88c0a7dcd730f9bab71b8c3702132"
|
||||
}
|
||||
],
|
||||
"validation": {
|
||||
"serverTests": 1617,
|
||||
"serverSkips": 16,
|
||||
"siteTests": 18,
|
||||
"fakeFramePanel": "2 targeted panel/store flows passed; private Compose resources removed",
|
||||
"focusedFrameyTests": 40
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# Framey detached Steam library context — 2026-10-26
|
||||
|
||||
Framey now reads a bounded library snapshot before importing official frontends
|
||||
that reference `appStore`, `collectionStore` or `m_CurrentUser`. It supplies app
|
||||
IDs/names/types/game IDs, sorting/playtime scalars, the actual all-games
|
||||
collection and the current public Steam ID. It does not copy credentials,
|
||||
balances, account names, filesystem paths or executable Steam objects.
|
||||
|
||||
The projection is a fixed, parameterless `GetLibrarySnapshot` read through the
|
||||
existing installed-store-plugin boundary. The parent checks the iframe source,
|
||||
session, method and empty argument list. The host validates rows before exposing
|
||||
detached Maps and lookup methods. Plugin patches remain local to that iframe.
|
||||
Missing or invalid data leaves the globals unavailable and logs a clear warning;
|
||||
there is no invented account or empty-library fallback. Reopening the panel
|
||||
refreshes the snapshot. Live MobX observations, account-switch notifications and
|
||||
running-game events are outside this change.
|
||||
|
||||
Library-sized backend arguments now have a 256 KiB UTF-8 JSON budget. The old
|
||||
8 KiB ceiling rejected normal app-name maps. Payloads travel as quoted literals
|
||||
on SSH stdin, avoiding operating-system limits on a single command argument.
|
||||
Store receipt ownership, method validation and legacy keyword envelopes still
|
||||
apply. The library read has a separate 2 MiB / 10,000-app limit; other Steam
|
||||
reads retain their 256 KiB response limit.
|
||||
|
||||
## Observations
|
||||
|
||||
| Plugin | Verified behavior | Remaining limit |
|
||||
| --- | --- | --- |
|
||||
| Steamback 1.0.0-1 | Panel opens; account setup and empty save-list reads succeed through actual Decky 3.3.0 amd64 | Private container had no Steam saves. ARM64 backend, backup/restore and automatic hooks unverified. Partial. |
|
||||
| PlayTime 3.2.5 | Actual backend selects the user, returns empty statistics; statistics panel and routed settings/tabs render | No sessions were created. ARM64 backend, existing databases, live observations and automatic recording unverified. Partial. |
|
||||
| Deck Settings 1.2.6 | Installed game picker renders the real Frame folder/name snapshot; search modal opens | Remote reports/proxy fetch, settings changes and ARM64 backend unverified. Partial. |
|
||||
| Shotty 0.1.0 | Library-based initializer now completes | Exact backend fails importing `click.get_app_dir`: `ModuleNotFoundError: No module named 'click'`. Archive does not bundle it. Broken. |
|
||||
| Discord Status 1.5.0 | Detached overview lookups available | Live running-app observations and artwork helpers remain absent; Discord activity unverified. Broken. |
|
||||
|
||||
Exact plugin versions/digests, runtime digest, scoped calls and counts are in the
|
||||
[redacted observations](framey-library-context-2026-10-26.json). The canonical
|
||||
table/store labels use the same digest-scoped JSON. None of these plugins is
|
||||
promoted to **works** by this pass.
|
||||
|
||||
## Verification boundaries
|
||||
|
||||
- **Real Frame SSH:** atomic `/tmp/frame-test.lock` acquisition for every probe,
|
||||
matching-owner release in `finally`. Frame OS 0.5.6 ARM64 Steam CEF returned
|
||||
1,106 apps and 895 game IDs; no missing collection members or invalid rows.
|
||||
The compact library-only response was 88,451 bytes. Current-user field shape
|
||||
was separately inspected. Values remain private; only aggregate counts are
|
||||
published. No wake, power/service changes or device artifacts were needed.
|
||||
- **Local Chromium:** production `headset.html`, current host, opaque iframe and
|
||||
unmodified digest-checked official frontends. Library/user and install-folder
|
||||
data were detached snapshots read from the Frame, replayed locally. This is
|
||||
not a deployed headset HTTP or worn-VR test.
|
||||
- **Actual local backends:** published Decky 3.3.0 amd64 executable, private
|
||||
unprivileged container, network disabled, capabilities dropped, no devices,
|
||||
host home, saves or Steam files mounted. Only account setup within private
|
||||
plugin data and read methods were allowed. Backup, restore, aggregation,
|
||||
screenshot rescue and game actions were denied. Shotty's import failure was
|
||||
observed from the runtime log before invoking screenshot methods.
|
||||
- **Catalog regression:** 116 exact frontends attempted, 101 initializer-ready
|
||||
and 15 failed, compared with 98 ready before this theme. Steamback, Shotty and
|
||||
PlayTime gained initializer readiness; no previous ready state regressed.
|
||||
Backend/network calls stayed denied in this sweep. Readiness alone does not
|
||||
establish useful operation.
|
||||
- **Fake Frame:** 116 archive/transport cases; 60 install/remove cycles and 56
|
||||
expected blockers. Official backend execution was stubbed. Its private
|
||||
Compose containers, network and volumes were removed.
|
||||
|
||||
The browser audit accepts `--library-snapshot /private/library.json` to reproduce
|
||||
the detached replay. This flag does not convert the audit into live Steam or
|
||||
backend evidence. Never commit personal library snapshots or account values.
|
||||
|
||||
No worn-VR, physical controller, keyboard, automatic recording or backup claim
|
||||
is made. Further device backend checks still require the shared hardware lock.
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26",
|
||||
"plugin": {
|
||||
"id": 125,
|
||||
"name": "Decky Localsend",
|
||||
"officialArchiveStatus": "x86_64; blocked; no substitutions or safeguard changes"
|
||||
},
|
||||
"source": {
|
||||
"url": "https://github.com/MoYoez/LocalSend-Go",
|
||||
"revision": "66edc509a28b9f877e7374504892dcbc653a4da4",
|
||||
"license": "MIT",
|
||||
"licenseUrl": "https://github.com/MoYoez/LocalSend-Go/blob/66edc509a28b9f877e7374504892dcbc653a4da4/LICENSE",
|
||||
"pluginLicense": "BSD-3-Clause; distinct from the MIT helper",
|
||||
"officialElfBuildInfo": "Go 1.25.7, CGO_ENABLED=0, GOARCH=amd64; no embedded VCS revision. Exact source correspondence unproven."
|
||||
},
|
||||
"build": {
|
||||
"toolchain": "go1.25.7",
|
||||
"environment": {
|
||||
"GOTOOLCHAIN": "go1.25.7",
|
||||
"CGO_ENABLED": "0",
|
||||
"GOOS": "linux",
|
||||
"GOARCH": "arm64"
|
||||
},
|
||||
"command": "go build -trimpath -o localsend-core .",
|
||||
"elfMachine": 183,
|
||||
"architecture": "aarch64",
|
||||
"staticallyLinked": true,
|
||||
"sha256": "e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9",
|
||||
"rebuildSha256Matches": true
|
||||
},
|
||||
"realFrame": {
|
||||
"os": "Frame OS 0.5.6 ARM64",
|
||||
"sharedLockOwner": "kp0z857q58-live-steam-reads-20261026",
|
||||
"helpExitCode": 0,
|
||||
"officialBackendFlagsAllRecognized": true,
|
||||
"helpFlags": [
|
||||
"-doNotMakeSessionFolder",
|
||||
"-log",
|
||||
"-scanTimeout",
|
||||
"-skipNotify",
|
||||
"-useAlias",
|
||||
"-useAutoSave",
|
||||
"-useAutoSaveFromFavorites",
|
||||
"-useConfigPath",
|
||||
"-useDefaultUploadFolder",
|
||||
"-useDownload",
|
||||
"-useHttp",
|
||||
"-useMultcastAddress",
|
||||
"-useMultcastPort",
|
||||
"-usePin",
|
||||
"-useReferNetworkInterface",
|
||||
"-useWebOutPath"
|
||||
],
|
||||
"runtimeExitCode": 0,
|
||||
"isolation": "unshare -Urn; loopback and private veth pair only; no external interface/default route",
|
||||
"receiving": "-useAutoSave=false and -useAutoSaveFromFavorites=false; no upload/transfer requests",
|
||||
"notifications": "-skipNotify",
|
||||
"readOnlyRequest": "GET http://127.0.0.1:53317/api/localsend/v2/info",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"protocolVersion": "2.0",
|
||||
"deviceType": "headless",
|
||||
"responseKeys": [
|
||||
"alias",
|
||||
"deviceModel",
|
||||
"deviceType",
|
||||
"download",
|
||||
"fingerprint",
|
||||
"version"
|
||||
],
|
||||
"aliasMatches": true
|
||||
},
|
||||
"cleanup": {
|
||||
"helperExited": true,
|
||||
"filesCreated": [
|
||||
"config.yaml",
|
||||
"helper.log"
|
||||
]
|
||||
},
|
||||
"privateDirectoryAbsent": true,
|
||||
"servicesInstalledOrStarted": false,
|
||||
"powerChanges": false,
|
||||
"sharedLockReleasedAfterTests": true,
|
||||
"independentReacquireAudit": "Deferred: another job had acquired the shared lock; it was left untouched. The helper process exited and TemporaryDirectory removal completed within the original locked probe."
|
||||
},
|
||||
"failedProbe": {
|
||||
"interface": "loopback only",
|
||||
"result": "Upstream rejects lo as a discovery interface and exits. Repeated with a veth pair wholly inside the private namespace; no source changes."
|
||||
},
|
||||
"unverified": [
|
||||
"Exact source correspondence to shipped official ELF",
|
||||
"Unmodified official Python backend invoking this ARM64 artifact",
|
||||
"HTTPS, discovery on real network, pairing and file transfers",
|
||||
"Framey frontend/proxy protocol integration",
|
||||
"Worn VR/controller/keyboard"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# LocalSend ARM64 build and isolated Frame probe
|
||||
|
||||
The official Decky Localsend archive remains **broken/blocked**: its helper is
|
||||
x86_64. A source rebuild is now proven to execute on the ARM64 Frame and answer
|
||||
the LocalSend 2.0 info endpoint. This is a core runtime result, not a working
|
||||
official-plugin or file-transfer claim. [Machine-readable evidence](framey-localsend-arm64-2026-10-26.json)
|
||||
records the exact scope and cleanup.
|
||||
|
||||
## Reproduce the build
|
||||
|
||||
The helper is MIT-licensed; the separate official Python plugin is BSD-3-Clause.
|
||||
Use the pinned helper source and retain its license for any redistribution.
|
||||
No binary is redistributed in Frame Control, and no installer digest or
|
||||
architecture safeguard was changed.
|
||||
|
||||
```sh
|
||||
framey_build_dir=$(mktemp -d)
|
||||
git clone https://github.com/MoYoez/LocalSend-Go "$framey_build_dir/source"
|
||||
git -C "$framey_build_dir/source" checkout --detach 66edc509a28b9f877e7374504892dcbc653a4da4
|
||||
cd "$framey_build_dir/source"
|
||||
env GOTOOLCHAIN=go1.25.7 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
|
||||
go build -trimpath -o "$framey_build_dir/localsend-core" .
|
||||
sha256sum "$framey_build_dir/localsend-core"
|
||||
```
|
||||
|
||||
Two builds produced the same static aarch64 ELF SHA-256:
|
||||
`e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9`.
|
||||
The official ELF uses Go 1.25.7 too, but embeds no VCS revision, so the exact
|
||||
source correspondence is still unproven. The rebuilt helper recognizes every
|
||||
CLI flag supplied by the official Python backend.
|
||||
|
||||
## Real Frame scope
|
||||
|
||||
Tests ran under the normal atomic `/tmp/frame-test.lock` with a private temporary
|
||||
directory. `-h` exited before configuration or networking. For the health probe,
|
||||
`unshare -Urn` created a private user/network namespace with loopback and a veth
|
||||
pair entirely inside it: no external interface or default route. The veth used
|
||||
the documentation-only `192.0.2.1/24` address. Upstream rejects a loopback-only
|
||||
discovery interface, which is why the second isolated attempt used a veth.
|
||||
|
||||
The helper used a private config/upload path, `-skipNotify`,
|
||||
`-useAutoSave=false`, `-useAutoSaveFromFavorites=false`, `-useHttp=true`,
|
||||
`-useReferNetworkInterface probe0` and `-scanTimeout 1`. The test client sent only
|
||||
`GET /api/localsend/v2/info` over loopback; it returned version `2.0`, device type
|
||||
`headless` and the private test alias. No upload, transfer, pairing or external
|
||||
discovery request ran. The helper exited; its private config/log and executable
|
||||
directory were removed; no service or power setting changed. The lock was
|
||||
released. An independent cleanup reacquisition found another job's lock and
|
||||
left it alone; cleanup success comes from the original probe's process wait and
|
||||
temporary-directory exit, not that deferred audit.
|
||||
|
||||
Before enabling an ARM64 official package, verify the unmodified Python backend
|
||||
and frontend/proxy protocol with this source revision, then HTTPS, discovery and
|
||||
an explicitly safe private transfer. The official store must publish a matching
|
||||
ARM64 archive or Frame Control must gain a separately reviewed, digest-scoped
|
||||
build/package path. Compiling the core does not justify accepting the current
|
||||
x86 archive or marking the full plugin works.
|
||||
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"timeUtc": "2026-10-26T03:29:05.328492+00:00",
|
||||
"baseCommit": "c89ddff",
|
||||
"scope": "Finite Framey-local DFL component/CSS registry and explicit unsupported optional UI-audio export",
|
||||
"upstreamReferences": [
|
||||
"https://github.com/SteamDeckHomebrew/decky-frontend-lib/blob/main/src/webpack.ts",
|
||||
"https://github.com/SteamDeckHomebrew/decky-frontend-lib/blob/main/src/class-mapper.ts"
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"id": 16,
|
||||
"name": "Audio Loader",
|
||||
"version": "1.6.1",
|
||||
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
|
||||
"status": "broken",
|
||||
"reason": "This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel.",
|
||||
"browser": {
|
||||
"id": 16,
|
||||
"name": "Audio Loader",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_AudioPlaybackManager')",
|
||||
"logs": [
|
||||
{
|
||||
"kind": "warn",
|
||||
"message": "Resolve failed: [object Object] promise [object Promise]"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"store_read"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"name": "SteamGridDB",
|
||||
"version": "1.8.1",
|
||||
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
|
||||
"status": "broken",
|
||||
"reason": "Local module mapping is available; frontend still requires the Steam LibraryContextMenu implementation, which has no target in the isolated Framey panel.",
|
||||
"browser": {
|
||||
"id": 36,
|
||||
"name": "SteamGridDB",
|
||||
"ready": false,
|
||||
"error": "Cannot convert undefined or null to object",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"name": "TabMaster",
|
||||
"version": "2.15.1",
|
||||
"sha256": "e08333859a32b0c177b8e9a7959c5eeeafe38f946578f7f1d161c721f0e05da5",
|
||||
"status": "partial",
|
||||
"reason": "Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified.",
|
||||
"browser": {
|
||||
"id": 61,
|
||||
"name": "TabMaster",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c TabMaster %c ERROR %c background: #ff6d05; color: black; background: #c70808; color: black; background: transparent; Could not find mobx.reaction"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"log_message"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 84,
|
||||
"name": "Junk-Store",
|
||||
"version": "1.1.13",
|
||||
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1",
|
||||
"status": "broken",
|
||||
"reason": "Local CSS lookup fixes the loading view crash. Actual Decky 3.3.0 amd64 startup/static InitActions return a script execution/JSON error; the published scripts are not executable after extraction. Store install/login/game launch and ARM64 remain unverified.",
|
||||
"browser": {
|
||||
"id": 84,
|
||||
"name": "Junk-Store",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"fetch_rss_feed",
|
||||
"execute_action"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"name": "DeckSP",
|
||||
"version": "1.1.3",
|
||||
"sha256": "d6cbedcd64430e1f94a2b12caf936ba270f1ceafe77c6252808ff39c88640b40",
|
||||
"status": "broken",
|
||||
"reason": "Optional UI-audio lookup succeeds, but the published frontend raises \"Cannot access PluginManager before initialization\" when Steam MobX reaction lookup is unavailable. JamesDSP installation/audio routing and ARM64 remain unverified.",
|
||||
"browser": {
|
||||
"id": 102,
|
||||
"name": "DeckSP",
|
||||
"ready": false,
|
||||
"error": "Cannot access 'PluginManager' before initialization",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"fullCatalogFrontend": {
|
||||
"attempted": 116,
|
||||
"ready": 102,
|
||||
"failed": 14,
|
||||
"previousReady": 101,
|
||||
"newlyReady": [
|
||||
61
|
||||
],
|
||||
"regressions": [],
|
||||
"backendAndNetworkDenied": true,
|
||||
"library": "Private detached real Frame snapshot replay, not live Steam or backend verification"
|
||||
},
|
||||
"focusedBrowser": {
|
||||
"host": "Actual ui/headset.html, React 18.3.1, opaque allow-scripts iframe, exact official bundles",
|
||||
"installedPluginAndStatusResponses": "Fixture-shaped; no headset HTTP endpoint exercised",
|
||||
"backend": "Actual official Decky 3.3.0 amd64 in unprivileged network-none container; no host audio/device/data mounts",
|
||||
"deckySha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
|
||||
"allowedMethods": {
|
||||
"TabMaster": [
|
||||
"log_message",
|
||||
"get_setting",
|
||||
"get_users_dict",
|
||||
"get_tags"
|
||||
],
|
||||
"Junk-Store": [
|
||||
"execute_action only init/InitActions"
|
||||
]
|
||||
},
|
||||
"checks": [
|
||||
"TabMaster QAM renders; library editing controls remain disabled",
|
||||
"TabMaster Open Docs -> Overview renders",
|
||||
"TabMaster Filters tab renders",
|
||||
"Back to plugin returns to QAM",
|
||||
"Junk-Store static InitActions via real backend returns Type Error; not promoted"
|
||||
],
|
||||
"denied": [
|
||||
"External RSS/network",
|
||||
"Other execute_action arguments",
|
||||
"Login, installation, game launch, library patching, audio playback"
|
||||
],
|
||||
"directBackendReads": {
|
||||
"TabMaster.get_setting": "default string returned",
|
||||
"TabMaster.get_users_dict": {},
|
||||
"TabMaster.get_tags": [],
|
||||
"Junk-Store.scriptMode": "0644, Python shebang present"
|
||||
}
|
||||
},
|
||||
"headset": {
|
||||
"newPluginDeviceExecution": false,
|
||||
"lockProbe": "Normal atomic lock acquired/released for read-only dependency availability probe; no service or power changes",
|
||||
"ARM64OfficialBackend": "Not exercised in this theme",
|
||||
"wornVR": false
|
||||
},
|
||||
"verification": {
|
||||
"frameyTests": 42,
|
||||
"fullServerTests": {
|
||||
"count": 1620,
|
||||
"skipped": 16,
|
||||
"result": "OK"
|
||||
},
|
||||
"siteTestFilesPassed": 2,
|
||||
"fakeFrame": {
|
||||
"attempted": 116,
|
||||
"installedRemoved": 60,
|
||||
"expectedBlockers": 56,
|
||||
"backendExecuted": false,
|
||||
"privateProject": "framey-catalog-6456d3d939",
|
||||
"projectRemoved": true,
|
||||
"baseCommit": "c89ddff"
|
||||
}
|
||||
},
|
||||
"storeRefresh": {
|
||||
"timeUtc": "2026-10-26T03:29:46.606326+00:00",
|
||||
"currentCount": 116,
|
||||
"pinnedCount": 116,
|
||||
"unchanged": true,
|
||||
"changedIds": []
|
||||
},
|
||||
"postMainMerge": {
|
||||
"mainCommit": "38c388f",
|
||||
"mergedPRs": [
|
||||
286,
|
||||
287,
|
||||
288
|
||||
],
|
||||
"frameyTests": 43,
|
||||
"fullCatalogFrontend": {
|
||||
"attempted": 116,
|
||||
"ready": 103,
|
||||
"failed": 13,
|
||||
"additionalReadyFromUpstream": [
|
||||
135
|
||||
],
|
||||
"regressionsComparedWithPreMergeAudit": []
|
||||
},
|
||||
"catalog": {
|
||||
"works": 1,
|
||||
"partial": 50,
|
||||
"broken": 54,
|
||||
"na": 11
|
||||
},
|
||||
"fullServerTests": {
|
||||
"count": 1621,
|
||||
"skipped": 16,
|
||||
"result": "OK"
|
||||
}
|
||||
},
|
||||
"cleanup": {
|
||||
"focusedBackendContainerAbsent": true,
|
||||
"loopback18770Closed": true,
|
||||
"loopback18771Closed": true,
|
||||
"fakeProjectAndVolumesRemoved": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# Framey-local module lookup: official plugin checks
|
||||
|
||||
TabMaster 2.15.1 now renders its QAM and bundled documentation in local Chromium.
|
||||
Opening its docs, selecting Overview and Filters, and returning to the panel
|
||||
passed. Its status moves from **broken to partial**: library editing controls
|
||||
remain disabled because Steam login, live MobX reactions and library-page
|
||||
patches are unavailable. This is not working library-tab customization.
|
||||
|
||||
The [observations](framey-module-lookup-2026-10-26.json) pin each tested official
|
||||
archive and the actual Decky 3.3.0 amd64 binary. The complete current official
|
||||
store was refreshed: all 116 IDs, versions and digests match the catalog.
|
||||
|
||||
| Plugin | Result | Remaining limitation |
|
||||
| --- | --- | --- |
|
||||
| TabMaster 61 | Partial: QAM and documentation navigation render | Steam library editing unavailable; ARM64 and worn VR unverified |
|
||||
| Junk-Store 84 | Loading and backend-error views render without the CSS lookup crash | Actual startup/static InitActions return an execution/JSON error; extracted scripts lack executable permission; remains broken |
|
||||
| SteamGridDB 36 | Module mapping is callable | Still needs Steam's LibraryContextMenu implementation; remains broken |
|
||||
| DeckSP 102 | Optional UI-audio lookup succeeds | Published bundle accesses PluginManager before initialization when MobX reaction is absent; remains broken |
|
||||
| Audio Loader 16 | Optional UI-audio lookup succeeds | Still requires Steam's mutable audio playback store; remains broken |
|
||||
|
||||
## What ran
|
||||
|
||||
The host exposes a finite registry of its own components and styled CSS maps,
|
||||
with DFL module/export/class lookup and mapping semantics. Unknown matches return
|
||||
undefined or an empty mapping. An optional audio export explicitly reports
|
||||
unavailable; invoking playback throws. No Steam webpack cache, function-source
|
||||
signature, audio store or library patch was manufactured.
|
||||
|
||||
The full exact-bundle browser pass ran with backend and network calls denied.
|
||||
It completed 102 initializers and failed 14, versus 101/15 before this theme.
|
||||
Initializer readiness alone does not establish rendered or useful behavior.
|
||||
After integrating main’s #286/#287/#288, all 116 were retried: 103 initializers
|
||||
completed and 13 failed. The additional completion is Deck Shelves from #286;
|
||||
no previously completed initializer regressed.
|
||||
|
||||
Focused checks used actual `ui/headset.html`, React 18.3.1 and opaque
|
||||
`allow-scripts` iframes. Status/installed-plugin responses were fixture-shaped,
|
||||
and the private detached library snapshot from the earlier Frame SSH check was
|
||||
replayed. The relay allowed only TabMaster log/settings reads and Junk-Store's
|
||||
static `init/InitActions` action. TabMaster's official backend returned the
|
||||
default setting, empty users dictionary and empty tags. Junk-Store returned a
|
||||
structured Type Error, which its panel displayed. RSS/network, login, installs,
|
||||
launch actions and other backend methods were denied.
|
||||
|
||||
The unmodified Python backends ran in actual Decky 3.3.0 inside a disposable
|
||||
unprivileged amd64 container with no network, host audio, devices or user-data
|
||||
mounts. This bypasses Steam CEF and is not a headset HTTP/RPC check. The normal
|
||||
headset lock was briefly acquired and released for read-only dependency/service
|
||||
availability; no official plugin ran there in this theme. No ARM64 or worn-VR
|
||||
result is claimed.
|
||||
|
||||
## Regression checks
|
||||
|
||||
- 1,621 server tests passed, 16 skipped; all 43 focused Framey tests passed
|
||||
after integrating the latest main.
|
||||
- Both site test files passed.
|
||||
- All 116 exact archives passed the fake-Frame audit: 60 install/remove flows
|
||||
and 56 expected blockers. Official backend execution remained stubbed.
|
||||
- The unique fake-Frame project and its volumes were removed. The focused
|
||||
backend container and both loopback helpers were removed after the checks.
|
||||
|
||||
The catalog and generated compatibility table use the same status/reasons as
|
||||
both store views. The result is **1 works / 50 partial / 54 broken / 11 N/A** (including main’s independent Deck Shelves improvement);
|
||||
the majority goal remains open.
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-25",
|
||||
"source": "https://plugins.deckbrew.xyz/plugins",
|
||||
"method": "Downloaded exact current official store versions; local SHA-256 matched each store hash; inspected ZIP plugin.json and dist/index.js without running plugin code.",
|
||||
"limit": "Static inspection on PC. Prior backend load results are documented in docs/decky-frame.md; VR visibility and controller input remain unverified.",
|
||||
"plugins": [
|
||||
{
|
||||
"storeId": 7,
|
||||
"name": "CSS Loader",
|
||||
"storeVersion": "2.1.3",
|
||||
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 14,
|
||||
"name": "ProtonDB Badges",
|
||||
"storeVersion": "1.3.4",
|
||||
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
|
||||
"manifestApiVersion": 2,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 16,
|
||||
"name": "Audio Loader",
|
||||
"storeVersion": "1.6.1",
|
||||
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [
|
||||
"_root"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 18,
|
||||
"name": "Bluetooth",
|
||||
"storeVersion": "3.1.1",
|
||||
"sha256": "f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 21,
|
||||
"name": "PowerTools",
|
||||
"storeVersion": "2.0.3",
|
||||
"sha256": "47614f53b8c538c4caa15f89a01e4ab106fa328e89f78545bacb3166d104d964",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [
|
||||
"root",
|
||||
"global-dfl"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 23,
|
||||
"name": "Animation Changer",
|
||||
"storeVersion": "1.3.2",
|
||||
"sha256": "f2c62b90ca60d8a80b6d0f75d8027552b1509c7a05842c6f4a24a9072846d133",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 26,
|
||||
"name": "HLTB for Deck",
|
||||
"storeVersion": "2.0.12",
|
||||
"sha256": "bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 36,
|
||||
"name": "SteamGridDB",
|
||||
"storeVersion": "1.8.1",
|
||||
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [
|
||||
"debug"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 50,
|
||||
"name": "Storage Cleaner",
|
||||
"storeVersion": "1.4.0",
|
||||
"sha256": "1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 68,
|
||||
"name": "Wine Cellar",
|
||||
"storeVersion": "0.2.1",
|
||||
"sha256": "7e077b933c234414d172b5c6d1c804b026bf39fa4e18e6663495a282637031e8",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [
|
||||
"debug"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 82,
|
||||
"name": "MagicPods",
|
||||
"storeVersion": "2.0.21",
|
||||
"sha256": "abe2eef5788cb3ff6e73fcb9783e30a54b127e28c761a9bf99e64ab5fc4be9ab",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"timeUtc": "2026-10-25T23:35:22.022586+00:00",
|
||||
"method": "Real Frame ARM64 Decky 3.3.0, isolated removable user service; explicit read-only plugin methods only",
|
||||
"plugins": [
|
||||
{
|
||||
"id": 14,
|
||||
"version": "1.3.4",
|
||||
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
|
||||
"method": "get_setting",
|
||||
"result": {
|
||||
"result": "default"
|
||||
},
|
||||
"errors": [
|
||||
"Transient CEF reconnect during Decky frontend injection; succeeded on retry."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 123,
|
||||
"version": "1.2.3",
|
||||
"sha256": "9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e",
|
||||
"method": "get_settings",
|
||||
"result": {
|
||||
"keys": [
|
||||
"enable_notification_antiflood",
|
||||
"enable_notifications",
|
||||
"gateway_api_key",
|
||||
"gateway_url",
|
||||
"refresh_interval_seconds",
|
||||
"show_history",
|
||||
"show_regions",
|
||||
"show_trending_games"
|
||||
],
|
||||
"defaultRefreshInterval": 120
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"deckyService": "inactive",
|
||||
"panelService": "inactive"
|
||||
},
|
||||
"service": {
|
||||
"created": true
|
||||
},
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"passed": true,
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
99
|
||||
],
|
||||
"deckyService": "inactive",
|
||||
"panelService": "inactive"
|
||||
},
|
||||
"lockReleased": true,
|
||||
"cleanupErrors": []
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Framey receipt guard, unworn Frame exercise (26 October 2026)
|
||||
|
||||
The bounded `scripts/framey-vr-check.py --exercise` runner acquired `/tmp/frame-test.lock` with its own random owner before reading the real Frame. Its 05:26 UTC run used source commit `c2f94c242dac670e18259193311a7375f5a87942`. Preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. No wearer was confirmed.
|
||||
|
||||
The temporary Frame Control dashboard overlay became visible to OpenVR. The stereo compositor image changed, and the `/dev/video99` mirror produced a frame. The sample settings panel displayed its stored off value, accepted a trusted synthetic OpenVR pointer event, saved on through the real Decky Python backend, read the setting from disk, and showed on after a fresh iframe reopen. The quick action read on. A native slider reached 50. Text entry used CDP after OpenVR focus. The runner exited 0 with `input.passed=true` and `compositorChanged=true`.
|
||||
|
||||
The private capture files remain outside the repository at `/tmp/frame-pr301-unworn-vr-fixed/`. The before/after stereo SHA-256 values were `1683bf1dab70c1a0a1e8f313c8317dc04f992070b5b0de232de9008700130711` and `684a27eecf7bcbbc5308092ddbb1517c4596fb283e061e73dd6bb7f025954e8b`; the mirror frame SHA-256 was `5939149ea9c85fd2d86d3f8879c348f006c421a386bd66a93fe`. Room pixels are not committed.
|
||||
|
||||
An earlier locked run at 05:24 UTC exposed a missing SHA-256 in the runner's temporary sample receipt: the new guard correctly asked to reopen the panel. Commit `c2f94c2` gives that sample a content digest; the second run passed. Both runs reported no cleanup errors, restored `power.pauseCompositorOnStandby=false` and `power.turnOffScreensTimeout=5`, removed their power recovery timers, and released only their own locks. Battery remained 99%.
|
||||
|
||||
This is an **unworn sample-plugin result**. It does not prove wearer-visible pixels, physical controller input, native scrolling (the attempted scroll offset stayed 0), VR keyboard behavior, comfort, or any official Decky plugin's VR panel or backend. The hourly worn check remains enabled.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,101 @@
|
||||
# Running-game context and Discord Status, 2026-10-26
|
||||
|
||||
Framey now projects running apps and the selected main app from Steam CEF into
|
||||
detached `SteamUIStore` / `DFL.Router` getters. It relays actual Steam lifetime
|
||||
callbacks while the panel is open. Public Steam CDN capsule URLs are projected
|
||||
for running apps; local artwork paths and URL query data never cross the bridge.
|
||||
The [machine-readable evidence](framey-running-context-2026-10-26.json) records
|
||||
the exact archives, fixtures, browser observations and cleanup results.
|
||||
|
||||
| Plugin / scope | Result | Verification |
|
||||
| --- | --- | --- |
|
||||
| Discord Status 1.5.0 | **Broken → partial.** Disconnected panel renders; activity construction accepts the synthetic running game without the previous missing-store/artwork errors. | Exact unmodified official frontend in opaque Chromium iframe; backend/network denied. Actual official `is_connected` returns false in real Decky 3.3.0 ARM64, with no Discord IPC present. |
|
||||
| Pause Games | **Partial.** Running-list initialization reaches `pid_from_appid` for the synthetic game. | Local Chromium fixture; all backend calls, including settings writes and process actions, denied. |
|
||||
| Notebook | **Works for its previously verified default notebook scope.** Per-game context is now supplied, but real per-game selection remains unverified. | Synthetic game causes the official frontend to request `loadPage`; backend calls denied in this pass. Earlier default save/reopen ARM64 evidence remains separate. |
|
||||
| PlayTime | **Partial.** Running snapshots do not satisfy its private MobX observation. | Local Chromium explicitly logs that its running-app observer is unavailable. No automatic tracking claim. |
|
||||
| 14 other focused panels | Labels retained. Initialization alone does not establish backend functionality. | Exact frontends with synthetic nonempty running/library snapshots and backend/network denied; Game Sync still stops on the denied `get_config` read. |
|
||||
|
||||
The bridge addresses a shared dependency detected in 34 official frontend
|
||||
bundles, including hardware-inapplicable plugins whose installation remains
|
||||
blocked. This is infrastructure coverage, not 34 working plugins. Catalog after
|
||||
this pass: **3 works / 49 partial / 53 broken / 11 N/A**.
|
||||
|
||||
## Real Frame checks and cleanup
|
||||
|
||||
Every device probe acquired the atomic `/tmp/frame-test.lock` and released only
|
||||
its matching owner. Read-only probes verified the actual RunningApps and
|
||||
MainRunningApp getter implementations, the lifetime API's object with an
|
||||
`unregister()` method, and a public `https://shared.steamstatic.com` capsule URL.
|
||||
|
||||
A private removable user service ran the published Decky 3.3.0 ARM64 executable
|
||||
and the exact unmodified Discord Status 1.5.0 store archive. Production
|
||||
`framey_panel.call_method` returned false from `is_connected`. Production
|
||||
`poll_running` verified two empty running-list reads, an actual lifetime
|
||||
subscription and explicit close. The device had no running apps: **nonempty
|
||||
reads and launch/exit callbacks were not observed on hardware**. Decky startup
|
||||
reinjected its frontend/restarted Steam CEF. No plugin game, power, audio or process-control
|
||||
action was invoked, and no Discord connection/activity mutation was allowed.
|
||||
|
||||
After close, CEF reported zero sessions and a null timer. The test service,
|
||||
unit and private base were removed. A fresh independently locked audit read
|
||||
`LoadState=not-found`, `ActiveState=inactive`, `MainPID=0`, absent paths and zero
|
||||
CEF sessions/timers. Battery stayed **99 → 99**; normal Decky stayed failed and
|
||||
the normal panel service stayed inactive. No headset wipe or system service
|
||||
installation was used.
|
||||
|
||||
## Local verification and limits
|
||||
|
||||
- Full current official store fetch: 116 IDs/versions/digests unchanged at
|
||||
2026-10-26 05:18:22 UTC.
|
||||
- Fake Frame: 116 transport cases, 60 install/remove cycles and 56 expected
|
||||
blockers; private Compose project and volumes removed. Plugin execution was
|
||||
stubbed.
|
||||
- Full local Chromium sweep: 103 of 116 initializer-ready with explicitly
|
||||
synthetic library/running data. No backend/network request was allowed.
|
||||
The focused Discord Status observation lasted eight seconds and constructed
|
||||
`Test Running Game` activity; the visible UI correctly remained disconnected.
|
||||
- Node contract tests cover actual-event queue projection, retry/deduplication,
|
||||
bounds, expiry, unregister, reconnect, update-before-callback ordering,
|
||||
invalid data, public-artwork filtering, stale sessions, overlapping reads,
|
||||
failure clearing stale state and late replies after close. Nonempty app rows
|
||||
and launch/exit notifications in these tests are explicit synthetic fixtures.
|
||||
|
||||
The queue retains at most 64 real notifications per session; overflow can lose
|
||||
old callbacks. Polling runs about every two seconds with no overlap. At most four
|
||||
CEF sessions survive; unreachable cleanup expires after 15 seconds, checked
|
||||
every five seconds. The bridge does not implement private MobX internals,
|
||||
suspend/input events, game actions or closed-panel automation.
|
||||
|
||||
Discord shortcut detection, real IPC connection/activity, local custom artwork,
|
||||
actual running-game transitions, worn VR and physical controller/keyboard use
|
||||
remain unverified. None of the new fixture checks promotes a plugin to works.
|
||||
|
||||
After #301 merged, running polls and both close paths adopted its pinned SHA-256
|
||||
receipt guard. Local Python/Node tests verify the parent carries that receipt,
|
||||
missing/stale receipts are rejected before CEF after plugin replacement, and a
|
||||
reopened panel can poll and close with the new receipt. Rejected stale closes
|
||||
retain the tested finite-lease cleanup. This authorization-only follow-up did
|
||||
not repeat the device checks above; their recorded hardware scope is unchanged.
|
||||
|
||||
## Post-merge unworn panel exercise
|
||||
|
||||
On merged main `daee952873255b86018cce1d2a4d3bd070ec4341`,
|
||||
`scripts/framey-vr-check.py --exercise --output /tmp/framey-post304-exercise`
|
||||
acquired its own `/tmp/frame-test.lock` before any headset read. At 05:54 UTC,
|
||||
preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%.
|
||||
The temporary sample panel passed the real Decky setting save and fresh-iframe
|
||||
reopen, a trusted synthetic OpenVR pointer, quick action, slider value 50 and
|
||||
CDP text entry after OpenVR focus. Stereo compositor SHA-256 changed from
|
||||
`50fcab1ee25e6d9398106cc0c24ec6616e07bcf4573b52be1774c715b756060b`
|
||||
to `98241806fb1f775e7f4c5ef0b0564bfd3c552ee896ff225b4d0cb5b7b53f70d1`;
|
||||
the `/dev/video99` mirror capture hash was
|
||||
`c284c05f86b60362168c1d1f58fe775974b70ac54bbfd57834802b892eb4509e`.
|
||||
The command exited 0 with `passed=true`. The original power values (`false` and
|
||||
`5.000000`) were read back, battery remained 99%, the recovery timer was removed,
|
||||
cleanup errors were empty and the run released only its own lock. The private
|
||||
full report is `/tmp/framey-post304-exercise/result.json` on the test host.
|
||||
|
||||
This exercise covered the sample panel path after the running-context merge. It
|
||||
did not observe a nonempty running-game list, a game lifetime callback, Discord
|
||||
activity, a worn display or physical controller input. Synthetic native scroll
|
||||
again did not move (`scrollTop=0`, no wheel event).
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T07:22:08.822239+00:00",
|
||||
"plugin": {
|
||||
"id": 84,
|
||||
"name": "Junk-Store",
|
||||
"version": "1.1.13",
|
||||
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1",
|
||||
"license": "BSD-3-Clause",
|
||||
"sourceModified": false
|
||||
},
|
||||
"installer": {
|
||||
"beforeScriptMode": "0644",
|
||||
"afterScriptMode": "0744",
|
||||
"policy": "Add owner execute for a shebang or ZIP-declared executable; do not restore archive privilege/write bits. Existing installs require update or reinstall."
|
||||
},
|
||||
"isolatedBackend": {
|
||||
"runtime": "published Decky 3.3.0 amd64",
|
||||
"loaderSha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
|
||||
"isolation": [
|
||||
"unprivileged",
|
||||
"no network",
|
||||
"no devices",
|
||||
"no capabilities",
|
||||
"read-only root",
|
||||
"private tmpfs home and tmp"
|
||||
],
|
||||
"beforeInit": {
|
||||
"result": {
|
||||
"Type": "Error",
|
||||
"Content": {
|
||||
"Message": "Error parsing json result Expecting value: line 1 column 1 (char 0)",
|
||||
"Data": "",
|
||||
"ActionName": "init",
|
||||
"ActionSet": "init"
|
||||
}
|
||||
},
|
||||
"success": true
|
||||
},
|
||||
"afterInit": {
|
||||
"result": {
|
||||
"Type": "Success",
|
||||
"Content": {
|
||||
"Message": "Initialized"
|
||||
}
|
||||
},
|
||||
"success": true
|
||||
},
|
||||
"beforeInitActions": {
|
||||
"result": {
|
||||
"Type": "Error",
|
||||
"Content": {
|
||||
"Message": "Error parsing json result Expecting value: line 1 column 1 (char 0)",
|
||||
"Data": "",
|
||||
"ActionName": "InitActions",
|
||||
"ActionSet": "init"
|
||||
}
|
||||
},
|
||||
"success": true
|
||||
},
|
||||
"afterInitActions": {
|
||||
"outerSuccess": true,
|
||||
"innerType": "ActionSet",
|
||||
"setName": "MainMenu",
|
||||
"actionCount": 4
|
||||
}
|
||||
},
|
||||
"realFrame": {
|
||||
"os": "Frame OS 0.5.6",
|
||||
"arch": "aarch64",
|
||||
"uid": 1000,
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"transport": "existing trusted SSH via lxso2",
|
||||
"sharedLock": true,
|
||||
"service": "private removable user service; not enabled; RuntimeMaxSec=600",
|
||||
"installedWithProductionApply": true,
|
||||
"scriptMode": "0o744",
|
||||
"legendaryInstalled": false,
|
||||
"backendMethods": [
|
||||
"execute_action",
|
||||
"get_websocket_port"
|
||||
],
|
||||
"staticActions": [
|
||||
"JunkStoreActions/GetContent",
|
||||
"JunkStoreActions/GetEpicActions",
|
||||
"MainMenu/GetContent",
|
||||
"MainMenu/JunkStoreInit",
|
||||
"init/InitActions"
|
||||
],
|
||||
"websocketPort": 8765,
|
||||
"websocketCommandsSent": false,
|
||||
"librarySnapshotCounts": {
|
||||
"apps": 1106,
|
||||
"games": 895
|
||||
},
|
||||
"explicitInitActionInvoked": false,
|
||||
"normalDeckyBeforeAfter": [
|
||||
"inactive",
|
||||
"inactive"
|
||||
],
|
||||
"normalPanelBeforeAfter": [
|
||||
"inactive",
|
||||
"inactive"
|
||||
],
|
||||
"batteryBeforeAfter": [
|
||||
[
|
||||
99
|
||||
],
|
||||
[
|
||||
100
|
||||
]
|
||||
],
|
||||
"powerSettingChanged": false,
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"lockReleased": true
|
||||
},
|
||||
"localBrowser": {
|
||||
"runtime": "local Chromium, real headset.html/framey-host.js, vendored React, official frontend in opaque iframe",
|
||||
"backend": "production receipt-bound bridge to actual ARM64 backend",
|
||||
"steamLibrary": "production receipt-bound GetLibrarySnapshot; only counts retained",
|
||||
"outerStatusInstallResponses": "fixtures",
|
||||
"passed": [
|
||||
"Custom Stores main menu and Games/News/About navigation items render",
|
||||
"Games route opens static Epic tab",
|
||||
"Back returns to main menu",
|
||||
"close/reopen returns to main menu"
|
||||
],
|
||||
"remainingFailure": "About: Cannot read properties of undefined (reading 'installed'); detached app overview lacks local_per_client_data.installed",
|
||||
"deniedActions": [
|
||||
"fetch_rss_feed",
|
||||
"EpicActions/GetContent"
|
||||
],
|
||||
"denialIsNotBackendFailure": true
|
||||
},
|
||||
"cleanupAudit": {
|
||||
"attempts": 3,
|
||||
"allPrivateBasesAbsent": true,
|
||||
"allPrivateUnitsAbsent": true,
|
||||
"allArchivesAbsent": true,
|
||||
"allServicesInactive": true,
|
||||
"freshAuditLockReleased": true
|
||||
},
|
||||
"verification": {
|
||||
"python": {
|
||||
"run": 1628,
|
||||
"skipped": 16,
|
||||
"failures": 0
|
||||
},
|
||||
"site": {
|
||||
"passed": 18
|
||||
},
|
||||
"focusedStore": {
|
||||
"passed": 19
|
||||
},
|
||||
"fakeFrame": {
|
||||
"blocked": 56,
|
||||
"installed-removed": 60
|
||||
},
|
||||
"officialStore": {
|
||||
"plugins": 116,
|
||||
"errors": 0,
|
||||
"identitySetUnchanged": true,
|
||||
"checkedAtUtc": "2026-10-26T07:22:17.371046+00:00"
|
||||
},
|
||||
"staticEntrypointArchives": 28
|
||||
},
|
||||
"unverified": [
|
||||
"Legendary dependency installation",
|
||||
"Epic login",
|
||||
"game listing",
|
||||
"game downloads or launch",
|
||||
"RSS feed",
|
||||
"Steam shortcut mutation",
|
||||
"optional websocket operations",
|
||||
"deployed headset HTTP endpoint",
|
||||
"worn VR"
|
||||
],
|
||||
"status": "partial"
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# Executable script entrypoints: Junk-Store
|
||||
|
||||
Junk-Store 1.1.13 (official ID 84) moves **broken → partial**. The exact unmodified
|
||||
BSD-3-Clause archive is pinned in the [redacted results](framey-script-entrypoints-2026-10-26.json).
|
||||
No ARM64 rebuild or upstream plugin source patch was needed.
|
||||
|
||||
Python ZIP extraction drops execute permissions. This archive also publishes its
|
||||
shebang scripts as 0644, relying on upstream Decky's recursive permission setup.
|
||||
Framey's user service disables that setup. Direct script calls therefore fail.
|
||||
The installer now adds **owner execute only** to shebang scripts or ZIP-declared
|
||||
executables. It does not restore archive privilege/write bits, grant group/other
|
||||
execute, or change unmarked data permissions. Archive validation and installation
|
||||
blocks remain in place. Existing installations need update/reinstall for repair.
|
||||
|
||||
The upstream comparison is Decky 3.3.0's
|
||||
[permission setup](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/browser.py)
|
||||
and [Linux implementation](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/localplatform/localplatformlinux.py).
|
||||
Static inspection found 28 accepted official archives with shebang or declared
|
||||
executable members. This is shared installer coverage, not 28 functional claims.
|
||||
|
||||
## Before and after in an isolated real backend
|
||||
|
||||
Both runs used the exact official plugin and published Decky 3.3.0 amd64, with no
|
||||
network/devices/capabilities, a read-only root and private temporary home/tmp.
|
||||
The only difference was the production installer before/after this change.
|
||||
|
||||
| Check | Before | After |
|
||||
| --- | --- | --- |
|
||||
| `scripts/get-json.py` | 0644 | 0744 |
|
||||
| `execute_action(init, init)` | inner `Type:Error`, empty output / JSON parse error | `Type:Success`, `Initialized` |
|
||||
| `execute_action(init, InitActions)` | inner `Type:Error` | `Type:ActionSet`, `MainMenu`, four actions |
|
||||
|
||||
The legacy wrapper reports `success:true` even for the failing inner error.
|
||||
Startup log text alone is also insufficient: the baseline emits initialized/started
|
||||
messages despite those errors. Both private containers were removed.
|
||||
|
||||
## Real ARM64 Frame and local Chromium
|
||||
|
||||
Under the shared headset lock, the patched production installer installed the
|
||||
official archive into a private, bounded, removable Decky 3.3.0 ARM64 user service
|
||||
on Frame OS 0.5.6. The service was not enabled. Legendary was absent before and
|
||||
after; no dependency installer, login, download, launch or websocket command ran.
|
||||
|
||||
The production receipt-bound bridge called the actual backend through live Steam
|
||||
CEF. Static action sets, main-menu content and the Epic action set returned real
|
||||
results. `get_websocket_port` returned 8765. Explicit `init/init` success was tested
|
||||
in the isolated amd64 backend; it was not explicitly invoked in the final Frame run.
|
||||
|
||||
Local Chromium ran the actual Framey host and official frontend in an opaque
|
||||
iframe, relaying only allowed static calls to that ARM64 backend. Main menu,
|
||||
Games → static Epic tab, Back and fresh close/reopen passed. Steam library reads
|
||||
used the production receipt-bound bridge; only counts were retained. Outer
|
||||
status/install responses were fixtures, and the deployed headset HTTP endpoint
|
||||
was not tested.
|
||||
|
||||
The About route still crashes on missing `local_per_client_data.installed` in
|
||||
the detached app overview. Epic game-list and RSS calls were deliberately denied;
|
||||
their loading/error views do not demonstrate backend failure. Dependency
|
||||
installation, login, game operations, RSS, Steam shortcut mutation and worn VR
|
||||
remain unverified. This supports **partial**, not works.
|
||||
|
||||
Three temporary attempts were cleaned, including an initial Steam reinjection
|
||||
readiness failure and a test-relay omission of the library endpoint. A fresh
|
||||
shared-lock audit found all three services inactive/unloaded (PID 0), with their
|
||||
private bases, unit files and archives absent. Normal managed Decky/panel services
|
||||
stayed inactive; final battery was 99 → 100 while charging. No power-setting action
|
||||
ran. The audit lock was released, and local relays/browser tabs were closed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Python suite: 1,628 tests, 16 skipped, no failures; store suite: 19 passed.
|
||||
- Site suite: 18 passed. Two new regression tests failed before the fix.
|
||||
- All 116 official plugins: fake-Frame 60 installed/removed, 56 expected blocks;
|
||||
no actual plugin backends run by this harness. Disposable project cleaned.
|
||||
- Fresh official-store audit: 116 plugins, zero errors; ID/version/digest set
|
||||
matches the catalog. Other plugin statuses are unchanged.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Unworn Framey scroll transport probe, 26 October 2026
|
||||
|
||||
The previous `scripts/framey-vr-check.py --exercise` runs reported `scrollY=0` before and after one injected smooth-scroll event. That was inconclusive because the repository sample may fit inside the iframe. This probe changed only the **test script**: it created a temporary 160-pixel-high scroll region with 716 pixels of content, moved the native OpenVR pointer over it, and measured its own `scrollTop` and captured wheel events. No production Framey panel code changed.
|
||||
|
||||
Two local diagnostic versions ran on the real Frame from base main `784118b` after PR #292. The final version in this PR tried OpenVR `VREvent_ScrollSmooth` (309) and `VREvent_ScrollDiscrete` (305), using Valve's documented `VREvent_Scroll_t` ABI. The overlay listing showed the temporary Frame Control dashboard visible with `+smoothscroll`; a trusted native pointer move reached the iframe. Neither scroll event reached Chromium's capture listener and the region stayed at `scrollTop=0`. This establishes only that `IVROverlayView_003.PostOverlayEvent` did not deliver these synthetic scroll events in this run. A physical controller may follow a different path.
|
||||
|
||||
| Check | First probe | Final probe |
|
||||
| --- | --- | --- |
|
||||
| Unique shared lock and standby gate | Acquired; activity 3, battery 99% | Acquired; activity 3, battery 100% |
|
||||
| Scroll region | 160 px viewport, 716 px content | 160 px viewport, 716 px content |
|
||||
| Native pointer | Moved over region | Trusted pointer move reached iframe |
|
||||
| Injected scroll | Smooth 309 | Smooth 309, then discrete 305 |
|
||||
| Wheel events / offset | 0 / 0 → 0 | 0 / 0 → 0 |
|
||||
| Other sample checks | Passed | Passed |
|
||||
| Cleanup | Power restored to `false/5`, recovery timer removed, owned lock released; battery 99% | Same cleanup; battery 99% |
|
||||
|
||||
Both runs captured changed stereo compositor frames and a `/dev/video99` mirror frame, saved only in private folders `/tmp/framey-native-scroll-probe/` and `/tmp/framey-native-scroll-events/`. No room pixels are committed. The final run's `result.json` reported `passed=true`, `cleanupErrors=[]`, `powerRecoveryRemoved=true` and `lockReleased=true`. Here `passed` covers the sample setting, quick action, slider, text and cleanup checks; **native scroll did not pass**. The final immediate SteamVR activity was 0 after the temporary wake; original power settings were restored and read back. No person wore the headset, and physical controller scrolling, rays, haptics, keyboard, optical readability and comfort remain unverified. The hourly worn-check schedule remains enabled.
|
||||
|
||||
## Post-#294 exact-main sample check
|
||||
|
||||
After PR #294 merged, the **unmodified** `scripts/framey-vr-check.py --exercise` from exact main `c38e7e8da459e44a9a717d15e6a07ab8a8e8ef8d` exited 0 on the real Frame. Locked preflight found activity 3 standby, proximity 6.3125 and battery 99%. The sample saved `{"enabled": true}` through trusted synthetic OpenVR input and the live Decky backend, read the file independently, then reopened a fresh iframe with the value retained. Quick action, slider value 50 and text entry completed; compositor frames changed and the mirror capture returned a frame. The old scroll diagnostic still reported no offset movement.
|
||||
|
||||
The run removed its temporary services and recovery timer, restored/read back power `false/5`, reported no cleanup errors, and released only its owned lock. Its immediate activity was 0 after the temporary wake. Private captures/result are under `/tmp/framey-post294/` outside Git. This was one setting activation, so it does not prove the new rapid-double-input guard on physical controllers. No person wore the headset.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Framey session cache browser check, 25 October 2026
|
||||
|
||||
Frame Control v0.4.34's opaque Framey iframe made the official Steam Status
|
||||
1.2.3 frontend log `SecurityError` for its `localStorage` cache. This follow-up
|
||||
used a per-panel memory substitute. It did **not** change the iframe's sandbox
|
||||
flags or give plugin code Frame Control's UI key.
|
||||
|
||||
A local Chromium page loaded the unmodified official Steam Status 1.2.3 archive
|
||||
from `tests/fixtures/framey-official/123.zip` (SHA-256
|
||||
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`).
|
||||
The page used `sandbox="allow-scripts allow-popups"` and returned `{}` for
|
||||
four `get_settings` calls. That reply was a stub, not the Decky backend.
|
||||
|
||||
The iframe sent `framey:ready`. Its test script wrote and read a cache item.
|
||||
The parent could not read the iframe document (`contentDocument` was null).
|
||||
The plugin displayed Steam Status, Store, Community, WebAPI, Connection
|
||||
Managers and Players Online. Its public status request returned HTTP 200.
|
||||
No cache `SecurityError` appeared in the captured console, while the
|
||||
same archive had logged that error with the v0.4.34 host.
|
||||
|
||||
Node host tests also checked item updates, removal, clear, key order, string
|
||||
conversion, quota rejection, and preservation of an existing item after a
|
||||
rejected write. The substitute keeps at most 256 items and 512 KiB of keys and
|
||||
values **in memory**. Closing or reloading the panel loses this cache. This
|
||||
check did not test offline behavior, persistent plugin settings, a live Decky
|
||||
backend, SteamVR visibility, controller input, or headset performance.
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:40:00Z",
|
||||
"scope": "Actual SteamClient parameterless reads through Steam CEF on ARM64 Frame. Official frontends ran in local Chromium with plugin backend/network calls denied. Private loopback relay used the production read bridge with fixture installed-plugin status and the real frame_steam module supplied in memory because the optional headset service was not deployed. This does not verify a deployed real-Frame HTTP endpoint.",
|
||||
"allowlist": [
|
||||
"User.GetIPCountry",
|
||||
"InstallFolder.GetInstallFolders",
|
||||
"System.GetSystemInfo"
|
||||
],
|
||||
"probeResults": {
|
||||
"GetIPCountry": {
|
||||
"ok": true,
|
||||
"type": "string",
|
||||
"length": 2,
|
||||
"value": "redacted"
|
||||
},
|
||||
"GetInstallFolders": {
|
||||
"ok": true,
|
||||
"folders": 1,
|
||||
"apps": 29,
|
||||
"pathsAndTitles": "redacted"
|
||||
},
|
||||
"GetSystemInfo": {
|
||||
"ok": true,
|
||||
"fields": 27,
|
||||
"identifiers": "redacted"
|
||||
}
|
||||
},
|
||||
"officialPanels": {
|
||||
"IsThereAnyDeal for Deck": {
|
||||
"observation": {
|
||||
"id": 81,
|
||||
"name": "IsThereAnyDeal for Deck",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"settings_load",
|
||||
"settings_save"
|
||||
]
|
||||
},
|
||||
"countryDropdownMatchesLiveFrameValue": true,
|
||||
"backendSettingsSave": "attempted by official frontend; denied; no setting written",
|
||||
"dealLookups": "not run"
|
||||
},
|
||||
"Deck Settings": {
|
||||
"observation": {
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: TypeError: Cannot read properties of undefined (reading 'allGamesCollection')"
|
||||
}
|
||||
],
|
||||
"methods": []
|
||||
},
|
||||
"remainingBlocker": "collectionStore.allGamesCollection"
|
||||
}
|
||||
},
|
||||
"boundariesTested": [
|
||||
"Only three explicit zero-argument APIs",
|
||||
"Installed store plugin required",
|
||||
"Headset-service gate",
|
||||
"Iframe source and current session required",
|
||||
"Late replies dropped after reopen",
|
||||
"256 KiB UTF-8 response cap and socket cleanup"
|
||||
],
|
||||
"deviceState": {
|
||||
"sharedLock": "owned normally; released after testing",
|
||||
"servicesInstalledOrStarted": false,
|
||||
"powerChanges": false,
|
||||
"deviceFilesForSteamProbe": false
|
||||
},
|
||||
"unverified": [
|
||||
"Deployed headset HTTP endpoint",
|
||||
"Plugin backend settings/deal lookups",
|
||||
"Steam page patches and live library context",
|
||||
"Worn VR/controller/keyboard"
|
||||
],
|
||||
"catalogRefresh": {
|
||||
"source": "https://plugins.deckbrew.xyz/plugins",
|
||||
"checkedAtUtc": "2026-10-26T00:44:00Z",
|
||||
"visiblePlugins": 116,
|
||||
"idVersionDigestChanges": 0
|
||||
},
|
||||
"localRegressionAudit": {
|
||||
"exactArchivedFrontends": 116,
|
||||
"ready": 98,
|
||||
"failed": 18,
|
||||
"initializationRegressions": 0,
|
||||
"pluginBackendAndNetworkActions": "denied, including live Steam read APIs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Steam Status in the local Framey browser, 25 October 2026
|
||||
|
||||
This check used the unmodified official Steam Status 1.2.3 archive already
|
||||
pinned in `tests/fixtures/framey-official/123.zip`. Its SHA-256 is
|
||||
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`.
|
||||
The archive declares Decky API v2. No headset was contacted for this check.
|
||||
|
||||
On a Linux PC, we extracted the archive to a disposable plugin folder with a
|
||||
Frame Control store receipt. A local HTTP server served the page from
|
||||
`framey_panel.panel(123)`, Frame Control's React 18.3.1 runtime and its
|
||||
`framey-host.js`. A parent page put the panel in an iframe with
|
||||
`sandbox="allow-scripts"` and returned `{}` for the plugin's `get_settings`
|
||||
backend calls. This was a **stub**, not the Decky service.
|
||||
|
||||
Chromium reported `framey:ready` and four `get_settings` calls. The page showed
|
||||
Steam Status, Store, Community, WebAPI, Connection Managers, Players Online,
|
||||
and other controls. Its own public `https://steamstatus.schelstraete.org/api/v1/status`
|
||||
request returned HTTP 200 and populated the status view. A click on **Refresh**
|
||||
sent Framey's trusted pointer event to the parent; this test did not prove a
|
||||
fresh status fetch or settings write. The [browser screenshot](framey-steam-status-browser-2026-10-25.png)
|
||||
contains only the local test page and public service data.
|
||||
|
||||
The plugin logged `SecurityError` while reading and writing `localStorage` in
|
||||
the opaque-origin iframe. It caught the errors and rendered anyway. Its cache
|
||||
and offline behavior in Framey are **not verified**. The real Decky backend,
|
||||
live settings, SteamVR visibility, controller input and headset performance
|
||||
were not tested. This result supports only local frontend rendering for this
|
||||
exact archive and Frame Control v0.4.34 host code.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:29:00Z",
|
||||
"scope": "Exact archived frontends only. Production Framey host with bounded SteamClient JSON cache, image fallback and inert Steam subscriptions/context. Opaque iframe; all backend calls and network denied. No headset or plugin backends used in this follow-up.",
|
||||
"baseHostCommit": "52a0974",
|
||||
"before": {
|
||||
"ready": 96,
|
||||
"failed": 20
|
||||
},
|
||||
"after": {
|
||||
"ready": 98,
|
||||
"failed": 18
|
||||
},
|
||||
"regressions": [],
|
||||
"observations": [
|
||||
{
|
||||
"id": 13,
|
||||
"name": "Pause Games",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"load_settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"name": "Tailscale Control",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_tailscale_state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_CurrentUser')",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_mapApps')",
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"fetch_playtime_information",
|
||||
"statistics_for_last_two_weeks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 120,
|
||||
"name": "Reroll",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_setting",
|
||||
"version"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tailscaleFocusedCheck": {
|
||||
"rendered": [
|
||||
"Settings",
|
||||
"Toggle Tailscale",
|
||||
"Exit Node IP",
|
||||
"Toggle LAN Access",
|
||||
"Advanced Settings",
|
||||
"Device Status",
|
||||
"Tailscale Setup"
|
||||
],
|
||||
"assetError": "Hardcoded localhost:1337 QR SVG blocked by image CSP",
|
||||
"actions": "Inspected only; no toggles, exit nodes, installs or connection actions"
|
||||
},
|
||||
"pauseGamesFocusedCheck": {
|
||||
"rendered": [
|
||||
"Pause before Suspend",
|
||||
"Pause on focus loss"
|
||||
],
|
||||
"actions": "Inspected only; no process signaling or suspend actions"
|
||||
},
|
||||
"storageChecks": [
|
||||
"Per-plugin cache survives panel reopen",
|
||||
"Missing key rejects Not found",
|
||||
"JSON serialization and cache quota enforced",
|
||||
"Malformed and stale-session cache updates rejected"
|
||||
],
|
||||
"unverified": [
|
||||
"Real backends for these plugins",
|
||||
"Tailscale network state/actions",
|
||||
"Live games and pause/resume",
|
||||
"Worn VR/controller/keyboard"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
{
|
||||
"baseCommit": "daee952873255b86018cce1d2a4d3bd070ec4341",
|
||||
"plugin": {
|
||||
"id": 58,
|
||||
"name": "Volume Boost",
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc"
|
||||
},
|
||||
"runtime": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6",
|
||||
"os": "Frame OS 0.5.6",
|
||||
"arch": "aarch64"
|
||||
},
|
||||
"verificationLimits": {
|
||||
"fakeFrame": "Prior exact archive HTTP/SSH install/receipt/removal; plugin execution stubbed, not repeated",
|
||||
"localBrowser": "Actual headset.html, React and Framey host, exact unmodified official frontend in opaque iframe; direct RPC relay to actual ARM64 backend",
|
||||
"realFrameSSH": "Production receipt-bound framey_panel.call_method over SSH and live Steam CEF DeckyBackend.call into actual Decky 3.3.0 ARM64 plugin; default speaker sink remained muted throughout",
|
||||
"wornVR": "Not tested",
|
||||
"audibleOutput": "Not tested; gain writes and readbacks only",
|
||||
"deployedHTTP": "Not tested; outer status/install/check responses were fixtures, methods used production call_method with exact current receipt"
|
||||
},
|
||||
"catalogRefresh": {
|
||||
"checkedAtUtc": "2026-10-26T06:10:58.537029+00:00",
|
||||
"plugins": 116,
|
||||
"changedIdsVersionsDigests": 0
|
||||
},
|
||||
"runs": [
|
||||
{
|
||||
"timeUtc": "2026-10-26T06:07:19.370988+00:00",
|
||||
"locale": "LC_ALL=C for the private Decky service",
|
||||
"originalSink": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 0,
|
||||
"front-right": 0
|
||||
}
|
||||
},
|
||||
"writes": [
|
||||
{
|
||||
"request": {
|
||||
"value": 125,
|
||||
"channel": "both"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 81920,
|
||||
"front-right": 81920
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 125,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 125,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"value": 75,
|
||||
"channel": "both"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 49152,
|
||||
"front-right": 49152
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 75,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 75,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"value": 74,
|
||||
"channel": "both"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 48496,
|
||||
"front-right": 48496
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 74,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 74,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"value": 33,
|
||||
"channel": "left"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 21626,
|
||||
"front-right": 0
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 33,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"value": 67,
|
||||
"channel": "right"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 0,
|
||||
"front-right": 43909
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 67,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"value": 33,
|
||||
"channel": "right"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 0,
|
||||
"front-right": 21626
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 33,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
}
|
||||
],
|
||||
"reads": [
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"reopen": "Fresh query/session, ready; UI returned to actual restored zero; no write on mount",
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"exactSinkRestored": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
{
|
||||
"timeUtc": "2026-10-26T06:12:04.889613+00:00",
|
||||
"locale": "Inherited user-service environment, matching production unit; no LANG or LC_ALL override",
|
||||
"originalSink": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 0,
|
||||
"front-right": 0
|
||||
}
|
||||
},
|
||||
"writes": [
|
||||
{
|
||||
"request": {
|
||||
"value": 150,
|
||||
"channel": "both"
|
||||
},
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentPulseReadback": {
|
||||
"mute": true,
|
||||
"volumes": {
|
||||
"front-left": 98304,
|
||||
"front-right": 98304
|
||||
}
|
||||
},
|
||||
"backendReadback": {
|
||||
"left": {
|
||||
"result": 150,
|
||||
"success": true
|
||||
},
|
||||
"right": {
|
||||
"result": 150,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"guardArmedBeforeWrite": true,
|
||||
"exactOriginalRestored": true
|
||||
}
|
||||
],
|
||||
"reads": [
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"reopen": "Fresh query/session, ready; UI returned to actual restored zero; no write on mount",
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"exactSinkRestored": true,
|
||||
"lockReleased": true
|
||||
}
|
||||
],
|
||||
"guard": {
|
||||
"scope": "Private test helper only; not a shipped feature or plugin patch",
|
||||
"timeoutSeconds": 60,
|
||||
"runtimeMaxSec": 120,
|
||||
"sequence": [
|
||||
"Capture exact default sink identity, native per-channel volume integers and original mute",
|
||||
"Persist recovery state; start bounded device-side systemd watchdog before mute/write",
|
||||
"Require stereo front-left/front-right and mute readback before plugin call",
|
||||
"Only official get_volume_state/set_volume_state; value 0..150, known channels",
|
||||
"Read native Pulse volume and mute independently; read both channels through actual plugin",
|
||||
"Restore exact native volume while muted, verify, then restore original mute and verify exact snapshot",
|
||||
"Remove recovery state, stop only own watchdog; cleanup own service/base and release matching lock"
|
||||
],
|
||||
"automaticTimeoutExercised": false,
|
||||
"explicitRestoreExercised": true
|
||||
},
|
||||
"freshCleanupAudit": {
|
||||
"exactSinkRestored": true,
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"services": {
|
||||
"framey-boost-kp0z857q58-b4126054.service": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
|
||||
"framey-boost-kp0z857q58-b4126054-guard.service": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n"
|
||||
},
|
||||
"battery": [
|
||||
100
|
||||
]
|
||||
},
|
||||
"remaining": [
|
||||
"Audible sound/quality, perceived loudness and physical speaker gain are unverified",
|
||||
"Worn VR, headset controller input and Bluetooth sinks are unverified",
|
||||
"No default route change, playback or power-setting action was invoked",
|
||||
"Decky startup reinjects its frontend and can restart Steam CEF; normal managed services stayed inactive"
|
||||
],
|
||||
"repositoryChecks": {
|
||||
"python": "1,626 tests, 16 skips, no failures",
|
||||
"site": "18 passed",
|
||||
"catalogDocCheck": "passed",
|
||||
"diffCheck": "passed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# Volume Boost sink controls on the ARM64 Frame
|
||||
|
||||
Volume Boost 0.0.7 (official store ID 58) now qualifies as **works for sink
|
||||
controls**. The exact official, unmodified Python backend ran in Decky 3.3.0
|
||||
ARM64 on Frame OS 0.5.6. Local Chromium rendered its official frontend through
|
||||
Framey's existing legacy bridge. Actual control changes reached the Frame's
|
||||
default speaker sink, including above-100% gain, with independent Pulse
|
||||
readbacks. The sink stayed muted; no audible-output or worn-VR claim is made.
|
||||
|
||||
[Digest-pinned observations and cleanup](framey-volume-boost-arm64-2026-10-26.json)
|
||||
record both runs. This supersedes the Volume Boost read-only limitation in the
|
||||
[previous ARM64 audio check](framey-audio-arm64-2026-10-26.md). No plugin patch,
|
||||
native rebuild or new compatibility shim was necessary. Volume Boost's backend
|
||||
uses Python and the Frame's existing ARM64 `pactl`; its hard-coded UID 1000
|
||||
matches the actual Frame user.
|
||||
|
||||
| Local Chromium action | Actual ARM64 result, independently read back |
|
||||
| --- | --- |
|
||||
| Linked numeric input 125% | Native Pulse channel values 81920 / 81920; plugin reads 125 / 125 |
|
||||
| Linked slider click, then ArrowDown | 75% then 74%; native values 49152 / 49152 then 48496 / 48496 |
|
||||
| Unlink, set left to 33% | Native values 21626 / 0; plugin reads 33 / 0 |
|
||||
| Set right to 67% | Native values 0 / 43909; plugin reads 0 / 67 |
|
||||
| Re-link | Official frontend issues a right-only 33% write; native values 0 / 21626 |
|
||||
| Close and reopen | New document/session, ready; backend reads and displayed linked value return to the restored 0%; no mount write |
|
||||
| Repeat linked numeric 150% with inherited service locale | Native values 98304 / 98304; plugin reads 150 / 150; reopen again reads restored zero |
|
||||
|
||||
The test restores the original sink state after **each** write. Therefore the
|
||||
separate-channel checks intentionally begin from 0% / 0%, rather than from the
|
||||
previous UI value. A right-only re-link request is the published plugin's
|
||||
behavior. These checks verify channel selection and preservation of the other
|
||||
channel; they do not claim a continuous unmuted listening session. Pulse's
|
||||
integer conversion truncates fractional native values; the evidence retains
|
||||
those integers rather than replacing them with rounded percentages.
|
||||
|
||||
## Runtime and transport
|
||||
|
||||
Each run separately acquired the atomic `/tmp/frame-test.lock`; neither stole
|
||||
an existing lock. A unique private Decky user service used its own plugin,
|
||||
settings and runtime directories, was never enabled, and had an eight-minute
|
||||
runtime limit. The upstream ARM64 loader matched GitHub's published SHA-256
|
||||
and ELF machine 183. The plugin ZIP matched the official digest.
|
||||
|
||||
The local relay served actual `ui/headset.html`, vendored React and
|
||||
`framey-host.js`, with exact official frontend bytes in an opaque
|
||||
`allow-scripts` iframe. Outer status/install/check responses were fixtures.
|
||||
Plugin calls used production `framey_panel.call_method`, including the current
|
||||
store receipt, SSH through lxso2, live Steam CEF and `DeckyBackend.call` into the
|
||||
actual plugin subprocess. Only the temporary install paths were redirected.
|
||||
This does not verify the deployed headset HTTP endpoint or a VR compositor.
|
||||
|
||||
The first private service explicitly used `LC_ALL=C`. The second inherited the
|
||||
user-service locale without a `LANG` or `LC_ALL` override, matching the
|
||||
production service configuration, and repeated actual reads, a 150% write and
|
||||
reopen. Zero alone was not accepted as success: upstream returns zero on
|
||||
command failure. Every write returned true, both backend channel reads agreed,
|
||||
and independent native Pulse readbacks confirmed nonzero changes.
|
||||
|
||||
## Bounded muted writes and restoration
|
||||
|
||||
The default sink was already muted at native channel values 0 / 0. Before each
|
||||
plugin write, a private device-side helper captured the exact sink identity,
|
||||
channel values and mute state; persisted recovery state; and started a bounded
|
||||
60-second systemd watchdog. It then required a successful mute readback before
|
||||
allowing the plugin call. The relay allowed only `get_volume_state` and
|
||||
`set_volume_state`, known channels and values from 0 through 150.
|
||||
|
||||
After the call, independent `pactl --format=json list sinks` and both plugin
|
||||
reads checked the result. In `finally`, the helper restored exact native
|
||||
channel values while muted, checked them, restored the original mute state,
|
||||
and verified the entire snapshot before removing recovery state and stopping
|
||||
its watchdog. No default route, playback, existing stream or power setting was
|
||||
changed. Automatic watchdog expiry was armed but not deliberately exercised;
|
||||
explicit restoration passed after all seven writes.
|
||||
|
||||
Both private Decky services/bases were removed and their locks released.
|
||||
Fresh-lock cleanup audits found their Decky and guard units unloaded/inactive
|
||||
with PID 0, paths absent, and the exact original sink/mute restored. Battery
|
||||
stayed 100 → 100; normal managed Decky and headset-panel services stayed
|
||||
inactive. Decky startup reinjects its frontend and can restart Steam CEF.
|
||||
|
||||
## Catalog and verification limits
|
||||
|
||||
The canonical store catalog and generated table report **4 works / 48 partial /
|
||||
53 broken / 11 N/A**. A fresh full official-store fetch at 06:10:58 UTC found
|
||||
all 116 IDs, versions and digests unchanged. Historical fake-Frame checks cover
|
||||
this exact archive's install/receipt/removal with backend execution stubbed;
|
||||
that test was not repeated or relabelled as real-backend evidence.
|
||||
|
||||
Audible gain, sound quality, physical speaker output, Bluetooth sinks,
|
||||
headset-controller input and worn VR remain unverified. The store row includes
|
||||
these limits. This change records new real-backend evidence and updates its
|
||||
compatibility classification; it changes no shipped audio implementation.
|
||||
|
||||
The full server suite completed 1,626 tests with no failures (16 skips); all
|
||||
18 site tests passed. Generated-document consistency and `git diff --check`
|
||||
also passed. No implementation change followed those tests.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Framey unattended VR evidence, 25 October 2026 UTC
|
||||
|
||||
The committed runner exited **0** on the real Frame, using the existing
|
||||
`ssh lxso2 'ssh frame …'` route from lxso1. No person wore the headset.
|
||||
Frame OS **0.5.6**, build **20261022.6153909**, ARM64, SteamVR **2.18.2**,
|
||||
Decky **3.3.0**. The run began at **23:27:34 UTC**; the Frame's own clock/logs
|
||||
use UTC+11 and therefore show 26 October.
|
||||
|
||||
```sh
|
||||
python3 scripts/framey-vr-check.py --via lxso2 --exercise --output /tmp/framey-vr-verified
|
||||
```
|
||||
|
||||
[Full machine result](result.json) records source commit
|
||||
`cb9ad1b6cb7302008783d5f7fe42fa77a4dd43af`, bundle digest, before/after
|
||||
SteamVR listings, power settings, input observations, and private image hashes.
|
||||
The checked app code includes the sample settings-path and iframe-reopen fixes
|
||||
and main through `d77e2bd`. This is source-bundle testing, not a newly published
|
||||
release/AppImage test. The sample has a random test name and store-shaped local
|
||||
receipt; it is not presented as an official catalogue listing.
|
||||
|
||||
| Check | Observed | Scope |
|
||||
| --- | --- | --- |
|
||||
| Overlay registration and visibility | `IVROverlay_027.IsOverlayVisible=true` | Actual Frame Control gamescope overlay, not a browser fixture |
|
||||
| Transform | type **5**, DashboardTab; absolute getter returns **18**, WrongTransformType | Dashboard owns placement; no transform/dock setters used |
|
||||
| Texture queries | main size 0×0; image read **24**, InvalidTexture | Direct texture refresh not established; compositor capture used instead |
|
||||
| Stereo compositor | Before/after SHA-256 differ; final image visibly contains Framey tabs, quick action and saved result | Actual `IVRScreenshots_001` production screenshot path |
|
||||
| OBS mirror | Current run's random name and “Saved setting is on” appear after draining 60 frames | `/dev/video99` pixels; OBS app itself was not launched in this task |
|
||||
| OpenVR click | Move/down/up reaches intended control; iframe pointer event is trusted | `IVROverlayView_003.PostOverlayEvent`, no DOM `.click()` |
|
||||
| Live setting | off → on; real backend file `{"enabled":true}`; fresh iframe reads on | Actual Framey HTTP → Steam CEF → Decky websocket → Python backend; no stubs |
|
||||
| Tabs/quick action | Settings/Panel clicked; “Saved setting is on” returned | Actual shipped host controls and sample action |
|
||||
| Slider | 10 → **50** | Native OpenVR click on actual DFL slider, diagnostic UI-only value |
|
||||
| Text | **framey-ci** | OpenVR pointer focus, then CDP `Input.insertText`; not SteamVR keyboard proof |
|
||||
| Smooth scroll attempt | `scrollBefore=0`, `scrollAfter=0` | No scrolling established by this attempt; do not count it as a pass |
|
||||
| Battery | **99% → 99%** | Probed only under the owned lock |
|
||||
| Power restoration | original pause **false**, timeout **5.000000**, exact values read back | Temporary values false/120; no idle-suspend timer or proximity changes |
|
||||
| Cleanup | `cleanupErrors=[]`, `powerRecoveryRemoved=true`, `lockReleased=true` | Temporary managed services, sample/settings/profile, timer and restore script removed |
|
||||
|
||||
`IsDashboardVisible` and `IsActiveDashboardOverlay` are false while the panel is
|
||||
visible in these captures: this is an existing placed/docked panel. They cannot
|
||||
be used alone as a Framey visibility verdict. Activity changes from standby 3
|
||||
to active 0 without wearing the headset, so activity alone is not worn proof.
|
||||
Native OpenVR events also produce trusted pointers, so `event.isTrusted` alone
|
||||
is not physical-controller proof. The worn script now requires explicit
|
||||
`--worn-confirmed` and reports trusted pointer input with that narrower name.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
These are inset panel crops from the raw captures, with all other pixels
|
||||
replaced by solid black. No UI content was synthesized. [Image provenance](images.json)
|
||||
contains source hashes, mask polygons, crops and published hashes. Raw images
|
||||
remain private under `/tmp/framey-vr-verified` on lxso1. They are not committed.
|
||||
|
||||
## Bugs found and fixed
|
||||
|
||||
1. The author sample used `decky.decky_SETTINGS_DIR`, absent from real Decky
|
||||
3.3.0. The actual iframe displayed its backend `AttributeError`. Decky's
|
||||
[pinned module](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/plugin/imports/decky.py)
|
||||
exports `DECKY_PLUGIN_SETTINGS_DIR`. The sample, docs and Python test fixture
|
||||
now use that name. The old fixture invented the same wrong attribute and
|
||||
masked the failure. The live fixed backend save/reopen is in `result.json`.
|
||||
2. Reopening the same plugin changed only `#session`, so Chromium retained the
|
||||
old document/host/cache session. It could retain an error and eventually
|
||||
show a false “No compatible panel loaded” message. A fresh query parameter
|
||||
forces a new document while keeping the fragment session. The cache
|
||||
regression now checks document URLs, rather than manually assuming a new
|
||||
iframe. The real reopen with the saved setting passed.
|
||||
|
||||
## Methods tried and limits
|
||||
|
||||
Read-only `vrcmd` stats/overlay listings, the shipped OpenVR ABI, compositor
|
||||
logs and actual CEF/Chromium targets were inspected first. A simple
|
||||
`--handlewakeup` initially produced the Frame Control status panel in a stereo
|
||||
screenshot. The full test uses a bounded screen timeout so it stays active.
|
||||
Decky's service startup reloads Steam's web helper. During exploratory service
|
||||
recreation, SteamVR logged `UpdateInputFocus: Failed to give focus to panel`
|
||||
and returned a hidden, zero-sized main overlay despite a successful focus
|
||||
request. Retagging only the test browser's `STEAM_GAME` property recovered it;
|
||||
no Steam, SteamVR, gamescope or headset restart was needed. The final run did
|
||||
not need this recovery. The runner records it when used.
|
||||
|
||||
The first mirror image was demonstrably stale: it contained a previous test's
|
||||
random plugin name while the contemporaneous stereo image contained the current
|
||||
one. Reading 60 frames before selecting the mirror image fixed that observation;
|
||||
the final two published images contain the same current run name.
|
||||
|
||||
The diagnostic itself initially failed its cleanup check because an idle
|
||||
systemd watchdog service was already unloaded. It restored power and removed
|
||||
the test services, retained the lock, then the exact owned remainder was cleaned
|
||||
up. Cleanup now checks `LoadState` and tolerates an already-unloaded transient
|
||||
unit. Subsequent full runs, including the committed run here, exited 0 with no
|
||||
cleanup errors. A four-minute power-recovery timer was created and cancelled;
|
||||
forced host death or network loss was not fault injected.
|
||||
|
||||
A null driver or virtual tracked controller was not needed for pointer delivery
|
||||
and was not installed. No root/system files, tracking/proximity configuration,
|
||||
Steam global binaries, or spatial transforms were edited. PR #113 was untouched.
|
||||
This check does not establish every official plugin, sustained frame rate,
|
||||
backend persistence across Steam/Decky reboot, physical controller tracking or
|
||||
haptics, a functioning virtual keyboard, native scrolling, comfort or legibility
|
||||
through the optics. Follow the [three-minute worn checklist](../../framey-vr-check.md#three-minute-worn-checklist).
|
||||
|
||||
## Local checks
|
||||
|
||||
- Full Python suite after integrating current main and worn-confirmation gate:
|
||||
**1,595 tests, 16 skipped, OK**. The final added scroll/mirror helpers also
|
||||
passed the **24 focused Framey tests** and the real-device run above.
|
||||
- App and website test commands passed under **Node 24.22.1**, matching CI's
|
||||
Node major. System Node 22 cancelled unrelated Meta sign-in timer tests;
|
||||
those files were not changed. Node 24 was used for final app verification.
|
||||
- `git diff --check` and Python compilation passed. Hosted CI status belongs to
|
||||
the PR; local checks do not substitute for its macOS/Windows/ARM64 jobs.
|
||||
|
||||
A separate [locked cleanup audit](cleanup-verification.json) confirms both test
|
||||
services inactive, no panel bundle or Decky binary, no test plugins/profiles or
|
||||
remaining timers, power restored to false/5, battery 99→99, and its own lock
|
||||
released. It also removed the stale DevTools discovery file created during the
|
||||
earlier exploratory use of the original browser profile.
|
||||
Loaded 100 of 664 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user